ml_meta_perf.attribution

What each term is worth, and whether dataset or model features carry the equation.

An equation is only useful as guidance if its terms can be compared, and raw weights cannot be: they are in the units of whatever the term happens to compute. Two comparable quantities are derived here instead.

Effect is the swing in predicted MCC produced by moving a term from its 10th to its 90th percentile, holding everything else fixed. It is stated in MCC units, which is what makes one term's importance comparable with another's and with the target itself.

Share attributes the variance of the equation's output to groups of terms. Terms are labelled by the features they use -- dataset-only, model-only, or mixed -- so the split between "how hard is this data" and "how capable is this model" can be read off the fitted equation rather than assumed.

Study chapter: 6. Best practices against the equation -- the rationale, in prose, with the figures.

  1"""What each term is worth, and whether dataset or model features carry the equation.
  2
  3An equation is only useful as guidance if its terms can be compared, and raw weights
  4cannot be: they are in the units of whatever the term happens to compute. Two comparable
  5quantities are derived here instead.
  6
  7*Effect* is the swing in predicted MCC produced by moving a term from its 10th to its
  890th percentile, holding everything else fixed. It is stated in MCC units, which is what
  9makes one term's importance comparable with another's and with the target itself.
 10
 11*Share* attributes the variance of the equation's output to groups of terms. Terms are
 12labelled by the features they use -- dataset-only, model-only, or mixed -- so the split
 13between "how hard is this data" and "how capable is this model" can be read off the
 14fitted equation rather than assumed.
 15
 16Study chapter: [6. Best practices against the equation][study-chapter] -- the rationale, in
 17prose, with the figures.
 18
 19[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/06-practices.md
 20"""
 21
 22from __future__ import annotations
 23
 24import numpy as np
 25import polars as pl
 26
 27from ml_meta_perf.model import Equation, direction
 28
 29DATASET_ONLY = "dataset"
 30MODEL_ONLY = "model"
 31MIXED = "mixed"
 32
 33
 34def classify(
 35    features: tuple[str, ...],
 36    dataset_features: tuple[str, ...],
 37    model_features: tuple[str, ...],
 38) -> str:
 39    """Label a term by which feature groups it draws on."""
 40    uses_dataset = any(feature in dataset_features for feature in features)
 41    uses_model = any(feature in model_features for feature in features)
 42    if uses_dataset and uses_model:
 43        return MIXED
 44    return MODEL_ONLY if uses_model else DATASET_ONLY
 45
 46
 47def contributions(equation: Equation, columns: dict[str, np.ndarray]) -> np.ndarray:
 48    """The per-row contribution of each term, as a ``(n_rows, n_terms)`` matrix."""
 49    if not equation.terms:
 50        return np.zeros((len(next(iter(columns.values()))), 0))
 51    with np.errstate(all="ignore"):
 52        return np.column_stack(
 53            [weight * term.evaluate(columns) for term, weight in zip(equation.terms, equation.weights, strict=True)]
 54        )
 55
 56
 57def term_effects(
 58    equation: Equation,
 59    columns: dict[str, np.ndarray],
 60    dataset_features: tuple[str, ...],
 61    model_features: tuple[str, ...],
 62) -> pl.DataFrame:
 63    """Per-term effect sizes, in MCC units, strongest first.
 64
 65    ``effect`` is the 10th-to-90th-percentile swing of the term's contribution: how much
 66    predicted MCC moves across the bulk of the observed range of that term. Percentiles
 67    rather than min-to-max so that a single extreme row cannot define the headline number.
 68    """
 69    matrix = contributions(equation, columns)
 70    rows: list[dict[str, object]] = []
 71    for index, term in enumerate(equation.terms):
 72        column = matrix[:, index]
 73        low, high = np.percentile(column, [10.0, 90.0])
 74        rows.append(
 75            {
 76                "term": term.name,
 77                "group": classify(term.features, dataset_features, model_features),
 78                "weight": equation.weights[index],
 79                "beta": equation.standardized_weights[index],
 80                "effect": float(high - low),
 81                "direction": direction(equation.standardized_weights[index]),
 82            }
 83        )
 84    return pl.DataFrame(rows).sort(pl.col("effect").abs(), descending=True)
 85
 86
 87def group_shares(
 88    equation: Equation,
 89    columns: dict[str, np.ndarray],
 90    dataset_features: tuple[str, ...],
 91    model_features: tuple[str, ...],
 92) -> pl.DataFrame:
 93    """How much of the equation's output variance each feature group drives.
 94
 95    Shares are computed as ``cov(group total, prediction) / var(prediction)``. That
 96    decomposition sums to exactly 1 even when the groups are correlated, which a naive
 97    ``var(group) / var(total)`` does not -- and the groups here are correlated, because
 98    three of the model features are functions of dataset size.
 99    """
100    matrix = contributions(equation, columns)
101    total = matrix.sum(axis=1)
102    variance = float(np.var(total))
103    labels = [classify(term.features, dataset_features, model_features) for term in equation.terms]
104
105    rows: list[dict[str, object]] = []
106    for group in (DATASET_ONLY, MODEL_ONLY, MIXED):
107        selected = [index for index, label in enumerate(labels) if label == group]
108        if not selected:
109            rows.append({"group": group, "n_terms": 0, "share": 0.0, "effect_sum": 0.0})
110            continue
111        subtotal = matrix[:, selected].sum(axis=1)
112        # Population covariance (ddof=0) to match np.var. np.cov defaults to ddof=1, and
113        # mixing the two inflates every share by n/(n-1) so the decomposition stops
114        # summing to 1.
115        covariance = float(((subtotal - subtotal.mean()) * (total - total.mean())).mean())
116        share = covariance / variance if variance > 1e-15 else 0.0
117        low, high = np.percentile(subtotal, [10.0, 90.0])
118        rows.append(
119            {
120                "group": group,
121                "n_terms": len(selected),
122                "share": share,
123                "effect_sum": float(high - low),
124            }
125        )
126    return pl.DataFrame(rows)
127
128
129def variance_decomposition(
130    target: np.ndarray,
131    datasets: np.ndarray,
132    models: np.ndarray,
133) -> pl.DataFrame:
134    """How much of MCC is explained by dataset identity alone, and by model identity alone.
135
136    This is measured on the target itself, independent of any equation, and is the honest
137    answer to "does the choice of model matter more than the choice of data". Each row is
138    the variance explained by knowing *only* that group label -- an upper bound on what
139    any equation over that group's features could reach.
140    """
141    total = float(((target - target.mean()) ** 2).sum())
142    rows: list[dict[str, object]] = []
143    for name, labels in (("dataset identity", datasets), ("model identity", models)):
144        prediction = np.zeros_like(target)
145        for label in np.unique(labels):
146            mask = labels == label
147            prediction[mask] = target[mask].mean()
148        residual = float(((target - prediction) ** 2).sum())
149        rows.append(
150            {
151                "knowing only": name,
152                "n_groups": int(np.unique(labels).shape[0]),
153                "variance_explained": 1.0 - residual / total if total > 1e-15 else 0.0,
154            }
155        )
156    return pl.DataFrame(rows)
DATASET_ONLY = 'dataset'
MODEL_ONLY = 'model'
MIXED = 'mixed'
def classify( features: tuple[str, ...], dataset_features: tuple[str, ...], model_features: tuple[str, ...]) -> str:
35def classify(
36    features: tuple[str, ...],
37    dataset_features: tuple[str, ...],
38    model_features: tuple[str, ...],
39) -> str:
40    """Label a term by which feature groups it draws on."""
41    uses_dataset = any(feature in dataset_features for feature in features)
42    uses_model = any(feature in model_features for feature in features)
43    if uses_dataset and uses_model:
44        return MIXED
45    return MODEL_ONLY if uses_model else DATASET_ONLY

Label a term by which feature groups it draws on.

def contributions( equation: ml_meta_perf.model.Equation, columns: dict[str, numpy.ndarray]) -> numpy.ndarray:
48def contributions(equation: Equation, columns: dict[str, np.ndarray]) -> np.ndarray:
49    """The per-row contribution of each term, as a ``(n_rows, n_terms)`` matrix."""
50    if not equation.terms:
51        return np.zeros((len(next(iter(columns.values()))), 0))
52    with np.errstate(all="ignore"):
53        return np.column_stack(
54            [weight * term.evaluate(columns) for term, weight in zip(equation.terms, equation.weights, strict=True)]
55        )

The per-row contribution of each term, as a (n_rows, n_terms) matrix.

def term_effects( equation: ml_meta_perf.model.Equation, columns: dict[str, numpy.ndarray], dataset_features: tuple[str, ...], model_features: tuple[str, ...]) -> polars.dataframe.frame.DataFrame:
58def term_effects(
59    equation: Equation,
60    columns: dict[str, np.ndarray],
61    dataset_features: tuple[str, ...],
62    model_features: tuple[str, ...],
63) -> pl.DataFrame:
64    """Per-term effect sizes, in MCC units, strongest first.
65
66    ``effect`` is the 10th-to-90th-percentile swing of the term's contribution: how much
67    predicted MCC moves across the bulk of the observed range of that term. Percentiles
68    rather than min-to-max so that a single extreme row cannot define the headline number.
69    """
70    matrix = contributions(equation, columns)
71    rows: list[dict[str, object]] = []
72    for index, term in enumerate(equation.terms):
73        column = matrix[:, index]
74        low, high = np.percentile(column, [10.0, 90.0])
75        rows.append(
76            {
77                "term": term.name,
78                "group": classify(term.features, dataset_features, model_features),
79                "weight": equation.weights[index],
80                "beta": equation.standardized_weights[index],
81                "effect": float(high - low),
82                "direction": direction(equation.standardized_weights[index]),
83            }
84        )
85    return pl.DataFrame(rows).sort(pl.col("effect").abs(), descending=True)

Per-term effect sizes, in MCC units, strongest first.

effect is the 10th-to-90th-percentile swing of the term's contribution: how much predicted MCC moves across the bulk of the observed range of that term. Percentiles rather than min-to-max so that a single extreme row cannot define the headline number.

def group_shares( equation: ml_meta_perf.model.Equation, columns: dict[str, numpy.ndarray], dataset_features: tuple[str, ...], model_features: tuple[str, ...]) -> polars.dataframe.frame.DataFrame:
 88def group_shares(
 89    equation: Equation,
 90    columns: dict[str, np.ndarray],
 91    dataset_features: tuple[str, ...],
 92    model_features: tuple[str, ...],
 93) -> pl.DataFrame:
 94    """How much of the equation's output variance each feature group drives.
 95
 96    Shares are computed as ``cov(group total, prediction) / var(prediction)``. That
 97    decomposition sums to exactly 1 even when the groups are correlated, which a naive
 98    ``var(group) / var(total)`` does not -- and the groups here are correlated, because
 99    three of the model features are functions of dataset size.
100    """
101    matrix = contributions(equation, columns)
102    total = matrix.sum(axis=1)
103    variance = float(np.var(total))
104    labels = [classify(term.features, dataset_features, model_features) for term in equation.terms]
105
106    rows: list[dict[str, object]] = []
107    for group in (DATASET_ONLY, MODEL_ONLY, MIXED):
108        selected = [index for index, label in enumerate(labels) if label == group]
109        if not selected:
110            rows.append({"group": group, "n_terms": 0, "share": 0.0, "effect_sum": 0.0})
111            continue
112        subtotal = matrix[:, selected].sum(axis=1)
113        # Population covariance (ddof=0) to match np.var. np.cov defaults to ddof=1, and
114        # mixing the two inflates every share by n/(n-1) so the decomposition stops
115        # summing to 1.
116        covariance = float(((subtotal - subtotal.mean()) * (total - total.mean())).mean())
117        share = covariance / variance if variance > 1e-15 else 0.0
118        low, high = np.percentile(subtotal, [10.0, 90.0])
119        rows.append(
120            {
121                "group": group,
122                "n_terms": len(selected),
123                "share": share,
124                "effect_sum": float(high - low),
125            }
126        )
127    return pl.DataFrame(rows)

How much of the equation's output variance each feature group drives.

Shares are computed as cov(group total, prediction) / var(prediction). That decomposition sums to exactly 1 even when the groups are correlated, which a naive var(group) / var(total) does not -- and the groups here are correlated, because three of the model features are functions of dataset size.

def variance_decomposition( target: numpy.ndarray, datasets: numpy.ndarray, models: numpy.ndarray) -> polars.dataframe.frame.DataFrame:
130def variance_decomposition(
131    target: np.ndarray,
132    datasets: np.ndarray,
133    models: np.ndarray,
134) -> pl.DataFrame:
135    """How much of MCC is explained by dataset identity alone, and by model identity alone.
136
137    This is measured on the target itself, independent of any equation, and is the honest
138    answer to "does the choice of model matter more than the choice of data". Each row is
139    the variance explained by knowing *only* that group label -- an upper bound on what
140    any equation over that group's features could reach.
141    """
142    total = float(((target - target.mean()) ** 2).sum())
143    rows: list[dict[str, object]] = []
144    for name, labels in (("dataset identity", datasets), ("model identity", models)):
145        prediction = np.zeros_like(target)
146        for label in np.unique(labels):
147            mask = labels == label
148            prediction[mask] = target[mask].mean()
149        residual = float(((target - prediction) ** 2).sum())
150        rows.append(
151            {
152                "knowing only": name,
153                "n_groups": int(np.unique(labels).shape[0]),
154                "variance_explained": 1.0 - residual / total if total > 1e-15 else 0.0,
155            }
156        )
157    return pl.DataFrame(rows)

How much of MCC is explained by dataset identity alone, and by model identity alone.

This is measured on the target itself, independent of any equation, and is the honest answer to "does the choice of model matter more than the choice of data". Each row is the variance explained by knowing only that group label -- an upper bound on what any equation over that group's features could reach.