Repository navigation
Add timeseries label processors and normalization options - #1337
Conversation
ae04c2f to
7f301c6
Compare
Test CoverageTotal: 72.43% (19304 / 26653 lines)
|
42347b9 to
30fef9d
Compare
30fef9d to
227808a
Compare
jhnwu3
left a comment
There was a problem hiding this comment.
Sorry will undo this on the draft for now, accidentally sped through it.
|
/review |
There was a problem hiding this comment.
Previous review
AI Review
suggestion: need change
decision: need change
Thank you for this contribution. It adds per-step label processors (timeseries_binary and timeseries_regression) that return a value and a mask. It also adds opt-in normalization and missing-value indicators for TimeseriesProcessor and TensorProcessor, with shared statistics in processors/utils.py. Defaults stay unchanged, and the tests are thorough. Coverage meets the bar: the changed files are at 84–95% and the whole repository is at 71.9%.
It is close. A few things need fixing first.
To get this merged:
- Reuse the gridding helper.
sample_on_gridintimeseries_processor.pyis nearly a verbatim copy of the gridding loop inTemporalTimeseriesProcessor(temporal_timeseries_processor.py, around lines 96–106). Please have that processor call the new helper, or reuse its existing logic, so the grid logic lives in one place. - Make the label docs match the behavior. The
TimeseriesLabelProcessordocstring says labels "not on the grid" are masked. In practicesample_on_gridfloors each timestamp into a step. When several labels fall in the same step, the last one silently overwrites the others. Please either document this clearly or raise or mask on collisions. Masking them out would fit the "never imputed" intent.
Optional suggestions:
- Check or document that the label's first timestamp must equal the input's first timestamp for the steps to line up. A label series longer than its input currently fails only inside the loss during training.
- With normalization on, a scalar
TensorProcessorinput comes out with shape(1,)whiledim()reports 0. Please make these consistent. - Add the new public classes to
docs/api/processors.rst. tests/core/test_per_step_labels.pyseems to repeat several model-level checks already intests/core/test_timeseries_modes.py. You could trim them.
Reviewed commit 227808a
227808a to
a47c614
Compare
|
/review |
a47c614 to
0af98d6
Compare
Label processors: timeseries_binary and timeseries_regression
(TimeseriesLabelProcessor) place per-step labels on the same uniform
time grid as TimeseriesProcessor and return (value, mask), with mask 0
for steps without a label; they feed the timeseries_* model modes.
Gridding is shared through sample_on_grid.
Preprocessing options, defaults unchanged:
- TimeseriesProcessor: normalization ("zscore" or "minmax", statistics
learned in fit from the observed values) applied before imputation, and
missing-value indicators appended. With zscore and forward_fill, values
before the first observation become 0, the training mean.
- TensorProcessor: the same normalization, missing-value indicators and
impute_strategy="zero".
Both set learns_statistics when normalizing, so fitting without a
training split warns. The shared helpers live in processors/utils.py.
- Move sample_on_grid to processors/utils.py, next to a new grid_steps helper, and use it in TemporalTimeseriesProcessor too, so the gridding lives in one place. - TimeseriesLabelProcessor raises a ValueError when two labels fall into the same time step instead of silently keeping the last one, and its docstring now states how timestamps map to steps and that labels must share the input's first timestamp and sampling rate. - TensorProcessor: normalization keeps a scalar input scalar, and dim() / spatial() report one dimension when missing-value indicators turn a scalar into a vector. - Add API pages for the label processors and processors.utils, and note the new options in the processor list. - Replace the model-level tests that repeated test_timeseries_modes.py with one end-to-end test of the label processors; add tests for label collisions, flooring, the shared grid and scalar shapes.
0af98d6 to
64b7cca
Compare
| """ | ||
| start_time = timestamps[0] | ||
| return np.array( | ||
| [int((t - start_time) / sampling_rate) for t in timestamps], dtype=int |
There was a problem hiding this comment.
[low] grid_steps truncates toward zero instead of flooring
The docstring says a timestamp goes to floor((t - t0) / sampling_rate), but int(...) truncates toward zero. A timestamp earlier than the first one (only possible with unsorted input) such as t0 - 30 min maps to step 0 rather than being dropped, so it can silently overwrite or be placed on step 0. The same truncation existed in the old TimeseriesProcessor code, but the new label processor relies on this helper for its duplicate check. Fix: use int(np.floor((t - start_time) / sampling_rate)) or math.floor, or document that timestamps must be sorted.
| timestamps, labels = value | ||
| if len(timestamps) == 0: | ||
| raise ValueError("Timestamps list is empty.") | ||
| steps, counts = np.unique( |
There was a problem hiding this comment.
[low] NaN labels count toward the same-step duplicate check
The duplicate check runs over all timestamps, including those whose label is NaN. The class docstring says NaN labels are masked as missing, yet a NaN sharing a step with a real label raises "2 labels fall into time step ..." instead of being ignored. Fix: compute grid_steps only over the entries where labels is not NaN, for example grid_steps([t for t, v in zip(timestamps, labels) if not np.isnan(v)], ...), with the same handling for scalar NaN and length-check edge cases.
| NaN, are masked out and never imputed. Two labels in the same step raise | ||
| a ``ValueError`` rather than one silently replacing the other. | ||
|
|
||
| The processor does not see the input time series, so the caller must give |
There was a problem hiding this comment.
[low] Label/input alignment is a caller contract that is not validated
The processor cannot see the input series, so labels only line up if the caller gives them the same first timestamp and sampling_rate. The docstring states this. However, base_model._masked_step_loss silently truncates a label that is shorter than the input, so a label series starting later is shifted without any error; only longer labels are rejected. Non-blocking because the contract is documented. A fix would be an optional start time argument, or a check in the task that builds the labels.
| value was missing before imputation. | ||
| 4. Imputation for missing values. | ||
|
|
||
| With ``normalization="zscore"`` and ``impute_strategy="forward_fill"``, |
There was a problem hiding this comment.
[low] Minmax leading-gap fill is not documented
With forward_fill, values before the first observation are set to 0 before the fill. That is the training mean for zscore, but for minmax it is the training minimum, since the center is low. The docstring documents only the zscore case. Fix: state the minmax meaning, or fill with a neutral value for minmax.
| np.testing.assert_array_equal(value.numpy(), [0, 0, 1, 1]) | ||
| np.testing.assert_array_equal(mask.numpy(), [1, 0, 1, 1]) | ||
|
|
||
| def test_grid_follows_sampling_rate(self): |
There was a problem hiding this comment.
[low] Test named for sampling_rate does not pass a sampling rate
test_grid_follows_sampling_rate uses the default one-hour rate, so no test covers a non-default sampling_rate for the label processors. Pass e.g. sampling_rate=timedelta(hours=2) and check the step placement, so the name matches the test.
There was a problem hiding this comment.
AI Review
suggestion: approve
decision: approve
Thank you for this contribution. It adds per-step label processors (timeseries_binary and timeseries_regression) that return (value, mask) on the input's time grid. It also adds opt-in normalization and missing_indicators options for TimeseriesProcessor and TensorProcessor, plus impute_strategy="zero" for TensorProcessor, and a shared utils.py. The defaults are unchanged and the new options are off unless requested. The grid logic is consolidated into sample_on_grid rather than duplicated. The tests cover the statistics against NumPy, the masks and grid, the unchanged defaults, and an end-to-end run with RNN and Trainer. I think it looks ready to merge.
Coverage: the whole repository is at 72.43%, above the 60% bar. Most changed files are above 80%: utils.py 96.6%, __init__.py 94.1%, tensor_processor.py 92.5% and timeseries_processor.py 84.5%. temporal_timeseries_processor.py is at 78.6%, slightly under 80%. Your change there is a small refactor onto sample_on_grid that is exercised by a test, so I don't see this as blocking.
Optional suggestions:
grid_stepsusesint(), which truncates toward zero. Timestamps earlier than the first one would land on step 0, which doesn't match the "floor" wording in the docstring. Sorted inputs are unaffected.- A NaN label that shares a step with a real label currently raises the duplicate-step error. You could treat NaN as "no label" and exclude it from that check.
- Add tests for a non-default
sampling_rateon the label processors, forminmaxat the processor level, and for thelearns_statisticswarning. - Note in the docs that
minmaxwithforward_fillmaps leading gaps to the training minimum.
Reviewed commit 64b7cca
the original reason for this request change is "Sorry will undo this on the draft for now, accidentally sped through it."
Summary
Adds processors for per-step labels, and normalization and missing-value options for time series and tensor features.
Label processors
timeseries_binaryandtimeseries_regression(TimeseriesLabelProcessor) take a label time series(timestamps, values), place it on the same uniform time grid asTimeseriesProcessor(with the same first timestamp andsampling_rateas the input, each label lines up with one input step), and return(value, mask), with mask 0 for steps without a label. Missing labels are masked, never imputed. Their registered names match thetimeseries_binary/timeseries_regressionmodel modes, so models predict and are scored at every step. The gridding is shared by both processors throughsample_on_grid.Normalization and missing-value options
All options are off by default, so existing behaviour is unchanged.
TimeseriesProcessor:normalization:"zscore"or"minmax", learned infit()from the observed (non-missing) values and applied before imputation. With"zscore"and the existingimpute_strategy="forward_fill", values before the first observation become 0, which is the training mean.missing_indicators: appends one 0/1 indicator per feature, 1 where the value was missing before imputation.TensorProcessor: the samenormalizationandmissing_indicators, plusimpute_strategy="zero".Both processors set
learns_statisticswhen normalizing, so PyHealth warns when they are fitted on all samples instead of a training split (set_task(task, split=...)). The shared normalization helpers live in the newpyhealth/processors/utils.py.Tests
tests/core/test_per_step_labels.py: the label processors (masks, grid, binary validation) and end to end withRNNand theTrainer.tests/core/test_processor_normalization.py: the statistics (streamed across samples, matching NumPy), constant and unobserved features, unchanged defaults, and the processor options.Existing processor tests pass.