Repository navigation
Conversation
BaseModel, Trainer, calibration, autoimprove and the interpretability modules each switched on a mode string to decide a label's output size, activation, loss, positive-class weight, how predictions are gathered and which metrics apply. LabelProcessor puts all of that on the processor that defines the label: size(), activation(), loss(), to_numpy(), gather() and metrics_fn(), with pos_weight as a task option of the BCE-based processors. The four static processors and four time-series counterparts (timeseries_multiclass and timeseries_multilabel are new) subclass it. BaseModel exposes label_processors / label_processor and a read-only mode, and drops _resolve_mode, the mode setter, get_output_size, get_loss_function, prepare_y_prob and set_pos_weight; models call the label processor directly.
It forwards to size() for every label processor so far. Models size their output layer with it, so a label whose logits differ from its width only needs to override output_size.
Test CoverageTotal: 77.96% (25015 / 32089 lines)
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
LabelProcessor, a base class for output processors. Everything that depends on the kind of label is defined on the processor that defines the label, not inBaseModel,Trainerand the modules around them.Eight subclasses:
modepos_weightBinaryLabelProcessorbinaryMultiClassLabelProcessormulticlassMultiLabelProcessormultilabelRegressionLabelProcessorregressionTimeseriesBinaryLabelProcessortimeseries_binaryTimeseriesMultiClassLabelProcessor(new)timeseries_multiclassTimeseriesMultiLabelProcessor(new)timeseries_multilabelTimeseriesRegressionLabelProcessortimeseries_regressionThe four static processors do not subclass each other. Binary and multilabel share only a small helper for the optional
pos_weight. Each time-series processor stores an instance of its static counterpart (TimeseriesLabelProcessor[B]) and applies it to every label record. They no longer subclassSequenceProcessorV2. They parse{"time", "values"}and pack batches (collate) themselves, in the same(values, time, offsets)format as before. They were added in #1337 and are not in a release, so they move totimeseries_label_processor.pywithout a compatibility import.1. Why unify them
The kind of a label (binary, multiclass, ...) decided seven things, and each was decided somewhere else, by switching on a mode string:
BaseModel.get_output_size)BaseModel.prepare_y_prob, anif/elifover six modes)BaseModel.get_loss_function, anotherif/elif, plus_label_record_lossfor time-series labels)BaseModel.set_pos_weight, with hard-coded("binary", "multilabel")checks inBaseModeland autoimprove)Trainer.inference, aper_stepbranch)trainer.get_metrics_fn, anif/elifover six modes)BaseModel._resolve_mode, which walked each processor class's MRO against the registry names; autoimprove's_label_modeand the interpretability metrics had their own copies)The processor is the one place that knows what its label is. Spreading that knowledge has a cost: adding a label kind meant editing all of these places, and they could disagree. For example, adding the time-series labels needed new branches in
BaseModel,TrainerandRNN, and #1358 adds another one toTrainer.inference. WithLabelProcessor, a new label kind is one class, andBaseModelandTrainerdo not change.2. The methods, and why each moves to the processor
mode(class attribute)BaseModel._resolve_mode, autoimprove_label_mode,metrics/interpretability/base.pyoutput_schema={"y": "binary"}still selectsBinaryLabelProcessor.model.modeis now a read-only property that reads it.size()FeatureProcessorconvention: 1 for binary and regression, the number of classes or labels otherwise. It is now declared abstract onLabelProcessor, so every label processor must have it.output_size()BaseModel.get_output_sizeget_output_sizeonly forwarded to the processor'ssize(), andoutput_size()does the same for every label processor so far. It is a separate method so that a label whose logits differ from its width only overridesoutput_size(), without touching the models. Models callself.label_processor.output_size().activation(logits)BaseModel.prepare_y_probloss(logits, y_true)BaseModel.get_loss_function,_label_record_loss(values, time, offsets)that the processor itself builds. The per-record shape check and the zero loss for a batch without label records are inTimeseriesLabelProcessor.loss.pos_weight(constructor option) andweightBaseModel.set_pos_weight/pos_weight, autoimprove'spos_weightsearch parameter, ProtoECGNet'sset_pos_weightoverride"balanced"is a statistic of the training labels, which is exactly whatfitcomputes. A"balanced"processor setslearns_statistics, so the existing warning fires when processors are fitted on all samples rather than on a training split. Set it in the schema:("binary", {"pos_weight": "balanced"}), or a number, or one number per label for multilabel.to_numpy(y_true, y_prob)/gather(...)Trainer.inference'sper_stepbranchmetrics_fn(y_true, y_prob, metrics)(class method)trainer.get_metrics_fnget_metrics_fn(mode)stays, as a registry lookup, for calibration, autoimprove and existing callers.labels=option (multiclass and multilabel)GradientBoostedTreesraising when a class has no training samplesBaseModelgainslabel_processors(the output processors that areLabelProcessors, by key) andlabel_processor(the single one, or a clear error). Models that score something other than their dataset's label overridelabel_processors:{}, so the trainer reports only their loss, as before.Removed from
BaseModel:_resolve_mode, themodesetter,get_output_size,get_loss_function,prepare_y_prob,set_pos_weight,pos_weight,_label_record_lossandVALID_MODES. These are public, andset_pos_weightshipped in 2.1.0. There are no compatibility aliases.examples/and the tutorial notebooks are left unchanged and still callset_pos_weight.Other behaviour changes:
model.moderaisesAttributeError.ValueErrorinstead ofKeyError.pos_weightis no longer in autoimprove's training search space, since it is now a task option.TemperatureScalingandDirichletCalibrationno longer casty_truewithlong if mode == "multiclass" else float. The arrays keep the dtype the processor emitted (long for multiclass, float otherwise), so the cast changed nothing.3. Files
Call-site changes only (29 files). These only rename
self.get_output_size()→self.label_processor.output_size(),self.get_loss_function()(...)→self.label_processor.loss(...)andself.prepare_y_prob(...)→self.label_processor.activation(...):pyhealth/calib/calibration/logistic_recalibration.py,pyhealth/models/{adacare, agent, biot, cnn, concare, contrawr, deepr, ehrmamba_cehr, gnn, gradient_boosted_trees, graphcare, grasp, heartlang, jamba_ehr, llm_ehr_encoder, logistic_regression, mlp, retain, sparcnet, stagenet, stagenet_mha, tcn, tfm_tokenizer, torchvision_model, transformer, transformer_deid, transformers_model}.py,tests/core/test_gnn.pyThree more get the same renames, plus the
>>>docstring example the PR rules require of a modified public class:pyhealth/models/{califorest, ehrmamba, graph_torchvision_model}.py.Real changes:
pyhealth/processors/label_processor.py:LabelProcessorand the four static processors.pyhealth/processors/timeseries_label_processor.py: new; the four time-series processors.pyhealth/processors/timeseries_processor.py: the old time-series label classes are removed.pyhealth/processors/__init__.py: exports.pyhealth/models/base_model.py:label_processors,label_processorand the read-onlymode; the removed methods.pyhealth/trainer.py:inferenceandevaluatedelegate to the label processor;get_metrics_fnis a registry lookup.apn.py,vae.py,trajsurv.py,survival_mdn.py: overridelabel_processorsinstead of settingself.mode.protoecgnet.py:class_weightsdefaults to the task'spos_weight; theset_pos_weightoverride is removed.rnn.py: RNN, MultimodalRNN and RNN_V2 detect per-step labels withisinstance(..., TimeseriesLabelProcessor)instead of a mode tuple.medfuse.py,molerec.py,safedrug.py: remove their own mode resolution.pyhealth/calib/calibration/{dircal, temperature_scale}.py: the redundant dtype casts are removed.pyhealth/interpret/methods/base_interpreter.py,pyhealth/metrics/interpretability/base.py: read the mode from the label processor.pyhealth/autoimprove/{improver, evaluate, space}.py: the label mode comes from the processor;pos_weightis no longer searched.docs/api/models.rstanddocs/api/processors.rst: updated.LabelProcessorand the two new time-series processors; updated forTimeseriesLabelProcessor.tests/core/test_label_processor.py.test_pos_weight,test_legacy_mode_resolution,test_model_mode.label_record_model_utils.pytest_apntest_autoimprovetest_gimandtest_ig_gim: the mock datasets get a realBinaryLabelProcessor.test_interp_metricstest_per_step_labelstest_protoecgnettest_timeseries_modestest_trajsurv4. Prediction types not covered yet (planned follow-ups)
These tasks and models exist in the repo but do not fit one of the eight label processors yet. They keep working as before: their models compute their own loss, and where a model has no label processor, the
Trainerreports only the loss.survival_mimic4({"duration": "tensor", "event": "tensor"}), TrajSurv, SurvivalMDNTrainer.evaluatereports only the loss, althoughmetrics/survival.pyhas the c-index, IPCW Brier score and cumulative/dynamic AUC.SurvivalLabelProcessorfor one(duration, event)label whosemetrics_fnissurvival_metrics_fn. The loss differs by model (Cox partial likelihood, mixture density, trajectory), so the models would keep their own loss.physionet2012_forecasting({"y": "tensor"}, NaN where a value is not observed), APNRegressionLabelProcessorhas one output, so APN defines its own_ForecastLabelwith a masked MSE.deid_ner({"labels": TextProcessor}), TransformerDeIDcross_entropywithignore_indexitself.timeseries_multiclass, with token positions in place of times and padding for positions without a label. Deferred until a second task needs it.patient_linkage_mimic3(output_schema = {}), MedLink,metrics/ranking.pyTraineron in-batch pairs. It is evaluated outside it: the corpus is encoded and searched, results are restricted to each query's candidates (filter_by_candidates), then scored withranking_metrics_fn.RankingLabelProcessor("ranking"): per query,{"candidates": [...], "relevance": [...]}, packed like the time-series labels as(relevance [ΣC], candidate [ΣC], offsets [B + 1]).output_size()is 1 (one score per candidate). The loss is softmax cross-entropy within each query's group (MedLink's in-batch retrieval loss is the case where the candidates are the batch's positives).metrics_fngives NDCG, MAP, Recall and P at k per query. The packing (collate, splitting by offsets) moves into a helper shared with the time-series processors, rather than ranking subclassing them. Searching the full corpus stays model API (encode_queries,encode_corpus,search), and its results are scored with the samemetrics_fn. Adopting it needs the linkage task to emit candidates and relevance per query.label_processorswith a regression processor (in this PR). The others report only their loss.label_processorswith the existing processor that matches the input:RegressionLabelProcessorfor an image,MultiLabelProcessor(labels=<input vocabulary>)for a multi-hot code set,TimeseriesMultiClassLabelProcessor(labels=<vocabulary size>)for a code sequence (position as time). The models keep their own loss (reconstruction plus KL, or adversarial). Two pieces are planned: a constructor that builds the processor from the input processor's vocabulary, so label indices equal token ids; and packed(tokens, position, offsets)asy_truefor sequences, sinceto_numpysees onlyy_trueandy_prob.label_processorscan already hold several, butTrainerandmodel.modehandle only a model with one label.Trainer. This is aTrainerchange, not a new processor.Generation (sampling new patients: MedGAN, CorGAN, HALO, GPT-2, PromptEHR, Synthea) is out of scope because it has no per-sample label. The generators train with their own
train_modelloops,generate(num_samples)returns records rather than tensors, and quality is judged on the whole dataset bymetrics/generative(privacy, utility and prevalence against real train and test data). Supporting it properly needs its own interface for generative models (training plusgenerate) and an evaluator aroundevaluate_synthetic_ehr, not a label processor. Their tasks keepoutput_schema = {}.Testing
Focused suite. The tests of the changed modules (852 tests: processors, the models above, trainer, calibration, interpretability and autoimprove) pass locally, except two that also fail on
masterhere:test_trajsurv's state-dict round trip: a CPU/CUDA device mismatch.test_transformers_model.TestChunking.test_windows_need_a_fast_tokenizer.test_survival_mdnimports pytest, which is not installed in this environment, so it did not run locally.New tests in
tests/core/test_label_processor.py:pos_weight, including"balanced"learned infitand its leak warninglabels=option, and pickles from before the new optionscollate, the per-record loss,to_numpy/gatherand pooled metricsTrainerrun for all eight modesCoverage. All added lines in the processors,
BaseModel,Trainerand the other real changes above are covered.Other checks.
tools/check_pr_rules.pypasses. Pyright reports no errors in the two processor modules, and no new errors in the other changed files.