ergodicDocs
Products: switch from ergodic to another product
Get started

GuidesCheck conformance and decision points

Check conformance and decision points

Mine a process model from an event log, check how well the log fits it, and estimate the effect of taking one branch of a decision. The example routes cases to an expedited or a standard track.

You'll need

Run the program#

Each case is opened, triaged, sent to expedite or standard, and resolved. Complex cases are expedited more often and take longer anyway. Expediting cuts the time left by 5 hours. About 3% of standard cases escalate to expedite midway.

decision_points.pyDownload
import numpy as np
import pandas as pd
from ergodic import conformance, decision_points, decision_table, discover, discover_model
from ergodic import estimate_effect, event_log
from ergodic.process import filter_variants, is_sound, seq, xor

# 1. Simulate 2,000 cases; timestamps are in hours. Complex cases are expedited
#    more often, expediting cuts 5 hours, and some standard cases escalate.
rng = np.random.default_rng(7)
n = 2000
complexity = rng.uniform(0, 1, n)
expedite = rng.uniform(size=n) < 1 / (1 + np.exp(-3 * (complexity - 0.5)))
remaining = 10 + 8 * complexity - 5 * expedite + rng.normal(0, 1, n)
escalate = ~expedite & (rng.uniform(size=n) < 0.03)
opened = rng.uniform(0, 90 * 24, n)
rows = []
for i in range(n):
    t = opened[i]
    decision = t + 1.0 + rng.uniform(0.5, 2.0)
    branch = "expedite" if expedite[i] else "standard"
    steps = [("open", t), ("triage", t + 1.0), (branch, decision)]
    steps += [("expedite", decision + 1.0)] * int(escalate[i])
    steps.append(("resolve", decision + remaining[i]))
    rows += [(f"C{i:04d}", activity, at, complexity[i]) for activity, at in steps]
frame = pd.DataFrame(rows, columns=["case", "activity", "hour", "complexity"])
log = event_log(frame, case="case", activity="activity", timestamp="hour")

# 2. Mine a model from the whole log, then from its two most frequent variants.
print(discover_model(log).tree)
model = discover_model(filter_variants(log, top=2))
print(model.tree)
print(model.tree == seq("open", "triage", xor("expedite", "standard"), "resolve"))
print(model.net, "sound:", is_sound(model.net))

# 3. Check the whole log against the model.
fit = conformance(log, model)
print(fit)
for variant in fit.variants:
    print(variant.n_cases, f"{variant.alignment_fitness:.3f}", variant.alignment)

# 4. Find the decision, and encode it as one row per case.
print(decision_points(log, model))
table = decision_table(
    log, model, covariates=["complexity", "elapsed_at_landmark", "workload_at_landmark"]
)
print(table)
positivity = table.positivity
print([round(share, 3) for share in positivity.shares], positivity.overlap_share, positivity.flagged)
by_case = table.data.to_pandas()
print(by_case.groupby("branch", observed=True)["remaining_time"].mean().round(2))

# 5. Discover the graph and estimate the effect of expediting.
graph = discover(table.data, knowledge=table.knowledge, alpha=0.01).graph
estimate = estimate_effect(
    graph.as_kind("dag"), table.data, "branch", "remaining_time",
    strategy="dml", treated="expedite", random_state=0,
)
print(estimate)
Recorded output
seq('open', 'triage', xor(TAU, 'standard'), xor(TAU, 'expedite'), 'resolve')
seq('open', 'triage', xor('expedite', 'standard'), 'resolve')
True
WorkflowNet(places=5, transitions=5 (0 silent), arcs=10) sound: True
ConformanceResult(fitness=0.998, alignment_fitness=0.999, fitting=1976/2000 cases)
1003 1.000 (('sync', 'open'), ('sync', 'triage'), ('sync', 'expedite'), ('sync', 'resolve'))
973 1.000 (('sync', 'open'), ('sync', 'triage'), ('sync', 'standard'), ('sync', 'resolve'))
24 0.889 (('sync', 'open'), ('sync', 'triage'), ('sync', 'standard'), ('log', 'expedite'), ('sync', 'resolve'))
(DecisionPoint(path=(2,), expedite=1003, standard=973, skipped=0, ambiguous=24),)
DecisionTable(cases=1976, branches=('expedite', 'standard'), outcome='remaining_time', covariates=3, dropped_ambiguous=24)
[0.508, 0.492] 1.0 False
branch
expedite     9.89
standard    13.10
Name: remaining_time, dtype: float64
EffectEstimate(dml, analytic: ate=-4.981, se=0.0489, 95% CI [-5.08, -4.88], n=1976)

Mine and check a model#

discover_model(log, noise=0.0) is the Inductive Miner. It returns a ProcessModel with a tree (a ProcessTree), its net (a WorkflowNet), and what it removed on the way. noise drops each directly-follows arc lighter than noise times the heaviest arc leaving the same activity; the count is in n_filtered_arcs. The filter removes arcs, not traces, so an activity missing from some traces still becomes optional at any noise. Mining from filter_variants is the more direct way to get the main path.

A process tree is built from seq, xor, par and loop(do, redo), with TAU for a silent step. Strings become activity leaves. tree.to_net() converts it to a sound workflow net. is_sound(net) checks any net for soundness and raises past max_states=100_000 reachable markings rather than guess.

conformance(log, model) takes a ProcessModel, a ProcessTree or a WorkflowNet and answers twice:

Read a decision causally#

At an xor, the branch a case takes is a treatment. decision_table measures each case at the landmark, its first event inside the split. The covariates come from before that moment, and the default outcome is remaining_time: from the landmark to the last event. Time already spent before the decision can't be an effect of it.

table.knowledge orders covariates, then branch, then the outcome. Pass treated= with a branch name when you estimate.

Read the positivity report

table.positivity is flagged when a branch has under 5% of the cases, any branch has fewer than 10 cases, or, for two branches, fewer than 80% of cases have a propensity in [0.05, 0.95]. A flag means the branches barely overlap, and the estimate there leans on extrapolation.

Here the covariates are listed by hand. With covariates="auto", the table also includes workload_at_start, and discovery leaves the edge between the two workload columns undirected. as_kind("dag") then raises InvalidGraphError.

What can't a decision table handle?
  • A tree with several xor nodes needs point=: a path from decision_points, or the DecisionPoint itself.
  • A split with a silent branch (xor(TAU, ...)) raises: taking it leaves no event, so the decision moment can't be seen. decision_points still reports it.
  • Every activity label must sit in exactly one leaf of the tree. A mined tree satisfies this; a hand-built tree that repeats a label raises.
  • A log column named branch collides with the treatment column and raises.
  • A replay or alignment search past max_states raises instead of returning a partial answer. So does a net that can't reach its final marking.

Next steps#

Documentation

Search ergodic 0.2.1

Search ergodic by task, function or error.