Representation Quality¶
A model's headline accuracy conflates two things: how good its representation is, and how good the head fitted on top of it is. Freezing the encoder and fitting nothing but a logistic regression separates them. Running that probe on a corpus the encoder never saw asks the question the embeddings exist to answer — whether an architecture learns transferable ECG structure, or a shortcut peculiar to one dataset.
from deepecgkit.evaluation import cross_dataset_transfer
matrix = cross_dataset_transfer(
{"af-classification": "runs/af/best.pt", "ptbxl": "runs/ptbxl/best.pt"},
model_name="kanres",
)
print(matrix.to_frame())
deepecg probe -m kanres -o probe/ \
--source-checkpoint af-classification=runs/af/best.pt \
--source-checkpoint ptbxl=runs/ptbxl/best.pt
Each source may be a checkpoint file or a run directory.
Probe encoders at the rate they were trained at
The protocol resamples every dataset to one rate, 300 Hz by default. An encoder trained at
another rate is then measured on inputs it never saw, and the matrix reports that as poor
transfer. deepecg train uses 300 Hz for the AF datasets and 500 Hz for PTB-XL unless told
otherwise, so train the PTB-XL encoder with --sampling-rate 300 (single-lead) for this
protocol, or pass --sampling-rate to the probe to match your checkpoints. The command warns
when a source checkpoint records a different rate, and when it records none, which is the
case for checkpoints from earlier releases; those were trained at 500 Hz.
Standardisation is part of the protocol¶
Probe features are standardised inside the estimator pipeline, not before it:
from deepecgkit.evaluation import build_probe
build_probe()
# Pipeline([("scaler", StandardScaler()), ("probe", LogisticRegression())])
Results without this step are not comparable and should not be reported. Embedding scales differ
by orders of magnitude across the registered architectures: most CNNs return non-negative
post-ReLU activations, convnext-v2 and mamba are LayerNorm'd, crnn, gru and lstm are
tanh-bounded, and xresnet sits near zero at initialisation. Logistic regression applies one L2
penalty to every coefficient, so on unstandardised features that penalty means something different
for each architecture, and the solver may not converge at all on the wide-range ones. The comparison
then measures embedding scale as much as embedding quality.
Keeping the scaler as a pipeline step, rather than standardising up front, is what makes it fit on
the training split alone and merely apply to the held-out split. Standardising the two together
leaks the held-out distribution into the fit. embed_2d standardises for the same reason before
projecting.
Do not compare probe scores fitted without the scaler
A probe on unstandardised embeddings is not a weaker version of this protocol, it is a different measurement. Numbers from the two cannot be placed in the same table.
The linear probe¶
linear_probe fits on one split's embeddings and reports on another's. It never touches the
encoder's weights.
from deepecgkit import FeatureExtractor
from deepecgkit.evaluation import probe_encoder
extractor = FeatureExtractor.from_checkpoint("runs/af/best.pt", model_name="kanres")
result = probe_encoder(extractor, data_module, target="af-classification")
result.score # the headline metric
result.metrics # every metric, keyed by name
result.task # binary, multiclass or multilabel
result.n_train, result.n_test
Four metrics are computed for every probe: auroc_macro (the default headline), accuracy,
balanced_accuracy and f1_macro. Macro AUROC leads because it is the only one defined identically
for all three label shapes in this library — binary, multiclass one-vs-rest, and PTB-XL's
multi-label — and because it is threshold-free, so it does not reward a probe for predicting the
majority class.
Multi-label targets are fitted one-vs-rest and scored per label. They are deliberately not put
through calculate_classification_metrics, which takes the argmax of any two-dimensional
prediction; that is the right reading for a softmax and the wrong one for independent sigmoids.
A degenerate split — one class in the training half, or a label column with no positives — yields
NaN for the affected metric and a note on the result rather than an exception, so one bad cell
does not abort a whole matrix.
The cross-dataset transfer matrix¶
Rows are the corpora the frozen encoders were trained on, columns are the corpora they were probed on. The diagonal is the in-domain probe; everything off it is transfer.
matrix.to_frame()
# af-classification ptbxl mitbih-afdb ltafdb
# random-init 0.512 0.601 0.498 0.520
# af-classification 0.884 0.702 0.771 0.755
# ptbxl 0.690 0.912 0.664 0.671
The probe head is refitted for every column. It has to be: the label spaces differ — four classes for PhysioNet 2017, five multi-label diagnostic superclasses for PTB-XL, four for MIT-BIH AFDB and LTAFDB — so no head can be carried across them. What transfers, or fails to, is the representation.
The protocol does not train anything. It consumes checkpoints that deepecg train already produced,
one per source corpus.
Every corpus is run single-lead¶
Transfer only means anything if every encoder sees the same input, so the protocol pins all four corpora to one lead at one sampling rate:
| Dataset | Native leads | Under this protocol |
|---|---|---|
af-classification |
1 | 1, unchanged |
mitbih-afdb |
2 available | 1, its constructor default |
ltafdb |
2 available | 1, its constructor default |
ptbxl |
12 | 1, pinned to lead II and resampled |
Three of the four already yield a single lead at their defaults, so only PTB-XL is constrained, via
PROTOCOL_DATASET_KWARGS. Every cell then feeds an identical 1 x 3000 tensor and
input_channels=1 holds throughout.
afmodel cannot be probed under this protocol
At 300 Hz over 10 seconds every architecture accepts the input except afmodel, which needs at
least 7971 samples. No sampling rate satisfies both afmodel and the attention architectures,
which cap at 5000, so the two cannot share a protocol. See the length table in
Feature Extraction.
Splits come from ECGDataModule, so its group-aware splitting keeps a subject's records out of both
halves of the probe.
The random-init baseline¶
The first row probes an untrained encoder built by build_model under the run's seed. It is the
floor for every column, and it is not optional decoration.
A probe score means nothing on its own. Much of what a linear head recovers from a trained convolutional encoder it also recovers from a random one, because a random convolutional stack is already a bank of filters and a global pool. A cell at 0.88 next to a floor of 0.51 is evidence; the same 0.88 next to a floor of 0.86 is not. Reporting probe numbers without this row is the most common way the measurement gets misread, and it is usually omitted.
matrix.to_frame().loc["random-init"] # the floor for each target
matrix.to_frame().T # transpose for sources across the top
Pass include_random_init=False (or --no-random-init) to drop it, and seed= to choose which
random network the floor describes. The same seed reproduces the same floor exactly.
Embedding visualisation¶
embed_2d standardises, reduces wide embeddings by PCA, then projects with scikit-learn's t-SNE.
method="pca" is the cheap alternative. There is no UMAP dependency.
Held-out embeddings are kept only when asked for, since keeping all of them costs memory proportional to the corpus:
diagonal retains the in-domain cells together with the random-init row, which is the pairing worth
looking at: the same target seen through a trained encoder and through an untrained one. all
retains every cell, none is the default. embedding_for returns None for a cell that was not
retained.
from deepecgkit.evaluation import embed_2d, plot_embedding
features = matrix.embedding_for("ptbxl", "ptbxl")
plot_embedding(
embed_2d(features.features),
features.labels,
class_names=matrix.class_names["ptbxl"],
save_path="embedding.png",
)
t-SNE requires a perplexity below the sample count; embed_2d clamps it rather than letting
scikit-learn reject a small split. Multi-hot labels are coloured by their first positive label.
Output¶
TransferMatrix.save dispatches on the suffix. The .json form keeps every metric of every cell
plus the resolved protocol settings; .csv holds the headline table alone.
For the manuscript, deepecgkit.analysis.latex.transfer_matrix_table emits a booktabs table in the
same style as the other result tables. Nothing is bolded: a single probe score carries no interval,
so marking a best cell would assert a comparison the numbers cannot support.
from deepecgkit.analysis.latex import transfer_matrix_table
transfer_matrix_table(matrix.to_frame(), metric=matrix.metric, baseline_row="random-init")
Lead counts that cannot be recovered¶
deep-res-cnn, gru, lstm and transformer do not record their lead count anywhere a state dict
can be read from, so input_channels must be passed explicitly. The protocol's default of 1 is
already correct for the single-lead configuration; pass --input-channels if you deviate from it.
See Feature Extraction for the full table.