Skip to content

fix(kg_emb): migrate SampleKGDataset off the removed 1.x dataset API - #1202

Open
AxelNoun wants to merge 12 commits into
sunlabuiuc:masterfrom
AxelNoun:fix/kg-emb-2.0-migration
Open

fix(kg_emb): migrate SampleKGDataset off the removed 1.x dataset API#1202
AxelNoun wants to merge 12 commits into
sunlabuiuc:masterfrom
AxelNoun:fix/kg-emb-2.0-migration

Conversation

@AxelNoun

@AxelNoun AxelNoun commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Closes #952. Supersedes #1192. Part of #1201.

What the rename does not reach

#1192 renames SampleBaseDataset to SampleDataset across 7 files. The rename
clears the ImportError, but the module stays unusable: since 2.0,
SampleDataset is a litdata.StreamingDataset whose constructor expects a
directory containing schema.pkl, while SampleKGDataset.__init__ still passes
a list of samples, an argument the rename does not touch.

>>> SampleKGDataset(samples=[{"triple": (0, 0, 1)}], entity2id={}, relation2id={})
ValueError: dir_path must be either a string, Path, or Dir, got: <class 'list'>
  litdata.streaming.resolver._resolve_dir

self.samples is then never assigned, so __getitem__ and stat() raise
AttributeError, and BaseKGDataset.set_task() cannot return anything usable.
The failure moves from import time to call time.

To be fair to the author: #952 explicitly asked for the rename across eight
files, and #1192 does exactly that. The gap is in how the issue was framed.

Root cause

SampleKGDataset inherited from SampleBaseDataset to reuse __len__ and
.samples. It required none of the parent's behavioural contract and used none
of the services the hierarchy exists to provide: a knowledge graph has no
feature schema, no processors and no patient/visit index. The inheritance was
convenience reuse, not an is-a relationship.

The 2.0 migration did not introduce this. It removed the coincidence that made
it harmless, namely the overlap between the interface actually used and the one
the parent exposed. Adapting SampleKGDataset to the streaming contract would
mean satisfying a constraint the module has no use for, so removing the
inheritance is the minimal fix rather than the drastic one.

What this PR does

SampleKGDataset becomes a standalone torch.utils.data.Dataset.

Models depend on a structural typing.Protocol (PEP 544) instead of a concrete
class. KGEBaseModel.__init__ only ever reads entity_num, relation_num and
task_spec_param, so both the old and the new annotation over-specified the
contract, and both were wrong: SampleDataset has neither attribute.
KGDatasetProtocol states the capability actually required. The annotation
becomes checkable by mypy, the model layer becomes testable with lightweight
doubles, and kg_emb stops being coupled to changes in the main pipeline. That
last point is the durable one: a rename would have held until the next
SampleDataset refactor.

Also in scope:

  • split() no longer overwrites NumPy's global random state, and validates its
    input with ValueError rather than assert, which is stripped under
    python -O and should not be load-bearing for user input validation. Note:
    for a given seed=, this returns a different partition than the previous
    implementation (different RNG algorithm). There is no prior release of this
    module, so no existing caller depends on the old ordering.
  • The four models' __main__ examples imported SampleKGDataset from
    pyhealth.datasets, where it has never existed. They now use
    torch.utils.data.DataLoader with collate_fn_dict_with_padding directly,
    since get_dataloader calls set_shuffle and is streaming-only.
  • The pandarallel import, of a package never declared in pyproject.toml, is
    removed rather than added to the dependencies, since no parallel_apply
    exists anywhere in kg_emb. There is no lockfile entry to regenerate.
  • base_kg_dataset.py and umls.py imported their siblings through the
    absolute package path, so the package only initialised correctly for one
    ordering of the imports in __init__.py, which nothing enforced. Enabling
    ruff's isort rule would have sorted base_kg_dataset first and broken it.

Tests

tests/core/test_kg_emb.py, 22 behavioural tests: construction, indexing,
vocabularies, cardinality validation, split partitioning and reproducibility,
generic collation of variable-length ground truths, BaseKGDataset.set_task()
on a synthetic graph, and scoring invariants (DistMult symmetry in head and
tail, TransE margin on an exact triple).

No test asserts on a type annotation: annotations are metadata, and under
PEP 563 such an assertion compares against a string.

Every test was validated by fault injection. Reverting the fix it protects makes
it fail:

Test Mutation Caught
test_construction_and_length restore super().__init__(samples, dataset_name, task_name) yes
test_is_a_map_style_dataset add a fake set_shuffle yes
test_set_task_on_a_synthetic_graph drop **kwargs from the SampleKGDataset(...) call yes
test_global_numpy_state_is_untouched restore np.random.seed plus shuffle yes
test_variable_length_ground_truth_stays_a_python_list tensorise ground_truth_head in __getitem__ yes
... (22/22, full table on request) yes

These are hand-seeded mutants aimed at this PR's changes, not a systematic
mutation run, so 22/22 is not a mutation score in the usual sense. It supports a
weaker and sufficient claim: a rename-only fix cannot make this suite go green.

Scope and limits

  • This decouples kg_emb from the 2.0 pipeline; it does not migrate it into
    the streaming pipeline. If you would rather see the latter, the dataset layer
    here is isolated and tested and would be the starting point.
  • Correctness of the plumbing, not empirical validation: no MRR or Hits@k
    compared against the original papers. Verified on synthetic graphs only, as
    UMLS requires a licence.
  • get_dataloader remains streaming-only. kg_emb no longer depends on it, so
    this is not blocking here; tracked in PyHealth 2.0: several modules still target the 1.x dataset API #1201 together with the other
    modules still targeting the 1.x API.

Credit to @userjuma for the original diagnosis in #1192. The import chain
described there is accurate and was the starting point for this work.

AxelNoun and others added 8 commits August 25, 2026 10:46
SampleDataset is now a litdata.StreamingDataset that expects schema.pkl. A knowledge-graph task is an in-memory list of triples, so SampleKGDataset subclasses torch.utils.data.Dataset and exposes KGDatasetProtocol for the models.

Co-authored-by: Cursor <cursoragent@cursor.com>
KGE models only need entity_num, relation_num and task_spec_param. Annotate that structural contract with KGDatasetProtocol so the model layer no longer imports the removed SampleBaseDataset.

Co-authored-by: Cursor <cursoragent@cursor.com>
SampleKGDataset was never exported from pyhealth.datasets. The examples now import it from kg_emb.datasets and build a torch DataLoader with collate_fn_dict_with_padding, because get_dataloader requires litdata.StreamingDataset.set_shuffle().

Co-authored-by: Cursor <cursoragent@cursor.com>
Validate ratios with ValueError so the check survives python -O, and shuffle with a local Generator so the function no longer mutates global NumPy state.

Co-authored-by: Cursor <cursoragent@cursor.com>
pandarallel was never declared in pyproject.toml or pixi.lock. The undeclared import in umls.py and base_kg_dataset.py is what produced the ModuleNotFoundError on the kg_emb import path in issue sunlabuiuc#952. initialize() ran in umls.py with no parallel_apply in kg_emb; mimicextract's parallel_apply calls are unreachable on the empty BaseEHRDataset stub. There is no lockfile entry to regenerate.

Co-authored-by: Cursor <cursoragent@cursor.com>
Cover construction, split reproducibility, generic collation of variable-length ground truths, set_task on a synthetic graph, and scoring invariants. Tests instantiate SampleKGDataset so a rename-only fix cannot go green.

Co-authored-by: Cursor <cursoragent@cursor.com>
Document the map-style SampleKGDataset path in the MedCode API page and add a synthetic TransE example that uses DataLoader instead of get_dataloader.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace double-hyphen asides in five docstrings with periods or commas so they remain readable in a terminal and under Sphinx.

Co-authored-by: Cursor <cursoragent@cursor.com>
SampleKGDataset failed to satisfy its own KGDatasetProtocol under mypy:
the Protocol declared task_spec_param as a plain attribute
(Mapping[str, Any] | None), which Protocol treats as read-write and
therefore invariant, while SampleKGDataset declares it as
dict[str, Any] | None. Models only ever read task_spec_param, so
declare it as a read-only property instead: read-only Protocol members
are covariant, and a concrete dict satisfies it.
Each model's __main__ block builds an untyped list of dict literals
and then adds a "train" key with a bool value, which mypy rejects
because it infers the dict's value type from the first literal.
Annotate samples as list[dict[str, Any]] in all four demo blocks.
@AxelNoun
AxelNoun marked this pull request as draft August 29, 2026 16:46
@AxelNoun

Copy link
Copy Markdown
Contributor Author

Update: after discussing direction with @jhnwu3, the target is to keep
SampleKGDataset inside the SampleDataset hierarchy rather than decoupling from it,
with in-memory loading as an interim step for negative sampling. Moving this to draft
while I rework it, and suggesting #1192 merge first as the unblocking fix for #952.

Carrying over regardless of the base class: the split() fixes, the constructor
raising AttributeError on its own default entity2id=None, stat() returning
None, the phantom SampleKGDataset import in the models' __main__ blocks, and the
test suite. Not carrying over: KGDatasetProtocol and the standalone Dataset.

@AxelNoun

AxelNoun commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Architecture & Implementation Plan Update

Just dropping a quick update here to keep a clear record of the architectural decisions we aligned on via Discord, which will guide the next commits for #1202:

1. Base Architecture (In-Memory)
As discussed, SampleKGDataset will inherit directly from InMemorySampleDataset. This keeps us correctly under the SampleDataset umbrella while cleanly sidestepping the immediate complexities of handling negative sampling in a pure streaming setup.

2. Data Shape & Serialization (The "Tensor Trick")
To handle the KG triples and the variable-length entity lists (ground_truth_head / ground_truth_tail), we are going to avoid slow JSON/Pickle serialization. I dug into the tuple_time_text_processor.py and will adapt their pattern to keep the litdata backend happy:

  • I will implement a custom KGProcessor.
  • fit() phase: Calculate the global max_length of the entity lists.
  • process() phase: Pad the variable-length lists and convert everything directly into pure PyTorch tensors before serialization.

Next Steps:
I will be working on drafting the KGProcessor and the base class integration tonight. I'll push the new commits once the core pipeline is running so we have concrete code to review!

AxelNoun and others added 2 commits August 31, 2026 22:46
…the Tensor Trick

Per the architecture pivot agreed in the PR discussion: SampleKGDataset moves
back under InMemorySampleDataset instead of standalone torch.utils.data.Dataset,
while keeping its full public surface (entity2id/relation2id, cardinality
validation, split(), stat(), dev/task_spec_param) unchanged.

- Add KGProcessor ("kg_entity_list"), pre-padding ground_truth_head/tail to
  each field's own max length and emitting {"value", "mask"} pure tensors
  ahead of litdata's pickle-based caching, instead of raw variable-length
  Python lists. "triple" goes through the existing "tensor" processor.

- Fix a correctness issue the padding introduces: pad_token_id (0) is not a
  reserved sentinel and can collide with a real entity id. kg_base.py's
  train_neg_sample_gen and test_neg_sample_filter_bias_gen now reconstruct
  the exact unpadded entity list via the mask (_unpad_ground_truth) before
  doing set-membership filtering, so negative sampling and filtered ranking
  stay correct whenever entity 0 is legitimate.

- Add a nested-dict collation branch to collate_fn_dict_with_padding,
  restricted to all-tensor dicts, so {"value","mask"} pairs batch via a
  plain stack (shape is already uniform per field) without disturbing the
  existing list-of-dicts collation used by heterogeneous per-sample dicts
  such as "hyperparameters".

- Update tests/core/test_kg_emb.py for the new shapes: triple is now a
  Tensor (not a tuple), ground_truth_* collate to {"value","mask"}, and
  set_shuffle is now expected (SampleKGDataset is intentionally back under
  the SampleDataset umbrella). Adds TestGroundTruthUnpadding, a regression
  test for the padding/entity-0 collision fix above.

24/24 tests in tests/core/test_kg_emb.py pass, plus the sample_kg_dataset.py
and splitter.py doctests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mple)

The contribution-rules check (tools/check_pr_rules.py) failed on 5f66ff4,
something I hadn't run locally before pushing — only pytest, not ruff or
the repo's own gate. It enforces ruff-clean added/modified lines plus a
'>>>' doctest on every new/modified top-level public class or function.

- kg_processor.py: replace typing.Dict/List/Iterable with PEP 585 builtins
  and collections.abc.Iterable (ruff UP035/UP006, target-version py313).
  Entirely new file, so every line was in scope.
- datasets/utils.py: add a runnable '>>>' example to
  collate_fn_dict_with_padding's docstring, since the function body was
  modified (the nested all-tensor-dict branch) and had none.

Verified against the same tooling and base/head SHAs CI used
(0a75f99..<new commit>): `python tools/check_pr_rules.py --base --head`
now reports "All PR contribution rules passed." tests/core/test_kg_emb.py
still 24/24, plus the collate_fn_dict_with_padding doctest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@AxelNoun

Copy link
Copy Markdown
Contributor Author

CI/CD Green & Ready for Review

Everything is fully green! I've completed the implementation of the architectural plan we discussed:

  • Reverted SampleKGDataset to correctly inherit from InMemorySampleDataset.
  • Integrated the KGProcessor (using the "Tensor Trick" with independent max-length padding) to bypass the litdata serialization bottlenecks.
  • Ensured negative sampling correctness in kg_base.py by safely reconstructing the unpadded entity lists via attention masks prior to np.in1d filtering.
  • Passed all local repo linting rules (Ruff Python 3.13 typing updates and newly required doctests).

The PR is out of draft and ready for your final review whenever you have time!

@AxelNoun
AxelNoun marked this pull request as ready for review August 31, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: kg_emb broken import in PyHealth 2.0

1 participant