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 +51 -0
- dpg/core.py +512 -0
- dpg/explainer.py +1364 -0
- dpg/py.typed +0 -0
- dpg/sklearn_dpg.py +233 -0
- dpg/sklearn_normalizer.py +94 -0
- dpg/themes.py +310 -0
- dpg/utils.py +124 -0
- dpg/visualizer.py +2715 -0
- dpg-0.1.5.dist-info/METADATA +403 -0
- dpg-0.1.5.dist-info/RECORD +19 -0
- dpg-0.1.5.dist-info/WHEEL +4 -0
- dpg-0.1.5.dist-info/entry_points.txt +3 -0
- dpg-0.1.5.dist-info/licenses/LICENSE +21 -0
- metrics/__init__.py +5 -0
- metrics/edges.py +55 -0
- metrics/graph.py +475 -0
- metrics/nodes.py +39 -0
- metrics/py.typed +0 -0
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])
|