Skip to content

Towards Unified Sequence Representation I: Add SequenceProcessorV2 for packed hierarchical records - #1343

Open
Logiquo wants to merge 11 commits into
masterfrom
feat/seq-v2
Open

Logiquo wants to merge 11 commits into
masterfrom
feat/seq-v2

Conversation

@Logiquo

@Logiquo Logiquo commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

This is the first PR of a stack towards one unified representation for sequential features. It adds SequenceProcessorV2 ("sequence_v2"), a processor for nested codes, values and timestamps at any depth, stored as packed records (CSR / jagged layout) with no padding or truncation. It also makes SampleDataset store and batch such fields. Existing processors and models are unchanged. Model support comes in later PRs.

Why

Today every combination of nesting, values and time gets its own processor (SequenceProcessor, NestedSequenceProcessor, DeepNested*, StageNet*, Timeseries*, ...). Each one pads to rectangular shapes fixed during fit(), so inputs longer than anything seen in training are truncated. SequenceProcessorV2 describes all of them with one configuration, and its output keeps every record.

What a field looks like

A task returns each field as Records, a plain dict of columns. children holds the records one level down:

{"time": [0.0, 30.0], "children": [{"tokens": ["A", "B"]}, {"tokens": ["C"]}]}

The processor is configured per level, innermost first:

input_schema = {
    "visits": ("sequence_v2", {"levels": [{"ordering": "set", "tokens": 1}, {"ordering": "series"}]}),
    "labs":   ("sequence_v2", {"levels": [{"ordering": "series", "tokens": 2, "values": [1],
                                           "impute": [{"method": "forward_fill", "by_tokens": (0,)},
                                                      {"method": "mean", "by_tokens": (0,)}],
                                           "normalize": {"method": "zscore", "by_tokens": (0,)},
                                           "missing_indicator": True}]}),
    "vitals": ("sequence_v2", {"levels": [{"ordering": "series", "values": [3],
                                           "resample": {"step": 1.0, "method": "avg"},
                                           "impute": {"method": "linear"}}]}),
}

process() returns a tuple of tensors in schema() order: by level, then by role (tokens, values, mask, time, offsets). For example, ((0, "tokens"), (0, "offsets"), (1, "time")) for visits. offsets at level l partitions level-l records into level-l + 1 records.

Changes

pyhealth/processors/sequence_processor_v2.py (new)

  • Configuration (LevelConfig): ordering (set / sequence / series), the number of token namespaces tokens (K) and the value shape values (S). These are fixed by the task, so fit() only learns vocabularies and statistics. The configuration is checked in __init__.
  • Tokens: <pad> = 0 and <unk> = 1 in each namespace. code_mapping maps codes through CrossMap. A mapping where one code has several targets expands records, so it is only allowed on a set level without values.
  • Resampling of a series level onto a time grid (Resample), with first, last, min, max, avg or merge (union of child sets). A step without records is left for imputation.
  • Imputation (Impute), a step or a chain of steps:
    • zero;
    • mean / min / max, dataset statistics of the raw values, per token (by_tokens) and reduced over value axes (by_dims);
    • forward_fill / backward_fill / linear along time, within one group, optionally only from the same token.
  • Normalization (Normalize): zscore or minmax, with the same keys. A token without statistics falls back to the statistics over all tokens.
  • Missing indicator: a bool mask role shaped like values, set where a value was missing before imputation.
  • Batching and checks: collate() packs a batch (concatenates payloads, shifts offsets, appends batch offsets [B + 1]), and validate() checks single-sample and batched outputs.
  • Statistics are streamed in one fit() pass (count, mean and M2 via Chan et al., min, max) and pickle with the processor.

pyhealth/medcode/cross_map.py

  • CrossMap.has_many_targets: whether some code maps to more than one target. The icd-mappings backend never does.

pyhealth/processors/base_processor.py, pyhealth/datasets/sample_dataset.py

  • stores_tensor = True now also covers a tuple of tensors with the same length for every sample. litdata already stores such tuples natively, so SampleBuilder.encode writes them as tensors and no longer pickles them. SequenceProcessorV2 declares it. STORAGE_FORMAT is unchanged: older caches still decode.

pyhealth/datasets/utils.py

  • collate_fn_dict_with_padding(batch, collators=None): a field with a collator is batched only by it, before the (time, values) heuristic. Without collators, behaviour is unchanged.
  • get_dataloader passes each processor's collate() (only SequenceProcessorV2 defines one) via functools.partial, so the collate function still pickles for worker processes.

Docs: docs/api/processors/pyhealth.processors.SequenceProcessorV2.rst, and exports from pyhealth.processors.

Testing

  • tests/core/test_sequence_processor_v2.py: 76 tests covering:
    • configuration errors;
    • the canonical examples (flat codes, codes in visits, three levels, labs with units, multichannel vitals, notes over time, spatial values);
    • unseen tokens and no truncation;
    • code mapping and expansion;
    • every resample, impute and normalize method;
    • missing indicators;
    • collate, validate, pickling, create_sample_dataset round trips;
    • get_dataloader batching through collate().
  • tests/core/test_medcode_icd_mappings.py: has_many_targets.
  • End to end: I ran a synthetic ICU mortality task with five V2 fields and a small model that pools the offsets level by level. The fields were three-level diagnoses merged per day, labs with (test, unit) tokens and an impute chain, hourly-averaged vitals with masks, timed notes, and two-lead ECG windows. It went through a disk-backed SampleDataset, get_dataloader and the unchanged Trainer, and trained successfully.

Not in this PR

  • Model consumption: existing models and interpretability methods read schema(), is_token() and size() with their legacy meanings, so a V2 field cannot be passed to them yet. Adapters (packed_to_padded) and native jagged models are the next PRs in this stack.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Test Coverage

Total: 74.68% (21041 / 28173 lines)

Modified file Coverage Lines
pyhealth/datasets/sample_dataset.py 94.48% 325 / 344
pyhealth/datasets/utils.py 45.31% 87 / 192
pyhealth/medcode/cross_map.py 96.91% 94 / 97
pyhealth/processors/__init__.py 94.29% 33 / 35
pyhealth/processors/base_processor.py 79.73% 59 / 74
pyhealth/processors/sequence_processor_v2.py 100.00% 683 / 683

Workflow run · d623252

@Logiquo
Logiquo marked this pull request as draft October 28, 2026 13:24
@Logiquo
Logiquo changed the base branch from master to feat/yaib-mimic-4-3-tasks October 28, 2026 14:49
@Logiquo
Logiquo added this pull request to stack #1339 October 28, 2026 14:49
Base automatically changed from feat/yaib-mimic-4-3-tasks to master October 30, 2026 06:04
Lets callers check up front whether map() can return more than one target
code, e.g. to reject a code mapping that would duplicate records. The
icd-mappings backend always returns a single target; PyHealth tables are
checked over their entries.
A single processor for nested codes, values and timestamps at any depth,
emitted in CSR layout (payloads plus offsets) without padding or truncation.

- Records: plain-dict columnar input (time, tokens, values, children).
- LevelConfig: per-level ordering, token namespaces K, value shape S,
  code mapping, resampling and spatial axes; validated at construction.
  fit() only builds vocabularies.
- Code mapping through CrossMap; multi-target mappings expand records and
  are only allowed on set levels without values.
- Resampling of series levels onto a time grid with first, last, min, max,
  avg or merge; steps without records are left for imputation.
- collate() and validate() for packed batches.
- normalize, impute and missing_indicator are reserved for future work.
- Impute: a step {"method", "by_tokens", "by_dims"} or a chain of them,
  run after resampling. Methods: zero (0.0 / <pad>), mean / min / max
  (dataset statistics of the raw values, learned in fit(), per token in
  by_tokens and reduced over the value axes in by_dims), forward_fill /
  backward_fill / linear (along time, within one group, optionally only
  from records with the same token). Gaps a step cannot fill keep NaN
  and still raise unless a later step fills them.
- learns_statistics is True when an impute step uses statistics.
- Resampling and imputation now live in private _Resampler and _Imputer
  classes; fit() and process() only call them.
- Normalize: {"method", "by_tokens", "by_dims"} with zscore or minmax,
  run after resampling and imputation. Statistics are learned in fit()
  from the raw values, per token in by_tokens and reduced over the value
  axes in by_dims (default (): one statistic per value position). A token
  without statistics (<unk>, <pad>, or only NaN in training) uses the
  statistics over all tokens; a constant divides by 1.
- _KeyedStatistics holds per-token-key dataset statistics (count, mean,
  M2 via Chan et al., min, max) and is shared by _Imputer and the new
  _Normalizer.
- normalize is no longer reserved; Normalize is exported.
With missing_indicator, a level emits a "mask" role right after
"values": a bool tensor shaped like the values, True where a value was
NaN after resampling and before imputation (resampled steps without
records included). It needs values and impute. collate() and validate()
handle it like any other role. Nothing in the configuration is reserved
any more.
…te()

- stores_tensor now also covers a tuple of tensors with the same length
  for every sample; litdata stores such tuples natively, so
  SampleBuilder.encode writes them as tensors instead of pickling them.
  SequenceProcessorV2 declares stores_tensor = True.
- A processor can define collate(items). collate_fn_dict_with_padding
  takes an optional mapping of per-field collators, checked before the
  (time, values) heuristic, and get_dataloader passes each processor's
  collate for its field. Other fields and loaders are unchanged.
forward_fill, backward_fill and linear reshaped the level's values with
reshape(n, -1), which numpy cannot do when n is 0 (e.g. a sample whose
stays have no lab records). Use the explicit value width instead, and
test every impute method on such a level.
- Records, Resample, Impute, Normalize and LevelConfig get runnable
  '>>>' examples (all module doctests pass).
- collate_fn_dict_with_padding gets an example and list / X | None
  annotations; its import block is sorted.
@Logiquo
Logiquo marked this pull request as ready for review October 30, 2026 06:05
@Logiquo Logiquo added the status: manual Pending maintainer's review label Oct 30, 2026
@Logiquo

Logiquo commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review

Reviewed commit 51f5c7e

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review

AI Review

suggestion: need change
decision: human review

Thank you for this contribution. SequenceProcessorV2 gives one configurable way to represent nested codes, values and timestamps as packed records with no padding or truncation. It also adds the storage and batching support it needs. The tests are thorough, and coverage of the new processor file is 98%.

A maintainer will also take a look. The PR touches shared paths: SampleDataset.encode, get_dataloader, collate_fn_dict_with_padding and the FeatureProcessor base-class docs. Existing behaviour looks unchanged, but these paths are used by every dataset.

To get this merged:

  1. The constructor's multi-target check (around sequence_processor_v2.py:1012) is incomplete. It lets through a set level that has children, and a level with several token namespaces. Such a config builds, then raises on every sample containing a multi-target code. Reject these cases in __init__, or make _map_tokens handle them, and add tests.
  2. The streaming mean/variance statistics (Chan et al.) and the forward-fill logic appear to duplicate what pyhealth/processors/utils.py:fit_normalization and TimeseriesProcessor already provide. Please reuse those where you can. If they can't be reused, explain briefly why in the PR.
  3. Coverage is below the 80% bar for pyhealth/datasets/utils.py (45%) and pyhealth/processors/base_processor.py (about 80%, borderline). The whole repository is at 74%, which clears the 60% bar. Much of the utils.py gap is probably existing code. Please add tests for the new lines you added there, for example collate_fn_dict_with_padding with collators.

Optional suggestions:

  • Time is held as float32, which loses precision for absolute epoch timestamps. Consider float64, or document the limit.
  • The module docstring cites notes/Unified_Sequence_Processor.md, which isn't in the repo. Remove the reference or add the file.
  • SampleBuilder.encode accepts tuples of tensors whose length varies between samples, although the docs require a fixed length. A stricter check, or a note in the docs, would help.

Reviewed commit 51f5c7e

- A one-to-many code_mapping is now rejected in __init__ unless the level
  is a bottom-level 'set' with one token namespace and no values. Before,
  a set level with children or several namespaces was accepted and then
  failed in process() on the first sample with such a code; that runtime
  check can no longer trigger and is removed.
- SampleBuilder.encode checks that a stored tuple of tensors has one
  tensor per schema() entry, so every sample (in every worker) has the
  same structure for litdata.
- SequenceProcessorV2._count drops an unreachable branch, and the module
  docstring no longer points to a design note outside the repo.
- Tests cover the new rejections, collate_fn_dict_with_padding with
  collators, the tuple-length check, and the remaining validate, vocab,
  time and fit error paths.
@Logiquo

Logiquo commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Updated in d5420d8.

Fixed

  1. Multi-target check: a one-to-many code_mapping is now rejected in __init__ unless the level is a bottom-level set with one token namespace and no values. The error lists every rule the config breaks, so a set level with children or a multi-namespace level fails at construction instead of on the first matching sample. The runtime check in _map_tokens can no longer trigger, so it's removed.
  2. Coverage: every line this PR adds is now covered by tests. New tests cover collate_fn_dict_with_padding with collators, the new tuple check in SampleBuilder.encode, and the remaining error paths in SequenceProcessorV2 (validate, vocab, 2-D time, tokens/values length mismatch at fit). An unreachable branch in _count was removed, and sequence_processor_v2.py is now at 100%.
  3. Tuple-of-tensors length: encode now checks that a stored tuple has one tensor per entry of its processor's schema(). Comparing against the first sample wouldn't catch differences across set_task workers, since each worker encodes its own samples. The docs now state the rule this way.
  4. Design-note reference: removed from the module docstring.

Intentionally not changed

  • Duplicated statistics and forward-fill logic: the overlap with fit_normalization and TimeseriesProcessor is deliberate. V1 and V2 are kept separate so they can change independently while V2 is still evolving.
  • Coverage of datasets/utils.py and base_processor.py: the uncovered lines in these files are existing code this PR doesn't touch. The base_processor.py change is docstring-only, and the new lines in utils.py are covered.
  • float32 time: kept on purpose. Time is meant to be relative (e.g. hours from admission), where float32 is exact enough.
  • schema() returning (level, role) keys: known and deferred. No model or interpretability method uses V2 in this PR, so this will be handled in a later PR.

@Logiquo

Logiquo commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review

Reviewed commit d5420d8

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review

AI Review

suggestion: need change
decision: human review

Thank you for this contribution. SequenceProcessorV2 gives PyHealth one configurable, padding-free packed representation for nested codes, values and time. The PR also adds tuple-of-tensor storage, a per-processor collate hook and CrossMap.has_many_targets. The tests are thorough, and the new processor file has 100% coverage.

A maintainer will take a look as well. The change touches shared core paths: SampleDataset.encode (the storage path) and get_dataloader / collate_fn_dict_with_padding (batching). Your description says behaviour is unchanged when no processor defines collate() or returns tuples. A maintainer still needs to confirm that.

I found no text aimed at reviewers.

Coverage: the whole repository is at 74.68%, above the 60% bar. The new processor file is at 100%, sample_dataset.py at 94%, cross_map.py at 97% and processors/init.py at 94%. Two modified files are below the 80% guideline: pyhealth/datasets/utils.py at 45% and base_processor.py at 80% (79.73%, just under). The low figure for utils.py comes mostly from code your PR doesn't touch. Please confirm that the lines you added are covered, in particular the get_dataloader collators branch.

To get this merged:

  1. Normalize fallback for all-NaN tokens. The Normalize docs say a token whose training values were all NaN uses the statistics over all tokens. _KeyedStatistics.lookup() only falls back to the pooled statistics when the key is absent. A token with all-NaN training values has an entry whose mean, std, min and max are NaN. Such a token is therefore either left unnormalized or given the wrong scale. Either make lookup fall back in that case or change the docs, and add a test.
  2. Reuse existing statistics code. _KeyedStatistics re-implements the streaming mean/variance and zscore/minmax logic. Please reuse fit_normalization in pyhealth/processors/utils.py where it fits. If it doesn't fit (per-token keys, the by_dims reduction), say why in a short comment.

Optional suggestions:

  • merge is documented as a union of child sets, but duplicate child tokens appear to be concatenated. Either deduplicate or reword the docs.
  • The docs say forward_fill / backward_fill fill <pad> tokens when by_tokens is None, and linear is documented to do the same. In the code, linear skips the token step. Please align the docs with the code.
  • A level-0 series with resample and no tokens or values yields an empty schema. A configuration error would be clearer.
  • The schema() and spatial() overrides change the base return types. They are marked as such already, but it is worth keeping that in mind for the later model PRs.

Reviewed commit d5420d8

- Normalization by token: each value position uses its token's statistic,
  or, where the token has none, the statistic over all tokens. A token
  whose training values were all NaN (or one value position of it) was
  left unnormalized, although the docs said it falls back like <unk>.
  The fallback is now one rule in _KeyedStatistics.lookup; imputation
  keeps its NaN for a key without a statistic, as documented.
- A resampled level 0 without tokens or values is rejected: resampling
  drops its time, so it would have no output fields.
- Docs: "merge" combines children into one set and keeps repeated
  children, as any set level does; only forward_fill and backward_fill
  fill <pad> tokens, linear leaves them.
@Logiquo

Logiquo commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Updated in 1e96214.

Fixed

  1. Normalize fallback for tokens without finite training values: _KeyedStatistics.lookup now applies one rule: each value position uses its token's statistic, or, where the token has none, the statistic over all tokens. That covers <unk>, <pad>, a token whose training values were all NaN, and a single value position a token never saw with a finite value. Before, such a token got NaN statistics and its values were left unnormalized. Imputation keeps its documented behaviour: a key without a statistic keeps its NaN for a later step. Tests cover the all-NaN token, a partly seen token, a position without any training value, and the imputation case.
  2. Level-0 series with only resample: rejected in configuration parsing, since resampling drops the level's time and leaves no output fields.

Docs aligned with the code

  • merge: now documented as combining the children into one set. Repeated children are kept, as in any set level; the processor never removes duplicate records, and doing so is ill-defined when children carry values or children of their own.
  • Impute.by_tokens: only forward_fill and backward_fill fill <pad> tokens when by_tokens is None. linear leaves them, as a token cannot be interpolated.

Intentionally not changed

  • Statistics duplicated from fit_normalization: as before, V1 and V2 are kept separate so they can change independently. V2 also needs statistics per token key, with a configurable by_dims reduction and a per-position fallback, which fit_normalization does not provide.
  • Coverage of datasets/utils.py and base_processor.py: every line this PR adds is covered, including the get_dataloader collators branch (exercised by TestStorage.test_dataloader_uses_the_processor_collate) and the collators argument of collate_fn_dict_with_padding (tests/core/test_collate.py). The change to base_processor.py is docstring-only.
  • schema() / spatial() overrides: known and deferred to the model PRs later in this stack.

@Logiquo

Logiquo commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed commit 1e96214

for i, kept in zip(filled.tolist(), keep.tolist()):
grid_children[i] = children[kept]
else:
reduce = {"min": np.minimum, "max": np.maximum, "avg": np.add}[self.method]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] avg/min/max resampling propagates NaN and discards valid readings

np.minimum, np.maximum and np.add propagate NaN, so one missing raw reading in a step makes the whole step NaN for that channel. process() accepts raw NaN (the missing-indicator tests rely on it), and the step is then marked missing and imputed, which throws away the other readings in the same step. Fix: reduce with NaN skipped (for example np.fmin/np.fmax for min and max, and a sum over finite values divided by the finite count for avg), and add a test with a NaN reading inside a step.

def _read_time(self, level: int, records: Records) -> np.ndarray:
if "time" not in records:
raise ValueError(f"Level {level}: the 'series' records have no 'time'.")
array = np.asarray(records["time"], dtype=np.float32)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] Time is cast to float32, losing precision for absolute timestamps

Records.time is documented as numbers in the task's own unit, but it is cast to float32 (about 7 significant digits). Unix-second timestamps near 1.7e9 have a float32 spacing of 128 s, so a resample with step 1.0, or linear imputation, collapses distinct records into the same bins and produces wrong results without any error. Fix: keep time as float64 through _read_time and the resampler and emit float64 time (updating the dtype check in validate), or document that time must be relative and add a test with large offsets.

total = n_a + n_b
with np.errstate(invalid="ignore", divide="ignore"):
share = np.where(total > 0, n_b / total, 0.0)
delta = mean_b - mean_a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] Chan mean/M2 merge duplicates the existing fit_normalization helper

This streamed update is the same Chan et al. merge as fit_normalization in pyhealth/processors/utils.py (lines 52-61), which tensor_processor.py and timeseries_processor.py already use. Fix: extract the merge arithmetic into a helper in processors/utils.py that works on per-key running state, and call it from _KeyedStatistics.accumulate, keeping the token-keyed and by_dims logic local to this module.


# FeatureProcessor.schema() returns semantic names for legacy models; V2
# deliberately returns (level, role) keys instead and is not used by them.
def schema(self) -> tuple[FieldKey, ...]: # type: ignore[override]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] schema(), vocab_size() and spatial() overrides change the FeatureProcessor return types

The base class documents schema() as a tuple of semantic names and spatial() as a tuple of bools, and vocab_size() as an int. These overrides return (level, role) keys, dicts and dicts, respectively. The PR says V2 fields cannot reach legacy models yet, but interpretability code tests "mask" not in processor.schema(), which silently takes the non-mask path instead of failing. Consider raising a clear error from these methods for V2 processors, or documenting the divergence on the class.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review

suggestion: need change
decision: human review

Thank you for this contribution. SequenceProcessorV2 gives one configurable processor for nested codes, values and timestamps stored as packed records with no padding or truncation, and the 76 tests and the end-to-end run are thorough. The new processor file has 100% coverage and the whole repository is at 74.68%, above the 60% bar. A maintainer will take a look as well, because the change modifies SampleDataset.encode (the disk-cache write path used by every dataset) and get_dataloader (the shared loader). Both changes look additive for existing processors, but they are core paths.

To get this merged:

  1. Fix the NaN handling in the min/max/avg resampling. These reductions (np.minimum/np.maximum/np.add reduceat) propagate NaN. One NaN reading in a step makes the whole step NaN for that channel and throws away the valid readings. Use NaN-skipping reductions, for example a count of finite values for avg, so only steps with no finite readings stay NaN for imputation. Add a test for this. A test for empty groups would also help.
  2. Fix the float32 time precision. _read_time stores time as float32. Unix-second timestamps (about 1.7e9) lose precision at that size, so the floor(time/step) binning and the linear interpolation can put records in the wrong step. Keep time as float64 during processing, or require and document relative time. The output dtype can stay float32 if the values are relative.
  3. Reuse the existing streaming mean/variance code. The Chan et al. merge in _KeyedStatistics.accumulate appears to duplicate fit_normalization in pyhealth/processors/utils.py. Please reuse that helper, or explain why it cannot cover per-token, per-dimension, min and max statistics.

Optional suggestions:

  • pyhealth/datasets/utils.py is at 45% coverage. The new collators branch and the get_dataloader branch are covered by your tests, but the 80% bar for modified files still applies. Please confirm the uncovered lines were already uncovered before this PR, or add tests for them.
  • pyhealth/processors/base_processor.py is at about 80%, which is borderline.
  • schema(), spatial() and vocab_size() return types that differ from the FeatureProcessor contract. You note this in the PR, which is fine for now.

Reviewed commit 1e96214

@Logiquo
Logiquo requested review from jhnwu3 and solarsys October 30, 2026 12:32
NaN in a value is a missing reading, as in the statistics and the missing
indicators. The min, max and avg reductions propagated it, so one NaN
reading in a step made the whole step NaN for that channel and threw away
the other readings. They now skip NaN, so a step is NaN, and left to
imputation, only where none of its readings has a value.
@Logiquo

Logiquo commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Replies to each point:

  1. NaN in min / max / avg resampling: fixed in d623252. NaN in a value means a missing reading, as in the statistics and the missing indicators, so the reductions now skip it. A step is NaN, and so imputed and marked missing, only where none of its readings has a value. A test covers a NaN reading inside a step and a channel without any reading in a step, for all three methods.

  2. float32 time: intentional. time is meant to be relative, in the task's own unit (e.g. hours since admission), as in the tasks built on this processor. float32 is exact enough at that scale, and absolute epoch timestamps are not a supported input, so time stays float32 throughout.

  3. Statistics duplicated from fit_normalization: intentional, as in the previous round. V1 and V2 processors are kept separate so they can change independently while V2 is still evolving. _KeyedStatistics also needs per-token keys, a configurable by_dims reduction, min/max, and a per-position fallback to the pooled statistics, none of which fit_normalization provides.

  4. schema(), spatial() and vocab_size() return types: known and deferred. No legacy model or interpretability method consumes V2 fields in this PR; their handling comes with the model PRs later in this stack.

  5. Coverage of datasets/utils.py and base_processor.py: every line this PR adds is covered. The uncovered lines in datasets/utils.py were already uncovered before this PR, and the change to base_processor.py is docstring-only.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: manual Pending maintainer's review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant