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/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
+ )