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/interox.py
ADDED
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
"""Core InterOX class and rule induction logic."""
|
|
2
|
+
# pylint: disable=too-many-lines
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Dict, List, Any, Tuple, Union, Literal, cast
|
|
7
|
+
from pandas import DataFrame, Series
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from rulelearn_ri.trxf.core import Conjunction, DnfRuleSet
|
|
11
|
+
from rulelearn_ri.algorithms.ripper import RipperExplainer
|
|
12
|
+
from coolname import generate_slug
|
|
13
|
+
from eclear.enrichment import describe_attribute, is_numeric_series, provenance_of
|
|
14
|
+
from .helpers import (conjunction_to_conditions, is_usable_feature_name,
|
|
15
|
+
rule_to_lambda, ruleset_to_lambda)
|
|
16
|
+
from .rule_analyzer import DnfRuleAnalyzer
|
|
17
|
+
from .ruleset_table_widget import RulesetTableWidget
|
|
18
|
+
from .explanation_status_widget import ExplanationStatusWidget
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
CaseFilter = Literal['any', 'positive', 'negative']
|
|
23
|
+
|
|
24
|
+
#: Columns of the rule table that carry a metric rather than a rule condition.
|
|
25
|
+
_METRIC_COLUMNS = frozenset({'Precision', 'Recall', 'TP', 'FP', 'FN', 'TN'})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def feature_is_hidden(column: str, hidden_feats: List[str]) -> bool:
|
|
29
|
+
"""Whether a column is covered by a hidden-feature entry.
|
|
30
|
+
|
|
31
|
+
An entry is an exact column name or a regex, and the two are checked in
|
|
32
|
+
that order. A name is not necessarily a valid pattern -- an enrichment
|
|
33
|
+
column like `*.concept:name::append` starts with a regex quantifier -- so a
|
|
34
|
+
pattern that does not compile only ever matches literally, rather than
|
|
35
|
+
raising out of induction.
|
|
36
|
+
"""
|
|
37
|
+
for pattern in hidden_feats:
|
|
38
|
+
if column == pattern:
|
|
39
|
+
return True
|
|
40
|
+
try:
|
|
41
|
+
if re.fullmatch(pattern, column):
|
|
42
|
+
return True
|
|
43
|
+
except re.error:
|
|
44
|
+
continue
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def rule_induction(case_log: DataFrame,
|
|
49
|
+
outcome: "Series[bool]",
|
|
50
|
+
hidden_feats: List[str]) -> DnfRuleSet:
|
|
51
|
+
"""Induce a ruleset from a case log, outcome, and hidden features.
|
|
52
|
+
|
|
53
|
+
Returns an empty ruleset when there is nothing left to induce from, i.e.
|
|
54
|
+
when the case log is empty or the outcome has a single unique value (for
|
|
55
|
+
example, after a stored rule has explained every remaining positive case).
|
|
56
|
+
The underlying RIPPER estimator requires at least two outcome classes.
|
|
57
|
+
|
|
58
|
+
Columns whose name cannot become a rule condition are dropped alongside the
|
|
59
|
+
hidden ones: RIPPER turns the name of every column it selects into a `trxf`
|
|
60
|
+
`Feature`, and a name that does not parse takes down the whole induction
|
|
61
|
+
rather than just its own rule.
|
|
62
|
+
"""
|
|
63
|
+
if len(case_log) == 0 or outcome.nunique() < 2:
|
|
64
|
+
return DnfRuleSet(conjunctions=[], then_part=1)
|
|
65
|
+
|
|
66
|
+
x_train = case_log.copy()
|
|
67
|
+
|
|
68
|
+
columns_to_drop = set()
|
|
69
|
+
unusable = []
|
|
70
|
+
for col in x_train.columns:
|
|
71
|
+
if feature_is_hidden(col, hidden_feats):
|
|
72
|
+
logger.debug('Dropping hidden feature %s.', col)
|
|
73
|
+
columns_to_drop.add(col)
|
|
74
|
+
elif not is_usable_feature_name(col):
|
|
75
|
+
unusable.append(col)
|
|
76
|
+
columns_to_drop.add(col)
|
|
77
|
+
|
|
78
|
+
if unusable:
|
|
79
|
+
logger.warning(
|
|
80
|
+
'Dropping %d column(s) whose name cannot be used as a rule condition: %s. '
|
|
81
|
+
'Rename them to remove the arithmetic operator.',
|
|
82
|
+
len(unusable), ', '.join(repr(col) for col in unusable)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if columns_to_drop:
|
|
86
|
+
x_train = x_train.drop(columns=list(columns_to_drop))
|
|
87
|
+
estimator = RipperExplainer()
|
|
88
|
+
estimator.fit(x_train, outcome, target_label=True)
|
|
89
|
+
return estimator.explain()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InterOX: # pylint: disable=too-many-instance-attributes, too-many-public-methods
|
|
93
|
+
"""Interactive manager for rule-based outcome explanation.
|
|
94
|
+
|
|
95
|
+
The class tracks two rulesets over a case log:
|
|
96
|
+
- Stored ruleset: rules accepted by the user.
|
|
97
|
+
- Suggested ruleset: rules induced from the currently unclaimed cases
|
|
98
|
+
(those no stored rule fires on).
|
|
99
|
+
|
|
100
|
+
As rules are stored or visible features are changed, suggested rules are
|
|
101
|
+
re-induced.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, outcome: "Series[bool]",
|
|
105
|
+
case_log: DataFrame,
|
|
106
|
+
hidden_feats: Union[List[str], None] = None):
|
|
107
|
+
"""Initialize InterOX state for an outcome and case log.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
outcome: Boolean target label per case. `True` denotes the
|
|
111
|
+
positive outcome to explain.
|
|
112
|
+
case_log: Feature table indexed by case. Rows are evaluated by
|
|
113
|
+
induced and stored rules.
|
|
114
|
+
hidden_feats: List of exact feature names or regex patterns to
|
|
115
|
+
exclude from suggested-rule induction.
|
|
116
|
+
|
|
117
|
+
Notes:
|
|
118
|
+
On initialization, InterOX induces an initial suggested ruleset
|
|
119
|
+
over the full case log and marks all cases as unclaimed.
|
|
120
|
+
"""
|
|
121
|
+
self._outcome = outcome
|
|
122
|
+
self._case_log = case_log
|
|
123
|
+
self._hidden_feats = hidden_feats if hidden_feats is not None else []
|
|
124
|
+
self._explained_cases: "Series[bool]" = Series(
|
|
125
|
+
[False] * len(self._outcome), index=self._outcome.index
|
|
126
|
+
)
|
|
127
|
+
self._stored_ruleset: DnfRuleSet = DnfRuleSet(conjunctions=[], then_part=1)
|
|
128
|
+
# Annotations keyed by stable per-rule id (see _ensure_rule_id). Holds
|
|
129
|
+
# only non-empty notes; missing ids resolve to ''.
|
|
130
|
+
self._stored_annotations: Dict[int, str] = {}
|
|
131
|
+
# Human-readable rule names keyed by iox_rule_id
|
|
132
|
+
self._rule_names: Dict[int, str] = {}
|
|
133
|
+
self._rule_id_counter = 0
|
|
134
|
+
# One-shot confirmation handed to the next rule builder widget (see
|
|
135
|
+
# `get_rule_builder`).
|
|
136
|
+
self._pending_builder_flash = ''
|
|
137
|
+
# The rule builder widget created last, i.e. the one the notebook is
|
|
138
|
+
# currently showing -- `None` until a cell asks for one. It is what
|
|
139
|
+
# `send_rule_to_builder` writes a rule into (see there).
|
|
140
|
+
self._rule_builder: Any = None
|
|
141
|
+
self._suggested_ruleset: DnfRuleSet = rule_induction(case_log, outcome, self._hidden_feats)
|
|
142
|
+
self._suggested_ruleset_analyzer = DnfRuleAnalyzer(self._suggested_ruleset,
|
|
143
|
+
case_log,
|
|
144
|
+
outcome)
|
|
145
|
+
self._stored_ruleset_analyzer = DnfRuleAnalyzer(self._stored_ruleset,
|
|
146
|
+
case_log,
|
|
147
|
+
outcome)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_stored_ruleset_raw(self) -> DnfRuleSet:
|
|
151
|
+
"""Return the stored ruleset object.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The current stored `DnfRuleSet`.
|
|
155
|
+
"""
|
|
156
|
+
return self._stored_ruleset
|
|
157
|
+
|
|
158
|
+
def get_suggested_ruleset_raw(self) -> DnfRuleSet:
|
|
159
|
+
"""Return the suggested ruleset object.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The current suggested `DnfRuleSet`, induced from the unclaimed
|
|
163
|
+
cases and visible features.
|
|
164
|
+
"""
|
|
165
|
+
return self._suggested_ruleset
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _get_exp_status_data(self) -> Dict[str, int]:
|
|
170
|
+
"""Return explanation progress counts.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
A dict with keys:
|
|
174
|
+
- `cases_tbe`: Remaining positive cases (FN).
|
|
175
|
+
- `exp_tp`: Explained cases (TP).
|
|
176
|
+
- `exp_fp`: Wrongly claimed cases (FP).
|
|
177
|
+
- `exp_tn`: Negative cases the explanation leaves alone (TN).
|
|
178
|
+
|
|
179
|
+
The four counts partition the case log.
|
|
180
|
+
"""
|
|
181
|
+
exp_tp = len(self._case_log[self._explained_cases & self._outcome])
|
|
182
|
+
exp_fp = len(self._case_log[self._explained_cases & ~self._outcome])
|
|
183
|
+
cases_tbe = len(self._case_log[~self._explained_cases & self._outcome])
|
|
184
|
+
exp_tn = len(self._case_log[~self._explained_cases & ~self._outcome])
|
|
185
|
+
return {
|
|
186
|
+
"cases_tbe": cases_tbe,
|
|
187
|
+
"exp_tp": exp_tp,
|
|
188
|
+
"exp_fp": exp_fp,
|
|
189
|
+
"exp_tn": exp_tn,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
def get_exp_status(self) -> ExplanationStatusWidget:
|
|
193
|
+
"""Return a widget view of explanation status.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
`ExplanationStatusWidget` populated with current explanation
|
|
197
|
+
progress counts and the size of the stored ruleset.
|
|
198
|
+
"""
|
|
199
|
+
exp_status = self._get_exp_status_data()
|
|
200
|
+
return ExplanationStatusWidget(
|
|
201
|
+
cases_tbe=exp_status["cases_tbe"],
|
|
202
|
+
exp_tp=exp_status["exp_tp"],
|
|
203
|
+
exp_fp=exp_status["exp_fp"],
|
|
204
|
+
exp_tn=exp_status["exp_tn"],
|
|
205
|
+
stored_rules=self.get_len_stored_ruleset(),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def get_len_suggested_ruleset(self) -> int:
|
|
209
|
+
""" Get number of rules in the suggested ruleset. """
|
|
210
|
+
return len(self._suggested_ruleset.conjunctions)
|
|
211
|
+
|
|
212
|
+
def get_len_stored_ruleset(self) -> int:
|
|
213
|
+
""" Get the number of rules in the stored ruleset. """
|
|
214
|
+
return len(self._stored_ruleset.conjunctions)
|
|
215
|
+
|
|
216
|
+
def _ensure_rule_id(self, conjunction: Conjunction) -> int:
|
|
217
|
+
"""Return a stable unique id for a rule, assigning one if needed.
|
|
218
|
+
|
|
219
|
+
The id is tagged directly onto the ``Conjunction`` object (which is
|
|
220
|
+
unhashable and therefore cannot key a dict). Object identity is stable
|
|
221
|
+
for a stored rule's lifetime, so its id and annotation follow it across
|
|
222
|
+
re-induction, deduplication, and removals — independent of position.
|
|
223
|
+
"""
|
|
224
|
+
iox_rule_id = getattr(conjunction, 'iox_rule_id', None)
|
|
225
|
+
if iox_rule_id is None:
|
|
226
|
+
iox_rule_id = self._rule_id_counter
|
|
227
|
+
self._rule_id_counter += 1
|
|
228
|
+
setattr(conjunction, 'iox_rule_id', iox_rule_id)
|
|
229
|
+
# Generate a human-readable name for this rule
|
|
230
|
+
self._rule_names[iox_rule_id] = self._generate_rule_name()
|
|
231
|
+
return iox_rule_id
|
|
232
|
+
|
|
233
|
+
def _generate_rule_name(self) -> str:
|
|
234
|
+
"""Generate a human-readable rule name using two words."""
|
|
235
|
+
return generate_slug(2)
|
|
236
|
+
|
|
237
|
+
def get_rule_name(self, iox_rule_id: int) -> str:
|
|
238
|
+
"""Return the human-readable name for a rule with the given iox_rule_id."""
|
|
239
|
+
return self._rule_names.get(iox_rule_id, f'Rule {iox_rule_id}')
|
|
240
|
+
|
|
241
|
+
def get_stored_rule_options(self) -> Dict[str, int]:
|
|
242
|
+
"""Return `{rule_name: iox_rule_id}` for the stored ruleset.
|
|
243
|
+
|
|
244
|
+
Intended for selectors (dropdowns): rules are addressed by their stable
|
|
245
|
+
id, so a selection survives re-induction, deduplication and removals,
|
|
246
|
+
while the user sees the rule's human-readable name.
|
|
247
|
+
"""
|
|
248
|
+
return self._rule_options(self._stored_ruleset.conjunctions)
|
|
249
|
+
|
|
250
|
+
def get_suggested_rule_options(self) -> Dict[str, int]:
|
|
251
|
+
"""Return `{rule_name: iox_rule_id}` for the suggested ruleset.
|
|
252
|
+
|
|
253
|
+
See `get_stored_rule_options`.
|
|
254
|
+
"""
|
|
255
|
+
return self._rule_options(self._suggested_ruleset.conjunctions)
|
|
256
|
+
|
|
257
|
+
def _rule_options(self, conjunctions: List[Conjunction]) -> Dict[str, int]:
|
|
258
|
+
options: Dict[str, int] = {}
|
|
259
|
+
for conjunction in conjunctions:
|
|
260
|
+
iox_rule_id = self._ensure_rule_id(conjunction)
|
|
261
|
+
options[self.get_rule_name(iox_rule_id)] = iox_rule_id
|
|
262
|
+
return options
|
|
263
|
+
|
|
264
|
+
def _require_stored_index(self, iox_rule_id: int) -> int:
|
|
265
|
+
idx = self._stored_index_by_id(iox_rule_id)
|
|
266
|
+
if idx is None:
|
|
267
|
+
raise ValueError(f"No stored rule with id {iox_rule_id}.")
|
|
268
|
+
return idx
|
|
269
|
+
|
|
270
|
+
def _require_suggested_index(self, iox_rule_id: int) -> int:
|
|
271
|
+
idx = self._suggested_index_by_id(iox_rule_id)
|
|
272
|
+
if idx is None:
|
|
273
|
+
raise ValueError(f"No suggested rule with id {iox_rule_id}.")
|
|
274
|
+
return idx
|
|
275
|
+
|
|
276
|
+
def _build_rule_detail_payload(self, # pylint: disable=too-many-locals
|
|
277
|
+
analyzer: DnfRuleAnalyzer,
|
|
278
|
+
rule_index: int,
|
|
279
|
+
case_log: DataFrame,
|
|
280
|
+
outcome: "Series[bool]") -> dict:
|
|
281
|
+
metrics_per_rule = analyzer.get_metrics_per_rule()
|
|
282
|
+
rule_metrics = metrics_per_rule[rule_index]
|
|
283
|
+
rule_df = analyzer.get_feature_impact_for_rule(rule_index)
|
|
284
|
+
per_feat_metrics_df = analyzer.get_per_feature_metrics_for_rule(rule_index)
|
|
285
|
+
per_feat_metrics = {
|
|
286
|
+
row['Feature']: (float(row['Precision']), float(row['Recall']))
|
|
287
|
+
for _, row in per_feat_metrics_df.iterrows()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
rule_feature_names = list(rule_df['Feature']) if len(rule_df) > 0 else []
|
|
291
|
+
feature_summaries = (
|
|
292
|
+
self._build_feature_summaries(case_log, rule_feature_names, outcome)
|
|
293
|
+
if rule_feature_names else {}
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
conjunction = analyzer.rule_set.conjunctions[rule_index]
|
|
297
|
+
feat_predicate_info: dict[str, dict] = {}
|
|
298
|
+
for predicate in conjunction.predicates:
|
|
299
|
+
feat = str(predicate.feature)
|
|
300
|
+
if feat not in feat_predicate_info:
|
|
301
|
+
feat_predicate_info[feat] = {
|
|
302
|
+
'relations': [],
|
|
303
|
+
'boundaries': [],
|
|
304
|
+
'string_values': [],
|
|
305
|
+
}
|
|
306
|
+
feat_predicate_info[feat]['relations'].append(str(predicate.relation.value))
|
|
307
|
+
val = predicate.value
|
|
308
|
+
if hasattr(val, 'tolist'):
|
|
309
|
+
val = val.tolist()
|
|
310
|
+
if isinstance(val, (int, float)) and np.isfinite(val):
|
|
311
|
+
feat_predicate_info[feat]['boundaries'].append(float(val))
|
|
312
|
+
feat_predicate_info[feat]['string_values'].append('')
|
|
313
|
+
else:
|
|
314
|
+
feat_predicate_info[feat]['boundaries'].append(0.0)
|
|
315
|
+
feat_predicate_info[feat]['string_values'].append(str(val))
|
|
316
|
+
|
|
317
|
+
features = []
|
|
318
|
+
max_total_removed = 0
|
|
319
|
+
for _, row in rule_df.iterrows():
|
|
320
|
+
unique_tp_removed = int(row['Unique TPs Removed'])
|
|
321
|
+
unique_fp_removed = int(row['Unique FPs Removed'])
|
|
322
|
+
total_removed = unique_tp_removed + unique_fp_removed
|
|
323
|
+
max_total_removed = max(max_total_removed, total_removed)
|
|
324
|
+
|
|
325
|
+
feature_name = str(row['Feature'])
|
|
326
|
+
raw_values = row.get('Predicate Values', [])
|
|
327
|
+
if hasattr(raw_values, 'tolist'):
|
|
328
|
+
raw_values = raw_values.tolist()
|
|
329
|
+
predicate_boundaries = []
|
|
330
|
+
for val in raw_values:
|
|
331
|
+
if isinstance(val, (int, float)) and np.isfinite(val):
|
|
332
|
+
predicate_boundaries.append(float(val))
|
|
333
|
+
|
|
334
|
+
precision_alone, recall_alone = per_feat_metrics.get(
|
|
335
|
+
feature_name, (0.0, 0.0)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
pred_info = feat_predicate_info.get(feature_name, {
|
|
339
|
+
'relations': [],
|
|
340
|
+
'boundaries': [],
|
|
341
|
+
'string_values': [],
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
features.append({
|
|
345
|
+
'feature': feature_name,
|
|
346
|
+
'predicates_removed': str(row['Predicates Removed']),
|
|
347
|
+
'predicate_boundaries': predicate_boundaries,
|
|
348
|
+
'predicate_relations': pred_info['relations'],
|
|
349
|
+
'predicate_string_values': pred_info['string_values'],
|
|
350
|
+
'unique_tp_removed': unique_tp_removed,
|
|
351
|
+
'unique_fp_removed': unique_fp_removed,
|
|
352
|
+
'precision_feature_alone': precision_alone,
|
|
353
|
+
'recall_feature_alone': recall_alone,
|
|
354
|
+
'summary': feature_summaries.get(feature_name, {}),
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
rule_name = self.get_rule_name(self._ensure_rule_id(conjunction))
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
'rule_index': rule_index,
|
|
361
|
+
'rule_name': rule_name,
|
|
362
|
+
'precision': float(rule_metrics['precision']),
|
|
363
|
+
'recall': float(rule_metrics['recall']),
|
|
364
|
+
'features': features,
|
|
365
|
+
'max_total_removed': max_total_removed,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
def _new_rule_detail_widget(self, title: str, rule_data: dict) -> Any:
|
|
369
|
+
widget_module = importlib.import_module('.rule_detail_widget', package=__package__)
|
|
370
|
+
return widget_module.RuleDetailWidget(
|
|
371
|
+
title=title,
|
|
372
|
+
rule_data=rule_data,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _new_rule_builder_widget(self, title: str, outcome_name: str,
|
|
376
|
+
attributes: list) -> Any:
|
|
377
|
+
widget_module = importlib.import_module('.rule_builder_widget', package=__package__)
|
|
378
|
+
return widget_module.RuleBuilderWidget(
|
|
379
|
+
title=title,
|
|
380
|
+
outcome_name=outcome_name,
|
|
381
|
+
attributes=attributes,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def _is_numeric_feature(self, series: Series) -> bool:
|
|
385
|
+
"""Whether a feature is numeric, by the same test the enrichers use."""
|
|
386
|
+
return is_numeric_series(series)
|
|
387
|
+
|
|
388
|
+
def _build_categorical_full_summary(self,
|
|
389
|
+
series: Series,
|
|
390
|
+
outcome_series: Series) -> dict:
|
|
391
|
+
"""Frequency-bar summary listing *every* category, count-ordered, each
|
|
392
|
+
with its outcome-positive count (for the rule builder's clickable bars)."""
|
|
393
|
+
missing_count = int(series.isna().sum())
|
|
394
|
+
non_missing = series.dropna()
|
|
395
|
+
total_count = int(len(series))
|
|
396
|
+
|
|
397
|
+
if len(non_missing) == 0:
|
|
398
|
+
return {
|
|
399
|
+
'kind': 'empty',
|
|
400
|
+
'total_count': total_count,
|
|
401
|
+
'missing_count': missing_count,
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
str_series = non_missing.astype(str)
|
|
405
|
+
outcome_aligned = outcome_series.reindex(non_missing.index).fillna(False)
|
|
406
|
+
counts = str_series.value_counts()
|
|
407
|
+
|
|
408
|
+
categories = []
|
|
409
|
+
for label, count in counts.items():
|
|
410
|
+
positive = int(outcome_aligned[(str_series == label).to_numpy()].sum())
|
|
411
|
+
categories.append({
|
|
412
|
+
'value': str(label),
|
|
413
|
+
'count': int(count),
|
|
414
|
+
'outcome_positive_count': positive,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
'kind': 'categorical',
|
|
419
|
+
'total_count': total_count,
|
|
420
|
+
'missing_count': missing_count,
|
|
421
|
+
'categories': categories,
|
|
422
|
+
'outcome_positive_total': int(outcome_aligned.sum()),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
def _compute_attribute_importances(self, # pylint: disable=too-many-locals
|
|
426
|
+
case_log: DataFrame,
|
|
427
|
+
outcome: "Series[bool]",
|
|
428
|
+
features: list) -> dict:
|
|
429
|
+
"""Rank attributes by predictive usefulness using a tree-based model.
|
|
430
|
+
|
|
431
|
+
Returns a mapping ``feature -> importance`` in ``[0, 1]`` (normalized so
|
|
432
|
+
the most important attribute is ``1.0``). The exact method is an
|
|
433
|
+
implementation detail surfaced abstractly in the widget's (i) tooltip;
|
|
434
|
+
on any failure all attributes fall back to a neutral importance.
|
|
435
|
+
"""
|
|
436
|
+
fallback = {feat: 0.5 for feat in features}
|
|
437
|
+
if not features:
|
|
438
|
+
return fallback
|
|
439
|
+
try:
|
|
440
|
+
from sklearn.ensemble import RandomForestClassifier # pylint: disable=import-outside-toplevel
|
|
441
|
+
|
|
442
|
+
encoded = DataFrame(index=case_log.index)
|
|
443
|
+
for feat in features:
|
|
444
|
+
column = case_log[feat]
|
|
445
|
+
if self._is_numeric_feature(column):
|
|
446
|
+
encoded[feat] = pd.to_numeric(column, errors='coerce')
|
|
447
|
+
else:
|
|
448
|
+
encoded[feat] = column.astype(str).astype('category').cat.codes
|
|
449
|
+
encoded = encoded.fillna(-1)
|
|
450
|
+
|
|
451
|
+
target = outcome.astype(bool)
|
|
452
|
+
if encoded.shape[1] == 0 or target.nunique() < 2:
|
|
453
|
+
return fallback
|
|
454
|
+
|
|
455
|
+
forest = RandomForestClassifier(n_estimators=100, random_state=0)
|
|
456
|
+
forest.fit(encoded, target)
|
|
457
|
+
importances = forest.feature_importances_
|
|
458
|
+
max_importance = float(importances.max()) if len(importances) else 0.0
|
|
459
|
+
if max_importance <= 0:
|
|
460
|
+
return fallback
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
feat: float(importance) / max_importance
|
|
464
|
+
for feat, importance in zip(encoded.columns, importances)
|
|
465
|
+
}
|
|
466
|
+
except Exception: # pylint: disable=broad-except
|
|
467
|
+
return fallback
|
|
468
|
+
|
|
469
|
+
def _build_rule_builder_attributes(self,
|
|
470
|
+
case_log: DataFrame,
|
|
471
|
+
outcome: "Series[bool]",
|
|
472
|
+
features: list) -> list:
|
|
473
|
+
importances = self._compute_attribute_importances(case_log, outcome, features)
|
|
474
|
+
# Enrichment provenance, when the case log carries one: the enrichers
|
|
475
|
+
# record what they produced, so the family and sources below are read
|
|
476
|
+
# rather than guessed. Columns with no record -- written by hand, or by
|
|
477
|
+
# an earlier version -- fall back to parsing the name.
|
|
478
|
+
provenance = provenance_of(case_log)
|
|
479
|
+
attributes = []
|
|
480
|
+
for feat in features:
|
|
481
|
+
series = case_log[feat]
|
|
482
|
+
if self._is_numeric_feature(series):
|
|
483
|
+
kind = 'num'
|
|
484
|
+
summary = self._build_numeric_feature_summary(series, outcome)
|
|
485
|
+
else:
|
|
486
|
+
kind = 'cat'
|
|
487
|
+
summary = self._build_categorical_full_summary(series, outcome)
|
|
488
|
+
meta = describe_attribute(feat, provenance)
|
|
489
|
+
attributes.append({
|
|
490
|
+
'id': feat,
|
|
491
|
+
'label': feat,
|
|
492
|
+
'kind': kind,
|
|
493
|
+
'importance': float(importances.get(feat, 0.5)),
|
|
494
|
+
'summary': summary,
|
|
495
|
+
'family': meta.family,
|
|
496
|
+
'family_label': meta.family_label,
|
|
497
|
+
'sources': list(meta.sources),
|
|
498
|
+
})
|
|
499
|
+
return attributes
|
|
500
|
+
|
|
501
|
+
def _build_numeric_feature_summary(self, series: Series, outcome_series: Series) -> dict: # pylint: disable=too-many-locals
|
|
502
|
+
numeric_values = pd.to_numeric(series, errors='coerce')
|
|
503
|
+
missing_count = int(numeric_values.isna().sum())
|
|
504
|
+
non_missing = numeric_values.dropna()
|
|
505
|
+
total_count = int(len(numeric_values))
|
|
506
|
+
|
|
507
|
+
assert missing_count + len(non_missing) == total_count
|
|
508
|
+
|
|
509
|
+
if len(non_missing) == 0:
|
|
510
|
+
return {
|
|
511
|
+
'kind': 'empty',
|
|
512
|
+
'total_count': total_count,
|
|
513
|
+
'missing_count': missing_count,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
min_value = float(non_missing.min())
|
|
517
|
+
max_value = float(non_missing.max())
|
|
518
|
+
median_value = float(non_missing.median())
|
|
519
|
+
|
|
520
|
+
outcome_aligned = outcome_series.reindex(non_missing.index).fillna(False)
|
|
521
|
+
positive_values = non_missing[outcome_aligned]
|
|
522
|
+
outcome_positive_total = int(outcome_aligned.sum())
|
|
523
|
+
|
|
524
|
+
distinct_values, distinct_counts = np.unique(non_missing.to_numpy(), return_counts=True)
|
|
525
|
+
if len(distinct_values) <= 7:
|
|
526
|
+
value_counts = [int(v) for v in distinct_counts.tolist()]
|
|
527
|
+
exact_values = [float(v) for v in distinct_values.tolist()]
|
|
528
|
+
|
|
529
|
+
assert sum(value_counts) == len(non_missing)
|
|
530
|
+
|
|
531
|
+
outcome_positive_bin_counts = [
|
|
532
|
+
int((positive_values == val).sum()) for val in exact_values
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
'kind': 'numeric_discrete',
|
|
537
|
+
'total_count': total_count,
|
|
538
|
+
'missing_count': missing_count,
|
|
539
|
+
'min': min_value,
|
|
540
|
+
'median': median_value,
|
|
541
|
+
'max': max_value,
|
|
542
|
+
'bin_counts': value_counts,
|
|
543
|
+
'bin_edges': exact_values,
|
|
544
|
+
'outcome_positive_bin_counts': outcome_positive_bin_counts,
|
|
545
|
+
'outcome_positive_total': outcome_positive_total,
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
histogram, bin_edges = np.histogram(non_missing.to_numpy(), bins=7)
|
|
549
|
+
bin_counts = [int(v) for v in histogram.tolist()]
|
|
550
|
+
bin_edges_list = [float(v) for v in bin_edges.tolist()]
|
|
551
|
+
|
|
552
|
+
assert sum(bin_counts) == len(non_missing)
|
|
553
|
+
|
|
554
|
+
positive_histogram, _ = np.histogram(positive_values.to_numpy(), bins=bin_edges)
|
|
555
|
+
outcome_positive_bin_counts = [int(v) for v in positive_histogram.tolist()]
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
'kind': 'numeric',
|
|
559
|
+
'total_count': total_count,
|
|
560
|
+
'missing_count': missing_count,
|
|
561
|
+
'min': min_value,
|
|
562
|
+
'median': median_value,
|
|
563
|
+
'max': max_value,
|
|
564
|
+
'bin_counts': bin_counts,
|
|
565
|
+
'bin_edges': bin_edges_list,
|
|
566
|
+
'outcome_positive_bin_counts': outcome_positive_bin_counts,
|
|
567
|
+
'outcome_positive_total': outcome_positive_total,
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
def _build_categorical_feature_summary(self, series: Series) -> dict:
|
|
571
|
+
missing_count = int(series.isna().sum())
|
|
572
|
+
non_missing = series.dropna()
|
|
573
|
+
total_count = int(len(series))
|
|
574
|
+
|
|
575
|
+
if len(non_missing) == 0:
|
|
576
|
+
return {
|
|
577
|
+
'kind': 'empty',
|
|
578
|
+
'total_count': total_count,
|
|
579
|
+
'missing_count': missing_count,
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
counts = non_missing.astype(str).value_counts()
|
|
583
|
+
top_categories = []
|
|
584
|
+
total_non_missing = int(len(non_missing))
|
|
585
|
+
|
|
586
|
+
for label, count in counts.head(3).items():
|
|
587
|
+
top_categories.append({
|
|
588
|
+
'label': str(label),
|
|
589
|
+
'count': int(count),
|
|
590
|
+
'ratio': float(count) / total_non_missing,
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
'kind': 'categorical',
|
|
595
|
+
'total_count': total_count,
|
|
596
|
+
'missing_count': missing_count,
|
|
597
|
+
'top_categories': top_categories,
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
def _build_feature_summaries(self,
|
|
601
|
+
case_log: DataFrame,
|
|
602
|
+
feature_columns: list[str],
|
|
603
|
+
outcome: Series) -> dict:
|
|
604
|
+
summaries: dict[str, dict] = {}
|
|
605
|
+
|
|
606
|
+
for feature in feature_columns:
|
|
607
|
+
if feature not in case_log.columns:
|
|
608
|
+
raise ValueError(f"Feature '{feature}' not found in case log columns.")
|
|
609
|
+
|
|
610
|
+
series = case_log[feature]
|
|
611
|
+
if self._is_numeric_feature(series):
|
|
612
|
+
summaries[feature] = self._build_numeric_feature_summary(series, outcome)
|
|
613
|
+
else:
|
|
614
|
+
summaries[feature] = self._build_categorical_feature_summary(series)
|
|
615
|
+
|
|
616
|
+
return summaries
|
|
617
|
+
|
|
618
|
+
@staticmethod
|
|
619
|
+
def _feature_columns(metrics_per_rule_df: DataFrame) -> List[str]:
|
|
620
|
+
"""The rule table's feature columns: everything that is not a metric."""
|
|
621
|
+
return [col for col in metrics_per_rule_df.columns if col not in _METRIC_COLUMNS]
|
|
622
|
+
|
|
623
|
+
def _build_ruleset_rows(self, # pylint: disable=too-many-locals
|
|
624
|
+
analyzer: DnfRuleAnalyzer,
|
|
625
|
+
conjunctions: List[Conjunction],
|
|
626
|
+
with_annotations: bool) -> Tuple[List[dict], List[str]]:
|
|
627
|
+
"""Build the per-rule payload the ruleset table renders.
|
|
628
|
+
|
|
629
|
+
Identical for both rulesets bar the annotation, which only stored rules
|
|
630
|
+
carry. Returns the rows alongside the feature columns they were keyed by,
|
|
631
|
+
since the widget needs both and they are derived from the same frame.
|
|
632
|
+
"""
|
|
633
|
+
metrics_per_rule_df = analyzer.get_ruleset_aligned_with_metrics()
|
|
634
|
+
unique_per_rule = analyzer.get_unique_TP_FP_per_rule()
|
|
635
|
+
feature_columns = self._feature_columns(metrics_per_rule_df)
|
|
636
|
+
|
|
637
|
+
rules_data = []
|
|
638
|
+
for i, conjunction in enumerate(conjunctions):
|
|
639
|
+
metrics = metrics_per_rule_df.iloc[i]
|
|
640
|
+
u_tp = len(unique_per_rule[i]['unique_tp_cases'])
|
|
641
|
+
u_fp = len(unique_per_rule[i]['unique_fp_cases'])
|
|
642
|
+
t_tp = int(metrics['TP'])
|
|
643
|
+
t_fp = int(metrics['FP'])
|
|
644
|
+
iox_rule_id = self._ensure_rule_id(conjunction)
|
|
645
|
+
|
|
646
|
+
rule_dict = {
|
|
647
|
+
'iox_rule_id': iox_rule_id,
|
|
648
|
+
'rule_name': self.get_rule_name(iox_rule_id),
|
|
649
|
+
'precision': float(metrics['Precision']),
|
|
650
|
+
'recall': float(metrics['Recall']),
|
|
651
|
+
'tp': t_tp,
|
|
652
|
+
'fp': t_fp,
|
|
653
|
+
'fn': int(metrics['FN']),
|
|
654
|
+
'unique_tp': u_tp,
|
|
655
|
+
'non_unique_tp': t_tp - u_tp,
|
|
656
|
+
'unique_fp': u_fp,
|
|
657
|
+
'non_unique_fp': t_fp - u_fp,
|
|
658
|
+
}
|
|
659
|
+
if with_annotations:
|
|
660
|
+
rule_dict['annotation'] = self._stored_annotations.get(iox_rule_id, '')
|
|
661
|
+
rule_dict['features'] = {feat: str(metrics[feat]) for feat in feature_columns}
|
|
662
|
+
rules_data.append(rule_dict)
|
|
663
|
+
|
|
664
|
+
return rules_data, feature_columns
|
|
665
|
+
|
|
666
|
+
def get_stored_ruleset(self, on_refresh=None) -> RulesetTableWidget:
|
|
667
|
+
"""Return a table widget for stored rules with *metrics computed over the whole case log*.
|
|
668
|
+
|
|
669
|
+
The returned widget exposes inline controls to remove a stored rule
|
|
670
|
+
(with an optional note) and to view/edit the annotation attached when
|
|
671
|
+
the rule was stored.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
on_refresh: Optional callback invoked after a rule is removed or its
|
|
675
|
+
annotation is edited, e.g. to refresh dependent notebook cells.
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
`RulesetTableWidget` with rule-level metrics, TP/FP uniqueness
|
|
679
|
+
breakdown, and feature summaries computed on the full case log.
|
|
680
|
+
"""
|
|
681
|
+
rules_data, feature_columns = self._build_ruleset_rows(
|
|
682
|
+
self._stored_ruleset_analyzer,
|
|
683
|
+
self._stored_ruleset.conjunctions,
|
|
684
|
+
with_annotations=True,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
feature_summaries = self._build_feature_summaries(
|
|
688
|
+
self._case_log,
|
|
689
|
+
feature_columns,
|
|
690
|
+
self._outcome)
|
|
691
|
+
|
|
692
|
+
ruleset_table_widget = RulesetTableWidget(
|
|
693
|
+
rules=rules_data,
|
|
694
|
+
features=feature_columns,
|
|
695
|
+
feature_summaries=feature_summaries,
|
|
696
|
+
mode='stored',
|
|
697
|
+
)
|
|
698
|
+
ruleset_table_widget.set_action_context(
|
|
699
|
+
remove_callback=lambda rid, note: (
|
|
700
|
+
self.remove_rule_by_id(rid),
|
|
701
|
+
on_refresh() if on_refresh else None),
|
|
702
|
+
annotate_callback=lambda rid, note: (
|
|
703
|
+
self.set_stored_annotation_by_id(rid, note),
|
|
704
|
+
on_refresh() if on_refresh else None),
|
|
705
|
+
)
|
|
706
|
+
return ruleset_table_widget
|
|
707
|
+
|
|
708
|
+
def get_suggested_ruleset(self, on_refresh=None) -> RulesetTableWidget:
|
|
709
|
+
"""Return a table widget for suggested rules on unclaimed cases.
|
|
710
|
+
|
|
711
|
+
The returned widget exposes an inline control to store a suggested rule
|
|
712
|
+
(with an optional note), moving it into the stored ruleset.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
on_refresh: Optional callback invoked after a rule is stored, e.g.
|
|
716
|
+
to refresh dependent notebook cells.
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
`RulesetTableWidget` with rule-level metrics, TP/FP uniqueness
|
|
720
|
+
breakdown, and feature summaries *computed on unclaimed cases*.
|
|
721
|
+
"""
|
|
722
|
+
rules_data, feature_columns = self._build_ruleset_rows(
|
|
723
|
+
self._suggested_ruleset_analyzer,
|
|
724
|
+
self._suggested_ruleset.conjunctions,
|
|
725
|
+
with_annotations=False,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
feature_summaries = self._build_feature_summaries(
|
|
729
|
+
self._case_log[~self._explained_cases],
|
|
730
|
+
feature_columns,
|
|
731
|
+
self._outcome[~self._explained_cases]
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
ruleset_table_widget = RulesetTableWidget(
|
|
735
|
+
rules=rules_data,
|
|
736
|
+
features=feature_columns,
|
|
737
|
+
feature_summaries=feature_summaries,
|
|
738
|
+
mode='suggested',
|
|
739
|
+
)
|
|
740
|
+
# Editing does not touch the stored ruleset, so unlike storing it does
|
|
741
|
+
# not refresh the notebook: the rule goes straight into the live builder
|
|
742
|
+
# and the outcome is reported in this table, where the click happened.
|
|
743
|
+
def edit(iox_rule_id, _note):
|
|
744
|
+
sent, message = self.send_rule_to_builder(iox_rule_id)
|
|
745
|
+
ruleset_table_widget.notify('edit', 'ok' if sent else 'warn', message)
|
|
746
|
+
|
|
747
|
+
ruleset_table_widget.set_action_context(
|
|
748
|
+
store_callback=lambda rid, note: (
|
|
749
|
+
self.store_rule_by_id(rid, note),
|
|
750
|
+
on_refresh() if on_refresh else None),
|
|
751
|
+
edit_callback=edit,
|
|
752
|
+
)
|
|
753
|
+
return ruleset_table_widget
|
|
754
|
+
|
|
755
|
+
def get_stored_rule_detail(self, rule_index: int, on_refresh=None):
|
|
756
|
+
"""Return a rule detail widget for a single stored rule.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
rule_index: Index of the stored rule to show details for.
|
|
760
|
+
|
|
761
|
+
Returns:
|
|
762
|
+
A `RuleDetailWidget` showing feature distributions, predicate
|
|
763
|
+
boundaries, per-feature impact, and per-feature precision/recall,
|
|
764
|
+
*computed over the whole case log*.
|
|
765
|
+
|
|
766
|
+
Raises:
|
|
767
|
+
ValueError: If `rule_index` is out of range for the stored ruleset.
|
|
768
|
+
"""
|
|
769
|
+
num_rules = len(self._stored_ruleset.conjunctions)
|
|
770
|
+
if rule_index < 0 or rule_index >= num_rules:
|
|
771
|
+
raise ValueError(
|
|
772
|
+
f"Invalid rule index: {rule_index}. "
|
|
773
|
+
f"Must be between 0 and {num_rules - 1}."
|
|
774
|
+
)
|
|
775
|
+
rule_data = self._build_rule_detail_payload(
|
|
776
|
+
self._stored_ruleset_analyzer,
|
|
777
|
+
rule_index,
|
|
778
|
+
self._case_log,
|
|
779
|
+
self._outcome,
|
|
780
|
+
)
|
|
781
|
+
widget = self._new_rule_detail_widget('Stored Rule Details', rule_data)
|
|
782
|
+
widget.set_experiment_context(
|
|
783
|
+
self._stored_ruleset.conjunctions[rule_index],
|
|
784
|
+
self._case_log,
|
|
785
|
+
self._outcome,
|
|
786
|
+
store_callback=lambda rule, note='': (self.store_rule_raw(rule, note),
|
|
787
|
+
on_refresh() if on_refresh else None),
|
|
788
|
+
)
|
|
789
|
+
return widget
|
|
790
|
+
|
|
791
|
+
def get_suggested_rule_detail(self, rule_index: int, on_refresh=None):
|
|
792
|
+
"""Return a rule detail widget for a single suggested rule.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
rule_index: Index of the suggested rule to show details for.
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
A `RuleDetailWidget` showing feature distributions, predicate
|
|
799
|
+
boundaries, per-feature impact, and per-feature precision/recall,
|
|
800
|
+
*computed for unclaimed cases*.
|
|
801
|
+
|
|
802
|
+
Raises:
|
|
803
|
+
ValueError: If `rule_index` is out of range for the suggested ruleset.
|
|
804
|
+
"""
|
|
805
|
+
num_rules = len(self._suggested_ruleset.conjunctions)
|
|
806
|
+
if rule_index < 0 or rule_index >= num_rules:
|
|
807
|
+
raise ValueError(
|
|
808
|
+
f"Invalid rule index: {rule_index}. "
|
|
809
|
+
f"Must be between 0 and {num_rules - 1}."
|
|
810
|
+
)
|
|
811
|
+
case_log = self._case_log[~self._explained_cases]
|
|
812
|
+
outcome = self._outcome[~self._explained_cases]
|
|
813
|
+
rule_data = self._build_rule_detail_payload(
|
|
814
|
+
self._suggested_ruleset_analyzer,
|
|
815
|
+
rule_index,
|
|
816
|
+
case_log,
|
|
817
|
+
outcome,
|
|
818
|
+
)
|
|
819
|
+
widget = self._new_rule_detail_widget('Suggested Rule Details', rule_data)
|
|
820
|
+
widget.set_experiment_context(
|
|
821
|
+
self._suggested_ruleset.conjunctions[rule_index],
|
|
822
|
+
case_log,
|
|
823
|
+
outcome,
|
|
824
|
+
store_callback=lambda rule, note='': (self.store_rule_raw(rule, note),
|
|
825
|
+
on_refresh() if on_refresh else None),
|
|
826
|
+
)
|
|
827
|
+
return widget
|
|
828
|
+
|
|
829
|
+
def get_stored_rule_detail_by_id(self, iox_rule_id: int, on_refresh=None):
|
|
830
|
+
"""Return a rule detail widget for the stored rule with this stable id.
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
iox_rule_id: Stable id of a rule in the stored ruleset.
|
|
834
|
+
on_refresh: Optional callback invoked after a rule is stored.
|
|
835
|
+
|
|
836
|
+
Raises:
|
|
837
|
+
ValueError: If no stored rule carries this id.
|
|
838
|
+
"""
|
|
839
|
+
return self.get_stored_rule_detail(
|
|
840
|
+
self._require_stored_index(iox_rule_id), on_refresh=on_refresh
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def get_suggested_rule_detail_by_id(self, iox_rule_id: int, on_refresh=None):
|
|
844
|
+
"""Return a rule detail widget for the suggested rule with this stable id.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
iox_rule_id: Stable id of a rule in the suggested ruleset.
|
|
848
|
+
on_refresh: Optional callback invoked after a rule is stored.
|
|
849
|
+
|
|
850
|
+
Raises:
|
|
851
|
+
ValueError: If no suggested rule carries this id.
|
|
852
|
+
"""
|
|
853
|
+
return self.get_suggested_rule_detail(
|
|
854
|
+
self._require_suggested_index(iox_rule_id), on_refresh=on_refresh
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def get_rule_builder(self, scope: Literal['remaining', 'all'] = 'remaining',
|
|
858
|
+
on_refresh=None):
|
|
859
|
+
"""Return an interactive widget for building a rule from scratch.
|
|
860
|
+
|
|
861
|
+
The user drags/clicks attributes (ranked by tree-based importance) into
|
|
862
|
+
a canvas and sets boundaries directly on each attribute's distribution
|
|
863
|
+
(numerical) or picks values from count-ordered frequency bars
|
|
864
|
+
(categorical). Conditions combine with ``AND``. Evaluation
|
|
865
|
+
(precision/recall/TP/FP/FN) is computed on the Python side, and storing
|
|
866
|
+
the assembled rule appends it to the stored ruleset via
|
|
867
|
+
`store_rule_raw` (re-inducing suggestions).
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
scope: Data the builder operates on. ``'remaining'`` (default) uses
|
|
871
|
+
the unclaimed cases (no stored rule fires on them), negatives
|
|
872
|
+
included so that FP can be counted — the natural place to add
|
|
873
|
+
a new rule. ``'all'`` uses the whole case log.
|
|
874
|
+
on_refresh: Optional callback invoked after a rule is stored, e.g.
|
|
875
|
+
to refresh dependent notebook cells.
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
A `RuleBuilderWidget` populated with per-attribute summaries and
|
|
879
|
+
tree-based importances.
|
|
880
|
+
|
|
881
|
+
Raises:
|
|
882
|
+
ValueError: If `scope` is not ``'remaining'`` or ``'all'``.
|
|
883
|
+
"""
|
|
884
|
+
if scope == 'all':
|
|
885
|
+
case_log = self._case_log
|
|
886
|
+
outcome = self._outcome
|
|
887
|
+
title = 'Rule builder'
|
|
888
|
+
pool_label = 'cases'
|
|
889
|
+
elif scope == 'remaining':
|
|
890
|
+
case_log = self._case_log[~self._explained_cases]
|
|
891
|
+
outcome = self._outcome[~self._explained_cases]
|
|
892
|
+
title = 'Rule builder (unclaimed cases)'
|
|
893
|
+
pool_label = 'unclaimed cases'
|
|
894
|
+
else:
|
|
895
|
+
raise ValueError(
|
|
896
|
+
f"Invalid scope: {scope}. Must be 'remaining' or 'all'."
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
features = [feat for feat in self.get_unhidden_features()
|
|
900
|
+
if feat in case_log.columns]
|
|
901
|
+
attributes = self._build_rule_builder_attributes(case_log, outcome, features)
|
|
902
|
+
outcome_name = str(outcome.name) if outcome.name is not None else 'outcome'
|
|
903
|
+
|
|
904
|
+
# Restrict the builder's working data to the unhidden features so that
|
|
905
|
+
# evaluation and (crucially) the RIPPER optimizer can never use hidden
|
|
906
|
+
# columns such as the outcome itself — mirroring `rule_induction`, which
|
|
907
|
+
# drops hidden features before fitting. Otherwise the optimizer would
|
|
908
|
+
# pick the outcome column as a perfect (leaking) predictor.
|
|
909
|
+
builder_case_log = case_log[features]
|
|
910
|
+
|
|
911
|
+
# Storing a rule re-creates this widget (the host re-runs the cell on the
|
|
912
|
+
# now-smaller unclaimed case log), which would swallow the builder's own
|
|
913
|
+
# "stored" toast. Hand the confirmation to the successor widget instead;
|
|
914
|
+
# it is consumed here so it shows exactly once.
|
|
915
|
+
flash = self._pending_builder_flash
|
|
916
|
+
self._pending_builder_flash = ''
|
|
917
|
+
|
|
918
|
+
widget = self._new_rule_builder_widget(title, outcome_name, attributes)
|
|
919
|
+
widget.flash = flash
|
|
920
|
+
widget.pool_label = pool_label
|
|
921
|
+
widget.set_context(
|
|
922
|
+
builder_case_log,
|
|
923
|
+
outcome,
|
|
924
|
+
store_callback=lambda rule, note='': self._store_from_rule_builder(
|
|
925
|
+
rule, note, on_refresh),
|
|
926
|
+
)
|
|
927
|
+
self._rule_builder = widget
|
|
928
|
+
return widget
|
|
929
|
+
|
|
930
|
+
def send_rule_to_builder(self, iox_rule_id: int) -> Tuple[bool, str]:
|
|
931
|
+
"""Load a suggested rule into the rule builder so it can be edited.
|
|
932
|
+
|
|
933
|
+
Unlike storing or removing, this changes nothing in `InterOX`: the rule
|
|
934
|
+
is written straight into the builder widget the notebook is currently
|
|
935
|
+
showing, which puts it on the canvas and evaluates it. Whatever was being
|
|
936
|
+
built there is discarded.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
iox_rule_id: Stable id of a rule in the suggested ruleset.
|
|
940
|
+
|
|
941
|
+
Returns:
|
|
942
|
+
``(True, message)`` once the rule is on its way to the builder, or
|
|
943
|
+
``(False, message)`` if this notebook has no rule builder to send it
|
|
944
|
+
to. The message is meant to be shown to the user as-is.
|
|
945
|
+
|
|
946
|
+
Raises:
|
|
947
|
+
ValueError: If no suggested rule carries this id.
|
|
948
|
+
"""
|
|
949
|
+
rule = self._suggested_ruleset.conjunctions[
|
|
950
|
+
self._require_suggested_index(iox_rule_id)
|
|
951
|
+
]
|
|
952
|
+
name = self.get_rule_name(iox_rule_id)
|
|
953
|
+
if self._rule_builder is None:
|
|
954
|
+
return False, ('No rule builder in this notebook — '
|
|
955
|
+
'add a cell with iox.get_rule_builder() first.')
|
|
956
|
+
self._rule_builder.load_conditions(conjunction_to_conditions(rule), label=name)
|
|
957
|
+
return True, f'{name} is now editable in the rule builder below.'
|
|
958
|
+
|
|
959
|
+
def _store_from_rule_builder(self, rule: Conjunction, note: str, on_refresh):
|
|
960
|
+
self.store_rule_raw(rule, note)
|
|
961
|
+
stored_rule = self._stored_ruleset.conjunctions[
|
|
962
|
+
self._stored_ruleset.conjunctions.index(rule)
|
|
963
|
+
]
|
|
964
|
+
name = self.get_rule_name(self._ensure_rule_id(stored_rule))
|
|
965
|
+
self._pending_builder_flash = f'Stored ✓ {name}'
|
|
966
|
+
if on_refresh:
|
|
967
|
+
on_refresh()
|
|
968
|
+
|
|
969
|
+
def get_hidden_features(self) -> List[str]:
|
|
970
|
+
"""Return currently hidden feature patterns.
|
|
971
|
+
|
|
972
|
+
Returns:
|
|
973
|
+
List of feature names or regex patterns excluded from suggested
|
|
974
|
+
rule induction.
|
|
975
|
+
"""
|
|
976
|
+
return self._hidden_feats
|
|
977
|
+
|
|
978
|
+
def get_unhidden_features(self) -> List[str]:
|
|
979
|
+
""" Get a list of all available features in the
|
|
980
|
+
whole case log that are not currently hidden. """
|
|
981
|
+
return [feat for feat in self._case_log.columns
|
|
982
|
+
if not feature_is_hidden(feat, self._hidden_feats)]
|
|
983
|
+
|
|
984
|
+
def _reinduce_suggestions(self):
|
|
985
|
+
"""Re-induce the suggested ruleset over the currently unclaimed cases.
|
|
986
|
+
|
|
987
|
+
Called whenever the input to induction changes: the stored ruleset (which
|
|
988
|
+
moves the boundary between claimed and unclaimed) or the hidden
|
|
989
|
+
features (which change the columns induction may use).
|
|
990
|
+
"""
|
|
991
|
+
remaining = ~self._explained_cases
|
|
992
|
+
self._suggested_ruleset = rule_induction(
|
|
993
|
+
self._case_log[remaining], self._outcome[remaining], self._hidden_feats
|
|
994
|
+
)
|
|
995
|
+
self._suggested_ruleset_analyzer = DnfRuleAnalyzer(
|
|
996
|
+
self._suggested_ruleset,
|
|
997
|
+
self._case_log[remaining],
|
|
998
|
+
self._outcome[remaining]
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
def _refresh_after_ruleset_change(self):
|
|
1002
|
+
"""Recompute every piece of state derived from the stored ruleset."""
|
|
1003
|
+
self._explained_cases = self._case_log.apply(
|
|
1004
|
+
ruleset_to_lambda(self._stored_ruleset), axis=1
|
|
1005
|
+
)
|
|
1006
|
+
self._reinduce_suggestions()
|
|
1007
|
+
self._stored_ruleset_analyzer = DnfRuleAnalyzer(
|
|
1008
|
+
self._stored_ruleset,
|
|
1009
|
+
self._case_log,
|
|
1010
|
+
self._outcome
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
def store_rule_raw(self, rule: Conjunction, annotation: str = ''):
|
|
1014
|
+
"""Store a raw rule and recompute derived explanation state.
|
|
1015
|
+
|
|
1016
|
+
This is the advanced variant of `store_rule` and accepts any
|
|
1017
|
+
`Conjunction`, including rules not currently present in the suggested
|
|
1018
|
+
ruleset.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
rule: Rule conjunction to append to the stored ruleset.
|
|
1022
|
+
annotation: Optional free-text note explaining why this rule was
|
|
1023
|
+
stored. Retained alongside the rule and viewable in the stored
|
|
1024
|
+
ruleset table.
|
|
1025
|
+
|
|
1026
|
+
Notes:
|
|
1027
|
+
This method updates claimed-case flags, re-induces the suggested
|
|
1028
|
+
ruleset on the unclaimed cases, and rebuilds analyzers.
|
|
1029
|
+
Might fail if the provided rule is not compatible with the case log features.
|
|
1030
|
+
"""
|
|
1031
|
+
self._stored_ruleset.add_conjunction(rule)
|
|
1032
|
+
# add_conjunction dedups on predicate-set equality, so the object now in
|
|
1033
|
+
# the ruleset is either the pre-existing duplicate or the one just added.
|
|
1034
|
+
stored_rule = self._stored_ruleset.conjunctions[
|
|
1035
|
+
self._stored_ruleset.conjunctions.index(rule)
|
|
1036
|
+
]
|
|
1037
|
+
iox_rule_id = self._ensure_rule_id(stored_rule)
|
|
1038
|
+
if annotation:
|
|
1039
|
+
self._stored_annotations[iox_rule_id] = str(annotation)
|
|
1040
|
+
self._refresh_after_ruleset_change()
|
|
1041
|
+
|
|
1042
|
+
def store_rule(self, i: int, annotation: str = ''):
|
|
1043
|
+
"""Store one rule from the suggested ruleset.
|
|
1044
|
+
|
|
1045
|
+
Args:
|
|
1046
|
+
i: Index of the suggested rule to store.
|
|
1047
|
+
annotation: Optional free-text note explaining why this rule was
|
|
1048
|
+
stored.
|
|
1049
|
+
|
|
1050
|
+
Raises:
|
|
1051
|
+
ValueError: If `i` is out of range for the suggested ruleset.
|
|
1052
|
+
|
|
1053
|
+
Notes:
|
|
1054
|
+
Delegates to `store_rule_raw` and therefore updates claimed-case
|
|
1055
|
+
flags, re-induces suggestions, and refreshes analyzers.
|
|
1056
|
+
"""
|
|
1057
|
+
if i < 0 or i >= len(self._suggested_ruleset.conjunctions):
|
|
1058
|
+
raise ValueError(
|
|
1059
|
+
f"Invalid rule index: {i}. "
|
|
1060
|
+
f"Must be between 0 and {len(self._suggested_ruleset.conjunctions) - 1}."
|
|
1061
|
+
)
|
|
1062
|
+
rule_to_store = self._suggested_ruleset.conjunctions[i]
|
|
1063
|
+
self.store_rule_raw(rule_to_store, annotation)
|
|
1064
|
+
|
|
1065
|
+
def remove_rule(self, i: int):
|
|
1066
|
+
"""Remove a rule from the stored ruleset.
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
i: Index of the rule to remove.
|
|
1070
|
+
|
|
1071
|
+
Raises:
|
|
1072
|
+
ValueError: If `i` is out of range for the stored ruleset.
|
|
1073
|
+
|
|
1074
|
+
Notes:
|
|
1075
|
+
Updates claimed-case
|
|
1076
|
+
flags, re-induces suggestions, and refreshes analyzers.
|
|
1077
|
+
"""
|
|
1078
|
+
if i < 0 or i >= len(self._stored_ruleset.conjunctions):
|
|
1079
|
+
raise ValueError(
|
|
1080
|
+
f"Invalid rule index: {i}. "
|
|
1081
|
+
f"Must be between 0 and {len(self._stored_ruleset.conjunctions) - 1}."
|
|
1082
|
+
)
|
|
1083
|
+
rule_to_remove = self._stored_ruleset.conjunctions[i]
|
|
1084
|
+
iox_rule_id = getattr(rule_to_remove, 'iox_rule_id', None)
|
|
1085
|
+
self._stored_ruleset.remove_conjunction(rule_to_remove)
|
|
1086
|
+
if iox_rule_id is not None:
|
|
1087
|
+
self._stored_annotations.pop(iox_rule_id, None)
|
|
1088
|
+
self._rule_names.pop(iox_rule_id, None)
|
|
1089
|
+
self._refresh_after_ruleset_change()
|
|
1090
|
+
|
|
1091
|
+
def get_stored_annotation(self, i: int) -> str:
|
|
1092
|
+
"""Return the annotation attached to stored rule `i`.
|
|
1093
|
+
|
|
1094
|
+
Args:
|
|
1095
|
+
i: Index of the stored rule.
|
|
1096
|
+
|
|
1097
|
+
Returns:
|
|
1098
|
+
The free-text annotation, or an empty string if none was set.
|
|
1099
|
+
|
|
1100
|
+
Raises:
|
|
1101
|
+
ValueError: If `i` is out of range for the stored ruleset.
|
|
1102
|
+
"""
|
|
1103
|
+
self._validate_rule_index(i, len(self._stored_ruleset.conjunctions), 'stored')
|
|
1104
|
+
iox_rule_id = self._ensure_rule_id(self._stored_ruleset.conjunctions[i])
|
|
1105
|
+
return self._stored_annotations.get(iox_rule_id, '')
|
|
1106
|
+
|
|
1107
|
+
def set_stored_annotation(self, i: int, annotation: str):
|
|
1108
|
+
"""Set or update the annotation attached to stored rule `i`.
|
|
1109
|
+
|
|
1110
|
+
Args:
|
|
1111
|
+
i: Index of the stored rule.
|
|
1112
|
+
annotation: Free-text note to store for the rule.
|
|
1113
|
+
|
|
1114
|
+
Raises:
|
|
1115
|
+
ValueError: If `i` is out of range for the stored ruleset.
|
|
1116
|
+
"""
|
|
1117
|
+
self._validate_rule_index(i, len(self._stored_ruleset.conjunctions), 'stored')
|
|
1118
|
+
iox_rule_id = self._ensure_rule_id(self._stored_ruleset.conjunctions[i])
|
|
1119
|
+
if annotation:
|
|
1120
|
+
self._stored_annotations[iox_rule_id] = str(annotation)
|
|
1121
|
+
else:
|
|
1122
|
+
self._stored_annotations.pop(iox_rule_id, None)
|
|
1123
|
+
|
|
1124
|
+
def _stored_index_by_id(self, iox_rule_id: int):
|
|
1125
|
+
for idx, conj in enumerate(self._stored_ruleset.conjunctions):
|
|
1126
|
+
if getattr(conj, 'iox_rule_id', None) == iox_rule_id:
|
|
1127
|
+
return idx
|
|
1128
|
+
return None
|
|
1129
|
+
|
|
1130
|
+
def _suggested_index_by_id(self, iox_rule_id: int):
|
|
1131
|
+
for idx, conj in enumerate(self._suggested_ruleset.conjunctions):
|
|
1132
|
+
if getattr(conj, 'iox_rule_id', None) == iox_rule_id:
|
|
1133
|
+
return idx
|
|
1134
|
+
return None
|
|
1135
|
+
|
|
1136
|
+
def store_rule_by_id(self, iox_rule_id: int, annotation: str = ''):
|
|
1137
|
+
"""Store the suggested rule with the given stable id.
|
|
1138
|
+
|
|
1139
|
+
Args:
|
|
1140
|
+
iox_rule_id: Stable id of a rule currently in the suggested ruleset.
|
|
1141
|
+
annotation: Optional note attached to the stored rule.
|
|
1142
|
+
|
|
1143
|
+
Notes:
|
|
1144
|
+
No-op if no suggested rule currently carries this id (e.g. a stale
|
|
1145
|
+
id from a superseded induction).
|
|
1146
|
+
"""
|
|
1147
|
+
idx = self._suggested_index_by_id(iox_rule_id)
|
|
1148
|
+
if idx is not None:
|
|
1149
|
+
self.store_rule(idx, annotation)
|
|
1150
|
+
|
|
1151
|
+
def remove_rule_by_id(self, iox_rule_id: int):
|
|
1152
|
+
"""Remove the stored rule with the given stable id.
|
|
1153
|
+
|
|
1154
|
+
Notes:
|
|
1155
|
+
No-op if no stored rule currently carries this id.
|
|
1156
|
+
"""
|
|
1157
|
+
idx = self._stored_index_by_id(iox_rule_id)
|
|
1158
|
+
if idx is not None:
|
|
1159
|
+
self.remove_rule(idx)
|
|
1160
|
+
|
|
1161
|
+
def set_stored_annotation_by_id(self, iox_rule_id: int, annotation: str):
|
|
1162
|
+
"""Set or update the annotation of the stored rule with this id.
|
|
1163
|
+
|
|
1164
|
+
Notes:
|
|
1165
|
+
No-op if no stored rule currently carries this id.
|
|
1166
|
+
"""
|
|
1167
|
+
idx = self._stored_index_by_id(iox_rule_id)
|
|
1168
|
+
if idx is not None:
|
|
1169
|
+
self.set_stored_annotation(idx, annotation)
|
|
1170
|
+
|
|
1171
|
+
def hide_feature(self, feature: str):
|
|
1172
|
+
"""Hide a feature pattern from future suggested-rule induction.
|
|
1173
|
+
|
|
1174
|
+
Args:
|
|
1175
|
+
feature: Feature name or regex pattern to hide.
|
|
1176
|
+
|
|
1177
|
+
Raises:
|
|
1178
|
+
ValueError: If `feature` is already hidden.
|
|
1179
|
+
|
|
1180
|
+
Notes:
|
|
1181
|
+
Stored rules are not modified. Suggested rules are re-induced from
|
|
1182
|
+
the unclaimed cases with the updated hidden-feature list.
|
|
1183
|
+
"""
|
|
1184
|
+
if feature in self._hidden_feats:
|
|
1185
|
+
raise ValueError(f"Feature '{feature}' is already hidden.")
|
|
1186
|
+
self._hidden_feats.append(feature)
|
|
1187
|
+
self._reinduce_suggestions()
|
|
1188
|
+
|
|
1189
|
+
def unhide_feature(self, feature: str):
|
|
1190
|
+
"""Unhide a previously hidden feature pattern.
|
|
1191
|
+
|
|
1192
|
+
Args:
|
|
1193
|
+
feature: Feature name or regex pattern to remove from hidden
|
|
1194
|
+
features.
|
|
1195
|
+
|
|
1196
|
+
Raises:
|
|
1197
|
+
ValueError: If `feature` is not currently hidden.
|
|
1198
|
+
|
|
1199
|
+
Notes:
|
|
1200
|
+
Stored rules are not modified. Suggested rules are re-induced from
|
|
1201
|
+
the unclaimed cases with the updated hidden-feature list.
|
|
1202
|
+
"""
|
|
1203
|
+
if feature not in self._hidden_feats:
|
|
1204
|
+
raise ValueError(f"Feature '{feature}' is not hidden.")
|
|
1205
|
+
self._hidden_feats.remove(feature)
|
|
1206
|
+
self._reinduce_suggestions()
|
|
1207
|
+
|
|
1208
|
+
def set_hidden_features(self, features: List[str]):
|
|
1209
|
+
"""Replace the hidden-feature patterns in one step.
|
|
1210
|
+
|
|
1211
|
+
Args:
|
|
1212
|
+
features: Feature names or regex patterns to hide; every pattern
|
|
1213
|
+
not in this list is unhidden.
|
|
1214
|
+
|
|
1215
|
+
Notes:
|
|
1216
|
+
Stored rules are not modified. Suggested rules are re-induced once,
|
|
1217
|
+
however many features change, which is why hiding several features
|
|
1218
|
+
should go through here rather than repeated `hide_feature` calls.
|
|
1219
|
+
"""
|
|
1220
|
+
# Copied so the caller's list (e.g. a UI element's value) and ours
|
|
1221
|
+
# can never change each other.
|
|
1222
|
+
self._hidden_feats = list(features)
|
|
1223
|
+
self._reinduce_suggestions()
|
|
1224
|
+
|
|
1225
|
+
def _validate_case_filter(self, filter_name: str, value: CaseFilter) -> CaseFilter:
|
|
1226
|
+
normalized = str(value).lower()
|
|
1227
|
+
valid_values = {'any', 'positive', 'negative'}
|
|
1228
|
+
if normalized not in valid_values:
|
|
1229
|
+
raise ValueError(
|
|
1230
|
+
f"Invalid {filter_name} filter: {value}. "
|
|
1231
|
+
"Expected one of {'any', 'positive', 'negative'}."
|
|
1232
|
+
)
|
|
1233
|
+
return cast(CaseFilter, normalized)
|
|
1234
|
+
|
|
1235
|
+
def _validate_rule_index(self, i: int, num_rules: int, ruleset_name: str):
|
|
1236
|
+
if i < 0 or i >= num_rules:
|
|
1237
|
+
raise ValueError(
|
|
1238
|
+
f"Invalid rule index: {i}. "
|
|
1239
|
+
f"Must be between 0 and {num_rules - 1} for {ruleset_name} ruleset."
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
def _mask_by_outcome(self, index: pd.Index, outcome_filter: CaseFilter) -> "Series[bool]":
|
|
1243
|
+
normalized = self._validate_case_filter('outcome', outcome_filter)
|
|
1244
|
+
mask = self._outcome.loc[index]
|
|
1245
|
+
if normalized == 'positive':
|
|
1246
|
+
return mask
|
|
1247
|
+
if normalized == 'negative':
|
|
1248
|
+
return ~mask
|
|
1249
|
+
return Series([True] * len(index), index=index)
|
|
1250
|
+
|
|
1251
|
+
def _mask_by_ruleset(self, index: pd.Index,
|
|
1252
|
+
ruleset_filter: CaseFilter,
|
|
1253
|
+
ruleset_positive_mask: "Series[bool]") -> "Series[bool]":
|
|
1254
|
+
normalized = self._validate_case_filter('ruleset', ruleset_filter)
|
|
1255
|
+
mask = ruleset_positive_mask.loc[index]
|
|
1256
|
+
if normalized == 'positive':
|
|
1257
|
+
return mask
|
|
1258
|
+
if normalized == 'negative':
|
|
1259
|
+
return ~mask
|
|
1260
|
+
return Series([True] * len(index), index=index)
|
|
1261
|
+
|
|
1262
|
+
def _combine_case_filters(self,
|
|
1263
|
+
case_log: DataFrame,
|
|
1264
|
+
outcome: CaseFilter,
|
|
1265
|
+
ruleset: CaseFilter,
|
|
1266
|
+
ruleset_positive_mask: "Series[bool]") -> "Series[bool]":
|
|
1267
|
+
outcome_mask = self._mask_by_outcome(case_log.index, outcome)
|
|
1268
|
+
ruleset_mask = self._mask_by_ruleset(case_log.index, ruleset, ruleset_positive_mask)
|
|
1269
|
+
return outcome_mask & ruleset_mask
|
|
1270
|
+
|
|
1271
|
+
def get_whole_case_log(self,
|
|
1272
|
+
outcome: CaseFilter = 'any',
|
|
1273
|
+
ruleset: CaseFilter = 'any') -> DataFrame:
|
|
1274
|
+
"""Return the full original case log, optionally filtered.
|
|
1275
|
+
|
|
1276
|
+
Filter semantics for this method:
|
|
1277
|
+
- `outcome='positive'|'negative'` filters by ground-truth outcome.
|
|
1278
|
+
- `ruleset='positive'|'negative'` filters by the stored ruleset
|
|
1279
|
+
evaluation over the full case log (`positive` means explained/
|
|
1280
|
+
matched by stored ruleset).
|
|
1281
|
+
- `outcome` and `ruleset` are combined with logical AND.
|
|
1282
|
+
|
|
1283
|
+
Returns:
|
|
1284
|
+
DataFrame used to initialize this `InterOX` instance, filtered by
|
|
1285
|
+
`outcome` and `ruleset` if provided.
|
|
1286
|
+
|
|
1287
|
+
Raises:
|
|
1288
|
+
ValueError: If either filter is not one of
|
|
1289
|
+
`{'any', 'positive', 'negative'}`.
|
|
1290
|
+
"""
|
|
1291
|
+
combined_mask = self._combine_case_filters(
|
|
1292
|
+
self._case_log,
|
|
1293
|
+
outcome,
|
|
1294
|
+
ruleset,
|
|
1295
|
+
self._explained_cases
|
|
1296
|
+
)
|
|
1297
|
+
return self._case_log[combined_mask]
|
|
1298
|
+
|
|
1299
|
+
def get_remaining_unexpl_case_log(self,
|
|
1300
|
+
outcome: CaseFilter = 'any',
|
|
1301
|
+
ruleset: CaseFilter = 'any') -> DataFrame:
|
|
1302
|
+
"""Return the unclaimed cases, optionally filtered.
|
|
1303
|
+
|
|
1304
|
+
Filter semantics for this method:
|
|
1305
|
+
- baseline subset is the unclaimed cases (no stored rule fires on
|
|
1306
|
+
them), negatives included.
|
|
1307
|
+
- `outcome='positive'|'negative'` filters by ground-truth outcome.
|
|
1308
|
+
- `ruleset='positive'|'negative'` filters by suggested ruleset
|
|
1309
|
+
evaluation on the unclaimed cases (`positive` means matched
|
|
1310
|
+
by suggested ruleset).
|
|
1311
|
+
- `outcome` and `ruleset` are combined with logical AND.
|
|
1312
|
+
|
|
1313
|
+
Returns:
|
|
1314
|
+
Subset of the case log where stored rules currently evaluate to
|
|
1315
|
+
`False`, optionally filtered by `outcome` and suggested-ruleset
|
|
1316
|
+
evaluation.
|
|
1317
|
+
|
|
1318
|
+
Raises:
|
|
1319
|
+
ValueError: If either filter is not one of
|
|
1320
|
+
`{'any', 'positive', 'negative'}`.
|
|
1321
|
+
"""
|
|
1322
|
+
remaining_case_log = self._case_log[~self._explained_cases]
|
|
1323
|
+
suggested_ruleset_mask = remaining_case_log.apply(
|
|
1324
|
+
ruleset_to_lambda(self._suggested_ruleset), axis=1
|
|
1325
|
+
)
|
|
1326
|
+
combined_mask = self._combine_case_filters(
|
|
1327
|
+
remaining_case_log,
|
|
1328
|
+
outcome,
|
|
1329
|
+
ruleset,
|
|
1330
|
+
suggested_ruleset_mask
|
|
1331
|
+
)
|
|
1332
|
+
return remaining_case_log[combined_mask]
|
|
1333
|
+
|
|
1334
|
+
def get_data_suggested_rule(self,
|
|
1335
|
+
i: int,
|
|
1336
|
+
outcome: CaseFilter = 'any',
|
|
1337
|
+
pass_: bool = True) -> DataFrame:
|
|
1338
|
+
"""Return unclaimed cases that pass/fail suggested rule `i`.
|
|
1339
|
+
|
|
1340
|
+
Args:
|
|
1341
|
+
i: Index of the suggested rule.
|
|
1342
|
+
outcome: Ground-truth outcome filter (`any`, `positive`,
|
|
1343
|
+
or `negative`) applied before rule pass/fail filtering.
|
|
1344
|
+
pass_: If `True`, return cases where rule `i` evaluates to `True`.
|
|
1345
|
+
If `False`, return cases where rule `i` evaluates to `False`.
|
|
1346
|
+
|
|
1347
|
+
Returns:
|
|
1348
|
+
DataFrame of unclaimed cases selected by pass/fail
|
|
1349
|
+
evaluation for suggested rule `i`.
|
|
1350
|
+
|
|
1351
|
+
Raises:
|
|
1352
|
+
ValueError: If `i` is out of range for the suggested ruleset.
|
|
1353
|
+
ValueError: If `outcome` is not one of
|
|
1354
|
+
`{'any', 'positive', 'negative'}`.
|
|
1355
|
+
"""
|
|
1356
|
+
num_rules = len(self._suggested_ruleset.conjunctions)
|
|
1357
|
+
self._validate_rule_index(i, num_rules, 'suggested')
|
|
1358
|
+
|
|
1359
|
+
filtered_case_log = self.get_remaining_unexpl_case_log(outcome=outcome, ruleset='any')
|
|
1360
|
+
rule_mask = filtered_case_log.apply(
|
|
1361
|
+
rule_to_lambda(self._suggested_ruleset.conjunctions[i]), axis=1
|
|
1362
|
+
)
|
|
1363
|
+
return filtered_case_log[rule_mask] if pass_ else filtered_case_log[~rule_mask]
|
|
1364
|
+
|
|
1365
|
+
def get_data_stored_rule(self,
|
|
1366
|
+
i: int,
|
|
1367
|
+
outcome: CaseFilter = 'any',
|
|
1368
|
+
pass_: bool = True) -> DataFrame:
|
|
1369
|
+
"""Return full-case-log data that pass/fail stored rule `i`.
|
|
1370
|
+
|
|
1371
|
+
Args:
|
|
1372
|
+
i: Index of the stored rule.
|
|
1373
|
+
outcome: Ground-truth outcome filter (`any`, `positive`,
|
|
1374
|
+
or `negative`) applied before rule pass/fail filtering.
|
|
1375
|
+
pass_: If `True`, return cases where rule `i` evaluates to `True`.
|
|
1376
|
+
If `False`, return cases where rule `i` evaluates to `False`.
|
|
1377
|
+
|
|
1378
|
+
Returns:
|
|
1379
|
+
DataFrame of full case-log rows selected by optional `outcome`
|
|
1380
|
+
filtering and pass/fail evaluation for stored rule `i`.
|
|
1381
|
+
|
|
1382
|
+
Raises:
|
|
1383
|
+
ValueError: If `i` is out of range for the stored ruleset.
|
|
1384
|
+
ValueError: If `outcome` is not one of
|
|
1385
|
+
`{'any', 'positive', 'negative'}`.
|
|
1386
|
+
"""
|
|
1387
|
+
num_rules = len(self._stored_ruleset.conjunctions)
|
|
1388
|
+
self._validate_rule_index(i, num_rules, 'stored')
|
|
1389
|
+
|
|
1390
|
+
filtered_case_log = self.get_whole_case_log(outcome=outcome, ruleset='any')
|
|
1391
|
+
rule_mask = filtered_case_log.apply(
|
|
1392
|
+
rule_to_lambda(self._stored_ruleset.conjunctions[i]), axis=1
|
|
1393
|
+
)
|
|
1394
|
+
return filtered_case_log[rule_mask] if pass_ else filtered_case_log[~rule_mask]
|
|
1395
|
+
|
|
1396
|
+
def get_data_stored_rule_by_id(self,
|
|
1397
|
+
iox_rule_id: int,
|
|
1398
|
+
outcome: CaseFilter = 'any',
|
|
1399
|
+
pass_: bool = True) -> DataFrame:
|
|
1400
|
+
"""Return full-case-log data that pass/fail the stored rule with this id.
|
|
1401
|
+
|
|
1402
|
+
See `get_data_stored_rule`.
|
|
1403
|
+
|
|
1404
|
+
Raises:
|
|
1405
|
+
ValueError: If no stored rule carries this id.
|
|
1406
|
+
"""
|
|
1407
|
+
return self.get_data_stored_rule(
|
|
1408
|
+
self._require_stored_index(iox_rule_id), outcome=outcome, pass_=pass_
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
def get_data_suggested_rule_by_id(self,
|
|
1412
|
+
iox_rule_id: int,
|
|
1413
|
+
outcome: CaseFilter = 'any',
|
|
1414
|
+
pass_: bool = True) -> DataFrame:
|
|
1415
|
+
"""Return unclaimed cases that pass/fail the suggested rule with this
|
|
1416
|
+
id.
|
|
1417
|
+
|
|
1418
|
+
See `get_data_suggested_rule`.
|
|
1419
|
+
|
|
1420
|
+
Raises:
|
|
1421
|
+
ValueError: If no suggested rule carries this id.
|
|
1422
|
+
"""
|
|
1423
|
+
return self.get_data_suggested_rule(
|
|
1424
|
+
self._require_suggested_index(iox_rule_id), outcome=outcome, pass_=pass_
|
|
1425
|
+
)
|