wikillm

Paper · 2022-05-19 · cs.CV

A graph-transformer for whole slide image classification

GTP classifies lung whole slide images as normal, LUAD or LSCC. Contrastively trained ResNet18 patch features become nodes of an 8-neighbour graph; one graph-convolution layer and min-cut pooling reduce it to 100 clusters, which a 3-block transformer reads. GraphCAM gives class-specific region maps. Author-reported: 91.2% accuracy (5-fold CV, CPTAC) and 82.3% on external TCGA.

Graph transformerGraph neural networkContrastive learningFeature attributioncomputational-pathologyinterpretabilitywhole-slide-images

arXivPDFCodeMarkdown16 min read

AI-compiled by claude-haiku-5-5 on 2026-11-06, not yet reviewed by a human. Every claim cites its source; check it when a detail matters.

In plain words

A whole slide image is a microscope scan of a tissue sample, so large that it holds thousands of small tiles. The task here is to label the whole slide as normal lung tissue, lung adenocarcinoma, or lung squamous cell carcinoma. Most earlier methods looked at tiles one at a time, or gave every tile the slide's label, which adds noise.

The method keeps the layout. Each tile becomes a point described by a feature vector from a network that was first trained, without labels, to recognise that two differently altered versions of the same tile belong together. Tiles that touch on the slide are linked, so the slide becomes a graph. The graph is first smoothed by neighbours, then squeezed into a smaller set of groups, and a transformer reads those groups to produce one label. A companion method, GraphCAM, colours the slide to show which regions pushed the decision towards each label, so a pathologist can check the result.

The authors report strong accuracy on their main data and a drop on a second, external dataset. The numbers below are theirs.

TL;DR

GTP is a graph convolution, min-cut pooling and a small transformer stacked on features from a contrastive-learning ResNet18. It reaches 91.2% three-class accuracy on CPTAC under 5-fold cross-validation and 82.3% on TCGA. GraphCAM gives class-specific heatmaps. The official code differs from the paper in several training and architecture details, listed under Paper vs code.

Problem

Patch-level models give every tile the slide label, which injects label noise, and they miss how tiles relate across the slide §1. Transformers on all tiles would need quadratic memory in the number of tiles, and a slide can contain thousands of them §1 §2.2. ImageNet pretraining is common, but the paper's ablation finds ImageNet features weaker than its contrastive features §1 tab. 3. The paper uses slide-level labels from CPTAC, TCGA and NLST §2.1 tab. 1.

Method

The pipeline has three stages: patch feature extraction, graph construction with graph-transformer inference, and GraphCAM for explanation fig. 1.

Patch features. Each slide is cut into non-overlapping 512 × 512 patches at 20× magnification, and patches that are more than half background are discarded §3.1. A ResNet18 is trained with a contrastive loss (NT-Xent): two random augmentations of the same patch form a positive pair, and the other 2K − 1 views in the mini-batch are negatives §2.2 eq. 1 fig. 1c. A projection MLP sits on top during training; the 512-d backbone output is used as the node feature §2.2 fig. 1c. NLST data (about 1.8 million patches) is used only for this stage §3.

Graph construction. Each retained patch is a node. Two nodes are joined by an edge when their patches are neighbours in the 8-neighbourhood on the slide, giving a binary adjacency matrix A with at most 8 ones per row §2.2 fig. 1b.

Graph-transformer. Three pieces run in sequence §2.2:

  1. One graph-convolution (GC) layer propagates features over the graph using the normalised adjacency with self-loops eq. 2a–2b.
  2. A dense learned assignment S maps the N nodes onto a fixed number of clusters, following min-cut pooling. Pooled features are S-transposed times the node features; the pooling layer has its own min-cut and orthogonality losses that are added to the classification loss §2.2 fig. 1a.
  3. A learnable class token is prepended to the pooled cluster tokens and a vision-transformer encoder processes them eq. 3a–3d eq. 4a–4c. No positional embedding is added; spatial information enters only through the adjacency used in the GC layers and pooling §2.2. The class token, after a layer norm, gives the prediction eq. 5.
Pseudocode
# GTP forward pass for one WSI  [§2.2] [eq. 2a-2b] [eq. 3a-3d] [eq. 5]
# Input:  features (N, 512), adjacency (N, N) of 0/1 (8-neighbour graph)
# Output: logits (3,), training loss (scalar)

def gtp_forward(features, adjacency, label):
    # Add self-loops and symmetric-normalise the adjacency  [eq. 2b]
    loops = adjacency + identity(N)
    degree = diagonal(row sums of loops)
    adj_hat = inv_sqrt(degree) @ loops @ inv_sqrt(degree)
    # One graph-convolution layer with ReLU  [eq. 2a] [§3.1]
    # Code: no ReLU and no degree normalisation  [code: models/gcn.py#L364-L372]
    hidden = relu(adj_hat @ features @ weight_gc)       # (N, 64)
    # Dense soft assignment of the N nodes to 100 clusters  [§2.2]
    assign = softmax(hidden @ weight_s, axis=1)         # (N, 100)
    pooled = assign.T @ hidden                          # (100, 64)
    # Pooling losses; the paper gives no formula  [§2.2]
    # Code: PyG dense_mincut_pool  [code: models/GraphTransformer.py#L67]
    cut_loss, ortho_loss = mincut_terms(adjacency, assign)
    # Prepend the learnable class token  [§2.2] [eq. 4a]
    tokens = concatenate(class_token, pooled)           # (101, 64)
    # Three pre-norm blocks, 8 heads, MLP width 128  [§3.1] [eq. 4b-4c]
    for block in range(3):
        tokens = tokens + attention(layer_norm(tokens)) # head size 8
        tokens = tokens + mlp(layer_norm(tokens))       # GELU in code
    # Read out the class token  [eq. 5]
    # Code adds one linear head after the layer norm  [code: models/ViT.py#L338]
    logits = linear_head(layer_norm(tokens[0]))
    loss = cross_entropy(logits, label) + cut_loss + ortho_loss
    # Code: unit weights on both pooling terms  [code: models/GraphTransformer.py#L76]
    return logits, loss

Contrastive pretraining. The loss for one positive pair (i, j) among the 2K augmented views is the negative log-probability of the positive, with cosine similarity scaled by a temperature τ §2.2 eq. 1:

Pseudocode
# NT-Xent loss for one positive pair  [§2.2] [eq. 1]
# z: L2-normalised projections of the 2K augmented views of a mini-batch
# Output: scalar loss for the pair (i, j)

def pair_loss(z, i, j, tau):
    # Scaled cosine similarity of view i to every other view  [eq. 1]
    scores = [dot(z[i], z[k]) / tau for k in range(2 * K) if k != i]
    # Negative log-probability of the positive view j  [eq. 1]
    return logsumexp(scores) - dot(z[i], z[j]) / tau

The paper computes the loss over both (i, j) and (j, i) in a mini-batch §2.2.

GraphCAM. GraphCAM adapts the transformer relevance method of Chefer et al. §2.3. For each attention block l, it takes the gradient of the attention map and the layer relevance, multiplies them element-wise, averages over heads, adds the identity, and multiplies the block matrices together eq. 6a–6b. The resulting transformer relevance is pushed back to the graph nodes through the pooling assignment S and drawn onto the slide using the adjacency and patch coordinates §2.3.

Pseudocode
# GraphCAM for one class  [§2.3] [eq. 6a-6b]
# Input:  per block l: attention A_l (heads, T, T), gradient dA_l,
#         relevance R_l; T = 101 tokens; assign S (N, 100)
# Output: relevance per patch, shape (N,)

def graphcam_patch_map(attn, grad_attn, relevance, assign):
    block_maps = []
    for layer in range(L):
        # Head-averaged Hadamard product plus identity  [eq. 6b]
        # Code clamps the product at zero first  [code: models/ViT.py#L382]
        mixed = mean_over_heads(grad_attn[layer] * relevance[layer])
        block_maps.append(mixed + identity(T))
    # Product of block maps; code multiplies later blocks on the left  [eq. 6a]
    rollout = product(block_maps)
    # Class-token row gives cluster relevances  [code: models/ViT.py#L385]
    cluster_relevance = rollout[0, 1:]
    # Spread cluster relevance back to patches with the assignment  [§2.3]
    return assign @ cluster_relevance

Pitfall: the paper describes the relevance rules only by reference to Chefer et al., but the official code implements its own relevance propagation for each module (linear, attention, layer norm, add, clone), which the paper does not specify ViT.py.

Hyperparameters

Values are as stated in the paper unless marked "code". Where the paper is silent, the table says so. Citations point to the paper or the official code at the commit in the front matter.

NameValueWhereNotes
patch size and magnification512 × 512 px at 20×§3.1Non-overlapping. Code resizes patches to 224 × 224 before feature extraction build_graphs.py; the paper does not say this.
background cut-offdiscard if non-tissue > 50%§3.1Tiling default -B 50 tile_WSI.py.
backboneResNet18§3.1Code: no pretrained weights; InstanceNorm2d resnet_simclr.py.
projection headMLP, 2 dense layersfig. 1Training only. Code: 512 → 512 → 512, ReLU between resnet_simclr.py.
feature extractor optimiserAdam, initial LR 1e-4§3.1Code hard-codes 1e-5 simclr.py.
feature extractor LR schedulecosine annealing§3.1Code: constant for 10 epochs, then cosine with T_max = 20 simclr.py simclr.py.
feature extractor mini-batch512§3.1Code config: 256 config.yaml.
feature extractor epochsnot stated—Code: 20 config.yaml.
temperature τnot statedeq. 1Code: 0.5 config.yaml.
feature extractor weight decaynot stated—Code: 1e-5 config.yaml.
augmentationscolour distortion, Gaussian blur, random crop with resize§2.2 fig. 1Code: colour jitter (0.8, 0.8, 0.8, 0.2), grayscale p = 0.2, blur kernel 13, horizontal flip, resize to 224. The random crop is commented out dataset_wrapper.py.
validation split (contrastive)not stated—Code: 10% of patches held out config.yaml.
node feature dimension512§2.2Output of the ResNet18 before projection.
adjacency8-neighbourhood, binary, no self-loops§2.2 fig. 1bCode: neighbours with
GC layers1§3.1Table 4 top row lists 3 GCN layers tab. 4. Code: 1 GraphTransformer.py.
GC width64§2.2 §3.1Code: embed_dim 64 GraphTransformer.py.
min-cut clustersnot stated in §3.1tab. 4Table 4 top row, which the paper reports as best, uses 120. Code default: 100 GraphTransformer.py.
transformer blocks L3§3.1Code default depth 3 ViT.py.
heads k8§3.1
head size D_h8§2.2D / k. Code scale 1/√8 ViT.py.
MLP width128§3.1Code: mlp_ratio 2 gives 128 ViT.py ViT.py.
GTP optimisernot stated for GTP—Code: Adam, weight decay 5e-4 main.py.
GTP mini-batch8§3.1Code drops the last incomplete batch main.py.
GTP training length150 "iterations"§3.1Code: 120 epochs option.py.
GTP learning rate1e-3, then 1e-4 at step 30, then 1e-5 at step 100§3.1Code: milestones at epochs 20 and 100, gamma 0.1 main.py.
GTP lossnot written§2.2Code: cross-entropy + min-cut + orthogonality GraphTransformer.py.
checkpoint selectionnot stated—Code keeps the epoch with best validation accuracy main.py.
GraphCAM—§2.3Code: class maps times predicted probability, clipped to [0.4, 1] vis_graphcam.py vis_graphcam.py.

Table 4 hyperparameter ablation (all rows trained and tested on a 70/30 split of CPTAC; accuracy on the held-out CPTAC cases, from tab. 4):

HiddenGCN layersTransformer blocksMin-cut nodesPatch sizeConnectivityAccuracy
1283312051280.925
1283310051280.915
128338051280.903
1281310051280.908
1283610051280.894
1281610051280.919
128168051280.906
1281612051280.898
1281312051280.911
643312051280.908
641310051280.903
641610051280.913
643310051280.901
643610051280.901
1283312051240.896
12833120512 with 10% overlap80.881
12833120102480.864
12833120360 (batch size 2)80.898

Results

Author-reported. Setup: three-class task (normal, LUAD, LSCC). Training on CPTAC (2,071 WSIs) with 5-fold cross-validation; TCGA (2,082 WSIs) is the external test set, with the same hyperparameters §3 tab. 1.

Headline numbers §1 tab. 2:

SettingMetricBaselineThis paperSource
CPTAC, 5-fold CVaccuracyTransMIL 85.9 ± 0.7; AttPool 81.9 ± 2.191.2 ± 2.5tab. 2
CPTAC, 5-fold CVAUCTransMIL 96.1 ± 0.3; AttPool 92.5 ± 1.697.7 ± 0.9tab. 2
TCGA, externalaccuracyTransMIL 71.6 ± 2.3; AttPool 79.3 ± 2.382.3 ± 1.0tab. 2
TCGA, externalAUCTransMIL 88.0 ± 0.7; AttPool 91.3 ± 1.192.8 ± 0.3tab. 2

Full accuracy and AUC table, including the graph-only variant GTP* (no transformer) tab. 2:

MethodDataAccuracy (%)AUC (%)
TransMILCPTAC85.9 (0.7)96.1 (0.3)
TransMILTCGA71.6 (2.3)88.0 (0.7)
AttPoolCPTAC81.9 (2.1)92.5 (1.6)
AttPoolTCGA79.3 (2.3)91.3 (1.1)
GTP* (only GCN)CPTAC83.0 (2.7)95.2 (1.2)
GTP* (only GCN)TCGA72.4 (4.9)86.6 (3.8)
GTPCPTAC91.2 (2.5)97.7 (0.9)
GTPTCGA82.3 (1.0)92.8 (0.3)

Per-class precision, recall and specificity for GTP (mean, SD in parentheses; tab. 2 panel a; the baseline rows are in the same table):

MethodDataPrecision NormalPrecision LUADPrecision LSCCRecall NormalRecall LUADRecall LSCCSpecificity NormalSpecificity LUADSpecificity LSCC
GTPCPTAC93.2 (3.0)88.4 (3.9)87.8 (3.0)95.9 (2.2)83.9 (4.5)89.2 (4.0)96.2 (1.7)94.7 (1.9)93.8 (1.7)
GTPTCGA89.2 (2.8)74.4 (2.7)84.4 (0.7)92.6 (2.7)79.8 (1.9)75.2 (1.6)94.7 (1.6)86.0 (2.3)92.7 (0.4)

Baselines in the same panel, in order of the table's rows: TransMIL CPTAC precision 89.7 / 81.0 / 87.1, recall 90.4 / 81.2 / 85.9; TransMIL TCGA precision 76.6 / 64.3 / 80.4, recall 87.3 / 75.8 / 55.6; AttPool CPTAC precision 88.4 / 77.1 / 80.6, recall 85.9 / 78.0 / 81.6; AttPool TCGA precision 89.1 / 69.9 / 81.4, recall 87.8 / 79.4 / 71.3; GTP* CPTAC precision 82.9 / 81.6 / 86.5, recall 93.6 / 74.4 / 80.3; GTP* TCGA precision 72.5 / 69.1 / 82.0, recall 90.7 / 57.7 / 69.9 tab. 2.

DeLong test, log10 p-value of AUC differences (threshold log10(0.05) = −1.301) tab. 2 panel c:

MethodDatalog10(p)
TransMILCPTAC−1.578 (0.853)
TransMILTCGA−5.627 (2.263)
AttPoolCPTAC−2.305 (1.250)
AttPoolTCGA−2.068 (1.339)
GTP* (only GCN)CPTAC−1.759 (1.129)
GTP* (only GCN)TCGA−5.373 (3.146)

Feature-extractor ablation, accuracy and AUC tab. 3 panel b:

Feature extractorCPTAC accuracyCPTAC AUCTCGA accuracyTCGA AUC
ResNet18, ImageNet (no fine-tune)75.4 (2.0)88.9 (0.4)44.0 (2.8)60.4 (3.0)
ResNet18, fine-tuned on NLST patches with WSI labels (supervised)80.8 (1.1)92.2 (0.9)59.6 (1.1)76.2 (0.8)
Convolutional autoencoder80.2 (1.6)93.2 (0.9)54.7 (2.2)72.9 (1.6)
Contrastive, STL-1091.4 (1.1)98.0 (0.9)71.9 (1.9)86.4 (1.6)
Contrastive, NLST (used by GTP)91.2 (2.5)97.7 (0.9)82.3 (1.0)92.8 (0.3)

Per-class precision, recall and specificity for the feature-extractor ablation are in tab. 3 panel a; they are not copied here.

GraphCAM §4 fig. 4 fig. 5: maximum IoU against the pathologist's annotation was 0.857 at a threshold of 0.6 for one LUAD slide and 0.733 at 0.2 for one LSCC slide (CPTAC). On 20 TCGA slides (10 LUAD, 10 LSCC), the mean of the per-slide maximum IoU was 0.817 ± 0.165. GraphCAMs were consistent across the five cross-validation models fig. 5; in failure cases they were not interpretable fig. 6.

Compute §3.2 §3.3: PyTorch 1.9.0 on one NVIDIA 2080 Ti (11 GB). Training ran about 2.4 iterations per second and converged in under a day. Inference took under one second per slide at batch size 2. Training the transformer on raw patches, without the graph component, ran out of memory above 4,760 patches at batch size 1 on that card §3.2.

Paper vs code

Official code: github.com/vkola-lab/tmi2022 at commit 0d395a160a969578fa5ce66a8a7f0fd0817712b4, MIT licence, from the Kolachalama laboratory (the paper's corresponding group) LICENSE. The paper links it §2.4. The repository has no k-fold script and does not ship the split files train_set.txt, val_set.txt or test_set.txt, so the cross-validation protocol cannot be reproduced from the repository alone.

Differences found:

  • GC layer. Paper uses ReLU and the symmetric normalised adjacency with self-loops eq. 2a–2b. Code adds self features to the raw adjacency sum, applies a linear map, L2-normalises each node, then batch-normalises, with no ReLU gcn.py GraphTransformer.py.
  • GC layers. Paper text says one GC layer §3.1; Table 4 top row lists 3 tab. 4; code uses 1 GraphTransformer.py.
  • Min-cut clusters. Table 4 top row (best) uses 120 tab. 4; code default is 100 GraphTransformer.py.
  • Pooling losses. Paper describes a custom min-cut loss without a formula §2.2; code sums cut and orthogonality losses with unit weight into the classification loss GraphTransformer.py.
  • Training length and schedule. Paper: 150 "iterations", LR 1e-3 with decay at 30 and 100 §3.1. Code: 120 epochs, MultiStepLR milestones at 20 and 100, gamma 0.1 option.py main.py. A comment in main.py reads "20,90,130 -> 150", which suggests earlier schedules.
  • Optimiser for GTP. Paper names Adam only in the feature-extractor paragraph §3.1; code uses Adam with weight decay 5e-4 main.py.
  • Checkpoint selection. Code saves the epoch with the best validation accuracy main.py. The paper does not describe a validation split.
  • Feature-extractor optimiser. Paper: Adam, LR 1e-4 §3.1. Code: Adam at 1e-5, hard-coded simclr.py.
  • Feature-extractor batch size. Paper: 512 §3.1. Code config: 256 config.yaml.
  • Augmentations. Paper mentions random cropping with resizing §2.2. Code's random crop is commented out and replaced by a fixed resize; horizontal flip is added dataset_wrapper.py.
  • Temperature, epochs, weight decay. Not in the paper; code uses τ = 0.5, 20 epochs, weight decay 1e-5 config.yaml config.yaml config.yaml.
  • Feature-extractor schedule. Code: constant LR for 10 epochs, then cosine with T_max = 20 simclr.py. The paper says cosine annealing only.
  • Input resolution. Code resizes 512 px patches to 224 px for both contrastive training and graph building build_graphs.py. The paper does not mention this step.
  • Normalisation. Code initialises ResNet18 without pretrained weights and uses InstanceNorm2d resnet_simclr.py. Paper is silent on initialisation and normalisation.
  • Transformer details. Code uses GELU in the MLP, LayerNorm eps 1e-6 in blocks, no query/key/value bias, and a linear head after the final layer norm ViT.py ViT.py ViT.py. Paper is silent on the activation and gives eq. 5 without a head.
  • Feature weight loading. build_graphs.py loads the contrastive weights by pairing state-dict entries by position with strict=False build_graphs.py. The pairing depends on key order and may map projection-head weights onto the classifier. I did not run it.
  • Cohort counts. Paper Table 1: CPTAC 2,071 WSIs (719 / 667 / 685 normal / LUAD / LSCC) and TCGA 2,082 WSIs (534 / 808 / 740) tab. 1. The shipped data/cptac.txt lists 2,082 slides (734 / 669 / 679) and data/tcga.txt lists 1,346 (420 / 476 / 450) cptac.txt tcga.txt. Which subset was used is not established.
  • README cohort size. The README mentions 3,024 WSIs. The paper gives 4,818 across CPTAC, TCGA and NLST, with NLST (665) used only for contrastive pretraining tab. 1 §2.1.
  • Feature extractor weights. A pretrained extractor is shipped as feature_extractor/model.pth model.pth. I did not check it against the paper's training or its reported numbers.
  • GraphCAM. Paper gives no clamp, no class-probability scaling, and no row choice. Code clamps at zero before averaging heads ViT.py, reads the class-token row ViT.py, multiplies each class map by the predicted probability clipped to [0.4, 1] vis_graphcam.py vis_graphcam.py, and skips patches in the first 5 rows and the last 5 columns and rows vis_graphcam.py.
  • Annotation and IoU. The repository has the expert annotations as image files but no IoU or binarisation code. The annotation readme says only that expert annotations were added for CPTAC readme. The reported IoU thresholds cannot be checked from the repository.

Pitfalls

  • The "best" hyperparameter row is not the main result. Table 4 reports 92.5% accuracy on a 70/30 split of CPTAC with 3 GC layers and 120 clusters. The main 5-fold result is 91.2% tab. 2 tab. 4. Do not mix them.
  • Which GC depth? The paper says one GC layer in the text and three in the best row of Table 4. The code uses one. Start from one and treat three as an ablation unless the authors confirm otherwise.
  • Min-cut loss weights. The paper names a pooling loss but gives no formula or weight. The code's unit weights are the only specification available.
  • Normalisation choice matters for reproduction. The paper's ReLU and symmetric normalisation are absent from the code. Pick one deliberately and report which.
  • Validation leakage risk. Selecting the checkpoint by validation accuracy is standard, but the paper does not say which split was used. If the validation split is the CPTAC test fold, reported test numbers are optimistic.
  • Splits are not in the repository. Fold membership, the train/val/test lists and the patch-level holdout for contrastive training are absent. Build your own, and state it.
  • STL-10 versus NLST on CPTAC. Table 3 panel b shows STL-10 slightly above NLST on CPTAC accuracy and AUC (91.4 vs 91.2, 98.0 vs 97.7). NLST is clearly better on TCGA (71.9 vs 82.3 accuracy). The text's "significantly better" claim §4 should be read for the external test set.
  • Patch graph size and memory. Slides can exceed 4,760 patches. A transformer over all patches ran out of memory on an 11 GB card §3.2, which is why pooling is needed. Pooling to 100 clusters keeps the transformer cheap, but the GC layer and the dense assignment still scale with N.
  • Overlap versus non-overlap. A 10% overlap lowered CPTAC accuracy to 0.881 in Table 4 tab. 4. Non-overlapping tiles are the default in the paper and the code.
  • GraphCAM is per slide. The README notes it handles one slide at a time [README]. Each patch value is a soft-assignment mix of the 100 cluster relevances, so the map's resolution is set by the cluster count.
  • GraphCAM has no fixed binarisation. The paper reports IoU at several thresholds and gives the best value per slide §4. Choose a threshold on held-out slides, not on the annotated ones.
  • Relevance rules are code-only. Reproducing GraphCAM means implementing the relevance propagation in models/ViT.py and models/layers.py, which the paper does not describe.
  • Generalisation is weaker externally. Accuracy falls from 91.2% (CPTAC CV) to 82.3% (TCGA) tab. 2. The paper's own TCGA normal recall is 92.6%, but LSCC recall is 75.2%, and the paper says the model confused LSCC and LUAD on TCGA §4.

Lineage

  • Builds on: contrastive learning with NT-Xent and a projection head §2.2; graph convolution §2.2 eq. 2a–2b; min-cut pooling §2.2; the Vision Transformer encoder §2.2 eq. 3a–3d eq. 4a–4c; Chefer et al.'s transformer relevance propagation §2.3.
  • Compared against: TransMIL §3.2 tab. 2; AttPool §3.2 tab. 2; ResNet18 with and without fine-tuning and a convolutional autoencoder as feature extractors tab. 3.
  • Superseded by: not stated in the paper; no later work is referenced here.

Source and license

Adapted from “A graph-transformer for whole slide image classification” by Yi Zheng, Rushin H. Gindra, Emily J. Green, Eric J. Burks, Margrit Betke, Jennifer E. Beane, Vijaya B. Kolachalama (arXiv:2205.09671), used under CC BY 4.0. Changes: condensed and restructured for implementation, with tables, equations and algorithms taken from the paper and its code, each cited to its source. Not affiliated with or endorsed by the authors. Code citations refer to https://github.com/vkola-lab/tmi2022 at commit 0d395a160a96 (MIT). Any quoted code is shown under that license.