FastRiskScore: sparse integer risk scores, found by autoresearch.
A risk score adds up a few integer points, one per feature that applies to a row,
and turns the total into a probability. FastRiskScoreClassifier picks at most k
binary features (thresholds of numeric columns and levels of categorical ones)
and gives each an integer number of points in [-max_points, max_points], choosing
them to minimise the training log loss of the best map from total score to risk,
min over points w, and real a, b, of mean_i log(1 + exp(-y_i (a * x_i.w + b))).
This is the problem RiskSLIM and FasterRisk solve. The solver (solver.py) came out of
an autoresearch loop (agentic-imodels, evolve_slim) that started from FasterRisk; see
https://csinva.io/imodels/fastriskscore.html for the method and the benchmarks.
Expand source code
"""FastRiskScore: sparse integer risk scores, found by autoresearch.
A risk score adds up a few integer points, one per feature that applies to a row,
and turns the total into a probability. FastRiskScoreClassifier picks at most ``k``
binary features (thresholds of numeric columns and levels of categorical ones)
and gives each an integer number of points in [-max_points, max_points], choosing
them to minimise the training log loss of the best map from total score to risk,
min over points w, and real a, b, of mean_i log(1 + exp(-y_i (a * x_i.w + b))).
This is the problem RiskSLIM and FasterRisk solve. The solver (``solver.py``) came out of
an autoresearch loop (agentic-imodels, evolve_slim) that started from FasterRisk; see
https://csinva.io/imodels/fastriskscore.html for the method and the benchmarks.
"""
from __future__ import annotations
from imodels.util.introspection import TextMixin
import math
import numbers
import time
import warnings
import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, ClassifierMixin
from scipy import sparse
from sklearn.utils.validation import check_array, check_is_fitted
from imodels.algebraic.risk_score import solver
from imodels.util.numba_compile import notify_first_compile
from imodels.util.arguments import (_finite_check_kwarg, check_binary_target, check_predict_X,
set_feature_names_in)
NUMBA_HINT = (
"FastRiskScoreClassifier needs numba, which is not installed. Install it with "
"`pip install numba` (or `pip install imodels[optional]`). The search is compiled "
"with numba once per machine (about two minutes) and cached after that."
)
DECILE_PROFILE_MAX = 19 # n_thresholds above this use the "fine" profile (measured: "decile" best at 9, "fine" at 99)
def calibrate(scores, y):
"""(a, b, loss): the minimum over real a, b of the mean log loss of sigmoid(a * score + b).
Convex in (a, b); solved by damped Newton on the distinct score values."""
y = np.asarray(y, float)
u, inv = np.unique(np.round(np.asarray(scores, float), 9), return_inverse=True)
pos = np.bincount(inv.ravel(), weights=y, minlength=len(u))
cnt = np.bincount(inv.ravel(), minlength=len(u)).astype(float)
n = cnt.sum()
p = min(max(pos.sum() / n, 1e-12), 1 - 1e-12)
a, b = 0.0, math.log(p / (1 - p))
def f(a_, b_):
z = a_ * u + b_
return float((pos * np.logaddexp(0, -z) + (cnt - pos) * np.logaddexp(0, z)).sum() / n)
cur = f(a, b)
if len(u) > 1:
for _ in range(200):
q = 1 / (1 + np.exp(-(a * u + b)))
r = cnt * q - pos
w = cnt * q * (1 - q)
g = np.array([r @ u, r.sum()]) / n
H = np.array([[w @ (u * u), w @ u], [w @ u, w.sum()]]) / n + 1e-12 * np.eye(2)
step = np.linalg.solve(H, g)
t = 1.0
while t > 1e-10:
na, nb_ = a - t * step[0], b - t * step[1]
new = f(na, nb_)
if new <= cur - 1e-4 * t * float(g @ step):
break
t *= 0.5
if t <= 1e-10 or cur - new < 1e-13:
if t > 1e-10:
a, b, cur = na, nb_, new
break
a, b, cur = na, nb_, new
return a, b, cur
_MISSING = object() # stands for a missing value among the levels of a categorical column; equal to no string
def _threshold(t, nxt):
"""(value, text) for the rule ``x <= t`` when the next value of x in the training data is ``nxt``: a short
decimal d with t <= d < nxt, so that ``x <= d`` marks the same training rows as ``x <= t``, and the rule
is applied with d, exactly as printed. It is the 6-significant-digit form of t when that lies in the
gap, else the form with the fewest more digits that does, else ``repr(t)``. Thresholds of one column thus
get distinct names however large or close their values are."""
t, nxt = float(t), float(nxt)
for text in [f"{t:g}"] + [f"{t:.{p}g}" for p in range(7, 18)]:
d = float(text)
if t <= d < nxt:
return d, text
return t, repr(t)
def _value_text(v):
"""The 6-significant-digit form of v when it reads back as v, else ``repr``."""
v = float(v)
text = f"{v:g}"
return text if float(text) == v else repr(v)
def _unique_names(names):
"""Make names unique by appending " (2)", " (3)", ... to repeats (columns whose names collide with a rule
of another column, e.g. a column called "a <= 1")."""
seen, out = set(), []
for name in names:
new, i = name, 1
while new in seen:
i += 1
new = f"{name} ({i})"
seen.add(new)
out.append(new)
return out
class _Binarizer:
"""Indicator features: ``x <= t`` at up to ``n_thresholds`` quantiles of each numeric column,
``x = v`` for a two-valued column, one level per indicator for a categorical column (levels
covering at least 1% of rows, at most 20), and ``x missing`` where the training data had
missing values (for a categorical column, a missing value is never the same as a level called
"missing"). Columns are used by position. A missing value at predict time in a numeric column
without missing values in the training data is scored as above every threshold."""
def __init__(self, n_thresholds=9):
self.n_thresholds = n_thresholds
def fit(self, frame, names=None):
"""``names``: the column names used in the rule names (default: those of ``frame``)."""
names = [str(c) for c in frame.columns] if names is None else list(names)
rules = [] # (column position, kind, value, name)
self.categorical_ = [] # per column: its rules compare string levels
qs = np.arange(1, self.n_thresholds + 1) / (self.n_thresholds + 1)
for i, c in enumerate(names):
col = frame.iloc[:, i]
self.categorical_.append(not pd.api.types.is_numeric_dtype(col) or pd.api.types.is_bool_dtype(col))
if self.categorical_[-1]:
levels = _levels(col)
levels[col.isna().to_numpy()] = _MISSING
freq = pd.Series(levels, dtype=object).value_counts()
for level in list(freq[freq >= 0.01 * len(col)].index[:20]):
if 0 < freq[level] < len(col):
if level is _MISSING:
rules.append((i, "missing", None, f"{c} missing"))
else:
rules.append((i, "eq_str", level, f"{c} = {level}"))
continue
x = pd.to_numeric(col, errors="coerce").to_numpy(float)
miss = np.isnan(x)
if miss.any() and not miss.all():
rules.append((i, "missing", None, f"{c} missing"))
vals = np.unique(x[~miss])
if len(vals) <= 1:
continue
if len(vals) == 2:
rules.append((i, "eq", vals[1], f"{c} = {_value_text(vals[1])}"))
continue
ts = np.unique(np.quantile(x[~miss], qs, method="lower"))
ts = ts[ts < vals[-1]]
nxt = vals[np.searchsorted(vals, ts, side="right")]
for t, n in zip(ts, nxt):
d, text = _threshold(t, n)
rules.append((i, "le", d, f"{c} <= {text}"))
names = _unique_names([r[3] for r in rules])
self.rules_ = [r[:3] + (name,) for r, name in zip(rules, names)]
self.names_ = names
assert len(set(self.names_)) == len(self.names_)
return self
def transform(self, frame, rules=None):
"""Boolean matrix of the rules (all, or the indices ``rules``) for the rows of ``frame``. Each column
is converted once and all its thresholds are compared at once."""
rules = np.arange(len(self.rules_)) if rules is None else np.asarray(rules, dtype=int)
out = np.zeros((len(frame), len(rules)), dtype=bool)
by_col = {}
for pos, j in enumerate(rules):
c, kind, v, _ = self.rules_[j]
by_col.setdefault(c, {}).setdefault(kind, []).append((pos, v))
for c, kinds in by_col.items():
col = frame.iloc[:, c]
if self.categorical_[c]:
miss = col.isna().to_numpy()
if "missing" in kinds:
out[:, kinds["missing"][0][0]] = miss
if "eq_str" in kinds:
pos, levels = map(list, zip(*kinds["eq_str"]))
codes = pd.Categorical(_levels(col), categories=levels).codes.copy()
codes[miss] = -1 # a missing value is no level, even one whose string form is a level
out[:, _block(pos)] = codes[:, None] == np.arange(len(levels))[None, :]
continue
x = pd.to_numeric(col, errors="coerce").to_numpy(float)
for kind, items in kinds.items():
pos, vals = map(list, zip(*items))
if kind == "missing":
out[:, pos[0]] = np.isnan(x)
elif kind == "eq":
out[:, pos[0]] = x == vals[0]
else:
out[:, _block(pos)] = x[:, None] <= np.asarray(vals, float)[None, :] # NaN compares False
return out
def _block(pos):
"""Output columns ``pos`` as a slice when they are consecutive (writing a slice is much faster than a list)."""
if pos[-1] - pos[0] == len(pos) - 1 and all(b - a == 1 for a, b in zip(pos, pos[1:])):
return slice(pos[0], pos[-1] + 1)
return pos
def _levels(col):
"""The values of a categorical column as strings (missing rows are masked by the callers)."""
return col.astype(object).astype(str).to_numpy(object)
class FastRiskScoreClassifier(TextMixin, ClassifierMixin, BaseEstimator):
"""Sparse integer risk score (binary classification).
The columns of X are turned into binary features (thresholds of numeric columns, levels of
categorical ones), and the solver picks at most ``k`` of them with integer points. It runs a
continuous beam search over supports (as FasterRisk does), rounds the best supports at many
scales keeping the rounding with the smallest calibrated loss, and improves the best of them
by an integer local search (value changes, additions, threshold slides and swaps, ranked by
an estimate of the calibrated loss and checked exactly), refit swaps and, on near-separable
data, an exact polish. The solver comes from an autoresearch loop (agentic-imodels, evolve_slim
runs) that started from FasterRisk; the final version is n17_lean of run oct05-decile2. It has
two settings profiles: "decile" (tuned with 9 thresholds per numeric column) and "fine" (99),
chosen from ``n_thresholds``.
Parameters
----------
k: int
The most features the score may use.
max_points: int
Every feature's points are an integer in [-max_points, max_points].
n_thresholds: int
Number of quantile thresholds per numeric column: 9 splits at the deciles, 99 at the
percentiles (``x <= t`` indicators). More than 19 selects the solver's "fine" profile.
binarize: bool
If False, the columns of X are used as they are (they should then be binary or small
integers), and the points apply to them directly; the solver then uses the "decile" profile.
time_limit: float
Seconds for the search. It stops early and returns its best points if the limit is
reached (``stopped_early_`` is then True); on most data it finishes far sooner. Turning
X into binary features before the search (and the one-time numba compilation) is not
counted; ``fit_seconds_`` is the whole fit.
Attributes
----------
points_: dict
Feature name -> points, for the features the score uses. Every binary feature has its
own name: a threshold is printed with as many digits as it takes to tell it from the
next value in the training data, and is applied as printed.
coef_: ndarray
Points for every binary feature (``features_``), zero for those not used.
scale_, intercept_: float
The risk of a row with total score s is ``1 / (1 + exp(-(scale_ * s + intercept_)))``.
train_loss_: float
Mean training log loss of that risk.
profile_: str
The solver's settings profile ("decile" or "fine").
"""
def __init__(self, k: int = 5, max_points: int = 5, n_thresholds: int = 9, binarize: bool = True,
time_limit: float = 60.0):
if not solver.HAVE_NUMBA:
# say so when the model is built, as for the other models with optional dependencies;
# fit raises the same message, since there is no pure-Python fallback
warnings.warn(NUMBA_HINT + " Fitting will raise an ImportError until it is installed.")
self.k = k
self.max_points = max_points
self.n_thresholds = n_thresholds
self.binarize = binarize
self.time_limit = time_limit
# ------------------------------------------------------------------ fitting
def _check_params(self):
for name in ("k", "max_points", "n_thresholds"):
v = getattr(self, name)
if isinstance(v, bool) or not isinstance(v, numbers.Integral) or v < 1:
raise ValueError(f"{name} must be an integer of at least 1, got {v!r}")
v = self.time_limit
if isinstance(v, bool) or not isinstance(v, numbers.Real) or not (math.isfinite(v) and v > 0):
raise ValueError(f"time_limit must be a positive finite number of seconds, got {v!r}")
def _array(self, X):
"""X (not a DataFrame) as a 2-D array, by sklearn's check_array; sparse X is refused."""
if sparse.issparse(X):
raise TypeError(f"{type(self).__name__} does not support sparse X; pass a dense array "
"(X.toarray()) or a DataFrame")
return check_array(X, dtype=None, accept_sparse=False, **_finite_check_kwarg(True))
def fit(self, X, y, feature_names=None):
if not solver.HAVE_NUMBA:
raise ImportError(NUMBA_HINT)
self._check_params()
t0 = time.perf_counter()
set_feature_names_in(self, X) # also deletes a feature_names_in_ of an earlier fit on a DataFrame
if isinstance(X, pd.DataFrame):
frame = X
self.feature_names_ = [str(c) for c in X.columns]
else:
X = self._array(X)
frame = pd.DataFrame(X)
if feature_names is not None and len(feature_names) != X.shape[1]:
raise ValueError(f"feature_names has {len(feature_names)} names but X has {X.shape[1]} columns")
self.feature_names_ = [str(c) for c in feature_names] if feature_names is not None else \
[f"X{i}" for i in range(X.shape[1])]
y = np.asarray(y)
if y.ndim != 1 or len(y) != len(frame):
raise ValueError("y must be 1-D with one entry per row of X")
check_binary_target(self, y)
self.classes_, y01 = np.unique(y, return_inverse=True)
if len(self.classes_) < 2:
raise ValueError("FastRiskScoreClassifier needs two classes in y")
self.n_features_in_ = frame.shape[1]
if self.binarize:
self.binarizer_ = _Binarizer(self.n_thresholds).fit(frame, self.feature_names_)
B = self.binarizer_.transform(frame)
self.features_ = list(self.binarizer_.names_)
else:
B = frame.to_numpy(float)
if np.isnan(B).any():
raise ValueError("binarize=False needs X without missing values")
self.features_ = list(self.feature_names_)
fine = self.binarize and self.n_thresholds > DECILE_PROFILE_MAX
self.profile_ = "fine" if fine else "decile"
if B.shape[1] == 0:
points, stopped = np.zeros(0), False
else:
if not solver._WARM: # the first fit in this process compiles the search (or loads it)
notify_first_compile(solver.__file__, "FastRiskScoreClassifier", "about 2 minutes",
solver.NUMBA_CACHE, "RISKSCORE_NUMBA_CACHE")
points, _, _, stopped = solver.solve(B, y01, int(self.k), bound=int(self.max_points),
time_limit=float(self.time_limit), profile=self.profile_)
points = points.astype(np.int64)
a, b, loss = calibrate(B @ points if len(points) else np.zeros(len(y01)), y01)
if a < 0: # orient the score so that more points always means more risk
points, a = -points, -a
self.coef_ = points
self.scale_, self.intercept_, self.train_loss_ = float(a), float(b), float(loss)
self.points_ = {self.features_[j]: int(points[j]) for j in np.flatnonzero(points)}
assert len(self.points_) == np.count_nonzero(points)
self.stopped_early_ = bool(stopped)
self.fit_seconds_ = time.perf_counter() - t0
return self
# --------------------------------------------------------------- prediction
def _frame(self, X):
"""X checked against the fit and as a DataFrame whose columns are used by position (as sklearn does)."""
check_predict_X(self, X)
if isinstance(X, pd.DataFrame):
if getattr(self, "feature_names_in_", None) is None and X.shape[1] and \
all(isinstance(c, str) for c in X.columns):
warnings.warn(f"X has feature names, but {type(self).__name__} was fitted without feature "
"names", UserWarning)
return X
X = self._array(X)
if X.shape[1] != self.n_features_in_:
raise ValueError(f"X has {X.shape[1]} features, but {type(self).__name__} is expecting "
f"{self.n_features_in_} features as input.")
return pd.DataFrame(X)
def total_score(self, X):
"""The total points of each row."""
check_is_fitted(self)
frame = self._frame(X)
used = np.flatnonzero(self.coef_) # only the features the score uses are computed
if self.binarize:
B = self.binarizer_.transform(frame, used)
else:
B = frame.iloc[:, used].to_numpy(float)
return (B @ self.coef_[used]).astype(float) if len(used) else np.zeros(len(frame))
def decision_function(self, X):
s = self.total_score(X)
return self.scale_ * s + self.intercept_
def predict_proba(self, X):
p = 1.0 / (1.0 + np.exp(-self.decision_function(X)))
return np.column_stack([1 - p, p])
def predict(self, X):
z = self.decision_function(X)
return self.classes_[(z > 0).astype(int)]
def risk(self, score):
"""Probability of the second class for a total score."""
return 1.0 / (1.0 + np.exp(-(self.scale_ * np.asarray(score, float) + self.intercept_)))
# ----------------------------------------------------------------- display
Functions
def calibrate(scores, y)-
(a, b, loss): the minimum over real a, b of the mean log loss of sigmoid(a * score + b).
Convex in (a, b); solved by damped Newton on the distinct score values.
Expand source code
def calibrate(scores, y): """(a, b, loss): the minimum over real a, b of the mean log loss of sigmoid(a * score + b). Convex in (a, b); solved by damped Newton on the distinct score values.""" y = np.asarray(y, float) u, inv = np.unique(np.round(np.asarray(scores, float), 9), return_inverse=True) pos = np.bincount(inv.ravel(), weights=y, minlength=len(u)) cnt = np.bincount(inv.ravel(), minlength=len(u)).astype(float) n = cnt.sum() p = min(max(pos.sum() / n, 1e-12), 1 - 1e-12) a, b = 0.0, math.log(p / (1 - p)) def f(a_, b_): z = a_ * u + b_ return float((pos * np.logaddexp(0, -z) + (cnt - pos) * np.logaddexp(0, z)).sum() / n) cur = f(a, b) if len(u) > 1: for _ in range(200): q = 1 / (1 + np.exp(-(a * u + b))) r = cnt * q - pos w = cnt * q * (1 - q) g = np.array([r @ u, r.sum()]) / n H = np.array([[w @ (u * u), w @ u], [w @ u, w.sum()]]) / n + 1e-12 * np.eye(2) step = np.linalg.solve(H, g) t = 1.0 while t > 1e-10: na, nb_ = a - t * step[0], b - t * step[1] new = f(na, nb_) if new <= cur - 1e-4 * t * float(g @ step): break t *= 0.5 if t <= 1e-10 or cur - new < 1e-13: if t > 1e-10: a, b, cur = na, nb_, new break a, b, cur = na, nb_, new return a, b, cur
Classes
class FastRiskScoreClassifier (k: int = 5, max_points: int = 5, n_thresholds: int = 9, binarize: bool = True, time_limit: float = 60.0)-
Sparse integer risk score (binary classification).
The columns of X are turned into binary features (thresholds of numeric columns, levels of categorical ones), and the solver picks at most
kof them with integer points. It runs a continuous beam search over supports (as FasterRisk does), rounds the best supports at many scales keeping the rounding with the smallest calibrated loss, and improves the best of them by an integer local search (value changes, additions, threshold slides and swaps, ranked by an estimate of the calibrated loss and checked exactly), refit swaps and, on near-separable data, an exact polish. The solver comes from an autoresearch loop (agentic-imodels, evolve_slim runs) that started from FasterRisk; the final version is n17_lean of run oct05-decile2. It has two settings profiles: "decile" (tuned with 9 thresholds per numeric column) and "fine" (99), chosen fromn_thresholds.Parameters
k:int- The most features the score may use.
max_points:int- Every feature's points are an integer in [-max_points, max_points].
n_thresholds:int- Number of quantile thresholds per numeric column: 9 splits at the deciles, 99 at the
percentiles (
x <= tindicators). More than 19 selects the solver's "fine" profile. binarize:bool- If False, the columns of X are used as they are (they should then be binary or small integers), and the points apply to them directly; the solver then uses the "decile" profile.
time_limit:float- Seconds for the search. It stops early and returns its best points if the limit is
reached (
stopped_early_is then True); on most data it finishes far sooner. Turning X into binary features before the search (and the one-time numba compilation) is not counted;fit_seconds_is the whole fit.
Attributes
points_:dict- Feature name -> points, for the features the score uses. Every binary feature has its own name: a threshold is printed with as many digits as it takes to tell it from the next value in the training data, and is applied as printed.
coef_:ndarray- Points for every binary feature (
features_), zero for those not used. scale_,intercept_:float- The risk of a row with total score s is
1 / (1 + exp(-(scale_ * s + intercept_))). train_loss_:float- Mean training log loss of that risk.
profile_:str- The solver's settings profile ("decile" or "fine").
Expand source code
class FastRiskScoreClassifier(TextMixin, ClassifierMixin, BaseEstimator): """Sparse integer risk score (binary classification). The columns of X are turned into binary features (thresholds of numeric columns, levels of categorical ones), and the solver picks at most ``k`` of them with integer points. It runs a continuous beam search over supports (as FasterRisk does), rounds the best supports at many scales keeping the rounding with the smallest calibrated loss, and improves the best of them by an integer local search (value changes, additions, threshold slides and swaps, ranked by an estimate of the calibrated loss and checked exactly), refit swaps and, on near-separable data, an exact polish. The solver comes from an autoresearch loop (agentic-imodels, evolve_slim runs) that started from FasterRisk; the final version is n17_lean of run oct05-decile2. It has two settings profiles: "decile" (tuned with 9 thresholds per numeric column) and "fine" (99), chosen from ``n_thresholds``. Parameters ---------- k: int The most features the score may use. max_points: int Every feature's points are an integer in [-max_points, max_points]. n_thresholds: int Number of quantile thresholds per numeric column: 9 splits at the deciles, 99 at the percentiles (``x <= t`` indicators). More than 19 selects the solver's "fine" profile. binarize: bool If False, the columns of X are used as they are (they should then be binary or small integers), and the points apply to them directly; the solver then uses the "decile" profile. time_limit: float Seconds for the search. It stops early and returns its best points if the limit is reached (``stopped_early_`` is then True); on most data it finishes far sooner. Turning X into binary features before the search (and the one-time numba compilation) is not counted; ``fit_seconds_`` is the whole fit. Attributes ---------- points_: dict Feature name -> points, for the features the score uses. Every binary feature has its own name: a threshold is printed with as many digits as it takes to tell it from the next value in the training data, and is applied as printed. coef_: ndarray Points for every binary feature (``features_``), zero for those not used. scale_, intercept_: float The risk of a row with total score s is ``1 / (1 + exp(-(scale_ * s + intercept_)))``. train_loss_: float Mean training log loss of that risk. profile_: str The solver's settings profile ("decile" or "fine"). """ def __init__(self, k: int = 5, max_points: int = 5, n_thresholds: int = 9, binarize: bool = True, time_limit: float = 60.0): if not solver.HAVE_NUMBA: # say so when the model is built, as for the other models with optional dependencies; # fit raises the same message, since there is no pure-Python fallback warnings.warn(NUMBA_HINT + " Fitting will raise an ImportError until it is installed.") self.k = k self.max_points = max_points self.n_thresholds = n_thresholds self.binarize = binarize self.time_limit = time_limit # ------------------------------------------------------------------ fitting def _check_params(self): for name in ("k", "max_points", "n_thresholds"): v = getattr(self, name) if isinstance(v, bool) or not isinstance(v, numbers.Integral) or v < 1: raise ValueError(f"{name} must be an integer of at least 1, got {v!r}") v = self.time_limit if isinstance(v, bool) or not isinstance(v, numbers.Real) or not (math.isfinite(v) and v > 0): raise ValueError(f"time_limit must be a positive finite number of seconds, got {v!r}") def _array(self, X): """X (not a DataFrame) as a 2-D array, by sklearn's check_array; sparse X is refused.""" if sparse.issparse(X): raise TypeError(f"{type(self).__name__} does not support sparse X; pass a dense array " "(X.toarray()) or a DataFrame") return check_array(X, dtype=None, accept_sparse=False, **_finite_check_kwarg(True)) def fit(self, X, y, feature_names=None): if not solver.HAVE_NUMBA: raise ImportError(NUMBA_HINT) self._check_params() t0 = time.perf_counter() set_feature_names_in(self, X) # also deletes a feature_names_in_ of an earlier fit on a DataFrame if isinstance(X, pd.DataFrame): frame = X self.feature_names_ = [str(c) for c in X.columns] else: X = self._array(X) frame = pd.DataFrame(X) if feature_names is not None and len(feature_names) != X.shape[1]: raise ValueError(f"feature_names has {len(feature_names)} names but X has {X.shape[1]} columns") self.feature_names_ = [str(c) for c in feature_names] if feature_names is not None else \ [f"X{i}" for i in range(X.shape[1])] y = np.asarray(y) if y.ndim != 1 or len(y) != len(frame): raise ValueError("y must be 1-D with one entry per row of X") check_binary_target(self, y) self.classes_, y01 = np.unique(y, return_inverse=True) if len(self.classes_) < 2: raise ValueError("FastRiskScoreClassifier needs two classes in y") self.n_features_in_ = frame.shape[1] if self.binarize: self.binarizer_ = _Binarizer(self.n_thresholds).fit(frame, self.feature_names_) B = self.binarizer_.transform(frame) self.features_ = list(self.binarizer_.names_) else: B = frame.to_numpy(float) if np.isnan(B).any(): raise ValueError("binarize=False needs X without missing values") self.features_ = list(self.feature_names_) fine = self.binarize and self.n_thresholds > DECILE_PROFILE_MAX self.profile_ = "fine" if fine else "decile" if B.shape[1] == 0: points, stopped = np.zeros(0), False else: if not solver._WARM: # the first fit in this process compiles the search (or loads it) notify_first_compile(solver.__file__, "FastRiskScoreClassifier", "about 2 minutes", solver.NUMBA_CACHE, "RISKSCORE_NUMBA_CACHE") points, _, _, stopped = solver.solve(B, y01, int(self.k), bound=int(self.max_points), time_limit=float(self.time_limit), profile=self.profile_) points = points.astype(np.int64) a, b, loss = calibrate(B @ points if len(points) else np.zeros(len(y01)), y01) if a < 0: # orient the score so that more points always means more risk points, a = -points, -a self.coef_ = points self.scale_, self.intercept_, self.train_loss_ = float(a), float(b), float(loss) self.points_ = {self.features_[j]: int(points[j]) for j in np.flatnonzero(points)} assert len(self.points_) == np.count_nonzero(points) self.stopped_early_ = bool(stopped) self.fit_seconds_ = time.perf_counter() - t0 return self # --------------------------------------------------------------- prediction def _frame(self, X): """X checked against the fit and as a DataFrame whose columns are used by position (as sklearn does).""" check_predict_X(self, X) if isinstance(X, pd.DataFrame): if getattr(self, "feature_names_in_", None) is None and X.shape[1] and \ all(isinstance(c, str) for c in X.columns): warnings.warn(f"X has feature names, but {type(self).__name__} was fitted without feature " "names", UserWarning) return X X = self._array(X) if X.shape[1] != self.n_features_in_: raise ValueError(f"X has {X.shape[1]} features, but {type(self).__name__} is expecting " f"{self.n_features_in_} features as input.") return pd.DataFrame(X) def total_score(self, X): """The total points of each row.""" check_is_fitted(self) frame = self._frame(X) used = np.flatnonzero(self.coef_) # only the features the score uses are computed if self.binarize: B = self.binarizer_.transform(frame, used) else: B = frame.iloc[:, used].to_numpy(float) return (B @ self.coef_[used]).astype(float) if len(used) else np.zeros(len(frame)) def decision_function(self, X): s = self.total_score(X) return self.scale_ * s + self.intercept_ def predict_proba(self, X): p = 1.0 / (1.0 + np.exp(-self.decision_function(X))) return np.column_stack([1 - p, p]) def predict(self, X): z = self.decision_function(X) return self.classes_[(z > 0).astype(int)] def risk(self, score): """Probability of the second class for a total score.""" return 1.0 / (1.0 + np.exp(-(self.scale_ * np.asarray(score, float) + self.intercept_))) # ----------------------------------------------------------------- displayAncestors
- TextMixin
- sklearn.base.ClassifierMixin
- sklearn.base.BaseEstimator
- sklearn.utils._estimator_html_repr._HTMLDocumentationLinkMixin
- sklearn.utils._metadata_requests._MetadataRequester
Methods
def decision_function(self, X)-
Expand source code
def decision_function(self, X): s = self.total_score(X) return self.scale_ * s + self.intercept_ def fit(self, X, y, feature_names=None)-
Expand source code
def fit(self, X, y, feature_names=None): if not solver.HAVE_NUMBA: raise ImportError(NUMBA_HINT) self._check_params() t0 = time.perf_counter() set_feature_names_in(self, X) # also deletes a feature_names_in_ of an earlier fit on a DataFrame if isinstance(X, pd.DataFrame): frame = X self.feature_names_ = [str(c) for c in X.columns] else: X = self._array(X) frame = pd.DataFrame(X) if feature_names is not None and len(feature_names) != X.shape[1]: raise ValueError(f"feature_names has {len(feature_names)} names but X has {X.shape[1]} columns") self.feature_names_ = [str(c) for c in feature_names] if feature_names is not None else \ [f"X{i}" for i in range(X.shape[1])] y = np.asarray(y) if y.ndim != 1 or len(y) != len(frame): raise ValueError("y must be 1-D with one entry per row of X") check_binary_target(self, y) self.classes_, y01 = np.unique(y, return_inverse=True) if len(self.classes_) < 2: raise ValueError("FastRiskScoreClassifier needs two classes in y") self.n_features_in_ = frame.shape[1] if self.binarize: self.binarizer_ = _Binarizer(self.n_thresholds).fit(frame, self.feature_names_) B = self.binarizer_.transform(frame) self.features_ = list(self.binarizer_.names_) else: B = frame.to_numpy(float) if np.isnan(B).any(): raise ValueError("binarize=False needs X without missing values") self.features_ = list(self.feature_names_) fine = self.binarize and self.n_thresholds > DECILE_PROFILE_MAX self.profile_ = "fine" if fine else "decile" if B.shape[1] == 0: points, stopped = np.zeros(0), False else: if not solver._WARM: # the first fit in this process compiles the search (or loads it) notify_first_compile(solver.__file__, "FastRiskScoreClassifier", "about 2 minutes", solver.NUMBA_CACHE, "RISKSCORE_NUMBA_CACHE") points, _, _, stopped = solver.solve(B, y01, int(self.k), bound=int(self.max_points), time_limit=float(self.time_limit), profile=self.profile_) points = points.astype(np.int64) a, b, loss = calibrate(B @ points if len(points) else np.zeros(len(y01)), y01) if a < 0: # orient the score so that more points always means more risk points, a = -points, -a self.coef_ = points self.scale_, self.intercept_, self.train_loss_ = float(a), float(b), float(loss) self.points_ = {self.features_[j]: int(points[j]) for j in np.flatnonzero(points)} assert len(self.points_) == np.count_nonzero(points) self.stopped_early_ = bool(stopped) self.fit_seconds_ = time.perf_counter() - t0 return self def predict(self, X)-
Expand source code
def predict(self, X): z = self.decision_function(X) return self.classes_[(z > 0).astype(int)] def predict_proba(self, X)-
Expand source code
def predict_proba(self, X): p = 1.0 / (1.0 + np.exp(-self.decision_function(X))) return np.column_stack([1 - p, p]) def risk(self, score)-
Probability of the second class for a total score.
Expand source code
def risk(self, score): """Probability of the second class for a total score.""" return 1.0 / (1.0 + np.exp(-(self.scale_ * np.asarray(score, float) + self.intercept_))) def total_score(self, X)-
The total points of each row.
Expand source code
def total_score(self, X): """The total points of each row.""" check_is_fitted(self) frame = self._frame(X) used = np.flatnonzero(self.coef_) # only the features the score uses are computed if self.binarize: B = self.binarizer_.transform(frame, used) else: B = frame.iloc[:, used].to_numpy(float) return (B @ self.coef_[used]).astype(float) if len(used) else np.zeros(len(frame))