ReForma 0.3__tar.gz → 0.4__tar.gz

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.
@@ -1,16 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ReForma
3
- Version: 0.3
3
+ Version: 0.4
4
4
  Summary: Python bindings for the RePA/ReForma probabilistic automaton tool
5
5
  Author-email: Joshua Dourado <joshuadourado@ua.pt>
6
6
  License: MIT
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Topic :: Scientific/Engineering
10
- Requires-Python: >=3.10
10
+ Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
12
  Requires-Dist: networkx
13
13
  Requires-Dist: matplotlib
14
+ Provides-Extra: data
15
+ Requires-Dist: pandas; extra == "data"
16
+ Requires-Dist: numpy; extra == "data"
14
17
 
15
18
 
16
19
  # ReForma — Reconfigurable Formal Automata in Python
@@ -1,16 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ReForma
3
- Version: 0.3
3
+ Version: 0.4
4
4
  Summary: Python bindings for the RePA/ReForma probabilistic automaton tool
5
5
  Author-email: Joshua Dourado <joshuadourado@ua.pt>
6
6
  License: MIT
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Topic :: Scientific/Engineering
10
- Requires-Python: >=3.10
10
+ Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
12
  Requires-Dist: networkx
13
13
  Requires-Dist: matplotlib
14
+ Provides-Extra: data
15
+ Requires-Dist: pandas; extra == "data"
16
+ Requires-Dist: numpy; extra == "data"
14
17
 
15
18
 
16
19
  # ReForma — Reconfigurable Formal Automata in Python
@@ -0,0 +1,6 @@
1
+ networkx
2
+ matplotlib
3
+
4
+ [data]
5
+ pandas
6
+ numpy
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ReForma"
7
- version = "0.3"
7
+ version = "0.4"
8
8
  description = "Python bindings for the RePA/ReForma probabilistic automaton tool"
9
9
  readme = "README.md"
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.9"
11
11
  license = { text = "MIT" }
12
12
  authors = [
13
13
  { name = "Joshua Dourado", email = "joshuadourado@ua.pt" }
@@ -22,6 +22,9 @@ dependencies = [
22
22
  "matplotlib"
23
23
  ]
24
24
 
25
+ [project.optional-dependencies]
26
+ data = ["pandas", "numpy"]
27
+
25
28
  [tool.setuptools]
26
29
  packages = ["reforma", "reforma.bin", "reforma.bin.js"]
27
30
 
@@ -1,19 +1,19 @@
1
- """
2
- reforma - Python bindings for the RePA/reforma tool.
3
-
4
- The JAR is bundled inside the package. Usage:
5
-
6
- from reforma import ReForma
7
-
8
- model = ReForma()
9
-
10
- state = model.load_file("my_model.r")
11
- state = model.step("go_work")
12
- result = model.check_pdl("Home", "{P=?[F Office]}")
13
- """
14
-
15
- from .client import ReForma
16
- from .model import ReFormaModel, Transition, SimulationState
17
-
18
- __all__ = ["ReForma", "ReFormaModel", "Transition", "SimulationState"]
19
- __version__ = "0.2.0"
1
+ """
2
+ reforma - Python bindings for the RePA/reforma tool.
3
+
4
+ The JAR is bundled inside the package. Usage:
5
+
6
+ from reforma import ReForma
7
+
8
+ model = ReForma()
9
+
10
+ state = model.load_file("my_model.r")
11
+ state = model.step("go_work")
12
+ result = model.check_pdl("Home", "{P=?[F Office]}")
13
+ """
14
+
15
+ from .client import ReForma
16
+ from .model import ReFormaModel, Transition, SimulationState
17
+
18
+ __all__ = ["ReForma", "ReFormaModel", "Transition", "SimulationState"]
19
+ __version__ = "0.4"
@@ -81,19 +81,20 @@ class ReForma:
81
81
 
82
82
 
83
83
  def create_from_dataframe(
84
- self,
85
- df,
86
- session_col: str = 'user_session',
87
- event_col: str = 'event_type',
84
+ self,
85
+ df,
86
+ session_col: str = 'user_session',
87
+ event_col: str = 'event_type',
88
88
  sub_event_col: Optional[str] = None,
89
- time_col: str = 'event_time',
89
+ time_col: str = 'event_time',
90
90
  model_name: str = "MinedModel",
91
91
  save_model_path: Optional[str] = None,
92
- save_traces_path: Optional[str] = None
92
+ save_traces_path: Optional[str] = None,
93
+ initial_weights: str = "frequency"
93
94
  ) -> list[list[str]]:
94
95
  """
95
96
  Discovers the RePA model directly from a Pandas DataFrame.
96
-
97
+
97
98
  :param df: Pandas DataFrame containing the dataset.
98
99
  :param session_col: Column name identifying user sessions.
99
100
  :param event_col: Column name containing the action/event.
@@ -102,34 +103,85 @@ class ReForma:
102
103
  :param model_name: Name to give the automaton.
103
104
  :param save_model_path: If set, saves the generated .rta code to this file path.
104
105
  :param save_traces_path: If set, saves the session logs (.txt) to this file path.
106
+ :param initial_weights: "frequency" (default) writes the observed transition frequencies (the maximum
107
+ likelihood estimate) as weights; "uniform" leaves the weights out, so every state starts uniform.
105
108
  :return: A list of sessions (each session is a list of transition IDs).
106
109
  """
107
110
  try:
108
111
  import pandas as pd
112
+ import numpy as np
109
113
  import re
110
114
  except ImportError:
111
115
  raise RuntimeError("To use process mining you need to install: pip install pandas")
112
-
113
- def sanitize(text):
114
- return re.sub(r'[^a-zA-Z0-9_]', '_', str(text))
115
-
116
- df_work = df.dropna(subset=[session_col]).copy()
117
- df_work = df_work.sort_values(by=[session_col, time_col])
118
-
119
- if sub_event_col and sub_event_col in df_work.columns:
120
- df_work[sub_event_col] = df_work[sub_event_col].fillna('other').astype(str)
121
- parsed_sub = df_work[sub_event_col].apply(lambda x: str(x).split('.')[-1])
122
- df_work['__target_event'] = df_work[event_col].astype(str) + "_" + parsed_sub
116
+ if initial_weights not in ("frequency", "uniform"):
117
+ raise ValueError("initial_weights must be 'frequency' or 'uniform'")
118
+
119
+ has_sub = bool(sub_event_col) and sub_event_col in df.columns
120
+ cols = [session_col, time_col, event_col] + ([sub_event_col] if has_sub else [])
121
+ df_work = df.loc[df[session_col].notna(), cols]
122
+ # stable sort: events with the same timestamp keep the order of the log
123
+ df_work = df_work.sort_values(by=[session_col, time_col], kind="mergesort")
124
+
125
+ # Every row gets the integer code of its (composite) event; all the work below is done on these codes.
126
+ ev = df_work[event_col].astype("category")
127
+ ev_codes = ev.cat.codes.to_numpy().astype(np.int64)
128
+ ev_names = [str(c) for c in ev.cat.categories]
129
+ if has_sub:
130
+ sub = df_work[sub_event_col].astype("category")
131
+ sub_names = [str(c) for c in sub.cat.categories] + ["other"] # missing sub-events -> "other"
132
+ sub_codes = sub.cat.codes.to_numpy().astype(np.int64)
133
+ sub_codes[sub_codes < 0] = len(sub_names) - 1
134
+ # the last component of a category ("electronics.smartphone" -> "smartphone") is used, unless two
135
+ # different categories share it: those keep their full path
136
+ by_leaf: dict = {}
137
+ for c in sub_names:
138
+ by_leaf.setdefault(c.split('.')[-1], set()).add(c)
139
+ short = [c.split('.')[-1] if len(by_leaf[c.split('.')[-1]]) == 1 else c for c in sub_names]
140
+ combo = ev_codes * len(sub_names) + sub_codes
141
+ uniq, raw_codes = np.unique(combo, return_inverse=True)
142
+ raw_names = [f"{ev_names[u // len(sub_names)]}_{short[u % len(sub_names)]}" for u in uniq]
123
143
  else:
124
- df_work['__target_event'] = df_work[event_col].astype(str)
125
-
126
- df_work['next_event'] = df_work.groupby(session_col)['__target_event'].shift(-1).fillna('exit')
127
-
128
- df_work['clean_event'] = df_work['__target_event'].apply(sanitize)
129
- df_work['clean_next'] = df_work['next_event'].apply(sanitize)
130
-
131
- first_events = df_work.groupby(session_col)['clean_event'].first().value_counts()
132
- transitions = df_work.groupby(['clean_event', 'clean_next']).size().reset_index(name='count')
144
+ raw_codes, raw_names = ev_codes, ev_names
145
+ raw_codes = np.asarray(raw_codes, dtype=np.int64)
146
+
147
+ # One Re-Lang name per distinct event, distinct from each other, from the artificial states and from keywords.
148
+ reserved = {"Start", "exit", "init", "training", "calibration", "paradigm", "name", "int", "float", "bool",
149
+ "dyn", "def", "aut", "if", "then", "disabled", "true", "false", "return", "print", "foreach",
150
+ "in", "AND", "OR", "laplace", "aggregation", "arith", "prod", "max", "min", "geom"}
151
+ clean = [""] * len(raw_names)
152
+ used = set(reserved)
153
+ for i in sorted(range(len(raw_names)), key=lambda i: raw_names[i]):
154
+ base = re.sub(r'[^a-zA-Z0-9_]', '_', raw_names[i]).strip('_') or "event"
155
+ if base[0].isdigit() or base in reserved:
156
+ base = "ev_" + base
157
+ candidate, k = base, 2
158
+ while candidate in used:
159
+ candidate, k = f"{base}_{k}", k + 1
160
+ used.add(candidate)
161
+ clean[i] = candidate
162
+ K = len(clean)
163
+ names = clean + ["exit"] # index K is the artificial exit
164
+
165
+ # sessions are contiguous after the sort
166
+ sess_codes = pd.factorize(df_work[session_col], sort=False)[0]
167
+ new_sess = np.empty(len(sess_codes), dtype=bool)
168
+ new_sess[:1] = True
169
+ new_sess[1:] = sess_codes[1:] != sess_codes[:-1]
170
+ is_last = np.empty_like(new_sess)
171
+ is_last[:-1] = new_sess[1:]
172
+ is_last[-1:] = True
173
+ nxt = np.empty_like(raw_codes)
174
+ nxt[:-1] = raw_codes[1:]
175
+ nxt[is_last] = K
176
+
177
+ firsts = raw_codes[new_sess]
178
+ first_counts = np.bincount(firsts, minlength=K)
179
+ pair = raw_codes * (K + 1) + nxt
180
+ pair_counts = np.bincount(pair, minlength=K * (K + 1))
181
+ out_totals = pair_counts.reshape(K, K + 1).sum(axis=1)
182
+
183
+ def weight(count, total) -> str:
184
+ return f" ({count / total:.6f})" if initial_weights == "frequency" else ""
133
185
 
134
186
  rta_output = [
135
187
  f"name {model_name};",
@@ -139,44 +191,33 @@ class ReForma:
139
191
  "init Start;\n"
140
192
  ]
141
193
 
142
- mapping = {}
143
194
  edge_counter = 1
144
-
145
- for event_type, count in first_events.items():
195
+ n_sessions = int(first_counts.sum())
196
+ start_label = np.empty(K, dtype=object)
197
+ for i in sorted((i for i in range(K) if first_counts[i] > 0), key=lambda i: (-first_counts[i], names[i])):
146
198
  label_id = f"e{edge_counter}"
147
- rta_output.append(f"Start -enter_{event_type}-> {event_type} : {label_id}")
148
- mapping[('Start', event_type)] = label_id
199
+ rta_output.append(f"Start -enter_{names[i]}-> {names[i]} : {label_id}{weight(first_counts[i], n_sessions)}")
200
+ start_label[i] = label_id
149
201
  edge_counter += 1
150
202
 
151
203
  rta_output.append("")
152
204
 
153
- for source in transitions['clean_event'].unique():
154
- df_source = transitions[transitions['clean_event'] == source]
155
-
156
- for _, row in df_source.iterrows():
157
- src, tgt = row['clean_event'], row['clean_next']
158
- label_id = f"e{edge_counter}"
159
-
160
- rta_output.append(f"{src} -{tgt}-> {tgt} : {label_id}")
161
- mapping[(src, tgt)] = label_id
162
- edge_counter += 1
205
+ pair_label = np.empty(K * (K + 1), dtype=object)
206
+ observed = [p for p in np.flatnonzero(pair_counts)]
207
+ for p in sorted(observed, key=lambda p: (names[p // (K + 1)], names[p % (K + 1)])):
208
+ src, tgt = names[p // (K + 1)], names[p % (K + 1)]
209
+ label_id = f"e{edge_counter}"
210
+ rta_output.append(f"{src} -{tgt}-> {tgt} : {label_id}{weight(pair_counts[p], out_totals[p // (K + 1)])}")
211
+ pair_label[p] = label_id
212
+ edge_counter += 1
163
213
 
164
214
  loop_edge_id = f"e{edge_counter}"
165
215
  rta_output.append("\n// Loop at the final state to avoid deadlock in PRISM")
166
216
  rta_output.append(f"exit -loop-> exit : {loop_edge_id} (1.0000)")
167
217
 
168
- df_work['edge_tuple'] = list(zip(df_work['clean_event'], df_work['clean_next']))
169
- df_work['edge_id'] = df_work['edge_tuple'].map(mapping)
170
-
171
- first_in_session = df_work.groupby(session_col)['clean_event'].first()
172
- start_edges = first_in_session.apply(lambda e: mapping.get(('Start', e)))
173
- internal_edges = df_work.groupby(session_col)['edge_id'].apply(list)
174
-
175
- traces = []
176
- for session_id in internal_edges.index:
177
- start_edge = start_edges[session_id]
178
- path = [start_edge] + [e for e in internal_edges[session_id] if pd.notna(e)]
179
- traces.append(path)
218
+ row_labels = pair_label[pair]
219
+ starts = np.flatnonzero(new_sess)
220
+ traces = [[start_label[f]] + chunk.tolist() for f, chunk in zip(firsts, np.split(row_labels, starts[1:]))]
180
221
 
181
222
  full_source = "\n".join(rta_output)
182
223
  self.load(full_source, name=model_name)
@@ -184,10 +225,12 @@ class ReForma:
184
225
  if save_model_path:
185
226
  with open(save_model_path, "w", encoding="utf-8") as f:
186
227
  f.write(full_source)
187
-
228
+
188
229
  if save_traces_path:
189
230
  with open(save_traces_path, "w", encoding="utf-8") as f:
190
- f.write("\n".join(",".join(t) for t in traces))
231
+ for t in traces:
232
+ f.write(",".join(t))
233
+ f.write("\n")
191
234
 
192
235
  return traces
193
236
 
@@ -771,6 +814,11 @@ class ReForma:
771
814
  self._require_model()
772
815
  log_lines = [",".join(s) for s in sessions]
773
816
  updated_source = self._bridge.train(self._model.source, log_lines)
817
+ # the engine reports events that could not be replayed (unknown label, or not enabled at that point)
818
+ report = next((l for l in getattr(self._bridge, "last_stdout", "").splitlines() if "Warning:" in l), None)
819
+ if report:
820
+ import warnings
821
+ warnings.warn(report.split("Training completed")[-1].strip(), RuntimeWarning, stacklevel=2)
774
822
  self._model = ReFormaModel.from_string(updated_source, self._model.name)
775
823
  self._current_source = updated_source
776
824
  self._history = []
@@ -807,6 +855,11 @@ class ReForma:
807
855
  return self._bridge.get_prism(self._model.source)
808
856
 
809
857
 
858
+ def export_mcrl2(self) -> str:
859
+ """Export to mCRL2."""
860
+ self._require_model()
861
+ return self._bridge.get_mcrl2(self._model.source)
862
+
810
863
  def export_glts(self) -> str:
811
864
  """Translate to GLTS (imperative) format."""
812
865
  self._require_model()
@@ -141,6 +141,9 @@ class JarBridge:
141
141
  def get_prism(self, source: str) -> str:
142
142
  return self._run_with_tempfile("-prism", source)
143
143
 
144
+ def get_mcrl2(self, source: str) -> str:
145
+ return self._run_with_tempfile("-mcrl2", source)
146
+
144
147
  def get_cytoscape(self, source: str, history: Optional[list[str]] = None) -> str:
145
148
  extra = [",".join(history)] if history else []
146
149
  return self._run_with_tempfile("-cytoscape", source, extra_args=extra)
@@ -193,6 +196,7 @@ class JarBridge:
193
196
  raise JarError(
194
197
  f"JAR exited {result.returncode}:\n{result.stderr.strip()}"
195
198
  )
199
+ self.last_stdout = result.stdout or ""
196
200
  out_path = args[-1]
197
201
  if not os.path.exists(out_path):
198
202
  raise JarError(
@@ -88,7 +88,8 @@ class ReFormaModel:
88
88
  def from_file(cls, path: str) -> "ReFormaModel":
89
89
  with open(path, "r", encoding="utf-8") as f:
90
90
  source = f.read()
91
- name = path.split("/")[-1].replace(".r", "")
91
+ import os
92
+ name = os.path.splitext(os.path.basename(path))[0]
92
93
  return cls(source=source, name=name)
93
94
 
94
95
  @classmethod
@@ -5,9 +5,9 @@ tests/test_reforma.py — full test suite (mocked, no real JAR needed).
5
5
  import pytest
6
6
  from unittest.mock import MagicMock, patch
7
7
 
8
- from reforma import reforma
8
+ from reforma import ReForma as reforma
9
9
 
10
- from reforma.model import reformaModel, Transition, SimulationState
10
+ from reforma.model import ReFormaModel as reformaModel, Transition, SimulationState
11
11
  from reforma.jar_bridge import JarBridge, JarError
12
12
  from reforma.client import _parse_pdl_result
13
13
 
@@ -1,2 +0,0 @@
1
- networkx
2
- matplotlib
File without changes
File without changes
File without changes
File without changes