ergodic 0.1.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.
Files changed (165) hide show
  1. ergodic/__init__.py +216 -0
  2. ergodic/data/__init__.py +51 -0
  3. ergodic/data/_dataset.py +223 -0
  4. ergodic/data/_eventlog.py +126 -0
  5. ergodic/data/_frame.py +118 -0
  6. ergodic/data/_hierarchy.py +126 -0
  7. ergodic/data/_hts.py +118 -0
  8. ergodic/data/_panel.py +113 -0
  9. ergodic/data/_schema.py +311 -0
  10. ergodic/data/_tabular.py +60 -0
  11. ergodic/data/_timeseries.py +79 -0
  12. ergodic/datasets/__init__.py +35 -0
  13. ergodic/datasets/_bayesnet.py +210 -0
  14. ergodic/datasets/_bunch.py +43 -0
  15. ergodic/datasets/_bundled.py +263 -0
  16. ergodic/datasets/_tables.py +75 -0
  17. ergodic/datasets/data/birthweight.csv +4643 -0
  18. ergodic/datasets/data/germany.csv +749 -0
  19. ergodic/datasets/data/pension401k.csv +9916 -0
  20. ergodic/datasets/data/smoking.csv +1210 -0
  21. ergodic/discovery/__init__.py +94 -0
  22. ergodic/discovery/_bootstrap.py +336 -0
  23. ergodic/discovery/_ci.py +243 -0
  24. ergodic/discovery/_discover.py +70 -0
  25. ergodic/discovery/_encode.py +56 -0
  26. ergodic/discovery/_granger.py +108 -0
  27. ergodic/discovery/_lagged.py +172 -0
  28. ergodic/discovery/_registry.py +199 -0
  29. ergodic/discovery/_result.py +48 -0
  30. ergodic/discovery/_score.py +214 -0
  31. ergodic/discovery/_stats.py +71 -0
  32. ergodic/discovery/_tsresult.py +68 -0
  33. ergodic/discovery/algorithms/__init__.py +32 -0
  34. ergodic/discovery/algorithms/_astar.py +269 -0
  35. ergodic/discovery/algorithms/_boss.py +339 -0
  36. ergodic/discovery/algorithms/_fci.py +275 -0
  37. ergodic/discovery/algorithms/_ges.py +353 -0
  38. ergodic/discovery/algorithms/_glasso.py +521 -0
  39. ergodic/discovery/algorithms/_grasp.py +222 -0
  40. ergodic/discovery/algorithms/_hc.py +284 -0
  41. ergodic/discovery/algorithms/_lingam.py +361 -0
  42. ergodic/discovery/algorithms/_mmpc.py +271 -0
  43. ergodic/discovery/algorithms/_pc.py +287 -0
  44. ergodic/discovery/algorithms/_pcmci.py +546 -0
  45. ergodic/discovery/algorithms/_rfci.py +461 -0
  46. ergodic/discovery/algorithms/_skeleton.py +86 -0
  47. ergodic/discovery/ci/__init__.py +11 -0
  48. ergodic/discovery/ci/_discrete.py +68 -0
  49. ergodic/discovery/ci/_gcm.py +110 -0
  50. ergodic/discovery/ci/_linear.py +73 -0
  51. ergodic/discovery/ci/_mixed.py +64 -0
  52. ergodic/discovery/scores/__init__.py +11 -0
  53. ergodic/discovery/scores/_bdeu.py +95 -0
  54. ergodic/discovery/scores/_bic.py +146 -0
  55. ergodic/discovery/scores/_discrete_bic.py +85 -0
  56. ergodic/discovery/scores/_mixed_bic.py +111 -0
  57. ergodic/forecast/__init__.py +220 -0
  58. ergodic/forecast/_attribution.py +678 -0
  59. ergodic/forecast/_backtest.py +432 -0
  60. ergodic/forecast/_baselines.py +237 -0
  61. ergodic/forecast/_facade.py +734 -0
  62. ergodic/forecast/_fit.py +1057 -0
  63. ergodic/forecast/_mechanism.py +411 -0
  64. ergodic/forecast/_metrics.py +335 -0
  65. ergodic/forecast/_protocol.py +31 -0
  66. ergodic/forecast/_reconcile.py +634 -0
  67. ergodic/forecast/_result.py +347 -0
  68. ergodic/forecast/_scalers.py +140 -0
  69. ergodic/forecast/_schedule.py +449 -0
  70. ergodic/forecast/_staged.py +774 -0
  71. ergodic/forecast/_structure.py +129 -0
  72. ergodic/forecast/mechanisms/__init__.py +26 -0
  73. ergodic/forecast/mechanisms/_bass.py +253 -0
  74. ergodic/forecast/mechanisms/_ou.py +346 -0
  75. ergodic/forecast/mechanisms/_structural.py +360 -0
  76. ergodic/graph/__init__.py +77 -0
  77. ergodic/graph/_graph.py +598 -0
  78. ergodic/graph/_io.py +141 -0
  79. ergodic/graph/_marks.py +28 -0
  80. ergodic/graph/_meek.py +231 -0
  81. ergodic/graph/_pag.py +728 -0
  82. ergodic/graph/_primitives.py +184 -0
  83. ergodic/graph/_separation.py +231 -0
  84. ergodic/graph/_surgery.py +80 -0
  85. ergodic/identification/__init__.py +61 -0
  86. ergodic/identification/_criteria.py +573 -0
  87. ergodic/identification/_distribution.py +133 -0
  88. ergodic/identification/_evaluate.py +172 -0
  89. ergodic/identification/_functional.py +185 -0
  90. ergodic/identification/_id.py +276 -0
  91. ergodic/identification/_ida.py +163 -0
  92. ergodic/identification/_mediation.py +351 -0
  93. ergodic/identification/_recognize.py +110 -0
  94. ergodic/identification/_record.py +47 -0
  95. ergodic/inference/__init__.py +136 -0
  96. ergodic/inference/_crossfit.py +63 -0
  97. ergodic/inference/_design.py +32 -0
  98. ergodic/inference/_estimate.py +163 -0
  99. ergodic/inference/_estimator.py +817 -0
  100. ergodic/inference/_learner.py +140 -0
  101. ergodic/inference/_perunit.py +313 -0
  102. ergodic/inference/_refute.py +212 -0
  103. ergodic/inference/_registry.py +213 -0
  104. ergodic/inference/_result.py +111 -0
  105. ergodic/inference/_scores.py +227 -0
  106. ergodic/inference/_select.py +278 -0
  107. ergodic/inference/_stats.py +77 -0
  108. ergodic/inference/_target.py +36 -0
  109. ergodic/inference/estimators/__init__.py +49 -0
  110. ergodic/inference/estimators/_did.py +868 -0
  111. ergodic/inference/estimators/_dml.py +354 -0
  112. ergodic/inference/estimators/_doubly_robust.py +152 -0
  113. ergodic/inference/estimators/_forest.py +633 -0
  114. ergodic/inference/estimators/_frontdoor.py +66 -0
  115. ergodic/inference/estimators/_its.py +195 -0
  116. ergodic/inference/estimators/_iv.py +188 -0
  117. ergodic/inference/estimators/_mediation.py +599 -0
  118. ergodic/inference/estimators/_meta.py +126 -0
  119. ergodic/inference/estimators/_null.py +60 -0
  120. ergodic/inference/estimators/_plugin.py +423 -0
  121. ergodic/inference/estimators/_regression.py +169 -0
  122. ergodic/inference/estimators/_synthetic_control.py +361 -0
  123. ergodic/inference/estimators/_weighting.py +130 -0
  124. ergodic/inference/explain/__init__.py +34 -0
  125. ergodic/inference/explain/_blp.py +82 -0
  126. ergodic/inference/explain/_calibration.py +103 -0
  127. ergodic/inference/explain/_clan.py +100 -0
  128. ergodic/inference/explain/_gates.py +103 -0
  129. ergodic/inference/explain/_linalg.py +53 -0
  130. ergodic/inference/explain/_policy.py +459 -0
  131. ergodic/inference/explain/_rate.py +118 -0
  132. ergodic/inference/explain/_record.py +110 -0
  133. ergodic/inference/learners/__init__.py +17 -0
  134. ergodic/inference/learners/_linear.py +152 -0
  135. ergodic/inference/learners/_neighbors.py +107 -0
  136. ergodic/inference/learners/_pymc.py +149 -0
  137. ergodic/knowledge.py +444 -0
  138. ergodic/process/__init__.py +106 -0
  139. ergodic/process/_case_table.py +495 -0
  140. ergodic/process/_cases.py +108 -0
  141. ergodic/process/_conformance.py +461 -0
  142. ergodic/process/_decisions.py +527 -0
  143. ergodic/process/_filters.py +254 -0
  144. ergodic/process/_map.py +212 -0
  145. ergodic/process/_mine.py +489 -0
  146. ergodic/process/_net.py +261 -0
  147. ergodic/process/_panel.py +190 -0
  148. ergodic/process/_performance.py +169 -0
  149. ergodic/process/_soundness.py +103 -0
  150. ergodic/process/_tree.py +146 -0
  151. ergodic/process/_variants.py +64 -0
  152. ergodic/py.typed +0 -0
  153. ergodic/scm/__init__.py +51 -0
  154. ergodic/scm/_bounds.py +225 -0
  155. ergodic/scm/_compose.py +739 -0
  156. ergodic/scm/_district.py +111 -0
  157. ergodic/scm/_mechanism.py +762 -0
  158. ergodic/scm/_noise.py +99 -0
  159. ergodic/scm/_result.py +128 -0
  160. ergodic/scm/_scm.py +1038 -0
  161. ergodic-0.1.0.dist-info/METADATA +141 -0
  162. ergodic-0.1.0.dist-info/RECORD +165 -0
  163. ergodic-0.1.0.dist-info/WHEEL +4 -0
  164. ergodic-0.1.0.dist-info/licenses/LICENSE +202 -0
  165. ergodic-0.1.0.dist-info/licenses/NOTICE +42 -0
ergodic/__init__.py ADDED
@@ -0,0 +1,216 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Ergodic: Causal AI.
3
+
4
+ Tools for causal discovery, causal inference, and process intelligence. The
5
+ shared objects live at the top level: the mixed-graph family (:class:`MixedGraph`
6
+ with the :func:`dag`, :func:`admg`, :func:`cpdag`, :func:`pag` builders) and
7
+ :class:`DomainKnowledge` for prior constraints. Identification turns a graph into
8
+ an estimand with :func:`identify_effect`.
9
+
10
+ Homepage: https://ergodic.ai
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from . import datasets
16
+ from .data import (
17
+ Dataset,
18
+ EventLog,
19
+ HierarchicalTSData,
20
+ Hierarchy,
21
+ Ordinal,
22
+ PanelData,
23
+ Schema,
24
+ SchemaInferenceWarning,
25
+ TabularData,
26
+ TimeSeriesData,
27
+ VariableType,
28
+ event_log,
29
+ hts,
30
+ panel,
31
+ tabular,
32
+ timeseries,
33
+ )
34
+ from .discovery import discover, granger
35
+
36
+ # `forecast` names both the pillar and its facade, and the function is what the
37
+ # top level exports: `from ergodic import forecast` gives the callable, while
38
+ # `from ergodic.forecast import backtest` still reaches the module. Import the
39
+ # function last so the binding is the deliberate one.
40
+ from .forecast import backtest, forecast
41
+ from .graph import (
42
+ Edge,
43
+ GraphKind,
44
+ InvalidGraphError,
45
+ Mark,
46
+ MixedGraph,
47
+ admg,
48
+ cpdag,
49
+ d_separated,
50
+ dag,
51
+ m_separated,
52
+ mixed,
53
+ pag,
54
+ )
55
+ from .identification import (
56
+ IDA,
57
+ Functional,
58
+ Hedge,
59
+ Identification,
60
+ IDResult,
61
+ Marginal,
62
+ MediationID,
63
+ Prob,
64
+ Product,
65
+ Ratio,
66
+ adjustment_set,
67
+ frontdoor_set,
68
+ ida,
69
+ identify_distribution,
70
+ identify_effect,
71
+ identify_mediation,
72
+ instruments,
73
+ is_frontdoor_set,
74
+ is_instrument,
75
+ is_valid_adjustment_set,
76
+ minimal_adjustment_set,
77
+ )
78
+ from .inference import (
79
+ CATEResult,
80
+ CausalForest,
81
+ EffectEstimate,
82
+ EffectTarget,
83
+ ITEResult,
84
+ MediationEstimate,
85
+ PerUnitEffect,
86
+ PolicyTree,
87
+ Refutation,
88
+ SelectionReport,
89
+ VariableImportance,
90
+ cate,
91
+ did,
92
+ estimate,
93
+ estimate_effect,
94
+ estimate_mediation,
95
+ event_study,
96
+ interrupted_time_series,
97
+ policy_tree,
98
+ refute_effect,
99
+ select_cate,
100
+ synthetic_control,
101
+ )
102
+ from .knowledge import DomainKnowledge, InconsistentKnowledgeError
103
+ from .process import (
104
+ case_table,
105
+ conformance,
106
+ decision_points,
107
+ decision_table,
108
+ discover_model,
109
+ kpi_panel,
110
+ process_map,
111
+ )
112
+ from .scm import (
113
+ SCM,
114
+ BinaryGumbelMax,
115
+ CategoricalGumbelMax,
116
+ GaussianAdditive,
117
+ SCMResult,
118
+ Sensitivity,
119
+ )
120
+
121
+ __version__ = "0.1.0"
122
+
123
+ __all__ = [
124
+ "IDA",
125
+ "SCM",
126
+ "BinaryGumbelMax",
127
+ "CATEResult",
128
+ "CategoricalGumbelMax",
129
+ "CausalForest",
130
+ "Dataset",
131
+ "DomainKnowledge",
132
+ "Edge",
133
+ "EffectEstimate",
134
+ "EffectTarget",
135
+ "EventLog",
136
+ "Functional",
137
+ "GaussianAdditive",
138
+ "GraphKind",
139
+ "Hedge",
140
+ "HierarchicalTSData",
141
+ "Hierarchy",
142
+ "IDResult",
143
+ "ITEResult",
144
+ "Identification",
145
+ "InconsistentKnowledgeError",
146
+ "InvalidGraphError",
147
+ "Marginal",
148
+ "Mark",
149
+ "MediationEstimate",
150
+ "MediationID",
151
+ "MixedGraph",
152
+ "Ordinal",
153
+ "PanelData",
154
+ "PerUnitEffect",
155
+ "PolicyTree",
156
+ "Prob",
157
+ "Product",
158
+ "Ratio",
159
+ "Refutation",
160
+ "SCMResult",
161
+ "Schema",
162
+ "SchemaInferenceWarning",
163
+ "SelectionReport",
164
+ "Sensitivity",
165
+ "TabularData",
166
+ "TimeSeriesData",
167
+ "VariableImportance",
168
+ "VariableType",
169
+ "__version__",
170
+ "adjustment_set",
171
+ "admg",
172
+ "backtest",
173
+ "case_table",
174
+ "cate",
175
+ "conformance",
176
+ "cpdag",
177
+ "d_separated",
178
+ "dag",
179
+ "datasets",
180
+ "decision_points",
181
+ "decision_table",
182
+ "did",
183
+ "discover",
184
+ "discover_model",
185
+ "estimate",
186
+ "estimate_effect",
187
+ "estimate_mediation",
188
+ "event_log",
189
+ "event_study",
190
+ "forecast",
191
+ "frontdoor_set",
192
+ "granger",
193
+ "hts",
194
+ "ida",
195
+ "identify_distribution",
196
+ "identify_effect",
197
+ "identify_mediation",
198
+ "instruments",
199
+ "interrupted_time_series",
200
+ "is_frontdoor_set",
201
+ "is_instrument",
202
+ "is_valid_adjustment_set",
203
+ "kpi_panel",
204
+ "m_separated",
205
+ "minimal_adjustment_set",
206
+ "mixed",
207
+ "pag",
208
+ "panel",
209
+ "policy_tree",
210
+ "process_map",
211
+ "refute_effect",
212
+ "select_cate",
213
+ "synthetic_control",
214
+ "tabular",
215
+ "timeseries",
216
+ ]
@@ -0,0 +1,51 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Typed datasets: the data objects discovery and inference read.
3
+
4
+ Each shape embeds a column :class:`Schema` (continuous, categorical, ordinal,
5
+ datetime, or excluded) and shares the accessors on :class:`Dataset`. The shapes
6
+ are tabular, time-series, panel, and hierarchical time-series; the event log
7
+ follows.
8
+
9
+ Build a dataset with :func:`tabular`, :func:`timeseries`, :func:`panel`, or
10
+ :func:`hts`, each of which takes a pandas or polars dataframe, a dict of columns,
11
+ or a numpy array.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ._dataset import Dataset
17
+ from ._eventlog import EventLog, event_log
18
+ from ._hierarchy import Hierarchy
19
+ from ._hts import HierarchicalTSData, hts
20
+ from ._panel import PanelData, panel
21
+ from ._schema import (
22
+ ColumnType,
23
+ Ordinal,
24
+ Schema,
25
+ SchemaInferenceWarning,
26
+ VariableType,
27
+ infer_schema,
28
+ )
29
+ from ._tabular import TabularData, tabular
30
+ from ._timeseries import TimeSeriesData, timeseries
31
+
32
+ __all__ = [
33
+ "ColumnType",
34
+ "Dataset",
35
+ "EventLog",
36
+ "HierarchicalTSData",
37
+ "Hierarchy",
38
+ "Ordinal",
39
+ "PanelData",
40
+ "Schema",
41
+ "SchemaInferenceWarning",
42
+ "TabularData",
43
+ "TimeSeriesData",
44
+ "VariableType",
45
+ "event_log",
46
+ "hts",
47
+ "infer_schema",
48
+ "panel",
49
+ "tabular",
50
+ "timeseries",
51
+ ]
@@ -0,0 +1,223 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The dataset base: a typed, immutable column store the algorithms read.
3
+
4
+ ``Dataset`` holds the normalized columns, the :class:`Schema`, and the accessors
5
+ that discovery and inference need: a float matrix, integer codes for categorical
6
+ columns, missing-value reporting, and a name check against a graph. It is
7
+ immutable, so retyping a column, selecting columns, or dropping rows returns a
8
+ new dataset. The concrete shapes (tabular, time-series, panel, and the rest)
9
+ extend it with their index columns.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field, replace
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ import numpy as np
18
+
19
+ from ._schema import (
20
+ Ordinal,
21
+ Schema,
22
+ VariableType,
23
+ _encode,
24
+ _missing_mask,
25
+ _resolve_type,
26
+ _unique_nonnull,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Iterable, Mapping, Sequence
31
+ from typing import Self
32
+
33
+ import numpy.typing as npt
34
+ import pandas as pd
35
+
36
+ from ..graph import MixedGraph
37
+
38
+
39
+ @dataclass(frozen=True, eq=False)
40
+ class Dataset:
41
+ """A typed column store. Built through the shape factories, not directly."""
42
+
43
+ schema: Schema
44
+ n_rows: int
45
+ _store: Mapping[str, npt.NDArray[Any]] = field(repr=False)
46
+
47
+ # -- shape ---------------------------------------------------------------
48
+
49
+ @property
50
+ def names(self) -> tuple[str, ...]:
51
+ return self.schema.names
52
+
53
+ @property
54
+ def n_columns(self) -> int:
55
+ return len(self.schema)
56
+
57
+ @property
58
+ def shape(self) -> tuple[int, int]:
59
+ return (self.n_rows, self.n_columns)
60
+
61
+ def __len__(self) -> int:
62
+ return self.n_rows
63
+
64
+ @property
65
+ def index_columns(self) -> tuple[str, ...]:
66
+ """Columns that index the rows (time, entity, case), not modeled variables."""
67
+ return ()
68
+
69
+ @property
70
+ def variables(self) -> tuple[str, ...]:
71
+ """Column names the algorithms see: not the excluded ones, not the index."""
72
+ index = set(self.index_columns)
73
+ return tuple(name for name in self.schema.variables if name not in index)
74
+
75
+ # -- column access -------------------------------------------------------
76
+
77
+ def column(self, name: str) -> npt.NDArray[Any]:
78
+ """A read-only copy of one column's raw values."""
79
+ if name not in self.schema:
80
+ raise KeyError(name)
81
+ arr = np.array(self._store[name], copy=True)
82
+ arr.setflags(write=False)
83
+ return arr
84
+
85
+ def codes(self, name: str) -> npt.NDArray[np.int64]:
86
+ """Integer category codes for a categorical or ordinal column, -1 for missing.
87
+
88
+ A non-missing value outside the column's categories raises a ``ValueError``.
89
+ """
90
+ col = self.schema[name]
91
+ if not col.is_discrete:
92
+ raise ValueError(f"column {name!r} is {col.kind.value}, not categorical or ordinal")
93
+ return _encode(self._store[name], col.categories, col.ordered, name)
94
+
95
+ def categories(self, name: str) -> tuple[Any, ...] | None:
96
+ return self.schema[name].categories
97
+
98
+ def matrix(self, names: Sequence[str] | None = None) -> npt.NDArray[np.float64]:
99
+ """A float matrix of the named columns. Discrete columns become their codes,
100
+ and a missing value (an NA category or NaT) becomes NaN."""
101
+ chosen = list(names) if names is not None else list(self.variables)
102
+ if not chosen:
103
+ return np.empty((self.n_rows, 0), dtype=np.float64)
104
+ cols: list[npt.NDArray[Any]] = []
105
+ for name in chosen:
106
+ col = self.schema[name]
107
+ if col.is_continuous:
108
+ cols.append(np.asarray(self._store[name], dtype=np.float64))
109
+ elif col.is_discrete:
110
+ encoded = self.codes(name).astype(np.float64)
111
+ encoded[encoded == -1.0] = np.nan
112
+ cols.append(encoded)
113
+ elif col.is_datetime:
114
+ stamps = np.asarray(self._store[name], dtype="datetime64[ns]")
115
+ floats = stamps.view(np.int64).astype(np.float64)
116
+ floats[np.isnat(stamps)] = np.nan
117
+ cols.append(floats)
118
+ else:
119
+ raise ValueError(f"column {name!r} is excluded; it has no numeric form")
120
+ return np.asarray(np.column_stack(cols), dtype=np.float64)
121
+
122
+ def to_numpy(self) -> npt.NDArray[np.float64]:
123
+ return self.matrix()
124
+
125
+ def to_pandas(self) -> pd.DataFrame:
126
+ import pandas as pd
127
+
128
+ return pd.DataFrame({name: self._store[name] for name in self.schema.names})
129
+
130
+ # -- missing data --------------------------------------------------------
131
+
132
+ def missing(self) -> dict[str, int]:
133
+ """Missing-value count per column."""
134
+ return {name: int(_missing_mask(self._store[name]).sum()) for name in self.schema.names}
135
+
136
+ @property
137
+ def has_missing(self) -> bool:
138
+ return any(count > 0 for count in self.missing().values())
139
+
140
+ def dropna(self) -> Self:
141
+ """Drop every row with a missing value in any column."""
142
+ keep = np.ones(self.n_rows, dtype=bool)
143
+ for arr in self._store.values():
144
+ keep &= ~_missing_mask(arr)
145
+ store = {name: arr[keep] for name, arr in self._store.items()}
146
+ return replace(self, _store=store, n_rows=int(keep.sum()))
147
+
148
+ # -- immutable edits -----------------------------------------------------
149
+
150
+ def take(self, rows: Sequence[int] | npt.NDArray[np.int64]) -> Self:
151
+ """A new dataset with the rows at the given positions, in the given order.
152
+
153
+ Positions may repeat (a bootstrap resample) and may be negative
154
+ (Python indexing). Row identity is all this knows about; shapes with
155
+ row semantics (time series, panel) keep their index columns but it is
156
+ the caller's job to resample in a way their structure survives.
157
+ """
158
+ idx = np.asarray(rows, dtype=np.int64)
159
+ if idx.ndim != 1:
160
+ raise ValueError(f"rows must be one-dimensional, got shape {idx.shape}")
161
+ if idx.size and (int(idx.min()) < -self.n_rows or int(idx.max()) >= self.n_rows):
162
+ raise IndexError(f"row positions out of range for {self.n_rows} rows")
163
+ store = {name: arr[idx] for name, arr in self._store.items()}
164
+ return replace(self, _store=store, n_rows=int(idx.size))
165
+
166
+ def select(self, names: Iterable[str]) -> Self:
167
+ """A new dataset with the named columns, plus any index columns, in order."""
168
+ chosen = [str(n) for n in names]
169
+ absent = [n for n in chosen if n not in self.schema]
170
+ if absent:
171
+ raise KeyError(f"no such columns: {absent}")
172
+ for index_name in self.index_columns:
173
+ if index_name not in chosen:
174
+ chosen.append(index_name)
175
+ schema = Schema(tuple(self.schema[n] for n in chosen))
176
+ store = {n: self._store[n] for n in chosen}
177
+ return replace(self, schema=schema, _store=store)
178
+
179
+ def with_type(
180
+ self,
181
+ name: str,
182
+ kind: VariableType | str | Ordinal,
183
+ categories: Sequence[Any] | None = None,
184
+ ) -> Self:
185
+ """A new dataset with ``name`` retyped, categories read from data when needed."""
186
+ if name not in self.schema:
187
+ raise KeyError(name)
188
+ new = _resolve_type(name, _unique_nonnull(self._store[name]), kind, categories)
189
+ if new.is_discrete and (categories is not None or isinstance(kind, Ordinal)):
190
+ # encode once now, so declared categories that miss a value fail here
191
+ _encode(self._store[name], new.categories, new.ordered, name)
192
+ cols = tuple(new if c.name == name else c for c in self.schema.columns)
193
+ return replace(self, schema=Schema(cols))
194
+
195
+ def exclude(self, *names: str) -> Self:
196
+ """A new dataset with the named columns marked excluded."""
197
+ out = self
198
+ for name in names:
199
+ out = out.with_type(name, VariableType.EXCLUDED)
200
+ return out
201
+
202
+ # -- graph alignment -----------------------------------------------------
203
+
204
+ def alignment(self, graph: MixedGraph) -> list[str]:
205
+ """Where the variables and the graph's nodes disagree, empty when they match."""
206
+ nodes = set(graph.nodes)
207
+ cols = set(self.variables)
208
+ out = [f"graph node {n!r} has no column" for n in sorted(nodes - cols)]
209
+ out += [f"column {c!r} is not a graph node" for c in sorted(cols - nodes)]
210
+ return out
211
+
212
+ def aligns_with(self, graph: MixedGraph) -> bool:
213
+ """True when the variables are exactly the graph's nodes."""
214
+ return not self.alignment(graph)
215
+
216
+ def __repr__(self) -> str:
217
+ parts = [
218
+ f"{len(names)} {kind.value}"
219
+ for kind in VariableType
220
+ if (names := self.schema.of_kind(kind))
221
+ ]
222
+ body = ", ".join(parts)
223
+ return f"{type(self).__name__}(rows={self.n_rows}, cols={self.n_columns}: {body})"
@@ -0,0 +1,126 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Event log: variable-length traces of discrete events, keyed by a case.
3
+
4
+ Build it with :func:`event_log`, naming the case, activity, and timestamp
5
+ columns. Rows are events, grouped by case and ordered in time. The log reads as
6
+ the process-mining primitives the discovery algorithms need: the trace per case,
7
+ the distinct variants, and the directly-follows counts.
8
+
9
+ The projections to the grid shapes (case features to tabular, event rates to a
10
+ time-series) land with the process pillar. This is the container and its core
11
+ relations.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+ from dataclasses import dataclass
18
+ from itertools import pairwise
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ import numpy as np
22
+
23
+ from ._dataset import Dataset
24
+ from ._frame import to_columns
25
+ from ._schema import infer_schema
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Iterable, Mapping, Sequence
29
+
30
+ from ._schema import Ordinal, VariableType
31
+
32
+
33
+ @dataclass(frozen=True, eq=False)
34
+ class EventLog(Dataset):
35
+ """Events keyed by case, activity, and timestamp; one row per event."""
36
+
37
+ case: str
38
+ activity: str
39
+ timestamp: str
40
+
41
+ @property
42
+ def index_columns(self) -> tuple[str, ...]:
43
+ return (self.case, self.activity, self.timestamp)
44
+
45
+ @property
46
+ def cases(self) -> tuple[Any, ...]:
47
+ return tuple(sorted(set(self._store[self.case].tolist())))
48
+
49
+ @property
50
+ def n_cases(self) -> int:
51
+ return len(set(self._store[self.case].tolist()))
52
+
53
+ @property
54
+ def activities(self) -> tuple[str, ...]:
55
+ return tuple(sorted(set(self._store[self.activity].tolist())))
56
+
57
+ @property
58
+ def n_activities(self) -> int:
59
+ return len(set(self._store[self.activity].tolist()))
60
+
61
+ def traces(self) -> dict[Any, tuple[str, ...]]:
62
+ """The ordered activity sequence per case."""
63
+ cases = self._store[self.case].tolist()
64
+ acts = self._store[self.activity].tolist()
65
+ out: dict[Any, list[str]] = {}
66
+ for case_id, act in zip(cases, acts, strict=True):
67
+ out.setdefault(case_id, []).append(act)
68
+ return {case_id: tuple(seq) for case_id, seq in out.items()}
69
+
70
+ def variants(self) -> Counter[tuple[str, ...]]:
71
+ """How many cases follow each distinct activity sequence."""
72
+ return Counter(self.traces().values())
73
+
74
+ def directly_follows(self) -> Counter[tuple[str, str]]:
75
+ """Counts of each ``(a, b)`` where ``b`` immediately follows ``a`` in a case."""
76
+ out: Counter[tuple[str, str]] = Counter()
77
+ for trace in self.traces().values():
78
+ for first, second in pairwise(trace):
79
+ out[(first, second)] += 1
80
+ return out
81
+
82
+ def __repr__(self) -> str:
83
+ return (
84
+ f"EventLog(cases={self.n_cases}, events={self.n_rows}, activities={self.n_activities})"
85
+ )
86
+
87
+
88
+ def event_log(
89
+ data: Any,
90
+ *,
91
+ case: str,
92
+ activity: str,
93
+ timestamp: str,
94
+ columns: Sequence[str] | None = None,
95
+ types: Mapping[str, VariableType | str | Ordinal] | None = None,
96
+ exclude: Iterable[str] | None = None,
97
+ categorical_threshold: int = 10,
98
+ ) -> EventLog:
99
+ """Build an :class:`EventLog`, grouped by case and ordered by timestamp."""
100
+ store, n_rows, declared = to_columns(data, columns)
101
+ for role, name in (("case", case), ("activity", activity), ("timestamp", timestamp)):
102
+ if name not in store:
103
+ raise KeyError(f"{role} column {name!r} not found in {sorted(store)}")
104
+ overrides: dict[str, VariableType | str | Ordinal] = dict(types or {})
105
+ overrides.setdefault(case, "categorical")
106
+ overrides.setdefault(activity, "categorical")
107
+ if timestamp not in overrides:
108
+ overrides[timestamp] = "datetime" if store[timestamp].dtype.kind == "M" else "continuous"
109
+ schema = infer_schema(
110
+ store,
111
+ n_rows=n_rows,
112
+ declared=declared,
113
+ overrides=overrides,
114
+ exclude=exclude,
115
+ categorical_threshold=categorical_threshold,
116
+ )
117
+ order = np.lexsort((store[timestamp], store[case]))
118
+ ordered = {name: arr[order] for name, arr in store.items()}
119
+ return EventLog(
120
+ schema=schema,
121
+ n_rows=n_rows,
122
+ _store=ordered,
123
+ case=case,
124
+ activity=activity,
125
+ timestamp=timestamp,
126
+ )