Skip to content

solvi.counterfactual

Counterfactual explanations: the smallest change of a decision's given inputs that changes its answer, re-run through the deterministic flow with the models' recorded proposals.

Counterfactual explanations: the smallest change of a decision's given inputs that changes its answer — "approve if amount ≤ 1000 (now 1200)", "refund if purchase_date ≥ 2026-08-20 (now 2026-08-10)" — for adverse-action reasons in lending and clear answers in support.

How: the recorded decision is re-run on changed inputs through the deterministic flow only. The flow is the recorded one (same keys, so the same plan); every model-backed part — an extractor, a model decision, a learned answer head, a part whose provenance is decided / learned / proposed — is replaced by the proposal it recorded in this trace, so no model is ever called: the answer is "what the code would decide if the models said what they said". A model part that did not run in the recorded decision (skipped after a hard check failed) has no proposal: on inputs that need it the question abstains, and such inputs are not counted as changing the answer. Learned rule lists (learn_rule) are code and are re-run.

The search, per input: numbers (int, float) and dates — probe outward from the current value in both directions with doubling steps, then bisect between the last unchanged and the first changed value: the nearest threshold crossing for inputs the answer is monotone in. A direction where no probe changes the answer is tried again on an even grid between the current value and the farthest probe within the range, which finds a band of a non-monotone input ("alert unless within 5 of -20") — a band narrower than the grid can still be missed, so "no change was found" is what the result says. Non-negative inputs stay non-negative unless a domain says otherwise; a domain (lo, hi) that does not contain the current value is refused for that input (listed as not searched); float bounds are shown at the shortest decimal that still holds; booleans, Enums and Literal-typed inputs (System(input_model=...)) — every other value; anything else — only with domains={fact: [values]}. Two inputs together only when no single one changes the answer: one input's candidates (its values, or probe points) with a search over the other, then each bound tightened with the other change made. Changes are ranked by count, then by size (relative change of a number; days moved over 30 — or over the domain's width — for a date; 1 for an enumerated value).

Change dataclass

Change(fact: str, now: object, to: object, op: str = '=', cost: float = 1.0)

One change of a given input in a counterfactual: the fact, its value now, the value tried (op "=" or a boundary "≤", "<", "≥", ">" for a number) and its cost.

Counterfactual dataclass

Counterfactual(answer: object, changes: list, status: str = 'ok', why: str = '', kind: str | None = None)

The answer after a set of Changes (str: " if "), its status and reason, and cost (the sum of the changes' costs); to_dict() for JSON.

Rerun

Rerun(res, system, question)

The recorded decision as a function of its given inputs: rerun({fact: value}) → the question's Result, through the recorded flow with every model-backed part held at its recorded proposal.

search

search(res, question, max_changes=2, over=None, target=None, domains=None, system=None, max_evals=5000)

See Response.counterfactual.