polars_online.gram¶
The accumulators, read back (docs/ENHANCEMENTS.md E46).
gram() hands back the matrices the models
solve against, from one pass over data that is never materialized. This
module is what to do with them afterwards: pool shards, take a subset, read a
correlation, solve a ridge, walk a lasso path, put standard errors on
coefficients, and diagnose collinearity.
Every function takes the mapping gram() produces – columns,
means, comoments, cross_moments, target_weights,
target_means, target_vars, n_eff, n_kish, target_n_kish
– and merge() and subset() return one of the same shape.
The arithmetic is the models’ own, so solve() on a spec’s Gram
reproduces that spec’s coefficients and lasso_path() reproduces the
lasso model’s path. What it is not is the same arithmetic to the last
bit: the models factorize with faer’s Cholesky and numpy with LAPACK’s
LU, which round differently in the last place or two. The tests hold the two
to a relative tolerance, not to equality, and say so.
Requires numpy, which is an optional extra of this package
(pip install polars-online[numpy]) – not a dependency, as it is not one
of polars’ either. Nothing here needs scipy or scikit-learn.
- polars_online.gram.coef_stats(g: dict[str, Any], coef: Sequence[float], *, target: str | int = 0, features: Sequence[str | int] | None = None) dict[str, Any][source]¶
Residual variance, standard errors and t-statistics for a fit.
This is what the target moments were added for (E45): with
Var[y]in the Gram, a saved state answers “how good is this fit, and which coefficients are real” without the rows:resid_var = Var[y] - 2 b' Cov[X, y] + b' C b sigma2 = resid_var * n / (n - k) # n = target_n_kish se = sqrt(diag(inv(C)) * sigma2 / n) t = b / se
Returns
resid_var,sigma2,r2,n(the Kish size the correction and the errors use),seandt– the last two arrays over the same slots ascoef, with the intercept’s entrynan(its standard error depends on the design’s centring, which the Gram has already absorbed).nis Kish’s effective sample size, notn_eff: a weighted stream’s weight sum is not a count, and dividing by it would report standard errors too small by the factor the weights are unequal by. The rows behind an exponentially weighted fit are also neither independent nor identically distributed, so read athere as a scale for comparing coefficients, not as a p-value.ValueErrorif the Gram has no target moments – a state saved by 0.2.0 or earlier cannot answer this.
- polars_online.gram.condition(g: dict[str, Any], *, features: Sequence[str | int] | None = None) dict[str, Any][source]¶
Belsley’s collinearity diagnostics for the accumulated design.
Returns
singular_values(of the column-scaled design, largest first),condition_indexes(s_max / s_j),kappa(the largest of them) andproportions– the variance-decomposition proportions, one row per component and one column per feature, each column summing to 1.A component with a large condition index and a large share of two or more columns’ variance is a near-dependency between exactly those columns, which is what makes this worth more than a single
kappa: it says which columns are the problem, where a VIF only says that one is. Belsley’s rule of thumb is an index above 30 with two proportions above 0.5.The design is scaled to unit column length first (Belsley’s prescription), but not centred: the intercept is part of the collinearity when a column is nearly constant, and centring hides that.
singular_valuesare of that scaled raw matrix, so they are the square roots of the eigenvalues of the scaled second-moment matrix.
- polars_online.gram.correlation(g: dict[str, Any]) Any[source]¶
The correlation matrix of the columns, from the centred co-moments.
nanin the row and column of a constant one (the intercept included: a constant has no correlation with anything, and reporting 0 there would read as “independent”). The diagonal is 1 where the variance is positive.
- polars_online.gram.from_row(row: Any) dict[str, Any][source]¶
A closed group’s row (docs/ENHANCEMENTS.md E54) as the mapping
gram()returns, so everything in this module works on it:for row in bank.closed_groups().iter_rows(named=True): g = po.gram.from_row(row) beta = po.gram.solve(g, target=0)
Takes a one-row frame, a row of
iter_rows(named=True), or a mapping. The row’scomomentsis the upper triangle with the diagonal, row by row, and itscross_momentsis row-major(n_targets, k); this expands both.The result is what
gram()would have returned for that group bit for bit, except the co-moment matrix’s lower triangle, which is the upper one mirrored. The two differ in the last bit or so and not more: the accumulator updatesC[i][j]andC[j][i]with the same two products in the opposite order, which does not commute in IEEE arithmetic (docs/PERFORMANCE.md §14). Everything read off the matrix – a solve, a correlation, a condition number – is unaffected at that scale, and the packed half is what makes the closed row half the size.A row of a kind that keeps no accumulators (its
columnsis null) raisesValueError: there is no Gram to make.
- polars_online.gram.lasso_path(g: dict[str, Any], lambdas: Sequence[float], *, l1_ratio: float = 1.0, penalty_weights: Sequence[float] | None = None, target: str | int = 0, features: Sequence[str | int] | None = None, max_iter: int = 1000, tol: float = 1e-07) Any[source]¶
The elastic-net path from the Gram, one row of coefficients per lambda.
The
lassomodel’s coordinate descent (Lasso::solve), run offline: on the standardized (correlation-form) matrix, warm-started down the path in the order given, withb_i = soft(rho_i, l * l1_ratio * pw_i) / (C_ii + l * (1 - l1_ratio) * pw_i)where
rho_iis the standardized cross-correlation less the other columns’ contributions andsoft(v, t) = sign(v) * max(|v| - t, 0). Coefficients come back in original units with the intercept recovered from the means, so a row is directly comparable tobank.coef().penalty_weightsscales the penalty per feature (infeaturesorder, orcolumnsorder without the intercept): 0 leaves a column unpenalized, and a column the stream found constant is dropped whatever is asked for. The online model has no such parameter – it is the one thing here that the models do not also do, and it is cheap offline because the path is re-walked rather than carried.Give
lambdasfrom large to small, as a path is meant to be walked: the warm start makes that both faster and better conditioned.max_iterandtolare the model’smax_cd_itersandcd_tol.
- polars_online.gram.merge(grams: Sequence[dict[str, Any]]) dict[str, Any][source]¶
Pool the Grams of disjoint row sets into the Gram of their union.
Chan, Golub and LeVeque’s update: the pooled co-moments are the weighted average of the parts’ plus the spread between their means, and every quantity is a sum of parts rather than a difference of cumulative sums – so pooling a thousand shards loses no more precision than pooling two. With weights
W_a,W_band mean gapd = m_b - m_a:W = W_a + W_b m = m_a + (W_b / W) * d C = (W_a * C_a + W_b * C_b) / W + (W_a * W_b / W**2) * outer(d, d) Q = Q_a + Q_b
Use it to pool accumulators that share a weighting: one per shard of a pass, one per group being combined, one per worker. Not two halves of a decayed stream in time order – each part’s weights are relative to its own last row, so the earlier part is over-weighted by exactly the decay between them. Either run the parts under an infinite halflife, or scale the earlier part’s
n_effbylam**dtand itssum(w**2)bylam**(2*dt)before merging (the means and co-moments are unaffected, being weighted means already).lagsandlag_comomentscome backNone: a lagged cross-moment pairs a row with the row l back within its own part, and the pairings across a part boundary are what no part holds.Every part must have the same
columnsandtargets; a part with non_kishor no target moments (a state saved by 0.2.0 or earlier) makes the merge reportNonefor those, since the sums behind them are not there to add.groupandinstancecome back asNone: a pooled accumulator is no longer one group’s or one instance’s.Merging one Gram returns it unchanged; merging none is a
ValueError.
- polars_online.gram.solve(g: dict[str, Any], *, ridge: float | Sequence[float] = 0.0, target: str | int = 0, features: Sequence[str | int] | None = None, standardize: bool = False) Any[source]¶
Ridge coefficients from the Gram, in the features’ original units.
The model’s own algebra (
EwRidge::solve), so the result is the fit that spec would report on the same accumulator:standardize=Falseaddsridgeto the diagonal of the raw second-moment matrix, leaving the intercept unpenalized;standardize=Truecentres, scales to correlation form, addsridgethere, then unscales and recovers the intercept from the means – soridgemeans the same thing whatever the features’ units. A column with zero variance is dropped with a coefficient of 0 rather than making the system singular.
Pass the
standardizethe spec used, or the numbers will not match itscoef(). With an intercept incolumnsthe returned vector starts with it, inpolars_online.spec.coef_index()order.ridgemay be a sequence, and then the return is one row per value. A grid rides a single eigendecomposition wherever the penalty is uniform in the basis being solved – always withstandardize=True, and without an intercept otherwise: withV d V'in hand every ridge isV diag(1/(d + r)) V' b, which is what makes a grid of fifty cheap. An unstandardized fit with an intercept leaves that one column unpenalized, so its penalty is not a multiple of the identity and each value costs a factorization. That is the model’s arithmetic, and reproducing it is worth more here than the shortcut.targetpicks the target by name or position;featuresnarrows the regressors (equivalent tosubset()first, and refusing the intercept, which the solve handles itself).
- polars_online.gram.subset(g: dict[str, Any], cols: Sequence[str | int]) dict[str, Any][source]¶
The Gram of a subset of the columns, in the order given.
Exact, not approximate: a marginal set of moments is a sub-block of the joint ones, so this is a selection rather than a recomputation, and a regression on the subset is the regression the full accumulator implies. That is the point – forward stepwise, an information criterion over feature sets, or an
r-column fit read off ak-column stream all fall out of one pass.Names or positions, and the intercept may be selected like any other column. Targets are untouched: they index a different axis.
- polars_online.gram.vif(g: dict[str, Any], *, features: Sequence[str | int] | None = None) Any[source]¶
Variance inflation factors:
1 / (1 - R2_j)for each column on the rest, straight off the diagonal of the inverse correlation matrix.The intercept is not a regressor and is left out by default (its VIF is undefined – a constant is perfectly explained by any other constant). A column the stream found constant reports
inf.Above about 10 the coefficient of that column is mostly noise; the fix is a ridge, a subset, or a feature set the spec already knows how to fit beside the full one.