DPG 0.1.5__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.
dpg/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ # dpg/__init__.py
2
+ from .core import DecisionPredicateGraph
3
+ from .explainer import (
4
+ DPGExplainer,
5
+ DPGExplanation,
6
+ DPGLocalExplanation,
7
+ DPGTreePathExplanation,
8
+ )
9
+ from .themes import DPG_CLASS_PALETTE, DPG_COLORS, DPG_OLIVE_CLASS_PALETTE, resolve_theme_context
10
+ from .visualizer import (
11
+ class_feature_predicate_counts,
12
+ class_lookup_from_target_names,
13
+ classwise_feature_bounds_from_communities,
14
+ plot_class_feature_complexity,
15
+ plot_dpg,
16
+ plot_dpg_class_bounds_vs_dataset_feature_ranges,
17
+ plot_dpg_constraints_overview,
18
+ plot_dpg_local_paths_aggregate,
19
+ plot_dpg_reg,
20
+ plot_lec_vs_rf_importance,
21
+ plot_lrc_vs_rf_importance,
22
+ plot_sample_using_bc_weights,
23
+ plot_top_lrc_predicate_splits,
24
+ sample_bc_weights,
25
+ )
26
+
27
+ __all__ = [
28
+ "DecisionPredicateGraph",
29
+ "DPGExplainer",
30
+ "DPGExplanation",
31
+ "DPGLocalExplanation",
32
+ "DPGTreePathExplanation",
33
+ "DPG_COLORS",
34
+ "DPG_CLASS_PALETTE",
35
+ "DPG_OLIVE_CLASS_PALETTE",
36
+ "resolve_theme_context",
37
+ "plot_dpg",
38
+ "plot_dpg_local_paths_aggregate",
39
+ "plot_dpg_reg",
40
+ "plot_dpg_constraints_overview",
41
+ "plot_lrc_vs_rf_importance",
42
+ "plot_lec_vs_rf_importance",
43
+ "plot_top_lrc_predicate_splits",
44
+ "sample_bc_weights",
45
+ "plot_sample_using_bc_weights",
46
+ "class_feature_predicate_counts",
47
+ "classwise_feature_bounds_from_communities",
48
+ "plot_class_feature_complexity",
49
+ "plot_dpg_class_bounds_vs_dataset_feature_ranges",
50
+ "class_lookup_from_target_names",
51
+ ]
dpg/core.py ADDED
@@ -0,0 +1,512 @@
1
+ import pandas as pd
2
+ pd.set_option("display.max_colwidth", 255)
3
+ import re
4
+ import math
5
+ import os
6
+ import numpy as np
7
+
8
+ from tqdm import tqdm
9
+ import graphviz
10
+ import networkx as nx
11
+ import hashlib
12
+ import yaml
13
+ from joblib import Parallel, delayed
14
+
15
+ from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple, Union
16
+ from sklearn.base import is_classifier, is_regressor
17
+
18
+ # Handle OmegaConf DictConfig if available
19
+ try:
20
+ from omegaconf import DictConfig, OmegaConf
21
+ HAS_OMEGACONF = True
22
+ except ImportError:
23
+ HAS_OMEGACONF = False
24
+
25
+ from sklearn.ensemble import (
26
+ AdaBoostRegressor,
27
+ RandomForestRegressor,
28
+ ExtraTreesRegressor,
29
+ GradientBoostingClassifier,
30
+ GradientBoostingRegressor,
31
+ )
32
+ from dpg.sklearn_normalizer import SklearnEnsembleNormalizer
33
+
34
+ DEFAULT_DPG_CONFIG = {
35
+ "dpg": {
36
+ "default": {
37
+ "perc_var": 0.000000001,
38
+ "decimal_threshold": 6,
39
+ "n_jobs": -1,
40
+ },
41
+ "graph_construction": {
42
+ "mode": "aggregated_transitions",
43
+ },
44
+ "visualization": {},
45
+ }
46
+ }
47
+
48
+ class DPGError(Exception):
49
+ """Base exception class for DPG-specific errors"""
50
+ pass
51
+
52
+
53
+ class DecisionPredicateGraph:
54
+ SUPPORTED_GRAPH_CONSTRUCTION_MODES = {
55
+ "aggregated_transitions",
56
+ "execution_trace",
57
+ }
58
+
59
+ """
60
+ Main class for converting tree-based ensemble models into interpretable graphs.
61
+
62
+ Converts the internal decision paths of a tree-based ensemble (Random Forest,
63
+ AdaBoost, Extra Trees, …) into a compact directed graph — the
64
+ *Decision Predicate Graph* — that exposes which feature conditions the model
65
+ uses, how often, and in what order.
66
+ """
67
+ def __init__(
68
+ self,
69
+ model: Any,
70
+ feature_names: Iterable[str],
71
+ target_names: Optional[Iterable[str]] = None,
72
+ config_file: str = "config.yaml",
73
+ dpg_config: Optional[Dict[str, Any]] = None,
74
+ ) -> None:
75
+ """
76
+ Initialize DPG converter with model and configuration.
77
+
78
+ Args:
79
+ model: Tree ensemble model with estimators_ attribute
80
+ feature_names: List of feature names
81
+ target_names: Optional list of target class names
82
+ config_file: Path to YAML config file (fallback if dpg_config not provided)
83
+ dpg_config: Optional dict with DPG config parameters (overrides config_file)
84
+ """
85
+ # Load configuration from provided config, file, or defaults
86
+ if dpg_config is not None:
87
+ config = dpg_config
88
+ else:
89
+ config = None
90
+ if config_file:
91
+ if os.path.exists(config_file):
92
+ with open(config_file) as f:
93
+ config = yaml.safe_load(f)
94
+ else:
95
+ print(f"Config file not found at '{config_file}'. Using built-in defaults.")
96
+ if config is None:
97
+ config = DEFAULT_DPG_CONFIG
98
+
99
+ # Convert OmegaConf DictConfig to regular dict if needed
100
+ if HAS_OMEGACONF and isinstance(config, DictConfig):
101
+ config = OmegaConf.to_container(config, resolve=True)
102
+ # Handle dict-like objects that have to_dict() method (like custom DictConfig)
103
+ elif hasattr(config, 'to_dict'):
104
+ config = config.to_dict()
105
+
106
+ # Input validation
107
+ if not hasattr(model, 'estimators_'):
108
+ raise DPGError("Model must be a tree-based ensemble")
109
+ if len(feature_names) == 0:
110
+ raise DPGError("Feature names cannot be empty")
111
+
112
+ # Normalize sklearn ensemble models for consistent tree structure
113
+ model = SklearnEnsembleNormalizer.normalize(model)
114
+
115
+ # Initialize attributes
116
+ self.model = model
117
+ self.feature_names = feature_names
118
+ self.target_names = target_names #TODO create "Class as class name"
119
+
120
+ # Get config values with defaults
121
+ dpg_config_section = config.get('dpg', {})
122
+ default_config = dpg_config_section.get('default', {})
123
+
124
+ self.perc_var = default_config.get('perc_var', DEFAULT_DPG_CONFIG["dpg"]["default"]["perc_var"])
125
+ self.decimal_threshold = default_config.get('decimal_threshold', DEFAULT_DPG_CONFIG["dpg"]["default"]["decimal_threshold"])
126
+ self.n_jobs = default_config.get('n_jobs', DEFAULT_DPG_CONFIG["dpg"]["default"]["n_jobs"])
127
+ graph_construction_config = dpg_config_section.get('graph_construction', {})
128
+ self.graph_construction_mode = graph_construction_config.get(
129
+ 'mode',
130
+ DEFAULT_DPG_CONFIG["dpg"]["graph_construction"]["mode"],
131
+ )
132
+
133
+ # Validate required config values
134
+ if self.perc_var is None:
135
+ raise DPGError("perc_var not found in DPG config")
136
+ if self.decimal_threshold is None:
137
+ raise DPGError("decimal_threshold not found in DPG config")
138
+ if self.n_jobs is None:
139
+ raise DPGError("n_jobs not found in DPG config")
140
+ if self.graph_construction_mode not in self.SUPPORTED_GRAPH_CONSTRUCTION_MODES:
141
+ supported_modes = ", ".join(sorted(self.SUPPORTED_GRAPH_CONSTRUCTION_MODES))
142
+ raise DPGError(
143
+ f"Unsupported graph construction mode '{self.graph_construction_mode}'. "
144
+ f"Supported modes are: {supported_modes}"
145
+ )
146
+
147
+ print(
148
+ "DPG initialized with "
149
+ f"perc_var={self.perc_var}, "
150
+ f"decimal_threshold={self.decimal_threshold}, "
151
+ f"n_jobs={self.n_jobs}, "
152
+ f"graph_construction_mode={self.graph_construction_mode}"
153
+ )
154
+ # Store visualization config for use by utils
155
+ self.visualization_config = dpg_config_section.get('visualization', DEFAULT_DPG_CONFIG["dpg"]["visualization"])
156
+
157
+ def fit(self, X_train: Any) -> Any:
158
+ """
159
+ Main pipeline: Extract decision paths → Build graph → Generate visualization.
160
+
161
+ Args:
162
+ X_train: Training data (n_samples, n_features)
163
+
164
+ Returns:
165
+ graphviz.Digraph: Visualizable graph object
166
+ """
167
+ print("\nStarting DPG extraction *****************************************")
168
+ print("Model Class:", self.model.__class__.__name__)
169
+ print("Model Class Module:", self.model.__class__.__module__)
170
+ print("Model Estimators: ", len(self.model.estimators_))
171
+ print("Model Params: ", self.model.get_params())
172
+ print("*****************************************************************")
173
+
174
+ log_df = self._extract_trace_log(X_train)
175
+
176
+ print(f'Total of paths: {len(log_df["case:concept:name"].unique())}')
177
+ print('Building DPG...')
178
+ if self.graph_construction_mode == "execution_trace":
179
+ dfg = self.discover_dfg_execution_trace(log_df)
180
+ else:
181
+ if self.perc_var > 0:
182
+ log_df = self.filter_log(log_df)
183
+ dfg = self.discover_dfg(log_df)
184
+
185
+ print('Extracting graph...')
186
+ return self.generate_dot(dfg)
187
+
188
+ def _extract_trace_log(self, X_train: Any) -> pd.DataFrame:
189
+ """
190
+ Extract the raw execution trace log for all samples.
191
+
192
+ Args:
193
+ X_train: Training data (n_samples, n_features)
194
+
195
+ Returns:
196
+ pd.DataFrame: Raw trace log with case id and event columns
197
+ """
198
+ # Extract decision paths (parallel or sequential)
199
+ if self.n_jobs == 1:
200
+ log = Parallel(n_jobs=self.n_jobs)(
201
+ delayed(self.tracing_ensemble)(i, sample) for i, sample in tqdm(list(enumerate(X_train)), total=len(X_train))
202
+ )
203
+ else:
204
+ log = Parallel(n_jobs=self.n_jobs)(
205
+ delayed(self.tracing_ensemble_parallel)(i, sample) for i, sample in tqdm(list(enumerate(X_train)), total=len(X_train))
206
+ )
207
+
208
+ log = [item for sublist in log for item in sublist]
209
+ return pd.DataFrame(log, columns=["case:concept:name", "concept:name"])
210
+
211
+ def _leaf_class_label(self, tree_index: int, tree_: Any, node_index: int) -> str:
212
+ """Return the class label for a classifier leaf node."""
213
+ gb_class_index = SklearnEnsembleNormalizer.get_tree_class_index(self.model, tree_index)
214
+ if gb_class_index is not None:
215
+ pred_class = gb_class_index
216
+ elif isinstance(self.model, GradientBoostingClassifier) and getattr(self.model, "n_classes_", 0) == 2:
217
+ leaf_score = float(tree_.value[node_index][0][0])
218
+ pred_class = 1 if leaf_score > 0 else 0
219
+ else:
220
+ pred_class = int(tree_.value[node_index].argmax())
221
+
222
+ if self.target_names is not None:
223
+ pred_class = self.target_names[pred_class]
224
+ elif hasattr(self.model, "classes_"):
225
+ pred_class = self.model.classes_[pred_class]
226
+ return f"Class {pred_class}"
227
+
228
+ def tracing_ensemble(self, case_id: int, sample: Any) -> Generator[List[str], None, None]:
229
+ """
230
+ Extract decision path for a single sample (generator version).
231
+
232
+ Args:
233
+ case_id: Sample identifier
234
+ sample: Feature values (1D array)
235
+
236
+ Yields:
237
+ List[str]: Path segments as [prefix, decision/prediction]
238
+ """
239
+ is_regressor = isinstance(
240
+ self.model,
241
+ (
242
+ RandomForestRegressor,
243
+ ExtraTreesRegressor,
244
+ AdaBoostRegressor,
245
+ GradientBoostingRegressor,
246
+ ),
247
+ )
248
+ sample = sample.reshape(-1)
249
+ for i, tree in enumerate(self.model.estimators_):
250
+ tree_ = tree.tree_
251
+ node_index = 0
252
+ prefix = f"sample{case_id}_dt{i}"
253
+ while True:
254
+ left = tree_.children_left[node_index]
255
+ right = tree_.children_right[node_index]
256
+ if left == right:
257
+ if is_regressor:
258
+ pred = round(tree_.value[node_index][0][0], 2)
259
+ yield [prefix, f"Pred {pred}"]
260
+ else:
261
+ yield [prefix, self._leaf_class_label(i, tree_, node_index)]
262
+ break
263
+ feature_index = tree_.feature[node_index]
264
+ threshold = round(tree_.threshold[node_index], self.decimal_threshold)
265
+ feature_name = self.feature_names[feature_index]
266
+ sample_val = sample[feature_index]
267
+ if sample_val <= threshold:
268
+ condition = f"{feature_name} <= {threshold}"
269
+ node_index = left
270
+ else:
271
+ condition = f"{feature_name} > {threshold}"
272
+ node_index = right
273
+ yield [prefix, condition]
274
+
275
+ def tracing_ensemble_parallel(self, case_id: int, sample: Any) -> List[List[str]]:
276
+ """
277
+ Extract decision path for a single sample (list version for parallel workers).
278
+
279
+ Args:
280
+ case_id: Sample identifier used to name each path prefix.
281
+ sample: Feature values array, shape ``(n_features,)``.
282
+
283
+ Returns:
284
+ List of ``[prefix, event]`` pairs representing the full decision path
285
+ across all trees in the ensemble.
286
+ """
287
+ is_regressor = isinstance(
288
+ self.model,
289
+ (
290
+ RandomForestRegressor,
291
+ ExtraTreesRegressor,
292
+ AdaBoostRegressor,
293
+ GradientBoostingRegressor,
294
+ ),
295
+ )
296
+ sample = sample.reshape(-1)
297
+ result = []
298
+ for i, tree in enumerate(self.model.estimators_):
299
+ tree_ = tree.tree_
300
+ node_index = 0
301
+ prefix = f"sample{case_id}_dt{i}"
302
+ while True:
303
+ left = tree_.children_left[node_index]
304
+ right = tree_.children_right[node_index]
305
+ if left == right:
306
+ if is_regressor:
307
+ pred = round(tree_.value[node_index][0][0], 2)
308
+ result.append([prefix, f"Pred {pred}"])
309
+ else:
310
+ result.append([prefix, self._leaf_class_label(i, tree_, node_index)])
311
+ break
312
+ feature_index = tree_.feature[node_index]
313
+ threshold = round(tree_.threshold[node_index], self.decimal_threshold)
314
+ feature_name = self.feature_names[feature_index]
315
+ sample_val = sample[feature_index]
316
+ if sample_val <= threshold:
317
+ condition = f"{feature_name} <= {threshold}"
318
+ node_index = left
319
+ else:
320
+ condition = f"{feature_name} > {threshold}"
321
+ node_index = right
322
+ result.append([prefix, condition])
323
+ return result
324
+
325
+
326
+ def filter_log(self, log: Any) -> Any:
327
+ """
328
+ Filter paths based on frequency threshold.
329
+
330
+ Args:
331
+ log: DataFrame of extracted paths
332
+
333
+ Returns:
334
+ pd.DataFrame: Filtered paths meeting perc_var threshold
335
+ """
336
+ from collections import defaultdict
337
+ variant_map = defaultdict(list)
338
+ for case_id, group in log.groupby("case:concept:name", sort=False):
339
+ variant = "|".join(group["concept:name"].values)
340
+ variant_map[variant].append(case_id)
341
+
342
+ case_ids_to_keep = set()
343
+ min_count = len(log["case:concept:name"].unique()) * self.perc_var
344
+ for variant, case_ids in variant_map.items():
345
+ if len(case_ids) >= min_count:
346
+ case_ids_to_keep.update(case_ids)
347
+ return log[log["case:concept:name"].isin(case_ids_to_keep)].copy()
348
+
349
+ def discover_dfg(self, log: Any) -> Dict[Tuple[str, str], int]:
350
+ """
351
+ Build directed frequency graph from path logs.
352
+
353
+ Args:
354
+ log: DataFrame of decision paths
355
+
356
+ Returns:
357
+ Dict[tuple, int]: Edge frequencies as {(source, target): count}
358
+ """
359
+ cases = log["case:concept:name"].unique()
360
+ if len(cases) == 0:
361
+ raise Exception("There is no paths with the current value of perc_var and decimal_threshold!")
362
+
363
+ # Optimized: Group by case once, then process each group
364
+ # This avoids repeated filtering of the dataframe for each case
365
+ dfg = {}
366
+ grouped = log.groupby("case:concept:name", sort=False)
367
+
368
+ for case, trace_df in tqdm(grouped, desc="Processing cases", total=len(cases)):
369
+ trace_df = trace_df.sort_values(by="case:concept:name")
370
+ concepts = trace_df["concept:name"].values
371
+ for i in range(len(concepts) - 1):
372
+ key = (concepts[i], concepts[i + 1])
373
+ dfg[key] = dfg.get(key, 0) + 1
374
+
375
+ return dfg
376
+
377
+ def discover_dfg_execution_trace(self, log: Any) -> Dict[Tuple[str, str], int]:
378
+ """
379
+ Build a directed frequency graph directly from the raw execution trace.
380
+
381
+ If ``perc_var > 0``, infrequent edges are removed using a minimum edge
382
+ count of ``total_cases * perc_var`` where ``total_cases`` is the number
383
+ of unique case ids in the raw trace log.
384
+
385
+ Args:
386
+ log: Raw DataFrame of decision paths
387
+
388
+ Returns:
389
+ Dict[tuple, int]: Edge frequencies as {(source, target): count}
390
+ """
391
+ dfg = self.discover_dfg(log)
392
+ if self.perc_var <= 0:
393
+ return dfg
394
+
395
+ min_count = log["case:concept:name"].nunique() * self.perc_var
396
+ return {
397
+ edge: count for edge, count in dfg.items()
398
+ if count >= min_count
399
+ }
400
+
401
+ def generate_dot(self, dfg: Dict[Tuple[str, str], int]) -> Any:
402
+ """
403
+ Convert frequency graph to Graphviz format.
404
+
405
+ Args:
406
+ dfg: Directed frequency graph
407
+
408
+ Returns:
409
+ graphviz.Digraph: Visualizable graph
410
+ """
411
+ # Get visualization config
412
+ viz_config = self.visualization_config
413
+ graph_attrs = viz_config.get('graph_attrs', {})
414
+ node_attrs = viz_config.get('node_attrs', {})
415
+
416
+ # Build graph_attr dict, dropping unset values
417
+ final_graph_attr = {
418
+ "bgcolor": graph_attrs.get('bgcolor'),
419
+ "rankdir": graph_attrs.get('rankdir'),
420
+ "overlap": "false",
421
+ "fontsize": "20",
422
+ }
423
+ final_graph_attr = {k: v for k, v in final_graph_attr.items() if v is not None}
424
+
425
+ # Build node_attr dict, dropping unset values
426
+ final_node_attr = {
427
+ "shape": node_attrs.get('shape'),
428
+ }
429
+ final_node_attr = {k: v for k, v in final_node_attr.items() if v is not None}
430
+
431
+ # Get fillcolor for regular nodes
432
+ default_fillcolor = node_attrs.get('fillcolor')
433
+
434
+ dot = graphviz.Digraph(
435
+ "dpg",
436
+ engine="dot",
437
+ graph_attr=final_graph_attr,
438
+ node_attr=final_node_attr if final_node_attr else None,
439
+ )
440
+ def _escape_dot_label(label: str) -> str:
441
+ # Escape characters that can break DOT parsing.
442
+ return (
443
+ label.replace("\\", "\\\\")
444
+ .replace("\"", "\\\"")
445
+ .replace("[", "\\[")
446
+ .replace("]", "\\]")
447
+ )
448
+
449
+ added_nodes = set()
450
+ for k, v in sorted(dfg.items(), key=lambda item: item[1]):
451
+ for activity in k:
452
+ if activity not in added_nodes:
453
+ dot.node(
454
+ str(int(hashlib.sha1(activity.encode()).hexdigest(), 16)),
455
+ label=_escape_dot_label(activity),
456
+ style="filled",
457
+ fontsize="20",
458
+ fillcolor=default_fillcolor,
459
+ )
460
+ added_nodes.add(activity)
461
+ dot.edge(
462
+ str(int(hashlib.sha1(k[0].encode()).hexdigest(), 16)),
463
+ str(int(hashlib.sha1(k[1].encode()).hexdigest(), 16)),
464
+ label=str(v),
465
+ penwidth="1",
466
+ fontsize="18"
467
+ )
468
+ return dot
469
+
470
+ def to_networkx(self, graphviz_graph: Any) -> Tuple[Any, List[List[str]]]:
471
+ """
472
+ Convert Graphviz graph to NetworkX format.
473
+
474
+ Args:
475
+ graphviz_graph: Input graph
476
+
477
+ Returns:
478
+ Tuple[nx.DiGraph, List]: NetworkX graph and node metadata
479
+ """
480
+ networkx_graph = nx.DiGraph()
481
+ nodes_list = []
482
+ edges = []
483
+ weights = {}
484
+ for edge in graphviz_graph.body:
485
+ if "->" in edge:
486
+ src, dest = edge.split("->")
487
+ src = src.strip()
488
+ dest = dest.split(" [label=")[0].strip()
489
+ weight = None
490
+ if "[label=" in edge:
491
+ attr = edge.split("[label=")[1].split("]")[0].split(" ")[0]
492
+ weight = float(attr) if attr.replace(".", "").isdigit() else None
493
+ weights[(src, dest)] = weight
494
+ edges.append((src, dest))
495
+ if "[label=" in edge:
496
+ id, desc = edge.split("[label=")
497
+ id = id.replace("\t", "").replace(" ", "")
498
+ # Extract label value safely, ignoring other attributes
499
+ m = re.search(r'label="([^"]*)"', edge)
500
+ if m:
501
+ desc = m.group(1)
502
+ else:
503
+ # Fallback for unquoted labels: label=VALUE (no spaces)
504
+ m = re.search(r'label=([^\\s\\]]+)', edge)
505
+ desc = m.group(1) if m else ""
506
+ nodes_list.append([id, desc])
507
+ for src, dest in edges:
508
+ if (src, dest) in weights:
509
+ networkx_graph.add_edge(src, dest, weight=weights[(src, dest)])
510
+ else:
511
+ networkx_graph.add_edge(src, dest)
512
+ return networkx_graph, sorted(nodes_list, key=lambda x: x[0])