[{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/tags/diffusion/","section":"Tags","summary":"","title":"Diffusion","type":"tags"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/tags/discrete-diffusion/","section":"Tags","summary":"","title":"Discrete-Diffusion","type":"tags"},{"content":" The code below is trimmed to the essentials; the full, runnable version lives in the original notebook: github.com/litlig/notebooks/discrete_diffusion.ipynb\nDiffusion models are largely used in image and video generation. They operate in a high-dimensional continuous space, where we convert noise to an image by following a denoising process. Text generation, on the other hand, is discrete in nature. For a text sequence, we tokenize it and get a vector of token indices. The numerical difference between tokens has no natural meaning — a smaller gap between two token ids does not mean the tokens are semantically closer.\nDiscrete diffusion is designed for this discrete setting. Here we use the Shakespeare dataset from Andrej Karpathy\u0026rsquo;s LLM series, build a discrete DiT model from scratch, and see if it can generate Shakespeare-like text.\nWe work at the character level, so the \u0026ldquo;vocabulary\u0026rdquo; is just the set of distinct characters in the corpus:\nvocab = sorted(list(set(text))) vocab_size = len(vocab) # 65 stoi = { ch:i for i,ch in enumerate(vocab) } itos = { i:ch for i,ch in enumerate(vocab) } encode = lambda s: [stoi[c] for c in s] decode = lambda l: \u0026#39;\u0026#39;.join([itos[i] for i in l]) CTMC and the rate matrix # To recall (see the earlier 2-D diffusion post for the continuous case in full), the continuous diffusion model is done by flow matching, where we define a vector field \\(u_t\\) that tells the direction and velocity a noised image \\(x_t\\) should move at time \\(t\\). Since we do not know the distribution of the image manifold, \\(u_t\\) is not tractable. However, if we know the final state (a sample image), \\(u_t\\) can be computed analytically — one solution is to linearly connect the noised point \\(x_t\\) and the final state \\(z\\). What we actually need is not the vector field for a given \\(z\\), but the average vector field over all \\(z\\) that follow the posterior distribution \\(p(z\\,|\\,x_t)\\). In neural net training the loss is always an average over samples, so by training a network to minimize the average loss against the conditional vector field, we also recover the marginal vector field we need.\nFor a text sequence, the transition from noise to a meaningful sequence is a series of jumps across discrete states. Instead of a vector field, we define a rate matrix \\(Q_t(y\\,|\\,x)\\), the rate of jumping from state \\(x\\) to state \\(y\\) at time \\(t\\). If we are at state \\(x\\) at time \\(t\\), then over a very small interval \\(h\\) the probability we jump to \\(y\\) (with \\(y \\neq x\\)) is \\(h\\, Q_t(y\\,|\\,x)\\). This is a continuous-time Markov chain (CTMC).\nThe number of states is exponential in the sequence length — \\(V^d\\), where \\(V\\) is the vocab size and \\(d\\) is the sequence length. To keep \\(Q\\) manageable, we set the rate to zero whenever more than one position changes. For \\(X_t = x\\), a rate matrix \\(Q_t(v, j)\\) then defines the rate of changing position \\(j\\) to token \\(v\\), and we can sample with an Euler approximation:\ndef euler_step(x_t, rates, h): # delta_{v,x}: (B, d, V) one-hot at the current token delta = F.one_hot(x_t, num_classes=rates.size(-1)).to(rates.dtype) off_diag = h * rates * (1.0 - delta) # h*q(v) for v != x, else 0 stay = (1.0 - off_diag.sum(-1, keepdim=True)) # 1 - h*sum_{v!=x} q(v) probs = off_diag + delta * stay return torch.distributions.Categorical(probs=probs).sample() def sample(model, n_seq, n_steps, noise_gen): ts = torch.linspace(0.0, 1.0, n_steps + 1, device=device) x = noise_gen.gen(1, n_seq) for i in range(n_steps): s, t = ts[i], ts[i + 1] rate_mtx = model(x, s) # (n_seq, n_vocab) x = euler_step(x, rate_mtx, t - s) return x Before training anything, we can sanity-check the machinery with a mock model that returns random rates. Starting from uniform noise and running the sampler produces exactly what you\u0026rsquo;d expect — noise:\nclass MockModel(nn.Module): def forward(self, x, t): logits = torch.randn(x.shape[-1], vocab_size, device=device) return F.softmax(logits, dim=-1) - F.one_hot(x, num_classes=vocab_size).to(device) nwAlQgrsSo \u0026#39;bmm,DjUmT?hkdwWpui:\u0026amp;N iVXMEEqDm\u0026#39;zkh 3FfL?EK,oAvGkdLAK!x,cX.bG Factorized mixture path # Given an initial noise distribution \\(p_{\\mathrm{init}}\\) and a final data distribution \\(p_{\\mathrm{data}}\\), a discrete probability path is a family \\(p_t\\) with \\(p_0 \\sim p_{\\mathrm{init}}\\) and \\(p_1 \\sim p_{\\mathrm{data}}\\).\nThe most commonly used discrete probability path is the factorized mixture path, which defines the conditional path as:\n\\[p_t(x\\,|\\,z) = \\prod_{j=1}^d \\big[(1 - \\kappa_t)\\, p_{\\mathrm{init}}^{(j)}(x_j) + \\kappa_t\\, \\delta_{z_j}(x_j)\\big]\\]where \\(\\kappa_t\\) is the noise schedule. Each token position is treated independently. The rate matrix conditioned on \\(z\\) is:\n\\[Q^z_t(i, v\\,|\\,x_i) = \\frac{\\dot{\\kappa}_t}{1 - \\kappa_t}\\big(\\delta_{z_i}(v) - \\delta_{x_i}(v)\\big)\\]and the marginal rate matrix is:\n\\[Q_t(i, v\\,|\\,x_i) = \\sum_z Q^z_t(i, v\\,|\\,x_i)\\, p(z\\,|\\,x) = \\frac{\\dot{\\kappa}_t}{1 - \\kappa_t}\\big(p(z_j = v\\,|\\,x) - \\delta_{x_i}(v)\\big)\\]So the problem reduces to a categorization: predict \\(z\\) given \\(x\\) and \\(t\\). The rate matrix is just a reparameterization of that prediction.\nThe model: a discrete DiT # The network is a transformer that takes noised tokens \\(x_t\\) and a scalar time \\(t\\), and outputs logits over the vocabulary at each position — its job is to predict the clean tokens \\(z\\). Time is injected through AdaLN-Zero conditioning, as in the DiT architecture: a sinusoidal time embedding is passed through an MLP to produce a conditioning vector, which modulates each transformer block. The final projection is zero-initialized so each block starts as an identity map, which stabilizes training at initialization.\nclass DiscreteDiffusionTransformer(nn.Module): \u0026#34;\u0026#34;\u0026#34; Input: x_t (B, L) token ids, t (B,) Output: logits (B, L, vocab_size) predicting the original tokens \u0026#34;\u0026#34;\u0026#34; def forward(self, x_t, t, padding_mask=None): B, L = x_t.shape x = self.token_emb(x_t) + self.pos_emb[:, :L, :] c = self.time_mlp(t) # (B, cond_dim) conditioning vector shared across layers for block in self.blocks: x = block(x, c, key_padding_mask=padding_mask) return self.final(x, c) Training to predict z # Training follows the factorized mixture path directly. For each batch we sample a time \\(t\\), set the noise level \\(\\kappa_t\\) (here the schedule is simply \\(\\kappa_t = t\\)), and construct \\(x_t\\) by keeping each clean token with probability \\(\\kappa_t\\) and replacing it with uniform noise otherwise. The network then predicts the clean sequence \\(z\\), and the loss is a plain cross-entropy over positions:\ndef schedule(t): return t def get_batch(): t = torch.rand(n_sample, device=device) kappa = schedule(t) seq_idx = torch.randint(0, len(text) - n_seq + 1, (n_sample,), device=device).unsqueeze(1) \\ + torch.arange(n_seq, device=device) z = data[seq_idx] # (sample, seq) masks = torch.bernoulli(kappa.unsqueeze(1).expand(-1, n_seq)).long() # keep clean where 1 noise = noise_gen.gen(n_sample, n_seq) x = masks * z + (1 - masks) * noise return x, t, z def loss_fn(z_pred, z): return F.cross_entropy(z_pred.view(-1, vocab_size), z.view(-1)) Sampling with the trained model # At sampling time we turn the model\u0026rsquo;s clean-token prediction back into a rate matrix using the marginal formula above, then take an Euler step:\n@torch.no_grad() def sample(model, n_seq, n_steps, noise_gen): model.eval() ts = torch.linspace(0.0, 1.0, n_steps + 1, device=device) x = noise_gen.gen(1, n_seq) for i in range(n_steps): s, t = ts[i], ts[i + 1] logits = model(x, s.expand(x.shape[0])) rate_mtx = (F.softmax(logits, dim=-1) - F.one_hot(x, num_classes=vocab_size).to(device)) / (1 - s) x = euler_step(x, rate_mtx, t - s) return x After a short training run, the samples are no longer random noise — the model has picked up Shakespeare\u0026rsquo;s shape: capitalized speaker names, line breaks, and vaguely English words.\nd did neyce? HONRY VIA: sutur selon\u0026#39;sl mave, Myoury abenly tuke It is far from coherent — this is a tiny model trained for a few thousand steps on characters — but the discrete diffusion process clearly works: starting from pure noise, a sequence of denoising jumps lands us in something that looks like a play.\n","date":"17 July 2026","externalUrl":null,"permalink":"/posts/discrete-diffusion/","section":"Posts","summary":"Building a discrete diffusion transformer (DiT) from scratch and training it to generate Shakespeare-like text, one denoising jump at a time.","title":"Language generation with discrete diffusion","type":"posts"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/categories/learning/","section":"Categories","summary":"","title":"Learning","type":"categories"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/tags/notebook/","section":"Tags","summary":"","title":"Notebook","type":"tags"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/","section":"Strayforge","summary":"","title":"Strayforge","type":"page"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"17 July 2026","externalUrl":null,"permalink":"/tags/transformer/","section":"Tags","summary":"","title":"Transformer","type":"tags"},{"content":"","date":"2 July 2026","externalUrl":null,"permalink":"/tags/flow-matching/","section":"Tags","summary":"","title":"Flow-Matching","type":"tags"},{"content":"","date":"2 July 2026","externalUrl":null,"permalink":"/tags/guidance/","section":"Tags","summary":"","title":"Guidance","type":"tags"},{"content":" This is a direct follow-up to the earlier 2-D diffusion post. The code below is trimmed to the essentials; the full, runnable version lives in the original notebook: github.com/litlig/notebooks/guided_2d_diffusion.ipynb\nProblem framing # In an unguided diffusion model, we sample a noise image and follow the diffusion process to arrive at an image. Often, though, we want to prompt the model to generate a specific kind of image; such generation is called guided generation.\nWe continue with the 2-dimensional example — a two-pixel image. We say the cluster in the upper right are cat images, and the cluster in the lower left are dog images.\nVanilla guidance # With a text label/prompt \\(y\\), we want to learn a guided vector field that moves the probability mass toward the distribution of images conditioned on the label, \\(p_{data}(\\cdot\\,|\\,y)\\).\nIntuitively, we can follow the same procedure as unguided flow matching. For each training example, we sample a pair of image and label (or text caption). We train a neural network to estimate the conditional vector field, giving it the label \\(y\\) as an additional input. The loss function is still the mean squared error:\n\\[\\min_{\\theta}\\ \\mathbb{E}_{t \\sim U(0,1),\\ z,y \\sim p_{data},\\ x \\sim p(\\cdot\\,|\\,z)}\\ \\big\\| f_t^\\theta(x, y) - v_t(x\\,|\\,z) \\big\\|^2\\]When the target \\(z\\) is given, the conditional vector field is computed the same way as before:\n\\[v_t(x\\,|\\,z) = \\dot{a}_t\\, x_0 + \\dot{b}_t\\, z\\]The label enters the network through an embedding, which is concatenated with \\(x_t\\) and \\(t\\):\nclass ConditionalFlowModel(nn.Module): def __init__(self, embedding_dim=2, num_classes=2): super().__init__() self.embedding = nn.Embedding(num_classes, embedding_dim) input_size = 2 + 1 + embedding_dim # xt + t + label embedding self.net = nn.Sequential( nn.Linear(input_size, 128), nn.SiLU(), nn.Linear(128, 128), nn.SiLU(), nn.Linear(128, 128), nn.SiLU(), nn.Linear(128, 2), ) def forward(self, xt, t, y): y_embedded = self.embedding(y.squeeze(-1)) return self.net(torch.cat([xt, t, y_embedded], dim=-1)) Sampling then just fixes the guidance label and integrates the field with Euler steps. With guide_label=0 we pull noise toward the dogs, with guide_label=1 toward the cats:\nClassifier-free guidance # In this simple example, the vanilla approach above already works reasonably well. In practice, though, samples from such a model often don\u0026rsquo;t follow the prompt or label very strongly. The guided probability path is \\(p_t(x\\,|\\,y)\\). By Bayes\u0026rsquo; theorem:\n\\[p_t(x\\,|\\,y) = \\frac{p_t(y\\,|\\,x)\\, p_t(x)}{p_t(y)}\\]Taking the log of both sides and then the gradient with respect to \\(x\\) (the \\(p_t(y)\\) term drops out, since it doesn\u0026rsquo;t depend on \\(x\\)):\n\\[\\nabla \\log p_t(x\\,|\\,y) = \\nabla \\log p_t(y\\,|\\,x) + \\nabla \\log p_t(x)\\]\\(\\nabla \\log\\) is the score function. For a Gaussian probability path, the vector field relates to the score by \\(v_t(x) = \\alpha_t \\nabla \\log p_t(x) + \\beta_t x_t\\). Substituting gives a formula that connects the two vector fields:\n\\[v_t(x\\,|\\,y) = v_t(x) + \\alpha_t \\nabla \\log p_t(y\\,|\\,x)\\]The extra term \\(\\nabla \\log p_t(y\\,|\\,x)\\) is what contributes the guidance. A natural way to make generation follow the guidance more strongly is to scale this term up. Estimating \\(p_t(y\\,|\\,x)\\) is essentially a classification problem — given a noisy sample \\(x\\), predict its label — so this approach is called classifier guidance: it requires training one network for the vector field and a separate classifier.\nA more elegant approach eliminates the need for a separate classifier. Given the guided vector field \\(v_t(x\\,|\\,y)\\) and the unguided vector field \\(v_t(x)\\), the influence of guidance is \\(v_t(x\\,|\\,y) - v_t(x)\\). We scale it up by a weight \\(w \u003e 1\\) and use the following in forward sampling:\n\\[(1-w)\\, v_t(x) + w\\, v_t(x\\,|\\,y)\\]To get both \\(v_t(x)\\) and \\(v_t(x\\,|\\,y)\\) from a single network, we treat the unguided vector field as a guided one with a special null prompt, which we inject at random into a fraction of the training samples:\ndef get_batch(dist_fn, batch_size, inject_rate=0.1): z, y = dist_fn(batch_size) z = torch.from_numpy(z).float() y = torch.from_numpy(y).long().unsqueeze(1) x0 = torch.randn(batch_size, 2) t = torch.rand(batch_size, 1) xt = (1 - t) * x0 + t * z vf = z - x0 # Randomly replace some labels with the special \u0026#39;null\u0026#39; label (2) num_inject = int(batch_size * inject_rate) if num_inject \u0026gt; 0: inject_indices = torch.randperm(batch_size)[:num_inject] y[inject_indices] = 2 return xt, t, y, vf At sampling time we run the network twice per step — once with the real label, once with the null label — and combine them with the guidance weight:\ndef cfg_sample(model, nsample, steps=100, guide_label=0, weight=2.0): null_label = 2 x = torch.randn(nsample, 2) dt = 1.0 / steps y_guide = torch.full((nsample, 1), guide_label, dtype=torch.long) y_null = torch.full((nsample, 1), null_label, dtype=torch.long) for step in range(steps): t = (step / steps) * torch.ones(nsample, 1) vf_guided = model(x, t, y_guide) vf_unguided = model(x, t, y_null) x = x + ((1 - weight) * vf_unguided + weight * vf_guided) * dt return x Sweeping the guidance weight shows its effect directly. At \\(w=1\\) we recover plain conditional sampling. As \\(w\\) grows, the guidance term is amplified: samples cling more tightly to their target cluster and drift further from the opposite one — the samples follow the prompt more faithfully, at the cost of some diversity as they concentrate.\n","date":"2 July 2026","externalUrl":null,"permalink":"/posts/guided-2d-diffusion/","section":"Posts","summary":"A follow-up to the 2-D diffusion notebook: steering generation with labels, from vanilla conditioning to classifier-free guidance.","title":"Guided diffusion from scratch with 2-D examples","type":"posts"},{"content":" The code below is trimmed to the essentials; the full, runnable version lives in the original notebook: github.com/litlig/notebooks/2d_diffusion.ipynb\nProblem framing # All images are sampled from a probability distribution in a high-dimensional space. To generate an image, we can directly model that distribution. Diffusion models follow a different path: they model the trajectory of transforming a sample generated from a known distribution into the unknown image space.\nImages tend to form a manifold in high-dimensional space. A 256×256 image with 3 color channels has 3×256×256 dimensions as the initial input. For illustration, we say that an image has two pixels in continuous greyscale; then we can plot the distribution of all images in a 2-D chart.\nBelow we show two imaginary image distributions; every point on a line is an image, and points off the line are interpreted as noise.\nFlow matching # Imagine we start with a sample of pure white noise \\(x_0\\) and we want to reach a specific data point \\(z\\) (the \u0026lsquo;image\u0026rsquo;) at time \\(t=1\\). We can define a probability path \\(x_t\\) that connects noise to data:\n\\[x_t = a_t x_0 + b_t z\\]where \\(a_t\\) and \\(b_t\\) are noise schedulers with the following boundary conditions:\nat \\(t=0\\): \\(a_0=1,\\ b_0=0\\) (pure noise) at \\(t=1\\): \\(a_1=0,\\ b_1=1\\) (pure data) Objective # The goal is to learn a vector field \\(v(x_t, t)\\) that describes the \u0026lsquo;velocity\u0026rsquo;, or direction, of this trajectory; it is the derivative of \\(x_t\\) with respect to \\(t\\). If we know the vector field, we can gradually move \\(x_t\\) toward the target \\(z\\) using the Euler method: \\(x_{t+h} = x_t + v_t\\, h\\).\nWhen the target \\(z\\) is given, the conditional vector field has a closed-form formula:\n\\[v_t(x \\mid z) = \\dot{a_t} x_0 + \\dot{b_t} z\\]To get the unconditional vector field — the average vector field at a given point \\(x_t\\) over all possible \\(z\\) — we take the integral over the distribution of \\(z\\) given \\(x_t\\). We can further transform it using Bayes\u0026rsquo; theorem, and it can be proved that minimizing the mean squared error against the conditional vector field \\(v_t(x \\mid z)\\) is equivalent to minimizing it against the marginal vector field. We train a neural network \\(f(x_t, t, \\theta)\\) using the following objective:\n\\[\\min_{\\theta} \\mathbb{E}_{t,\\, q(z),\\, p(x_0)} \\| f(x_t, t, \\theta) - v_t(x_t \\mid z) \\|^2\\]By regressing against the simple-to-calculate \\(v_t(x \\mid z)\\), the model implicitly learns to follow the complex marginal flow of the data distribution. With a linear schedule (\\(a_t = 1-t,\\ b_t = t\\)), so the target velocity is \\(z - x_0\\), the whole training target fits in a few lines:\ndef get_batch(dist_fn, batch_size): z = torch.from_numpy(dist_fn(batch_size)).float() # a real sample x0 = torch.randn(batch_size, 2) # noise t = torch.rand(batch_size, 1) xt = (1 - t) * x0 + t * z # point on the noise→data path vf = z - x0 # target velocity return xt, t, vf The model f(x_t, t) is a small 3-layer MLP trained with F.mse_loss(f(xt, t), vf). Once trained, we sample by integrating the velocity from noise to data with plain Euler steps:\ndef sample_ode(model, nsample, steps=100): x = torch.randn(nsample, 2) dt = 1.0 / steps for step in range(steps): t = (step / steps) * torch.ones(nsample, 1) v = model(torch.cat([x, t], dim=-1)) x = x + v * dt # follow the velocity return x As shown by the trajectory diagram above, flow matching moves the noise points toward the closest image points in terms of Euclidean distance. Look at the training samples we use (get_batch above): we randomly pick an image from the collection, sample \\(x_0\\) from a Gaussian \\(N(0, I)\\), and sample \\(t\\) uniformly from 0 to 1. Why would \\(x_0\\) converge to the closest \\(z\\), after training on a dataset that shows no such bias or tendency?\nThe reason is that, even though \\(x_0\\) is unbiased, the model takes \\(x_t\\) as input, which carries information about which \\(z\\) is more likely. At \\(t=0\\), \\(x_t\\) is pure noise and \\(p(z \\mid x_t)\\) equals the prior, so \\(x_t\\) is drawn to the center of probability mass. When \\(t\u003e0\\), \\(x_t\\) starts to encode which \\(z\\) is more likely: \\(p(z \\mid x_t)\\) is larger for closer \\(z\\), so \\(x_t\\) is drawn toward those points.\nDiffusion models # With flow matching and the vector field, the sampling path becomes deterministic once the initial noise sample is drawn, and any error in the estimated vector field accumulates along the path, pushing the sample off the data manifold and yielding a poor image. Diffusion models inject noise at every sampling step, which lets the path re-explore and re-converge toward high-probability regions. The forward sampling takes the form:\n\\[x_{t+h} = x_t + \\text{drift} \\cdot h + \\text{diffusion} \\cdot \\text{noise}\\]With the additional diffusion term, for \\(x_t\\) to still follow \\(p_t\\) as in the flow model, the drift coefficient is the flow-model vector field plus a score correction. The score-correction term is derived from the gradient of the log-probability density (the \u0026lsquo;score\u0026rsquo;) of the data distribution, which guides noisy samples toward regions of higher probability, counteracting the effect of the injected noise.\nFor a Gaussian probability path, both the vector field and the score are linear combinations of \\(x_t\\) and \\(z\\), meaning the score can be recovered from the vector field and vice versa.\nBelow, without training a diffusion model, we reuse the previously trained vector field to compute the score and forward-sample along a diffusion path:\ndef sample_sde(model, nsample, steps=100, sigma=0.3, noise_off_after=0.9): x = torch.randn(nsample, 2) dt = 1.0 / steps for step in range(steps): t_val = step / steps t = t_val * torch.ones(nsample, 1) v = model(torch.cat([x, t], dim=-1)) s = (t * v - x) / torch.clamp(1 - t, min=1e-4) # score from velocity sg = sigma if t_val \u0026lt; noise_off_after else 0.0 # stop noise near t=1 x = x + (v + 0.5 * sg**2 * s) * dt + sg * np.sqrt(dt) * torch.randn_like(x) return x (The score \\(\\sim 1/(1-t)\\) diverges as \\(t \\to 1\\), so we switch the noise off for the final stretch.)\nODE vs SDE: does the noise help? # Both samplers use the same field — deterministic ODE (\\(\\sigma=0\\)) vs stochastic SDE (\\(\\sigma\u003e0\\)). With a perfect field they\u0026rsquo;d match, so any gap comes from model error and coarse steps. We measure the mean distance to the nearest ground-truth point (lower is better), averaged over 10 seeds at steps=10, paired by shared \\(x_0\\):\nσ circles (ODE → SDE) ellipse (ODE → SDE) 0.0 0.0529 → 0.0529 0.0333 → 0.0333 0.2 0.0529 → 0.0513 0.0333 → 0.0339 0.5 0.0529 → 0.0509 0.0333 → 0.0388 The trends are opposite and monotonic. On the smooth, unimodal ellipse the ODE is already near-optimal, so noise only knocks samples off the curve. On the bimodal circles the marginal path curves between modes and a coarse deterministic step cuts the corner; the SDE\u0026rsquo;s score-correction pulls samples back, improving with σ. So noise buys error-correction on hard, multimodal targets — and costs precision on easy ones.\n","date":"29 June 2026","externalUrl":null,"permalink":"/posts/diffusion-2d-notebook/","section":"Posts","summary":"A trimmed, annotated companion to the 2-D diffusion notebook — with an ODE-vs-SDE sampling comparison.","title":"Diffusion model from scratch with 2-D examples","type":"posts"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"}]