Rule Combination#

Functions#

combine_rules_cumulative#

iguanas.rule_combination.combine_rules_cumulative(R: polars.DataFrame, output_names: list[str] | None = None, operator: str = 'or') → polars.DataFrame[source]#

Compute horizontal cumulative boolean operations across all columns.

Parameters:
  • R (pl.DataFrame) – Input DataFrame. All columns will be used in the cumulative operation.

  • output_names (list[str] | None, default=None) – List of names for the output columns. If None, generates names based on operator. Must have the same length as R.columns.

  • operator (str, default='or') –

    Boolean operator to apply:

    • ’or’: cumulative OR (any True)

    • ’and’: cumulative AND (all True)

Returns:

DataFrame with boolean values:

  • If operator=’or’: True if at least one condition is True up to that position

  • If operator=’and’: True if all conditions are True up to that position

Return type:

pl.DataFrame

Raises:

ValueError – If operator is not ‘or’ or ‘and’, or if output_names length doesn’t match columns.

Examples

>>> import polars as pl
>>> R = pl.DataFrame({
...     "rule_A": [True, False, True],
...     "rule_B": [False, True, True],
...     "rule_C": [True, True, False],
... })
>>> combine_rules_cumulative(R, operator="or")
# Column 1: rule_A | ...; Column 2: rule_A | rule_B | ...; Column 3: all three
>>> combine_rules_cumulative(R, operator="and", output_names=["step1", "step2", "step3"])
# Named columns, each True only if all rules up to that position are True

combine_rules_budgeted#

iguanas.rule_combination.combine_rules_budgeted(R: polars.DataFrame, y: polars.Series, max_alert_rate: float, max_rules: int = 5, weights: polars.Series | None = None) → polars.DataFrame[source]#

Build the OR-ruleset that catches the most positives within an alert budget.

Operational rule systems are constrained by review capacity, not by F1: only a fixed fraction of the population can be flagged. This selects a disjunction maximising recall subject to that constraint, which is the budgeted maximum coverage problem. The greedy rule below is its standard 1 - 1/e approximation.

This differs from combine_rules_greedy() in what it optimises. Greedy metric maximisation ignores coverage, so under OR it tends to keep widening the ruleset until it flags far more than the budget permits; here a candidate is simply infeasible once the union breaches the budget.

Parameters:
  • R (pl.DataFrame) – DataFrame containing boolean rule columns. All columns are candidates.

  • y (pl.Series) – Boolean target series indicating true labels.

  • max_alert_rate (float) – Maximum fraction of the population the ruleset may flag, in (0, 1]. With weights, this is a fraction of total weight rather than of rows.

  • max_rules (int, default=5) – Maximum number of rules in the returned disjunction.

  • weights (pl.Series | None, default=None) – Optional sample weights. When given, both the budget and the positives caught are measured in weight rather than row counts.

Returns:

Single boolean column named by the combined rule expression. Empty when no single rule fits within the budget.

Return type:

pl.DataFrame

Raises:

ValueError – If max_alert_rate is outside (0, 1], or R has no columns.

Examples

>>> import polars as pl
>>> R = pl.DataFrame({"a": [True, False, False], "b": [False, True, False]})
>>> y = pl.Series([True, True, False])
>>> ruleset = combine_rules_budgeted(R, y, max_alert_rate=0.7)

Notes

Coverage is monotone under OR, so a rule that breaches the budget can never be rescued by adding more rules. Each step therefore takes the feasible rule with the greatest marginal gain in positives caught, breaking ties toward the cheaper rule, and stops as soon as nothing feasible adds a positive.

combine_rules_greedy#

iguanas.rule_combination.combine_rules_greedy(R: polars.DataFrame, y: polars.Series, metric: str = 'f1', max_rules: int = 5, operator: str = 'or', weights: polars.Series | None = None, min_improvement: float = 0.0) → polars.DataFrame[source]#

Greedily select rules that maximize a performance metric.

Starts with the best single rule, then iteratively adds rules that provide the largest metric improvement. Stops when no rule improves the metric by at least min_improvement or when max_rules is reached.

Parameters:
  • R (pl.DataFrame) – DataFrame containing boolean rule columns. All columns will be used as candidate rules.

  • y (pl.Series) – Boolean target series indicating true labels.

  • metric (str, default="f1") – Performance metric to optimize. Must be a column name produced by compute_metrics (e.g., “f1”, “accuracy”, “precision”, “recall”).

  • max_rules (int, default=5) – Maximum number of rules to select.

  • operator (str, default="or") – Boolean operator for combining rules: ‘or’ or ‘and’.

  • weights (pl.Series | None, default=None) – Optional sample weights for weighted metric computation.

  • min_improvement (float, default=0.0) – Minimum metric improvement required to add a new rule.

Returns:

DataFrame with single column containing the combined rule. Column name reflects the selected rules using the operator.

Return type:

pl.DataFrame

Examples

>>> import polars as pl
>>> R = pl.DataFrame({"rule_A": [True, False, True],
...                   "rule_B": [False, True, True],
...                   "rule_C": [True, True, False]})
>>> y = pl.Series([True, True, False])
>>> result_R = combine_rules_greedy(
...     R, y, metric="f1", max_rules=2
... )
>>> print(result_R.columns)  # e.g., ['(rule_B) | (rule_A)']
Raises:

ValueError – If operator is not ‘or’ or ‘and’, or if metric column not found.

combine_rules_a_star#

iguanas.rule_combination.combine_rules_a_star(R: polars.DataFrame, y: polars.Series, metric: str = 'f1', max_rules: int = 5, operator: str = 'or', weights: polars.Series | None = None, min_improvement: float | None = None, return_top_k: int = 10, protected: polars.Series | None = None, reference_group: object | None = None, min_dir: float | None = None, max_alert_rate: float | None = None, return_diagnostics: bool = False) → polars.DataFrame | tuple[polars.DataFrame, dict][source]#

Find the top rule combinations by best-first branch-and-bound search.

Searches the space of rule subsets containing between 1 and max_rules rules, and returns the return_top_k best-scoring combinations. Unlike greedy or beam search, this returns a provably optimal top-k under the conditions in Notes.

Search formulation#

Nodes are rule subsets; children extend a subset by one rule. Each node carries an upper bound on the metric attainable by any descendant. The frontier is ordered by that bound (best-first), and a node is discarded when its bound cannot beat the current k-th best incumbent. Search stops as soon as the best remaining bound falls below the k-th incumbent, at which point no unexplored subset can enter the top-k.

param R:

DataFrame containing boolean rule columns. All columns are candidates.

type R:

pl.DataFrame

param y:

Boolean target series indicating true labels.

type y:

pl.Series

param metric:

Metric to maximise: “precision”, “recall”, “accuracy”, “mcc”, or an F-beta score (“f1”, “f0.5”, “f2”, …).

type metric:

str, default=”f1”

param max_rules:

Maximum number of rules in a combination.

type max_rules:

int, default=5

param operator:

Boolean operator for combining rules: ‘or’ or ‘and’.

type operator:

str, default=”or”

param weights:

Optional sample weights for weighted metric computation.

type weights:

pl.Series | None, default=None

param min_improvement:

If set, a child is discarded unless it improves on its parent by at least this much. This is a heuristic filter that forfeits the optimality guarantee (the best subset may only be reachable through a temporarily worse ancestor – under OR-composition, adding a rule typically lowers precision before later rules raise recall). Leave as None for exact search.

type min_improvement:

float | None, default=None

param return_top_k:

Number of top combinations to return. Set to 1 for the single best.

type return_top_k:

int, default=10

param protected:

Optional protected-attribute series used to enforce a fairness constraint on returned combinations.

type protected:

pl.Series | None, default=None

param reference_group:

Value of protected to treat as the reference group. Defaults to the most frequent value.

type reference_group:

object | None, default=None

param min_dir:

Minimum acceptable disparate impact ratio. When set (together with protected), a combination is only admitted to the results if its DIR is at least this value; 0.8 corresponds to the “four-fifths rule”. Search remains exact over the feasible set.

type min_dir:

float | None, default=None

param max_alert_rate:

If set, only combinations flagging at most this fraction of the population are admitted. This makes the search the exact counterpart of combine_rules_budgeted(), whose greedy rule is a 1 - 1/e approximation, so the two together measure the approximation gap.

type max_alert_rate:

float | None, default=None

param return_diagnostics:

If True, return (results, diagnostics) where diagnostics reports node counts and whether the optimality guarantee held.

type return_diagnostics:

bool, default=False

returns:
  • pl.DataFrame – One column per returned combination, named by the combined rule expression, ordered best-first.

  • dict – Only when return_diagnostics is True. Keys: nodes_expanded, nodes_pruned, combinations_evaluated, bounds_computed, exact, bound_is_tight.

Examples

>>> import polars as pl
>>> R = pl.DataFrame({"rule_A": [True, False, True],
...                   "rule_B": [False, True, True],
...                   "rule_C": [True, True, False]})
>>> y = pl.Series([True, True, False])
>>> best = combine_rules_a_star(R, y, metric="f1", return_top_k=1)
>>> top_5, diag = combine_rules_a_star(R, y, return_top_k=5,
...                                    return_diagnostics=True)
raises ValueError:

If operator is not ‘or’ or ‘and’, or the metric is unsupported.

Notes

Optimality. The returned top-k is exact when min_improvement is None and the metric admits a non-trivial bound (see below). Exactness holds over the feasible set when a fairness constraint is active.

Why a bound rather than an A* heuristic. Metric value is a property of a state, not a sum of edge costs, so classical A* f = g + h does not apply: there is no additive path cost to decompose. This routine is therefore a best-first branch-and-bound, which is the correct formulation for maximising a non-additive set function. The public name is retained for backwards compatibility.

Admissibility. Under OR-composition, coverage is monotonically non-decreasing in the rule set: TP and FP can only grow and FN can only shrink. Writing P for total positive mass and k for the remaining slots, an attainable-TP upper bound follows from the union bound, TP_max = TP + (sum of the k largest per-rule new-TP masses), clipped at P, while FP is bounded below by its current value. Every metric below is non-decreasing in TP and non-increasing in FP, so evaluating it at (TP_max, FP_min) maximises it over the reachable region and never understates what a descendant can achieve:

  • recall = TP / P

  • precision = TP / (TP + FP)

  • accuracy = (TP + (N - P) - FP) / N

  • f_beta = (1 + b^2) TP / (TP + b^2 P + FP)

The F-beta identity uses FN = P - TP to eliminate FN, which is what makes the bound monotone in only two quantities. Under AND-composition the monotonicity reverses – coverage shrinks, so TP is bounded above by its current value and FP is bounded below by subtracting the k largest per-rule FP removals – and the same four expressions apply.

mcc has no non-trivial bound implemented and falls back to 1.0. That is admissible, so the result is still exact, but no pruning occurs and the search degenerates to exhaustive enumeration.

Alert budget. Under OR the constraint is monotone: coverage only grows, so a node already over budget can never be rescued and its entire subtree is discarded – the constraint makes the search cheaper, not more expensive. It also tightens the bound, since any rows a descendant adds consume the remaining capacity, capping attainable TP at budget - coverage. Under AND coverage shrinks, so an over-budget node may still have feasible descendants; there it is excluded from the results but still expanded.

Prior behaviour. Before v1.4 this function ordered the frontier by -mean(best remaining single-rule metrics), which is not admissible: for disjoint rules under OR the achievable recall gain is the sum of per-rule gains, so a mean understates it and the optimum could be ordered away. It also defaulted to min_improvement=0.0, discarding every non-improving child, which reduced the search to hill-climbing. Both are fixed here; min_improvement now defaults to None.