In this tutorial, we implement a Gin Config–controlled PyTorch experiment pipeline in which the executable training code remains stable. At the same time, the experimental degrees of freedom are moved into declarative configuration files. We construct a nonlinear spiral binary classification task, define a configurable MLP with scoped architectural variants, and expose parameters for the optimizer, scheduler, loss, batching, seeding, and training loop via @gin.configurable bindings. We use Gin’s scoped references to instantiate separate model configurations, runtime bindings to override selected parameters without editing source code, and operative config export to capture the exact resolved configuration that produces each training run. Installing Gin Config and Building the Spiral Dataset Copy CodeCopiedUse a different Browser !pip -q install gin-config import os import json import math import random import textwrap from pathlib import Path import gin import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import TensorDataset, DataLoader import matplotlib.pyplot as plt ROOT = Path(“/content/gin_config_sharp_tutorial”) CONFIG_DIR = ROOT / “configs” RUN_DIR = ROOT / “runs” CONFIG_DIR.mkdir(parents=True, exist_ok=True) RUN_DIR.mkdir(parents=True, exist_ok=True) gin.clear_config() @gin.configurable def seed_everything(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) return seed @gin.configurable def make_spiral_dataset( n_per_class=gin.REQUIRED, noise=0.18, rotations=1.75, train_fraction=0.8, seed=0, ): rng = np.random.default_rng(seed) radius_0 = np.linspace(0.05, 1.0, n_per_class) theta_0 = rotations * 2 * np.pi * radius_0 theta_0 += rng.normal(0.0, noise, size=n_per_class) x0 = np.stack( [ radius_0 * np.cos(theta_0), radius_0 * np.sin(theta_0), ], axis=1, ) radius_1 = np.linspace(0.05, 1.0, n_per_class) theta_1 = rotations * 2 * np.pi * radius_1 + np.pi theta_1 += rng.normal(0.0, noise, size=n_per_class) x1 = np.stack( [ radius_1 * np.cos(theta_1), radius_1 * np.sin(theta_1), ], axis=1, ) x = np.concatenate([x0, x1], axis=0).astype(np.float32) y = np.concatenate( [ np.zeros((n_per_class, 1)), np.ones((n_per_class, 1)), ], axis=0, ).astype(np.float32) order = rng.permutation(len(x)) x = x[order] y = y[order] split = int(train_fraction * len(x)) x_train, y_train = x[:split], y[:split] x_val, y_val = x[split:], y[split:] mean = x_train.mean(axis=0, keepdims=True) std = x_train.std(axis=0, keepdims=True) + 1e-8 x_train = (x_train – mean) / std x_val = (x_val – mean) / std return { “train”: ( torch.tensor(x_train), torch.tensor(y_train), ), “val”: ( torch.tensor(x_val), torch.tensor(y_val), ), “metadata”: { “n_train”: int(len(x_train)), “n_val”: int(len(x_val)), “n_features”: int(x_train.shape[1]), “noise”: float(noise), “rotations”: float(rotations), “seed”: int(seed), }, } @gin.configurable(denylist=[“x”, “y”]) def make_loader( x, y, batch_size=128, shuffle=True, seed=0, ): generator = torch.Generator() generator.manual_seed(seed) dataset = TensorDataset(x, y) return DataLoader( dataset, batch_size=batch_size, shuffle=shuffle, generator=generator, drop_last=False, ) We start by installing Gin Config and importing the core Python libraries, PyTorch, NumPy, and the plotting libraries required for the experiment. We create a clean project directory structure and reset Gin’s global configuration state so the notebook runs reproducibly. We then define the seed function, generate a nonlinear spiral dataset, and build a configurable DataLoader that Gin can control through external bindings. Defining a Gin-Configurable MLP, Optimizer, and Scheduler Copy CodeCopiedUse a different Browser def activation_layer(name): name = name.lower() if name == “relu”: return nn.ReLU() if name == “gelu”: return nn.GELU() if name == “tanh”: return nn.Tanh() if name == “silu”: return nn.SiLU() raise ValueError(f”Unknown activation: {name}”) @gin.configurable class MLP(nn.Module): def __init__( self, input_dim=gin.REQUIRED, hidden_dims=(64, 64), output_dim=1, activation=”gelu”, dropout=0.0, use_layernorm=False, ): super().__init__() layers = [] current_dim = input_dim for hidden_dim in hidden_dims: layers.append(nn.Linear(current_dim, hidden_dim)) if use_layernorm: layers.append(nn.LayerNorm(hidden_dim)) layers.append(activation_layer(activation)) if dropout > 0: layers.append(nn.Dropout(dropout)) current_dim = hidden_dim layers.append(nn.Linear(current_dim, output_dim)) self.network = nn.Sequential(*layers) def forward(self, x): return self.network(x) @gin.configurable(denylist=[“params”]) def make_optimizer( params, name=”adamw”, lr=3e-3, weight_decay=1e-3, momentum=0.9, ): name = name.lower() if name == “adamw”: return torch.optim.AdamW( params, lr=lr, weight_decay=weight_decay, ) if name == “sgd”: return torch.optim.SGD( params, lr=lr, momentum=momentum, weight_decay=weight_decay, ) raise ValueError(f”Unknown optimizer: {name}”) @gin.configurable(denylist=[“optimizer”]) def make_cosine_scheduler( optimizer, total_epochs=60, warmup_epochs=5, min_lr_factor=0.05, ): def lr_lambda(epoch): if epoch < warmup_epochs: return float(epoch + 1) / float(max(1, warmup_epochs)) progress = (epoch – warmup_epochs) / float( max(1, total_epochs – warmup_epochs) ) cosine = 0.5 * (1.0 + math.cos(math.pi * progress)) return min_lr_factor + (1.0 – min_lr_factor) * cosine return torch.optim.lr_scheduler.LambdaLR( optimizer, lr_lambda=lr_lambda, ) @gin.configurable def bce_with_logits_loss( logits, targets, label_smoothing=0.0, ): if label_smoothing > 0: targets = targets * (1.0 – label_smoothing) + 0.5 * label_smoothing return F.binary_cross_entropy_with_logits(logits, targets) @torch.no_grad() def evaluate(model, loader, loss_fn, device): model.eval() total_loss = 0.0 total_correct = 0 total_count = 0 for x, y in loader: x = x.to(device) y = y.to(device) logits = model(x) loss = loss_fn(logits, y) probs = torch.sigmoid(logits) preds = (probs >= 0.5).float() total_loss += loss.item() * len(x) total_correct += (preds == y).sum().item() total_count += len(x) return { “loss”: total_loss / total_count, “accuracy”: total_correct / total_count, } We define the neural network building blocks that form the configurable model and the training utilities. We create an MLP class whose architecture, activation function, dropout, and layer normalization behavior are controlled through Gin rather than hardcoded values. We also implement configurable optimizer, scheduler, loss, and evaluation functions so the training pipeline remains modular and experiment-ready. Implementing the Training Loop and Experiment Runner Copy CodeCopiedUse a different Browser @gin.configurable( denylist=[ “model”, “optimizer”, “scheduler”, “train_loader”, “val_loader”, “device”, ] ) def fit( model, optimizer, scheduler, train_loader, val_loader, device, epochs=60, grad_clip_norm=1.0, log_every=10, loss_fn=bce_with_logits_loss, ): history = [] for epoch in range(1, epochs + 1): model.train() for x, y in train_loader: x = x.to(device) y = y.to(device) optimizer.zero_grad(set_to_none=True) logits = model(x) loss = loss_fn(logits, y) loss.backward() if grad_clip_norm is not None: nn.utils.clip_grad_norm_( model.parameters(), grad_clip_norm, ) optimizer.step() if scheduler is not None: scheduler.step() train_metrics = evaluate( model, train_loader, loss_fn, device, ) val_metrics = evaluate( model, val_loader, loss_fn, device, ) lr = optimizer.param_groups[0][“lr”] row = { “epoch”: epoch, “lr”: lr, “train_loss”: train_metrics[“loss”], “train_accuracy”: train_metrics[“accuracy”], “val_loss”: val_metrics[“loss”], “val_accuracy”: val_metrics[“accuracy”], } history.append(row) if epoch == 1 or epoch % log_every == 0 or epoch == epochs: print( f”epoch={epoch:03d} | ” f”lr={lr:.6f} | ” f”train_loss={row[‘train_loss’]:.4f} | ” f”train_acc={row[‘train_accuracy’]:.3f} | ” f”val_loss={row[‘val_loss’]:.4f} | ” f”val_acc={row[‘val_accuracy’]:.3f}” ) return history @gin.configurable def run_experiment( tag=gin.REQUIRED, model=gin.REQUIRED, dataset_fn=make_spiral_dataset, optimizer_factory=make_optimizer, scheduler_factory=make_cosine_scheduler, prefer_gpu=True, ): seed_everything() device = “cuda” if prefer_gpu and torch.cuda.is_available() else “cpu” data = dataset_fn() x_train, y_train = data[“train”] x_val, y_val = data[“val”] train_loader = make_loader( x_train, y_train, shuffle=True, ) val_loader = make_loader( x_val, y_val, shuffle=False, ) model = model.to(device) optimizer = optimizer_factory(model.parameters()) scheduler = None if scheduler_factory is not None: scheduler = scheduler_factory(optimizer) print(“n” + “=” * 80) print(f”Experiment: {tag}”) print(“=” *