Weight whole feature GROUPS (blocks) — not individual features — by how much each group contributes to generalization. Tabular data often comes in natural families (order-book vs technicals vs sentiment); groupweight scores each family by the drop in out-of-fold transfer when the whole block is removed, and shrinks low-contribution groups as a unit (group-level shrinkage). The weighting is then validated on a held-out split against a magnitude-matched null that permutes the group weights across groups — so a reported gain means the low weights landed on the right groups, not merely that some spread existed. numpy-only.
from groupweight import held_out
from groupweight.synth import make_group_structured
X, y, info = make_group_structured() # rows in time order
r = held_out(X, y, groups=info["groups"]) # or groups=None to auto-discover
print(r["uniform"], r["learned"], r["beats_null"])$ groupweight demo
## 1. Signal vs noise blocks (2 signal groups + several large noise blocks)
groups : 8 total, 6 down-weighted (<0.5)
group weights : 0.97 1.00 0.02 0.04 0.03 0.02 0.01 0.01
group labels : SIG SIG noise noise noise noise noise noise
held-out transfer : 0.9624 (uniform 0.9389, lift +0.0235)
null p95 / p : 0.9511 / 0.025
VERDICT : REAL — beats held-out null
-> mean weight: signal groups 0.99 vs noise groups 0.02 (noise suppressed)
## 2. All-useful control (every block equally useful — must NOT beat null)
groups : 4 total, 2 down-weighted (<0.5)
group weights : 1.00 0.03 0.57 0.01
held-out transfer : 0.5909 (uniform 0.6622, lift -0.0713)
null p95 / p : 0.6185 / 0.480
VERDICT : no gain over null (the honest answer)
Most feature weighting works one column at a time. But features usually arrive in families — an order-book block, a technicals block, a sentiment block — and the right question is often which whole family is pulling its weight, not which individual column. A family of many weakly-spurious columns can quietly drag a pooled fit (each column grabs a little in-sample correlation; together they add real variance to held-out predictions) even when no single column looks guilty.
groupweight scores each group by its leave-one-group-out contribution to
out-of-fold transfer: build OOF predictions on time folds with all features,
then again with the group's columns zeroed, and take the drop in Pearson. A real
signal group hurts a lot when removed (large positive contribution); a pure-noise
group is neutral or even helps when removed (≈0 or negative). Those
contributions become one weight per group (min-max scaled to [floor, 1]),
expanded so every feature in a group shares its weight, and renormalized to
mean 1 — so the weighting only reallocates shrinkage across families, it never
changes the overall regularization versus a uniform fit. The fit is a
column-scaled ridge solved via the Gram trick, so every refit and every null
permutation is cheap.
If the contributions are essentially flat (spread below min_gap), groupweight
returns a uniform vector — a noise-floor guard so homogeneous data stays
honest instead of inventing a ranking out of sampling noise.
This was built to a strict rule: nothing counts unless it beats a held-out
null. held_out:
- splits the rows by order into
low(first 60%) andhigh; - builds (or accepts) the feature grouping — on
lowonly; - scores each group's contribution by leave-one-group-out OOF transfer, on
lowonly (thehighrows are never touched while learning); - turns the contributions into per-group weights, expands them, refits on all of
low, and scores the untouchedhighrows — the reportedlearnedtransfer; - compares against uniform weighting and a magnitude-matched null: the same
per-group weights permuted across the groups (same magnitudes, reassigned to
random groups), expanded, refit, and scored on
high,n_nulltimes.
beats_null is True only when learned clears both bars — the null's 95th
percentile (significance) and the uniform baseline by a real margin (effect
size): learned > null_p95 AND (learned - uniform) > 0.005. Permuting the weights
across groups is the sharp test: it asks whether putting the low weights on the
right groups is what mattered, not merely that a spread of weights existed.
What the demo shows — reported faithfully, not cherry-picked:
- Signal vs noise blocks → real. A couple of signal groups among several large pure-noise blocks: downweighting the noise families as units lifts held-out transfer past the null, because placing the low weights on the noise groups is what matters — permuting them onto signal groups (the null) is much worse.
- All-useful control → nothing. When every block is equally useful there is nothing to rank: no group weighting beats the null, and the honest verdict is "no gain over null."
A null result here is a finding, reported as "no gain over null (the honest answer)" — not dressed up as a discovery.
from groupweight import held_out, auto_groups, group_contribution, group_weights
groups = auto_groups(X) # correlation clustering → list[list[int]]
groups = auto_groups(X, n_groups=4) # or agglomerate to a fixed count
c = group_contribution(X, y, groups, k=4) # per-group LOGO contribution
fw = group_weights(c, groups, X.shape[1]) # per-feature weights (mean 1)
r = held_out(X, y, groups=groups, n_null=200) # learn + validate vs null
r["learned"], r["uniform"], r["lift"], r["beats_null"]
r["group_weights"], r["groups"], r["contribution"] # all inspectablePass groups=None and groupweight discovers families from the column correlation
structure; pass your own when you already know them (the usual case).
groupweight demo # signal/noise blocks + an all-useful control
groupweight fit data.csv --target y # weight feature groups + the held-out null verdict
--groups open,high,low|rsi,macd # name the families (',' within, '|' between); else autopip install numpy # then use the package directly, or `pip install .`MIT. Depends only on numpy.