Skip to content

feat: quantile interpolation for arbitrary levels and quantiles - #27

Open
AzulGarza wants to merge 5 commits into
mainfrom
feat/quantile-interpolation
Open

AzulGarza wants to merge 5 commits into
mainfrom
feat/quantile-interpolation

Conversation

@AzulGarza

Copy link
Copy Markdown
Member

Summary

  • Add foundationforecast/core/quantiles.py with vectorized linear interpolation and edge clamping for fixed-knot models
  • Refactor TiRex, TimesFM, TabPFN, FlowState, Tafsut, and Toto 2.0 to always run native quantile knots, then interpolate to any requested level or quantiles
  • Remove the undocumented level=0 sentinel from QuantileConverter (now raises a clear error)
  • Allow PatchTST-FM to pass arbitrary quantiles directly to the model backend
  • Document probabilistic forecasting, interpolation, and edge clamping in docs/forecasting-parameters.md and example notebooks

API after this change

# Standard intervals — no level=0 needed
fcst = tirex.forecast(df, h=12, level=[20, 40, 60, 80])

# Non-native level — interpolated (clamped at edge knots)
fcst = tirex.forecast(df, h=12, level=[95])

# Arbitrary quantiles
fcst = tirex.forecast(df, h=12, quantiles=[0.15, 0.5, 0.85])

Edge clamping

When a requested quantile falls outside the model's native knot range, the forecast at the nearest edge knot is returned (same semantics as numpy.interp). For example, on knots 0.1–0.9, quantiles=[0.01] returns the same values as quantiles=[0.1].

Test plan

  • tests/core/test_quantiles.py — interpolation, subset selection, edge clamping
  • tests/core/test_forecaster.pylevel=0 rejection, level/quantile conversion
  • tests/models/test_models.py — updated to use level=[20, 40, 60, 80] without sentinel
  • CI model integration tests (slow; run in GitHub Actions)

Made with Cursor

Introduce a shared interpolation layer in core/quantiles.py and refactor
fixed-knot models (TiRex, TimesFM, TabPFN, FlowState, Tafsut, Toto 2.0)
to always run native knots then interpolate to any requested level or
quantile with edge clamping. Remove the undocumented level=0 sentinel from
QuantileConverter and allow PatchTST-FM to pass arbitrary quantiles natively.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codspeed

codspeed Bot commented Sep 15, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 13.07%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 10 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime test_model_forecast[moirai-1.1] 1.6 s 1.4 s +13.07%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing feat/quantile-interpolation (a0c243d) with main (d6cc53f)

Open in CodSpeed

AzulGarza and others added 3 commits September 15, 2026 14:19
Always pass DEFAULT_QUANTILES_TFM when loading TimesFM v1, and apply
resolve_quantile_values for v2/v3 so arbitrary levels work without the
legacy exact-length guard.

Co-authored-by: Cursor <cursoragent@cursor.com>
Re-run tirex-family with level=[20, 40, 60, 80] and refresh timesfm-family
markdown and code cells to match the new quantile interpolation behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remove the allclose guard that raised on 8 vs 9 quantiles and apply
resolve_quantile_values like other fixed-knot models.

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@AzulGarza

Copy link
Copy Markdown
Member Author

@codspeedbot explain this regression

@codspeed

codspeed Bot commented Sep 17, 2026

Copy link
Copy Markdown

@codspeedbot explain this regression

Explanation: test_quantile_converter_level_to_quantiles (-11.28%)

Short version: the regression is real (not just runner noise) and it comes from the new validate_levels() call added to QuantileConverter._prepare_level_and_quantiles. It costs ~4.8k extra CPU instructions per converter construction, which is significant for this micro-benchmark and irrelevant for real forecasts.

What the metrics show

Metric breakdown for this benchmark (base d6cc53f → head 408fa7c):

Component BASE HEAD Change
Instructions 7.77 µs 9.19 µs +18.2%
Cache misses 14.25 µs 17.08 µs +19.9%
Memory accesses 75.53 µs 83.69 µs +10.8%
CPU total 97.6 µs 110 µs +12.7%

The instruction count is hardware-independent for a pure-Python benchmark like this one, so the +18% there is genuinely caused by the code change — the flagged runner difference (AMD EPYC → Intel Xeon) can move the cache/memory estimates, but it cannot invent extra executed instructions.

Where the extra work comes from

The benchmark measures exactly this:

qc = QuantileConverter(level=[80, 95])
return qc.quantiles

and the PR changed that path from a direct loop over level to:

validated_level = validate_levels(level)   # extra call frame + list(level) + any(genexpr)
assert validated_level is not None
for lv in validated_level:
    ...

I reproduced this locally with Callgrind on an isolated reproduction of both versions (20k iterations, startup subtracted):

Variant Instructions / call
main (direct loop) 18,771
this PR (validate_levels) 23,597 (+25.7%)

The ~4.8k extra instructions are the cost of an extra Python frame, the list(level) copy, and the generator created by any(lv == 0 for lv in levels) — all for a 2-element list. The extra allocations also explain the bump in the memory-access component, which is the dominant term here (~77% of the total).

Does it matter?

Practically, no. QuantileConverter is constructed once per forecast() call, so ~12 µs sits next to model inference measured in seconds. This benchmark is a pure micro-benchmark of the conversion path, which is why a few thousand instructions show up as a double-digit percentage.

If you want the µs back

Folding the zero check into the existing loop keeps the same validation and recovers most of the cost (measured the same way):

if quantiles is None and level is not None:
    validated_level = list(level)
    _quantiles = []
    for lv in validated_level:
        if lv == 0:
            raise ValueError(_LEVEL_ZERO_ERROR)
        q_lo, q_hi = QuantileConverter._level_to_quantiles(lv)
        ...

→ 19,805 instructions/call, i.e. ~+5% over main instead of +26%. The trade-off is losing validate_levels() as a shared, separately tested helper (it currently has only this one call site plus its unit tests).

I did not change any code — say the word and I'll apply that micro-optimization, or feel free to acknowledge the regression on CodSpeed if you'd rather keep the cleaner helper.

Note: the ⚡ +18.77% on test_model_forecast[moirai-1.1] is a WallTime benchmark that also ran across the runner difference, so I would not read it as a real speedup from this PR.

Summary
  • Warming up the performance wizard...
  • Investigate the regression — Compared base (d6cc53f) and head (408fa7c) metric breakdowns: +18% instructions, +11% memory accesses
  • Measure instruction delta locally — Callgrind: new validate_levels path adds ~4.8k instructions per QuantileConverter construction (+26%)
  • Handling comment — Comment handling finished successfully

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several quantile paths can return incorrect or duplicate output columns, and PatchTST-FM may use a requested quantile instead of the point forecast.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 17/17 changed files
  • Comments generated: 4
  • Review effort level: Lite

Comment thread foundationforecast/models/patchtst_fm.py
Comment thread foundationforecast/core/forecaster.py
Comment thread foundationforecast/core/quantiles.py Outdated
Comment thread foundationforecast/models/timesfm.py
- Inline level=0 check to avoid CodSpeed regression in QuantileConverter
- Fix single-knot interpolate_quantiles axis restoration
- Reject duplicate quantile column names from int(q*100) collisions
- Strip native TimesFM v1 q-columns before assigning resolved output
- Always request q=0.5 in PatchTST-FM backend for correct point forecasts

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Interpolation currently misaligns multi-row outputs, arbitrary quantile column collisions can overwrite results, and the TabPFN example still uses the removed sentinel.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

foundationforecast/models/toto.py:340

  • Toto 2.0 now resolves arbitrary quantiles, but forecast() still writes the returned values with the old int(q * 100) loop at lines 414–417. A valid request such as [0.151, 0.159] maps both values to *-q-15, so one forecast overwrites the other instead of returning both; use _assign_quantile_forecasts here as the other interpolating models do.
  • Files reviewed: 17/17 changed files
  • Comments generated: 3
  • Review effort level: Lite

kind="linear",
assume_sorted=True,
)
out = interp(requested).T.reshape(*orig_shape, len(requested_quantiles))
if quantiles is None:
quantile_levels = DEFAULT_QUANTILES
else:
quantile_levels = sorted(set(quantiles) | {0.5})
Comment on lines +412 to +413
if 0 in level:
raise ValueError(_LEVEL_ZERO_ERROR)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants