Skip to content

Latest commit

 

History

History
131 lines (106 loc) · 6.32 KB

File metadata and controls

131 lines (106 loc) · 6.32 KB

groupweight

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)

The idea

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.

Honesty is the whole point

This was built to a strict rule: nothing counts unless it beats a held-out null. held_out:

  1. splits the rows by order into low (first 60%) and high;
  2. builds (or accepts) the feature grouping — on low only;
  3. scores each group's contribution by leave-one-group-out OOF transfer, on low only (the high rows are never touched while learning);
  4. turns the contributions into per-group weights, expands them, refits on all of low, and scores the untouched high rows — the reported learned transfer;
  5. 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_null times.

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.

API

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 inspectable

Pass groups=None and groupweight discovers families from the column correlation structure; pass your own when you already know them (the usual case).

CLI

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 auto

Install

pip install numpy   # then use the package directly, or `pip install .`

MIT. Depends only on numpy.