openprocess 0.7.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.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Alignments (Carmona, van Dongen, Solti & Weidlich, ch. 7; course reading 5).
|
|
2
|
+
|
|
3
|
+
An **alignment** pairs up a trace with a complete run of the model, move by
|
|
4
|
+
move. Each move is one of:
|
|
5
|
+
|
|
6
|
+
============= ================= =============================================
|
|
7
|
+
move written meaning
|
|
8
|
+
============= ================= =============================================
|
|
9
|
+
synchronous ``(a, a)`` the log did a, the model did a -- agreement
|
|
10
|
+
log move ``(a, ≫)`` the log did a, the model could not
|
|
11
|
+
model move ``(≫, a)`` the model had to do a, the log did not
|
|
12
|
+
silent move ``(≫, τ)`` the model fired a τ transition (free)
|
|
13
|
+
============= ================= =============================================
|
|
14
|
+
|
|
15
|
+
With the **standard cost function** (log and model moves cost 1, synchronous
|
|
16
|
+
and silent moves cost 0), an *optimal* alignment is one with minimal total
|
|
17
|
+
cost: the fewest deviations needed to explain the trace with the model.
|
|
18
|
+
|
|
19
|
+
How it is computed
|
|
20
|
+
------------------
|
|
21
|
+
Search the **synchronous product**: states are ``(marking, i)`` where ``i``
|
|
22
|
+
is how many events have been explained so far. From each state:
|
|
23
|
+
|
|
24
|
+
* a synchronous move fires a transition labelled ``trace[i]`` and moves to
|
|
25
|
+
``i + 1``;
|
|
26
|
+
* a log move stays in the marking and moves to ``i + 1``;
|
|
27
|
+
* a model move fires any enabled transition and stays at ``i``.
|
|
28
|
+
|
|
29
|
+
The goal is ``(final marking, len(trace))``. Costs are non-negative, so
|
|
30
|
+
Dijkstra/A* returns an optimal path. As in PM4Py, costs are scaled: a real
|
|
31
|
+
deviation costs 10000 and a τ move costs 1, so that among equally good
|
|
32
|
+
alignments the one with fewest τ moves wins; the reported cost is the
|
|
33
|
+
integer division by 10000.
|
|
34
|
+
|
|
35
|
+
Heuristic: every remaining event whose label does not occur in the model
|
|
36
|
+
*must* become a log move, so their count is a lower bound on the remaining
|
|
37
|
+
cost -- admissible, so A* stays optimal.
|
|
38
|
+
|
|
39
|
+
Fitness
|
|
40
|
+
-------
|
|
41
|
+
``fitness(σ) = 1 − cost(σ) / (|σ| + cost(⟨⟩))`` where ``cost(⟨⟩)`` is the
|
|
42
|
+
cost of aligning the empty trace -- the length of the shortest visible run of
|
|
43
|
+
the model. The denominator is the worst case: every event a log move plus a
|
|
44
|
+
shortest run of model moves.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import heapq
|
|
50
|
+
import itertools
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
|
|
53
|
+
from ..log import SimpleLog
|
|
54
|
+
from ..petrinet import Marking, PetriNet
|
|
55
|
+
|
|
56
|
+
SKIP = "≫"
|
|
57
|
+
DEVIATION = 10_000
|
|
58
|
+
TAU_COST = 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Move:
|
|
63
|
+
log: str # activity, or SKIP
|
|
64
|
+
model: str # label, "τ", or SKIP
|
|
65
|
+
transition: str | None # transition id for model/sync moves
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def kind(self) -> str:
|
|
69
|
+
if self.log == SKIP:
|
|
70
|
+
return "silent" if self.model == "τ" else "model"
|
|
71
|
+
if self.model == SKIP:
|
|
72
|
+
return "log"
|
|
73
|
+
return "sync"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Alignment:
|
|
78
|
+
trace: tuple[str, ...]
|
|
79
|
+
count: int
|
|
80
|
+
moves: list[Move]
|
|
81
|
+
cost: int # number of deviations (standard cost function)
|
|
82
|
+
fitness: float
|
|
83
|
+
states_visited: int = 0
|
|
84
|
+
#: False if the search gave up (state limit); then moves are empty
|
|
85
|
+
complete: bool = True
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def log_moves(self) -> int:
|
|
89
|
+
return sum(1 for m in self.moves if m.kind == "log")
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def model_moves(self) -> int:
|
|
93
|
+
return sum(1 for m in self.moves if m.kind == "model")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class AlignmentResult:
|
|
98
|
+
net: PetriNet
|
|
99
|
+
alignments: list[Alignment] = field(default_factory=list) # per variant
|
|
100
|
+
empty_trace_cost: int = 0
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def trace_count(self) -> int:
|
|
104
|
+
return sum(a.count for a in self.alignments)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def average_fitness(self) -> float:
|
|
108
|
+
"""Mean of the per-trace fitness values (weighted by frequency)."""
|
|
109
|
+
total = self.trace_count
|
|
110
|
+
return sum(a.fitness * a.count for a in self.alignments) / total if total else 1.0
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def log_fitness(self) -> float:
|
|
114
|
+
"""1 − total cost / total worst-case cost."""
|
|
115
|
+
cost = sum(a.cost * a.count for a in self.alignments)
|
|
116
|
+
worst = sum((len(a.trace) + self.empty_trace_cost) * a.count for a in self.alignments)
|
|
117
|
+
return 1 - cost / worst if worst else 1.0
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def fitting_traces(self) -> int:
|
|
121
|
+
return sum(a.count for a in self.alignments if a.cost == 0 and a.complete)
|
|
122
|
+
|
|
123
|
+
def move_statistics(self) -> tuple[dict[str, int], dict[str, int], dict[str, int]]:
|
|
124
|
+
"""(sync, log-move, model-move) counts per activity, frequency-weighted."""
|
|
125
|
+
sync: dict[str, int] = {}
|
|
126
|
+
log_moves: dict[str, int] = {}
|
|
127
|
+
model_moves: dict[str, int] = {}
|
|
128
|
+
for alignment in self.alignments:
|
|
129
|
+
for move in alignment.moves:
|
|
130
|
+
if move.kind == "sync":
|
|
131
|
+
sync[move.log] = sync.get(move.log, 0) + alignment.count
|
|
132
|
+
elif move.kind == "log":
|
|
133
|
+
log_moves[move.log] = log_moves.get(move.log, 0) + alignment.count
|
|
134
|
+
elif move.kind == "model":
|
|
135
|
+
model_moves[move.model] = model_moves.get(move.model, 0) + alignment.count
|
|
136
|
+
return sync, log_moves, model_moves
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _CompiledNet:
|
|
140
|
+
"""The net flattened into integer arrays for a fast inner loop.
|
|
141
|
+
|
|
142
|
+
A marking becomes a tuple of token counts indexed by place number, and
|
|
143
|
+
each transition becomes its input requirements plus its effect vector.
|
|
144
|
+
Tuples hash and compare much faster than :class:`Marking` objects, which
|
|
145
|
+
matters because an alignment search can visit hundreds of thousands of
|
|
146
|
+
states.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, net: PetriNet) -> None:
|
|
150
|
+
self.places = list(net.places)
|
|
151
|
+
index = {p: i for i, p in enumerate(self.places)}
|
|
152
|
+
self.transitions = list(net.transitions.values())
|
|
153
|
+
self.pre = [tuple((index[p], n) for p, n in net.pre(t.id).items())
|
|
154
|
+
for t in self.transitions]
|
|
155
|
+
self.delta = []
|
|
156
|
+
for t in self.transitions:
|
|
157
|
+
change = [0] * len(self.places)
|
|
158
|
+
for p, n in net.pre(t.id).items():
|
|
159
|
+
change[index[p]] -= n
|
|
160
|
+
for p, n in net.post(t.id).items():
|
|
161
|
+
change[index[p]] += n
|
|
162
|
+
self.delta.append(tuple((i, d) for i, d in enumerate(change) if d))
|
|
163
|
+
self.initial = self.encode(net.initial_marking, index)
|
|
164
|
+
self.final = self.encode(net.final_marking, index)
|
|
165
|
+
|
|
166
|
+
def encode(self, marking: Marking, index: dict[str, int]) -> tuple[int, ...]:
|
|
167
|
+
vector = [0] * len(self.places)
|
|
168
|
+
for p, n in marking.items():
|
|
169
|
+
vector[index[p]] = n
|
|
170
|
+
return tuple(vector)
|
|
171
|
+
|
|
172
|
+
def enabled(self, marking: tuple[int, ...]) -> list[int]:
|
|
173
|
+
return [k for k, pre in enumerate(self.pre)
|
|
174
|
+
if all(marking[i] >= n for i, n in pre)]
|
|
175
|
+
|
|
176
|
+
def fire(self, marking: tuple[int, ...], k: int) -> tuple[int, ...]:
|
|
177
|
+
vector = list(marking)
|
|
178
|
+
for i, d in self.delta[k]:
|
|
179
|
+
vector[i] += d
|
|
180
|
+
return tuple(vector)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def align_trace(net: PetriNet, trace: tuple[str, ...], count: int = 1,
|
|
184
|
+
max_states: int = 500_000, compiled: "_CompiledNet | None" = None) -> Alignment:
|
|
185
|
+
"""An optimal alignment of one trace (A* on the synchronous product)."""
|
|
186
|
+
c = compiled or _CompiledNet(net)
|
|
187
|
+
labels = net.labels()
|
|
188
|
+
# Suffix heuristic: events not in the model are unavoidable log moves.
|
|
189
|
+
unavoidable = [0] * (len(trace) + 1)
|
|
190
|
+
for i in range(len(trace) - 1, -1, -1):
|
|
191
|
+
unavoidable[i] = unavoidable[i + 1] + (DEVIATION if trace[i] not in labels else 0)
|
|
192
|
+
|
|
193
|
+
start = (c.initial, 0)
|
|
194
|
+
tie = itertools.count()
|
|
195
|
+
frontier: list = [(unavoidable[0], 0, next(tie), start)]
|
|
196
|
+
best_cost: dict = {start: 0}
|
|
197
|
+
parent: dict = {start: None}
|
|
198
|
+
closed: set = set()
|
|
199
|
+
goal = (c.final, len(trace))
|
|
200
|
+
|
|
201
|
+
while frontier:
|
|
202
|
+
_, cost, _, state = heapq.heappop(frontier)
|
|
203
|
+
if state in closed:
|
|
204
|
+
continue
|
|
205
|
+
closed.add(state)
|
|
206
|
+
if state == goal:
|
|
207
|
+
moves = []
|
|
208
|
+
node = state
|
|
209
|
+
while parent[node] is not None:
|
|
210
|
+
previous, move = parent[node]
|
|
211
|
+
moves.append(move)
|
|
212
|
+
node = previous
|
|
213
|
+
return Alignment(trace, count, moves[::-1], cost // DEVIATION, 0.0, len(closed))
|
|
214
|
+
if len(closed) > max_states:
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
marking, position = state
|
|
218
|
+
enabled = c.enabled(marking)
|
|
219
|
+
successors = []
|
|
220
|
+
if position < len(trace):
|
|
221
|
+
activity = trace[position]
|
|
222
|
+
successors.append(((marking, position + 1), DEVIATION, Move(activity, SKIP, None)))
|
|
223
|
+
for k in enabled:
|
|
224
|
+
t = c.transitions[k]
|
|
225
|
+
if t.label == activity:
|
|
226
|
+
successors.append(((c.fire(marking, k), position + 1), 0,
|
|
227
|
+
Move(activity, activity, t.id)))
|
|
228
|
+
for k in enabled:
|
|
229
|
+
t = c.transitions[k]
|
|
230
|
+
step_cost = TAU_COST if t.label is None else DEVIATION
|
|
231
|
+
successors.append(((c.fire(marking, k), position), step_cost,
|
|
232
|
+
Move(SKIP, "τ" if t.label is None else t.label, t.id)))
|
|
233
|
+
|
|
234
|
+
for successor, step_cost, move in successors:
|
|
235
|
+
if successor in closed:
|
|
236
|
+
continue
|
|
237
|
+
new_cost = cost + step_cost
|
|
238
|
+
if new_cost < best_cost.get(successor, 1 << 62):
|
|
239
|
+
best_cost[successor] = new_cost
|
|
240
|
+
parent[successor] = (state, move)
|
|
241
|
+
heapq.heappush(frontier, (new_cost + unavoidable[successor[1]], new_cost,
|
|
242
|
+
next(tie), successor))
|
|
243
|
+
|
|
244
|
+
return Alignment(trace, count, [], len(trace), 0.0, len(closed), complete=False)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def align_log(net: PetriNet, log: SimpleLog, max_states: int = 500_000,
|
|
248
|
+
progress=None) -> AlignmentResult:
|
|
249
|
+
"""Align every variant; fitness per trace uses the empty-trace cost."""
|
|
250
|
+
compiled = _CompiledNet(net)
|
|
251
|
+
empty = align_trace(net, (), 1, max_states, compiled)
|
|
252
|
+
result = AlignmentResult(net, empty_trace_cost=empty.cost)
|
|
253
|
+
variants = sorted(log.items(), key=lambda item: (-item[1], item[0]))
|
|
254
|
+
for number, (trace, count) in enumerate(variants, 1):
|
|
255
|
+
alignment = align_trace(net, trace, count, max_states, compiled)
|
|
256
|
+
worst = len(trace) + empty.cost
|
|
257
|
+
alignment.fitness = 1 - alignment.cost / worst if worst else 1.0
|
|
258
|
+
if not alignment.complete:
|
|
259
|
+
alignment.fitness = 0.0
|
|
260
|
+
result.alignments.append(alignment)
|
|
261
|
+
if progress is not None:
|
|
262
|
+
progress(number, len(variants))
|
|
263
|
+
return result
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Precision, generalisation and simplicity -- the other three quality dimensions.
|
|
2
|
+
|
|
3
|
+
Fitness (token replay, alignments) asks "can the model replay the log?".
|
|
4
|
+
Three more dimensions complete the picture (course: the four quality
|
|
5
|
+
criteria):
|
|
6
|
+
|
|
7
|
+
precision
|
|
8
|
+
Does the model allow *only* what the log shows? A flower model fits
|
|
9
|
+
everything but has terrible precision.
|
|
10
|
+
generalisation
|
|
11
|
+
Does the model generalise beyond the examples, rather than overfitting
|
|
12
|
+
exactly the traces seen?
|
|
13
|
+
simplicity
|
|
14
|
+
Is the model as simple as possible (Occam's razor)?
|
|
15
|
+
|
|
16
|
+
Precision: escaping edges (ETConformance, Muñoz-Gama & Carmona)
|
|
17
|
+
---------------------------------------------------------------
|
|
18
|
+
Walk through every *prefix* of every trace. After prefix ``h``, the log
|
|
19
|
+
continued with some set of activities ``log_next(h)``; the model, in the
|
|
20
|
+
marking reached by replaying ``h``, would *allow* a set ``model_next(h)``.
|
|
21
|
+
Anything the model allows but the log never did is an **escaping edge**::
|
|
22
|
+
|
|
23
|
+
Σ_h freq(h) · |model_next(h) \\ log_next(h)|
|
|
24
|
+
precision = 1 − ─────────────────────────────────────────────
|
|
25
|
+
Σ_h freq(h) · |model_next(h)|
|
|
26
|
+
|
|
27
|
+
Prefixes that do not fit the model are skipped (there is no meaningful
|
|
28
|
+
marking to compare against). ``model_next`` looks through τ transitions.
|
|
29
|
+
|
|
30
|
+
Generalisation (token-based, as in PM4Py)
|
|
31
|
+
-----------------------------------------
|
|
32
|
+
A transition that fired only a few times during replay gives little evidence
|
|
33
|
+
that the model is right about it. With ``n_t`` the number of firings of
|
|
34
|
+
transition t::
|
|
35
|
+
|
|
36
|
+
generalisation = 1 − mean_t ( 1 / sqrt(n_t) )
|
|
37
|
+
|
|
38
|
+
Simplicity (arc degree, as in PM4Py)
|
|
39
|
+
------------------------------------
|
|
40
|
+
The mean number of arcs per node; a pure sequence has degree 2. Anything
|
|
41
|
+
above that makes the model harder to read::
|
|
42
|
+
|
|
43
|
+
simplicity = 1 / (1 + max(mean degree − 2, 0))
|
|
44
|
+
|
|
45
|
+
These last two are rough proxies -- which is also what the literature says
|
|
46
|
+
about them -- but they make the trade-off between the four dimensions
|
|
47
|
+
visible when comparing models discovered from the same log.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import math
|
|
53
|
+
from collections import Counter, defaultdict, deque
|
|
54
|
+
|
|
55
|
+
from ..log import SimpleLog
|
|
56
|
+
from ..petrinet import Marking, PetriNet
|
|
57
|
+
from .token_replay import ReplayResult, silent_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _visible_enabled(net: PetriNet, marking: Marking, max_states: int = 2000) -> set[str]:
|
|
61
|
+
"""Labels that can fire next, possibly after τ moves."""
|
|
62
|
+
result: set[str] = set()
|
|
63
|
+
seen = {marking}
|
|
64
|
+
queue = deque([marking])
|
|
65
|
+
while queue and len(seen) < max_states:
|
|
66
|
+
current = queue.popleft()
|
|
67
|
+
for transition in net.enabled(current):
|
|
68
|
+
label = net.transitions[transition].label
|
|
69
|
+
if label is not None:
|
|
70
|
+
result.add(label)
|
|
71
|
+
else:
|
|
72
|
+
successor = net.fire(current, transition)
|
|
73
|
+
if successor not in seen:
|
|
74
|
+
seen.add(successor)
|
|
75
|
+
queue.append(successor)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _replay_prefix(net: PetriNet, prefix: tuple[str, ...],
|
|
80
|
+
cache: dict[tuple[str, ...], Marking | None]) -> Marking | None:
|
|
81
|
+
"""Marking after replaying ``prefix`` without missing tokens, or None."""
|
|
82
|
+
if prefix in cache:
|
|
83
|
+
return cache[prefix]
|
|
84
|
+
if not prefix:
|
|
85
|
+
cache[prefix] = net.initial_marking
|
|
86
|
+
return net.initial_marking
|
|
87
|
+
before = _replay_prefix(net, prefix[:-1], cache)
|
|
88
|
+
result: Marking | None = None
|
|
89
|
+
if before is not None:
|
|
90
|
+
activity = prefix[-1]
|
|
91
|
+
candidates = net.transitions_with_label(activity)
|
|
92
|
+
for transition in candidates:
|
|
93
|
+
if net.is_enabled(before, transition):
|
|
94
|
+
result = net.fire(before, transition)
|
|
95
|
+
break
|
|
96
|
+
else:
|
|
97
|
+
for transition in candidates:
|
|
98
|
+
path = silent_path(net, before, lambda m, t=transition: net.is_enabled(m, t))
|
|
99
|
+
if path is not None:
|
|
100
|
+
marking = before
|
|
101
|
+
for step in path:
|
|
102
|
+
marking = net.fire(marking, step)
|
|
103
|
+
result = net.fire(marking, transition)
|
|
104
|
+
break
|
|
105
|
+
cache[prefix] = result
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def precision(net: PetriNet, log: SimpleLog) -> float:
|
|
110
|
+
"""ETConformance precision with escaping edges."""
|
|
111
|
+
log_next: dict[tuple[str, ...], set[str]] = defaultdict(set)
|
|
112
|
+
frequency: Counter = Counter()
|
|
113
|
+
for trace, count in log.items():
|
|
114
|
+
for i, activity in enumerate(trace):
|
|
115
|
+
prefix = trace[:i]
|
|
116
|
+
log_next[prefix].add(activity)
|
|
117
|
+
frequency[prefix] += count
|
|
118
|
+
|
|
119
|
+
cache: dict[tuple[str, ...], Marking | None] = {}
|
|
120
|
+
escaping = allowed = 0
|
|
121
|
+
for prefix, observed in log_next.items():
|
|
122
|
+
marking = _replay_prefix(net, prefix, cache)
|
|
123
|
+
if marking is None:
|
|
124
|
+
continue
|
|
125
|
+
enabled = _visible_enabled(net, marking)
|
|
126
|
+
allowed += frequency[prefix] * len(enabled)
|
|
127
|
+
escaping += frequency[prefix] * len(enabled - observed)
|
|
128
|
+
return 1.0 if allowed == 0 else 1 - escaping / allowed
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def generalisation(replay: ReplayResult) -> float:
|
|
132
|
+
fired, _ = replay.transition_totals()
|
|
133
|
+
net = replay.net
|
|
134
|
+
if not net.transitions:
|
|
135
|
+
return 1.0
|
|
136
|
+
terms = [1 / math.sqrt(fired[t]) if fired[t] else 1.0 for t in net.transitions]
|
|
137
|
+
return 1 - sum(terms) / len(terms)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def simplicity(net: PetriNet) -> float:
|
|
141
|
+
nodes = len(net.places) + len(net.transitions)
|
|
142
|
+
if nodes == 0:
|
|
143
|
+
return 1.0
|
|
144
|
+
mean_degree = 2 * len(net.arcs) / nodes
|
|
145
|
+
return 1 / (1 + max(mean_degree - 2, 0))
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Token-based replay (van der Aalst, *Process Mining*, §8.2).
|
|
2
|
+
|
|
3
|
+
Replay each trace on the net and *count tokens*:
|
|
4
|
+
|
|
5
|
+
``p`` produced
|
|
6
|
+
tokens put into places (the environment first produces the initial
|
|
7
|
+
marking, then every fired transition produces its output tokens);
|
|
8
|
+
``c`` consumed
|
|
9
|
+
tokens taken out (every fired transition consumes its inputs, and at the
|
|
10
|
+
end the environment consumes the final marking);
|
|
11
|
+
``m`` missing
|
|
12
|
+
tokens that had to be *created artificially* because the transition for
|
|
13
|
+
the next event was not enabled -- evidence the log did something the
|
|
14
|
+
model did not allow;
|
|
15
|
+
``r`` remaining
|
|
16
|
+
tokens left behind after the final marking was consumed -- evidence the
|
|
17
|
+
model expected something the log did not do.
|
|
18
|
+
|
|
19
|
+
Fitness of a trace σ on net N::
|
|
20
|
+
|
|
21
|
+
fitness(σ, N) = ½ (1 − m / c) + ½ (1 − r / p)
|
|
22
|
+
|
|
23
|
+
It is 1 exactly when nothing was missing or remaining. For a whole log the
|
|
24
|
+
four counts are summed over all traces (weighted by frequency) before the
|
|
25
|
+
formula is applied.
|
|
26
|
+
|
|
27
|
+
Silent transitions and duplicates
|
|
28
|
+
---------------------------------
|
|
29
|
+
Events never refer to τ transitions, so the replayer may fire them on its
|
|
30
|
+
own: when the transition for the next event is not enabled, it first looks
|
|
31
|
+
for a (shortest) sequence of silent transitions that enables it. With
|
|
32
|
+
duplicate labels, it prefers a transition that is enabled, then one reachable
|
|
33
|
+
through silent moves, then the one missing the fewest tokens. These are
|
|
34
|
+
heuristics -- token replay is fast but not always optimal, which is why
|
|
35
|
+
alignments exist (see :mod:`alignments`).
|
|
36
|
+
|
|
37
|
+
Diagnostics
|
|
38
|
+
-----------
|
|
39
|
+
Per place we keep the total missing and remaining tokens, and per transition
|
|
40
|
+
how often it fired with missing tokens. The app paints these on the model
|
|
41
|
+
(ProM's "replay" view): a red ``−n`` badge means the log wanted a token here
|
|
42
|
+
that was not there; a ``+n`` badge means a token was left behind.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from collections import Counter, deque
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
|
|
50
|
+
from ..log import SimpleLog
|
|
51
|
+
from ..petrinet import Marking, PetriNet
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TraceReplay:
|
|
56
|
+
trace: tuple[str, ...]
|
|
57
|
+
count: int
|
|
58
|
+
produced: int
|
|
59
|
+
consumed: int
|
|
60
|
+
missing: int
|
|
61
|
+
remaining: int
|
|
62
|
+
#: activities that do not occur in the model at all
|
|
63
|
+
unknown_activities: list[str] = field(default_factory=list)
|
|
64
|
+
#: indices of events whose transition was not enabled
|
|
65
|
+
problem_events: list[int] = field(default_factory=list)
|
|
66
|
+
missing_by_place: Counter = field(default_factory=Counter)
|
|
67
|
+
remaining_by_place: Counter = field(default_factory=Counter)
|
|
68
|
+
fired: Counter = field(default_factory=Counter) # transition id -> times
|
|
69
|
+
forced: Counter = field(default_factory=Counter) # fired with missing tokens
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def fitness(self) -> float:
|
|
73
|
+
return _fitness(self.produced, self.consumed, self.missing, self.remaining)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def fits(self) -> bool:
|
|
77
|
+
return self.missing == 0 and self.remaining == 0 and not self.unknown_activities
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fitness(p: int, c: int, m: int, r: int) -> float:
|
|
81
|
+
part_missing = 1 - m / c if c else 1.0
|
|
82
|
+
part_remaining = 1 - r / p if p else 1.0
|
|
83
|
+
return 0.5 * part_missing + 0.5 * part_remaining
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ReplayResult:
|
|
88
|
+
net: PetriNet
|
|
89
|
+
traces: list[TraceReplay] # one per variant, most frequent first
|
|
90
|
+
|
|
91
|
+
def _total(self, attribute: str) -> int:
|
|
92
|
+
return sum(getattr(t, attribute) * t.count for t in self.traces)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def produced(self) -> int:
|
|
96
|
+
return self._total("produced")
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def consumed(self) -> int:
|
|
100
|
+
return self._total("consumed")
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def missing(self) -> int:
|
|
104
|
+
return self._total("missing")
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def remaining(self) -> int:
|
|
108
|
+
return self._total("remaining")
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def fitness(self) -> float:
|
|
112
|
+
"""Log-level fitness from the summed counts."""
|
|
113
|
+
return _fitness(self.produced, self.consumed, self.missing, self.remaining)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def fitting_traces(self) -> int:
|
|
117
|
+
return sum(t.count for t in self.traces if t.fits)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def trace_count(self) -> int:
|
|
121
|
+
return sum(t.count for t in self.traces)
|
|
122
|
+
|
|
123
|
+
def place_totals(self) -> tuple[Counter, Counter]:
|
|
124
|
+
"""(missing, remaining) per place, frequency-weighted."""
|
|
125
|
+
missing, remaining = Counter(), Counter()
|
|
126
|
+
for t in self.traces:
|
|
127
|
+
for place, n in t.missing_by_place.items():
|
|
128
|
+
missing[place] += n * t.count
|
|
129
|
+
for place, n in t.remaining_by_place.items():
|
|
130
|
+
remaining[place] += n * t.count
|
|
131
|
+
return missing, remaining
|
|
132
|
+
|
|
133
|
+
def transition_totals(self) -> tuple[Counter, Counter]:
|
|
134
|
+
"""(fired, forced) per transition, frequency-weighted."""
|
|
135
|
+
fired, forced = Counter(), Counter()
|
|
136
|
+
for t in self.traces:
|
|
137
|
+
for transition, n in t.fired.items():
|
|
138
|
+
fired[transition] += n * t.count
|
|
139
|
+
for transition, n in t.forced.items():
|
|
140
|
+
forced[transition] += n * t.count
|
|
141
|
+
return fired, forced
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Silent moves
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
def silent_path(net: PetriNet, marking: Marking, goal, max_states: int = 2000) -> list[str] | None:
|
|
148
|
+
"""Shortest sequence of τ transitions from ``marking`` to a marking where
|
|
149
|
+
``goal(marking)`` holds. ``[]`` if it already holds, ``None`` if none found."""
|
|
150
|
+
if goal(marking):
|
|
151
|
+
return []
|
|
152
|
+
silent = [t for t, tr in net.transitions.items() if tr.silent]
|
|
153
|
+
if not silent:
|
|
154
|
+
return None
|
|
155
|
+
parent: dict[Marking, tuple[Marking, str] | None] = {marking: None}
|
|
156
|
+
queue = deque([marking])
|
|
157
|
+
while queue and len(parent) < max_states:
|
|
158
|
+
current = queue.popleft()
|
|
159
|
+
for transition in silent:
|
|
160
|
+
if not net.is_enabled(current, transition):
|
|
161
|
+
continue
|
|
162
|
+
successor = net.fire(current, transition)
|
|
163
|
+
if successor in parent:
|
|
164
|
+
continue
|
|
165
|
+
parent[successor] = (current, transition)
|
|
166
|
+
if goal(successor):
|
|
167
|
+
path = []
|
|
168
|
+
node = successor
|
|
169
|
+
while parent[node] is not None:
|
|
170
|
+
previous, step = parent[node] # type: ignore[misc]
|
|
171
|
+
path.append(step)
|
|
172
|
+
node = previous
|
|
173
|
+
return path[::-1]
|
|
174
|
+
queue.append(successor)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Replay
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
def replay_trace(net: PetriNet, trace: tuple[str, ...], count: int = 1) -> TraceReplay:
|
|
182
|
+
marking = net.initial_marking
|
|
183
|
+
result = TraceReplay(trace, count, produced=marking.total, consumed=0, missing=0,
|
|
184
|
+
remaining=0)
|
|
185
|
+
by_label: dict[str, list[str]] = {}
|
|
186
|
+
for transition in net.transitions.values():
|
|
187
|
+
if transition.label is not None:
|
|
188
|
+
by_label.setdefault(transition.label, []).append(transition.id)
|
|
189
|
+
|
|
190
|
+
def fire(transition: str, current: Marking) -> Marking:
|
|
191
|
+
pre, post = net.pre(transition), net.post(transition)
|
|
192
|
+
short = Marking({p: n - current[p] for p, n in pre.items() if current[p] < n})
|
|
193
|
+
if short:
|
|
194
|
+
result.missing += short.total
|
|
195
|
+
result.missing_by_place.update(dict(short.items()))
|
|
196
|
+
result.forced[transition] += 1
|
|
197
|
+
current = current + short
|
|
198
|
+
result.consumed += pre.total
|
|
199
|
+
result.produced += post.total
|
|
200
|
+
result.fired[transition] += 1
|
|
201
|
+
return current - pre + post
|
|
202
|
+
|
|
203
|
+
for index, activity in enumerate(trace):
|
|
204
|
+
candidates = by_label.get(activity)
|
|
205
|
+
if not candidates:
|
|
206
|
+
result.unknown_activities.append(activity)
|
|
207
|
+
result.problem_events.append(index)
|
|
208
|
+
continue
|
|
209
|
+
chosen: str | None = next((t for t in candidates if net.is_enabled(marking, t)), None)
|
|
210
|
+
if chosen is None:
|
|
211
|
+
# Try enabling one of the candidates with silent moves.
|
|
212
|
+
best_path = None
|
|
213
|
+
for transition in candidates:
|
|
214
|
+
path = silent_path(net, marking, lambda m, t=transition: net.is_enabled(m, t))
|
|
215
|
+
if path is not None and (best_path is None or len(path) < len(best_path[1])):
|
|
216
|
+
best_path = (transition, path)
|
|
217
|
+
if best_path is not None:
|
|
218
|
+
chosen, path = best_path
|
|
219
|
+
for step in path:
|
|
220
|
+
marking = fire(step, marking)
|
|
221
|
+
if chosen is None:
|
|
222
|
+
# Force the transition missing the fewest tokens.
|
|
223
|
+
chosen = min(candidates, key=lambda t: sum(
|
|
224
|
+
max(0, n - marking[p]) for p, n in net.pre(t).items()))
|
|
225
|
+
result.problem_events.append(index)
|
|
226
|
+
marking = fire(chosen, marking)
|
|
227
|
+
|
|
228
|
+
# Finish: move silently to the final marking if possible, then let the
|
|
229
|
+
# environment consume it.
|
|
230
|
+
final = net.final_marking
|
|
231
|
+
path = silent_path(net, marking, lambda m: m == final)
|
|
232
|
+
if path is None:
|
|
233
|
+
path = silent_path(net, marking, lambda m: m >= final) or []
|
|
234
|
+
for step in path:
|
|
235
|
+
marking = fire(step, marking)
|
|
236
|
+
short = Marking({p: n - marking[p] for p, n in final.items() if marking[p] < n})
|
|
237
|
+
if short:
|
|
238
|
+
result.missing += short.total
|
|
239
|
+
result.missing_by_place.update(dict(short.items()))
|
|
240
|
+
marking = marking + short
|
|
241
|
+
result.consumed += final.total
|
|
242
|
+
leftover = marking - final
|
|
243
|
+
result.remaining = leftover.total
|
|
244
|
+
result.remaining_by_place.update(dict(leftover.items()))
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def token_replay(net: PetriNet, log: SimpleLog) -> ReplayResult:
|
|
249
|
+
"""Replay every variant once and weight the results by frequency."""
|
|
250
|
+
traces = [replay_trace(net, trace, count)
|
|
251
|
+
for trace, count in sorted(log.items(), key=lambda item: (-item[1], item[0]))]
|
|
252
|
+
return ReplayResult(net, traces)
|