interox 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
interox/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """
2
+ # InterOX: Interactive Outcome Explanation for Case Logs
3
+
4
+ `interox` exposes the `InterOX` class, which supports iterative, rule-based
5
+ outcome explanation over a case log. The workflow is typically:
6
+
7
+ 1. Inspect explanation progress.
8
+ 2. Compare stored and suggested rules.
9
+ 3. Store rules that improve coverage.
10
+ 4. Optionally hide or unhide features and re-induce suggestions.
11
+
12
+ Start here
13
+ ----------
14
+ - `InterOX.get_exp_status` — where the explanation stands.
15
+ - `InterOX.get_stored_ruleset` / `InterOX.get_suggested_ruleset` — the two
16
+ rulesets, with metrics, as table widgets.
17
+ - `InterOX.get_rule_builder` — build a rule from scratch instead of taking a
18
+ suggested one.
19
+ - `InterOX.send_rule_to_builder` — take a suggested rule into that builder to
20
+ adjust it before storing (the ✎ button on a suggested row).
21
+ - `InterOX.store_rule_by_id` / `InterOX.remove_rule_by_id` — change the stored
22
+ ruleset; both re-induce suggestions.
23
+
24
+ A rule is addressed by its **stable id**, which survives re-induction and
25
+ reordering: prefer the `*_by_id` methods over the positional ones of the same
26
+ name. `InterOX.get_stored_rule_options` maps rule names to those ids.
27
+
28
+ The rest of the class -- rule detail widgets, annotations, hidden features, and
29
+ the `get_*_case_log` / `get_data_*` accessors for analysis outside the provided
30
+ widgets -- is documented on the methods themselves, below.
31
+ """
32
+ import logging
33
+ from .interox import InterOX
34
+
35
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
36
+
37
+ __all__ = ["InterOX"]
@@ -0,0 +1,22 @@
1
+ # pylint: disable=missing-module-docstring
2
+ import pathlib
3
+ import anywidget
4
+ import traitlets
5
+
6
+
7
+ class ExplanationStatusWidget(anywidget.AnyWidget): # pylint: disable=too-few-public-methods, abstract-method
8
+ """A widget for displaying the status of an outcome explanation
9
+ in terms of remaining (FN), explained (TP) and wrongly claimed (FP) cases,
10
+ the negatives the explanation leaves alone (TN), and the number of rules the
11
+ explanation is currently made of.
12
+
13
+ The four case counts partition the case log, which lets the frontend draw
14
+ the flow from the case log through the rule set to the prediction. All
15
+ traits are Python-owned; the frontend never writes back."""
16
+ _esm = pathlib.Path(__file__).parent / "static" / "explanation_status.js"
17
+
18
+ cases_tbe = traitlets.Int(default_value=0).tag(sync=True)
19
+ exp_tp = traitlets.Int(default_value=0).tag(sync=True)
20
+ exp_fp = traitlets.Int(default_value=0).tag(sync=True)
21
+ exp_tn = traitlets.Int(default_value=0).tag(sync=True)
22
+ stored_rules = traitlets.Int(default_value=0).tag(sync=True)
interox/helpers.py ADDED
@@ -0,0 +1,131 @@
1
+ """Supporting functions for InterOX."""
2
+ import operator
3
+ from collections import defaultdict
4
+ from typing import Any, Callable, Dict, List, Tuple
5
+ import logging
6
+ from rulelearn_ri.trxf.core import Conjunction, DnfRuleSet, Feature
7
+ from rulelearn_ri.trxf.core.predicate import Relation
8
+ from pandas import Series
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ #: The comparison each trxf relation stands for. Rule evaluation compares values
13
+ #: directly rather than rendering them into an expression to `eval`, so a value
14
+ #: is only ever compared, never parsed.
15
+ _RELATION_OPS = {
16
+ Relation.EQ: operator.eq,
17
+ Relation.NEQ: operator.ne,
18
+ Relation.LT: operator.lt,
19
+ Relation.LE: operator.le,
20
+ Relation.GT: operator.gt,
21
+ Relation.GE: operator.ge,
22
+ }
23
+
24
+
25
+ def is_usable_feature_name(name) -> bool:
26
+ """Whether a column name can carry a rule condition.
27
+
28
+ `trxf` parses a feature name as an arithmetic expression, so a name is only
29
+ usable if it happens to be a valid one. Most punctuation survives -- `.`,
30
+ `:` and `::` are absorbed into a single token -- but `+ - * /` and
31
+ parentheses are read as operators, and a name that then fails to parse
32
+ (`*.concept:name::append`, `Payment_(late)::count`) cannot become a
33
+ `Feature` at all.
34
+
35
+ The failure modes are varied enough (`ValueError` from the expression
36
+ validator, `TokenError` from the tokenizer, `IndexError` from an unbalanced
37
+ parenthesis) that the only useful question is whether construction works.
38
+ """
39
+ try:
40
+ Feature(str(name))
41
+ return True
42
+ except Exception: # pylint: disable=broad-except
43
+ return False
44
+
45
+
46
+ def rule_to_lambda(rule: Conjunction,
47
+ skip_feature=None,
48
+ only_feature=None) -> Callable[[Series], bool]:
49
+ """Convert a rule (conjunction of predicates) to a lambda function
50
+ that evaluates the rule on a case.
51
+
52
+ A predicate becomes a column lookup and a comparison against the value the
53
+ predicate carries, whatever that value is. Nothing is rendered into source
54
+ text, so a category that looks like a number (`'nan'`, `'1e3'`), a value
55
+ holding a quote, and a column name holding an apostrophe all compare as
56
+ themselves. An empty conjunction -- every predicate filtered out, or none to
57
+ begin with -- matches every case.
58
+
59
+ Raises:
60
+ KeyError: When evaluating a case that has no column for a predicate's
61
+ feature.
62
+ """
63
+ if skip_feature is not None and only_feature is not None:
64
+ raise ValueError("skip_feature and only_feature are mutually exclusive")
65
+ tests: List[Tuple[str, Callable[[Any, Any], bool], Any]] = []
66
+ for predicate in rule.predicates:
67
+ feature = str(predicate.feature)
68
+ if skip_feature is not None and feature == skip_feature:
69
+ continue
70
+ if only_feature is not None and feature != only_feature:
71
+ continue
72
+ logger.debug("Processing predicate: %s %s %s",
73
+ feature, predicate.relation.value, predicate.value)
74
+ try:
75
+ op = _RELATION_OPS[predicate.relation]
76
+ except KeyError as exc:
77
+ raise ValueError(f'Unknown relation {predicate.relation!r}') from exc
78
+ tests.append((feature, op, predicate.value))
79
+
80
+ return lambda case: all(op(case[feature], value) for feature, op, value in tests)
81
+
82
+
83
+ def conjunction_to_conditions(conjunction: Conjunction) -> List[Dict[str, Any]]:
84
+ """Convert a rule into the condition list the rule builder canvas speaks.
85
+
86
+ The builder holds at most one condition per attribute, so the predicates are
87
+ grouped by feature first: a GE/LE pair on the same feature is the two sides
88
+ of one `range` condition, and a lone bound keeps its own operator. Predicates
89
+ beyond the first of each relation on a feature are dropped -- the canvas has
90
+ nowhere to put them. So are whole relations the canvas cannot express: NEQ,
91
+ LT and GT have no branch here, because a categorical condition is `==` only
92
+ (RIPPER induces no negation) and a numeric one is an inclusive bound. Note
93
+ this is narrower than `_RELATION_OPS` above, which still evaluates all six --
94
+ a rule can carry a relation the builder would not let you author.
95
+
96
+ This is the inverse of `RuleBuilderWidget._conditions_to_predicates`, and it
97
+ is what lets an already-induced rule be loaded back into the builder (see
98
+ `InterOX.send_rule_to_builder`).
99
+ """
100
+ by_attr: Dict[str, List[Any]] = defaultdict(list)
101
+ for predicate in conjunction.predicates:
102
+ # `str`, as in `rule_to_lambda`: a predicate's feature is a `Feature` when
103
+ # it comes from the inducer but a bare column name when it was built by
104
+ # hand, and both render as the column name.
105
+ by_attr[str(predicate.feature)].append(predicate)
106
+
107
+ conditions: List[Dict[str, Any]] = []
108
+ for attr_name, preds in by_attr.items():
109
+ ge = next((p for p in preds if p.relation == Relation.GE), None)
110
+ le = next((p for p in preds if p.relation == Relation.LE), None)
111
+ eq = next((p for p in preds if p.relation == Relation.EQ), None)
112
+ if ge and le:
113
+ conditions.append({'attr': attr_name, 'op': 'range',
114
+ 'lo': float(ge.value), 'hi': float(le.value)})
115
+ elif ge:
116
+ conditions.append({'attr': attr_name, 'op': '>=', 'value': float(ge.value)})
117
+ elif le:
118
+ conditions.append({'attr': attr_name, 'op': '<=', 'value': float(le.value)})
119
+ elif eq:
120
+ conditions.append({'attr': attr_name, 'op': '==', 'value': str(eq.value)})
121
+ return conditions
122
+
123
+
124
+ def ruleset_to_lambda(ruleset: DnfRuleSet, skip_feature=None) -> Callable[[Series], bool]:
125
+ """Convert a ruleset (disjunction of conjunctions) to a lambda function
126
+ that evaluates the ruleset on a case."""
127
+ rule_lambdas = []
128
+ for conji in ruleset.conjunctions:
129
+ rule_lambdas.append(rule_to_lambda(conji, skip_feature))
130
+
131
+ return lambda case: any(rule_lambda(case) for rule_lambda in rule_lambdas)