Source code for iguanas.rule_explanation

from __future__ import annotations

import re
from typing import Any

import polars as pl

from .rule_evaluation import apply_rules

_COND_PATTERN = re.compile(r'\(X\["([^"]+)"\]\s*([><=!]+)\s*([^\)]+)\)')

_OP_TO_WORDS: dict[str, str] = {
    ">": "greater than",
    ">=": "at least",
    "<": "less than",
    "<=": "at most",
    "==": "equal to",
    "!=": "not equal to",
}


[docs] def verbalize_rule(rule: str) -> str: """Convert a rule expression to a plain-English sentence. Parameters ---------- rule : str Rule expression using ``X["col"]`` notation with ``&`` / ``|`` operators. Returns ------- str Plain-English description of the rule. Examples -------- >>> verbalize_rule('(X["age"] > 30) & (X["income"] < 50000)') 'age is greater than 30 AND income is less than 50000' >>> verbalize_rule('(X["score"] >= 0.8) | (X["flag"] == 1)') 'score is at least 0.8 OR flag is equal to 1' """ def _cond_to_words(m: re.Match) -> str: feature, op, val = m.group(1), m.group(2), m.group(3).strip() op_word = _OP_TO_WORDS.get(op, op) return f"{feature} is {op_word} {val}" verbalized = _COND_PATTERN.sub(_cond_to_words, rule) verbalized = re.sub(r"\s*&\s*", " AND ", verbalized) verbalized = re.sub(r"\s*\|\s*", " OR ", verbalized) # Remove leftover grouping parentheses verbalized = re.sub(r"[\(\)]", "", verbalized).strip() return verbalized
[docs] def compute_coverage_overlap(R: pl.DataFrame) -> pl.DataFrame: """Compute pairwise Jaccard overlap between rule predictions. For each pair of rules, computes the Jaccard similarity: *(samples flagged by both) / (samples flagged by either)*. A high score means the two rules flag nearly the same population. Parameters ---------- R : pl.DataFrame Boolean DataFrame of rule predictions (columns = rules, rows = samples). Returns ------- pl.DataFrame Long-format DataFrame with columns: ``rule_a``, ``rule_b``, ``jaccard``, ``flagged_by_both``, ``flagged_by_either``. Only the upper triangle of the pair matrix is returned. Sorted by ``jaccard`` descending (most overlapping first). Examples -------- >>> import polars as pl >>> R = pl.DataFrame({ ... "rule_A": [True, True, False, False], ... "rule_B": [True, False, True, False], ... }) >>> compute_coverage_overlap(R) shape: (1, 5) ┌────────┬────────┬─────────┬────────────────┬──────────────────┐ │ rule_a │ rule_b │ jaccard │ flagged_by_both │ flagged_by_either│ │ --- │ --- │ --- │ --- │ --- │ │ str │ str │ f64 │ i64 │ i64 │ ╞════════╪════════╪═════════╪════════════════╪══════════════════╡ │ rule_A │ rule_B │ 0.333… │ 1 │ 3 │ └────────┴────────┴─────────┴────────────────┴──────────────────┘ """ _EMPTY_SCHEMA = { "rule_a": pl.String, "rule_b": pl.String, "jaccard": pl.Float64, "flagged_by_both": pl.Int64, "flagged_by_either": pl.Int64, } rules = R.columns if len(rules) < 2: return pl.DataFrame(schema=_EMPTY_SCHEMA) records: list[dict[str, Any]] = [] for i, rule_a in enumerate(rules): for rule_b in rules[i + 1 :]: a = R[rule_a] b = R[rule_b] both = int((a & b).sum()) either = int((a | b).sum()) jaccard = both / either if either > 0 else 0.0 records.append( { "rule_a": rule_a, "rule_b": rule_b, "jaccard": jaccard, "flagged_by_both": both, "flagged_by_either": either, } ) return pl.DataFrame(records).sort("jaccard", descending=True)
[docs] def compute_counterfactual( rule: str, sample: pl.DataFrame, epsilon: float = 1e-6, ) -> list[dict[str, Any]]: """Find minimal feature changes to un-flag a sample from a rule. For each atomic condition in the rule that is currently satisfied by ``sample``, computes the smallest perturbation of that feature that would violate the condition. Results are sorted by ``abs_change`` ascending so the caller can pick the least-effort option. **AND rules**: breaking any single condition un-flags the sample. The first (cheapest) entry is sufficient. **OR rules**: every currently-satisfied condition must be broken to un-flag the sample. All returned entries together form the counterfactual. Parameters ---------- rule : str Rule expression to un-flag the sample from. sample : pl.DataFrame Single-row DataFrame representing the sample to explain. Must contain all columns referenced in ``rule``. epsilon : float, default=1e-6 Small perturbation used when breaking strict inequalities. Returns ------- list[dict] Candidate counterfactuals sorted by ``abs_change`` ascending. Each dict has keys: - ``feature`` — feature name to change - ``condition`` — the atomic condition that would be broken - ``current_value`` — current feature value - ``suggested_value`` — value that breaks the condition - ``abs_change`` — magnitude of the required change Returns an empty list if the sample is not flagged by the rule or if no atomic conditions can be broken numerically. Raises ------ ValueError If ``sample`` does not have exactly one row. Examples -------- >>> sample = pl.DataFrame({"age": [45], "income": [80_000]}) >>> rule = '(X["age"] > 30) & (X["income"] >= 50_000)' >>> compute_counterfactual(rule, sample) [ {'feature': 'age', 'condition': ..., 'current_value': 45.0, 'suggested_value': 29.999999, 'abs_change': 15.000001}, {'feature': 'income', ...}, ] """ if len(sample) != 1: raise ValueError(f"sample must have exactly 1 row, got {len(sample)}") # If any column referenced in the rule is absent, the rule cannot be evaluated rule_features = {m.group(1) for m in _COND_PATTERN.finditer(rule)} if not rule_features.issubset(sample.columns): return [] # Verify the rule actually fires on this sample is_flagged = bool(apply_rules(sample, [rule])[rule][0]) if not is_flagged: return [] results: list[dict[str, Any]] = [] for feature, op, val_str in _COND_PATTERN.findall(rule): val_str = val_str.strip() try: threshold = float(val_str) except ValueError: continue # non-numeric threshold — skip current = float(sample[feature][0]) # Check whether this specific condition is currently satisfied cond = f'(X["{feature}"] {op} {val_str})' if not bool(apply_rules(sample, [cond])[cond][0]): continue # Compute the suggested value that breaks this condition if op in (">", ">="): suggested = threshold - epsilon elif op in ("<", "<="): suggested = threshold + epsilon elif op == "==": suggested = current + epsilon elif op == "!=": suggested = threshold # set to the forbidden value results.append( { "feature": feature, "condition": cond, "current_value": current, "suggested_value": suggested, "abs_change": abs(suggested - current), } ) return sorted(results, key=lambda d: d["abs_change"])