Skip to content

Commit dfddbd5

Browse files
Add log-transform support to SleepStatsAgreement (Euser et al. 2008) (#249)
* Add log transform * Update evaluation review * Address copilot review comments * Bump minimal scipy version * Update index.rst
1 parent 6ff4dcd commit dfddbd5

6 files changed

Lines changed: 497 additions & 67 deletions

File tree

.github/workflows/python_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ jobs:
6666
# Minimum supported versions (as defined in pyproject.toml)
6767
- python-version: "3.10"
6868
numpy: "numpy==1.22.4"
69-
scipy: "scipy==1.8.1"
69+
scipy: "scipy==1.10.0"
7070
pandas: "pandas==2.1.1"
7171
mne: "mne==1.3.0"
7272
label: "minimum-versions"

docs/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Dependencies
5858
The core dependencies of YASA are:
5959

6060
* `NumPy <https://numpy.org/>`_ >= 1.22.4
61-
* `SciPy <https://www.scipy.org/>`_ >= 1.8.1
61+
* `SciPy <https://www.scipy.org/>`_ >= 1.10
6262
* `Pandas <https://pandas.pydata.org/>`_ >= 2.1.1
6363
* `Matplotlib <https://matplotlib.org/>`_
6464
* `Seaborn <https://seaborn.pydata.org/>`_

evaluation_review.md

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ See https://github.com/raphaelvallat/yasa/pull/228
3737

3838
| # | Issue | Status | Notes |
3939
|---|-------|--------|-------|
40-
| 6 | **Log transformation** missing | ⚠️ Deferred | Planned: `log_transform` param + Euser et al. (2008) back-transform. Separate PR. |
40+
| 6 | **Log transformation** missing | ✅ Implemented | `log_transform=True` param + Euser et al. (2008) back-transform; `"log"` loa_method. |
4141
| 7 | **Individual discrepancy heatmap** (`indDiscr.R`) || No equivalent; data available via `get_sleep_stats()` |
4242
| 8 | **Calibration direction bug** | ✅ Fixed | See detailed investigation below (Bugs A, B, C) |
4343

@@ -54,69 +54,66 @@ See https://github.com/raphaelvallat/yasa/pull/228
5454
| Overlaid hypnogram plots |`plot_hypnograms()` ||
5555
| MAD / median in group summary |`summary()` ||
5656
| Human-readable report table |`report()` ||
57+
| Bland-Altman plot |`plot_blandaltman()` |`BAplot.R` |
5758

5859
---
5960

60-
## Future PR: Bland-Altman Plot (`BAplot.R` vs YASA)
61+
## Bland-Altman Plot (`BAplot.R` vs YASA)
6162

62-
YASA has **no Bland-Altman plot** for `SleepStatsAgreement`. This is a major missing piece.
63+
**`plot_blandaltman()` implemented** in `SleepStatsAgreement` (`evaluation.py:1604`).
6364

64-
### What `BAplot.R` produces
65+
### What `BAplot.R` produces vs YASA
6566

66-
One scatter plot per sleep statistic (obs − ref differences vs reference), with:
67+
| Element | R (`BAplot.R`) | YASA (`plot_blandaltman`) |
68+
|---------|---------------|--------------------------|
69+
| Scatter points | One dot per session | ✅ One dot per session |
70+
| Bias line | Horizontal `mean(diff)` or regression `b0 + b1*ref` | ✅ Same; auto-selected from `assumptions` |
71+
| Bias CI | t-CI or bootstrap band | ✅ Shaded band; method via `ci_method` |
72+
| LoA lines | Constant `bias ± 1.96 SD` or `bias ± 2.46*(c0 + c1*ref)` | ✅ Same; auto-selected from `assumptions` |
73+
| LoA CI | Dashed CI bands | ✅ Shaded bands |
74+
| Flag biased | Red bias line if significant |`flag_biased=True` |
75+
| Euser LoA | `bias ± ref × euser_slope` |`log_transform=True` |
76+
| Marginal density | `ggExtra::ggMarginal` | ❌ Not implemented |
77+
| `xaxis="mean"` | (obs+ref)/2 on x-axis | ❌ Only reference on x-axis |
6778

68-
| Element | Description |
69-
|---------|-------------|
70-
| Scatter points | One dot per session |
71-
| Marginal density | Y-axis marginal distribution via `ggExtra::ggMarginal` |
72-
| Bias line (red) | Horizontal constant `mean(diff)` — or regression line `b0 + b1*ref` if proportional bias |
73-
| Bias CI (red dashed) | Classic t-CI or bootstrap CI band around bias line |
74-
| Upper LoA (gray) | `bias + 1.96*SD` — or `bias + 2.46*(c0 + c1*ref)` if heteroscedastic |
75-
| Lower LoA (gray) | `bias − 1.96*SD` — or `bias − 2.46*(c0 + c1*ref)` if heteroscedastic |
76-
| LoA CI (gray dashed) | CI bands around each LoA |
77-
| Log-transform mode | LoA drawn as `bias ± ref × euser_slope` (Euser 2008) |
79+
### `BAplot.R` parameters not yet in YASA
7880

79-
### `BAplot.R` parameters
81+
| Parameter | Purpose | Status |
82+
|-----------|---------|--------|
83+
| `logTransf = TRUE/FALSE` | Use Euser back-transform for LoA |`log_transform=True` |
84+
| `xaxis = "mean"` | X-axis: (obs+ref)/2 instead of ref | ❌ Not planned |
85+
| `xlim`, `ylim` | Axis limits | ❌ Can be set via matplotlib post-call |
8086

81-
| Parameter | Purpose |
82-
|-----------|---------|
83-
| `xaxis = "reference"` or `"mean"` | X-axis: reference value or (obs+ref)/2 |
84-
| `logTransf = TRUE/FALSE` | Use Euser back-transform for LoA |
85-
| `CI.type = "classic"/"boot"` | Parametric t-CI or bootstrap CI |
86-
| `xlim`, `ylim` | Axis limits |
87+
---
8788

88-
### YASA current state
89+
## Log transformation (Euser et al. 2008)
8990

90-
- `SleepStatsAgreement` has **no `plot_blandaltman()` method** (confirmed by grep)
91-
- All statistical values needed for the plot are already stored: `_vals`, `_ci`, `_regr`, `_data`
92-
- YASA already detects proportional bias and heteroscedasticity and chooses parm/regr methods accordingly — these would drive which line style to draw
93-
- Log-transform Euser slopes are deferred (item 6, separate PR)
91+
**Reference:** `groupDiscr.R` lines 208–264, `BAplot.R` log section, Euser et al. (2008) and Bland & Altman (1999).
9492

95-
### What a `plot_blandaltman()` method would need to implement
93+
### Design principles (simplified vs R)
9694

97-
1. Scatter of `difference` vs `ref` per sleep stat (one subplot per stat, or single stat via argument)
98-
2. Bias line: constant if `constant_bias`, regression line `b0 + b1*x` otherwise
99-
3. Bias CI: shaded band or dashed lines; use boot CI if `normal == False`
100-
4. LoA lines: constant if `homoscedastic`, regression `bias ± 2.46*(c0 + c1*x)` otherwise
101-
5. Euser LoA: `bias ± euser_slope * x` for log-transformed stats
102-
6. LoA CI bands (dashed)
103-
7. Optional: marginal distribution on y-axis (via `seaborn` or `matplotlib` inset)
104-
8. Optional: `xaxis="reference"` vs `xaxis="mean"` toggle
95+
- `log_transform` is **bool only** — no per-stat list. Mixed cases use two `SleepStatsAgreement` objects.
96+
- Euser slope is **internal** — not exposed in `summary()`, only rendered in `plot_blandaltman` and `report`.
97+
- `"log"` is a first-class **`loa_method` value**, alongside `"param"` and `"regr"`.
98+
- `auto_methods["loa"]` returns `"log"` for all stats when `log_transform=True`.
99+
- No `log_normal` in `assumptions` — normality of original diffs drives CI selection as before.
100+
- Bootstrap CI for Euser slope handled inside existing `_generate_bootstrap_ci`, no new methods.
105101

106102
---
107103

108-
## Future PR: Log transformation (Euser et al. 2008)
104+
### Background and motivation
105+
106+
When differences between devices and reference scale proportionally with the measurement size (heteroscedasticity), a log transformation stabilises the variance. The Euser et al. (2008) method computes limits of agreement in log space and back-transforms them to a slope that multiplies the reference value:
107+
108+
```
109+
LoA_upper = bias + slope × ref
110+
LoA_lower = bias − slope × ref
111+
slope = 2 * (exp(agreement × SD(log_diffs)) − 1) / (exp(agreement × SD(log_diffs)) + 1)
112+
```
109113

110-
**Planned parameter:** `log_transform=False` (bool or list of str)
114+
where `log_diffs = log(obs + ε) − log(ref + ε)` and `ε = 1e-4` to avoid `log(0)`.
111115

112-
**Design notes:**
113-
- `log_transform=True` applies to all stats; a list applies to named stats only (e.g., `["SOL", "WASO"]`)
114-
- Log-ratio differences: `d_i = log(obs_i + 1e-4) − log(ref_i + 1e-4)`
115-
- Euser slope: `2*(exp(agreement * SD(d_i)) − 1) / (exp(agreement * SD(d_i)) + 1)`
116-
- Parametric CI: `slope_ci = euser_fn(SD ± t * sqrt(SD² * 3 / n))` (Bland & Altman 1999 SE formula)
117-
- Store in `_vals["loa_log_slope"]` and `_ci["param"]["loa_log_slope"]`
118-
- `get_table()` to show `"Bias ± slope × ref"` for log-transformed stats (fstring key `"loa_log"`)
119-
- New property: `log_transform_stats`
116+
This is the **only** LoA representation that applies for log-transformed stats. The standard `loa_lower`/`loa_upper` (constant) and `loa_intercept`/`loa_slope` (regression) representations do not apply to these stats.
120117

121118
---
122119

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dynamic = ["version"]
2929
requires-python = ">=3.10"
3030
dependencies = [
3131
"numpy>=1.22.4",
32-
"scipy>=1.8.1",
32+
"scipy>=1.10",
3333
"pandas>=2.1.1",
3434
"matplotlib",
3535
"seaborn",

0 commit comments

Comments
 (0)