ml_meta_perf.model

The fitted object: an equation of the form MCC = w0 + w1*t1 + w2*t2 + ....

Selection runs on standardised terms, because greedy selection compares candidates by correlation with the residual and raw scales would make that comparison meaningless. What gets published, though, must be evaluable as written, so the weights are folded back into raw units before they are stored. Both sets are kept: the raw weights are the equation, the standardised weights are how the terms rank against each other.

Study chapter: 2. The additive model -- the rationale, in prose, with the figures.

  1"""The fitted object: an equation of the form ``MCC = w0 + w1*t1 + w2*t2 + ...``.
  2
  3Selection runs on standardised terms, because greedy selection compares candidates by
  4correlation with the residual and raw scales would make that comparison meaningless.
  5What gets published, though, must be evaluable as written, so the weights are folded
  6back into raw units before they are stored. Both sets are kept: the raw weights are the
  7equation, the standardised weights are how the terms rank against each other.
  8
  9Study chapter: [2. The additive model][study-chapter] -- the rationale, in
 10prose, with the figures.
 11
 12[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/02-additive-model.md
 13"""
 14
 15from __future__ import annotations
 16
 17import json
 18from dataclasses import dataclass
 19from pathlib import Path
 20
 21import numpy as np
 22
 23from ml_meta_perf.terms import Term
 24
 25MCC_LOWER = -1.0
 26MCC_UPPER = 1.0
 27
 28
 29@dataclass(frozen=True)
 30class Equation:
 31    """A sparse linear equation over named terms."""
 32
 33    intercept: float
 34    terms: tuple[Term, ...]
 35    weights: tuple[float, ...]
 36    standardized_weights: tuple[float, ...]
 37    name: str = "equation"
 38
 39    def __post_init__(self) -> None:
 40        if not (len(self.terms) == len(self.weights) == len(self.standardized_weights)):
 41            raise ValueError("terms and weights must have the same length")
 42
 43    @property
 44    def n_terms(self) -> int:
 45        return len(self.terms)
 46
 47    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
 48        """Apply the equation as written, without clipping."""
 49        n = len(next(iter(columns.values())))
 50        total = np.full(n, self.intercept, dtype=np.float64)
 51        with np.errstate(all="ignore"):
 52            for term, weight in zip(self.terms, self.weights, strict=True):
 53                total += weight * term.evaluate(columns)
 54        return total
 55
 56    def predict(self, columns: dict[str, np.ndarray]) -> np.ndarray:
 57        """Apply the equation and clip into the range MCC can actually take.
 58
 59        A linear form has no idea that MCC stops at 1.0, and the meta-dataset
 60        includes exact values at that bound. Clipping is the domain knowledge imposed on the
 61        output, and it is applied identically during cross-validation.
 62        """
 63        return np.clip(self.evaluate(columns), MCC_LOWER, MCC_UPPER)
 64
 65    def ranked_terms(self) -> list[tuple[Term, float, float]]:
 66        """Terms ordered by standardised weight magnitude: strongest contributor first."""
 67        triples = list(zip(self.terms, self.weights, self.standardized_weights, strict=True))
 68        return sorted(triples, key=lambda item: abs(item[2]), reverse=True)
 69
 70    def __str__(self) -> str:
 71        lines = [f"MCC = {self.intercept:+.6g}"]
 72        for term, weight, standardized in self.ranked_terms():
 73            # Long terms overflow the column rather than being truncated, so the padding
 74            # has to keep a separator of its own -- ljust alone glues the comment to a
 75            # name that is already past the stop.
 76            lines.append(f"      {weight:+.6g} * {term.name}".ljust(64) + f"  # beta={standardized:+.4f}")
 77        return "\n".join(lines)
 78
 79    def to_latex(self) -> str:
 80        """A LaTeX rendering of the equation, for dropping straight into the paper."""
 81        parts = [f"{self.intercept:+.4g}"]
 82        for term, weight, _ in self.ranked_terms():
 83            parts.append(f"{weight:+.4g} \\cdot \\mathrm{{{_escape(term.name)}}}")
 84        return "\\mathrm{MCC} = " + " ".join(parts)
 85
 86    def to_dict(self) -> dict[str, object]:
 87        return {
 88            "name": self.name,
 89            "intercept": self.intercept,
 90            "terms": [term.to_dict() for term in self.terms],
 91            "weights": list(self.weights),
 92            "standardized_weights": list(self.standardized_weights),
 93        }
 94
 95    @classmethod
 96    def from_dict(cls, payload: dict[str, object]) -> Equation:
 97        terms = payload["terms"]
 98        weights = payload["weights"]
 99        standardized = payload["standardized_weights"]
100        if not isinstance(terms, list) or not isinstance(weights, list) or not isinstance(standardized, list):
101            raise ValueError("malformed equation payload")
102        return cls(
103            intercept=float(payload["intercept"]),  # pyright: ignore[reportArgumentType]
104            terms=tuple(Term.from_dict(item) for item in terms),  # pyright: ignore[reportArgumentType]
105            weights=tuple(float(value) for value in weights),  # pyright: ignore[reportArgumentType]
106            standardized_weights=tuple(float(value) for value in standardized),  # pyright: ignore[reportArgumentType]
107            name=str(payload.get("name", "equation")),
108        )
109
110    def save(self, path: str | Path) -> None:
111        Path(path).write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
112
113    @classmethod
114    def load(cls, path: str | Path) -> Equation:
115        return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
116
117
118def _escape(name: str) -> str:
119    return name.replace("_", r"\_").replace("^", r"\^{}")
120
121
122#: How a term's direction is written in every exported table.
123#:
124#: One spelling, in one place, because `attribution.term_effects` and `report` both write a
125#: ``direction`` column and used to disagree -- "increases MCC" in one CSV, "raises MCC" in
126#: the next -- which reads as two different quantities to anyone joining them.
127RAISES, LOWERS = "raises MCC", "lowers MCC"
128
129
130def direction(signed: float) -> str:
131    """The word for the sign of a standardised weight or effect."""
132    return RAISES if signed > 0.0 else LOWERS
MCC_LOWER = -1.0
MCC_UPPER = 1.0
@dataclass(frozen=True)
class Equation:
 30@dataclass(frozen=True)
 31class Equation:
 32    """A sparse linear equation over named terms."""
 33
 34    intercept: float
 35    terms: tuple[Term, ...]
 36    weights: tuple[float, ...]
 37    standardized_weights: tuple[float, ...]
 38    name: str = "equation"
 39
 40    def __post_init__(self) -> None:
 41        if not (len(self.terms) == len(self.weights) == len(self.standardized_weights)):
 42            raise ValueError("terms and weights must have the same length")
 43
 44    @property
 45    def n_terms(self) -> int:
 46        return len(self.terms)
 47
 48    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
 49        """Apply the equation as written, without clipping."""
 50        n = len(next(iter(columns.values())))
 51        total = np.full(n, self.intercept, dtype=np.float64)
 52        with np.errstate(all="ignore"):
 53            for term, weight in zip(self.terms, self.weights, strict=True):
 54                total += weight * term.evaluate(columns)
 55        return total
 56
 57    def predict(self, columns: dict[str, np.ndarray]) -> np.ndarray:
 58        """Apply the equation and clip into the range MCC can actually take.
 59
 60        A linear form has no idea that MCC stops at 1.0, and the meta-dataset
 61        includes exact values at that bound. Clipping is the domain knowledge imposed on the
 62        output, and it is applied identically during cross-validation.
 63        """
 64        return np.clip(self.evaluate(columns), MCC_LOWER, MCC_UPPER)
 65
 66    def ranked_terms(self) -> list[tuple[Term, float, float]]:
 67        """Terms ordered by standardised weight magnitude: strongest contributor first."""
 68        triples = list(zip(self.terms, self.weights, self.standardized_weights, strict=True))
 69        return sorted(triples, key=lambda item: abs(item[2]), reverse=True)
 70
 71    def __str__(self) -> str:
 72        lines = [f"MCC = {self.intercept:+.6g}"]
 73        for term, weight, standardized in self.ranked_terms():
 74            # Long terms overflow the column rather than being truncated, so the padding
 75            # has to keep a separator of its own -- ljust alone glues the comment to a
 76            # name that is already past the stop.
 77            lines.append(f"      {weight:+.6g} * {term.name}".ljust(64) + f"  # beta={standardized:+.4f}")
 78        return "\n".join(lines)
 79
 80    def to_latex(self) -> str:
 81        """A LaTeX rendering of the equation, for dropping straight into the paper."""
 82        parts = [f"{self.intercept:+.4g}"]
 83        for term, weight, _ in self.ranked_terms():
 84            parts.append(f"{weight:+.4g} \\cdot \\mathrm{{{_escape(term.name)}}}")
 85        return "\\mathrm{MCC} = " + " ".join(parts)
 86
 87    def to_dict(self) -> dict[str, object]:
 88        return {
 89            "name": self.name,
 90            "intercept": self.intercept,
 91            "terms": [term.to_dict() for term in self.terms],
 92            "weights": list(self.weights),
 93            "standardized_weights": list(self.standardized_weights),
 94        }
 95
 96    @classmethod
 97    def from_dict(cls, payload: dict[str, object]) -> Equation:
 98        terms = payload["terms"]
 99        weights = payload["weights"]
100        standardized = payload["standardized_weights"]
101        if not isinstance(terms, list) or not isinstance(weights, list) or not isinstance(standardized, list):
102            raise ValueError("malformed equation payload")
103        return cls(
104            intercept=float(payload["intercept"]),  # pyright: ignore[reportArgumentType]
105            terms=tuple(Term.from_dict(item) for item in terms),  # pyright: ignore[reportArgumentType]
106            weights=tuple(float(value) for value in weights),  # pyright: ignore[reportArgumentType]
107            standardized_weights=tuple(float(value) for value in standardized),  # pyright: ignore[reportArgumentType]
108            name=str(payload.get("name", "equation")),
109        )
110
111    def save(self, path: str | Path) -> None:
112        Path(path).write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
113
114    @classmethod
115    def load(cls, path: str | Path) -> Equation:
116        return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))

A sparse linear equation over named terms.

Equation( intercept: float, terms: tuple[ml_meta_perf.terms.Term, ...], weights: tuple[float, ...], standardized_weights: tuple[float, ...], name: str = 'equation')
intercept: float
terms: tuple[ml_meta_perf.terms.Term, ...]
weights: tuple[float, ...]
standardized_weights: tuple[float, ...]
name: str = 'equation'
n_terms: int
44    @property
45    def n_terms(self) -> int:
46        return len(self.terms)
def evaluate(self, columns: dict[str, numpy.ndarray]) -> numpy.ndarray:
48    def evaluate(self, columns: dict[str, np.ndarray]) -> np.ndarray:
49        """Apply the equation as written, without clipping."""
50        n = len(next(iter(columns.values())))
51        total = np.full(n, self.intercept, dtype=np.float64)
52        with np.errstate(all="ignore"):
53            for term, weight in zip(self.terms, self.weights, strict=True):
54                total += weight * term.evaluate(columns)
55        return total

Apply the equation as written, without clipping.

def predict(self, columns: dict[str, numpy.ndarray]) -> numpy.ndarray:
57    def predict(self, columns: dict[str, np.ndarray]) -> np.ndarray:
58        """Apply the equation and clip into the range MCC can actually take.
59
60        A linear form has no idea that MCC stops at 1.0, and the meta-dataset
61        includes exact values at that bound. Clipping is the domain knowledge imposed on the
62        output, and it is applied identically during cross-validation.
63        """
64        return np.clip(self.evaluate(columns), MCC_LOWER, MCC_UPPER)

Apply the equation and clip into the range MCC can actually take.

A linear form has no idea that MCC stops at 1.0, and the meta-dataset includes exact values at that bound. Clipping is the domain knowledge imposed on the output, and it is applied identically during cross-validation.

def ranked_terms(self) -> list[tuple[ml_meta_perf.terms.Term, float, float]]:
66    def ranked_terms(self) -> list[tuple[Term, float, float]]:
67        """Terms ordered by standardised weight magnitude: strongest contributor first."""
68        triples = list(zip(self.terms, self.weights, self.standardized_weights, strict=True))
69        return sorted(triples, key=lambda item: abs(item[2]), reverse=True)

Terms ordered by standardised weight magnitude: strongest contributor first.

def to_latex(self) -> str:
80    def to_latex(self) -> str:
81        """A LaTeX rendering of the equation, for dropping straight into the paper."""
82        parts = [f"{self.intercept:+.4g}"]
83        for term, weight, _ in self.ranked_terms():
84            parts.append(f"{weight:+.4g} \\cdot \\mathrm{{{_escape(term.name)}}}")
85        return "\\mathrm{MCC} = " + " ".join(parts)

A LaTeX rendering of the equation, for dropping straight into the paper.

def to_dict(self) -> dict[str, object]:
87    def to_dict(self) -> dict[str, object]:
88        return {
89            "name": self.name,
90            "intercept": self.intercept,
91            "terms": [term.to_dict() for term in self.terms],
92            "weights": list(self.weights),
93            "standardized_weights": list(self.standardized_weights),
94        }
@classmethod
def from_dict(cls, payload: dict[str, object]) -> Equation:
 96    @classmethod
 97    def from_dict(cls, payload: dict[str, object]) -> Equation:
 98        terms = payload["terms"]
 99        weights = payload["weights"]
100        standardized = payload["standardized_weights"]
101        if not isinstance(terms, list) or not isinstance(weights, list) or not isinstance(standardized, list):
102            raise ValueError("malformed equation payload")
103        return cls(
104            intercept=float(payload["intercept"]),  # pyright: ignore[reportArgumentType]
105            terms=tuple(Term.from_dict(item) for item in terms),  # pyright: ignore[reportArgumentType]
106            weights=tuple(float(value) for value in weights),  # pyright: ignore[reportArgumentType]
107            standardized_weights=tuple(float(value) for value in standardized),  # pyright: ignore[reportArgumentType]
108            name=str(payload.get("name", "equation")),
109        )
def save(self, path: str | pathlib.Path) -> None:
111    def save(self, path: str | Path) -> None:
112        Path(path).write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
@classmethod
def load(cls, path: str | pathlib.Path) -> Equation:
114    @classmethod
115    def load(cls, path: str | Path) -> Equation:
116        return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
def direction(signed: float) -> str:
131def direction(signed: float) -> str:
132    """The word for the sign of a standardised weight or effect."""
133    return RAISES if signed > 0.0 else LOWERS

The word for the sign of a standardised weight or effect.