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.
- featuregraph_smoothing_core/__init__.py +10 -0
- featuregraph_smoothing_core/behaviors/__init__.py +0 -0
- featuregraph_smoothing_core/behaviors/oscillation.py +101 -0
- featuregraph_smoothing_core/datasets/__init__.py +3 -0
- featuregraph_smoothing_core/datasets/_bidmc.py +44 -0
- featuregraph_smoothing_core/operators/__init__.py +0 -0
- featuregraph_smoothing_core/operators/events.py +27 -0
- featuregraph_smoothing_core/operators/measures.py +13 -0
- featuregraph_smoothing_core/operators/states.py +30 -0
- featuregraph_smoothing_core/plot.py +460 -0
- featuregraph_smoothing_core/utils/__init__.py +0 -0
- featuregraph_smoothing_core/utils/_bidmc.py +215 -0
- featuregraph_smoothing_core/utils/_rename_map.py +23 -0
- featuregraph_smoothing_core/validation/__init__.py +15 -0
- featuregraph_smoothing_core/validation/canonical.py +76 -0
- featuregraph_smoothing_core/validation/characterize.py +61 -0
- featuregraph_smoothing_core/validation/exclusions.py +14 -0
- featuregraph_smoothing_core/validation/matching.py +42 -0
- featuregraph_smoothing_core-1.0.0.dist-info/METADATA +69 -0
- featuregraph_smoothing_core-1.0.0.dist-info/RECORD +23 -0
- featuregraph_smoothing_core-1.0.0.dist-info/WHEEL +5 -0
- featuregraph_smoothing_core-1.0.0.dist-info/licenses/LICENSE +21 -0
- featuregraph_smoothing_core-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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,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,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
|