From c1889213e618a31a64e0e4864693becbe174b866 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:20:44 +0200 Subject: [PATCH] Inductive Miner: the strict sequence cut, and the every-pair test, from Leemans 2017 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sequence cut accepted a partition when some pair of activities reached across it; the definition requires every pair. And the maximal cut made each optional stretch skippable on its own, ×(τ,a), ×(τ,c), where the strict sequence cut of Leemans' thesis (p. 233), which ProM and PM4Py follow, merges groups that can only be skipped together into ×(τ, →(a,c)). Both are implemented as the thesis has them; the How tab says when the strict step merged groups; the thesis is a reference on the IM topic, so Cite lists it. Two tests pin the two examples; the cross-check against PM4Py goes from 369 identical trees of 406 to 401, the guarantees holding on every log, and its docstring records both baselines. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CxRhDznFAbwU3x78bugqqR --- CHANGELOG.md | 15 +++ docs/references.md | 91 ++++++------- openprocess/gui/studio/derivation_view.py | 4 +- openprocess/mining/discovery/inductive.py | 157 ++++++++++++++++++---- openprocess/references.py | 7 +- tests/cross_check_inductive_miner.py | 21 ++- tests/test_mining.py | 35 +++++ 7 files changed, 251 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e71778..b3b6da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ notice, not how it was done. Versions before 0.7.0 were released as CPNpy. ## 0.11.2 +- **The Inductive Miner's sequence cut now agrees with the reference + implementation.** Two changes to `sequence_cut`, both from Leemans' + thesis (*Robust Process Mining with Guarantees*, 2017), which is what + ProM and PM4Py follow: (1) a cut is accepted only when *every* activity of + an earlier group reaches *every* activity of a later one, as the + definition says, not when some pair does; (2) the *strict* step: groups + that can only be skipped together become one group, so an optional + stretch `a, c` is `×(τ, →(a, c))` rather than `×(τ, a), ×(τ, c)`, which + also allowed `` and `` that never happened. The How tab says + when the strict step merged groups, and Cite on the box lists the thesis. + The cross-check against PM4Py goes from 369 identical trees of 406 to + 401; the five that remain are fall-throughs on logs with no cut, tried in + a different order, and the guarantees (a sound net, fitness 1) hold on + every log before and after. Models discovered from logs with skippable + stretches change; models of the textbook logs do not. - **Fixed: Describe log and Correlate failed with "No converter from EventLog to pandas.DataFrame" whenever pandas was installed.** A converter was registered under the class's defining module diff --git a/docs/references.md b/docs/references.md index 3bc0827..887bc61 100644 --- a/docs/references.md +++ b/docs/references.md @@ -7,45 +7,45 @@ Where OpenProcess's notation and algorithms come from. Each topic names the work ## Notation - **Event logs as multisets of traces** — A log written as $L = [\langle a,b,c \rangle^3, \langle a,c \rangle]$: a multiset of traces, the exponent the number of cases. *Log from notation…* and `log.txt` files use it. [1] *(`openprocess.mining.log`)* -- **Events, traces, classifiers and the XES attributes** — `concept:name`, `time:timestamp`, `org:resource` and `lifecycle:transition`, and classifiers that turn events into activity labels. [23], [1] *(`openprocess.mining.log, openprocess.mining.xes`)* -- **Petri nets, markings and WF-nets** — $N = (P, T, F)$, markings as multisets of places, a WF-net's source place $i$ and sink place $o$, and $[i]$ / $[o]$ for its initial and final marking. [16], [17], [1] *(`openprocess.mining.petrinet`)* +- **Events, traces, classifiers and the XES attributes** — `concept:name`, `time:timestamp`, `org:resource` and `lifecycle:transition`, and classifiers that turn events into activity labels. [24], [1] *(`openprocess.mining.log, openprocess.mining.xes`)* +- **Petri nets, markings and WF-nets** — $N = (P, T, F)$, markings as multisets of places, a WF-net's source place $i$ and sink place $o$, and $[i]$ / $[o]$ for its initial and final marking. [17], [18], [1] *(`openprocess.mining.petrinet`)* - **Footprint relations** — $a \rightarrow_L b$, $a \leftarrow_L b$, $a \parallel_L b$ and $a \#_L b$, derived from directly-follows $a >_L b$. [2], [1] *(`openprocess.mining.footprint`)* - **Process trees** — The operators $\rightarrow$ (sequence), $\times$ (exclusive choice), $\wedge$ (parallel) and $\circlearrowleft$ (loop), and $\tau$ for a silent step. [4], [1] *(`openprocess.mining.processtree`)* -- **Transition systems and regions** — $TS = (S, E, T, s_{in})$; a region entered, exited or not crossed by each event; generalised excitation regions $GER(e)$ and pre-/post-regions. [20], [8], [1] *(`openprocess.mining.transition_system, openprocess.mining.regions`)* -- **Alignments** — Moves $(a, a)$, $(a, \gg)$ and $(\gg, a)$, with the standard cost function. [11], [13] *(`openprocess.mining.conformance.alignments`)* -- **Coloured Petri nets and CPN ML** — Colour sets, variables, arc inscriptions, guards and timed tokens as in CPN Tools. [22], [25] *(`openprocess.ml, openprocess.model, openprocess.sim`)* +- **Transition systems and regions** — $TS = (S, E, T, s_{in})$; a region entered, exited or not crossed by each event; generalised excitation regions $GER(e)$ and pre-/post-regions. [21], [9], [1] *(`openprocess.mining.transition_system, openprocess.mining.regions`)* +- **Alignments** — Moves $(a, a)$, $(a, \gg)$ and $(\gg, a)$, with the standard cost function. [12], [14] *(`openprocess.mining.conformance.alignments`)* +- **Coloured Petri nets and CPN ML** — Colour sets, variables, arc inscriptions, guards and timed tokens as in CPN Tools. [23], [26] *(`openprocess.ml, openprocess.model, openprocess.sim`)* ## Discovery - **α-algorithm** — The eight steps from $T_L$ to $\alpha(L)$, with $X_L$ and $Y_L$. [2], [3], [1] *(`openprocess.mining.discovery.alpha`)* -- **Inductive Miner (IM) and IM – infrequent (IMf)** — Cuts, base cases and fall-throughs on the directly-follows graph; IMf filters infrequent behaviour. [4], [5] *(`openprocess.mining.discovery.inductive`)* -- **Heuristics Miner** — Dependency measures $a \Rightarrow b$, length-one and length-two loops, and the thresholds. [6], [7], [1] *(`openprocess.mining.discovery.heuristics`)* -- **State-based regions (two-phase discovery)** — A transition system from the log (prefix, postfix; sequence, multiset, set; a horizon), its minimal regions, and the synthesised net. [8], [3], [20], [21], [1] *(`openprocess.mining.discovery.state_regions`)* -- **Directly-follows graphs (process maps)** — Frequencies and times on the edges, and simplification. [9], [1] *(`openprocess.mining.dfg`)* -- **ILP Miner and heuristics nets (optional)** — Provided by PM4Py when it is installed. [15] *(`openprocess.mining.pm4py_bridge`)* +- **Inductive Miner (IM) and IM – infrequent (IMf)** — Cuts, base cases and fall-throughs on the directly-follows graph; the strict sequence cut; IMf filters infrequent behaviour. [4], [5], [6] *(`openprocess.mining.discovery.inductive`)* +- **Heuristics Miner** — Dependency measures $a \Rightarrow b$, length-one and length-two loops, and the thresholds. [7], [8], [1] *(`openprocess.mining.discovery.heuristics`)* +- **State-based regions (two-phase discovery)** — A transition system from the log (prefix, postfix; sequence, multiset, set; a horizon), its minimal regions, and the synthesised net. [9], [3], [21], [22], [1] *(`openprocess.mining.discovery.state_regions`)* +- **Directly-follows graphs (process maps)** — Frequencies and times on the edges, and simplification. [10], [1] *(`openprocess.mining.dfg`)* +- **ILP Miner and heuristics nets (optional)** — Provided by PM4Py when it is installed. [16] *(`openprocess.mining.pm4py_bridge`)* ## Conformance and quality -- **Token-based replay** — Produced, consumed, missing and remaining tokens, and fitness. [12], [1] *(`openprocess.mining.conformance.token_replay`)* -- **Alignments** — Optimal alignments by search over the synchronous product. [11], [13] *(`openprocess.mining.conformance.alignments`)* -- **Precision (escaping edges)** — ETConformance precision. [14], [11] *(`openprocess.mining.conformance.quality`)* -- **Generalisation and simplicity** — Token-based generalisation and simplicity, computed as in PM4Py. [15], [1] *(`openprocess.mining.conformance.quality`)* +- **Token-based replay** — Produced, consumed, missing and remaining tokens, and fitness. [13], [1] *(`openprocess.mining.conformance.token_replay`)* +- **Alignments** — Optimal alignments by search over the synchronous product. [12], [14] *(`openprocess.mining.conformance.alignments`)* +- **Precision (escaping edges)** — ETConformance precision. [15], [12] *(`openprocess.mining.conformance.quality`)* +- **Generalisation and simplicity** — Token-based generalisation and simplicity, computed as in PM4Py. [16], [1] *(`openprocess.mining.conformance.quality`)* ## Petri net analysis -- **Soundness of WF-nets** — Option to complete, proper completion, no dead transitions, and the short-circuited net $\overline{N}$. [16] *(`openprocess.mining.analysis`)* -- **Free-choice, well-structured, S-coverable** — Structural properties and the handles behind a problem. [16], [19] *(`openprocess.mining.structure`)* -- **Boundedness, liveness, deadlocks, reversibility** — Decided on the reachability graph, or the coverability graph for unbounded nets. [17], [18] *(`openprocess.mining.analysis`)* -- **Incidence matrix and invariants** — P- and T-invariants. [17] *(`openprocess.mining.invariants`)* -- **CPN simulation and state spaces** — Bindings, enabling and the state space report. [22] *(`openprocess.sim, openprocess.analysis`)* +- **Soundness of WF-nets** — Option to complete, proper completion, no dead transitions, and the short-circuited net $\overline{N}$. [17] *(`openprocess.mining.analysis`)* +- **Free-choice, well-structured, S-coverable** — Structural properties and the handles behind a problem. [17], [20] *(`openprocess.mining.structure`)* +- **Boundedness, liveness, deadlocks, reversibility** — Decided on the reachability graph, or the coverability graph for unbounded nets. [18], [19] *(`openprocess.mining.analysis`)* +- **Incidence matrix and invariants** — P- and T-invariants. [18] *(`openprocess.mining.invariants`)* +- **CPN simulation and state spaces** — Bindings, enabling and the state space report. [23] *(`openprocess.sim, openprocess.analysis`)* ## Files and pictures -- **XES event logs** — Reading and writing `.xes` and `.xes.gz`. [23] *(`openprocess.mining.xes`)* -- **PNML Petri nets** — Reading and writing `.pnml`. [24] *(`openprocess.mining.pnml`)* -- **CPN Tools models** — Reading and writing `.cpn`. [25], [22] *(`openprocess.io`)* -- **Dotted chart** — Events as dots over time, per case or per resource. [10] *(`openprocess.gui.studio.dotted_chart`)* -- **Layered graph layout** — How discovered models and graphs are drawn. [26] *(`openprocess.mining.layout`)* +- **XES event logs** — Reading and writing `.xes` and `.xes.gz`. [24] *(`openprocess.mining.xes`)* +- **PNML Petri nets** — Reading and writing `.pnml`. [25] *(`openprocess.mining.pnml`)* +- **CPN Tools models** — Reading and writing `.cpn`. [26], [23] *(`openprocess.io`)* +- **Dotted chart** — Events as dots over time, per case or per resource. [11] *(`openprocess.gui.studio.dotted_chart`)* +- **Layered graph layout** — How discovered models and graphs are drawn. [27] *(`openprocess.mining.layout`)* ## Works cited @@ -54,24 +54,25 @@ Where OpenProcess's notation and algorithms come from. Each topic names the work 3. W.M.P. van der Aalst and B.F. van Dongen. *Discovering Petri Nets from Event Logs*. Transactions on Petri Nets and Other Models of Concurrency VII, LNCS 7480. Springer, 2013. 4. S.J.J. Leemans, D. Fahland and W.M.P. van der Aalst. *Discovering Block-Structured Process Models from Event Logs – A Constructive Approach*. Application and Theory of Petri Nets and Concurrency (PETRI NETS 2013), LNCS 7927, pp. 311–329. Springer, 2013. 5. S.J.J. Leemans, D. Fahland and W.M.P. van der Aalst. *Discovering Block-Structured Process Models from Event Logs Containing Infrequent Behaviour*. Business Process Management Workshops (BPM 2013), LNBIP 171, pp. 66–78. Springer, 2014. -6. A.J.M.M. Weijters and W.M.P. van der Aalst. *Rediscovering Workflow Models from Event-Based Data using Little Thumb*. Integrated Computer-Aided Engineering 10(2):151–162, 2003. -7. A.J.M.M. Weijters, W.M.P. van der Aalst and A.K. Alves de Medeiros. *Process Mining with the HeuristicsMiner Algorithm*. BETA Working Paper Series, WP 166, Eindhoven University of Technology, 2006. -8. W.M.P. van der Aalst, V. Rubin, H.M.W. Verbeek, B.F. van Dongen, E. Kindler and C.W. Günther. *Process Mining: A Two-Step Approach to Balance Between Underfitting and Overfitting*. Software and Systems Modeling 9(1):87–111, 2010. -9. S.J.J. Leemans, E. Poppe and M.T. Wynn. *Directly Follows-Based Process Mining: Exploration & a Case Study*. International Conference on Process Mining (ICPM 2019), 2019. -10. M. Song and W.M.P. van der Aalst. *Supporting Process Mining by Showing Events at a Glance*. 17th Annual Workshop on Information Technologies and Systems (WITS 2007), pp. 139–145, 2007. -11. J. Carmona, B. van Dongen, A. Solti and M. Weidlich. *Conformance Checking: Relating Processes and Models*. Springer, 2018. -12. A. Rozinat and W.M.P. van der Aalst. *Conformance Checking of Processes Based on Monitoring Real Behavior*. Information Systems 33(1):64–95, 2008. -13. A. Adriansyah. *Aligning Observed and Modeled Behavior*. PhD thesis, Eindhoven University of Technology, 2014. -14. J. Muñoz-Gama and J. Carmona. *A Fresh Look at Precision in Process Conformance*. Business Process Management (BPM 2010), LNCS 6336, pp. 211–226. Springer, 2010. -15. A. Berti, S.J. van Zelst and W.M.P. van der Aalst. *Process Mining for Python (PM4Py): Bridging the Gap Between Process- and Data Science*. ICPM Demo Track 2019, CEUR-WS 2374, pp. 13–16, 2019. -16. W.M.P. van der Aalst. *Workflow Verification: Finding Control-Flow Errors Using Petri-Net-Based Techniques*. Business Process Management: Models, Techniques, and Empirical Studies, LNCS 1806, pp. 161–183. Springer, 2000. -17. T. Murata. *Petri Nets: Properties, Analysis and Applications*. Proceedings of the IEEE 77(4):541–580, 1989. -18. R.M. Karp and R.E. Miller. *Parallel Program Schemata*. Journal of Computer and System Sciences 3(2):147–195, 1969. -19. J. Desel and J. Esparza. *Free Choice Petri Nets*. Cambridge Tracts in Theoretical Computer Science 40. Cambridge University Press, 1995. -20. J. Cortadella, M. Kishinevsky, L. Lavagno and A. Yakovlev. *Deriving Petri Nets from Finite Transition Systems*. IEEE Transactions on Computers 47(8):859–882, 1998. -21. A. Ehrenfeucht and G. Rozenberg. *Partial (Set) 2-Structures. Part I: Basic Notions and the Representation Problem*, Acta Informatica 27(4):315–342, 1990; *Part II: State Spaces of Concurrent Systems*, Acta Informatica 27(4):343–368, 1990. -22. K. Jensen and L.M. Kristensen. *Coloured Petri Nets: Modelling and Validation of Concurrent Systems*. Springer, 2009. -23. IEEE Standard for eXtensible Event Stream (XES) for Achieving Interoperability in Event Logs and Event Streams. IEEE Std 1849-2016 (revised as IEEE Std 1849-2023). -24. ISO/IEC 15909-2:2011. *Systems and software engineering — High-level Petri nets — Part 2: Transfer format*. -25. CPN Tools and its model file format (`.cpn`). -26. K. Sugiyama, S. Tagawa and M. Toda. *Methods for Visual Understanding of Hierarchical System Structures*. IEEE Transactions on Systems, Man, and Cybernetics 11(2):109–125, 1981. +6. S.J.J. Leemans. *Robust Process Mining with Guarantees*. PhD thesis, Eindhoven University of Technology, 2017. ISBN 978-90-386-4257-4. +7. A.J.M.M. Weijters and W.M.P. van der Aalst. *Rediscovering Workflow Models from Event-Based Data using Little Thumb*. Integrated Computer-Aided Engineering 10(2):151–162, 2003. +8. A.J.M.M. Weijters, W.M.P. van der Aalst and A.K. Alves de Medeiros. *Process Mining with the HeuristicsMiner Algorithm*. BETA Working Paper Series, WP 166, Eindhoven University of Technology, 2006. +9. W.M.P. van der Aalst, V. Rubin, H.M.W. Verbeek, B.F. van Dongen, E. Kindler and C.W. Günther. *Process Mining: A Two-Step Approach to Balance Between Underfitting and Overfitting*. Software and Systems Modeling 9(1):87–111, 2010. +10. S.J.J. Leemans, E. Poppe and M.T. Wynn. *Directly Follows-Based Process Mining: Exploration & a Case Study*. International Conference on Process Mining (ICPM 2019), 2019. +11. M. Song and W.M.P. van der Aalst. *Supporting Process Mining by Showing Events at a Glance*. 17th Annual Workshop on Information Technologies and Systems (WITS 2007), pp. 139–145, 2007. +12. J. Carmona, B. van Dongen, A. Solti and M. Weidlich. *Conformance Checking: Relating Processes and Models*. Springer, 2018. +13. A. Rozinat and W.M.P. van der Aalst. *Conformance Checking of Processes Based on Monitoring Real Behavior*. Information Systems 33(1):64–95, 2008. +14. A. Adriansyah. *Aligning Observed and Modeled Behavior*. PhD thesis, Eindhoven University of Technology, 2014. +15. J. Muñoz-Gama and J. Carmona. *A Fresh Look at Precision in Process Conformance*. Business Process Management (BPM 2010), LNCS 6336, pp. 211–226. Springer, 2010. +16. A. Berti, S.J. van Zelst and W.M.P. van der Aalst. *Process Mining for Python (PM4Py): Bridging the Gap Between Process- and Data Science*. ICPM Demo Track 2019, CEUR-WS 2374, pp. 13–16, 2019. +17. W.M.P. van der Aalst. *Workflow Verification: Finding Control-Flow Errors Using Petri-Net-Based Techniques*. Business Process Management: Models, Techniques, and Empirical Studies, LNCS 1806, pp. 161–183. Springer, 2000. +18. T. Murata. *Petri Nets: Properties, Analysis and Applications*. Proceedings of the IEEE 77(4):541–580, 1989. +19. R.M. Karp and R.E. Miller. *Parallel Program Schemata*. Journal of Computer and System Sciences 3(2):147–195, 1969. +20. J. Desel and J. Esparza. *Free Choice Petri Nets*. Cambridge Tracts in Theoretical Computer Science 40. Cambridge University Press, 1995. +21. J. Cortadella, M. Kishinevsky, L. Lavagno and A. Yakovlev. *Deriving Petri Nets from Finite Transition Systems*. IEEE Transactions on Computers 47(8):859–882, 1998. +22. A. Ehrenfeucht and G. Rozenberg. *Partial (Set) 2-Structures. Part I: Basic Notions and the Representation Problem*, Acta Informatica 27(4):315–342, 1990; *Part II: State Spaces of Concurrent Systems*, Acta Informatica 27(4):343–368, 1990. +23. K. Jensen and L.M. Kristensen. *Coloured Petri Nets: Modelling and Validation of Concurrent Systems*. Springer, 2009. +24. IEEE Standard for eXtensible Event Stream (XES) for Achieving Interoperability in Event Logs and Event Streams. IEEE Std 1849-2016 (revised as IEEE Std 1849-2023). +25. ISO/IEC 15909-2:2011. *Systems and software engineering — High-level Petri nets — Part 2: Transfer format*. +26. CPN Tools and its model file format (`.cpn`). +27. K. Sugiyama, S. Tagawa and M. Toda. *Methods for Visual Understanding of Hierarchical System Structures*. IEEE Transactions on Systems, Man, and Cybernetics 11(2):109–125, 1981. diff --git a/openprocess/gui/studio/derivation_view.py b/openprocess/gui/studio/derivation_view.py index d717a2e..32bf81a 100644 --- a/openprocess/gui/studio/derivation_view.py +++ b/openprocess/gui/studio/derivation_view.py @@ -319,9 +319,11 @@ def _step_html(step) -> str: if step.kind == "cut": groups = "  |  ".join(_set(g) for g in step.groups or []) where = " (on the filtered DFG)" % muted if step.filtered else "" + # A sequence cut says when the strict step merged groups (Leemans 2017). + detail = f"
{escape(step.detail)}" if step.detail else "" return (f"{step.operator} " f"{OPERATOR_NAMES.get(step.operator, '')} cut{where}
" - f"{groups}") + f"{groups}{detail}") if step.kind == "base": leaf = "τ" if step.result == "τ" else _activity(step.result or "") return f"base case → {leaf}" diff --git a/openprocess/mining/discovery/inductive.py b/openprocess/mining/discovery/inductive.py index 3cff736..04e4809 100644 --- a/openprocess/mining/discovery/inductive.py +++ b/openprocess/mining/discovery/inductive.py @@ -1,7 +1,8 @@ """The Inductive Miner (IM) and Inductive Miner – infrequent (IMf). Reading: Leemans, Fahland & van der Aalst, "Discovering block-structured -process models from event logs" (course reading 4). +process models from event logs" (course reading 4); the strict sequence cut +is from Leemans, "Robust Process Mining with Guarantees" (PhD thesis, 2017). The idea: divide and conquer ---------------------------- @@ -22,6 +23,11 @@ Every activity of an earlier group can reach every activity of a later group, and never the other way round. Computed from the strongly connected components, merging components that are mutually unreachable. + Then the *strict* step (Leemans 2017, p. 233): groups that can only be + skipped together are merged, so an optional stretch ``a, c`` becomes + ``×(→(a,c), τ)`` rather than ``×(a,τ), ×(c,τ)``. ProM and PM4Py do the + same, and ``tests/cross_check_inductive_miner.py`` checks that the trees + agree. ∧ parallel Between two different groups, *every* pair of activities directly follows each other in both directions, and every group contains a start @@ -152,6 +158,15 @@ def mine(self, log: SimpleLog, depth: int) -> ProcessTree: self.note(depth, f"{operator.value} cut{' (on filtered DFG)' if filtered else ''}: " f"{described}", "cut", operator=operator.value, groups=[sorted(g) for g in groups], filtered=filtered) + if operator is Operator.SEQUENCE: + maximal = maximal_sequence_cut(dfg) or [] + if len(maximal) != len(groups): + merged = [sorted(g) for g in groups + if not any(set(g) == m for m in maximal)] + self.records[-1].detail = ( + "strict sequence cut: groups that can only be skipped together are " + "one group: " + " ".join("{" + ", ".join(g) + "}" for g in merged)) + self.steps.append(" " * (depth + 1) + self.records[-1].detail) sublogs = split_log(log, operator, groups, dfg) children = [self.mine(sub, depth + 1) for sub in sublogs] if operator is Operator.LOOP and len(children) > 2: @@ -269,30 +284,58 @@ def xor_cut(dfg: DFG) -> list[set[str]] | None: def sequence_cut(dfg: DFG) -> list[set[str]] | None: + """The *strict* sequence cut: the maximal sequence cut, then the groups + that can only be skipped together merged into one (Leemans 2017, p. 233). + + The maximal cut puts every activity in the finest possible position of + the sequence. That is correct but can be needlessly general: in + ``[^4, ]`` the maximal cut is ``{b} | {a} | {c}``, and since + ``a`` and ``c`` are absent from four of the five traces, each becomes + optional on its own, ``→(↺(b,τ), ×(a,τ), ×(c,τ))``, a model that also + allows ```` and ````, which never happened. ``c`` can only be + reached through ``a``, so whenever ``a`` is skipped ``c`` is skipped too: + merging them gives ``{b} | {a, c}`` and ``→(↺(b,τ), ×(→(a,c),τ))``. + """ + groups = maximal_sequence_cut(dfg) + return None if groups is None else merge_skippable_groups(dfg, groups) + + +def maximal_sequence_cut(dfg: DFG) -> list[set[str]] | None: + """The sequence cut with as many groups as the reachability relation + allows, ordered first to last. + + The definition (Leemans, Fahland & van der Aalst 2013; Leemans 2017, + p. 188): a partition ``Σ1, …, Σn`` such that for every earlier group + ``Σi`` and later group ``Σj``, *every* ``a ∈ Σi`` reaches *every* + ``b ∈ Σj`` through the directly-follows graph, and no ``b`` reaches + any ``a``. Two activities therefore belong to the same group exactly + when they reach each other (a loop) or when neither reaches the other + (two branches of a choice, or two concurrent activities): one of them + could not be put strictly before the other. + + Computed as the thesis does it: start with one group per activity and + merge any two groups that hold such a pair, until nothing merges. The + groups are then totally ordered by reachability. + """ reach = _reachability(dfg) - nodes = sorted(dfg.activities) - # Strongly connected components: a and b together iff each reaches the other. - groups: list[set[str]] = [] - for node in nodes: - for group in groups: - other = next(iter(group)) - if (other in reach[node] and node in reach[other]): - group.add(node) - break - else: - groups.append({node}) + groups: list[set[str]] = [{a} for a in sorted(dfg.activities)] + if not groups: + return None - def reaches(g1: set[str], g2: set[str]) -> bool: - return any(b in reach[a] for a in g1 for b in g2) + def must_share_a_group(g1: set[str], g2: set[str]) -> bool: + for a in g1: + for b in g2: + forward, backward = b in reach[a], a in reach[b] + if forward == backward: # both ways (a loop) or neither + return True + return False - # Merge groups that are mutually unreachable (neither can reach the other): - # they must sit in the same position of the sequence. merged = True while merged: merged = False for i in range(len(groups)): for j in range(i + 1, len(groups)): - if not reaches(groups[i], groups[j]) and not reaches(groups[j], groups[i]): + if must_share_a_group(groups[i], groups[j]): groups[i] |= groups.pop(j) merged = True break @@ -300,20 +343,86 @@ def reaches(g1: set[str], g2: set[str]) -> bool: break if len(groups) < 2: return None - # Order: g1 before g2 iff g1 reaches g2. Sorting by "number of groups - # reachable from me" (descending) gives a topological order. - # (Scores are computed first: during list.sort() the list looks empty to - # a key function that refers back to it.) + + # After the fixpoint, for any two groups every pair reaches in exactly one + # direction, so "how many groups does this one reach" orders them. + def reaches(g1: set[str], g2: set[str]) -> bool: + return any(b in reach[a] for a in g1 for b in g2) + score = [sum(1 for other in groups if other is not g and reaches(g, other)) for g in groups] groups = [g for _, g in sorted(zip(score, groups), key=lambda item: -item[0])] - # Validate: every earlier group reaches every later one and never back. + # Validate the definition literally: every pair, forward only. for i in range(len(groups)): for j in range(i + 1, len(groups)): - if not reaches(groups[i], groups[j]) or reaches(groups[j], groups[i]): - return None + for a in groups[i]: + for b in groups[j]: + if b not in reach[a] or a in reach[b]: + return None return groups +def merge_skippable_groups(dfg: DFG, groups: list[set[str]]) -> list[set[str]]: + """Merge the groups of a sequence cut that can only be skipped together. + + Follows the function on page 233 of S.J.J. Leemans, *Robust Process + Mining with Guarantees* (PhD thesis, 2017), which is also what ProM's and + PM4Py's Inductive Miner do, so the trees agree. + + A group ``p`` is *skippable* when some trace gets past it without + visiting it: an edge from a group before ``p`` to one after it, a start + activity after ``p``, or an end activity before ``p``. A skippable + group then absorbs + + * the groups just before it whose edges never go past ``p`` (they can + only leave the sequence through ``p`` or earlier, so skipping ``p`` + means skipping them), and + * the groups just after it that are only ever entered from ``p`` or + later (they can only be reached through ``p``, so skipping ``p`` + skips them as well). + + The merged group takes ``p``'s position, so the sequence order is kept. + Groups that do not merge are returned as they were. + """ + n = len(groups) + index = {a: i for i, g in enumerate(groups) for a in g} + # comes_from[i]: the earliest group an edge into group i comes from; before + # everything (-1) when the group holds a start activity. goes_to[i]: the + # latest group an edge out of group i goes to; past everything (n) when + # the group holds an end activity. + comes_from = [-1 if any(a in dfg.start for a in g) else n for g in groups] + goes_to = [n if any(a in dfg.end for a in g) else -1 for g in groups] + for a, b in dfg.edges: + comes_from[index[b]] = min(comes_from[index[b]], index[a]) + goes_to[index[a]] = max(goes_to[index[a]], index[b]) + + merged = [set(g) for g in groups] + where = dict(index) # activity -> its group's current position + + def skippable(p: int) -> bool: + if any(where[a] < p < where[b] for a, b in dfg.edges): + return True + return any(where[a] > p for a in dfg.start) or any(where[a] < p for a in dfg.end) + + def absorb(p: int, q: int) -> None: + merged[p] |= merged[q] + for a in merged[q]: + where[a] = p + merged[q] = set() + + for p in range(n): + if not merged[p] or not skippable(p): + continue + q = p - 1 + while q >= 0 and goes_to[q] <= p: + absorb(p, q) + q -= 1 + q = p + 1 + while q < n and comes_from[q] >= p: + absorb(p, q) + q += 1 + return [g for g in merged if g] + + def parallel_cut(dfg: DFG) -> list[set[str]] | None: nodes = sorted(dfg.activities) edges = set(dfg.edges) diff --git a/openprocess/references.py b/openprocess/references.py index 146f710..84f51b1 100644 --- a/openprocess/references.py +++ b/openprocess/references.py @@ -56,6 +56,8 @@ def url(self) -> str: "*Discovering Block-Structured Process Models from Event Logs Containing " "Infrequent Behaviour*. Business Process Management Workshops (BPM 2013), " "LNBIP 171, pp. 66–78. Springer, 2014.", "10.1007/978-3-319-06257-0_6"), + Reference("leemans2017", "S.J.J. Leemans. *Robust Process Mining with Guarantees*. PhD " + "thesis, Eindhoven University of Technology, 2017. ISBN 978-90-386-4257-4."), Reference("weijters2003", "A.J.M.M. Weijters and W.M.P. van der Aalst. *Rediscovering " "Workflow Models from Event-Based Data using Little Thumb*. Integrated " "Computer-Aided Engineering 10(2):151–162, 2003."), @@ -175,8 +177,9 @@ class Topic: Topic("α-algorithm", "The eight steps from $T_L$ to $\\alpha(L)$, with $X_L$ and " "$Y_L$.", ("aalst2004", "aalst2013", "aalst2016"), "openprocess.mining.discovery.alpha"), Topic("Inductive Miner (IM) and IM – infrequent (IMf)", - "Cuts, base cases and fall-throughs on the directly-follows graph; IMf filters " - "infrequent behaviour.", ("leemans2013", "leemans2014"), + "Cuts, base cases and fall-throughs on the directly-follows graph; the strict " + "sequence cut; IMf filters infrequent behaviour.", + ("leemans2013", "leemans2014", "leemans2017"), "openprocess.mining.discovery.inductive"), Topic("Heuristics Miner", "Dependency measures $a \\Rightarrow b$, length-one and length-two loops, and " diff --git a/tests/cross_check_inductive_miner.py b/tests/cross_check_inductive_miner.py index 9f3086f..f9c1a21 100644 --- a/tests/cross_check_inductive_miner.py +++ b/tests/cross_check_inductive_miner.py @@ -21,13 +21,20 @@ token-based precision) of the two models on the same log, so a difference can be read as "ours is less precise", "equal" or "more precise". -Baseline, OpenProcess 0.11.1 against PM4Py 2.7.23, seed 2026, 400 random logs: -406 logs, guarantees hold on 406, identical trees on 369 (all six textbook logs -among them), 37 differ, of which 30 less precise, 4 equal, 3 more precise. -Every one of the 37 is the same choice: after a sequence cut, groups that are -always skipped *together* (``, ``: ``a`` and ``c`` never appear -without each other) are modelled as skippable *separately*, -``→(…, ×(a,τ), ×(c,τ))``, where the reference gives ``→(…, ×(→(a,c),τ))``. +Baseline against PM4Py 2.7.23, seed 2026, 400 random logs: + +* OpenProcess 0.11.1: guarantees hold on 406, identical trees on 369 (all six + textbook logs among them), 37 differ, of which 30 less precise. Every one + of the 37 came from the sequence cut: groups that are only ever skipped + *together* (``, ``: ``a`` and ``c`` never appear without each + other) were modelled as skippable *separately*, ``→(…, ×(a,τ), ×(c,τ))``, + where the reference gives ``→(…, ×(→(a,c),τ))``; and a cut was accepted + when *some* pair of activities reached across it rather than *every* pair. +* After the strict sequence cut (Leemans 2017, p. 233) and the every-pair + test: guarantees hold on 406, identical trees on 401, 5 differ (2 less + precise, 2 equal, 1 more precise), all of them fall-throughs on logs that + have no cut at all, where the two implementations try the fall-throughs + in a different order. """ from __future__ import annotations diff --git a/tests/test_mining.py b/tests/test_mining.py index c7c63a6..e5e7c38 100644 --- a/tests/test_mining.py +++ b/tests/test_mining.py @@ -148,6 +148,41 @@ def test_inductive_miner_on_course_log_matches_pm4py(): assert "stow bag" in tree +def test_sequence_cut_follows_the_definition_for_every_pair(): + """Every activity of an earlier group must reach every activity of a later + one. In [, ] c reaches nothing and d is reached + only from a, so c and d (and a) must share a group: {b} | {a, c, d}, not + {b} | {a, c} | {d}, which the old any-pair test accepted.""" + from openprocess.mining.dfg import dfg_from_simple_log + from openprocess.mining.discovery.inductive import maximal_sequence_cut + dfg = dfg_from_simple_log(parse_simple_log("[^2, ^3]")) + assert maximal_sequence_cut(dfg) == [{"b"}, {"a", "c", "d"}] + tree = str(inductive_miner(parse_simple_log("[^2, ^3]")).tree) + assert tree == "→(↺(b, τ), ×(→(a, ↺(d, τ)), ↺(c, τ)))" # × is commutative + + +def test_strict_sequence_cut_merges_groups_skipped_together(): + """Leemans 2017, p. 233: a and c never occur without each other, so the + optional stretch is ×(τ, →(a, c)), not two separate choices ×(τ, a), + ×(τ, c) that would also allow and .""" + from openprocess.mining.dfg import dfg_from_simple_log + from openprocess.mining.discovery.inductive import (maximal_sequence_cut, + merge_skippable_groups, sequence_cut) + log = parse_simple_log("[^4, ]") + dfg = dfg_from_simple_log(log) + assert maximal_sequence_cut(dfg) == [{"b"}, {"a"}, {"c"}] + assert merge_skippable_groups(dfg, [{"b"}, {"a"}, {"c"}]) == [{"b"}, {"a", "c"}] + assert sequence_cut(dfg) == [{"b"}, {"a", "c"}] + result = inductive_miner(log) + assert str(result.tree) == "→(↺(b, τ), ×(τ, →(a, c)))" + assert any("skipped together" in s.text or "skipped together" in (s.detail or "") + for s in result.steps) + # The textbook log is a plain sequence cut: nothing to merge, nothing said. + plain = inductive_miner(parse_simple_log("[^3, ^2, ]")) + assert str(plain.tree) == "→(a, ×(∧(b, c), e), d)" + assert not any("skipped together" in (s.detail or "") for s in plain.steps) + + def test_inductive_miner_always_fits_randomised(): rng = random.Random(7) for _ in range(15):