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 +37 -0
- interox/explanation_status_widget.py +22 -0
- interox/helpers.py +131 -0
- interox/interox.py +1425 -0
- interox/rule_analyzer.py +300 -0
- interox/rule_builder_widget.py +324 -0
- interox/rule_detail_widget.py +165 -0
- interox/ruleset_table_widget.py +109 -0
- interox/static/explanation_status.js +238 -0
- interox/static/rule_builder.js +1523 -0
- interox/static/rule_detail.js +534 -0
- interox/static/ruleset_table.js +662 -0
- interox-0.1.0.dist-info/METADATA +189 -0
- interox-0.1.0.dist-info/RECORD +15 -0
- interox-0.1.0.dist-info/WHEEL +4 -0
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)
|