Skip to content
v0.1.3latest release121 skills4 categories6 sub-categories247 tested scripts397 reference docsContact us

linear_counterfactual.py

Helper script for counterfactual-explanation-generator · 320 lines

AI & Machine Learning / Classical Machine Learningv1.0.0Updated OCT 25 20263 references3 scripts
scripts/linear_counterfactual.py
#!/usr/bin/env python3
"""Minimal-cost counterfactuals (recourse) for linear and logistic models, with actionability constraints.

Model spec (JSON):
  {"model": "logistic" | "linear", "intercept": b, "coefficients": {feature: w},
   "features": {feature: {"mutable": true, "direction": "increase" | "decrease" | "any",
                          "min": .., "max": .., "integer": false, "binary": false,
                          "scale": MAD or std (cost unit), "cost_multiplier": 1.0}}}
Row (JSON object feature -> value). Features absent from "features" are treated as immutable.

Goal: reach --target-prob (logistic; default: just across --threshold 0.5 on the other side of the current
prediction) or --target-value (linear), with --side below|above (default: towards the other class).

Cost of a change: sum over features of |delta| / scale x cost_multiplier (L1 in MAD units: sparse,
interpretable changes; Wachter et al. 2017, Ustun et al. 2019). For a linear score the problem is a
fractional knapsack: features are used in order of score change per unit cost up to their bounds, which is
optimal for continuous features; binary features are searched exhaustively (2^b subsets); for integer
features both the rounded-down and rounded-up step are explored, so integer rounding stays optimal.

Reports:
  current      score and probability
  optimal      the minimal-cost counterfactual: changes, cost, sparsity, resulting probability
  alternatives the best counterfactual without each feature of the optimal one (diverse options) and the
               best single-feature changes
               (features without which no counterfactual exists are reported as essential)
  no recourse  when no allowed change reaches the target: the best reachable probability using every allowed
               change, and the immutable features (a fairness and recourse finding)

Usage:
    python linear_counterfactual.py cf_logreg.json cf_row.json --target-prob 0.3
    python linear_counterfactual.py model.json row.json --threshold 0.5 --json
    python linear_counterfactual.py linear.json row.json --target-value 250000 --side above

Exit codes: 0 counterfactual found, 1 no feasible recourse, 2 usage or read error.
"""

import argparse
import itertools
import json
import math
import sys


def logit(p):
    return math.log(p / (1 - p))


def sigmoid(z):
    return 1 / (1 + math.exp(-z)) if z > -700 else 0.0


def bounds(name, x, cfg, want_sign, w):
    """Allowed delta range for a feature, restricted to the helpful direction."""
    if not cfg.get("mutable", True) or w == 0:
        return 0.0, 0.0
    lo = cfg.get("min", -math.inf) - x
    hi = cfg.get("max", math.inf) - x
    d = cfg.get("direction", "any")
    if d == "increase":
        lo = max(lo, 0.0)
    elif d == "decrease":
        hi = min(hi, 0.0)
    # only move in the direction that changes the score the way we want
    helpful_up = (w > 0) == (want_sign > 0)
    return (0.0, max(hi, 0.0)) if helpful_up else (min(lo, 0.0), 0.0)


def best_fill(opts, i, remaining, coefs, fcfg):
    """Cheapest way to cover `remaining` score with features opts[i:] (sorted by efficiency).

    Continuous features are taken greedily (optimal for a fractional knapsack); for an integer feature both
    the rounded-down and the rounded-up step are tried, so rounding never forces an unnecessary unit.
    Returns (cost, {feature: delta}) or None when infeasible."""
    if remaining <= 1e-12:
        return 0.0, {}
    if i >= len(opts):
        return None
    _, f, room = opts[i]
    cfg = fcfg.get(f, {})
    unit_cost = cfg.get("cost_multiplier", 1.0) / cfg.get("scale", 1.0)
    w = abs(coefs[f])
    exact = remaining / w
    steps = [min(abs(room), exact)]
    if cfg.get("integer"):
        steps = sorted({min(abs(room), math.floor(exact + 1e-9)), min(abs(room), math.ceil(exact - 1e-9))})
    best = None
    for step in steps:
        rest = best_fill(opts, i + 1, remaining - w * step, coefs, fcfg)
        if rest is None:
            continue
        c = step * unit_cost + rest[0]
        if best is None or c < best[0] - 1e-12:
            d = dict(rest[1])
            if step:
                d[f] = math.copysign(step, room)
            best = (c, d)
    return best


def solve(spec, row, need, want_sign, exclude=()):
    """Minimise cost s.t. sum w*delta moves the score by `need` (signed, in want_sign direction)."""
    coefs = spec["coefficients"]
    fcfg = spec.get("features", {})
    names = [f for f in coefs if f not in exclude]
    binaries = [f for f in names if fcfg.get(f, {}).get("binary")]
    conts = [f for f in names if f not in binaries]
    best = None
    for k in range(len(binaries) + 1):
        for subset in itertools.combinations(binaries, k):
            delta, cost, gain = {}, 0.0, 0.0
            ok = True
            for f in subset:
                cfg = fcfg.get(f, {})
                if not cfg.get("mutable", True):
                    ok = False
                    break
                x = row[f]
                nd = (1 - x) - x                          # flip 0 <-> 1
                d = cfg.get("direction", "any")
                if (d == "increase" and nd < 0) or (d == "decrease" and nd > 0):
                    ok = False
                    break
                g = coefs[f] * nd * want_sign
                if g <= 0:
                    ok = False
                    break
                delta[f] = nd
                gain += g
                cost += abs(nd) / cfg.get("scale", 1.0) * cfg.get("cost_multiplier", 1.0)
            if not ok:
                continue
            remaining = need - gain
            opts = []
            for f in conts:
                cfg = fcfg.get(f, {})
                lo, hi = bounds(f, row[f], cfg, want_sign, coefs[f])
                room = hi if hi > 0 else lo
                if room == 0:
                    continue
                per_unit_gain = abs(coefs[f])
                per_unit_cost = cfg.get("cost_multiplier", 1.0) / cfg.get("scale", 1.0)
                opts.append((per_unit_gain / per_unit_cost, f, room))
            opts.sort(reverse=True)
            fill = best_fill(opts, 0, remaining, coefs, fcfg)
            if fill is None:                              # infeasible: use every allowed move
                for _, f, room in opts:
                    delta[f] = room
                    remaining -= abs(coefs[f]) * abs(room)
                    cfg = fcfg.get(f, {})
                    cost += abs(room) / cfg.get("scale", 1.0) * cfg.get("cost_multiplier", 1.0)
            else:
                fcost, fdelta = fill
                delta.update(fdelta)
                cost += fcost
                remaining = 0.0
            feasible = remaining <= 1e-9
            cand = {"delta": {f: v for f, v in delta.items() if v != 0}, "cost": cost, "feasible": feasible,
                    "shortfall": max(0.0, remaining)}
            if best is None or (cand["feasible"], -cand["cost"]) > (best["feasible"], -best["cost"]) or \
                    (not best["feasible"] and not cand["feasible"] and cand["shortfall"] < best["shortfall"]):
                best = cand
    return best


def score(spec, values):
    return spec["intercept"] + sum(w * values[f] for f, w in spec["coefficients"].items())


def describe(spec, row, sol):
    new = dict(row)
    for f, d in sol["delta"].items():
        new[f] = row[f] + d
    z = score(spec, new)
    out = {"changes": [{"feature": f, "from": row[f], "to": round(new[f], 6), "delta": round(d, 6)}
                       for f, d in sorted(sol["delta"].items(), key=lambda kv: -abs(kv[1]))],
           "cost": sol["cost"], "sparsity": len(sol["delta"]), "feasible": sol["feasible"], "score": z}
    if spec.get("model", "logistic") == "logistic":
        out["prob"] = sigmoid(z)
    return out


def run(spec, row, a):
    for f in spec["coefficients"]:
        if f not in row:
            raise ValueError(f"row lacks feature {f!r}")
    logistic = spec.get("model", "logistic") == "logistic"
    z0 = score(spec, row)
    if logistic:
        p0 = sigmoid(z0)
        side = a.side or ("below" if p0 >= a.threshold else "above")
        target = a.target_prob if a.target_prob is not None else a.threshold
        if not 0 < target < 1:
            raise ValueError("--target-prob must be in (0, 1)")
        zt = logit(target)
    else:
        if a.target_value is None:
            raise ValueError("linear models need --target-value")
        side = a.side or "above"
        zt = a.target_value
    want_sign = -1 if side == "below" else 1
    need = (zt - z0) * want_sign
    res = {"current": {"score": z0, **({"prob": sigmoid(z0)} if logistic else {})}, "side": side,
           "target": a.target_prob if logistic else a.target_value}
    if logistic and a.target_prob is None:
        res["target"] = a.threshold
    if need <= 0:
        res["already"] = True
        return res
    need += a.margin
    opt = solve(spec, row, need, want_sign)
    res["optimal"] = describe(spec, row, opt)
    fcfg = spec.get("features", {})
    if not opt["feasible"]:
        immut = [f for f in spec["coefficients"] if f not in fcfg or not fcfg[f].get("mutable", True)]
        res["no_recourse"] = {"immutable": immut, "shortfall_score": opt["shortfall"]}
        return res
    alts, without_none = [], []
    for f in res["optimal"]["changes"]:
        s = solve(spec, row, need, want_sign, exclude=(f["feature"],))
        if s and s["feasible"]:
            alts.append({"without": f["feature"], **describe(spec, row, s)})
        else:
            without_none.append(f["feature"])
    res["essential"] = without_none                   # features every counterfactual must change
    singles = []
    for f in spec["coefficients"]:
        others = tuple(g for g in spec["coefficients"] if g != f)
        s = solve(spec, row, need, want_sign, exclude=others)
        if s and s["feasible"]:
            singles.append(describe(spec, row, s))
    singles.sort(key=lambda d: d["cost"])
    res["alternatives"], res["single_feature"] = alts, singles
    return res


def fmt(v):
    return f"{v:.4g}" if isinstance(v, float) else str(v)


def show(cf, logistic):
    lines = []
    for c in cf["changes"]:
        lines.append(f"    {c['feature']}: {fmt(c['from'])} -> {fmt(c['to'])} ({c['delta']:+.4g})")
    tail = f"cost {cf['cost']:.3f} (MAD units), {cf['sparsity']} feature(s) changed"
    tail += f", new probability {cf['prob']:.1%}" if logistic else f", new prediction {cf['score']:.4g}"
    return lines + ["    " + tail]


def report(res, logistic):
    cur = res["current"]
    lines = [("current probability: " + f"{cur['prob']:.1%}" if logistic else f"current prediction: {cur['score']:.4g}")
             + f"   target: {res['side']} {res['target']}"]
    if res.get("already"):
        lines.append("the row already meets the target: no change needed")
        return "\n".join(lines)
    opt = res["optimal"]
    if res.get("no_recourse"):
        lines.append("NO RECOURSE: no combination of allowed changes reaches the target")
        best = f"{opt['prob']:.1%}" if logistic else f"{opt['score']:.4g}"
        lines.append(f"  best reachable: {best}, using every allowed change:")
        lines += show(opt, logistic)[:-1]
        imm = res["no_recourse"]["immutable"]
        lines.append("  the rest of the score is fixed by the intercept and immutable features"
                     + (f" ({', '.join(imm)})" if imm else "")
                     + ": review whether a decision without recourse is acceptable here")
        return "\n".join(lines)
    lines.append("")
    lines.append("OPTIMAL (minimal cost):")
    lines += show(opt, logistic)
    for f in res.get("essential", []):
        lines.append(f"  no feasible counterfactual without changing {f}")
    if res["alternatives"]:
        lines.append("")
        lines.append("ALTERNATIVES (diverse options):")
        for alt in res["alternatives"]:
            lines.append(f"  without {alt['without']}:")
            lines += show(alt, logistic)
    if res["single_feature"]:
        lines.append("")
        lines.append("SINGLE-FEATURE options:")
        for s in res["single_feature"][:5]:
            lines += show(s, logistic)
    lines.append("")
    lines.append("These are changes to the model's inputs that change its output; they are recourse options only if "
                 "the features are causally actionable in the real world.")
    return "\n".join(lines)


def main():
    ap = argparse.ArgumentParser(description="Minimal-cost counterfactuals for linear models.", epilog=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("model_json")
    ap.add_argument("row_json")
    ap.add_argument("--target-prob", type=float)
    ap.add_argument("--target-value", type=float)
    ap.add_argument("--threshold", type=float, default=0.5)
    ap.add_argument("--side", choices=["below", "above"])
    ap.add_argument("--margin", type=float, default=1e-6, help="extra score change beyond the target")
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args()
    try:
        with open(a.model_json, encoding="utf-8") as f:
            spec = json.load(f)
        with open(a.row_json, encoding="utf-8") as f:
            row = json.load(f)
        if "coefficients" not in spec or "intercept" not in spec:
            raise ValueError("model spec needs intercept and coefficients")
        row = {k: float(v) for k, v in row.items()}
        res = run(spec, row, a)
    except (OSError, ValueError, json.JSONDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    logistic = spec.get("model", "logistic") == "logistic"
    print(json.dumps(res, indent=2) if a.json else report(res, logistic))
    return 1 if res.get("no_recourse") else 0


if __name__ == "__main__":
    sys.exit(main())
More in Classical Machine Learning

anomaly-detection-model-builder

Build and evaluate anomaly detection for tabular, sensor and transaction data with few or no labels: define the anomaly type (point, contextual, collective), choose detectors (robust z-score/MAD and IQR rules, Mahalanobis with robust covariance, k-NN distance, LOF, Isolation Forest, one-class SVM, PCA reconstruction / autoencoders, forecasting residuals for time series), combine them by rank, explain each alert by its deviating features, set thresholds from an alert budget or the score distribution, and evaluate with precision@k, PR-AUC and recall on known incidents, or detector agreement when no labels exist. Includes pure-Python detectors (robust z, Mahalanobis, kNN, Isolation Forest, ensemble) and an evaluation script.

catboost-model-builder

Build CatBoost models that use its strengths correctly: raw categorical columns as cat_features (ordered target statistics, no manual encoding), text_features, Pool construction with NaN-safe categories, ordered boosting and has_time for temporal data, symmetric (oblivious) trees, overfitting detector with eval_set and use_best_model, losses for classification, regression (Quantile, Poisson, Tweedie) and ranking (YetiRank), class weights, monotone constraints, feature importance (PredictionValuesChange, LossFunctionChange) and SHAP values, GPU training and model export. Plans cat/text/numeric columns from a CSV and generates a runnable training script, and checks parameter sets for the conflicts CatBoost rejects (iterations vs n_estimators, Bayesian bootstrap with subsample, multiple class-weight options, depth > 16, use_best_model without eval_set).

categorical-encoding-selector

Choose and implement the right encoding for every categorical column given the model family and the data: one-hot (with infrequent-level grouping and handle_unknown), ordinal with an explicit order, cross-fitted target encoding (scikit-learn TargetEncoder, CatBoost ordered statistics, category_encoders inside CV), frequency/count encoding, feature hashing, native categorical support in LightGBM, HistGradientBoosting, XGBoost and CatBoost, prefix levels for codes (zip3, NAICS-2), and embeddings. Measures cardinality, rare-level share, unseen-level rate on a time-ordered holdout and target signal per column, emits the ColumnTransformer, and quantifies target-encoding leakage (in-sample vs out-of-fold vs holdout AUC).

classification-model-selector

Choose the right classification algorithm for tabular, text or mixed data and prove the choice with a fair bake-off: logistic regression (L2, L1, elastic net), linear SVM, naive Bayes, k-NN, RBF SVM, decision trees, random forest, extra trees, HistGradientBoosting, LightGBM, XGBoost, CatBoost, explainable boosting machines (EBM/GAM), points scorecards and MLPs, matched to data size, width, categorical and high-cardinality share, missingness, class imbalance, text, and constraints such as interpretability, monotonic constraints, calibrated probabilities, latency budgets and tiny deployable models. Ranks candidates with starting hyperparameters and cautions, then generates a bake-off script running every candidate on identical CV folds with per-fold metrics, fit time and prediction latency.

clustering-algorithm-selector

Choose the right clustering algorithm and check that clustering makes sense at all: cluster tendency (Hopkins statistic with a correlated-features caveat, nearest-neighbour isolation), then k-means / MiniBatchKMeans, Gaussian mixtures with BIC, agglomerative (Ward, average, complete, single) with dendrograms, DBSCAN, HDBSCAN, OPTICS, spectral clustering, BIRCH, mean shift, k-prototypes and k-modes for mixed or categorical data, and Gower distance, matched to data size, expected shapes, noise, varying density, known k, soft memberships, hierarchy and streaming. Covers preprocessing (scaling, encoding, dimensionality, distance metrics), validation (internal indices, bootstrap stability, external labels, business profiling) and assigning new points.

collaborative-filtering-implementer

Implement collaborative filtering recommenders that beat popularity: item-based kNN with cosine, Jaccard or asymmetric similarity, shrinkage, neighbourhood size, positive-only or mean-centred rating signals and 'because you liked X' explanations; implicit ALS with confidence weighting, BPR pairwise ranking and the closed-form EASE model in plain NumPy, tuned on a temporal validation split by recall@k and NDCG@k and refit on all data; plus user-kNN, explicit matrix factorisation, the implicit/LightFM libraries, hyperparameter ranges, cold-start fallbacks, scaling with sparse matrices and approximate nearest neighbours. Produces top-k lists in the format of the recommendation evaluator.