API reference
Estimators
FlagGAMClassifier / FlagGAMRegressor — the sklearn-compatible entry points.
estimator
FlagGAMClassifier and FlagGAMRegressor: sklearn-compatible estimators for rule-basis GAMs.
FlagGAMClassifier
FlagGAMClassifier(task='auto', quantile_low=(0.05, 0.45), quantile_high=(0.55, 0.95), quantile_step=0.05, min_support='auto', fdr_alpha=0.05, effect_size='risk_difference', representation='full', feature_weighting=None, head='additive', flexible_estimator=None, C=1.0, missing='no_evidence', monotonic_constraints=None, categorical_features=None, random_state=None)
Bases: ClassifierMixin, _BaseFlagGAM
Rule-basis generalized additive model classifier (sklearn contract).
Source code in src/flaggam/estimator.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160 | def __init__(
self,
task="auto",
quantile_low=(0.05, 0.45),
quantile_high=(0.55, 0.95),
quantile_step=0.05,
min_support="auto",
fdr_alpha=0.05,
effect_size="risk_difference",
representation="full",
feature_weighting=None,
head="additive",
flexible_estimator=None,
C=1.0,
missing="no_evidence",
monotonic_constraints=None,
categorical_features=None,
random_state=None,
) -> None:
self.task = task
self.quantile_low = quantile_low
self.quantile_high = quantile_high
self.quantile_step = quantile_step
self.min_support = min_support
self.fdr_alpha = fdr_alpha
self.effect_size = effect_size
self.representation = representation
self.feature_weighting = feature_weighting
self.head = head
self.flexible_estimator = flexible_estimator
self.C = C
self.missing = missing
self.monotonic_constraints = monotonic_constraints
self.categorical_features = categorical_features
self.random_state = random_state
|
fit
Discover flag bases, fit head on Z(X).
Source code in src/flaggam/estimator.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241 | def fit(self, X, y):
"""Discover flag bases, fit head on Z(X)."""
if y is None:
raise ValueError("requires y to be passed, but the target y is None")
if self.task not in {"auto", "binary", "multiclass"}:
raise ValueError(
f"task={self.task!r} is not recognised; "
"valid values are 'auto', 'binary', 'multiclass'"
)
if self.representation not in {"full", "compact"}:
raise ValueError(
f"representation={self.representation!r} is not recognised; "
"valid values are 'full', 'compact'"
)
if self.missing not in {"no_evidence", "indicator"}:
raise ValueError(
f"missing={self.missing!r} is not recognised; "
"valid values are 'no_evidence', 'indicator'"
)
df = self._to_frame(X, reset=True)
y = np.asarray(y)
if len(df) != len(y):
raise ValueError(
f"Found input variables with inconsistent numbers of samples: "
f"[{len(df)}, {len(y)}]"
)
check_classification_targets(y)
self.label_encoder_ = LabelEncoder().fit(y)
self.classes_ = self.label_encoder_.classes_
y_enc = self.label_encoder_.transform(y)
task = self.task
if task == "auto":
task = "binary" if len(self.classes_) == 2 else "multiclass"
if self.monotonic_constraints is not None:
_validate_constraints(self.monotonic_constraints, list(df.columns))
if self.head != "additive":
raise ValueError("monotonic_constraints require head='additive'")
if self.representation == "compact":
raise ValueError(
"monotonic_constraints are incompatible with representation='compact'"
)
if task == "multiclass":
raise NotImplementedError(
"monotonic constraints support binary classification only"
)
if self.head == "flexible" and self.flexible_estimator is None:
raise ValueError("head='flexible' requires flexible_estimator")
self.core_ = self._build_core(task).fit(df, y_enc)
Z = self.core_.transform(df)
self.feature_weights_ = None
H = Z
if self.representation == "compact":
if self.feature_weighting == "auto":
self.feature_weights_ = feature_weights(
df, y_enc, task, self.core_.numerical_features_
)
H = compact_scores(
Z,
self.core_.bases_,
np.arange(len(self.classes_)),
self.feature_weights_,
)
if self.head == "additive":
if self.monotonic_constraints is not None:
from .monotonic import MonotonicAdditiveHead, bounds_for_bases
C = 1.0 if isinstance(self.C, (list, tuple)) else self.C
bounds = bounds_for_bases(self.core_.bases_, dict(self.monotonic_constraints))
self.head_ = MonotonicAdditiveHead(task, bounds, C=C)
else:
self.head_ = AdditiveHead(task, C=self.C, random_state=self.random_state)
else:
self.head_ = FlexibleHead(self.flexible_estimator, task, random_state=self.random_state)
self.head_.fit(H, y_enc)
return self
|
FlagGAMRegressor
FlagGAMRegressor(quantile_low=(0.05, 0.45), quantile_high=(0.55, 0.95), quantile_step=0.05, min_support='auto', fdr_alpha=0.05, head='additive', flexible_estimator=None, alpha=1.0, missing='no_evidence', monotonic_constraints=None, categorical_features=None, random_state=None)
Bases: RegressorMixin, _BaseFlagGAM
Rule-basis generalized additive model regressor (sklearn contract).
Source code in src/flaggam/estimator.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299 | def __init__(
self,
quantile_low=(0.05, 0.45),
quantile_high=(0.55, 0.95),
quantile_step=0.05,
min_support="auto",
fdr_alpha=0.05,
head="additive",
flexible_estimator=None,
alpha=1.0,
missing="no_evidence",
monotonic_constraints=None,
categorical_features=None,
random_state=None,
) -> None:
self.quantile_low = quantile_low
self.quantile_high = quantile_high
self.quantile_step = quantile_step
self.min_support = min_support
self.fdr_alpha = fdr_alpha
self.head = head
self.flexible_estimator = flexible_estimator
self.alpha = alpha
self.missing = missing
self.monotonic_constraints = monotonic_constraints
self.categorical_features = categorical_features
self.random_state = random_state
|
fit
Discover flag bases, fit regression head on Z(X).
Source code in src/flaggam/estimator.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351 | def fit(self, X, y):
"""Discover flag bases, fit regression head on Z(X)."""
if y is None:
raise ValueError("requires y to be passed, but the target y is None")
if self.missing not in {"no_evidence", "indicator"}:
raise ValueError(
f"missing={self.missing!r} is not recognised; "
"valid values are 'no_evidence', 'indicator'"
)
df = self._to_frame(X, reset=True)
y = np.asarray(y)
if y.dtype.kind == "c":
raise ValueError(f"Complex data not supported\n{y}\n")
if y.ndim == 2 and y.shape[1] == 1:
warnings.warn(
"A column-vector y was passed when a 1d array was expected. "
"Please change the shape of y to (n_samples,), for example using ravel().",
DataConversionWarning,
stacklevel=2,
)
y = y.astype(float).ravel()
if len(df) != len(y):
raise ValueError(
f"Found input variables with inconsistent numbers of samples: "
f"[{len(df)}, {len(y)}]"
)
if self.monotonic_constraints is not None:
_validate_constraints(self.monotonic_constraints, list(df.columns))
if self.head != "additive":
raise ValueError("monotonic_constraints require head='additive'")
if self.head == "flexible" and self.flexible_estimator is None:
raise ValueError("head='flexible' requires flexible_estimator")
self.core_ = self._build_core("regression").fit(df, y)
Z = self.core_.transform(df)
if self.head == "additive":
if self.monotonic_constraints is not None:
from .monotonic import MonotonicAdditiveHead, bounds_for_bases
alpha = 1.0 if isinstance(self.alpha, (list, tuple)) else self.alpha
bounds = bounds_for_bases(self.core_.bases_, dict(self.monotonic_constraints))
self.head_ = MonotonicAdditiveHead("regression", bounds, alpha=alpha)
else:
self.head_ = AdditiveHead(
"regression", alpha=self.alpha, random_state=self.random_state
)
else:
self.head_ = FlexibleHead(
self.flexible_estimator, "regression", random_state=self.random_state
)
self.head_.fit(Z, y)
return self
|
Core
Rule discovery and Z(X) basis-matrix construction.
core
Flag Core Module: rule discovery and Z(X) construction (training data only).
FlagCoreModule
FlagCoreModule(task: str, quantile_low: tuple[float, float] = (0.05, 0.45), quantile_high: tuple[float, float] = (0.55, 0.95), quantile_step: float = 0.05, min_support: int | str = 'auto', fdr_alpha: float = 0.05, effect_size: str = 'risk_difference', missing: str = 'no_evidence')
Discovers per-feature flag bases and assembles the sparse Z(X) matrix.
Source code in src/flaggam/core.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47 | def __init__(
self,
task: str,
quantile_low: tuple[float, float] = (0.05, 0.45),
quantile_high: tuple[float, float] = (0.55, 0.95),
quantile_step: float = 0.05,
min_support: int | str = "auto",
fdr_alpha: float = 0.05,
effect_size: str = "risk_difference",
missing: str = "no_evidence",
) -> None:
self.task = task
self.quantile_low = quantile_low
self.quantile_high = quantile_high
self.quantile_step = quantile_step
self.min_support = min_support
self.fdr_alpha = fdr_alpha
self.effect_size = effect_size
self.missing = missing
|
Screening
UFA screening statistics used to select candidate rules.
screening
Screening statistics for rule discovery.
These are screening tools, not confirmatory inference (Zhao & Welsch,
arXiv:2605.31189).
Two-proportion test falls back to Fisher's exact test when any expected
cell count is below 5 (documented decision, spec §6.3).
compute_min_support
compute_min_support(n_train: int) -> int
Minimum tail/level support: min(200, max(20, ceil(0.02 * n_train))).
Source code in src/flaggam/screening.py
| def compute_min_support(n_train: int) -> int:
"""Minimum tail/level support: min(200, max(20, ceil(0.02 * n_train)))."""
return min(200, max(20, math.ceil(0.02 * n_train)))
|
bh_adjust
bh_adjust(p_values: ndarray) -> ndarray
Benjamini-Hochberg adjusted p-values (within one feature's candidates).
Source code in src/flaggam/screening.py
20
21
22
23
24
25
26
27
28
29 | def bh_adjust(p_values: np.ndarray) -> np.ndarray:
"""Benjamini-Hochberg adjusted p-values (within one feature's candidates)."""
p = np.asarray(p_values, dtype=float)
m = p.size
order = np.argsort(p)
ranked = p[order] * m / np.arange(1, m + 1)
ranked = np.minimum.accumulate(ranked[::-1])[::-1]
adj = np.empty(m, dtype=float)
adj[order] = np.clip(ranked, 0.0, 1.0)
return adj
|
two_proportion_test
two_proportion_test(k_tail: int, n_tail: int, k_base: int, n_base: int) -> float
Two-sided two-proportion z-test; Fisher's exact if any expected count < 5.
Source code in src/flaggam/screening.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52 | def two_proportion_test(k_tail: int, n_tail: int, k_base: int, n_base: int) -> float:
"""Two-sided two-proportion z-test; Fisher's exact if any expected count < 5."""
if n_tail == 0 or n_base == 0:
return 1.0
if _expected_counts(k_tail, n_tail, k_base, n_base) < 5.0:
table = [[k_tail, n_tail - k_tail], [k_base, n_base - k_base]]
return float(stats.fisher_exact(table, alternative="two-sided")[1])
p1, p0 = k_tail / n_tail, k_base / n_base
pooled = (k_tail + k_base) / (n_tail + n_base)
se = math.sqrt(pooled * (1.0 - pooled) * (1.0 / n_tail + 1.0 / n_base))
if se == 0.0:
return 1.0
z = (p1 - p0) / se
return float(2.0 * stats.norm.sf(abs(z)))
|
chi_square_test
chi_square_test(y_tail: ndarray, y_base: ndarray) -> float
Pearson chi-square p-value on the 2 x K (region x class) table.
Source code in src/flaggam/screening.py
55
56
57
58
59
60
61
62
63
64
65 | def chi_square_test(y_tail: np.ndarray, y_base: np.ndarray) -> float:
"""Pearson chi-square p-value on the 2 x K (region x class) table."""
classes = np.union1d(np.unique(y_tail), np.unique(y_base))
table = np.array(
[[np.sum(y_tail == c) for c in classes], [np.sum(y_base == c) for c in classes]]
)
keep = table.sum(axis=0) > 0
table = table[:, keep]
if table.shape[1] < 2 or table.sum(axis=1).min() == 0:
return 1.0
return float(stats.chi2_contingency(table, correction=False)[1])
|
welch_t_test
welch_t_test(a: ndarray, b: ndarray) -> float
Two-sided Welch t-test p-value; 1.0 when a side is degenerate.
Source code in src/flaggam/screening.py
| def welch_t_test(a: np.ndarray, b: np.ndarray) -> float:
"""Two-sided Welch t-test p-value; 1.0 when a side is degenerate."""
if len(a) < 2 or len(b) < 2:
return 1.0
res = stats.ttest_ind(a, b, equal_var=False)
return float(res.pvalue) if np.isfinite(res.pvalue) else 1.0
|
risk_difference
risk_difference(k_tail: int, n_tail: int, k_base: int, n_base: int) -> float
Absolute difference in positive-class rate, tail vs baseline (spec §6.1).
Source code in src/flaggam/screening.py
| def risk_difference(k_tail: int, n_tail: int, k_base: int, n_base: int) -> float:
"""Absolute difference in positive-class rate, tail vs baseline (spec §6.1)."""
return abs(k_tail / n_tail - k_base / n_base)
|
log_odds_ratio
log_odds_ratio(k_tail: int, n_tail: int, k_base: int, n_base: int) -> float
Absolute log odds ratio with 0.5 continuity correction.
Source code in src/flaggam/screening.py
| def log_odds_ratio(k_tail: int, n_tail: int, k_base: int, n_base: int) -> float:
"""Absolute log odds ratio with 0.5 continuity correction."""
a, b = k_tail + 0.5, n_tail - k_tail + 0.5
c, d = k_base + 0.5, n_base - k_base + 0.5
return abs(math.log((a * d) / (b * c)))
|
standardized_mean_difference
standardized_mean_difference(a: ndarray, b: ndarray) -> float
Absolute standardized mean difference with pooled (average) variance.
Source code in src/flaggam/screening.py
| def standardized_mean_difference(a: np.ndarray, b: np.ndarray) -> float:
"""Absolute standardized mean difference with pooled (average) variance."""
var = (np.var(a, ddof=1) + np.var(b, ddof=1)) / 2.0
if var == 0.0:
return 0.0
return float(abs(np.mean(a) - np.mean(b)) / np.sqrt(var))
|
Bases
Basis objects — one column of Z(X) each, with screening metadata.
bases
Basis objects: one column of Z(X) each, with screening metadata.
Missing semantics ("no_evidence", spec §7): NaN/None input never triggers a
basis; transform returns 0.0 there. For TrendBasis a missing value maps to
0.0, i.e. the feature mean (documented decision; paper is silent).
Basis
dataclass
Basis(feature: str, support: int, effect_size: float, p_value: float, p_adj: float, enriched_class: Any = None)
One univariate basis function z_ir(x_i) plus its discovery metadata.
ThresholdBasis
dataclass
ThresholdBasis(feature: str, support: int, effect_size: float, p_value: float, p_adj: float, enriched_class: Any = None, cutoff: float = 0.0, side: str = 'low')
Bases: Basis
Tail flag 1{x <= c} (side='low') or 1{x >= c} (side='high').
CategoryBasis
dataclass
CategoryBasis(feature: str, support: int, effect_size: float, p_value: float, p_adj: float, enriched_class: Any = None, level: Any = None)
Bases: Basis
Level flag 1{x == v}; also the regression step basis.
HingeBasis
dataclass
HingeBasis(feature: str, support: int, effect_size: float, p_value: float, p_adj: float, enriched_class: Any = None, cutoff: float = 0.0, side: str = 'low')
Bases: Basis
Tail-deviation hinge (x - c)+ (side='high') or (c - x)+ (side='low').
TrendBasis
dataclass
TrendBasis(feature: str, support: int, effect_size: float, p_value: float, p_adj: float, enriched_class: Any = None, mean: float = 0.0)
Bases: Basis
Centered baseline trend x - mean (regression numerical features).
MissingIndicatorBasis
dataclass
MissingIndicatorBasis(feature: str, support: int, effect_size: float, p_value: float, p_adj: float, enriched_class: Any = None)
Bases: Basis
Explicit flag 1{x is missing} (missing='indicator' mode only).
Missing
Missing-indicator discovery.
missing
Missing-indicator discovery. 'no_evidence' semantics live in bases.transform.
discover_missing_indicators
discover_missing_indicators(X: DataFrame, y: ndarray, task: str, min_support: int, fdr_alpha: float) -> list[MissingIndicatorBasis]
One candidate per feature; BH across features (one test each).
Source code in src/flaggam/missing.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46 | def discover_missing_indicators(
X: pd.DataFrame, y: np.ndarray, task: str, min_support: int, fdr_alpha: float
) -> list[MissingIndicatorBasis]:
"""One candidate per feature; BH across features (one test each)."""
candidates: list[tuple[str, int, float]] = []
for col in X.columns:
miss = X[col].isna().to_numpy()
n_miss = int(miss.sum())
if n_miss < min_support or (len(miss) - n_miss) < min_support:
continue
if task == "binary":
k1, k0 = int(y[miss].sum()), int(y[~miss].sum())
p = two_proportion_test(k1, n_miss, k0, len(miss) - n_miss)
else:
p = chi_square_test(y[miss], y[~miss])
candidates.append((col, n_miss, p))
if not candidates:
return []
p_adj = bh_adjust(np.array([c[2] for c in candidates]))
out = []
for (col, n_miss, p), pa in zip(candidates, p_adj, strict=False):
if pa <= fdr_alpha:
out.append(
MissingIndicatorBasis(
feature=col,
support=n_miss,
effect_size=float("nan"),
p_value=p,
p_adj=float(pa),
enriched_class=None,
)
)
return out
|
Heads
Prediction heads (additive / flexible) fit on Z(X).
heads
Prediction heads fit on Z(X) only. Z is used unstandardized (see DECISIONS.md).
AdditiveHead
AdditiveHead(task: str, C: float | list[float] = 1.0, alpha: float | list[float] = 1.0, random_state: int | None = None)
L2 logistic/softmax (classification) or ridge (regression) on Z(X).
Source code in src/flaggam/heads.py
13
14
15
16
17
18
19
20
21
22
23 | def __init__(
self,
task: str,
C: float | list[float] = 1.0,
alpha: float | list[float] = 1.0,
random_state: int | None = None,
) -> None:
self.task = task
self.C = C
self.alpha = alpha
self.random_state = random_state
|
FlexibleHead
FlexibleHead(estimator, task: str, random_state: int | None = None)
User-supplied tree-ensemble estimator fit on Z(X) only (no raw features).
Source code in src/flaggam/heads.py
| def __init__(self, estimator, task: str, random_state: int | None = None) -> None:
self.estimator = estimator
self.task = task
self.random_state = random_state
|
Weighting
Feature-weight statistics and the compact score representation.
weighting
Feature-weight statistics and the compact score representation (spec §6.6-6.7).
Compact scores are a classification-only ablation/option; benchmark default is
the full Z(X). A flag contributes to the score of its enriched class.
point_biserial
point_biserial(x: ndarray, y: ndarray) -> float
Absolute Pearson correlation between numerical x and binary y.
Source code in src/flaggam/weighting.py
| def point_biserial(x: np.ndarray, y: np.ndarray) -> float:
"""Absolute Pearson correlation between numerical x and binary y."""
if np.std(x) == 0.0 or np.std(y) == 0.0:
return 0.0
return float(abs(np.corrcoef(x, y)[0, 1]))
|
correlation_ratio
correlation_ratio(x: ndarray, y: ndarray) -> float
Correlation ratio eta of numerical x across the groups defined by y.
Source code in src/flaggam/weighting.py
| def correlation_ratio(x: np.ndarray, y: np.ndarray) -> float:
"""Correlation ratio eta of numerical x across the groups defined by y."""
grand = x.mean()
ss_total = float(((x - grand) ** 2).sum())
if ss_total == 0.0:
return 0.0
ss_between = sum(len(x[y == c]) * (x[y == c].mean() - grand) ** 2 for c in np.unique(y))
return float(np.sqrt(ss_between / ss_total))
|
cramers_v
cramers_v(x: ndarray, y: ndarray) -> float
Cramer's V between categorical x and target labels y.
Source code in src/flaggam/weighting.py
33
34
35
36
37
38
39
40
41
42
43 | def cramers_v(x: np.ndarray, y: np.ndarray) -> float:
"""Cramer's V between categorical x and target labels y."""
table = pd.crosstab(pd.Series(x), pd.Series(y)).to_numpy(dtype=float)
n = table.sum()
if n == 0 or min(table.shape) < 2:
return 0.0
expected = np.outer(table.sum(1), table.sum(0)) / n
with np.errstate(divide="ignore", invalid="ignore"):
chi2 = np.nansum((table - expected) ** 2 / expected)
k = min(table.shape) - 1
return float(np.sqrt(chi2 / (n * k)))
|
feature_weights
feature_weights(X: DataFrame, y: ndarray, task: str, numerical: list[str]) -> dict[str, float]
Per-feature association weight on non-missing training rows.
Source code in src/flaggam/weighting.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62 | def feature_weights(
X: pd.DataFrame, y: np.ndarray, task: str, numerical: list[str]
) -> dict[str, float]:
"""Per-feature association weight on non-missing training rows."""
weights: dict[str, float] = {}
for col in X.columns:
obs = X[col].notna().to_numpy()
if obs.sum() < 2:
weights[col] = 0.0
continue
xv, yv = X[col].to_numpy()[obs], y[obs]
if col in numerical:
xv = xv.astype(float)
weights[col] = point_biserial(xv, yv) if task == "binary" else correlation_ratio(xv, yv)
else:
weights[col] = cramers_v(xv, yv)
return weights
|
compact_scores
compact_scores(Z: spmatrix, bases: list[Basis], classes: ndarray, weights: dict[str, float] | None) -> ndarray
Per-class (optionally feature-weighted) sums of triggered flags: (n, K).
Source code in src/flaggam/weighting.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80 | def compact_scores(
Z: sparse.spmatrix,
bases: list[Basis],
classes: np.ndarray,
weights: dict[str, float] | None,
) -> np.ndarray:
"""Per-class (optionally feature-weighted) sums of triggered flags: (n, K)."""
Zd = np.asarray(Z.todense(), dtype=float)
out = np.zeros((Zd.shape[0], len(classes)))
class_index = {c: k for k, c in enumerate(classes)}
for j, b in enumerate(bases):
if b.kind not in _FLAG_KINDS or b.enriched_class not in class_index:
continue
w = 1.0 if weights is None else weights.get(b.feature, 0.0)
out[:, class_index[b.enriched_class]] += w * Zd[:, j]
return out
|
Inspection
Rule export (export_rules) and per-row reason codes (explain).
inspection
Rule export and per-row reason codes for fitted FlagGAM estimators.
Calibration
PD calibration diagnostics and recalibration. An original addition — see
Calibration.
calibration
PD calibration diagnostics and recalibration for FlagGAM.
This module is an ORIGINAL ADDITION and is not part of Zhao & Welsch
(arXiv:2605.31189): the paper evaluates only ranking metrics and never
assesses probability calibration. Design notes in docs/DECISIONS.md entry 19.
CalibratedFlagGAM
CalibratedFlagGAM(estimator: Any, method: str = 'platt', cv: int | str = 5, target_rate: float | None = None)
Recalibration wrapper: platt / isotonic / base_rate over a FlagGAM classifier.
cv=k cross-fits k clones to obtain leak-free out-of-fold predictions for
the single pooled calibrator, then refits the estimator on all data;
cv="prefit" treats estimator as already fitted and (X, y) in fit() as
pure calibration data. Binary classification only.
Source code in src/flaggam/calibration.py
111
112
113
114
115
116
117
118
119
120
121 | def __init__(
self,
estimator: Any,
method: str = "platt",
cv: int | str = 5,
target_rate: float | None = None,
) -> None:
self.estimator = estimator
self.method = method
self.cv = cv
self.target_rate = target_rate
|
reliability_curve
reliability_curve(y_true: Any, y_prob: Any, n_bins: int = 10, strategy: str = 'uniform') -> DataFrame
Binned reliability table (empty bins dropped).
Source code in src/flaggam/calibration.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67 | def reliability_curve(
y_true: Any, y_prob: Any, n_bins: int = 10, strategy: str = "uniform"
) -> pd.DataFrame:
"""Binned reliability table (empty bins dropped)."""
y, p = _validate_binary(y_true, y_prob)
edges = _bin_edges(p, n_bins, strategy)
idx = np.clip(np.digitize(p, edges[1:-1], right=False), 0, len(edges) - 2)
rows = []
for b in range(len(edges) - 1):
mask = idx == b
if not mask.any():
continue
rows.append(
{
"bin_lower": edges[b],
"bin_upper": edges[b + 1],
"mean_predicted": float(p[mask].mean()),
"fraction_positive": float(y[mask].mean()),
"count": int(mask.sum()),
}
)
return pd.DataFrame(rows)
|
Monotonic
Exact monotonicity constraints for the additive head. An original addition — see
Monotonicity.
monotonic
Exact monotonicity constraints for the FlagGAM additive head.
This module is an ORIGINAL ADDITION and is not part of Zhao & Welsch
(arXiv:2605.31189). Because each numerical feature contributes monotone
step/ramp bases (tail flags, hinges, trend), sign constraints on their
coefficients yield EXACT monotonicity of the additive contribution.
Sign table and design notes in docs/DECISIONS.md entry 20.
MonotonicAdditiveHead
MonotonicAdditiveHead(task: str, bounds: list[tuple[float | None, float | None]], C: float = 1.0, alpha: float = 1.0)
L-BFGS-B box-constrained L2 logistic (binary) or ridge (regression) head.
Drop-in for AdditiveHead when monotonic_constraints is active. Unlike
AdditiveHead, C/alpha are single floats only: CV tuning of the
constrained head is out of scope (spec routes list-valued C/alpha to the
spec default of 1.0 before construction, see estimator.py).
Source code in src/flaggam/monotonic.py
79
80
81
82
83
84
85
86
87
88
89 | def __init__(
self,
task: str,
bounds: list[tuple[float | None, float | None]],
C: float = 1.0,
alpha: float = 1.0,
) -> None:
self.task = task
self.bounds = bounds
self.C = C
self.alpha = alpha
|
bounds_for_bases
bounds_for_bases(bases: list, constraints: dict) -> list[tuple[float | None, float | None]]
Per-basis-column box bounds implementing spec §8.2 sign constraints.
Source code in src/flaggam/monotonic.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53 | def bounds_for_bases(bases: list, constraints: dict) -> list[tuple[float | None, float | None]]:
"""Per-basis-column box bounds implementing spec §8.2 sign constraints."""
out: list[tuple[float | None, float | None]] = []
for b in bases:
direction = constraints.get(b.feature, 0)
rule = _PLUS_ONE.get(b.kind)
if direction == 0 or rule is None: # unconstrained, categorical, or missing
if direction != 0 and b.kind not in ("category", "missing_indicator"):
logger.warning(
"basis kind %r on constrained feature %r is not in the "
"monotonicity sign table; leaving its coefficient unconstrained — "
"monotonicity is no longer guaranteed",
b.kind,
b.feature,
)
out.append(_FREE)
elif direction == 1:
out.append(rule)
else: # -1: mirror the +1 bound by swapping the tuple
out.append((rule[1], rule[0]))
return out
|
Fairness
Group metrics and rule-level proxy audit. An original addition — see
Fairness.
fairness
Fairness diagnostics and rule-level proxy audit for FlagGAM.
This module is an ORIGINAL ADDITION and is not part of Zhao & Welsch
(arXiv:2605.31189); it operationalizes the paper's own Impact-Statement
warning that selected rules may encode bias or proxies for protected
attributes. Thresholds and binarization notes in docs/DECISIONS.md entry 21.
ProxyAudit
ProxyAudit(estimator: Any)
Rank fitted rule bases by association with a protected attribute.
Association is computed on the BINARIZED basis indicator z > 0: exact
for threshold/category/missing_indicator flag bases, and a documented
approximation (fires vs. does-not-fire) for the continuous hinge/trend
bases. See docs/DECISIONS.md entry 21.
Source code in src/flaggam/fairness.py
| def __init__(self, estimator: Any) -> None:
self.estimator = estimator
|
report
report(X: Any, A: Any, threshold: float = 0.2) -> DataFrame
One row per basis: feature, rule, kind, association, method, flagged.
Source code in src/flaggam/fairness.py
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119 | def report(self, X: Any, A: Any, threshold: float = 0.2) -> pd.DataFrame:
"""One row per basis: feature, rule, kind, association, method, flagged."""
Zb, bases = self._indicators(X)
a = np.asarray(A).ravel()
numeric_a = np.issubdtype(a.dtype, np.number)
rows = []
for j, basis in enumerate(bases):
z = Zb[:, j]
if numeric_a:
assoc = 0.0 if len(np.unique(z)) < 2 else abs(stats.pointbiserialr(z, a)[0])
method = "point_biserial"
else:
assoc = _cramers_v(z, a)
method = "cramers_v"
rows.append(
{
"feature": basis.feature,
"rule": basis.name,
"kind": basis.kind,
"association": assoc,
"method": method,
"flagged": assoc > threshold,
}
)
return (
pd.DataFrame(rows)
.sort_values("association", ascending=False, kind="stable")
.reset_index(drop=True)
)
|
drop_proxies
drop_proxies(X: Any, y: Any, A: Any, threshold: float = 0.2) -> tuple[Any, DataFrame]
Refit the head without flagged bases; return (new_estimator, trade-off row).
Supports only fitted binary classifiers with representation="full"
and head="additive": compact-score columns don't map 1:1 to bases
and flexible heads can't be refit column-wise; multiclass is out of
PD scope. Estimators fitted with monotonic_constraints are also
rejected, since the head refit would discard the constraints
(see docs/DECISIONS.md entry 21). y must be numeric 0/1, matching
the group_metrics contract used for the before/after trade-off.
Source code in src/flaggam/fairness.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178 | def drop_proxies(
self, X: Any, y: Any, A: Any, threshold: float = 0.2
) -> tuple[Any, pd.DataFrame]:
"""Refit the head without flagged bases; return (new_estimator, trade-off row).
Supports only fitted binary classifiers with `representation="full"`
and `head="additive"`: compact-score columns don't map 1:1 to bases
and flexible heads can't be refit column-wise; multiclass is out of
PD scope. Estimators fitted with `monotonic_constraints` are also
rejected, since the head refit would discard the constraints
(see docs/DECISIONS.md entry 21). `y` must be numeric 0/1, matching
the `group_metrics` contract used for the before/after trade-off.
"""
est = self.estimator
n_classes = len(getattr(est, "classes_", []))
if not (n_classes == 2 and est.representation == "full" and est.head == "additive"):
raise ValueError(
"drop_proxies requires a fitted binary classifier with "
"representation='full' and head='additive'"
)
if est.monotonic_constraints is not None:
raise ValueError(
"drop_proxies does not support monotonic-constrained estimators "
"(the head refit would discard the constraints)"
)
report = self.report(X, A, threshold=threshold)
flagged_rules = set(report.loc[report.flagged, "rule"])
y_arr = np.asarray(y).ravel()
p_before = est.predict_proba(X)[:, 1]
new_est = copy.deepcopy(est)
new_est.core_.bases_ = [b for b in est.core_.bases_ if b.name not in flagged_rules]
df = new_est._to_frame(X, reset=False)
Z = new_est.core_.transform(df)
y_enc = new_est.label_encoder_.transform(y_arr)
new_est.head_ = AdditiveHead(
"binary", C=new_est.C, random_state=new_est.random_state
).fit(Z, y_enc)
p_after = new_est.predict_proba(X)[:, 1]
trade = pd.DataFrame(
[
{
"n_dropped": int(len(est.core_.bases_) - len(new_est.core_.bases_)),
"auroc_before": float(roc_auc_score(y_arr, p_before)),
"auroc_after": float(roc_auc_score(y_arr, p_after)),
"dp_diff_before": group_metrics(y_arr, p_before, A)["gaps"][
"demographic_parity_diff"
],
"dp_diff_after": group_metrics(y_arr, p_after, A)["gaps"][
"demographic_parity_diff"
],
}
]
)
if not flagged_rules:
logger.info("drop_proxies: nothing flagged at threshold %.3f", threshold)
return new_est, trade
|
group_metrics
group_metrics(y_true: Any, y_prob: Any, A: Any, threshold: float = 0.5, n_bins: int = 10) -> dict[str, Any]
Per-group PD metrics and max-minus-min gaps for protected attribute A.
Source code in src/flaggam/fairness.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60 | def group_metrics(
y_true: Any, y_prob: Any, A: Any, threshold: float = 0.5, n_bins: int = 10
) -> dict[str, Any]:
"""Per-group PD metrics and max-minus-min gaps for protected attribute A."""
y = np.asarray(y_true).ravel()
p = np.asarray(y_prob, dtype=float).ravel()
a = np.asarray(A).ravel()
if set(np.unique(y)) - {0, 1}:
raise ValueError("group_metrics supports binary y_true only")
rows = {}
for g in pd.unique(a):
m = a == g
yg, pg = y[m], p[m]
sel = (pg >= threshold).astype(int)
pos = yg == 1
single_class = len(np.unique(yg)) < 2
rows[g] = {
"n": int(m.sum()),
"base_rate": float(yg.mean()),
"mean_predicted": float(pg.mean()),
"selection_rate": float(sel.mean()),
"tpr": float(sel[pos].mean()) if pos.any() else np.nan,
"auroc": np.nan if single_class else float(roc_auc_score(yg, pg)),
"ece": expected_calibration_error(yg, pg, n_bins=n_bins),
}
by_group = pd.DataFrame.from_dict(rows, orient="index")
def _gap(col: str) -> float:
vals = by_group[col].dropna()
return float(vals.max() - vals.min()) if len(vals) > 1 else np.nan
gaps = {
"demographic_parity_diff": _gap("selection_rate"),
"equal_opportunity_diff": _gap("tpr"),
"auroc_gap": _gap("auroc"),
}
return {"by_group": by_group, "gaps": gaps}
|
Datasets
Benchmark dataset loaders with local caching.
datasets
Benchmark dataset loaders with local caching and license notes.
Each loader returns (X, y): X a DataFrame whose categorical features are
pd.Categorical dtype, y a Series (binary targets are int 0/1 with the
"event"/positive class = 1). Data is fetched at runtime and cached under
data_dir(); raw files are never committed to the repository. Verify each
dataset's license on its source page before redistributing anything.
data_dir
Cache directory: $FLAGGAM_DATA_DIR or ~/.cache/flaggam (created).
Source code in src/flaggam/datasets.py
| def data_dir() -> Path:
"""Cache directory: $FLAGGAM_DATA_DIR or ~/.cache/flaggam (created)."""
d = Path(os.environ.get("FLAGGAM_DATA_DIR", Path.home() / ".cache" / "flaggam"))
d.mkdir(parents=True, exist_ok=True)
return d
|
load_breast_cancer
load_breast_cancer() -> tuple[DataFrame, Series]
Wisconsin Diagnostic Breast Cancer (569 x 30, binary; positive=malignant).
Source: https://archive.ics.uci.edu/dataset/17 (bundled via scikit-learn).
License: CC BY 4.0 per UCI page — verify before redistribution.
Source code in src/flaggam/datasets.py
52
53
54
55
56
57
58
59
60
61
62
63 | def load_breast_cancer() -> tuple[pd.DataFrame, pd.Series]:
"""Wisconsin Diagnostic Breast Cancer (569 x 30, binary; positive=malignant).
Source: https://archive.ics.uci.edu/dataset/17 (bundled via scikit-learn).
License: CC BY 4.0 per UCI page — verify before redistribution.
"""
from sklearn.datasets import load_breast_cancer as _load
b = _load(as_frame=True)
X = b.data.astype(float)
y = pd.Series((b.target == 0).astype(int), name="malignant") # sklearn: 0=malignant
return X, y
|
load_california
load_california() -> tuple[DataFrame, Series]
California Housing (20640 x 8, regression on median house value).
Source: https://scikit-learn.org/stable/datasets/real_world.html#california-housing-dataset
License: public domain (US Census derived) — verify before redistribution.
Source code in src/flaggam/datasets.py
66
67
68
69
70
71
72
73
74
75 | def load_california() -> tuple[pd.DataFrame, pd.Series]:
"""California Housing (20640 x 8, regression on median house value).
Source: https://scikit-learn.org/stable/datasets/real_world.html#california-housing-dataset
License: public domain (US Census derived) — verify before redistribution.
"""
from sklearn.datasets import fetch_california_housing
b = fetch_california_housing(as_frame=True, data_home=str(data_dir()))
return b.data.astype(float), b.target.rename("median_house_value")
|
load_pima
load_pima() -> tuple[DataFrame, Series]
Pima Indians Diabetes (768 x 8, binary; positive=diabetic).
Source: https://www.openml.org/d/37 (Pima Indians Diabetes, dataset id=37).
License: CC0 per OpenML page — verify before redistribution.
Observed variant: OpenML dataset 37 (pima_diabetes), 768 rows, 8 features.
Short attribute names renamed: plas→glucose, pres→blood_pressure,
skin→skin_thickness, insu→insulin, mass→bmi, pedi→diabetes_pedigree.
DECISIONS 17: Physiologically impossible zeros (glucose, blood_pressure,
skin_thickness, insulin, bmi) are replaced with NaN per clinical convention
and FlagGAM's native-missing design.
Source code in src/flaggam/datasets.py
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125 | def load_pima() -> tuple[pd.DataFrame, pd.Series]:
"""Pima Indians Diabetes (768 x 8, binary; positive=diabetic).
Source: https://www.openml.org/d/37 (Pima Indians Diabetes, dataset id=37).
License: CC0 per OpenML page — verify before redistribution.
Observed variant: OpenML dataset 37 (pima_diabetes), 768 rows, 8 features.
Short attribute names renamed: plas→glucose, pres→blood_pressure,
skin→skin_thickness, insu→insulin, mass→bmi, pedi→diabetes_pedigree.
DECISIONS 17: Physiologically impossible zeros (glucose, blood_pressure,
skin_thickness, insulin, bmi) are replaced with NaN per clinical convention
and FlagGAM's native-missing design.
"""
def _fetch() -> pd.DataFrame:
import openml
dataset = openml.datasets.get_dataset(37)
X_raw, y_raw, _, _ = dataset.get_data(target="class")
assert isinstance(X_raw, pd.DataFrame)
_rename = {
"plas": "glucose",
"pres": "blood_pressure",
"skin": "skin_thickness",
"insu": "insulin",
"mass": "bmi",
"pedi": "diabetes_pedigree",
}
X_raw = X_raw.rename(columns={k: v for k, v in _rename.items() if k in X_raw.columns})
X_raw["_target"] = y_raw
return X_raw
df = _cached("pima", _fetch)
y = (df["_target"] == "tested_positive").astype(int)
y.name = "diabetic"
X = df.drop("_target", axis=1).copy()
_zero_cols = ["glucose", "blood_pressure", "skin_thickness", "insulin", "bmi"]
for col in _zero_cols:
if col in X.columns:
X[col] = X[col].replace(0.0, float("nan"))
return X, y
|
load_heart
load_heart() -> tuple[DataFrame, Series]
Heart Disease Cleveland (303 x 13, binary; positive=disease present).
Source: https://archive.ics.uci.edu/dataset/45 (Heart Disease, Cleveland).
License: CC BY 4.0 — verify before redistribution. Target: num > 0 → 1.
Observed variant: UCI id=45, Cleveland 303 rows, 13 features (all numeric).
ca/thal have missing values (kept as NaN).
Categorical features (originally coded as integers): cp, restecg, slope,
thal, sex, fbs, exang.
Source code in src/flaggam/datasets.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158 | def load_heart() -> tuple[pd.DataFrame, pd.Series]:
"""Heart Disease Cleveland (303 x 13, binary; positive=disease present).
Source: https://archive.ics.uci.edu/dataset/45 (Heart Disease, Cleveland).
License: CC BY 4.0 — verify before redistribution. Target: num > 0 → 1.
Observed variant: UCI id=45, Cleveland 303 rows, 13 features (all numeric).
ca/thal have missing values (kept as NaN).
Categorical features (originally coded as integers): cp, restecg, slope,
thal, sex, fbs, exang.
"""
def _fetch() -> pd.DataFrame:
from ucimlrepo import fetch_ucirepo
dataset = fetch_ucirepo(id=45)
X = dataset.data.features.copy()
y = dataset.data.targets.copy()
X["_target"] = y.iloc[:, 0]
return X
df = _cached("heart", _fetch)
y = (df["_target"] > 0).astype(int)
y.name = "heart_disease"
X = df.drop("_target", axis=1).copy()
_cat_cols = ["cp", "restecg", "slope", "thal", "sex", "fbs", "exang"]
for col in _cat_cols:
if col in X.columns:
X[col] = pd.Categorical(X[col])
return X, y
|
load_german_credit
load_german_credit() -> tuple[DataFrame, Series]
Statlog German Credit (1000 x 20, binary; positive=bad credit).
Source: https://archive.ics.uci.edu/dataset/144 (Statlog German Credit).
License: CC BY 4.0 — verify before redistribution. Target: bad credit = 1.
Observed variant: UCI id=144, 1000 rows, 20 features (Attribute* columns).
Target: class==2 → bad credit=1 (per UCI docs, 1=good, 2=bad).
Source code in src/flaggam/datasets.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187 | def load_german_credit() -> tuple[pd.DataFrame, pd.Series]:
"""Statlog German Credit (1000 x 20, binary; positive=bad credit).
Source: https://archive.ics.uci.edu/dataset/144 (Statlog German Credit).
License: CC BY 4.0 — verify before redistribution. Target: bad credit = 1.
Observed variant: UCI id=144, 1000 rows, 20 features (Attribute* columns).
Target: class==2 → bad credit=1 (per UCI docs, 1=good, 2=bad).
"""
def _fetch() -> pd.DataFrame:
from ucimlrepo import fetch_ucirepo
dataset = fetch_ucirepo(id=144)
X = dataset.data.features.copy()
y = dataset.data.targets.copy()
X["_target"] = y.iloc[:, 0]
return X
df = _cached("german_credit", _fetch)
y = (df["_target"] == 2).astype(int)
y.name = "bad_credit"
X = df.drop("_target", axis=1).copy()
for col in X.select_dtypes(include=["object", "string"]).columns:
X[col] = pd.Categorical(X[col])
return X, y
|
load_adult
load_adult() -> tuple[DataFrame, Series]
Adult / Census Income (~48842 rows, binary; positive=income >50K).
Source: https://archive.ics.uci.edu/dataset/2 (Adult / Census Income).
License: CC BY 4.0 — verify before redistribution. Target: income >50K = 1.
Observed variant: UCI id=2, 48842 rows, 14 features.
Whitespace stripped; '?' replaced with NaN; string cols → pd.Categorical.
Source code in src/flaggam/datasets.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223 | def load_adult() -> tuple[pd.DataFrame, pd.Series]:
"""Adult / Census Income (~48842 rows, binary; positive=income >50K).
Source: https://archive.ics.uci.edu/dataset/2 (Adult / Census Income).
License: CC BY 4.0 — verify before redistribution. Target: income >50K = 1.
Observed variant: UCI id=2, 48842 rows, 14 features.
Whitespace stripped; '?' replaced with NaN; string cols → pd.Categorical.
"""
def _fetch() -> pd.DataFrame:
from ucimlrepo import fetch_ucirepo
dataset = fetch_ucirepo(id=2)
X = dataset.data.features.copy()
y = dataset.data.targets.copy()
for col in X.select_dtypes(include=["object", "string"]).columns:
X[col] = X[col].str.strip()
y_col = y.iloc[:, 0]
X["_target"] = y_col.str.strip() if hasattr(y_col, "str") else y_col
return X
df = _cached("adult", _fetch)
df = df.dropna(subset=["_target"])
y_raw = df["_target"] # already stripped pre-cache
X = df.drop("_target", axis=1).copy()
for col in X.select_dtypes(include=["object", "string"]).columns:
X[col] = X[col].replace("?", float("nan"))
X[col] = pd.Categorical(X[col])
y = y_raw.str.contains(">50K", na=False).astype(int)
y.name = "high_income"
return X, y
|
load_bank_marketing
load_bank_marketing() -> tuple[DataFrame, Series]
Bank Marketing — bank-additional-full variant (41188 rows, binary; positive=subscribed).
Source: https://www.openml.org/d/42813 (OpenML id=42813, bank-additional-full variant).
License: CC BY 4.0 — verify before redistribution.
Observed variant: bank-additional-full (spec §9), 41188 rows, 21 original features
including socioeconomic columns emp.var.rate, cons.price.idx, cons.conf.idx,
euribor3m, nr.employed. After dropping 'duration' → 19 feature columns + y.
The post-call 'duration' column is always dropped (UCI recommendation, spec §9).
'unknown' is kept as a regular category level (FlagGAM/UFA design: treat
as explicit category, not NaN, to preserve structure of missingness).
Source code in src/flaggam/datasets.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283 | def load_bank_marketing() -> tuple[pd.DataFrame, pd.Series]:
"""Bank Marketing — bank-additional-full variant (41188 rows, binary; positive=subscribed).
Source: https://www.openml.org/d/42813 (OpenML id=42813, bank-additional-full variant).
License: CC BY 4.0 — verify before redistribution.
Observed variant: bank-additional-full (spec §9), 41188 rows, 21 original features
including socioeconomic columns emp.var.rate, cons.price.idx, cons.conf.idx,
euribor3m, nr.employed. After dropping 'duration' → 19 feature columns + y.
The post-call 'duration' column is always dropped (UCI recommendation, spec §9).
'unknown' is kept as a regular category level (FlagGAM/UFA design: treat
as explicit category, not NaN, to preserve structure of missingness).
"""
# invalidate stale bank-full cache (UCI id=222, 45211 rows)
_stale_path = data_dir() / "bank_marketing.parquet"
if _stale_path.exists():
try:
_stale_df = pd.read_parquet(_stale_path)
if len(_stale_df) != 41188:
_stale_path.unlink()
logger.info(
"deleted stale bank_marketing.parquet (%d rows, expected 41188)",
len(_stale_df),
)
except Exception:
_stale_path.unlink()
logger.info("deleted unreadable bank_marketing.parquet")
def _fetch() -> pd.DataFrame:
import openml
# OpenML id=42813 is 'bankmarketing' — bank-additional-full (41188×20+target).
# id=44234 is bank-full (45211 rows); id=1461 is also bank-full.
dataset = openml.datasets.get_dataset(42813)
X_raw, y_raw, _, _ = dataset.get_data(target=dataset.default_target_attribute or "y")
assert isinstance(X_raw, pd.DataFrame)
if X_raw.shape[0] != 41188 or "euribor3m" not in X_raw.columns:
dl = openml.datasets.list_datasets(
data_name="bankmarketing", output_format="dataframe"
)
did = int(dl[dl["NumberOfInstances"] == 41188].index[0])
dataset = openml.datasets.get_dataset(did)
X_raw, y_raw, _, _ = dataset.get_data(target=dataset.default_target_attribute or "y")
assert isinstance(X_raw, pd.DataFrame)
assert "euribor3m" in X_raw.columns, "bank-additional-full variant not found"
X_raw.drop(columns=["duration"], errors="ignore", inplace=True)
X_raw["_target"] = y_raw
return X_raw
df = _cached("bank_marketing", _fetch)
y = (df["_target"] == "yes").astype(int)
y.name = "subscribed"
X = df.drop("_target", axis=1).copy()
for col in X.select_dtypes(include=["object", "string"]).columns:
X[col] = pd.Categorical(X[col])
return X, y
|
load_ames
load_ames() -> tuple[DataFrame, Series]
Ames Housing (2930 rows, regression; target = log(Sale_Price)).
Source: https://www.openml.org/search?type=data&q=ames+housing (Ames Housing).
License: public domain (De Cock, 2011) — verify before redistribution.
Observed variant: OpenML 'ames_housing' version 1 (id=43926), 2930 rows,
80 features. OpenML returns features already as ordered CategoricalDtype.
Target is log(Sale_Price); RMSE is reported on the log scale.
Source code in src/flaggam/datasets.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319 | def load_ames() -> tuple[pd.DataFrame, pd.Series]:
"""Ames Housing (2930 rows, regression; target = log(Sale_Price)).
Source: https://www.openml.org/search?type=data&q=ames+housing (Ames Housing).
License: public domain (De Cock, 2011) — verify before redistribution.
Observed variant: OpenML 'ames_housing' version 1 (id=43926), 2930 rows,
80 features. OpenML returns features already as ordered CategoricalDtype.
Target is log(Sale_Price); RMSE is reported on the log scale.
"""
def _fetch() -> pd.DataFrame:
import openml
try:
dataset = openml.datasets.get_dataset("ames_housing", version=1)
except (openml.exceptions.OpenMLServerException, ValueError, KeyError):
dl = openml.datasets.list_datasets(data_name="ames_housing", output_format="dataframe")
did = int(dl.index[0]) if hasattr(dl, "index") else next(iter(dl))
dataset = openml.datasets.get_dataset(did)
target_col = dataset.default_target_attribute or "Sale_Price"
X_raw, y_raw, _, _ = dataset.get_data(target=target_col)
assert isinstance(X_raw, pd.DataFrame)
X_raw["_target"] = y_raw
return X_raw
df = _cached("ames", _fetch)
y = np.log(df["_target"].astype(float)).rename("log_sale_price")
X = df.drop("_target", axis=1).copy()
for col in X.select_dtypes(include=["object", "string"]).columns:
X[col] = pd.Categorical(X[col])
return X, y
|
load_wine_white
load_wine_white() -> tuple[DataFrame, Series]
Wine Quality white subset (4898 x 11, regression on quality score).
Source: https://archive.ics.uci.edu/dataset/186 (Wine Quality, white subset).
License: CC BY 4.0 — verify before redistribution.
Observed variant: UCI id=186 (combined red+white, 6497 rows); 'color' column
found in ds.data.original — filtered to white wines → 4898 rows, 11 features.
All features are float; no categorical columns.
Source code in src/flaggam/datasets.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348 | def load_wine_white() -> tuple[pd.DataFrame, pd.Series]:
"""Wine Quality white subset (4898 x 11, regression on quality score).
Source: https://archive.ics.uci.edu/dataset/186 (Wine Quality, white subset).
License: CC BY 4.0 — verify before redistribution.
Observed variant: UCI id=186 (combined red+white, 6497 rows); 'color' column
found in ds.data.original — filtered to white wines → 4898 rows, 11 features.
All features are float; no categorical columns.
"""
def _fetch() -> pd.DataFrame:
from ucimlrepo import fetch_ucirepo
dataset = fetch_ucirepo(id=186)
orig = dataset.data.original.copy()
if "color" in orig.columns:
orig = orig[orig["color"] == "white"].copy()
orig.drop(columns=["color"], inplace=True)
# orig now has 11 feature cols + quality (target)
return orig
df = _cached("wine_white", _fetch)
y = df["quality"].astype(float)
y.name = "quality"
X = df.drop("quality", axis=1).copy()
return X, y
|
Plots
Matplotlib visualization helpers (optional viz extra).
plots
Matplotlib visualization helpers for fitted FlagGAM estimators and diagnostics.
This module is an ORIGINAL ADDITION and is not part of Zhao & Welsch
(arXiv:2605.31189): the paper specifies no plotting API. matplotlib is an
OPTIONAL dependency (pip install flaggam[viz]); importing this module never
imports matplotlib eagerly. Every function calls _plt() first, which raises
a helpful ImportError if matplotlib is not installed, so import flaggam
and import flaggam.plots both work without matplotlib present.
plot_shape
plot_shape(estimator: Any, feature: str, ax: Axes | None = None, grid_points: int = 200) -> Axes
Plot the fitted additive contribution for one feature.
Numeric features get a step curve of contribution vs. value over the
range spanned by that feature's discovered cutoffs (padded 10%), plus a
rug of the cutoffs themselves. Categorical features get one bar per
discovered level, height equal to that level's coefficient. Requires
representation='full' (the compact head's coefficients are per-class
scores, not per-basis weights).
Source code in src/flaggam/plots.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99 | def plot_shape(
estimator: Any, feature: str, ax: "Axes | None" = None, grid_points: int = 200
) -> "Axes":
"""Plot the fitted additive contribution for one feature.
Numeric features get a step curve of contribution vs. value over the
range spanned by that feature's discovered cutoffs (padded 10%), plus a
rug of the cutoffs themselves. Categorical features get one bar per
discovered level, height equal to that level's coefficient. Requires
`representation='full'` (the compact head's coefficients are per-class
scores, not per-basis weights).
"""
plt = _plt()
if getattr(estimator, "representation", "full") == "compact":
raise ValueError(
"plot_shape requires representation='full'; under 'compact' the head "
"coefficients are per-class scores, not per-rule weights"
)
if hasattr(estimator, "classes_") and len(estimator.classes_) > 2:
raise ValueError("plot_shape supports binary classification and regression only")
if not isinstance(estimator.head_, _ADDITIVE_HEADS):
raise ValueError("plot_shape requires the additive head")
bases = estimator.core_.bases_
coef = np.ravel(estimator.head_.coef_)
feat_bases = [(j, b) for j, b in enumerate(bases) if b.feature == feature]
if not feat_bases:
available = sorted({b.feature for b in bases})
raise ValueError(
f"no bases discovered for feature {feature!r}; features with bases: {available}"
)
y_label = "contribution (log-odds)" if hasattr(estimator, "classes_") else "contribution"
if ax is None:
_, ax = plt.subplots()
if feature in estimator.core_.categorical_features_:
labels = [str(getattr(b, "level", b.name)) for _, b in feat_bases]
heights = [coef[j] for j, _ in feat_bases]
ax.bar(labels, heights)
else:
anchors = [
v
for _, b in feat_bases
for v in (getattr(b, "cutoff", None), getattr(b, "mean", None))
if v is not None
]
if not anchors:
raise ValueError(
f"feature {feature!r} has no threshold/hinge/trend bases to plot a shape for"
)
lo, hi = min(anchors), max(anchors)
pad = 0.1 * (hi - lo) if hi > lo else max(abs(lo), 1.0)
grid = np.linspace(lo - pad, hi + pad, grid_points)
contribution = np.zeros_like(grid)
for j, b in feat_bases:
contribution += coef[j] * b.transform(grid)
ax.step(grid, contribution, where="post")
rug_y = ax.get_ylim()[0]
ax.plot(anchors, [rug_y] * len(anchors), "|", color="black", markersize=12)
ax.set_xlabel(feature)
ax.set_ylabel(y_label)
ax.set_title(f"{feature} shape function")
return ax
|
plot_rule_importance
plot_rule_importance(estimator: Any, top_n: int = 20, ax: Axes | None = None) -> Axes
Horizontal bar chart of the top top_n rules by |weight| from export_rules().
Source code in src/flaggam/plots.py
102
103
104
105
106
107
108
109
110
111
112
113
114 | def plot_rule_importance(estimator: Any, top_n: int = 20, ax: "Axes | None" = None) -> "Axes":
"""Horizontal bar chart of the top `top_n` rules by |weight| from `export_rules()`."""
plt = _plt()
if not isinstance(estimator.head_, _ADDITIVE_HEADS):
raise ValueError("plot_rule_importance requires the additive head")
rules = estimator.export_rules()
top = rules.reindex(rules["weight"].abs().sort_values(ascending=False).index).head(top_n)
if ax is None:
_, ax = plt.subplots()
ax.barh(top["rule"].iloc[::-1], top["weight"].iloc[::-1])
ax.set_xlabel("weight")
ax.set_title("Rule importance")
return ax
|
plot_waterfall
plot_waterfall(estimator: Any, x_row: Any, ax: Axes | None = None, max_rules: int = 15) -> Axes
Cumulative horizontal bars from intercept to total score for one row.
Rules are sorted by |contribution| and collapsed beyond max_rules into
a single "(other rules)" bucket; the final bar marks the total score.
Source code in src/flaggam/plots.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164 | def plot_waterfall(
estimator: Any, x_row: Any, ax: "Axes | None" = None, max_rules: int = 15
) -> "Axes":
"""Cumulative horizontal bars from intercept to total score for one row.
Rules are sorted by |contribution| and collapsed beyond `max_rules` into
a single "(other rules)" bucket; the final bar marks the total score.
"""
plt = _plt()
exp = estimator.explain(x_row)
row0 = exp[exp["row"] == 0]
intercept = float(row0.loc[row0["feature"] == "<intercept>", "contribution"].sum())
rules = row0[row0["feature"] != "<intercept>"]
rules = rules.reindex(rules["contribution"].abs().sort_values(ascending=False).index)
if len(rules) > max_rules:
head, tail = rules.iloc[:max_rules], rules.iloc[max_rules:]
names = [*head["rule"], "(other rules)"]
contributions = [*head["contribution"].astype(float), float(tail["contribution"].sum())]
else:
names = list(rules["rule"])
contributions = list(rules["contribution"].astype(float))
labels = ["<intercept>", *names, "total"]
values = [intercept, *contributions]
running = 0.0
lefts: list[float] = []
widths: list[float] = []
for v in values:
lefts.append(min(running, running + v))
widths.append(abs(v))
running += v
total = running
lefts.append(min(0.0, total))
widths.append(abs(total))
if ax is None:
_, ax = plt.subplots()
y_pos = np.arange(len(labels))
colors = (
["tab:blue"] + ["tab:green" if v >= 0 else "tab:red" for v in values[1:]] + ["black"]
)
ax.barh(y_pos, widths, left=lefts, color=colors)
ax.set_yticks(y_pos)
ax.set_yticklabels(labels)
ax.invert_yaxis()
ax.set_xlabel("contribution")
ax.set_title(f"Waterfall (total = {total:.3f})")
return ax
|
plot_reliability
plot_reliability(y_true: Any, y_prob: Any, n_bins: int = 10, strategy: str = 'uniform', ax: Axes | None = None) -> Axes
Reliability diagram: mean predicted vs. observed rate, with per-bin counts.
Source code in src/flaggam/plots.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190 | def plot_reliability(
y_true: Any,
y_prob: Any,
n_bins: int = 10,
strategy: str = "uniform",
ax: "Axes | None" = None,
) -> "Axes":
"""Reliability diagram: mean predicted vs. observed rate, with per-bin counts."""
plt = _plt()
table = reliability_curve(y_true, y_prob, n_bins=n_bins, strategy=strategy)
if ax is None:
_, ax = plt.subplots()
ax.plot([0.0, 1.0], [0.0, 1.0], linestyle="--", color="gray", label="perfectly calibrated")
ax.plot(table["mean_predicted"], table["fraction_positive"], marker="o", label="model")
count_ax = ax.twinx()
count_ax.bar(
table["mean_predicted"], table["count"], width=1.0 / n_bins, alpha=0.2, color="gray"
)
count_ax.set_ylabel("count")
ax.set_xlabel("mean predicted probability")
ax.set_ylabel("fraction of positives")
ax.set_title("Reliability diagram")
ax.legend()
return ax
|
plot_proxy_association
plot_proxy_association(report: DataFrame, top_n: int = 20, ax: Axes | None = None) -> Axes
Horizontal bars of rule-level proxy association, flagged rules highlighted.
Source code in src/flaggam/plots.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210 | def plot_proxy_association(
report: pd.DataFrame, top_n: int = 20, ax: "Axes | None" = None
) -> "Axes":
"""Horizontal bars of rule-level proxy association, flagged rules highlighted."""
plt = _plt()
top = report.reindex(report["association"].sort_values(ascending=False).index).head(top_n)
if ax is None:
_, ax = plt.subplots()
colors = ["tab:red" if f else "tab:blue" for f in top["flagged"]]
ax.barh(top["rule"].iloc[::-1], top["association"].iloc[::-1], color=list(reversed(colors)))
if top["flagged"].any():
# Exact audit threshold isn't carried in `report`; approximate the
# boundary with the smallest association among the flagged rows.
line_x = float(top.loc[top["flagged"], "association"].min())
ax.axvline(line_x, linestyle="--", color="black")
ax.set_xlabel("association")
ax.set_title("Proxy association by rule")
return ax
|
plot_group_metrics
plot_group_metrics(metrics: dict[str, Any], ax: Axes | None = None) -> Axes
Grouped bar chart of selection_rate/tpr/auroc per protected-attribute group.
Source code in src/flaggam/plots.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233 | def plot_group_metrics(metrics: dict[str, Any], ax: "Axes | None" = None) -> "Axes":
"""Grouped bar chart of selection_rate/tpr/auroc per protected-attribute group."""
plt = _plt()
by = metrics["by_group"][["selection_rate", "tpr", "auroc"]]
if ax is None:
_, ax = plt.subplots()
groups = list(by.index)
cols = list(by.columns)
n_metrics = len(cols)
x = np.arange(len(groups))
width = 0.8 / n_metrics
for j, col in enumerate(cols):
ax.bar(x + j * width, by[col].to_numpy(dtype=float), width, label=col)
ax.set_xticks(x + width * (n_metrics - 1) / 2)
ax.set_xticklabels([str(g) for g in groups])
gap_str = ", ".join(
f"{k}={v:.3f}" if not np.isnan(v) else f"{k}=nan" for k, v in metrics["gaps"].items()
)
ax.set_title(f"Group metrics (gaps: {gap_str})")
ax.legend()
return ax
|
Explorer
Self-contained interactive HTML rules explorer.
explorer
Self-contained interactive HTML rules explorer for fitted FlagGAM estimators.
This module is an ORIGINAL ADDITION (not part of Zhao & Welsch, arXiv:2605.31189):
the paper specifies no explorer/export API. export_rules_html renders a single
HTML document with inlined CSS and vanilla JS and NO external resources (no CDN,
fonts, or images), so the result is fully self-contained: it works offline and
can be embedded in an iframe (e.g. the docs site) with no network access.
export_rules_html
export_rules_html(estimator: Any, path: str | Path | None = None, title: str = 'FlagGAM rules explorer') -> str
Render a self-contained interactive HTML explorer of the fitted rules.
Requires an estimator fitted with representation='full' and the additive
head (binary classification or regression only; the compact head's
coefficients are per-class scores, not per-rule weights). Returns the
complete HTML document as a string; if path is given, also writes it
(UTF-8) and still returns the string.
Source code in src/flaggam/explorer.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133 | def export_rules_html(
estimator: Any, path: "str | Path | None" = None, title: str = "FlagGAM rules explorer"
) -> str:
"""Render a self-contained interactive HTML explorer of the fitted rules.
Requires an estimator fitted with `representation='full'` and the additive
head (binary classification or regression only; the compact head's
coefficients are per-class scores, not per-rule weights). Returns the
complete HTML document as a string; if `path` is given, also writes it
(UTF-8) and still returns the string.
"""
check_is_fitted(estimator, "core_")
if getattr(estimator, "representation", "full") == "compact":
raise ValueError(
"export_rules_html requires representation='full'; under 'compact' the head "
"coefficients are per-class scores, not per-rule weights"
)
if hasattr(estimator, "classes_") and len(estimator.classes_) > 2:
raise ValueError("export_rules_html supports binary classification and regression only")
if not isinstance(estimator.head_, _ADDITIVE_HEADS):
raise ValueError("export_rules_html requires the additive head")
payload = _build_payload(estimator, title)
data = json.dumps(payload, allow_nan=False).replace("</", "<\\/")
doc = _TEMPLATE.replace("%%DATA%%", data).replace("%%TITLE%%", html.escape(title, quote=True))
if path is not None:
Path(path).write_text(doc, encoding="utf-8")
return doc
|