featuregraph-smoothing-core 1.0.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.
@@ -0,0 +1,10 @@
1
+ from featuregraph_smoothing_core import datasets
2
+ from featuregraph_smoothing_core import validation
3
+ from featuregraph_smoothing_core.plot import (
4
+ plot,
5
+ plot_annotated_oscillation,
6
+ plot_oscillation_panel,
7
+ add_construction_legend,
8
+ )
9
+
10
+ __all__ = ["datasets", "validation", "plot", "plot_annotated_oscillation", "plot_oscillation_panel", "add_construction_legend"]
File without changes
@@ -0,0 +1,101 @@
1
+ from dataclasses import dataclass
2
+ from featuregraph_smoothing_core.operators.states import rising_state, falling_state
3
+ from featuregraph_smoothing_core.operators.events import enter_state, exit_state, event_id, event_index
4
+ from featuregraph_smoothing_core.operators.measures import smooth, smooth_grouped
5
+
6
+ import pandas as pd
7
+ import numpy as np
8
+
9
+ @dataclass
10
+ class OscillationConfig:
11
+ signal: str
12
+ eps: float = 0
13
+ lag: int = 1
14
+ smooth_window: int = 100
15
+
16
+ @property
17
+ def smooth(self):
18
+ return f'{self.signal}_smooth'
19
+
20
+ @property
21
+ def rising_col(self):
22
+ return f'{self.signal}_rising'
23
+
24
+ @property
25
+ def falling_col(self):
26
+ return f'{self.signal}_falling'
27
+
28
+ @property
29
+ def enter_rising_col(self):
30
+ return f'enter_{self.signal}_rising'
31
+
32
+ @property
33
+ def exit_rising_col(self):
34
+ return f'exit_{self.signal}_rising'
35
+
36
+ @property
37
+ def peak_event_id_col(self):
38
+ return f'{self.signal}_peak_event_id'
39
+
40
+ @property
41
+ def trough_event_id_col(self):
42
+ return f'{self.signal}_trough_event_id'
43
+
44
+ @property
45
+ def peak_index_col(self):
46
+ return f'{self.signal}_peak_index'
47
+
48
+ @property
49
+ def trough_index_col(self):
50
+ return f'{self.signal}_trough_index'
51
+
52
+ def add_primitives(self, df: pd.DataFrame, group=None):
53
+ df = df.copy()
54
+ df[self.smooth] = smooth_grouped(df, self.signal, window=self.smooth_window, group=group)
55
+ df[self.rising_col] = rising_state(df[self.smooth])
56
+ df[self.falling_col] = falling_state(df[self.smooth])
57
+ df[self.exit_rising_col] = exit_state(df[self.rising_col], group=df[group] if group else None)
58
+ df[self.enter_rising_col] = enter_state(df[self.rising_col], group=df[group] if group else None)
59
+ df[self.peak_event_id_col] = event_id(df, self.exit_rising_col, group=group)
60
+ df[self.trough_event_id_col] = event_id(df, self.enter_rising_col, group=group)
61
+ df[self.peak_index_col] = event_index(df, self.exit_rising_col, group=group)
62
+ df[self.trough_index_col] = event_index(df, self.enter_rising_col, group=group)
63
+ return df
64
+
65
+ def summarize(self, df: pd.DataFrame, group=None):
66
+ summarydf = df.groupby(group).agg(
67
+ start_index=(self.trough_index_col, 'first'),
68
+ rising_duration=(self.rising_col, 'sum'),
69
+ falling_duration=(self.falling_col, 'sum'),
70
+ peak_index=(self.peak_index_col, 'last'),
71
+ trough_index=(self.trough_index_col, 'first'),
72
+ max_raw_signal=(self.signal, 'max'),
73
+ max_smooth_signal=(self.smooth, 'max'),
74
+ min_raw_signal=(self.signal, 'min'),
75
+ min_smooth_signal=(self.smooth, 'min'),
76
+ )
77
+
78
+ summarydf['end_index'] = summarydf.groupby(level=0)['start_index'].shift(-1)
79
+ summarydf['is_complete'] = summarydf['start_index'].notna() & summarydf['end_index'].notna()
80
+ summarydf['duration'] = summarydf['rising_duration'] + summarydf['falling_duration']
81
+
82
+ if not group:
83
+ summarydf['period'] = summarydf['peak_index'].diff()
84
+ else:
85
+ summarydf['period'] = summarydf.groupby(group[0])['peak_index'].diff()
86
+
87
+ net_change_raw = (summarydf['max_raw_signal'] - summarydf['min_raw_signal'])
88
+ net_change_smooth = (summarydf['max_smooth_signal'] - summarydf['min_smooth_signal'])
89
+
90
+ summarydf['amplitude_raw'] = net_change_raw / 2
91
+ summarydf['amplitude_smooth'] = net_change_smooth / 2
92
+
93
+ summarydf['raw_rising_mean_rate'] = (net_change_raw / summarydf['rising_duration']).where(summarydf['rising_duration'] > 0)
94
+ summarydf['raw_falling_mean_rate'] = (net_change_raw / summarydf['falling_duration']).where(summarydf['falling_duration'] > 0)
95
+
96
+ summarydf['smooth_rising_mean_rate'] = (net_change_smooth / summarydf['rising_duration']).where(summarydf['rising_duration'] > 0)
97
+ summarydf['smooth_falling_mean_rate'] = (net_change_smooth / summarydf['falling_duration']).where(summarydf['falling_duration'] > 0)
98
+
99
+ summarydf["temporal_symmetry"] = (1 - (summarydf['rising_duration'] - summarydf['falling_duration']).abs() / summarydf['duration']).where(summarydf['duration'] > 0)
100
+
101
+ return summarydf
@@ -0,0 +1,3 @@
1
+ from featuregraph_smoothing_core.datasets._bidmc import bidmc, bidmc_breaths
2
+
3
+ __all__ = ["bidmc", "bidmc_breaths"]
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+ from featuregraph_smoothing_core.utils._bidmc import (
6
+ load_bidmc_breaths,
7
+ load_bidmc_subject,
8
+ )
9
+ from featuregraph_smoothing_core.utils._rename_map import bidmc_map
10
+
11
+
12
+ def bidmc(
13
+ subject: int = 1,
14
+ *,
15
+ refresh: bool = False,
16
+ ) -> pd.DataFrame:
17
+ """
18
+ Load one BIDMC subject.
19
+
20
+ Returns
21
+ -------
22
+ pandas.DataFrame
23
+ Physiological waveform observations with standardized
24
+ FeatureGraph column names.
25
+ """
26
+ return (
27
+ load_bidmc_subject(
28
+ subject,
29
+ refresh=refresh,
30
+ )
31
+ .rename(columns=bidmc_map)
32
+ )
33
+
34
+
35
+ def bidmc_breaths(
36
+ subject: int = 1,
37
+ *,
38
+ refresh: bool = False,
39
+ ) -> pd.DataFrame:
40
+ """Load the two BIDMC breath-annotation columns for one subject."""
41
+ return load_bidmc_breaths(
42
+ subject,
43
+ refresh=refresh,
44
+ )
File without changes
@@ -0,0 +1,27 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+
4
+ def enter_state(state, group=None):
5
+ x = state.astype(int)
6
+ if group is None:
7
+ return x.diff().eq(1)
8
+ return x.groupby(group).diff().eq(1)
9
+
10
+
11
+ def exit_state(state, group=None):
12
+ x = state.astype(int)
13
+ if group is None:
14
+ return x.diff().eq(-1)
15
+ return x.groupby(group).diff().eq(-1)
16
+
17
+
18
+ def event_id(df, enter_col, group=None):
19
+ if group is None:
20
+ return df[enter_col].cumsum()
21
+ return df.groupby(group)[enter_col].cumsum()
22
+
23
+ def event_index(df, event_col, group=None):
24
+ event_positions = pd.Series(np.where(df[event_col], df.index, np.nan), index=df.index)
25
+ if group is None:
26
+ return event_positions.ffill()
27
+ return event_positions.groupby(df[group]).ffill()
@@ -0,0 +1,13 @@
1
+ def smooth(signal, window):
2
+ return (
3
+ signal.rolling(center=True, window=window).median().rolling(center=True, window=window).mean()
4
+ )
5
+
6
+ def smooth_grouped(df, signal_col, window, group):
7
+ return df.groupby(group)[signal_col].transform(lambda s: smooth(s, window))
8
+
9
+ def group_transform(df, signal, op, group):
10
+ return df.groupby(group)[signal].transform(op)
11
+
12
+ def group_map(df, signal, op, group, offset=0):
13
+ return df[group].map(df.groupby(group)[signal].agg(op).shift(offset))
@@ -0,0 +1,30 @@
1
+ def positive_state(quantity, eps=0):
2
+ return quantity.gt(eps)
3
+
4
+
5
+ def negative_state(quantity, eps=0):
6
+ return quantity.lt(-eps)
7
+
8
+
9
+ def inactive_state(quantity, eps=0):
10
+ return quantity.abs().le(eps)
11
+
12
+
13
+ def rising_state(series, lag=1, eps=0):
14
+ return positive_state(series.diff(lag), eps)
15
+
16
+
17
+ def falling_state(series, lag=1, eps=0):
18
+ return negative_state(series.diff(lag), eps)
19
+
20
+
21
+ def stable_state(series, lag=1, eps=0):
22
+ return inactive_state(series.diff(lag), eps)
23
+
24
+
25
+ def accumulating_state(contribution, eps=0):
26
+ return positive_state(contribution, eps)
27
+
28
+
29
+ def depleting_state(contribution, eps=0):
30
+ return negative_state(contribution, eps)
@@ -0,0 +1,460 @@
1
+ from __future__ import annotations
2
+
3
+ import matplotlib.pyplot as plt
4
+ import pandas as pd
5
+
6
+ def plot(
7
+ df: pd.DataFrame,
8
+ rows: list[list[str]],
9
+ *,
10
+ figsize: tuple[float, float] | None = None,
11
+ sharex: bool = True,
12
+ linewidth: float = 2,
13
+ grid_alpha: float = 0.3,
14
+ ):
15
+ """
16
+ Plot groups of DataFrame columns on stacked axes.
17
+
18
+ Each inner list in `rows` defines the columns plotted on one axis.
19
+
20
+ Example
21
+ -------
22
+ plot(
23
+ df,
24
+ [
25
+ ["respiration"],
26
+ ["respiration_amplitude"],
27
+ ["respiration_period", "respiration_previous_period"],
28
+ ],
29
+ )
30
+ """
31
+ if not rows:
32
+ raise ValueError("rows must contain at least one list of column names")
33
+
34
+ missing = [
35
+ column
36
+ for columns in rows
37
+ for column in columns
38
+ if column not in df.columns
39
+ ]
40
+ if missing:
41
+ raise KeyError(f"Columns not found in DataFrame: {missing}")
42
+
43
+ nrows = len(rows)
44
+
45
+ if figsize is None:
46
+ figsize = (16, max(3, 2.2 * nrows))
47
+
48
+ fig, axes = plt.subplots(
49
+ nrows=nrows,
50
+ ncols=1,
51
+ figsize=figsize,
52
+ sharex=sharex,
53
+ constrained_layout=True,
54
+ squeeze=False,
55
+ )
56
+
57
+ axes = axes[:, 0]
58
+
59
+ for ax, columns in zip(axes, rows):
60
+ for column in columns:
61
+ ax.plot(
62
+ df.index,
63
+ df[column],
64
+ label=column,
65
+ linewidth=linewidth,
66
+ )
67
+
68
+ ax.legend(loc="upper right")
69
+ ax.set_ylabel("\n".join(columns))
70
+ ax.grid(alpha=grid_alpha)
71
+
72
+ axes[-1].set_xlabel(df.index.name or "Time")
73
+
74
+ return fig, axes
75
+
76
+
77
+ def plot_annotated_oscillation(
78
+ df,
79
+ summarydf,
80
+ signal,
81
+ oscillation_id,
82
+ smoothed_signal=None,
83
+ title=None,
84
+ ):
85
+ """
86
+ Plot one oscillation object with its intrinsic measurements annotated.
87
+
88
+ Parameters
89
+ ----------
90
+ df : pandas.DataFrame
91
+ Sample-level DataFrame.
92
+ summarydf : pandas.DataFrame
93
+ One-row-per-oscillation object table.
94
+ signal : str
95
+ Name of the observed signal column.
96
+ oscillation_id : int
97
+ Oscillation object to plot.
98
+ smoothed_signal : str, optional
99
+ Smoothed signal column used for construction.
100
+ title : str, optional
101
+ Plot title.
102
+ """
103
+ row = summarydf.loc[
104
+ summarydf["oscillation_id"] == oscillation_id
105
+ ].iloc[0]
106
+
107
+ start_index = int(row["start_index"])
108
+ peak_index = int(row["peak_index"])
109
+ end_index = int(row["end_index"])
110
+
111
+ y_column = smoothed_signal or signal
112
+ segment = df.loc[start_index:end_index]
113
+
114
+ start_value = df.loc[start_index, y_column]
115
+ peak_value = df.loc[peak_index, y_column]
116
+ end_value = df.loc[end_index, y_column]
117
+
118
+ baseline = (start_value + end_value) / 2
119
+ amplitude = row["amplitude"]
120
+
121
+ fig, ax = plt.subplots(figsize=(8, 4.5))
122
+
123
+ if smoothed_signal is not None:
124
+ ax.plot(
125
+ segment.index,
126
+ segment[signal],
127
+ linewidth=1,
128
+ alpha=0.45,
129
+ label="Observed signal",
130
+ )
131
+
132
+ ax.plot(
133
+ segment.index,
134
+ segment[y_column],
135
+ linewidth=2,
136
+ label="Oscillation signal",
137
+ )
138
+
139
+ ax.axvspan(
140
+ start_index,
141
+ peak_index,
142
+ alpha=0.10,
143
+ label="Rising phase",
144
+ )
145
+
146
+ ax.axvspan(
147
+ peak_index,
148
+ end_index,
149
+ alpha=0.10,
150
+ label="Falling phase",
151
+ )
152
+
153
+ ax.scatter(
154
+ [start_index, peak_index, end_index],
155
+ [start_value, peak_value, end_value],
156
+ s=60,
157
+ zorder=3,
158
+ )
159
+
160
+ ax.vlines(
161
+ peak_index,
162
+ baseline,
163
+ peak_value,
164
+ linewidth=1.5,
165
+ )
166
+
167
+ ax.annotate(
168
+ f"Amplitude = {amplitude:.3f}",
169
+ xy=(peak_index, (baseline + peak_value) / 2),
170
+ xytext=(8, 0),
171
+ textcoords="offset points",
172
+ va="center",
173
+ )
174
+
175
+ ax.annotate(
176
+ f"Rise = {row['rise_duration']:.0f}",
177
+ xy=((start_index + peak_index) / 2, start_value),
178
+ xytext=(0, -28),
179
+ textcoords="offset points",
180
+ ha="center",
181
+ )
182
+
183
+ ax.annotate(
184
+ f"Fall = {row['fall_duration']:.0f}",
185
+ xy=((peak_index + end_index) / 2, end_value),
186
+ xytext=(0, -28),
187
+ textcoords="offset points",
188
+ ha="center",
189
+ )
190
+
191
+ ax.annotate(
192
+ f"Period = {row['period']:.0f}",
193
+ xy=((start_index + end_index) / 2, min(start_value, end_value)),
194
+ xytext=(0, -52),
195
+ textcoords="offset points",
196
+ ha="center",
197
+ )
198
+
199
+ ax.text(
200
+ 0.02,
201
+ 0.95,
202
+ (
203
+ f"O{oscillation_id}\n"
204
+ f"Duration = {row['duration']:.0f}\n"
205
+ f"Symmetry = {row['temporal_symmetry']:.3f}"
206
+ ),
207
+ transform=ax.transAxes,
208
+ va="top",
209
+ )
210
+
211
+ ax.set_title(title or f"Oscillation object O{oscillation_id}")
212
+ ax.set_xlabel("Time index")
213
+ ax.set_ylabel(signal)
214
+ ax.grid(alpha=0.3)
215
+ ax.legend(loc="upper right")
216
+
217
+ return fig, ax
218
+
219
+
220
+ def plot_oscillation_panel(
221
+ ax,
222
+ df,
223
+ summarydf,
224
+ signal,
225
+ object_ids,
226
+ *,
227
+ smoothed_signal=None,
228
+ title=None,
229
+ ylabel=None,
230
+ ):
231
+ """
232
+ Plot observed data, the construction signal, transition points,
233
+ rising/falling phases, and oscillation-object identifiers.
234
+
235
+ Parameters
236
+ ----------
237
+ ax : matplotlib.axes.Axes
238
+ Axis on which to draw the plot.
239
+ df : pandas.DataFrame
240
+ Sample-level DataFrame indexed by time or sample number.
241
+ summarydf : pandas.DataFrame
242
+ One-row-per-oscillation table containing:
243
+ oscillation_id, start_index, peak_index, and end_index.
244
+ signal : str
245
+ Observed signal column.
246
+ object_ids : iterable
247
+ Oscillation IDs to include.
248
+ smoothed_signal : str, optional
249
+ Signal used to construct the oscillations. When omitted, `signal`
250
+ is used for both the observed and construction signals.
251
+ title : str, optional
252
+ Panel title.
253
+ ylabel : str, optional
254
+ Y-axis label.
255
+ """
256
+ objects = (
257
+ summarydf.loc[
258
+ summarydf["oscillation_id"].isin(object_ids),
259
+ [
260
+ "oscillation_id",
261
+ "start_index",
262
+ "peak_index",
263
+ "end_index",
264
+ ],
265
+ ]
266
+ .dropna()
267
+ .sort_values("start_index")
268
+ .copy()
269
+ )
270
+
271
+ # Keep only complete oscillation objects.
272
+ objects = objects.loc[
273
+ (objects["start_index"] < objects["peak_index"])
274
+ & (objects["peak_index"] < objects["end_index"])
275
+ ]
276
+
277
+ if objects.empty:
278
+ raise ValueError("No complete oscillation objects were selected.")
279
+
280
+ start = objects["start_index"].min()
281
+ end = objects["end_index"].max()
282
+
283
+ segment = df.loc[start:end]
284
+ construction_signal = smoothed_signal or signal
285
+
286
+ # Default matplotlib color cycle, rather than hard-coded colors.
287
+ cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]
288
+ observed_color = cycle[0]
289
+ construction_color = cycle[1]
290
+ rising_color = cycle[2]
291
+ falling_color = cycle[3]
292
+
293
+ # Observed signal.
294
+ ax.plot(
295
+ segment.index,
296
+ segment[signal],
297
+ linewidth=1.0,
298
+ alpha=0.55 if smoothed_signal else 1.0,
299
+ label="Observed signal",
300
+ color=observed_color,
301
+ )
302
+
303
+ # Smoothed/construction signal, when different from the observation.
304
+ if smoothed_signal is not None:
305
+ ax.plot(
306
+ segment.index,
307
+ segment[construction_signal],
308
+ linewidth=2.0,
309
+ label="Smoothed signal",
310
+ color=construction_color,
311
+ )
312
+
313
+ y = segment[construction_signal]
314
+ y_min = y.min()
315
+ y_max = y.max()
316
+ y_range = y_max - y_min
317
+
318
+ label_y = y_min + 0.06 * y_range
319
+
320
+ for _, obj in objects.iterrows():
321
+ object_id = int(obj["oscillation_id"])
322
+ start_index = obj["start_index"]
323
+ peak_index = obj["peak_index"]
324
+ end_index = obj["end_index"]
325
+
326
+ start_value = df.loc[start_index, construction_signal]
327
+ peak_value = df.loc[peak_index, construction_signal]
328
+ end_value = df.loc[end_index, construction_signal]
329
+
330
+ # Rising and falling phases.
331
+ ax.axvspan(
332
+ start_index,
333
+ peak_index,
334
+ alpha=0.08,
335
+ color=rising_color,
336
+ )
337
+ ax.axvspan(
338
+ peak_index,
339
+ end_index,
340
+ alpha=0.08,
341
+ color=falling_color,
342
+ )
343
+
344
+ # Boundaries between oscillation objects.
345
+ ax.axvline(
346
+ start_index,
347
+ linestyle="--",
348
+ linewidth=0.8,
349
+ alpha=0.45,
350
+ )
351
+
352
+ # Trough: enter rising.
353
+ ax.scatter(
354
+ start_index,
355
+ start_value,
356
+ marker="v",
357
+ s=55,
358
+ color=rising_color,
359
+ zorder=4,
360
+ )
361
+
362
+ # Peak: exit rising / enter falling.
363
+ ax.scatter(
364
+ peak_index,
365
+ peak_value,
366
+ marker="^",
367
+ s=55,
368
+ color=falling_color,
369
+ zorder=4,
370
+ )
371
+
372
+ # Oscillation-object label.
373
+ midpoint = start_index + (end_index - start_index) / 2
374
+
375
+ ax.text(
376
+ midpoint,
377
+ label_y,
378
+ rf"$O_{{{object_id}}}$",
379
+ ha="center",
380
+ va="bottom",
381
+ fontsize=10,
382
+ fontweight="medium",
383
+ )
384
+
385
+ # Final boundary.
386
+ ax.axvline(
387
+ objects["end_index"].max(),
388
+ linestyle="--",
389
+ linewidth=0.8,
390
+ alpha=0.45,
391
+ )
392
+
393
+ ax.set_title(title or signal)
394
+ ax.set_ylabel(ylabel or signal)
395
+ ax.grid(alpha=0.25)
396
+ ax.margins(x=0.01)
397
+
398
+
399
+ def add_construction_legend(fig):
400
+ """Add one shared legend for both panels."""
401
+ cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]
402
+
403
+ handles = [
404
+ plt.Line2D(
405
+ [],
406
+ [],
407
+ linewidth=1.0,
408
+ color=cycle[0],
409
+ label="Observed signal",
410
+ ),
411
+ plt.Line2D(
412
+ [],
413
+ [],
414
+ linewidth=2.0,
415
+ color=cycle[1],
416
+ label="Smoothed signal",
417
+ ),
418
+ plt.Rectangle(
419
+ (0, 0),
420
+ 1,
421
+ 1,
422
+ alpha=0.08,
423
+ color=cycle[2],
424
+ label="Rising phase",
425
+ ),
426
+ plt.Rectangle(
427
+ (0, 0),
428
+ 1,
429
+ 1,
430
+ alpha=0.08,
431
+ color=cycle[3],
432
+ label="Falling phase",
433
+ ),
434
+ plt.Line2D(
435
+ [],
436
+ [],
437
+ linestyle="none",
438
+ marker="v",
439
+ markersize=7,
440
+ color=cycle[2],
441
+ label="enter rising (trough)",
442
+ ),
443
+ plt.Line2D(
444
+ [],
445
+ [],
446
+ linestyle="none",
447
+ marker="^",
448
+ markersize=7,
449
+ color=cycle[3],
450
+ label="exit rising (peak)",
451
+ ),
452
+ ]
453
+
454
+ fig.legend(
455
+ handles=handles,
456
+ loc="lower center",
457
+ ncol=3,
458
+ frameon=True,
459
+ bbox_to_anchor=(0.5, -0.01),
460
+ )
File without changes
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ import pandas as pd
7
+ import requests
8
+
9
+
10
+ BIDMC_VERSION = "1.0.0"
11
+ BIDMC_BASE_URL = (
12
+ f"https://physionet.org/files/bidmc/{BIDMC_VERSION}/bidmc_csv"
13
+ )
14
+
15
+ FileKind = Literal["Signals", "Numerics", "Breaths", "Fix"]
16
+
17
+
18
+ def get_cache_dir() -> Path:
19
+ """
20
+ Return the BIDMC cache directory outside the Git repository.
21
+ """
22
+ cache_dir = (
23
+ Path.home()
24
+ / ".cache"
25
+ / "featuregraph"
26
+ / "bidmc"
27
+ / BIDMC_VERSION
28
+ )
29
+ cache_dir.mkdir(parents=True, exist_ok=True)
30
+ return cache_dir
31
+
32
+
33
+ def bidmc_filename(subject: int, kind: FileKind) -> str:
34
+ """
35
+ Return the source filename for a BIDMC subject file.
36
+ """
37
+ if not isinstance(subject, int):
38
+ raise TypeError("subject must be an integer")
39
+
40
+ if not 1 <= subject <= 53:
41
+ raise ValueError("subject must be between 1 and 53")
42
+
43
+ suffix = "txt" if kind == "Fix" else "csv"
44
+ return f"bidmc_{subject:02d}_{kind}.{suffix}"
45
+
46
+
47
+ def download_bidmc_file(
48
+ subject: int,
49
+ kind: FileKind,
50
+ *,
51
+ refresh: bool = False,
52
+ timeout: int = 60,
53
+ ) -> Path:
54
+ """
55
+ Download one BIDMC source file into the external cache.
56
+ """
57
+ filename = bidmc_filename(subject, kind)
58
+ destination = get_cache_dir() / filename
59
+
60
+ if destination.exists() and destination.stat().st_size > 0 and not refresh:
61
+ return destination
62
+
63
+ url = f"{BIDMC_BASE_URL}/{filename}"
64
+ temporary_path = destination.with_suffix(
65
+ destination.suffix + ".part"
66
+ )
67
+
68
+ try:
69
+ with requests.get(
70
+ url,
71
+ stream=True,
72
+ timeout=timeout,
73
+ ) as response:
74
+ response.raise_for_status()
75
+
76
+ with temporary_path.open("wb") as file:
77
+ for chunk in response.iter_content(
78
+ chunk_size=1024 * 1024
79
+ ):
80
+ if chunk:
81
+ file.write(chunk)
82
+
83
+ if temporary_path.stat().st_size == 0:
84
+ raise RuntimeError(
85
+ f"Downloaded BIDMC file is empty: {url}"
86
+ )
87
+
88
+ temporary_path.replace(destination)
89
+
90
+ except Exception:
91
+ temporary_path.unlink(missing_ok=True)
92
+ raise
93
+
94
+ return destination
95
+
96
+
97
+ def _load_bidmc_csv(
98
+ subject: int,
99
+ kind: Literal["Signals", "Numerics", "Breaths"],
100
+ *,
101
+ refresh: bool = False,
102
+ ) -> pd.DataFrame:
103
+ """
104
+ Download and load one BIDMC CSV table.
105
+ """
106
+ path = download_bidmc_file(
107
+ subject,
108
+ kind,
109
+ refresh=refresh,
110
+ )
111
+
112
+ df = pd.read_csv(path)
113
+
114
+ # Clean source-column whitespace at the loader boundary.
115
+ df.columns = df.columns.str.strip()
116
+
117
+ # Ensure the observation table identifies its source subject.
118
+ df["subject"] = subject
119
+
120
+ df.attrs["bidmc_subject"] = subject
121
+ df.attrs["bidmc_kind"] = kind
122
+ df.attrs["source_file"] = str(path)
123
+ df.attrs["bidmc_version"] = BIDMC_VERSION
124
+
125
+ return df
126
+
127
+
128
+ def load_bidmc_signals(
129
+ subject: int,
130
+ *,
131
+ refresh: bool = False,
132
+ ) -> pd.DataFrame:
133
+ """
134
+ Load waveform observations for one BIDMC subject.
135
+ """
136
+ return _load_bidmc_csv(
137
+ subject,
138
+ "Signals",
139
+ refresh=refresh,
140
+ )
141
+
142
+
143
+ def load_bidmc_numerics(
144
+ subject: int,
145
+ *,
146
+ refresh: bool = False,
147
+ ) -> pd.DataFrame:
148
+ """
149
+ Load numeric observations for one BIDMC subject.
150
+ """
151
+ return _load_bidmc_csv(
152
+ subject,
153
+ "Numerics",
154
+ refresh=refresh,
155
+ )
156
+
157
+
158
+ def load_bidmc_breaths(
159
+ subject: int,
160
+ *,
161
+ refresh: bool = False,
162
+ ) -> pd.DataFrame:
163
+ """
164
+ Load breath annotations for one BIDMC subject.
165
+ """
166
+ return _load_bidmc_csv(
167
+ subject,
168
+ "Breaths",
169
+ refresh=refresh,
170
+ )
171
+
172
+
173
+ def load_bidmc_subject(
174
+ subject: int,
175
+ *,
176
+ refresh: bool = False,
177
+ ) -> pd.DataFrame:
178
+ """
179
+ Load the waveform observation table for one BIDMC subject.
180
+
181
+ This is the primary BIDMC loader used by FeatureGraph. It returns a
182
+ DataFrame directly so that its output can be passed into behavioral
183
+ constructors such as ``featuregraph.oscillate``.
184
+
185
+ Numerics and breath annotations remain available through
186
+ ``load_bidmc_numerics`` and ``load_bidmc_breaths``.
187
+
188
+ Parameters
189
+ ----------
190
+ subject:
191
+ BIDMC subject number, between 1 and 53.
192
+
193
+ refresh:
194
+ Redownload the source waveform file even when it is cached.
195
+
196
+ Returns
197
+ -------
198
+ pandas.DataFrame
199
+ Waveform observations for one subject.
200
+ """
201
+ return load_bidmc_signals(
202
+ subject,
203
+ refresh=refresh,
204
+ )
205
+
206
+
207
+ def clear_bidmc_cache() -> None:
208
+ """
209
+ Remove locally cached BIDMC files.
210
+ """
211
+ cache_dir = get_cache_dir()
212
+
213
+ for path in cache_dir.iterdir():
214
+ if path.is_file():
215
+ path.unlink()
@@ -0,0 +1,23 @@
1
+ """
2
+ bidmc_map transcribed from the real state_detection/utils/_rename_map.py
3
+ (private repo, commit 8bfee18), lines 72-79.
4
+
5
+ NOTE ON SCOPE: the real file also contains a second dict, eastman_map,
6
+ mapping Tennessee Eastman Process (TEP) column names (xmeas_1..41,
7
+ xmv_1..11, faultNumber, simulationRun) to descriptive names. That dict
8
+ is deliberately NOT included here -- it's irrelevant to this package's
9
+ purpose (reproducing the smoothing paper's BIDMC-only population
10
+ statistics), and TEP work hasn't started yet regardless. It was also
11
+ only partially visible when this package was assembled (lines 1-19 and
12
+ 45-79 of the real file were seen; lines 20-44 were not), so it
13
+ couldn't be transcribed completely even if it were in scope.
14
+ """
15
+
16
+ bidmc_map = {
17
+ "Time [s]": "time",
18
+ "RESP": "respiration",
19
+ "PLETH": "ppg",
20
+ "II": "ecg_ii",
21
+ "V": "ecg_v",
22
+ "AVR": "ecg_avr",
23
+ }
@@ -0,0 +1,15 @@
1
+ from featuregraph_smoothing_core.validation.characterize import (
2
+ estimate_period_acf,
3
+ characterize_signal,
4
+ )
5
+ from featuregraph_smoothing_core.validation.matching import match_breaths
6
+ from featuregraph_smoothing_core.validation.canonical import compute_canonical_indices
7
+ from featuregraph_smoothing_core.validation.exclusions import FLAGGED_ANOMALY_SUBJECTS
8
+
9
+ __all__ = [
10
+ "estimate_period_acf",
11
+ "characterize_signal",
12
+ "match_breaths",
13
+ "compute_canonical_indices",
14
+ "FLAGGED_ANOMALY_SUBJECTS",
15
+ ]
@@ -0,0 +1,76 @@
1
+ """
2
+ The private repo's ground-truth validation notebook fetched "canonical"
3
+ detected objects (the ones built with each subject's own ACF-recommended
4
+ window) from a Postgres table that cached an earlier computation. That
5
+ table is just a cache -- the same objects can be recomputed directly,
6
+ identically, using characterize_signal() and OscillationConfig(), both
7
+ of which are public in this package. This module does that recomputation
8
+ so the ground-truth validation doesn't depend on the private storage
9
+ layer at all.
10
+ """
11
+
12
+ from featuregraph_smoothing_core.behaviors.oscillation import OscillationConfig
13
+ from featuregraph_smoothing_core.validation.characterize import characterize_signal
14
+
15
+
16
+ def compute_canonical_indices(df, signal, group, index_kind='peak', min_correlation=0.6):
17
+ """
18
+ For each group (e.g. subject or case_id), estimate a smoothing
19
+ window via ACF-based characterization and, if confident, construct
20
+ Oscillation objects using that group's own recommended window.
21
+
22
+ Mirrors the private repo's canonical_bidmc_troughs / canonical_bidmc_peaks
23
+ / canonical_capno_troughs functions, but recomputes directly instead
24
+ of reading a cached Postgres table -- the result is the same, since
25
+ those functions cached exactly this computation.
26
+
27
+ Parameters
28
+ ----------
29
+ df : pandas.DataFrame
30
+ Observations for all groups (e.g. all 53 BIDMC subjects).
31
+ signal : str
32
+ Name of the observed signal column (e.g. "respiration").
33
+ group : str
34
+ Name of the grouping column (e.g. "subject" or "case_id").
35
+ index_kind : "peak" or "trough"
36
+ Which boundary index to return.
37
+ min_correlation : float
38
+ Passed through to characterize_signal.
39
+
40
+ Returns
41
+ -------
42
+ (indices_by_group, characterization)
43
+ indices_by_group : dict mapping each confident group's value to
44
+ a sorted list of int sample indices.
45
+ characterization : the full characterize_signal() output, so
46
+ callers can inspect is_confident / estimated_period / etc.
47
+ for every group, not only the confident ones.
48
+ """
49
+ if index_kind not in ('peak', 'trough'):
50
+ raise ValueError("index_kind must be 'peak' or 'trough'")
51
+
52
+ characterization = characterize_signal(df, signal, group, min_correlation=min_correlation)
53
+
54
+ indices_by_group = {}
55
+ for group_value, row in characterization.iterrows():
56
+ if not row['is_confident']:
57
+ continue
58
+
59
+ window = int(row['window_medium'])
60
+ subj_df = df[df[group] == group_value].copy()
61
+
62
+ config = OscillationConfig(signal=signal, smooth_window=window)
63
+ added = config.add_primitives(subj_df, group)
64
+
65
+ # summarize() gives one row per actual oscillation object -- the
66
+ # add_primitives() output has one row per SAMPLE, with peak/trough
67
+ # index forward-filled across every row between events, so reading
68
+ # straight from added would count each sample between two peaks
69
+ # as if it were its own detected peak.
70
+ summarized = config.summarize(added, [group, config.trough_event_id_col])
71
+ index_col = 'peak_index' if index_kind == 'peak' else 'trough_index'
72
+ indices_by_group[group_value] = sorted(
73
+ summarized[index_col].dropna().astype(int).tolist()
74
+ )
75
+
76
+ return indices_by_group, characterization
@@ -0,0 +1,61 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+
5
+ def estimate_period_acf(signal, max_lag=None, min_lag=100):
6
+ """
7
+ Estimate the dominant period of a signal using autocorrelation,
8
+ computed directly on the raw signal with no smoothing window involved.
9
+ Returns (peak_lag, peak_correlation, at_boundary).
10
+ at_boundary=True means the search never found a real local peak --
11
+ the max was just the start of the region, i.e. the curve was still
12
+ decaying from lag 0 rather than showing genuine periodic recurrence.
13
+ """
14
+ x = signal.dropna().to_numpy()
15
+ x = x - x.mean()
16
+ n = len(x)
17
+ if max_lag is None:
18
+ max_lag = n // 2
19
+
20
+ f = np.fft.fft(x, n=2 * n)
21
+ acf = np.fft.ifft(f * np.conj(f))[:n].real
22
+ acf /= acf[0]
23
+
24
+ search_region = acf[min_lag:max_lag]
25
+ if len(search_region) == 0:
26
+ return None, None, True
27
+
28
+ peak_offset = np.argmax(search_region)
29
+ peak_lag = peak_offset + min_lag
30
+ peak_corr = acf[peak_lag]
31
+ at_boundary = (peak_offset == 0)
32
+
33
+ return peak_lag, peak_corr, at_boundary
34
+
35
+
36
+ def characterize_signal(df, signal, group, min_correlation=0.6):
37
+ """
38
+ Estimate each group's dominant period directly from the raw signal
39
+ via autocorrelation. is_confident requires both a sufficiently strong
40
+ peak correlation AND a genuine local peak (not just the search
41
+ boundary), since a boundary hit means no real periodicity was found.
42
+ """
43
+ results = []
44
+ for subj, subj_df in df.groupby(group):
45
+ period, corr, at_boundary = estimate_period_acf(subj_df[signal])
46
+ is_confident = (
47
+ period is not None
48
+ and corr is not None
49
+ and corr >= min_correlation
50
+ and not at_boundary
51
+ )
52
+ results.append({
53
+ group: subj,
54
+ 'estimated_period': period,
55
+ 'acf_peak_correlation': corr,
56
+ 'at_boundary': at_boundary,
57
+ 'is_confident': is_confident,
58
+ 'window_small': max(int(round(period * 0.25)), 2) if is_confident else None,
59
+ 'window_medium': max(int(round(period * 0.5)), 2) if is_confident else None,
60
+ })
61
+ return pd.DataFrame(results).set_index(group)
@@ -0,0 +1,14 @@
1
+ """
2
+ Subjects flagged in the private repo's earlier analysis as showing a
3
+ validation gap that does not close as the matching tolerance widens --
4
+ a structural construction-annotation mismatch, not ordinary timing
5
+ imprecision. Transcribed verbatim (FLAGGED_ANOMALY_SUBJECTS) from the
6
+ private repo's ground-truth validation notebook (cell-bidmc-gap-to-ceiling).
7
+
8
+ Excluding these from the paper's reported recall/precision figures is
9
+ the same treatment already given to the 16 BIDMC subjects with no
10
+ reliable ACF period at all -- a recorded, structural fact, not an
11
+ error hidden from the reader.
12
+ """
13
+
14
+ FLAGGED_ANOMALY_SUBJECTS = [9, 10, 23, 48, 51]
@@ -0,0 +1,42 @@
1
+ import pandas as pd
2
+
3
+
4
+ def match_breaths(detected_indices, expert_indices, tolerance_samples):
5
+ """
6
+ Matches each expert-marked breath sample index to the nearest
7
+ detected cycle's boundary index, within a tolerance window. Works
8
+ for any two lists of sample indices, so this is dataset-agnostic.
9
+
10
+ recall = fraction of expert-marked breaths that had a detected cycle
11
+ boundary nearby (did the algorithm miss real breaths?)
12
+ precision = fraction of detected cycle boundaries that had an
13
+ expert-marked breath nearby (did the algorithm hallucinate
14
+ extra cycles?)
15
+ """
16
+ detected = pd.DataFrame({'detected_index': sorted(detected_indices)}).astype(float)
17
+ expert = pd.DataFrame({'expert_index': sorted(expert_indices)}).astype(float)
18
+
19
+ if len(detected) == 0 or len(expert) == 0:
20
+ return {
21
+ 'recall': float('nan'), 'precision': float('nan'),
22
+ 'n_expert': len(expert), 'n_detected': len(detected),
23
+ }
24
+
25
+ matched_forward = pd.merge_asof(
26
+ expert, detected,
27
+ left_on='expert_index', right_on='detected_index',
28
+ direction='nearest', tolerance=tolerance_samples,
29
+ )
30
+ recall = matched_forward['detected_index'].notna().mean()
31
+
32
+ matched_backward = pd.merge_asof(
33
+ detected, expert,
34
+ left_on='detected_index', right_on='expert_index',
35
+ direction='nearest', tolerance=tolerance_samples,
36
+ )
37
+ precision = matched_backward['expert_index'].notna().mean()
38
+
39
+ return {
40
+ 'recall': recall, 'precision': precision,
41
+ 'n_expert': len(expert), 'n_detected': len(detected),
42
+ }
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: featuregraph-smoothing-core
3
+ Version: 1.0.0
4
+ Summary: Minimal, frozen subset of FeatureGraph's state-detection code, extracted to support reproducibility of the smoothing-paper results.
5
+ Author: Nazia Habib
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: pandas>=1.5
11
+ Requires-Dist: numpy>=1.23
12
+ Requires-Dist: matplotlib>=3.5
13
+ Requires-Dist: requests>=2.25
14
+ Dynamic: license-file
15
+
16
+ # featuregraph-smoothing-core
17
+
18
+ Code and data behind "A Compiler-Level Account of Smoothing-Parameter
19
+ Choice." Contains `OscillationConfig` (the construction used throughout
20
+ the paper), a BIDMC data loader, and the validation logic behind the
21
+ paper's human-annotation results.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install -e .
27
+ ```
28
+
29
+ ## Reproduce the paper's results
30
+
31
+ ```bash
32
+ pytest tests/ -v
33
+ ```
34
+
35
+ Two tests are skipped by default, since they need a live download from
36
+ PhysioNet. Their skip reasons record the exact confirmed results;
37
+ remove the `@pytest.mark.skip` decorator above either one and rerun to
38
+ reproduce it directly:
39
+
40
+ - `test_reproduces_paper_correlation_and_ratio_range` — the paper's
41
+ population correlation (0.39) and ratio range (1.14x–40.25x) across
42
+ all 53 BIDMC subjects.
43
+ - `test_bidmc_peak_matched_recall_excluding_flagged_subjects` — the
44
+ paper's human-annotation recall results (Table 1), N=32.
45
+
46
+ ## Using OscillationConfig directly
47
+
48
+ ```python
49
+ from featuregraph_smoothing_core.behaviors.oscillation import OscillationConfig
50
+
51
+ config = OscillationConfig(signal="respiration", smooth_window=100)
52
+ added = config.add_primitives(df, "subject")
53
+ summary = config.summarize(added, ["subject", config.trough_event_id_col])
54
+ ```
55
+
56
+ ## What's here
57
+
58
+ - `src/featuregraph_smoothing_core/` — the package
59
+ - `notebooks/bidmc_visual_demo.ipynb` — the code that produced Figure 1
60
+ - `artifacts/paper/compiler/smoothing.md` — the paper manuscript
61
+ - `tests/` — the full test suite
62
+
63
+ ## Citation
64
+
65
+ Software: https://doi.org/10.5281/zenodo.22947447
66
+
67
+ ## License
68
+
69
+ MIT. See `LICENSE`.
@@ -0,0 +1,23 @@
1
+ featuregraph_smoothing_core/__init__.py,sha256=56xg_Y7VTThPE8u4UFuql9tS_Nvrskr367OhyV0QM8c,377
2
+ featuregraph_smoothing_core/plot.py,sha256=lDrAG82ekeYxTZcG1s2Vt1-EGH2vKgvz-pmXIYpfqo0,11016
3
+ featuregraph_smoothing_core/behaviors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ featuregraph_smoothing_core/behaviors/oscillation.py,sha256=_hCrtxGC_WZ1nXNzYHLF8XkcdUmhl3Pisk_50MHvXmk,4236
5
+ featuregraph_smoothing_core/datasets/__init__.py,sha256=ISTqeMQEo_FwgKrcBJNlzlMwYNA85b4F2Y1eSIWnuaE,115
6
+ featuregraph_smoothing_core/datasets/_bidmc.py,sha256=9wxzzhIyLyy_rIUIAkhV8OGOq_3HPdOMHQxOlk2UBMw,894
7
+ featuregraph_smoothing_core/operators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ featuregraph_smoothing_core/operators/events.py,sha256=-uwO6cFhANyxxcSADiLqE6IdQMM7jAWMiADFmFI9Bx0,756
9
+ featuregraph_smoothing_core/operators/measures.py,sha256=nPvNWHtJftbnK9UoaHqd6Tm8yLwr9xvGa9u9wrGCCqI,501
10
+ featuregraph_smoothing_core/operators/states.py,sha256=SnyoyTvNnmODL4x2agXMpIOWGiVCOvO0dhtSE-MPdEA,661
11
+ featuregraph_smoothing_core/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ featuregraph_smoothing_core/utils/_bidmc.py,sha256=5TvvI0cmGNdSiNw6wMwJRwmfFNFLABn_Wh4wlWvR_mQ,4814
13
+ featuregraph_smoothing_core/utils/_rename_map.py,sha256=A7RlqV1oQ9Mwm5HZEzue94nWZeYkawvn2W4e956AIa0,884
14
+ featuregraph_smoothing_core/validation/__init__.py,sha256=gYq6X2Q7JHA26PPvPKTrbl1WxZKUzYPmCMK3Hq41nu0,521
15
+ featuregraph_smoothing_core/validation/canonical.py,sha256=-1Ile-3GhBRYnta9eBqREgidoTCxSyGR7YJFriEI6sQ,3345
16
+ featuregraph_smoothing_core/validation/characterize.py,sha256=5U7DXpCCwi9FBF8GViY_53qipH40L8MTYea4jAuj-ro,2172
17
+ featuregraph_smoothing_core/validation/exclusions.py,sha256=8hwZsHdq9dypDzvAX_1RpwQS7iNPRawveunJo_wiNhg,643
18
+ featuregraph_smoothing_core/validation/matching.py,sha256=GbNbXmJgH81TfG8txHidkpX0qEBlUiw_qCMUnFNCLgE,1631
19
+ featuregraph_smoothing_core-1.0.0.dist-info/licenses/LICENSE,sha256=XRtysk4F6gH4GboWx_7UwWzosDC3RHHoLx1ynZZAJeQ,1068
20
+ featuregraph_smoothing_core-1.0.0.dist-info/METADATA,sha256=Oyw_Qn0gLF5YwyAUlLMKuPvVUstHyxT0ESmar2Gf_as,2043
21
+ featuregraph_smoothing_core-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
22
+ featuregraph_smoothing_core-1.0.0.dist-info/top_level.txt,sha256=Vdb7pu2xT6ASV-C3qskrH32qHTEXOJlu8TFUVXBHfcI,28
23
+ featuregraph_smoothing_core-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nazia Habib
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ featuregraph_smoothing_core