polars_online.eval¶
Evaluation harness (docs/PLAN.md section 8).
Pure Polars over the output structs: per (spec, group, target) out-of-sample
R^2, IC (correlation of prediction with realized target) and hit rate, either
overall or in rolling windows measured in clock units. One group_by
compares specs.
Everything here consumes the frame a ModelBank returns:
the original columns plus one struct column per spec. It needs the whole
frame – these are Polars aggregations over collected output – where a
spec’s emit_metrics keeps the same numbers beside the model, O(state),
for a stream too long to hold.
What each function raises is what unpack() raises, since each starts
there: KeyError for a spec_name the frame has not got, TypeError
for a column that is not a model’s prediction struct, ValueError for a
slot whose target column cannot be found. A by or clock column the
frame has not got is polars’ ColumnNotFoundError.
- polars_online.eval.metrics(df: DataFrame, spec_name: str, *, by: Iterable[str] = (), targets: Sequence[str] | None = None, min_obs: int = 30, binary: bool = False) DataFrame[source]¶
Overall out-of-sample metrics per
(slot, *by).Rows where the prediction or the target is null are dropped, so warmup and skipped rows never enter the numbers; a group with fewer than
min_obsrows left is dropped from the result rather than reported on too little.nis the rows counted,r2out-of-sample R^2 against the realized mean,icthe correlation of prediction with target,hit_ratethe fraction of sign agreements (rows withy == 0excluded),msethe mean squared residual.binary=Truereadspredas a probability andyas a 0/1 label – the output of asgdorftrlfit withloss="logistic"– rather than a signed regression target (docs/PLAN.md task 76):hit_ratebecomes accuracy at a 0.5 threshold and every row scores, an extralog_losscolumn is added (-(y*ln(p) + (1-y)*ln(1-p)), averaged), andr2/ickeep their formulas under names that mean something different on a 0/1 target – Brier skill score and point-biserial correlation. Nothing here reads the target’s values to decide which reading applies; name it explicitly, the way you chose the loss.Raises as
unpack()does; abycolumn the frame has not got is polars’ColumnNotFoundError.
- polars_online.eval.rolling_metrics(df: DataFrame, spec_name: str, *, clock: str, window: float, by: Iterable[str] = (), targets: Sequence[str] | None = None, min_obs: int = 30, binary: bool = False) DataFrame[source]¶
Metrics in non-overlapping windows of
windowclock units.window_startis the left edge of each bucket (floor(clock/window)*window); the columns aremetrics()’s, per window, andbinaryismetrics()’s. Raises asunpack()does,ValueErrorfor awindowthat is not above 0,TypeErrorfor aclockcolumn that is not numeric, and polars’ColumnNotFoundErrorfor aclockorbycolumn the frame has not got.
- polars_online.eval.compare_specs(df: DataFrame, spec_names: Iterable[str], *, by: Iterable[str] = (), targets: Sequence[str] | None = None, min_obs: int = 30, binary: bool = False) DataFrame[source]¶
Stack
metrics()for several specs, adding aspeccolumn.binaryismetrics()’s, applied to every spec alike – compare specs that share a loss, not a logistic fit against a regression one. Raises asmetrics()does for each; no specs give an empty frame.
- polars_online.eval.unpack(df: DataFrame, spec_name: str, *, spec: dict | None = None, targets: Sequence[str] | None = None) DataFrame[source]¶
Long form: one row per (row, prediction slot).
Returns
slot(the struct field name),target(the target column it predicts),pred,yand every other non-struct column ofdf. Input columns named like the output ones (seeRESERVED) are dropped – a target column calledywould otherwise collide with theyoutput.Pass
specto resolve each slot’s target exactly (viapolars_online.spec.output_index()); without it a name-based heuristic is used, which can misattribute a target whose name embeds another’s.Raises
KeyErrorfor aspec_namethe frame has not got;TypeErrorfor a column that is not a struct, or a struct with nopred_*fields (anew_cov,kmeans,microorew_classoutput);ValueErrorwhen a slot’s target column cannot be found – the frame no longer has it, ortargetsdoes not name it – and, withspec, whateverpolars_online.spec.output_index()raises for it.
- polars_online.eval.seqtest(df: DataFrame, *, targets: Sequence[str] | None = None, a: str | None = None, b: str | None = None, a_suffix: str = '', b_suffix: str = '', by: Iterable[str] = (), min_periods: float = 0.0, name: str = 'seqtest') DataFrame[source]¶
polars_online.spec.seqtest()in polars expressions, over a frame in memory: the same e-processes, the same fields, row for row.Column mode (no
a/b):targetsname the columns whose sign is tested. Compare mode:aandbname two output structs ofdf(two specs the bank ran),targetsthe residuals both carry –tforresid_<t><a_suffix>ofaagainstresid_<t><b_suffix>ofb; everytthey share whenNone– and the sign tested is that of|resid_b| - |resid_a|, positive whenawas closer.Returns
dfwith a struct columnnameholding, per targettand read before the row, as the bank emits them:log_e_pos_<t>,log_e_neg_<t>,n_pos_<t>,n_neg_<t>in column mode,log_e_a_<t>,log_e_b_<t>,wins_a_<t>,wins_b_<t>in compare mode, thenn_eff– the rows before this one in itsbygroup – with every other field null untiln_effreachesmin_periods.byruns one process per group, in row order (.over(by)). A null, zero or NaN value bets nothing and counts nothing, as in the bank; what the bank adds is the clock (session,on_clock_reset), which a frame in memory has not got.Per target, with
sthe sign and the counts before the row:lam_pos = max(0, (n_pos - n_neg) / (n_pos + n_neg + 1)) log_e_pos += log1p(lam_pos * s) (lam_neg, log_e_neg likewise)
tests/test_seqtest.pyholds the bank’s struct to this one to the last bit; the difference is that the bank is O(state) over a stream and this is O(rows) over a frame.Raises
ValueErrorforawithoutb(or the reverse), for column mode withouttargets, and for a target neither side has a residual for (naming the fields it does have);KeyErrorfor a spec the frame has not got,TypeErrorfor one that is not a struct.
- polars_online.eval.sums(df: DataFrame, spec_name: str, *, by: Iterable[str] = (), targets: Sequence[str] | None = None, spec: dict | None = None, weight: str | None = None, binary: bool = False) DataFrame[source]¶
Reduce a chunk of output to the sufficient statistics of its metrics (docs/ENHANCEMENTS.md E49).
metrics()needs the whole frame. This needs one chunk at a time: ten doubles per(slot, target, *by), whichmerge_sums()adds together andfrom_sums()turns back into the same numbers. A run that compares fifty slots over a billion rows then keeps ten doubles per key instead of writing the rows out to evaluate them later.The columns beside the keys are
SUM_FIELDS:nrows andwweight behind them, the weighted meansmean_yandmean_pred, the centred sumsm2_y,m2_predandcov, the residual sum of squaressse, andhits/signedfor the hit rate –hitscounting sign agreements andsignedthe rows withy != 0, or (binary=True,metrics()’s reading)hitscounting agreement at a 0.5 threshold andsignedevery row, since every row scores. Chunks reduced with differentbinarysettings must not be merged –merge_sums()sums whatever is inhits/signedwithout knowing which reading produced it.Centred, not raw. The obvious form – keep
sum(y)andsum(y**2)and subtract – is one addition simpler and loses the variance entirely when the mean is large relative to the spread: a unit-variance target around 1e8 hasvar / E[y**2]of about 1e-16, and the subtraction has nothing left.merge_sums()pays for the centring with a parallel-axis term, which is a multiply, and keeps every digit. It is the same choice EwCov makes for the same reason (E11b).Rows where the prediction or the target is null are dropped, as
metrics()drops them, so warmup and skipped rows never enter the numbers.weightnames a column to weight rows by; without it every row counts 1 andwequalsn.spec,targetsand the errors areunpack()’s.
- polars_online.eval.merge_sums(first: DataFrame, *rest: DataFrame) DataFrame[source]¶
Add the sufficient statistics of disjoint row sets.
Exact, whatever the split: the means are pooled by weight and the centred sums pick up the parallel-axis term for the distance between each part’s mean and the pooled one:
w = sum(w_g) mean = sum(w_g * mean_g) / w m2 = sum(m2_g + w_g * (mean_g - mean)**2) cov = sum(cov_g + w_g * (mean_y_g - mean_y) * (mean_p_g - mean_p))
That is the n-way form of Chan, Golub and LeVeque’s merge – every part enters as a sum, never as a difference of running totals – so merging a thousand chunks loses no more than merging two.
Keys present in one part and not another are carried through as they are. Merging one frame returns it unchanged.
- polars_online.eval.from_sums(s: DataFrame, *, min_obs: int = 30) DataFrame[source]¶
The metrics
metrics()reports, fromsums()instead of rows.Same columns and same numbers:
n,r2(out-of-sample against the realized mean),ic(correlation of prediction with target),hit_rate,mse, andrmsebeside it. Which readinghit_rateis – sign agreement or accuracy at 0.5 – was fixed when the sums were built (sums()’sbinary); this just divideshitsbysigned, so there is nothing to choose here. There is nolog_losscolumn here (metrics()’sbinary=Truehas it);SUM_FIELDSwould need a mean and a weight for it, not added since nothing has asked for the chunked form yet. A key with fewer thanmin_obsrows is dropped, asmetrics()drops it.r2andicarenullwhere they are undefined – a key whose target or prediction never varied has no correlation to report, and dividing by its zero variance would give an infinity that reads as a number.