Ocelescope - Object-Centric Process Mining
// OBJECT-CENTRIC PROCESS MINING
An extensible web framework for object-centric process mining.
Upload an OCEL log, filter it down, discover models, and extend everything with plugins. Spin it up with Docker and analyze in minutes - entirely in your browser.
LOG OVERVIEW
Understand any OCEL log at a glance.
Upload a log and Ocelescope profiles it instantly: events and objects, every type, attribute value ranges and the time span they cover. Know what you are working with before you write a single query.
FILTER CONSTRUCTOR
Carve a large log down to the slice you need.
Compose filters visually to shrink an OCEL. Stack conditions across object and event types, attribute values, time windows and the existence of O2O or E2O relations - then carry the reduced log straight into discovery.
DISCOVERY
Discover models - and add your own methods.
Out of the box, the discovery tab renders object-centric Petri nets and directly-follows graphs. Need a different algorithm? Define it plugin-style, upload the zip, and it appears as a selectable method right in the tab.
WHY OCELESCOPE
Ocelescope is where research code becomes reusable software.
Ocelescope provides a plugin system to integrate new funtionality at runtime. A method should not disappear into a one-off prototype for publication. Package it, upload it, run it, and let others build on it.
BUILD A PLUGIN
Declare your parameters. Ocelescope renders the UI.
No frontend work. Type your plugin inputs in Python and the form on the right is generated for you.
from typing import Annotated
from ocelescope import (
OCEL,
OCELAnnotation,
Plugin,
plugin_method,
)
from .input import OCELGraphInput
from .resource import OCELGraph
from .util import mine_ocel_graph
class OcelGraphDiscovery(Plugin):
label = "Ocel Graph"
description = "Discovers a Object-Centric event log graph"
version = "1.0.3"
@plugin_method(label="Mine OCEL Graph", description="Mines a OCEL Graph")
def mine_ocel_graph(
self,
ocel: Annotated[
OCEL,
OCELAnnotation(label="Event Log", description="The log from which the ocel graph should be mined from"),
],
input: OCELGraphInput,
) -> OCELGraph:
return mine_ocel_graph(ocel, input) from ocelescope import OCEL_FIELD, PluginInput
from pydantic import BaseModel, Field
class ObjectRoot(BaseModel):
class Config:
title = "Object"
object_id: str = OCEL_FIELD(
field_type="object_id",
title="Object Id",
ocel_id="ocel",
description="The ID of the Event which is the root of the OcelGraph",
)
class EventRoot(BaseModel):
class Config:
title = "Event"
event_id: str = OCEL_FIELD(
field_type="event_id",
title="Event Id",
ocel_id="ocel",
description="The ID of the Event which is the root of the OcelGraph",
)
class OCELGraphInput(PluginInput):
root: ObjectRoot | EventRoot
depth: int = Field(
title="OCEL Graph Depth", description="The maximum depth of the ocel graph", default=3, gt=0, le=10
)
max_neighbours: int = Field(
title="Maximum Neighbours", description="The maximum amount of neighbours a node can have", gt=0, default=5
) from ocelescope import Graph, GraphEdge, GraphNode, GraphvizLayoutConfig, Resource, generate_color_map
from pydantic import BaseModel
class EventNode(BaseModel):
id: str
activity: str
class ObjectNode(BaseModel):
id: str
object_type: str
class Relation(BaseModel):
qualifier: str
source: str
target: str
object_type: str | None = None
class OCELGraph(Resource):
label = "Ocel Graph"
description = "A Ocel graph"
events: list[EventNode] = []
objects: list[ObjectNode] = []
relations: list[Relation] = []
@property
def event_ids(self) -> list[str]:
return [event.id for event in self.events]
@property
def object_ids(self) -> list[str]:
return [object.id for object in self.objects]
def visualize(self) -> Graph:
color_map = generate_color_map(list(set([object.object_type for object in self.objects])))
object_nodes = [
GraphNode(
id=object_node.id, shape="rectangle", label=object_node.id, color=color_map[object_node.object_type]
)
for object_node in self.objects
]
event_nodes = [GraphNode(id=event.id, shape="rectangle", label=event.id) for event in self.events]
edges = [
GraphEdge(
source=edge.source,
target=edge.target,
label=edge.qualifier,
color=color_map[edge.object_type] if edge.object_type else None,
)
for edge in self.relations
]
return Graph(
type="graph",
nodes=object_nodes + event_nodes,
edges=edges,
layout_config=GraphvizLayoutConfig(engine="neato", graphAttrs={"overlap": "prism"}),
) from typing import cast
import pandas as pd
from ocelescope import OCEL
from .input import EventRoot, OCELGraphInput
from .resource import EventNode, ObjectNode, OCELGraph, Relation
def group_relation_entity(
df: pd.DataFrame,
entity_ids: list[str],
id_column: str,
type_column: str,
target_id_column: str,
):
"""
Count how many 'target' entities are linked to each entity (event or object).
Returns:
DataFrame with columns: id, type, count
"""
return (
df[df[id_column].isin(entity_ids)]
.groupby([id_column, type_column])[target_id_column]
.size()
.reset_index()
.rename(columns={id_column: "id", type_column: "type", target_id_column: "count"})
)
def mine_ocel_graph(ocel: OCEL, input: OCELGraphInput):
graph = OCELGraph()
events_to_visit = []
objects_to_visit = []
if isinstance(input.root, EventRoot):
root_id = input.root.event_id
root = ocel.events.df[ocel.events.df[ocel.ocel.event_id_column] == input.root.event_id].iloc[0]
events_to_visit.append(EventNode(id=input.root.event_id, activity=root[ocel.ocel.event_activity]))
else:
root_id = input.root.object_id
root = ocel.objects.df[ocel.objects.df[ocel.ocel.object_id_column] == input.root.object_id].iloc[0]
objects_to_visit.append(ObjectNode(id=input.root.object_id, object_type=root[ocel.ocel.object_type_column]))
for _ in range(input.depth):
# Get current frontier IDs
event_ids_to_visit = [event.id for event in events_to_visit]
object_ids_to_visit = [obj.id for obj in objects_to_visit]
# Get event-object relations using XOR and not already in the graph
relations: pd.DataFrame = cast(
pd.DataFrame,
ocel.e2o.df[
(
(ocel.e2o.df[ocel.ocel.event_id_column].isin(event_ids_to_visit))
^ (ocel.e2o.df[ocel.ocel.object_id_column].isin(object_ids_to_visit))
)
& ~(ocel.e2o.df[ocel.ocel.event_id_column].isin(graph.event_ids))
& ~(ocel.e2o.df[ocel.ocel.object_id_column].isin(graph.object_ids))
],
)
# Count object neighbors per event
events = group_relation_entity(
df=relations,
entity_ids=event_ids_to_visit,
id_column=ocel.ocel.event_id_column,
type_column=ocel.ocel.event_activity,
target_id_column=ocel.ocel.object_id_column,
)
# Count event neighbors per object
e2o_objects = group_relation_entity(
df=relations,
entity_ids=object_ids_to_visit,
id_column=ocel.ocel.object_id_column,
type_column=ocel.ocel.object_type_column,
target_id_column=ocel.ocel.event_id_column,
)
# Get object-object (o2o) relations using XOR
o2o = cast(
pd.DataFrame,
ocel.o2o.typed_df[
(
(ocel.o2o.df["ocel:oid_1"].isin(object_ids_to_visit))
^ (ocel.o2o.df["ocel:oid_2"].isin(object_ids_to_visit))
)
& ~(ocel.o2o.df["ocel:oid_1"].isin(graph.object_ids))
& ~(ocel.o2o.df["ocel:oid_2"].isin(graph.object_ids))
],
)
# Normalize and mirror o2o (treat as undirected)
o2o = o2o.rename(
columns={"ocel:oid_1": "id", "ocel:type_1": "type", "ocel:oid_2": "target_id", "ocel:type_2": "target_type"}
)
mirrored = o2o.rename(
columns={"target_id": "id", "target_type": "type", "id": "target_id", "type": "target_type"}
)
mirrored_o2o = pd.concat([o2o, mirrored], ignore_index=True).rename(columns={"ocel:qualifier": "qualifier"})
# Count o2o neighbours for each object
o2o_objects = group_relation_entity(
df=mirrored_o2o,
entity_ids=object_ids_to_visit,
id_column="id",
type_column="type",
target_id_column="target_id",
)
# Combine object neighbour counts
objects = (
pd.concat([o2o_objects, e2o_objects], ignore_index=True)
.groupby(["id", "type"], as_index=False)["count"]
.sum()
)
# Update graph with this layer
graph.objects = graph.objects + objects_to_visit
graph.events = graph.events + events_to_visit
# Prepare for next layer
events_to_visit = []
objects_to_visit = []
object_id_with_neighbours = [
row["id"] for _, row in objects.iterrows() if row["count"] <= input.max_neighbours or row["id"] == root_id
]
event_id_with_neighbours = [
row["id"] for _, row in events.iterrows() if row["count"] <= input.max_neighbours or row["id"] == root_id
]
for _, row in (
cast(pd.DataFrame, mirrored_o2o[mirrored_o2o["id"].isin(object_id_with_neighbours)])
.drop_duplicates(subset=["target_id"], keep="first")
.iterrows()
):
graph.relations.append(
Relation(source=str(row["id"]), target=str(row["target_id"]), qualifier=str(row["qualifier"]))
)
objects_to_visit.append(ObjectNode(id=str(row["target_id"]), object_type=str(row["target_type"])))
for _, row in (
cast(pd.DataFrame, relations[relations["ocel:oid"].isin(object_id_with_neighbours)])
.drop_duplicates(subset=["ocel:eid"], keep="first")
.iterrows()
):
graph.relations.append(
Relation(
source=str(row["ocel:eid"]),
target=str(row["ocel:oid"]),
qualifier=str(row["ocel:qualifier"]),
object_type=str(row["ocel:type"]),
)
)
events_to_visit.append(EventNode(id=str(row["ocel:eid"]), activity=str(row["ocel:activity"])))
for _, row in (
cast(
pd.DataFrame,
relations[
relations["ocel:eid"].isin(event_id_with_neighbours)
& ~relations["ocel:oid"].isin([obj.id for obj in objects_to_visit])
],
)
.drop_duplicates(subset=["ocel:oid"], keep="first")
.iterrows()
):
graph.relations.append(
Relation(
source=str(row["ocel:eid"]),
target=str(row["ocel:oid"]),
qualifier=str(row["ocel:qualifier"]),
object_type=str(row["ocel:type"]),
)
)
objects_to_visit.append(ObjectNode(id=str(row["ocel:oid"]), object_type=str(row["ocel:type"])))
graph.objects = graph.objects + objects_to_visit
graph.events = graph.events + events_to_visit
return graph EXTENSIBLE UI
When a form is not enough, ship a full module.
Each plugin gets a generated page. For richer workflows, custom React modules mount as first-class views. This way, the OCELOT module was integrated.
PLUGIN ECOSYSTEM
A growing library of analyses.
RESOURCES
Everything to get unstuck.
Documentation
GitHub
Share a plugin with the Ocelescope ecosystem.
PACKAGES Request an environment packageAsk for a Python package to be added to the plugin execution environment.
ISSUES Report an issueOpen a focused bug report with reproduction details.
IDEAS Request a featureSuggest improvements for the framework or docs.
SOURCE GitHub & troubleshootingBrowse the source code and project activity.
GET STARTED
Running in two commands.
Download the compose file, start the stack, and open the frontend on localhost.