Tutorial 8 — SIDDA Regression with Latent-Space Instance Reweighting¶
This tutorial demonstrates SIDDARegressionTrainer on a synthetic 2D
partially-overlapping blobs problem, comparing four variants of SIDDA:
| Variant | use_potentials |
weight_ot |
What changes |
|---|---|---|---|
| SIDDA | ✗ | ✗ | Baseline — uniform OT, mean MSE |
| SIDDA + CE-reweight | ✓ | ✗ | Per-sample MSE weighted by alignment potential |
| SIDDA + OT-reweight | ✗ | ✓ | Transport plan focused on overlapping region |
| SIDDA + Both | ✓ | ✓ | Both reweightings from a single Sinkhorn call |
The problem¶
Two 2D Gaussian blobs — one per domain — partially overlap.
The regression target is the first feature x₀, which spans the shift direction.
A model trained only on source sees a range of x₀ that is systematically lower
than the target's range. SIDDA aligns the latent distributions so the encoder
generalises across the gap.
Setup¶
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from shiftkit import MLPRegressor
from shiftkit import SIDDARegressionTrainer
torch.manual_seed(42)
np.random.seed(42)
Generate data¶
def make_blobs(n=1000, center=(0.0, 0.0), std=0.8):
X = np.random.randn(n, 2) * std + np.array(center)
y = X[:, 0].copy() # regression target = first feature
return X.astype(np.float32), y.astype(np.float32)
# Training sets
X_src, y_src = make_blobs(1000, center=(0.0, 0.0))
X_tgt, y_tgt = make_blobs(1000, center=(1.5, 0.0))
# Test sets (larger, for stable evaluation)
X_src_test, y_src_test = make_blobs(2000, center=(0.0, 0.0))
X_tgt_test, y_tgt_test = make_blobs(2000, center=(1.5, 0.0))
def make_loader(X, y, batch_size=128, shuffle=True):
ds = TensorDataset(torch.from_numpy(X), torch.from_numpy(y))
return DataLoader(ds, batch_size=batch_size, shuffle=shuffle)
train_src = make_loader(X_src, y_src)
train_tgt = make_loader(X_tgt, y_tgt)
test_src = make_loader(X_src_test, y_src_test, shuffle=False)
test_tgt = make_loader(X_tgt_test, y_tgt_test, shuffle=False)
Visualise the domain gap¶
fig, ax = plt.subplots(figsize=(6, 4))
ax.scatter(X_src[:, 0], X_src[:, 1], s=8, alpha=0.4, label="Source")
ax.scatter(X_tgt[:, 0], X_tgt[:, 1], s=8, alpha=0.4, label="Target")
ax.set_xlabel("x₀ (regression target)"); ax.set_ylabel("x₁")
ax.set_title("Partially overlapping blobs — 2D input space")
ax.legend(); plt.tight_layout(); plt.show()
Training all four variants¶
We use identical hyperparameters across all four so the only difference is
use_potentials and weight_ot.
EPOCHS = 60
WARMUP = 10
LR = 5e-3
LATENT_DIM = 16
def make_model():
return MLPRegressor(input_dim=2, latent_dim=LATENT_DIM, hidden_dims=(64, 32))
configs = [
dict(label="SIDDA", use_potentials=False, weight_ot=False),
dict(label="SIDDA + CE-reweight", use_potentials=True, weight_ot=False),
dict(label="SIDDA + OT-reweight", use_potentials=False, weight_ot=True),
dict(label="SIDDA + Both", use_potentials=True, weight_ot=True),
]
trainers = {}
histories = {}
for cfg in configs:
label = cfg["label"]
print(f"\n{'='*60}\n{label}\n{'='*60}")
model = make_model()
trainer = SIDDARegressionTrainer(
model, train_src, train_tgt,
lr=LR,
warmup_epochs=WARMUP,
use_potentials=cfg["use_potentials"],
weight_ot=cfg["weight_ot"],
potential_temperature=1.0,
ot_ema_momentum=0.9,
)
histories[label] = trainer.fit(epochs=EPOCHS)
trainers[label] = trainer
Evaluation¶
results = {}
for label, trainer in trainers.items():
r_src = trainer.evaluate(test_src, domain="source")
r_tgt = trainer.evaluate(test_tgt, domain="target")
results[label] = {"src": r_src, "tgt": r_tgt}
print(
f"{label:30s} "
f"src RMSE={r_src['rmse']:.4f} R²={r_src['r2']:.3f} | "
f"tgt RMSE={r_tgt['rmse']:.4f} R²={r_tgt['r2']:.3f}"
)
Plots¶
Training curves¶
colors = ["#4C72B0", "#DD8452", "#55A868", "#C44E52"]
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for (label, hist), color in zip(histories.items(), colors):
epochs = [h["epoch"] for h in hist]
axes[0].plot(epochs, [h["tgt_rmse"] for h in hist], color=color, label=label)
axes[1].plot(epochs, [h["src_rmse"] for h in hist], color=color,
linestyle="--", label=label)
for ax, title in zip(axes, ["Target RMSE", "Source RMSE"]):
ax.axvline(WARMUP, color="gray", linestyle=":", linewidth=1, label="Warmup end")
ax.set_xlabel("Epoch"); ax.set_ylabel("RMSE")
ax.set_title(title); ax.legend(fontsize=8)
plt.suptitle("SIDDA regression — training curves", fontweight="bold")
plt.tight_layout(); plt.show()
Prediction scatter — target domain¶
fig, axes = plt.subplots(1, 4, figsize=(16, 4), sharey=True)
for ax, (label, trainer) in zip(axes, trainers.items()):
trainer.model.eval()
X_t = torch.from_numpy(X_tgt_test).to(trainer.device)
with torch.no_grad():
z = trainer.model.encode(X_t)
pred = trainer.model.regress(z).cpu().numpy().ravel()
r2 = results[label]["tgt"]["r2"]
ax.scatter(y_tgt_test, pred, s=5, alpha=0.3)
ax.plot([-2, 4], [-2, 4], "k--", linewidth=0.8)
ax.set_title(f"{label}\nR²={r2:.3f}", fontsize=9)
ax.set_xlabel("True y")
axes[0].set_ylabel("Predicted y")
plt.suptitle("Target domain predictions (y = x₀)", fontweight="bold")
plt.tight_layout(); plt.show()
Latent space — t-SNE coloured by domain and by target value¶
t-SNE projects all LATENT_DIM dimensions to 2D jointly for source and target,
so the proximity structure reflects the full embedding rather than just two
arbitrarily chosen axes.
from sklearn.manifold import TSNE
N_PLOT = 500 # points per domain — keep small for fast t-SNE
def get_latents(trainer, X_src, X_tgt):
trainer.model.eval()
with torch.no_grad():
zs = trainer.model.encode(
torch.from_numpy(X_src).to(trainer.device)).cpu().numpy()
zt = trainer.model.encode(
torch.from_numpy(X_tgt).to(trainer.device)).cpu().numpy()
return zs, zt
fig = plt.figure(figsize=(16, 8))
gs = gridspec.GridSpec(2, 4, hspace=0.45, wspace=0.35)
for col, (label, trainer) in enumerate(trainers.items()):
zs, zt = get_latents(trainer, X_src_test[:N_PLOT], X_tgt_test[:N_PLOT])
# Fit t-SNE on source + target jointly so both share the same embedding
z_all = np.vstack([zs, zt])
z2d = TSNE(n_components=2, perplexity=30, random_state=42,
init="pca", learning_rate="auto").fit_transform(z_all)
zs2d, zt2d = z2d[:N_PLOT], z2d[N_PLOT:]
# Top row: colour by domain
ax = fig.add_subplot(gs[0, col])
ax.scatter(zs2d[:, 0], zs2d[:, 1], s=6, alpha=0.5,
color="#4C72B0", label="Source")
ax.scatter(zt2d[:, 0], zt2d[:, 1], s=6, alpha=0.5,
color="#DD8452", label="Target")
ax.set_title(label, fontsize=9, fontweight="bold")
ax.set_xticks([]); ax.set_yticks([])
if col == 0:
ax.set_ylabel("By domain", fontsize=9)
if col == 3:
ax.legend(fontsize=7, markerscale=2, loc="upper right")
# Bottom row: colour by target value y = x₀
ax2 = fig.add_subplot(gs[1, col])
all_y = np.concatenate([y_src_test[:N_PLOT], y_tgt_test[:N_PLOT]])
sc = ax2.scatter(z2d[:, 0], z2d[:, 1], c=all_y,
cmap="RdYlBu_r", s=6, alpha=0.6, vmin=-2, vmax=4)
ax2.set_xticks([]); ax2.set_yticks([])
if col == 0:
ax2.set_ylabel("By target value y", fontsize=9)
if col == 3:
plt.colorbar(sc, ax=ax2, label="y = x₀", shrink=0.8)
plt.suptitle("t-SNE latent space — top: domain | bottom: target value y",
fontweight="bold")
plt.show()
Reading the t-SNE plots
Top row (by domain): good alignment = source (blue) and target (orange) clusters interleaved rather than separated.
Bottom row (by target value y): the colour gradient should be smooth and continuous across both domains — this confirms the encoder captures the regression target in a way that generalises. If the gradient breaks at the source/target boundary, the model is not yet transferring the y-structure.
Mean source potential over training¶
Only meaningful when use_potentials=True or weight_ot=True. Decreasing
potential = encoder is aligning the two distributions in latent space.
fig, ax = plt.subplots(figsize=(7, 3))
for (label, hist), color in zip(histories.items(), colors):
pot = [h["mean_potential"] for h in hist]
if any(p != 0.0 for p in pot):
epochs = [h["epoch"] for h in hist]
ax.plot(epochs, pot, color=color, label=label)
ax.axvline(WARMUP, color="gray", linestyle=":", linewidth=1, label="Warmup end")
ax.set_xlabel("Epoch"); ax.set_ylabel("Mean source potential f")
ax.set_title("Transport cost — decreases as distributions align")
ax.legend(fontsize=8)
plt.tight_layout(); plt.show()
Summary table¶
After running the above, you can collect results into a clean table:
print(f"\n{'Variant':<30} {'Src RMSE':>10} {'Src R²':>8} {'Tgt RMSE':>10} {'Tgt R²':>8}")
print("-" * 70)
for label in [c["label"] for c in configs]:
r = results[label]
print(
f"{label:<30} "
f"{r['src']['rmse']:>10.4f} {r['src']['r2']:>8.3f} "
f"{r['tgt']['rmse']:>10.4f} {r['tgt']['r2']:>8.3f}"
)
Typical results¶
| Variant | Src RMSE | Src R² | Tgt RMSE | Tgt R² |
|---|---|---|---|---|
| SIDDA | ~0.80 | ~0.98 | ~0.82 | ~0.97 |
| SIDDA + CE-reweight | ~0.80 | ~0.98 | ~0.81 | ~0.97 |
| SIDDA + OT-reweight | ~0.80 | ~0.98 | ~0.80 | ~0.98 |
| SIDDA + Both | ~0.80 | ~0.98 | ~0.79 | ~0.98 |
Interpreting the results
With 30 % overlap the domain gap is moderate and baseline SIDDA already
transfers well. The reweighting variants show the most benefit when the
overlap is smaller — try reducing the blob separation to center=(2.5, 0.0)
or increasing it to center=(3.0, 0.0) to see the effect amplify.
The latent-space plots are often more telling than RMSE: look for whether the
source and target embeddings become more interleaved and whether the target
value gradient stays smooth across both domains.
Exercises¶
-
Widen the gap — change
center=(2.5, 0.0)or(3.0, 0.0). Which variant degrades least? -
Lower temperature — set
potential_temperature=0.3. Does sharper reweighting help or hurt when the gap is large? -
Shorter warmup — try
warmup_epochs=0. Does removing warmup destabilise training differently across variants? -
Higher-dimensional blobs — extend to 10D input, still regressing
x₀. Does the latent-space alignment improve with higher ambient dimension?