<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?>
  <rss version="2.0"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:media="http://search.yahoo.com/mrss/" 
  xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Koray Ulusan | Writings</title>
    <link>https://korayulusan.github.io</link>
    <description>Thoughts, Research &amp; Engineering | 7 Posts</description>
    <language>en-us</language>
    <lastBuildDate>Fri, 24 Jul 2026 11:13:53 GMT</lastBuildDate>
    <atom:link href="https://korayulusan.github.io/rss.xml" rel="self" type="application/rss+xml"/>
    
  <item>
    <title>Building Frequency Altered Feedback in the Browser</title>
    <link>https://korayulusan.github.io/blog/frequency-altered-feedback-browser-psola-ola-speech-therapy/</link>
    <guid isPermaLink="true">https://korayulusan.github.io/blog/frequency-altered-feedback-browser-psola-ola-speech-therapy/</guid>
    <pubDate>Mon, 25 May 2026 05:48:47 GMT</pubDate>
    <description>How I implemented real-time FAF pitch shifting in the browser for speech therapy: the wrong algorithm first, why PSOLA is the right one, the latency math, jitter measurement, and every non-obvious bug along the way.</description>
    <media:content url="https://korayulusan.github.io/og/blog/building-frequency-altered-feedback-in-the-browser-landscape-thumbnail.jpeg" medium="image" />
    <enclosure url="https://korayulusan.github.io/og/blog/building-frequency-altered-feedback-in-the-browser-landscape-thumbnail.jpeg" length="0" type="image/jpeg" />
    <content:encoded><![CDATA[<div style="background-color: #1c1c1d; color: #e8e8e8; font-family: sans-serif;"><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">TL;DR</h3><p>I extended <a href="https://korayulusan.github.io/delayed-auditory-feedback-online/" style="color:#70a5fd">DAF Online</a> with real-time <strong>Frequency Altered Feedback (FAF)</strong>. FAF pitch-shifts your voice in your headphones while you speak, triggering the brain’s choral effect to reduce stuttering. Getting it right took three algorithm iterations, some careful latency accounting, and a custom jitter benchmark that works around a non-obvious limitation of <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">AudioWorkletGlobalScope</code>. This post covers all of it.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="The DAF Online interface featuring a clean, minimalist design with the addition of FAF sliders." height="2398" src="https://korayulusan.github.io/static/faf-browser-psola-ola-pitch-shifting.Bc12Lu1Z_ZIRiXO.webp" width="2520" class="w-full block border-2 border-border h-full m-0 object-cover" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 leading-relaxed max-w-[90%] mt-4 mx-auto text-center" style="text-align:center"><span class="font-bold text-muted">Figure 1:</span> The DAF Online interface featuring a clean, minimalist design with the addition of FAF sliders.</figcaption></figure><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">What is Frequency Altered Feedback?</h2><p><strong>Delayed Auditory Feedback (DAF)</strong> slows speech by creating a timing mismatch between articulation and perception. <a href="https://korayulusan.github.io/blog/i-built-delayed-auditory-feedback-online-tool" style="color:#70a5fd">I covered DAF in the previous post.</a></p><p><strong>Frequency Altered Feedback (FAF)</strong> attacks the same problem through a different mechanism. It shifts the pitch of your voice in your headphones by a ratio <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code>, where</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$r = 2^{s/12}$$</pre><p>and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$s$</code> is the semitone shift. A shift of 3-6 semitones is enough to trigger the <strong>choral effect</strong>: your brain perceives itself as speaking alongside another voice and drops into a group-speaker processing mode, which disengages the feedback loop that drives stuttering.</p><p>The two mechanisms are neurally independent. DAF acts on auditory-motor timing pathways. FAF acts on the choral speech cortical network. Clinical devices like SpeechEasy combine both, which is why DAF Online now supports them together.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Clinical semitone ranges</h3><table style="margin-top:1em;border-collapse:collapse;width:100%"><thead><tr><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Goal</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Range</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Mechanism</th></tr></thead><tbody><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>Stuttering therapy</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$3$</code>-<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$6$</code> semitones</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Choral effect. ~35% fluency improvement at 3 st, 65-70% at 6 st. Below ~2 st too subtle; above ~6 st diminishing returns and unnatural quality.</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>Pitch-Shift Reflex (PSR) research</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$50$</code>-<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$200$</code> cents (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$0.5$</code>-<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$2$</code> st)</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Small enough that the brain reads the shift as accidental pitch drift, triggering an involuntary compensatory counter-shift within 50-150 ms. Larger shifts are interpreted as an external error and no reflex fires.</td></tr></tbody></table><p>The ±300-cent range mode in the app’s cents UI exists specifically for PSR experiments.</p><span class="tracking-[0.2em] uppercase text-[10px] block font-black mb-2 text-accent">Key Takeaway</span><p>FAF and DAF engage different neural pathways and their effects add up. In trials, the combination outperforms either alone, which is why clinical hardware uses both.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Wrong Algorithm First: WSOLA</h2><p>My initial implementation used WSOLA (Waveform Similarity Overlap-Add). The algorithm advances its input read head at a rate proportional to <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code>:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">._inR </span><span style="color:#f97583">+=</span><span style="color:#79b8ff"> H</span><span style="color:#f97583"> *</span><span style="color:#e1e4e8"> r;</span></span></code></pre><p>For <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r &gt; 1$</code> (pitch up), the read head races ahead of incoming audio. The input buffer drains faster than new samples arrive. For <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r &lt; 1$</code>, it falls behind. In both cases <strong>pitch and delay are coupled</strong>: setting <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$+8$</code> semitones audibly shortened the DAF delay; <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$-8$</code> semitones stretched it. This is unsuitable.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Fixed-Anchor OLA: Decoupling Pitch from Delay</h2><p>The fix is to remove the drifting read head. Every synthesis hop, the analysis position is anchored at a fixed offset behind the write head:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{anchor} = w_{in} - L$$</pre><p>where <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$w_{in}$</code> is the input write head and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$L$</code> is the lookback depth. Pitch is shifted by resampling <em>within</em> the grain: a span of <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G \cdot r$</code> input samples is linearly interpolated onto <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G$</code> output samples.</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{srcPos}(i) = \text{anchor} + \frac{i}{G - 1} \cdot G \cdot r, \quad i \in [0, G-1]$$</pre><ul><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r &gt; 1:\quad$</code> <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G \cdot r &gt; G$</code> — more input samples compressed into <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G$</code> outputs → higher frequency</li><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r = 1:\quad$</code> <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G \cdot r = G$</code> — identity</li><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r &lt; 1:\quad$</code> <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G \cdot r &lt; G$</code> — fewer input samples stretched across <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G$</code> outputs → lower frequency</li></ul><p>The anchor is fixed relative to the write head. The delay is invariant under changes to <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code>.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Choosing <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$R_{max}$</code> and the lookback depth</h3><p>The readSpan <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G \cdot r$</code> grows with <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code>. At maximum shift (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r = R_{max} = 2.0$</code>, i.e. +12 semitones), the grain reads <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$512$</code> input samples. The lookback must guarantee all of them are already in the ring buffer:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$L = \lceil G \cdot R_{max} \rceil + 2 = \lceil 256 \times 2.0 \rceil + 2 = 514 \text{ samples} \approx 10.7 \text{ ms at 48 kHz}$$</pre><p>The <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$+2$</code> guard ensures the linear interpolation never reads past the write head at exactly <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r = R_{max}$</code>. With anchor at <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$w_{in} - 514$</code>, the maximum source position is:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{srcPos}_{max} = (w_{in} - 514) + 256 \times 2.0 = w_{in} - 2$$</pre><p>leaving a two-sample margin before the write head. Note the sizing criterion flipped from the old design: formerly the lookback was sized for the <em>smallest</em> reachable ratio (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$R_{min}$</code>, governing how far back the read head could stray); now it is sized for the <em>largest</em> reachable ratio (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$R_{max}$</code>, governing how wide the read span can grow).</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Amplitude normalisation</h3><p>With fixed grain size <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G = 256$</code> and fixed synthesis hop <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$H = 128$</code> (50% overlap), the Hann COLA sum is approximately 1 regardless of pitch ratio <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code>. No <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code>-dependent gain correction is required; grain amplitude is left at unity (1.0).</p><p>This differs from variable-hop schemes — including the PSOLA processor below — where the synthesis hop <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\lfloor T_0 / r \rfloor$</code> varies with both <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_0$</code> and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code>, causing the COLA sum to vary with <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code> and requiring an explicit <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$1/r$</code> correction to maintain consistent output level.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Why Switch to PSOLA?</h2><p>Fixed-anchor OLA solves the delay-coupling problem and runs cheaply. But it has a fundamental quality limit: <strong>grain boundaries don’t align to the signal’s periodicity</strong>.</p><p>A 256-sample grain cut from a 150 Hz voice at 48 kHz captures 0.8 pitch periods. The next grain starts at a random phase of the next cycle. The Hann window smooths the transition but cannot eliminate the phase discontinuity, which manifests as metallic flutter on sustained vowels, audible at shifts above ±2 semitones.</p><p>For stuttering therapy, sustained vowels at 3-6 semitones are exactly the use case.</p><p><strong>TD-PSOLA</strong> (Time-Domain Pitch Synchronous Overlap-Add) fixes this by aligning grain boundaries to the signal’s own pitch periods. Each grain is exactly <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$2T_0$</code> samples, centered on a pitch mark (glottal closure instant). The synthesis hop is <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_0 / r$</code>, which is what changes the perceived fundamental frequency. Because adjacent grains start at the same phase of their respective pitch cycles, overlap-add is phase-coherent and the output is perceptually smooth.</p><p><strong>FD-PSOLA</strong> (Frequency-Domain PSOLA) is a more complex variant that applies a Fourier transform to each grain, manipulates the spectrum, then inverse transforms back to time domain. It can achieve higher quality at extreme shifts but is more computationally expensive and has higher latency due to the FFT window size. This latency makes it unsuitable for real-time FAF in the browser.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">PSOLA Implementation</h2><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">YIN pitch detection</h3><p>YIN (<a href="https://www.semanticscholar.org/paper/YIN%2C-a-fundamental-frequency-estimator-for-speech-Cheveign%C3%A9-Kawahara/4581aa7dec34a64c826d50b3dc0826602c197826" style="color:#70a5fd">de Cheveigné &amp; Kawahara, 2002</a>) estimates <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_0$</code> via the cumulative mean normalised difference function. The raw difference function is:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$d(\tau) = \sum_{j=0}^{W-1} \left( x[j] - x[j - \tau] \right)^2$$</pre><p>Normalised by the running cumulative mean:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$d'(\tau) = \frac{d(\tau) \cdot \tau}{\sum_{k=1}^{\tau} d(k)}$$</pre><p><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$d'(\tau)$</code> tends toward 1.0 for aperiodic signals and dips below a threshold (0.15 here) at the true period. The running-sum formulation keeps normalisation at <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$O(1)$</code> per <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\tau$</code>. We re-run every 4 grains (~20 ms) since <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_0$</code> doesn’t change faster in natural speech, and smooth the estimate with a first-order IIR:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\hat{T}_0 \leftarrow 0.85 \cdot \hat{T}_0 + 0.15 \cdot T_0^{raw}$$</pre><p>This prevents grain-size discontinuities at voiced/voiced boundaries.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Pitch mark detection</h3><p>Once <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_0$</code> is estimated, the processor locates a pitch mark — the specific sample to centre the grain on — by searching near the analysis anchor for a <strong>negative-to-positive zero-crossing</strong> (a directed crossing). The requirement for directionality is non-obvious but important: a plain minimum-<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$|$</code>sample<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$|$</code> search is direction-ambiguous and can alternate between positive- and negative-going crossings on successive grains, effectively halving the perceived pitch. The directed search eliminates this. If no qualifying crossing is found within the search window (which occurs on frames where the signal is locally flat), the processor falls back to the sample of minimum absolute value.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Unvoiced fallback</h3><p>Fricatives, plosive releases, and silence have no <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_0$</code>. YIN returns <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">_voiced = false</code> and the processor falls back to a <strong>128-sample direct passthrough</strong>: input samples are copied to the output buffer unwindowed, with a short crossfade at voiced/unvoiced boundaries to preserve phase continuity. There is no <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_0$</code> to shift; attempting PSOLA on <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">/s/</code> produces artefacts. Unlike a Hann-windowed OLA fallback, the direct copy adds negligible algorithmic overhead.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">PSOLA latency and the voice type selector</h3><p>The processor lookback covers <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_{max}$</code> samples — one maximum pitch period — which is the minimum history required for the grain-centred synthesis to access the full <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$2T_0$</code> analysis window:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$T_{max} = \left\lfloor \frac{f_s}{f_{floor}} \right\rfloor, \quad L_{PSOLA} = \frac{T_{max}}{f_s} \times 1000 \text{ ms}$$</pre><p><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">pitchFloor</code> is passed at node construction via <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">processorOptions</code>, the correct mechanism for init-time configuration that cannot be updated via <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">AudioParam</code> automation:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">new</span><span style="color:#b392f0"> AudioWorkletNode</span><span style="color:#e1e4e8">(ctx, </span><span style="color:#9ecbff">'pitch-shifter-psola'</span><span style="color:#e1e4e8">, {</span></span>
<span class="line"><span style="color:#e1e4e8">  processorOptions: { pitchFloor: </span><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">._pitchFloor },</span></span>
<span class="line"><span style="color:#e1e4e8">});</span></span></code></pre><p>The voice type selector adjusts this value:</p><table style="margin-top:1em;border-collapse:collapse;width:100%"><thead><tr><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Voice type</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$f_{floor}$</code></th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$T_{max}$</code> @ 48 kHz</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$L_{PSOLA}$</code></th></tr></thead><tbody><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Deep</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">80 Hz</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">600 samples</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">≈ 12.5 ms</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Average</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">120 Hz</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">400 samples</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">≈ 8.3 ms</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">High-pitched</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">150 Hz</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">320 samples</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">≈ 6.7 ms</td></tr></tbody></table><p>Matching voice type to your actual pitch range is not cosmetic; it directly controls the algorithm’s latency floor.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Loading Both Processors in One Blob</h2><p>Both OLA and PSOLA are available in the app. Since each processor is a self-contained IIFE string, they are concatenated into a single blob and loaded with one <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">addModule()</code> call, with no extra Vite config and no <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">public/</code> files:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">const</span><span style="color:#79b8ff"> blob</span><span style="color:#f97583"> =</span><span style="color:#f97583"> new</span><span style="color:#b392f0"> Blob</span><span style="color:#e1e4e8">([</span><span style="color:#79b8ff">COMBINED_CODE</span><span style="color:#e1e4e8">], { type: </span><span style="color:#9ecbff">'application/javascript'</span><span style="color:#e1e4e8"> });</span></span>
<span class="line"><span style="color:#f97583">await</span><span style="color:#e1e4e8"> ctx.audioWorklet.</span><span style="color:#b392f0">addModule</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">URL</span><span style="color:#e1e4e8">.</span><span style="color:#b392f0">createObjectURL</span><span style="color:#e1e4e8">(blob));</span></span></code></pre><p>The shared Hann table (1024 floats, 4 KB) is allocated once at module scope, not per node instance.</p><p>Switching modes (OLA ↔ PSOLA) requires tearing down existing nodes because <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">processorOptions.pitchFloor</code> is init-time only and cannot be changed post-construction. The “same count fast-path” (which updates only <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">pitchRatio</code> AudioParams without graph surgery) is explicitly bypassed on mode switches.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Multi-FAF: N Parallel Signals</h2><p>The app supports <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$N$</code> simultaneous pitch-shifted signals. The graph topology:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{source} \rightarrow \left\{ \begin{array}{c} \text{fafNode}_1 \\ \vdots \\ \text{fafNode}_N \end{array} \right\} \rightarrow \text{sumGain}\!\left(\tfrac{1}{N}\right) \rightarrow \text{delayNode} \rightarrow \text{out}$$</pre><p>The normalising gain at <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$1/N$</code> keeps perceived loudness constant as <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$N$</code> increases. Parallel worklet nodes don’t stack latency; they all process within the same 128-sample scheduler pass.</p><p>PSOLA nodes apply an internal <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$1/r$</code> gain correction (their synthesis hop <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\lfloor T_0 / r \rfloor$</code> varies, so the COLA sum varies with <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$r$</code>). OLA nodes use unity gain, since their hop and grain size are fixed. In both cases the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$1/N$</code> sumGain composes correctly on top.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Effective Delay Display</h2><p>Total user-perceived delay follows the same equation as DAF:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$k_{eff} = k_{user} + k_{sys}$$</pre><p>where <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{user}$</code> is the slider value and</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$k_{sys} = k_{floor} + k_{FAF}$$</pre><p>with <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{floor}$</code> = <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">baseLatency</code> + <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">outputLatency</code> + <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">inputLatency</code> from the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">AudioContext</code>, and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{FAF}$</code> the algorithmic latency of whichever FAF mode is active. The display recomputes whenever any component changes: slider moved, FAF toggled, session started, graph resumed, voice type changed.</p><p>Chrome reports <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">outputLatency = 0</code> immediately after context creation and updates asynchronously. A 200 ms delayed re-read of <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">measureLatencyFloor</code> catches the stabilised value without a full benchmark run.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Jitter Measurement</h2><p>Accurate jitter measurement from an <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">AudioWorklet</code> required solving a non-obvious problem.</p><p>The naive approach of measuring intervals between consecutive <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">process()</code> calls using <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">currentTime</code> produces stddev = 0 by definition. <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">currentTime</code> is the <em>audio clock</em>: it advances in exactly <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$128 / f_s$</code> seconds per quantum as guaranteed by the spec. It measures ideal scheduling, not actual scheduling.</p><p><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">performance.now()</code> is not available in <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">AudioWorkletGlobalScope</code>. <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Date.now()</code> is, but its 1 ms resolution causes its own problem: at 48 kHz the quantum is 2.67 ms, so two consecutive quanta can land on the same millisecond tick, producing 0 ms intervals that accumulate into spurious spikes. Raw intervals from <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Date.now()</code> have correct mean but meaningless stddev.</p><p>The fix is to stop measuring intervals and measure <strong>drift from expected time</strong> instead. Anchor to both <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">currentFrame</code> (exact integer sample count) and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Date.now()</code> (coarse wall clock) at the first quantum:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{drift}[n] = t_{wall}[n] - \left( t_0 + \frac{f[n] - f_0}{f_s} \times 1000 \right)$$</pre><p>where <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$f[n]$</code> is <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">currentFrame</code> at quantum <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n$</code> and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$f_0$</code>, <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$t_0$</code> are the anchor values. <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">currentFrame</code> advances in exact samples with no resolution loss. Even with 1 ms <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Date.now()</code> resolution, drift is meaningful: an 8 ms CPU preemption produces ~8 ms drift regardless of rounding. The standard deviation of drift samples is the canonical jitter metric.</p><span class="flex items-center gap-3 font-bold text-heading/80 text-sm tracking-tight"><span class="text-accent">◈</span> Why drift and not intervals? </span><span class="duration-300 group-open:rotate-180 transition-transform text-muted">↓</span><p>An interval measures the gap between two coarse timestamps. A drift measures the deviation of one coarse timestamp from a precise prediction. The prediction is exact (derived from <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">currentFrame</code>), so the noise is one-sided: only <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Date.now()</code> contributes coarsening, not both endpoints. This halves the effective resolution noise compared to raw intervals.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Non-Obvious Bugs</h2><p><strong><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">onStateChange</code> null dereference.</strong> The original listener:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">._ctx?.</span><span style="color:#b392f0">addEventListener</span><span style="color:#e1e4e8">(</span><span style="color:#9ecbff">'statechange'</span><span style="color:#e1e4e8">, () </span><span style="color:#f97583">=&gt;</span><span style="color:#b392f0"> cb</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">._ctx</span><span style="color:#f97583">!</span><span style="color:#e1e4e8">.state));</span></span></code></pre><p><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">AudioContext.close()</code> sets <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">this._ctx = null</code>, then the context fires a final <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">statechange</code> event with state <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">"closed"</code>. The <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">!</code> dereferences null and throws. Fix: read state from the event target, not the instance field:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">._ctx?.</span><span style="color:#b392f0">addEventListener</span><span style="color:#e1e4e8">(</span><span style="color:#9ecbff">'statechange'</span><span style="color:#e1e4e8">, (</span><span style="color:#ffab70">e</span><span style="color:#e1e4e8">) </span><span style="color:#f97583">=&gt;</span></span>
<span class="line"><span style="color:#b392f0">  cb</span><span style="color:#e1e4e8">((e.target </span><span style="color:#f97583">as</span><span style="color:#b392f0"> AudioContext</span><span style="color:#e1e4e8">).state)</span></span>
<span class="line"><span style="color:#e1e4e8">);</span></span></code></pre><p><strong><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">this._graph!</code> after <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">await</code> in teardown/rebuild chain.</strong></p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">._graph.</span><span style="color:#b392f0">setFAFNodes</span><span style="color:#e1e4e8">([])</span></span>
<span class="line"><span style="color:#e1e4e8">  .</span><span style="color:#b392f0">then</span><span style="color:#e1e4e8">(() </span><span style="color:#f97583">=&gt;</span><span style="color:#79b8ff"> this</span><span style="color:#e1e4e8">._graph</span><span style="color:#f97583">!</span><span style="color:#e1e4e8">.</span><span style="color:#b392f0">setFAFNodes</span><span style="color:#e1e4e8">(semitones)) </span><span style="color:#6a737d">// throws if DAF stopped mid-await</span></span></code></pre><p>The user can stop DAF between teardown and rebuild. The <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">!</code> assertion fires on null. Fix: <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">this._graph?.setFAFNodes(semitones)</code>, which silently no-ops if the session ended.</p><p><strong><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">activateModeBtn</code> called before declaration.</strong> Chrome hoists block-scoped function declarations within a block; Firefox and Safari do not (ES2015 strict mode). The initialisation call to <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">activateModeBtn('faf-type', 'single')</code> preceded the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">function</code> declaration by 10 lines within the same <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">DOMContentLoaded</code> callback. This works on Chrome but throws a <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">TypeError</code> on Firefox. Fixed by reordering the declaration above the call.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">OLA vs PSOLA: When Each is Appropriate</h2><table style="margin-top:1em;border-collapse:collapse;width:100%"><thead><tr><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"></th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">OLA (Standard)</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">PSOLA (High-Fidelity, Default)</th></tr></thead><tbody><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Latency @ 120 Hz floor</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">≈ 10.7 ms (fixed)</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">≈ 8.3 ms</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Quality at ±4 st</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Audible flutter on vowels</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Phase-coherent, natural</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Requires voice type config</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">No</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Yes</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Computational cost</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Low</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Low + YIN every ~20 ms</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Unvoiced handling</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Direct passthrough</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Direct passthrough</td></tr></tbody></table><p>High-Fidelity (PSOLA) is the default and the correct choice for most therapy use. The latency advantage that once favoured OLA no longer exists — at 120 Hz voice floor PSOLA is now marginally faster. Standard (OLA) is appropriate when a fixed, voice-type-independent latency is needed without any configuration, or when the semitone shift is small (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\leq 2$</code> st) and phase coherence is less critical.</p><ul><li>You can try it yourself at <a href="https://korayulusan.github.io/delayed-auditory-feedback-online/" style="color:#70a5fd">DAF Online</a>.</li></ul><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">References</h2><ul><li>Natke, U., et al. (2001). <a href="https://www.researchgate.net/publication/222671766_Fluency_fundamental_frequency_and_speech_rate_under_frequency-shifted_auditory_feedback_in_stuttering_and_nonstuttering_persons" style="color:#70a5fd">Fluency, fundamental frequency, and speech rate under frequency-shifted auditory feedback in stuttering and nonstuttering persons.</a> Journal of the Acoustical Society of America.</li><li>Kalinowski, J., et al. (1996). <a href="https://pubs.asha.org/doi/abs/10.1044/jshr.3902.396" style="color:#70a5fd">Effect of alterations in auditory feedback and speech rate on stuttering frequency.</a> Journal of Speech and Hearing Research, 39, 396–407.</li></ul><p class="mt-8 text-muted/80">Thanks for reading! If you have questions, feedback, or want to discuss a project, feel free to drop me an <a style="color:#70a5fd"><span>email</span></a>or message.</p>
      <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center; font-family: 'JetBrains Mono', monospace; color: #9ca3af;">
        <span style="color: #ffb86c;">◆</span> 
        Originally published at 
        <a href="https://korayulusan.github.io/blog/frequency-altered-feedback-browser-psola-ola-speech-therapy/" style="color: #ffb86c; text-decoration: none; border-bottom: 1px solid #ffb86c;">korayulusan.github.io</a>
      </div>
    </div>]]></content:encoded>
  </item>

  <item>
    <title>The 1000th Sheet of A4 Paper</title>
    <link>https://korayulusan.github.io/blog/the-1000th-sheet-of-a4-paper/</link>
    <guid isPermaLink="true">https://korayulusan.github.io/blog/the-1000th-sheet-of-a4-paper/</guid>
    <pubDate>Fri, 22 May 2026 05:54:42 GMT</pubDate>
    <description>Today marks the 1000th Sheet of A4 Paper I&apos;ve used since I began university.</description>
    <media:content url="https://korayulusan.github.io/og/blog/the-1000th-sheet-of-a4-paper-landscape-thumbnail.jpeg" medium="image" />
    <enclosure url="https://korayulusan.github.io/og/blog/the-1000th-sheet-of-a4-paper-landscape-thumbnail.jpeg" length="0" type="image/jpeg" />
    <content:encoded><![CDATA[<div style="background-color: #1c1c1d; color: #e8e8e8; font-family: sans-serif;"><p>Today marks the 1000th sheet of A4 paper I’ve used since I began university back in 2020.<sup class="footnote-ref"><a href="#fn-1" style="color:#70a5fd">1</a></sup> My studying technique is writing what I’ve learned using pen and paper. All the theoretical knowledge, math, and most stuff that isn’t code belong on paper. Typing these ideas is less memorable compared to writing with pen and paper. Despite this, flashcards belong to Anki.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose sm:w-[50%]"><p></p><figcaption class="text-sm leading-relaxed max-w-[90%] mt-4 mx-auto text-center text-muted/80" style="text-align:center"><span class="font-bold text-muted">Figure 1:</span> A magazine file of my handwritten university notes.</figcaption></figure><p>I was close to running out of paper, so I bought another 500-sheet stack. When buying paper, you’ll find there are a lot of options if you look closely. I want to write briefly about A4 paper types for writing with a pen from a consumer perspective. Quality Class A is really nice to write on. Quality Class C is okay at the end of the day. Since I don’t use that much paper, I think using Class A would be a good choice to write on. Here are the details:</p><ul><li><strong>Paper Weight (GSM):</strong> <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$80~\text{g/m}^2$</code> is your standard copy paper, while <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$90~\text{g/m}^2$</code> is heavier and feels nicer. <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$80~\text{g/m}^2$</code> works fine.</li><li><strong>Whiteness (CIE):</strong> Higher CIE means brighter paper. I prefer around 170 CIE for better contrast. When writing with a pen, I really like 170 CIE. 145 CIE is your average white paper, and when you go down to 100 CIE, it’s noticeably duller. I thought about including an comparisonimage here but digital screens doesn’t do it justice.<br />It is measured via a spectrophotometer that shines light on the paper.</li><li><strong>Smoothness:</strong> Well, you guessed it right—it’s how smooth it is. The smoother the paper, the more time it can take to absorb ink, which might lead to smudging if you’re not careful. 220 ml/min is good for ballpoint pens, but 170 ml/min feels nicer to touch.<br />It’s measured via the Bendtsen method, where a ring is pressed against the paper and the air volume passing between the ring and paper is measured.</li><li><strong>Opacity (Ghosting):</strong> Higher opacity means less ink bleed-through. 93% is good for single-sided writing, while 95% opacity is good for double-sided writing. Usually, this isn’t a big issue as I mostly write using a pencil.</li><li><strong>Glossy vs. Matte:</strong> For writing, matte is usually better, and glossy is more for printing photos.</li></ul><p>If you want to dig deeper, there is literature about its <a href="https://www.google.com/search?q=Paper:%20Paging%20Through%20History" style="color:#70a5fd">history</a>, <a href="https://www.google.com/search?q=Handbook%20for%20Pulp%20and%20Paper%20Technologist" style="color:#70a5fd">manufacturing</a>, <a href="https://www.google.com/search?q=Handbook%20of%20Paper%20and%20Board%20by%20Herbert%20Holik" style="color:#70a5fd">measurement</a>, and more. But it’s not my expertise.</p><h2 class="text-sm font-semibold mb-4 pb-0 text-heading tracking-widest uppercase" style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Footnotes</h2><ol class="text-sm pl-6 space-y-4 text-body-text/90"><li class="footnote-item" style="list-style-type:decimal !important"><span>It's probably more than that; I use the uni's printers, some copy shops, and other A4 stacks that are not in my home.</span><a href="#fnref-1" class="text-accent/90 footnote-back hover:underline ml-2 no-underline text-xs" title="Back to text" style="color:#70a5fd">↩</a></li></ol><p class="mt-8 text-muted/80">Thanks for reading! If you have questions, feedback, or want to discuss a project, feel free to drop me an <a style="color:#70a5fd"><span>email</span></a>or message.</p>
      <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center; font-family: 'JetBrains Mono', monospace; color: #9ca3af;">
        <span style="color: #ffb86c;">◆</span> 
        Originally published at 
        <a href="https://korayulusan.github.io/blog/the-1000th-sheet-of-a4-paper/" style="color: #ffb86c; text-decoration: none; border-bottom: 1px solid #ffb86c;">korayulusan.github.io</a>
      </div>
    </div>]]></content:encoded>
  </item>

  <item>
    <title>The Tech Stack Behind This Site</title>
    <link>https://korayulusan.github.io/blog/the-tech-stack-behind-this-site/</link>
    <guid isPermaLink="true">https://korayulusan.github.io/blog/the-tech-stack-behind-this-site/</guid>
    <pubDate>Wed, 22 Apr 2026 05:54:40 GMT</pubDate>
    <description>Technologies and tools I used to build this website.</description>
    <media:content url="https://korayulusan.github.io/og/blog/the-tech-stack-behind-this-site-landscape-thumbnail.jpeg" medium="image" />
    <enclosure url="https://korayulusan.github.io/og/blog/the-tech-stack-behind-this-site-landscape-thumbnail.jpeg" length="0" type="image/jpeg" />
    <content:encoded><![CDATA[<div style="background-color: #1c1c1d; color: #e8e8e8; font-family: sans-serif;"><p>I built this website in April 2026 using <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Astro 6</code>, <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Tailwind CSS 4</code> and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">MDX</code>. My stack centers on a <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Vite 7</code>-powered pipeline optimized with <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Terser</code> and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">astro-compress</code>. The <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\LaTeX$</code> equations are rendered using <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">remark-math</code> and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">rehype-katex</code>. I also have a <a href="https://korayulusan.github.io/rss.xml" style="color:#70a5fd">RSS feed</a> with a beautiful <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">xsl</code>.</p><p>In late April, I’ve added <a href="https://korayulusan.github.io/photos" style="color:#70a5fd">/photos</a> with some tweaks to css, along with a thumbnail renderer using the good old <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">Canvas API</code> for OpenGraph images and JSON-LD. This allows me to add a “<em>share as image</em>” button to my posts, which generates a thumbnail on the fly with the post title and a nice background. Of course /photos has <a href="https://korayulusan.github.io/photos/photos-rss.xml" style="color:#70a5fd">its own RSS feed</a> as well.</p><p>The entire lifecycle, from transforming <em>content collections</em> to final minification and deployment to <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">GitHub Pages</code>, is orchestrated via <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">make</code> utilizing <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">exiftool</code> and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">gh</code> to ensure a lean, production-ready build. The unsung heroes include <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">awk</code>, <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">xargs</code>, <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">find</code>, and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">grep</code>. I’ve opted to <a href="https://github.com/KorayUlusan/korayulusan.github.io" style="color:#70a5fd">ship the site as a compressed binary artifact</a> rather than loose files to the GitHub repo to provide a layer of mild obfuscation to deter casual scrapers and bots.</p><p>Rather than inheriting the legacy overhead of <em>al-folio</em> or similar, I spent three days engineering a custom solution tailored to my specific needs. The second iteration took 2 to 3 days as well. Building from scratch ensured total control over the stack, and while it took a few more days to iron out the finer details, I’m happy with the result. I hope you like it, too!</p><p class="mt-8 text-muted/80">Thanks for reading! If you have questions, feedback, or want to discuss a project, feel free to drop me an <a style="color:#70a5fd"><span>email</span></a>or message.</p>
      <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center; font-family: 'JetBrains Mono', monospace; color: #9ca3af;">
        <span style="color: #ffb86c;">◆</span> 
        Originally published at 
        <a href="https://korayulusan.github.io/blog/the-tech-stack-behind-this-site/" style="color: #ffb86c; text-decoration: none; border-bottom: 1px solid #ffb86c;">korayulusan.github.io</a>
      </div>
    </div>]]></content:encoded>
  </item>

  <item>
    <title>I Built a Free DAF Tool to Replace OS Native Paid Apps</title>
    <link>https://korayulusan.github.io/blog/i-built-delayed-auditory-feedback-online-tool/</link>
    <guid isPermaLink="true">https://korayulusan.github.io/blog/i-built-delayed-auditory-feedback-online-tool/</guid>
    <pubDate>Mon, 20 Apr 2026 05:39:25 GMT</pubDate>
    <description>DAF devices cost hundreds of dollars so I built a sub-6ms browser-based alternative that&apos;s free, instant, and reaches 500+ users a month.</description>
    <media:content url="https://korayulusan.github.io/og/blog/i-built-a-free-daf-tool-to-replace-os-native-paid-landscape-thumbnail.jpeg" medium="image" />
    <enclosure url="https://korayulusan.github.io/og/blog/i-built-a-free-daf-tool-to-replace-os-native-paid-landscape-thumbnail.jpeg" length="0" type="image/jpeg" />
    <content:encoded><![CDATA[<div style="background-color: #1c1c1d; color: #e8e8e8; font-family: sans-serif;"><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">TL;DR</h3><p>I built <strong>DAF Online</strong>, a <a href="https://korayulusan.github.io/delayed-auditory-feedback-online/" style="color:#70a5fd">free, browser-based tool for speech therapy</a> that helps people who stutter and Parkinson’s patients find fluency. While native apps and $1000 hardware exist, I used the <strong>Web Audio API</strong> to achieve sub-6ms latency in the browser by aggressively optimizing the audio graph.</p><p><strong>Update:</strong> I’ve added Frequency Altered Feedback (FAF) to the existing tool. It’s more complex to implement due to buffering requirements, but it opens up another therapeutic avenue. You can <a href="http://korayulusan.github.io/blog/frequency-altered-feedback-browser-psola-ola-speech-therapy" style="color:#70a5fd">read more about it here</a>.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">What is Delayed Auditory Feedback?</h2><p>Delayed Auditory Feedback (DAF) is simple: you hear your own voice played back with a short delay. What’s less obvious is what that tiny lag does to your brain.</p><p>For people who stutter, speaking while hearing a slightly delayed version of your own voice can induce near-instant fluency. It’s called the <strong>Chorus Effect</strong>. Your brain perceives a second speaker and shifts into a different, more fluid processing mode. The same principle is used by speech-language pathologists (SLPs) for Parkinson’s patients, where the delay acts as a natural “speed limit,” forcing slower, more deliberate speech.</p><p>The tool has three core audiences: people who stutter, individuals with Parkinson’s Disease, and SLPs running remote telehealth sessions who need a quick, zero-friction way to get a patient practicing from home.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="The DAF Online interface featuring a clean, minimalist design with a central 'Start' button and a delay slider." height="1458" src="https://korayulusan.github.io/static/delayed-auditory-feedback-online-user-interface.CZBQFSuM_1UuCwj.webp" width="1858" class="w-full block h-full m-0 object-cover border-2 border-border" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 leading-relaxed max-w-[90%] mt-4 mx-auto text-center" style="text-align:center"><span class="font-bold text-muted">Figure 1:</span> The DAF Online interface featuring a clean, minimalist design with a central 'Start' button and a delay slider.</figcaption></figure><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Landscape Before I Built This (Early 2025)</h2><p>When I went looking for a free, browser-based DAF tool, I found: nothing that actually worked.</p><p>The market looked roughly like this:</p><ul><li><strong>Dedicated hardware</strong> (e.g., <a href="https://casafuturatech.com/daf-devices/" style="color:#70a5fd">Casa Futuro</a>, <a href="https://www.speecheasy.com/" style="color:#70a5fd">SpeechEasy</a>): $1000-$2500+. Clinically validated, but you need to order, wait, and pay.</li><li><strong>Native mobile apps (e.g., <a href="https://speechtools.co/daf-pro" style="color:#70a5fd">DAF Pro</a>)</strong>: A handful exist on iOS and Android. Some are free-tier, most push you toward a subscription. They work reasonably well on modern phones.</li><li><strong>Web-based pages</strong>: The few I found were marketing funnels pointing back to the native apps, or had limited functionality, or long delays. No one had built an actual working web implementation you could just… open and use.</li><li><strong>The “Developer Gap”</strong>: I found a few GitHub repositories that implemented DAF logic. Some used the Web Audio API, while others were native C++ or Python implementations. Their problem was that they weren’t hosted. Just code sitting in a repo.</li></ul><p>The implementation isn’t complex. The Web Audio API has had a <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">DelayNode</code> for years. The gap wasn’t technical; nobody had simply bothered to close it.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Math: What’s Actually Happening</h2><p>The feedback loop is simple. The output signal is the input signal shifted in time:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$y[n] = \alpha \cdot x[n - (k_{user} + k_{sys})]$$</pre><p>Where:</p><ul><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$y[n]$</code>: The signal the user hears at time <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n$</code></li><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$x[n]$</code>: The user’s voice entering the microphone</li><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{user}$</code>: <strong>Intentional Lag</strong> is the delay you dial in</li><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{sys}$</code>: <strong>System Floor</strong> is the device lag. the hidden hardware/OS latency floor</li><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\alpha$</code>: Gain (volume)</li></ul><p>Then the effective delay <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{eff}$</code> is the sum of the intentional lag and the device lag:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$k_{eff} = k_{user} + k_{sys}$$</pre><p>The variable most people ignore is <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{sys}$</code>. It’s not zero. And if it’s high, your “50ms delay” is actually 100ms, which is a qualitatively different therapeutic experience and potentially useless.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Why Latency Is the Whole Game</h2><p>For DAF to work therapeutically, the <strong>internal device latency</strong> needs to stay under <strong>15-20ms</strong>. That is the time your hardware and software spend processing audio <em>before</em> your intentional delay is added.</p><p>Here’s why it matters: if <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{sys}$</code> is already 50ms and you set a 50ms intentional delay, the user hears a 100ms echo. Worse, high internal latency usually comes with <strong>jitter</strong> (timing variance), which breaks the chorus effect entirely. Jitter makes the delay feel unstable. The brain doesn’t settle into choral mode, it just gets confused.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Latency Landscape by Device</h3><table style="margin-top:1em;border-collapse:collapse;width:100%"><thead><tr><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Setup</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Typical Internal Latency</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Verdict</th></tr></thead><tbody><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>Dedicated PC Drivers/Hardware</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><a href="https://soundorp.com/low-latency-audio-interface-top-10-picks/" style="color:#70a5fd">1 - 9ms</a></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Excellent</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>Dedicated DAF Hardware</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><a href="https://doi.org/10.1044/2020_JSLHR-19-00419" style="color:#70a5fd">&lt; 10 ms</a></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Excellent</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>High-End PC + Chrome<sup class="footnote-ref"><a href="#fn-1" style="color:#70a5fd">1</a></sup></strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">6 - 10 ms</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Excellent</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>iPhone 16 + Safari<sup class="footnote-ref"><a href="#fn-2" style="color:#70a5fd">2</a></sup></strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">13 ms</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Good</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>aptX Low Latency codec (Bluetooth)</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><a href="https://en.wikipedia.org/wiki/AptX" style="color:#70a5fd">40 ms</a></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Borderline</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>AAC codec (Bluetooth)</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><a href="https://besttechradar.com/sbc-vs-aac-bluetooth-codec/" style="color:#70a5fd">100 - 200 ms</a></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Unusable</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>SBC codec (Bluetooth)</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><a href="https://besttechradar.com/sbc-vs-aac-bluetooth-codec/" style="color:#70a5fd">150 - 250 ms</a></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Unusable</td></tr></tbody></table><p>NB: My implementation achieves latency around 6 to 13ms on modern hardware. The exact number depends on the device and browser.</p><p>This is why native apps have historically had an edge over web tools. iOS and Android give native audio code direct access to the hardware buffer. The browser sits a layer above that but with the right flags, you can close most of the gap.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">How to Test Your Own Floor</h3><p>Set the software delay to <strong>0 ms</strong>. Speak a sharp “P” or “K” sound. If it sounds like one sound, your floor is likely under 15ms. If it sounds like a double-hit or a slap-back echo, your internal latency is above 30ms and you should switch to a wired headset or a better audio driver before using the tool therapeutically.</p><span class="text-accent mb-2 block font-black text-[10px] tracking-[0.2em] uppercase">Avoid Bluetooth</span><p>Bluetooth headphones are incompatible with DAF therapy. Their 150-250ms hardware latency dwarfs any intentional delay you’d set, making the total delay unpredictable and therapeutically ineffective. Always use wired headphones.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">How It’s Built for Speed</h2><p>To be a legitimate alternative to dedicated hardware, the implementation needed to minimize <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k_{sys}$</code> as aggressively as possible. Three things matter most.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">1. Minimal Audio Graph Topology</h3><p>Every node in the Web Audio API graph adds overhead. The final implementation uses a lean, four-node linear chain. No branches, no unnecessary processing.</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#6a737d">// Minimal 2-hop topology for maximum performance</span></span>
<span class="line"><span style="color:#b392f0">_connectAudioNodes</span><span style="color:#e1e4e8">() {</span></span>
<span class="line"><span style="color:#f97583">    const</span><span style="color:#79b8ff"> nodes</span><span style="color:#f97583"> =</span><span style="color:#79b8ff"> this</span><span style="color:#e1e4e8">.audioNodes;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6a737d">    // source (Mic) -&gt; delay (DAF) -&gt; gain (Vol) -&gt; destination (Output)</span></span>
<span class="line"><span style="color:#e1e4e8">    nodes.source.</span><span style="color:#b392f0">connect</span><span style="color:#e1e4e8">(nodes.delayNode);</span></span>
<span class="line"><span style="color:#e1e4e8">    nodes.delayNode.</span><span style="color:#b392f0">connect</span><span style="color:#e1e4e8">(nodes.gainNode);</span></span>
<span class="line"><span style="color:#e1e4e8">    nodes.gainNode.</span><span style="color:#b392f0">connect</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">.audioContext.destination);</span></span>
<span class="line"><span style="color:#e1e4e8">}</span></span></code></pre><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">2. Requesting Hardware-Level Latency</h3><p>Browsers default to an “interactive” latency mode (~50ms buffer). Setting <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">latencyHint: 0</code> tells the browser to <a href="https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/AudioContext#latencyhint" style="color:#70a5fd">request the minimum buffer size the hardware allows</a>. Matching the native device sample rate eliminates resampling lag.</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#b392f0">_createAudioContext</span><span style="color:#e1e4e8">() {</span></span>
<span class="line"><span style="color:#f97583">    const</span><span style="color:#79b8ff"> contextOptions</span><span style="color:#f97583"> =</span><span style="color:#e1e4e8"> {</span></span>
<span class="line"><span style="color:#6a737d">        // Request absolute minimum buffer size from hardware</span></span>
<span class="line"><span style="color:#e1e4e8">        latencyHint: </span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#e1e4e8">    };</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6a737d">    // Match native hardware sample rate to bypass resampling lag</span></span>
<span class="line"><span style="color:#f97583">    if</span><span style="color:#e1e4e8"> (</span><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">.deviceSampleRate) {</span></span>
<span class="line"><span style="color:#e1e4e8">        contextOptions.sampleRate </span><span style="color:#f97583">=</span><span style="color:#79b8ff"> this</span><span style="color:#e1e4e8">.deviceSampleRate;</span></span>
<span class="line"><span style="color:#e1e4e8">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#79b8ff">    this</span><span style="color:#e1e4e8">.audioContext </span><span style="color:#f97583">=</span><span style="color:#f97583"> new</span><span style="color:#e1e4e8"> (window.AudioContext </span><span style="color:#f97583">||</span><span style="color:#e1e4e8"> window.webkitAudioContext)(contextOptions);</span></span>
<span class="line"><span style="color:#e1e4e8">}</span></span></code></pre><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">3. Honest Latency Measurement</h3><p>The tool reads <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">baseLatency</code> and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">outputLatency</code> directly from the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">AudioContext</code> and adds them to the display so the user always sees their <em>effective</em> delay, not just the slider value.</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#6a737d">// Measuring the true hardware "floor"</span></span>
<span class="line"><span style="color:#f97583">const</span><span style="color:#79b8ff"> outputMs</span><span style="color:#f97583"> =</span><span style="color:#e1e4e8"> (</span><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">.audioContext.baseLatency </span><span style="color:#f97583">+</span><span style="color:#e1e4e8"> (</span><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">.audioContext.outputLatency </span><span style="color:#f97583">||</span><span style="color:#79b8ff"> 0</span><span style="color:#e1e4e8">)) </span><span style="color:#f97583">*</span><span style="color:#79b8ff"> 1000</span><span style="color:#e1e4e8">;</span></span>
<span class="line"><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">.measuredFloorMs </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> outputMs;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6a737d">// UI shows both the target and the honest effective delay</span></span>
<span class="line"><span style="color:#f97583">const</span><span style="color:#79b8ff"> effective</span><span style="color:#f97583"> =</span><span style="color:#e1e4e8"> Math.</span><span style="color:#b392f0">round</span><span style="color:#e1e4e8">(targetDelay </span><span style="color:#f97583">+</span><span style="color:#e1e4e8"> measuredFloorMs);</span></span>
<span class="line"><span style="color:#79b8ff">this</span><span style="color:#e1e4e8">.displayLabel </span><span style="color:#f97583">=</span><span style="color:#9ecbff"> `${</span><span style="color:#e1e4e8">targetDelay</span><span style="color:#9ecbff">} ms (~${</span><span style="color:#e1e4e8">effective</span><span style="color:#9ecbff">} ms effective)`</span><span style="color:#e1e4e8">;</span></span></code></pre><p>This matters for trust. A user who sees “50ms (est. 56ms effective)” understands their setup. A user who sees “50ms” and hears 100ms thinks the tool is broken.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Frequency Altered Feedback (FAF)</h2><p>I havent gotten around implementing FAF yet, but its also used in speech therapy. Instead of delaying the signal, it shifts the pitch up or down. The effect is similar: it disrupts the brain’s normal feedback loop and can improve fluency for some users. It’s on the roadmap for a future update, but DAF was the priority since it’s more widely used and has a clearer latency requirement.</p><p>Implementing FAF is a challenge because it requires buffering audio to analyze and shift the frequency content. The buffering means added latency, which can break the therapeutic effect if it exceeds the 15-20ms threshold. More on that in the deep dive below.</p><span class="flex items-center gap-3 font-bold text-heading/80 text-sm tracking-tight"><span class="text-accent">◈</span> FAF Algorithms and the Buffer Tax </span><span class="transition-transform duration-300 group-open:rotate-180 text-muted">↓</span><p>FAF shifts your voice up or down in pitch rather than delaying it. The therapeutic mechanism is similar, but the implementation is messier.</p><p>The problem is that you can’t shift pitch without first buffering a chunk of audio to analyze. A simple delay line just holds samples and replays them. Pitch shifting has to look at a window of the signal before it can do anything, which means latency before your intentional delay is even added.</p><p>At 44.1 kHz, the relationship is straightforward:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$L_{ms} = \frac{N}{f_s} \cdot 1000$$</pre><table style="margin-top:1em;border-collapse:collapse;width:100%"><thead><tr><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Buffer Size (Samples)</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Latency Added (at 44.1kHz)</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Therapeutic Verdict</th></tr></thead><tbody><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>128</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">~2.9 ms</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Fine</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>256</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">~5.8 ms</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Fine</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>512</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">~11.6 ms</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Borderline</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left"><strong>1024</strong></td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">~23.2 ms</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Already over the threshold</td></tr></tbody></table><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Why you can’t skip the buffer</h3><p>The naive fix is sample-by-sample processing: shift pitch like a sped-up record. That works for about half a second until the playback outruns the input and you get a gap. To keep the feedback in sync with actual speech rate, you need time-domain splicing (SOLA): small grains of audio, cross-faded together. That requires an <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">AudioWorklet</code> and a minimum window size.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Phase vocoders vs. granular synthesis</h3><p>Phase vocoders do this better perceptually. They use FFTs to shift pitch cleanly with no metallic artifacts. The catch is they need large buffers for frequency resolution, typically 1024 samples or more, which puts you at 23ms of algorithmic latency before anything else. That’s already past the cutoff.</p><p>Granular synthesis sounds rougher, but it runs on 128 or 256 samples. For this use case, a slightly robotic voice at 10ms beats a natural-sounding one at 40ms.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">SEO: Why the Body Text Is Long (On Purpose)</h2><p>“Delayed Auditory Feedback” is an incredibly niche topic. If you <a href="https://trends.google.com/explore?q=%2Fm%2F0wpkvb1%2C%2Fm%2F070yw&amp;date=all&amp;geo=Worldwide" style="color:#70a5fd">compare it to a broader term like “Stuttering” in Google Trends</a>, you can see how small the specific search market is for the tool itself compared to the condition it treats.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="Delayed Auditory Feedback (Blue) vs Stuttering (Red) search interest over time according to Google Trends" height="830" src="https://korayulusan.github.io/static/delayed-auditory-feedback-google-trends-stats.BycJ2d-G_13S4eo.webp" width="1488" class="w-full block h-full m-0 object-cover" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 leading-relaxed max-w-[90%] mt-4 mx-auto text-center" style="text-align:center"><span class="font-bold text-muted">Figure 2:</span> Delayed Auditory Feedback (Blue) vs Stuttering (Red) search interest over time according to Google Trends</figcaption></figure><p>Building the tool was the easy part. Getting it in front of people who need it took just as long.</p><p>Most users arrive via high-intent functional queries. They know what a DAF tool is. They just need to find one that works. <strong>A minimal landing page with a slider and a button would rank for nothing.</strong> In the first months, the tool was <strong>invisible</strong> to these high-intent users, stalled at 11th-15th in the rankings while hardware retailers and native apps claimed the top spots.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="Google Search Console stats since the tool's launch" height="814" src="https://korayulusan.github.io/static/delayed-auditory-feedback-search-console-stats.D8aWFvra_2vcVfO.webp" width="2238" class="w-full block h-full m-0 object-cover" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 leading-relaxed max-w-[90%] mt-4 mx-auto text-center" style="text-align:center"><span class="font-bold text-muted">Figure 3:</span> Google Search Console stats since the tool's launch</figcaption></figure><p>By writing thorough, accurate content about the science and the use cases, the site achieved a <strong>2.4 weighted average position</strong> for core keywords, capturing <strong>85% of organic traffic</strong> from the top 3 results with a <strong>75% CTR</strong> on primary search intent (Mar 2026).</p><blockquote style="border-left:4px solid rgba(255, 255, 255, 0.1);padding-left:15px;color:#9ca3af;font-style:italic;margin:10px 0"><p>Fun Fact: As it turns out, “DAF” is a congested acronym dominated by Dutch heavy-duty truck manufacturer <a href="https://www.daf.com/" style="color:#70a5fd">DAF Trucks N.V</a>. If you search “DAF” without context, Google assumes you’re looking for a 7.5-ton hauler, not a speech aid.</p></blockquote><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Conclusion</h2><p>With the right initialization flags and a minimal graph topology, a browser-based DAF tool can match native app latency on decent hardware, with no install, no account, and no payment required.</p><p>The gap wasn’t a hard engineering problem; it was just an ignored one. Speech therapy is a small market, and most developers aren’t building for people who stutter or have Parkinson’s. Which is why it was worth doing.</p><p>If you want to look at the implementation or try the tool yourself:</p><ul><li><strong><a href="https://korayulusan.github.io/delayed-auditory-feedback-online/" style="color:#70a5fd">DAF Online — Try it here!</a></strong></li><li><strong><a href="https://github.com/korayulusan/delayed-auditory-feedback-online" style="color:#70a5fd">Source code on GitHub</a></strong></li></ul><h2 class="text-sm font-semibold mb-4 pb-0 text-heading tracking-widest uppercase" style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Footnotes</h2><ol class="text-sm pl-6 space-y-4 text-body-text/90"><li style="list-style-type:decimal !important" class="footnote-item"><span>My implementation.</span><a href="#fnref-1" class="text-accent/90 footnote-back hover:underline ml-2 no-underline text-xs" title="Back to text" style="color:#70a5fd">↩</a></li><li style="list-style-type:decimal !important" class="footnote-item"><span>My implementation.</span><a href="#fnref-2" class="text-accent/90 footnote-back hover:underline ml-2 no-underline text-xs" title="Back to text" style="color:#70a5fd">↩</a></li></ol><p class="mt-8 text-muted/80">Thanks for reading! If you have questions, feedback, or want to discuss a project, feel free to drop me an <a style="color:#70a5fd"><span>email</span></a>or message.</p>
      <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center; font-family: 'JetBrains Mono', monospace; color: #9ca3af;">
        <span style="color: #ffb86c;">◆</span> 
        Originally published at 
        <a href="https://korayulusan.github.io/blog/i-built-delayed-auditory-feedback-online-tool/" style="color: #ffb86c; text-decoration: none; border-bottom: 1px solid #ffb86c;">korayulusan.github.io</a>
      </div>
    </div>]]></content:encoded>
  </item>

  <item>
    <title>Enhancing Facial Realism with Synthetic Data Augmentation</title>
    <link>https://korayulusan.github.io/blog/genai-synthetic-data-facial-resemblance-dreambooth-instantid/</link>
    <guid isPermaLink="true">https://korayulusan.github.io/blog/genai-synthetic-data-facial-resemblance-dreambooth-instantid/</guid>
    <pubDate>Wed, 28 May 2025 05:39:11 GMT</pubDate>
    <description>Blog post on research published at a CVPR 2025 Workshop by Ulusan, K., &amp; Kiefer, B; Generating synthetic data via augmentations for improved facial resemblance in DreamBooth and InstantID.</description>
    <media:content url="https://korayulusan.github.io/og/blog/enhancing-facial-realism-with-synthetic-data-augme-landscape-thumbnail.jpeg" medium="image" />
    <enclosure url="https://korayulusan.github.io/og/blog/enhancing-facial-realism-with-synthetic-data-augme-landscape-thumbnail.jpeg" length="0" type="image/jpeg" />
    <content:encoded><![CDATA[<div style="background-color: #1c1c1d; color: #e8e8e8; font-family: sans-serif;"><p>This post explores research originally presented at the <a href="#citation" style="color:#70a5fd">CVPR 2025 Workshop on Synthetic Data for Computer Vision</a> (SynData4CV).</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">TL;DR</h3><p>Instead of using “classical” image tweaks like flipping or rotating, which actually distort facial identity, this research proves that using <strong>InstantID</strong> to generate high-quality synthetic portraits as training data significantly improves a <strong>DreamBooth</strong> model’s ability to produce realistic, professional-grade headshots.</p><figure class="my-10 fig-root md:my-12 mx-auto not-prose"><img alt="Schematic diagram showing the pipeline from real images to synthetic dataset generation using InstantID and DreamBooth." height="904" src="https://korayulusan.github.io/static/genai-synthetic-data-augmentation-pipeline.w29XHU9g_ZMEplz.webp" width="2252" class="block h-full m-0 object-cover w-full bg-white opacity-95 p-4" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-relaxed max-w-[90%] mt-4 mx-auto" style="text-align:center"><span class="font-bold text-muted">Figure 1:</span> Schematic diagram showing the pipeline from real images to synthetic dataset generation using InstantID and DreamBooth.</figcaption></figure><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Problem with Few-Shot AI Portraits</h2><p>You have five casual phone photos and want a polished LinkedIn headshot. Sounds like a job for a text-to-image model, right? In theory, yes. In practice, personalized diffusion models like <em>DreamBooth</em> struggle with a bottleneck of identity retention: they need to learn <em>who you are</em> from a tiny handful of images, then generalize that identity to entirely new scenes and styles.</p><p>This is the few-shot personalization problem. It sits at the tension between two competing goals: <strong>identity retention</strong> (the output should actually look like you) and <strong>recontextualization</strong> (you should be placeable in any scene the user prompts). Most standard training pipelines lean hard in one direction or the other. My research, published as <a href="#citation" style="color:#70a5fd">“Generating Synthetic Data via Augmentations for Improved Facial Resemblance in DreamBooth and InstantID”</a>, investigates a third path: using one generative model to improve the training of another.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Why Classical Augmentations Backfire</h2><p>When deep learning practitioners want more training data, the first instinct is to reach for classical augmentations: random flips, crops, rotations, colour jitter. These are reliable staples for large-scale classification tasks. For few-shot face personalization, they are a trap.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Geometric traps</h3><p><strong>Random Horizontal Flip</strong> seems harmless, but faces are subtly asymmetric. A mole on the left cheek, a slightly crooked smile, the direction of a part in the hair: flipping these teaches the model a <em>second</em> identity that contradicts the first. Rather than generalizing, the model averages them into an uncanny composite.</p><p><strong>Random Rotation</strong> introduces black padding bars around the frame, which the model dutifully learns as part of the subject’s visual signature. It also misaligns facial landmarks, undermining the spatial consistency that makes face generation coherent.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Colour confusion</h3><p><strong>Colour Jittering</strong> (tweaking brightness, contrast, saturation, and hue) causes the model to incorrectly associate those shifts with the rare token representing your subject. The result is erratic generations where the subject might appear with an alien skin tone or under lighting that was never in any real photograph.</p><figure class="my-10 fig-root md:my-12 mx-auto not-prose"><figure class="flex flex-col gap-2.5 m-0"><img alt="Skin tone discoloration and hue jitter artifacts caused by classical color augmentations." height="1333" src="https://korayulusan.github.io/static/dreambooth-hue-jitter-discoloration-artifact.juBlnH-o_1NTxo7.webp" width="1000" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 2:</span> Skin tone discoloration and hue jitter artifacts caused by classical color augmentations.</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Rotation artifacts that always position the subject in tilted positions." height="1333" src="https://korayulusan.github.io/static/ai-portrait-rotation-artifact-padding.DJWcrjVc_2gdfAk.webp" width="1000" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 3:</span> Rotation artifacts that always position the subject in tilted positions.</figcaption></figure></figure><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Segmentation imperfections</h3><p>Replacing backgrounds using a segmentation model like <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$U^2$</code>-Net sounds like a clean solution to background leakage. In practice, the segmentation boundary around fine hair creates a blended halo artifact. The model then learns that wispy, semi-transparent fringe is part of the subject’s identity, making clean background swaps nearly impossible downstream.</p><p>The pattern is the same across all of these: classical augmentations introduce distributional artifacts, and the model, with no other signal to reject them, faithfully memorizes those artifacts as identity-defining features.</p><span class="block text-[10px] uppercase font-black mb-2 text-accent tracking-[0.2em]">Key Takeaway</span><p>Classical augmentations introduce distributional artifacts. With no other signal to reject them, the model faithfully memorizes these artifacts as identity-defining features.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">A New Approach: GenAI Improving GenAI</h2><p>Instead of perturbing real images in ways that corrupt facial structure, the approach explored in this paper asks a different question: <em>what if the augmented images were themselves high-quality generations of the person, produced by a model that already understands faces?</em></p><p>The answer is <strong>generative augmentation via InstantID</strong>. By conditioning InstantID on a subject’s facial landmarks and a set of reference images, we can synthesize diverse, photo-realistic portraits of that person across varied poses, lighting conditions, and contexts, all while preserving the structural integrity of their face. These synthetic images already live in the diffusion model’s feature space, so DreamBooth does not have to reconcile the domain gap that plagues classical augmentations.</p><p>The result is measurably better facial resemblance in the fine-tuned DreamBooth model, with the full range of recontextualization still intact.</p><figure class="my-10 fig-root md:my-12 mx-auto not-prose"><img alt="Technical workflow architecture illustrating the integration of InstantID and DreamBooth for facial fidelity." height="742" src="https://korayulusan.github.io/static/instantid-dreambooth-workflow-diagram.BvKQXwyO_1t84E0.webp" width="2376" class="block h-full m-0 object-cover w-full bg-white opacity-95 p-4" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-relaxed max-w-[90%] mt-4 mx-auto" style="text-align:center"><span class="font-bold text-muted">Figure 4:</span> Technical workflow architecture illustrating the integration of InstantID and DreamBooth for facial fidelity.</figcaption></figure><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Practical Takeaways</h2><p>These findings translate directly into actionable recommendations for anyone building portrait personalization pipelines.</p><figure class="my-10 fig-root md:my-12 mx-auto not-prose"><figure class="flex flex-col gap-2.5 m-0"><img alt="Loss in facial detail caused by bicubic downsampling." height="1000" src="https://korayulusan.github.io/static/low-resolution-bicubic-downsampling-artifacts.CjcDl4AL_ZaYVPR.webp" width="1499" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 5:</span> Loss in facial detail caused by bicubic downsampling.</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Comparison of standard upscaling versus high-fidelity ESRGAN enhancement." height="1000" src="https://korayulusan.github.io/static/esrgan-upscaling-facial-detail-comparison.BNop2tSC_eBSVq.webp" width="1500" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 6:</span> Comparison of standard upscaling versus high-fidelity ESRGAN enhancement.</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Impact of resampling on facial feature sharpness." height="1000" src="https://korayulusan.github.io/static/resolution-interpolation-resampling-comparison.sooEzftK_Z2klTYE.webp" width="1498" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 7:</span> Impact of resampling on facial feature sharpness.</figcaption></figure></figure><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">1. Balance real and synthetic images</h3><p>The most important constraint for preventing overfitting is <strong>dataset diversity</strong>. No single concept (a specific background, a particular outfit, a generated style) should represent more than 25% of your training set. When synthetic images crowd out real ones, the model loses its grip on genuine identity and begins to replicate InstantID’s stylistic fingerprint rather than the subject’s actual face.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">2. The Rule of Four</h3><p>When generating synthetic training data with InstantID, providing <strong>four reference images</strong> offers the best trade-off between usability and facial similarity. Fewer references produce inconsistent identity across generations; more references yield diminishing returns and increase annotation overhead.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">3. Resolution matters</h3><p>Images around <strong>1 megapixel</strong> align with the native training resolution of SDXL and deliver the best qualitative results. Upscaling smaller images introduces compression artefacts; downscaling large images discards high-frequency facial detail. If your source photos are from a phone camera, a light centre-crop to roughly 1024 × 1024 is ideal.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">4. Skip the flips, rotations, and jitter</h3><p>Given the evidence above: do not use Random Horizontal Flip, Random Rotation, or Colour Jitter in the fine-tuning pipeline. Their well-known benefits for large-scale classification tasks do not transfer to few-shot face personalization.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Measuring Resemblance: The FaceDistance Metric</h2><p>Qualitative “vibes” are a start, but human intuition is subjective. To systematically rank checkpoints and understand how synthetic data actually moves the needle, we needed a reproducible, automated metric. This led to the development of <strong>FaceDistance</strong>, a validation tool built on <strong>FaceNet</strong> embeddings.</p><p>Rather than looking at pixels, FaceDistance looks at geometry. It projects facial images into a 128-dimensional hyperspherical space where the distance between points reflects perceptual similarity. Specifically, the metric calculates the average cosine distance between a generated image <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G_i$</code> and the set of original reference images <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\{R_j\}$</code>:</p><p><strong>Definition:</strong> Given batches of generated images <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$G = \{G_i\}_{i=1}^m$</code> and original reference images <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$R = \{R_j\}_{j=1}^n$</code>, the <strong>FaceDistance</strong> is defined as:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\bigl[\operatorname{FaceDistance}(G, R)\bigr]_i := \frac{1}{n} \sum_{j=1}^n \delta^{[0,2]}_{\text{cos}}\!\bigl(f(G_i),\, f(R_j)\bigr)$$</pre><p><strong>Breaking down the logic:</strong></p><ul><li><strong>The Encoder (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$f$</code>):</strong> We use <strong>MTCNN</strong> for precise face detection, followed by <strong>FaceNet</strong> to extract the identity embedding.</li><li><strong>The Distance (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\delta_{\cos}$</code>):</strong> We use cosine distance, clipped to a <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$[0, 2]$</code> range for numerical stability.</li></ul><p>A <strong>lower FaceDistance score</strong> indicates a stronger mathematical resemblance to the subject.</p><blockquote style="border-left:4px solid rgba(255, 255, 255, 0.1);padding-left:15px;color:#9ca3af;font-style:italic;margin:10px 0"><p><strong>Pro Tip:</strong> FaceDistance acts more like a high-pass filter than a perfect judge. It is excellent for identifying “catastrophic drift” (where the model loses the subject entirely) but it isn’t sensitive enough to decide if a “good” image is “great.”</p></blockquote><p>In our pipeline, we found that simply discarding the top <strong>15%</strong> of highest-distance embeddings from the training set (in cases with <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n \geq 8$</code> references) consistently led to cleaner, more recognizable results.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Human Test: Does It Fool Real People?</h2><p>Metrics only go so far. To validate that these portraits actually pass muster in professional contexts, the study recruited <strong>97 white-collar workers</strong> to evaluate the generated headshots. Both DreamBooth and InstantID produced portraits that were frequently indistinguishable from genuine professional photographs.</p><p>Participants’ preferences split along an interesting fault line. Those who valued <strong>identity accuracy</strong> (“does this actually look like the person?”) tended to prefer DreamBooth outputs. Those drawn to overall aesthetics favoured InstantID for its polished, retouched quality. Neither model dominated on all dimensions, which points to a useful practical heuristic: use DreamBooth-with-generative-augmentation when fidelity to a specific individual is paramount, and use InstantID directly when a studio-quality aesthetic matters more than strict identity retention.</p><figure class="my-10 fig-root md:my-12 mx-auto not-prose"><figure class="flex flex-col gap-2.5 m-0"><img alt="DreamBooth with generative augmentation" height="2048" src="https://korayulusan.github.io/static/dreambooth-synthetic-augmentation-facial-fidelity-result.DcXJFhPF_1Krwoa.webp" width="2048" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 8:</span> DreamBooth with generative augmentation</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="generative augmentation via InstantID" height="1556" src="https://korayulusan.github.io/static/InstantID-generative-augmentation-facial-fidelity-result2.COpA5q-a_2jHS77.webp" width="1559" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 9:</span> generative augmentation via InstantID</figcaption></figure></figure><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Conclusion</h2><p>Classical augmentations are not universally beneficial. For few-shot face personalization, several common techniques actively degrade output quality. Replacing them with generative augmentation, where InstantID synthesizes diverse but identity-consistent training images, closes the gap between a handful of casual snapshots and a high-fidelity professional portrait.</p><p>The broader takeaway extends beyond portraits: <strong>synthetic data is not just a fallback for when real data is scarce. It is a tool for shaping precisely what a model learns.</strong> As generative models improve, training pipelines that use one model to curate data for another will become increasingly common.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Acknowledgments</h2><p>This work would not have been possible without the dedicated mentorship of <strong>Benjamin Kiefer</strong>. Beyond steering the technical direction of this research, Benjamin was a constant guide through the often-turbulent process of publishing my first paper. His attentiveness during our weekly meetings and his rigorous feedback were fundamental to the success of this project. I am deeply grateful for his support in turning these initial ideas into a peer-reviewed publication.</p><p>My thanks also go to <strong>Naman Deep Singh</strong> for valuable insights into image copyright. His guidance helped clarify the complexities of dataset curation and provided a clear understanding of the legal boundaries for image usage.</p><p>I am also grateful to the CVPR SynData4CV workshop reviewers for their constructive comments.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Citation</h2><p>If you build on this work or wish to explore the full list of references and literature supporting this research, please refer to the formal paper:</p><span class="font-bold uppercase tracking-[0.2em] text-[10px] text-muted">APA7</span> <p>Ulusan, K., &amp; Kiefer, B. (2025, May). Generating synthetic data via augmentations for improved facial resemblance in DreamBooth and InstantID [Paper presentation]. CVPR Workshop on Synthetic Data for Computer Vision (SynData4CV), Nashville, TN, United States. <a href="https://arxiv.org/abs/2505.03557" style="color:#70a5fd">https://arxiv.org/abs/2505.03557</a></p><span class="font-bold uppercase tracking-[0.2em] text-[10px] text-muted">BibTeX</span> <pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">@inproceedings</span><span style="color:#e1e4e8">{</span><span style="color:#b392f0">ulusan2025generating</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  author</span><span style="color:#e1e4e8">        = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">Ulusan, Koray and Kiefer, Benjamin</span><span style="color:#9ecbff">}</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  title</span><span style="color:#e1e4e8">         = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">Generating Synthetic Data via Augmentations for</span></span>
<span class="line"><span style="color:#e1e4e8">                   Improved Facial Resemblance in {DreamBooth} and {InstantID}</span><span style="color:#9ecbff">}</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  booktitle</span><span style="color:#e1e4e8">     = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">Synthetic Data for Computer Vision Workshop at {CVPR} 2025</span><span style="color:#9ecbff">}</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  year</span><span style="color:#e1e4e8">          = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">2025</span><span style="color:#9ecbff">}</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  url</span><span style="color:#e1e4e8">           = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">https://openreview.net/forum?id=2o0RxrcV23</span><span style="color:#9ecbff">}</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  eprint</span><span style="color:#e1e4e8">        = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">2505.03557</span><span style="color:#9ecbff">}</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  archivePrefix</span><span style="color:#e1e4e8"> = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">arXiv</span><span style="color:#9ecbff">}</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  primaryClass</span><span style="color:#e1e4e8">  = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">cs.CV</span><span style="color:#9ecbff">}</span><span style="color:#e1e4e8">,</span></span>
<span class="line"><span style="color:#79b8ff">  doi</span><span style="color:#e1e4e8">           = </span><span style="color:#9ecbff">{</span><span style="color:#e1e4e8">10.48550/arXiv.2505.03557</span><span style="color:#9ecbff">}</span></span>
<span class="line"><span style="color:#e1e4e8">}</span></span></code></pre><ul><li>The full paper is available at <a href="https://arxiv.org/abs/2505.03557" style="color:#70a5fd">arXiv:2505.03557</a></li><li>Project webpage is <a href="https://synthetic-face-augmentation.github.io/" style="color:#70a5fd">synthetic-face-augmentation.github.io</a></li></ul><p class="mt-8 text-muted/80">Thanks for reading! If you have questions, feedback, or want to discuss a project, feel free to drop me an <a style="color:#70a5fd"><span>email</span></a>or message.</p>
      <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center; font-family: 'JetBrains Mono', monospace; color: #9ca3af;">
        <span style="color: #ffb86c;">◆</span> 
        Originally published at 
        <a href="https://korayulusan.github.io/blog/genai-synthetic-data-facial-resemblance-dreambooth-instantid/" style="color: #ffb86c; text-decoration: none; border-bottom: 1px solid #ffb86c;">korayulusan.github.io</a>
      </div>
    </div>]]></content:encoded>
  </item>

  <item>
    <title>Modifying TD3 with PER, N-Step Returns, and Reward Shaping</title>
    <link>https://korayulusan.github.io/blog/rl-hockey-td3-per-reward-shaping-curriculum/</link>
    <guid isPermaLink="true">https://korayulusan.github.io/blog/rl-hockey-td3-per-reward-shaping-curriculum/</guid>
    <pubDate>Tue, 25 Feb 2025 06:53:18 GMT</pubDate>
    <description>How I modified Twin Delayed DDPG with Prioritized Experience Replay, reward shaping, and multi-step learning to train an agent in a simulated air hockey environment and what broke along the way.</description>
    <media:content url="https://korayulusan.github.io/og/blog/modifying-td3-with-per-n-step-returns-and-reward-s-landscape-thumbnail.jpeg" medium="image" />
    <enclosure url="https://korayulusan.github.io/og/blog/modifying-td3-with-per-n-step-returns-and-reward-s-landscape-thumbnail.jpeg" length="0" type="image/jpeg" />
    <content:encoded><![CDATA[<div style="background-color: #1c1c1d; color: #e8e8e8; font-family: sans-serif;"><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">TL;DR</h3><p>I modified <strong>TD3</strong> with <a href="#conclusion" style="color:#70a5fd">three reinforcement learning techniques</a>: Prioritized Experience Replay (PER), potential-based reward shaping, and multi-step returns. I used these to train an agent in a simulated air hockey game. Most modifications made things <em>worse</em>. What actually worked was a <strong>curriculum</strong>: pre-training on shooting and defending modes before facing the real opponent. The final agent wins 98.3% of games against the strong built-in opponent.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Problem: Sparse Rewards in a Competitive Environment</h2><p>Air hockey is a hard environment for RL. Goals are rare and delayed, preceded by a long sequence of positioning decisions that receive no direct reward signal. The agent needs to learn to move toward the puck, hit it in the right direction, and coordinate defense and offense, all from a reward that stays at zero until something decisive happens.</p><p>The environment I used is <strong>HockeyEnv</strong> (a.k.a. “Laser Hockey”), a Box2D/Gymnasium simulation of a two-player air hockey game. The observation space is 18-dimensional (positions, velocities, angles of both player and puck), and the action space is a 4-dimensional continuous vector covering movement and shooting. Each episode runs for up to 250 timesteps in normal mode, or a shorter 80-step window in dedicated shooting/defending training modes.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose w-[80%]"><img alt="Example HockeyEnv State: A 2D environment where 2 agents compete against each other to score goals with a puck while defending their own goal." height="934" src="https://korayulusan.github.io/static/hockey-env-reinforcement-learning-simulation-environment.B1wib9Kd_Z1sruSO.webp" width="1186" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-relaxed max-w-[90%] mt-4 mx-auto" style="text-align:center"><span class="text-muted font-bold">Figure 1:</span> Example HockeyEnv State: A 2D environment where 2 agents compete against each other to score goals with a puck while defending their own goal.</figcaption></figure><p>The standard “run a good off-policy algorithm and wait” approach struggles here. The agent’s first instinct is to stand still and draw, because drawing is better than the random-action baseline it gets penalized against. Getting past that local optimum requires deliberate intervention.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Base Algorithm: Twin Delayed DDPG (TD3)</h2><p>TD3 is an actor-critic algorithm that addresses the well-known overestimation bias of DDPG by maintaining <em>two</em> critics and taking the minimum of their Q-value estimates when computing targets:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$y = r_t + \gamma \min_{k=1,2} Q_{\theta_k'}(s_{t+1},\, \pi_{\phi'}(s_{t+1}) + \epsilon), \quad \epsilon \sim \text{clip}(\mathcal{N}(0,\sigma), -c, c)$$</pre><p>It also delays actor updates relative to critic updates (hence “Twin <em>Delayed</em>”), which gives the critics time to stabilize before the policy starts chasing them. I built <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">UlusanTD3</code> on top of <a href="https://github.com/DLR-RM/stable-baselines3" style="color:#70a5fd">Stable Baselines 3</a>, extending the base <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">TD3</code> class to support the three techniques described below.</p><blockquote style="border-left:4px solid rgba(255, 255, 255, 0.1);padding-left:15px;color:#9ca3af;font-style:italic;margin:10px 0"><p>The name UlusanTD3 is chosen purely for convenience of the project graders and easy identification in the codebase. It doesn’t imply any fundamental change to the TD3 algorithm itself, but rather serves as a container for the specific modifications and experiments conducted in this project.</p></blockquote><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Technique 1: Prioritized Experience Replay</h2><p>Standard experience replay samples uniformly from a FIFO buffer. <strong>PER</strong> [Schaul et al., 2015] argues that transitions where the agent was <em>wrong</em> (those with high temporal-difference TD error) are more informative and should be sampled more often. The sampling probability for a transition is:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$P(i) = \frac{p_i^\alpha}{\sum_k p_k^\alpha}$$</pre><p>where <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\alpha$</code> controls prioritization strength and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$p_i$</code> is the priority of transition <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$i$</code>. For TD3 with two critics, I define the priority as the average absolute TD error across both:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$p_i = \left|\frac{1}{2}(\delta_1 + \delta_2)_i\right| + \epsilon$$</pre><p>To keep priorities tractable and prevent divergence, I clip TD errors to the range <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$[\epsilon, 1]$</code>. Without this upper bound, a single catastrophic prediction early in training can dominate the buffer forever and destabilize the actor.</p><p>Sampling more from high-error transitions introduces a bias, which is corrected with <strong>importance-sampling (IS) weights</strong>:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$w_i = \left(N \cdot P(i)\right)^{-\beta}, \quad \text{normalized by } \max_i w_i$$</pre><p>These weights are folded into the critic loss, replacing the standard MSE:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\mathcal{L}(\theta_k) = \mathbb{E}\left[w \cdot \delta_k^2\right]$$</pre><p>Because I have two critics with potentially different scales, I give each its own optimizer rather than summing their losses:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#6a737d"># In UlusanTD3.__init__</span></span>
<span class="line"><span style="color:#f97583">if</span><span style="color:#79b8ff"> isinstance</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">self</span><span style="color:#e1e4e8">.replay_buffer, PrioritizedExperienceReplayBuffer):</span></span>
<span class="line"><span style="color:#79b8ff">    self</span><span style="color:#e1e4e8">.critic1_optimizer </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> th.optim.Adam(</span></span>
<span class="line"><span style="color:#79b8ff">        self</span><span style="color:#e1e4e8">.critic.q_networks[</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">].parameters(), </span><span style="color:#ffab70">lr</span><span style="color:#f97583">=</span><span style="color:#e1e4e8">learning_rate</span></span>
<span class="line"><span style="color:#e1e4e8">    )</span></span>
<span class="line"><span style="color:#79b8ff">    self</span><span style="color:#e1e4e8">.critic2_optimizer </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> th.optim.Adam(</span></span>
<span class="line"><span style="color:#79b8ff">        self</span><span style="color:#e1e4e8">.critic.q_networks[</span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">].parameters(), </span><span style="color:#ffab70">lr</span><span style="color:#f97583">=</span><span style="color:#e1e4e8">learning_rate</span></span>
<span class="line"><span style="color:#e1e4e8">    )</span></span></code></pre><p>The PER buffer itself is backed by a <strong>SumSegmentTree</strong>, which supports O(log N) priority updates and O(log N) stratified sampling. This is essential when the buffer holds a million transitions:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> _sample_indicies_proportional</span><span style="color:#e1e4e8">(self, batch_size: </span><span style="color:#79b8ff">int</span><span style="color:#e1e4e8">) -&gt; np.ndarray:</span></span>
<span class="line"><span style="color:#e1e4e8">    p_total </span><span style="color:#f97583">=</span><span style="color:#79b8ff"> self</span><span style="color:#e1e4e8">._td_errors.sum(</span><span style="color:#ffab70">end</span><span style="color:#f97583">=</span><span style="color:#79b8ff">self</span><span style="color:#e1e4e8">.size())</span></span>
<span class="line"><span style="color:#e1e4e8">    segment_length </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> p_total </span><span style="color:#f97583">/</span><span style="color:#e1e4e8"> batch_size</span></span>
<span class="line"><span style="color:#e1e4e8">    elem_at_segment_prefixsum </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> (</span></span>
<span class="line"><span style="color:#e1e4e8">        np.arange(batch_size) </span><span style="color:#f97583">+</span><span style="color:#e1e4e8"> np.random.uniform(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">, </span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">, batch_size)</span></span>
<span class="line"><span style="color:#e1e4e8">    ) </span><span style="color:#f97583">*</span><span style="color:#e1e4e8"> segment_length</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> [</span></span>
<span class="line"><span style="color:#79b8ff">        self</span><span style="color:#e1e4e8">._td_errors.find_prefixsum_idx(p)</span></span>
<span class="line"><span style="color:#f97583">        for</span><span style="color:#e1e4e8"> p </span><span style="color:#f97583">in</span><span style="color:#e1e4e8"> elem_at_segment_prefixsum</span></span>
<span class="line"><span style="color:#e1e4e8">    ]</span></span></code></pre><p>Using a NumPy array in <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">SegmentTree</code> was important because a Python list was too slow for the large buffer size and high update frequency.</p><p>After each gradient step, priorities are updated to reflect the latest TD errors:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#6a737d"># Back in the train loop</span></span>
<span class="line"><span style="color:#e1e4e8">td_errors </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> (td_error1 </span><span style="color:#f97583">+</span><span style="color:#e1e4e8"> td_error2) </span><span style="color:#f97583">/</span><span style="color:#79b8ff"> 2.0</span></span>
<span class="line"><span style="color:#79b8ff">self</span><span style="color:#e1e4e8">.replay_buffer.set_priorities(</span></span>
<span class="line"><span style="color:#e1e4e8">    batch_inds,</span></span>
<span class="line"><span style="color:#e1e4e8">    td_errors.abs().squeeze().detach().cpu().numpy()</span></span>
<span class="line"><span style="color:#e1e4e8">)</span></span></code></pre><p><strong>What actually happened:</strong> PER made performance <em>worse</em> in every configuration I tested. More on why below.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Technique 2: Potential-Based Reward Shaping</h2><p>In environments with sparse rewards, auxiliary signals that encode domain knowledge can accelerate learning without changing the optimal policy. <strong>Potential-based reward shaping</strong> [Ng et al., 1999] adds a shaping term:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$F(s_t, s_{t+1}) = \gamma \phi(s_{t+1}) - \phi(s_t)$$</pre><p>The key property is that this <em>never changes the optimal policy</em>. It only changes how quickly the agent converges to it. The potential function <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\phi: S \to \mathbb{R}$</code> can encode whatever domain knowledge you have.</p><p>HockeyEnv conveniently exposes sub-reward components in its <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">info</code> dict. I used a combination of:</p><ul><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">closeness_to_puck</code> — reward for staying near the puck</li><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">touch_puck</code> — bonus for making contact</li><li><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">puck_direction</code> — reward for hitting the puck toward the opponent’s goal</li></ul><p>Two components I tried and removed: <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">centered_puck</code> introduced noise and slowed training, and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">game_length</code> inadvertently taught the agent to step aside and let in own goals.</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> shaped_reward</span><span style="color:#e1e4e8">(rewards, infos):</span></span>
<span class="line"><span style="color:#e1e4e8">    phis </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> [</span></span>
<span class="line"><span style="color:#e1e4e8">        (info.get(</span><span style="color:#9ecbff">"prev_potential_reward"</span><span style="color:#e1e4e8">, </span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">),</span></span>
<span class="line"><span style="color:#e1e4e8">         info.get(</span><span style="color:#9ecbff">"current_potential_reward"</span><span style="color:#e1e4e8">, </span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">))</span></span>
<span class="line"><span style="color:#f97583">        for</span><span style="color:#e1e4e8"> info </span><span style="color:#f97583">in</span><span style="color:#e1e4e8"> infos</span></span>
<span class="line"><span style="color:#e1e4e8">    ]</span></span>
<span class="line"><span style="color:#6a737d">    # F(s, s') = gamma * phi(s') - phi(s)</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> [</span></span>
<span class="line"><span style="color:#e1e4e8">        r </span><span style="color:#f97583">+</span><span style="color:#79b8ff"> self</span><span style="color:#e1e4e8">.gamma </span><span style="color:#f97583">*</span><span style="color:#e1e4e8"> phi </span><span style="color:#f97583">-</span><span style="color:#e1e4e8"> phi_prev</span></span>
<span class="line"><span style="color:#f97583">        for</span><span style="color:#e1e4e8"> r, (phi_prev, phi) </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> zip</span><span style="color:#e1e4e8">(rewards, phis)</span></span>
<span class="line"><span style="color:#e1e4e8">    ]</span></span></code></pre><p>Because HockeyEnv is a fully observable MDP, I compute <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\phi$</code> directly from the environment state on every step, so no approximation is needed.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Technique 3: Multi-Step Returns</h2><p>Standard TD3 bootstraps one step into the future. In hockey, the decisive action (the puck shot) is made many timesteps before the goal is actually scored, so the one-step target has no way to credit that shot with the eventual reward.</p><p>The <strong>truncated n-step return</strong> addresses this by accumulating rewards forward:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$R_t^{(n)} = \sum_{k=0}^{n-1} \gamma^k \cdot r_{t+k}$$</pre><p>and substituting it into the TD3 target:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$y = R_t^{(n)} + \gamma^n \min_{k=1,2} Q_{\theta_k'}(s_{t+n},\, \pi_{\phi'}(s_{t+n}) + \epsilon)$$</pre><p>With reward shaping combined, the telescoping sum over the potential terms simplifies, and the target becomes:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$y = \sum_{k=0}^{n-1} \gamma^k r_{t+k} + \gamma^n \Phi(s_{t+n}) - \Phi(s_t)$$</pre><p>Implementation-wise, this requires buffering the last <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n$</code> transitions before committing any of them to the replay buffer. The buffer is flushed early when a terminal state is reached:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> add</span><span style="color:#e1e4e8">(self, obs, next_obs, action, reward, done, infos):</span></span>
<span class="line"><span style="color:#79b8ff">    self</span><span style="color:#e1e4e8">._reward_info_buffer.append((reward, infos))</span></span>
<span class="line"></span>
<span class="line"><span style="color:#f97583">    if</span><span style="color:#79b8ff"> any</span><span style="color:#e1e4e8">(done):</span></span>
<span class="line"><span style="color:#6a737d">        # flush remaining transitions on episode end</span></span>
<span class="line"><span style="color:#f97583">        while</span><span style="color:#79b8ff"> len</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">self</span><span style="color:#e1e4e8">._reward_info_buffer) </span><span style="color:#f97583">&gt;</span><span style="color:#79b8ff"> 0</span><span style="color:#e1e4e8">:</span></span>
<span class="line"><span style="color:#79b8ff">            self</span><span style="color:#e1e4e8">._add_n_step_return(obs, next_obs, action, done, infos)</span></span>
<span class="line"><span style="color:#f97583">        return</span></span>
<span class="line"></span>
<span class="line"><span style="color:#f97583">    if</span><span style="color:#79b8ff"> len</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">self</span><span style="color:#e1e4e8">._reward_info_buffer) </span><span style="color:#f97583">&lt;</span><span style="color:#79b8ff"> self</span><span style="color:#e1e4e8">.n_step_return_num:</span></span>
<span class="line"><span style="color:#f97583">        return</span><span style="color:#6a737d">  # keep buffering</span></span>
<span class="line"></span>
<span class="line"><span style="color:#79b8ff">    self</span><span style="color:#e1e4e8">._add_n_step_return(obs, next_obs, action, done, infos)</span></span></code></pre><p>The discount exponent in the Bellman target also needs updating to account for the extended horizon:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#e1e4e8">target_q_values </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> (</span></span>
<span class="line"><span style="color:#e1e4e8">    replay_data.rewards</span></span>
<span class="line"><span style="color:#f97583">    +</span><span style="color:#e1e4e8"> (</span><span style="color:#79b8ff">1</span><span style="color:#f97583"> -</span><span style="color:#e1e4e8"> replay_data.dones)</span></span>
<span class="line"><span style="color:#f97583">    *</span><span style="color:#79b8ff"> self</span><span style="color:#e1e4e8">.gamma </span><span style="color:#f97583">**</span><span style="color:#79b8ff"> self</span><span style="color:#e1e4e8">.n_step_return_num  </span><span style="color:#6a737d"># γⁿ instead of γ in TD3</span></span>
<span class="line"><span style="color:#f97583">    *</span><span style="color:#e1e4e8"> next_q_values</span></span>
<span class="line"><span style="color:#e1e4e8">).detach()</span></span></code></pre><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">The Ablation Study: Most Things Didn’t Help</h2><p>I ran combinations of all three techniques (26 configurations in total, sweeping <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n \in \{2, 3, 4, 5, 10, 20, 30\}$</code> for multi-step returns) and evaluated each against the weak built-in opponent. The results were not great.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><figure class="flex flex-col gap-2.5 m-0"><img alt="Comparative results of 26 configurations showing that standard RL improvements often struggle to beat the baseline in this environment." height="1357" src="https://korayulusan.github.io/static/td3-reinforcement-learning-ablation-study-reward-graph.kjqmTqxt_ir91C.webp" width="2024" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 2:</span> Comparative results of 26 configurations showing that standard RL improvements often struggle to beat the baseline in this environment.</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Detailed reward progression highlighting the modest performance gains of the 3-step return variant." height="1440" src="https://korayulusan.github.io/static/td3-reinforcement-learning-ablation-study-reward-graph-core.CpZ0HDFD_9EH3B.webp" width="2024" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 3:</span> Detailed reward progression highlighting the modest performance gains of the 3-step return variant.</figcaption></figure></figure><p>Most modifications performed <em>worse</em> than the TD3 baseline. The 3-step return variant was the only technique that consistently outperformed the baseline, and even that improvement was modest.</p><p>PER failed systematically. Training with IS weights disabled diverged immediately: without correction, the buffer fills with high-error transitions and the critic chases a badly biased distribution. With IS weights enabled, training was stable but still underperformed the baseline.</p><span class="block text-[10px] uppercase font-black mb-2 text-accent tracking-[0.2em]">Key Takeaway</span><p>The failure of PER wasn’t a bug, it was informative. PER’s design assumes a stationary data distribution. When the environment or the opponent changes, that assumption breaks.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Curriculum Learning: The Thing That Actually Worked</h2><p>The real insight was about changing <em>how</em> the agent was trained rather than what algorithm it used.</p><p>Without guidance, an agent facing a strong opponent quickly figures out that drawing (never scoring, never conceding) is safer than attempting to score. Once it settles into that strategy, it’s hard to unlearn, because the risk of a failed shot (giving the opponent a chance to score) outweighs any expected benefit from trying.</p><p>The curriculum I designed breaks this trap in two phases.</p><p><strong>Phase 1 (steps 0 to 440k):</strong> Alternate every episode between the dedicated shooting mode and defending mode of HockeyEnv. These stripped-down scenarios cut out the full-game complexity and force the agent to develop fundamental skills: aim and shoot; track and block. The episode horizon is only 80 steps, which enables much faster iteration.</p><p><strong>Phase 2 (steps 440k+):</strong> Empty the replay buffer entirely and switch to training against the strong <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">BasicOpponent</code> in full normal-mode games. The clean buffer prevents old experiences from contaminating the new distribution.</p><p>The agent that had been stuck below 50% win rate against the strong opponent reached <strong>98.3% win rate</strong> within 110k steps of Phase 2 training. Notably, the win rate against the <em>weak</em> opponent also climbed during Phase 1, even though the agent had never played full games during that phase.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="Win/Draw/Loss breakdown during curriculum training." height="1134" src="https://korayulusan.github.io/static/curriculum-learning-air-hockey-shooting-defending-modes.DTTGJ9TM_Z1LRJGN.webp" width="2740" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-relaxed max-w-[90%] mt-4 mx-auto" style="text-align:center"><span class="text-muted font-bold">Figure 4:</span> Win/Draw/Loss breakdown during curriculum training.</figcaption></figure><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Why PER and Curriculum Don’t Mix</h2><p>When the curriculum switches from Phase 1 to Phase 2, the replay buffer gets emptied. For standard TD3, this is a clean reset. For PER, it causes problems.</p><p>The new transitions in Phase 2 initially have high TD errors (the agent has never seen full-game states before). These saturate the buffer with maximum-priority entries. The IS weights assigned to these transitions drop to near zero, because <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$w_i = (N \cdot P(i))^{-\beta}$</code> becomes tiny when a large fraction of transitions share the same maximum priority. The critic is updated on high-error samples with effectively zero weight, which means it barely updates at all. The actor loss then diverges.</p><p>As shown in the logic below, when new high-error transitions dominate the distribution, the probability <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$P(i)$</code> of selecting a new sample becomes very large relative to the small buffer size <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$N$</code> during the reset, causing the weight to vanish:</p><p><code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$w_i = (N \cdot P(i))^{-\beta}$</code></p><p>When <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$P(i_{new}) \gg P(i_{old})$</code>, then <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$w_{i\_new} \to 0$</code>.</p><p>The critic is updated on high-error samples with effectively zero weight, which means it barely updates at all. Consequently, the actor loss diverges because it is receiving gradients from an unmoving, inaccurate critic.</p><p>This is why PER was dropped from the final curriculum configuration entirely.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><figure class="flex flex-col gap-2.5 m-0"><img alt="Critic loss divergence: The immediate spike in TD error when the curriculum changes destabilizes training." height="664" src="https://korayulusan.github.io/static/prioritized-experience-replay-per-critic-loss-divergence-graph-1.E5L85_T2_103Mdn.webp" width="708" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 5:</span> Critic loss divergence: The immediate spike in TD error when the curriculum changes destabilizes training.</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Importance Sampling (IS) weights dropping to near zero, effectively preventing the agent from learning." height="638" src="https://korayulusan.github.io/static/prioritized-experience-replay-per-critic-loss-divergence-graph-2.CQAntnOX_sVtEP.webp" width="740" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 6:</span> Importance Sampling (IS) weights dropping to near zero, effectively preventing the agent from learning.</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Behavioral shift toward drawing with longer episodes, which is a local optimum that the agent can't escape from." height="688" src="https://korayulusan.github.io/static/prioritized-experience-replay-per-critic-loss-divergence-graph-3.cS8LXscS_29DXec.webp" width="702" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 7:</span> Behavioral shift toward drawing with longer episodes, which is a local optimum that the agent can't escape from.</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Reward progression showing that the agent fails to improve after the curriculum switch, confirming the learning collapse." height="678" src="https://korayulusan.github.io/static/prioritized-experience-replay-per-critic-loss-divergence-graph-4.DKfiukvr_1CFtjt.webp" width="706" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 8:</span> Reward progression showing that the agent fails to improve after the curriculum switch, confirming the learning collapse.</figcaption></figure></figure><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Self-Play</h2><p>I also explored self-play, training the agent against a pool of its own past checkpoints. The hope was to develop generalization beyond the scripted <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">BasicOpponent</code>.</p><p>It didn’t work. The agents converged to a Nash equilibrium of <em>mutual avoidance</em>: both players positioning themselves to not touch the puck rather than risk conceding a goal. Episode lengths climbed toward the 250-step maximum. Once discovered, this drawing strategy was self-reinforcing, because any agent that tried to attack would get punished by an opponent that had learned to exploit aggressive positioning.</p><p>Risk aversion dominates in self-play when the stakes are symmetric. The agent’s value function correctly estimates that the expected return from “don’t touch the puck” is higher than the noisy expected return from “attempt a shot.” Injecting <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">BasicOpponent</code> episodes or clearing the buffer when switching opponents did not fix this.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><figure class="flex flex-col gap-2.5 m-0"><img alt="Mean episode length trends in self-play, showing the convergence toward a 250-step 'mutual avoidance' strategy." height="666" src="https://korayulusan.github.io/static/rl-self-play-agent-deadlock-episode-length-trends.DLvfHUZO_ZjfnRT.webp" width="718" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 9:</span> Mean episode length trends in self-play, showing the convergence toward a 250-step 'mutual avoidance' strategy.</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Visualization of the drawing deadlock where both agents prioritize safety over scoring." height="480" src="https://korayulusan.github.io/static/rl-self-play-agent-deadlock.CLCg0h3A_1BsSvb.webp" width="600" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 10:</span> Visualization of the drawing deadlock where both agents prioritize safety over scoring.</figcaption></figure></figure><p>The approach that actually works, from what I heard from peers, is to mix self-play with skill-based training against an easy opponent throughout the whole training run. That way the agent never completely forgets that scoring goals is the point.</p><span class="block text-[10px] uppercase font-black mb-2 text-accent tracking-[0.2em]">Key Takeaway</span><p>Self-play can lead to degenerate equilibria if not carefully structured. In competitive environments, it’s crucial to maintain a curriculum that keeps the agent focused on the ultimate goal rather than settling for safe but unproductive strategies.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Tournament Results</h2><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><figure class="flex flex-col gap-2.5 m-0"><img alt="UlusanTD3 (orange) playing against strong built-in opponent, employing a aggressive strategy. (immediate 4 Wins)" height="960" src="https://korayulusan.github.io/static/td3-basicstrong-startswithball.BxC_Xmqw_ZmAbif.webp" width="1200" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 11:</span> UlusanTD3 (orange) playing against strong built-in opponent, employing a aggressive strategy. (immediate 4 Wins)</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="UlusanTD3 (orange) defending against strong built-in opponent, showing improved defensive positioning. (4 Wins)" height="960" src="https://korayulusan.github.io/static/td3-basicstrong-startswithoutball.jJcZissb_Z2kyQmB.webp" width="1200" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="text-muted font-bold">Figure 12:</span> UlusanTD3 (orange) defending against strong built-in opponent, showing improved defensive positioning. (4 Wins)</figcaption></figure></figure><p>The final agent (3-step return, curriculum, no PER) competed in the 2025 RL course tournament at the University of Tübingen, ranking <strong>131/146</strong> (including stale accounts) with a <strong>40% win rate</strong> against other students’ agents. Some students chose not to join the tournament.</p><p>This is a more honest number than the 98.3% against <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">BasicOpponent</code>. My self-play loop didn’t worked because the rewards were very sparse. My peers included other modes in their curriculum, to increase reward signal and prevent the drawing deadlock. In the tournament, RL agents with world models didn’t performed much better than ones like TD3. The real winner move was to train on tournament data to learn the specific quirks of the opponents, which is a form of overfitting but effective in a competitive setting. Yes, it was allowed.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="Tournament final ranking showing our agent's position at 131 and a 40% win rate." height="565" src="https://korayulusan.github.io/static/air-hockey-rl-tournament-final-ranking-win-rate.D8jfs0ve_2lrHjG.webp" width="922" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-relaxed max-w-[90%] mt-4 mx-auto" style="text-align:center"><span class="text-muted font-bold">Figure 13:</span> Tournament final ranking showing our agent's position at 131 and a 40% win rate.</figcaption></figure><p>If I included basic defending and shooting modes throughout the self-play phase, tournament performance would have been noticeably better. The agent was robust but never had a complete training setup.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Practical Takeaways</h2><p>These findings aren’t specific to air hockey.</p><p><strong>On PER:</strong> It works well in stationary, single-distribution settings. In non-stationary environments (curriculum training, population-based training, anything that changes the data distribution mid-training), the mismatch between stored priorities and the current distribution becomes a liability. Either clear the buffer on every regime change or skip PER in this setting.</p><p><strong>On reward shaping:</strong> Be selective about what you encode in <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\phi$</code>. Subcomponents that make intuitive sense (stay near the puck) can introduce perverse incentives at the MDP level (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">game_length</code> rewarding own goals). The sufficiency theorem guarantees no harm asymptotically, but finite training is far from asymptotic.</p><p><strong>On multi-step returns:</strong> Modest <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n \in [2, 5]$</code> is almost always better than large <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n \in [10, 30]$</code> in continuous control. Large <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n$</code> introduces high variance in the return estimate and makes the bootstrap target less reliable.</p><p><strong>On self-play:</strong> Combine it with skill-based training modes from day one. Self-play alone, starting from scratch, finds the drawing equilibrium before it finds the scoring one.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Conclusion</h2><p>Adding algorithmic improvements to TD3 mostly made things worse. What actually unlocked real performance was a carefully structured training curriculum: a decision about <em>what the agent practices</em> rather than <em>how it learns</em>.</p><p>In sparse-reward environments, the hardest problem is not the algorithm, it’s the training setup. PER, multi-step returns, and reward shaping are all principled ideas, but they operate on data. Curriculum learning shapes what data is generated in the first place.</p><p>A stable self-play loop that combines pool-based opponent selection with dedicated skill modes is the most promising direction for pushing these agents further.</p><p>Here are the resources if you want to dive deeper:</p><ul><li><a href="https://github.com/KorayUlusan/Reinforcement-Learning-TD3/blob/main/hockey-technical-report.pdf" style="color:#70a5fd">Technical Report</a></li><li><a href="https://github.com/KorayUlusan/Reinforcement-Learning-TD3/blob/main/hockey-presentation.pdf" style="color:#70a5fd">Presentation</a></li><li><a href="https://github.com/KorayUlusan/Reinforcement-Learning-TD3" style="color:#70a5fd">GitHub Repo</a></li><li><a href="https://github.com/KorayUlusan/Reinforcement-Learning-TD3/blob/main/src/agents/ulusan_td3.py" style="color:#70a5fd">RL Agent’s Code</a></li></ul><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Acknowledgments</h2><p>This project was completed as part of the RL Course 2024/25 taught by Prof. Georg Martius at the University of Tübingen, in collaboration with Elia Frederick Reppchen (Rainbow DQN) and ChandraLekha Ramireddy (SAC). Compute was provided by the <a href="https://uni-tuebingen.de/fakultaeten/mathematisch-naturwissenschaftliche-fakultaet/fachbereiche/informatik/lehrstuehle/kognitive-systeme/projects/tcml-cluster/" style="color:#70a5fd">TCML cluster</a> offered by the Cognitive Systems Group (of Prof. Andreas Zell) at the University of Tübingen.</p><p class="mt-8 text-muted/80">Thanks for reading! If you have questions, feedback, or want to discuss a project, feel free to drop me an <a style="color:#70a5fd"><span>email</span></a>or message.</p>
      <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center; font-family: 'JetBrains Mono', monospace; color: #9ca3af;">
        <span style="color: #ffb86c;">◆</span> 
        Originally published at 
        <a href="https://korayulusan.github.io/blog/rl-hockey-td3-per-reward-shaping-curriculum/" style="color: #ffb86c; text-decoration: none; border-bottom: 1px solid #ffb86c;">korayulusan.github.io</a>
      </div>
    </div>]]></content:encoded>
  </item>

  <item>
    <title>Trustworthy ML: OOD Generalisation, XAI, and Calibration</title>
    <link>https://korayulusan.github.io/blog/trustworthy-ml-ood-xai-uncertainty/</link>
    <guid isPermaLink="true">https://korayulusan.github.io/blog/trustworthy-ml-ood-xai-uncertainty/</guid>
    <pubDate>Sat, 14 Dec 2024 06:47:54 GMT</pubDate>
    <description>Three homework assignments from Trustworthy ML (WS 2024-25): ensemble diversification with PoE and HSIC on dSprites, feature attribution via saliency and CAM on CUB-200, influence functions with a kNN speedup, and calibration from proper scoring rules.</description>
    <media:content url="https://korayulusan.github.io/og/blog/trustworthy-ml-ood-generalisation-xai-and-calibrat-landscape-thumbnail.jpeg" medium="image" />
    <enclosure url="https://korayulusan.github.io/og/blog/trustworthy-ml-ood-generalisation-xai-and-calibrat-landscape-thumbnail.jpeg" length="0" type="image/jpeg" />
    <content:encoded><![CDATA[<div style="background-color: #1c1c1d; color: #e8e8e8; font-family: sans-serif;"><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">TL;DR</h3><p>Three homework sets from <em>Trustworthy Machine Learning</em>. The assignments covered <strong>OOD generalisation</strong> (ensemble diversification, IRM, adversarial attacks on LLMs), <strong>explainability</strong> (feature attribution, training data attribution), and <strong>uncertainty</strong> (calibration, epistemic/aleatoric decomposition, deep ensembles). They were quite the uphill battle at the time, but the insights have stuck with me and regularly shape how I approach ML problems today.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">OOD Generalisation</h2><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Domain generalisation vs. the actual setup</h3><p>Domain generalisation trains on labelled source domains <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$D_1, \dots, D_n$</code> with zero access to the target domain at all. The exercise used a slightly different setup: <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n$</code> source domains plus a batch of <em>unlabelled</em> deployment samples from <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$D_{n+1}$</code>. That unlabelled data opens the door to semi-supervised adaptation techniques that pure domain generalisation cannot use.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Learning from Failure</h3><p>The paper <em>Learning from Failure</em> (LfF, NeurIPS 2020) handles the feature-selection problem without labelling which features are spurious. The trick is to train a biased model first and read off the task-relevant features from where it fails: samples where spurious cues (e.g. colour) point the wrong way are exactly the samples the biased model gets wrong. A second, debiased model is then up-weighted on those failures. No bias annotation needed; the model’s own failure mode is the signal.</p><p>The implicit assumption is that spurious correlations are <em>easier</em> to learn than task-relevant ones, so the biased model really does latch on to them first. That holds on synthetic benchmarks and is less certain in the wild.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">dSprites and the diagonal setup</h3><p>All experiments used <a href="https://github.com/deepmind/dsprites-dataset" style="color:#70a5fd">dSprites</a>: 64x64 images of shapes (square, ellipse, heart) with latent factors for colour, shape, scale, orientation, and position. Labels are uniformly binned into 3 classes per latent.</p><p>The <strong>diagonal dataset</strong> is the core construct: bias cue and task cue are perfectly correlated in training (<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">off_diag_proportion = 0</code>), so <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">colour == shape</code> always. A model that learns colour achieves 100% training accuracy without ever touching shape. On the unbiased test set, colour accuracy collapses to chance (33%) and shape accuracy reveals whether anything real was learned. Setting <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">off_diag_proportion = 0.1</code> adds 10% bias-conflicting samples, which slightly dilutes the spurious signal.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Product of Experts</h3><p>The PoE loss adds member logits before the softmax, which is equivalent to taking the product of their softmax distributions up to normalisation:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$p_{\text{PoE}}(y \mid x) \propto \prod_{m=1}^{M} p_m(y \mid x) = \text{softmax}\!\left(\sum_{m=1}^{M} \ell_m(x)\right)$$</pre><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">class</span><span style="color:#b392f0"> ModelTrainerPoE</span><span style="color:#e1e4e8">(</span><span style="color:#b392f0">ModelTrainer</span><span style="color:#e1e4e8">):</span></span>
<span class="line"><span style="color:#f97583">    def</span><span style="color:#b392f0"> train_loop</span><span style="color:#e1e4e8">(self, images_batch, labels_batch, epoch</span><span style="color:#f97583">=</span><span style="color:#79b8ff">None</span><span style="color:#e1e4e8">):</span></span>
<span class="line"><span style="color:#e1e4e8">        labels_batch </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> labels_batch[</span><span style="color:#79b8ff">self</span><span style="color:#e1e4e8">.task_label_index]</span></span>
<span class="line"><span style="color:#e1e4e8">        logits_dict </span><span style="color:#f97583">=</span><span style="color:#79b8ff"> self</span><span style="color:#e1e4e8">.model(images_batch)</span></span>
<span class="line"><span style="color:#f97583">        for</span><span style="color:#e1e4e8"> idx, (_, logits) </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> enumerate</span><span style="color:#e1e4e8">(logits_dict.items()):</span></span>
<span class="line"><span style="color:#e1e4e8">            combined </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> logits </span><span style="color:#f97583">if</span><span style="color:#e1e4e8"> idx </span><span style="color:#f97583">==</span><span style="color:#79b8ff"> 0</span><span style="color:#f97583"> else</span><span style="color:#e1e4e8"> combined </span><span style="color:#f97583">+</span><span style="color:#e1e4e8"> logits</span></span>
<span class="line"><span style="color:#f97583">        return</span><span style="color:#e1e4e8"> F.cross_entropy(combined, labels_batch)</span></span></code></pre><p>Two identical ResNet18s trained jointly on the fully diagonal dataset both specialise in colour. They share the same architecture, the same data, and receive symmetric gradients through the summed logits. Nothing breaks the symmetry.</p><p>Three changes together produced actual diversification:</p><ol><li><strong>Heterogeneous architectures</strong>: ResNet18 for member 0, ResNet101 for member 1.</li><li><strong>Greyscale input for member 1</strong>: it physically cannot use colour.</li><li><strong>Sequential optimisation</strong>: train only member 0 for epoch 1, then switch to member 1.</li></ol><p>A cosine-similarity diversity penalty also helps at the margins:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#e1e4e8">diversity_penalty </span><span style="color:#f97583">=</span><span style="color:#79b8ff"> 0</span></span>
<span class="line"><span style="color:#f97583">for</span><span style="color:#e1e4e8"> i, i_outs </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> enumerate</span><span style="color:#e1e4e8">(logits_dict.values()):</span></span>
<span class="line"><span style="color:#f97583">    for</span><span style="color:#e1e4e8"> j, j_outs </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> enumerate</span><span style="color:#e1e4e8">(logits_dict.values()):</span></span>
<span class="line"><span style="color:#f97583">        if</span><span style="color:#e1e4e8"> i </span><span style="color:#f97583">!=</span><span style="color:#e1e4e8"> j:</span></span>
<span class="line"><span style="color:#e1e4e8">            diversity_penalty </span><span style="color:#f97583">+=</span><span style="color:#e1e4e8"> F.cosine_similarity(i_outs, j_outs).mean()</span></span>
<span class="line"><span style="color:#e1e4e8">loss </span><span style="color:#f97583">+=</span><span style="color:#79b8ff"> 0.1</span><span style="color:#f97583"> *</span><span style="color:#e1e4e8"> diversity_penalty</span></span></code></pre><p>The greyscale intervention is architecturally clean; the diversity penalty is a soft nudge without guarantees about what the two members disagree <em>on</em>.</p><span class="uppercase tracking-[0.2em] text-[10px] block font-black mb-2 text-accent">Key Takeaway</span><p>PoE alone does not diversify a homogeneous ensemble. You need either different inputs, different architectures, or explicit gradient-level repulsion.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">HSIC regularisation</h3><p><a href="https://arxiv.org/abs/1910.02806" style="color:#70a5fd">ReBias (ICML 2020)</a> uses the Hilbert-Schmidt Independence Criterion to decorrelate a biased model’s embeddings from an unbiased one’s. HSIC measures statistical dependence via centred kernel matrices:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{HSIC}(K, L) = \frac{1}{m^2} \operatorname{tr}(\tilde{K} \tilde{L}), \quad \tilde{K} = HKH, \quad H = I - \tfrac{1}{m}\mathbf{1}\mathbf{1}^\top$$</pre><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> _hsic_loss</span><span style="color:#e1e4e8">(self, embeddings):</span></span>
<span class="line"><span style="color:#e1e4e8">    em1, em2 </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> embeddings</span></span>
<span class="line"><span style="color:#f97583">    def</span><span style="color:#b392f0"> rbf_kernel</span><span style="color:#e1e4e8">(x, sigma</span><span style="color:#f97583">=</span><span style="color:#79b8ff">1.0</span><span style="color:#e1e4e8">):</span></span>
<span class="line"><span style="color:#f97583">        return</span><span style="color:#e1e4e8"> torch.exp(</span><span style="color:#f97583">-</span><span style="color:#e1e4e8">torch.cdist(x, x, </span><span style="color:#ffab70">p</span><span style="color:#f97583">=</span><span style="color:#79b8ff">2</span><span style="color:#e1e4e8">)</span><span style="color:#f97583">**</span><span style="color:#79b8ff">2</span><span style="color:#f97583"> /</span><span style="color:#e1e4e8"> sigma</span><span style="color:#f97583">**</span><span style="color:#79b8ff">2</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#e1e4e8">    K, L </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> rbf_kernel(em1), rbf_kernel(em2)</span></span>
<span class="line"><span style="color:#e1e4e8">    m </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> K.shape[</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">]</span></span>
<span class="line"><span style="color:#e1e4e8">    H </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> (torch.eye(m) </span><span style="color:#f97583">-</span><span style="color:#e1e4e8"> torch.ones(m, m)</span><span style="color:#f97583">/</span><span style="color:#e1e4e8">m).to(K.device)</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> (</span><span style="color:#79b8ff">1</span><span style="color:#f97583">/</span><span style="color:#e1e4e8">m</span><span style="color:#f97583">**</span><span style="color:#79b8ff">2</span><span style="color:#e1e4e8">) </span><span style="color:#f97583">*</span><span style="color:#e1e4e8"> torch.trace(H </span><span style="color:#f97583">@</span><span style="color:#e1e4e8"> K </span><span style="color:#f97583">@</span><span style="color:#e1e4e8"> H </span><span style="color:#f97583">@</span><span style="color:#e1e4e8"> (H </span><span style="color:#f97583">@</span><span style="color:#e1e4e8"> L </span><span style="color:#f97583">@</span><span style="color:#e1e4e8"> H))</span></span></code></pre><p>The training loss is <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\mathcal{L}_{CE} + \alpha \cdot \text{HSIC}(z_{\text{biased}}, z_{\text{unbiased}})$</code>.</p><p>Running the heterogeneous ensemble (BiasedNet + ResNet18) and varying <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">ALPHA</code> across five orders of magnitude did not materially change the accuracy breakdown. The HSIC value itself was small and stable, which pointed at the kernel bandwidth: with <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\sigma = 1$</code> and 512-dimensional penultimate embeddings, pairwise distances saturate the RBF and the gradient signal effectively disappears. A data-adaptive bandwidth (e.g. the median heuristic) would be the right fix, but I didn’t pursue it.<sup class="footnote-ref"><a href="#fn-1" style="color:#70a5fd">1</a></sup></p><p>The <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">off_diag_proportion</code> mattered more than HSIC in these experiments: going from 0% to 10% off-diagonal pushed ResNet18 shape accuracy from 33% to 41% regardless of HSIC regularisation.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">IRM</h3><p><a href="https://arxiv.org/abs/1907.02893" style="color:#70a5fd">Invariant Risk Minimisation (Arjovsky et al., 2019)</a> takes a different approach to domain generalisation: instead of diversifying the ensemble, it learns a single representation where the optimal linear classifier on top of it is the same across all training environments. The penalty measures this by checking the gradient of each environment’s loss with respect to a dummy scalar <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$w$</code> clamped at 1: if <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\nabla_w \mathcal{L}_e(\Phi \cdot w)\big|_{w=1} \approx 0$</code> for all <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$e$</code>, then <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$w = 1$</code> is simultaneously optimal everywhere. The implementation splits each batch by environment label, computes the ERM loss and the squared gradient norm per environment separately, then combines them as <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\mathcal{L}_{\text{IRM}} = \sum_e \mathcal{L}_e + \lambda \sum_e \lVert \nabla_{w|w=1} \mathcal{L}_e \rVert^2$</code>. The gradient norm is computed by attaching a scalar dummy multiplier to the representation, doing a backward pass per environment, and squaring the resulting grad. On dSprites, IRM did not clearly outperform ERM in early training, which is consistent with its known sensitivity to finite-sample noise: the penalty can be trivially satisfied when environments are not sufficiently diverse to force a genuinely invariant solution.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Adversarial attacks on LLMs</h3><p><a href="https://arxiv.org/abs/2307.15043" style="color:#70a5fd">GCG (Zou et al., 2023)</a> showed that aligned LLMs can be jailbroken by appending an adversarial suffix to a harmful prompt. The suffix is found by a greedy coordinate search: compute the gradient of the target string’s cross-entropy with respect to one-hot token embeddings, sample the top-<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k$</code> candidate replacements for each suffix position, evaluate all candidates in a forward pass, and keep the best. <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">get_loss</code> is cross-entropy between the model’s logits at the target positions and the target token ids. <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">compute_token_gradient</code> embeds the input as one-hot vectors, runs a forward pass with those embeddings, calls <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">get_loss</code>, and returns the gradient of the loss with respect to the one-hot matrix. <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">sample_ids_from_grad</code> uses the negated gradient at each suffix position as a proxy score for candidate tokens and samples without replacement from the top-<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k$</code> by score, producing a set of candidate replacement tokens per position. The <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">run_gcg</code> outer loop and evaluation were provided.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">XAI</h2><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Remove-and-classify</h3><p>Evaluating a feature attribution map by eye is not very rigorous. Remove-and-classify (RAC) gives a model-agnostic metric: sort pixels by attribution score, remove the top <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k$</code> at a time, rerun the model, measure accuracy drop. A lower area under the accuracy curve (lower RAC score) means the method correctly identifies which pixels the model actually relies on.</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> remove_and_classify</span><span style="color:#e1e4e8">(model, dataset, explanations, k</span><span style="color:#f97583">=</span><span style="color:#79b8ff">100</span><span style="color:#e1e4e8">):</span></span>
<span class="line"><span style="color:#e1e4e8">    sorted_ranks </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> sort_explanations_by_importance(explanations)</span></span>
<span class="line"><span style="color:#e1e4e8">    performances </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> []</span></span>
<span class="line"><span style="color:#f97583">    for</span><span style="color:#e1e4e8"> i </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> range</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">, width </span><span style="color:#f97583">*</span><span style="color:#e1e4e8"> height, k):</span></span>
<span class="line"><span style="color:#f97583">        for</span><span style="color:#e1e4e8"> idx, coords </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> enumerate</span><span style="color:#e1e4e8">([sorted_ranks[j][i:i</span><span style="color:#f97583">+</span><span style="color:#e1e4e8">k]</span></span>
<span class="line"><span style="color:#f97583">                                      for</span><span style="color:#e1e4e8"> j </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> range</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">len</span><span style="color:#e1e4e8">(explanations))]):</span></span>
<span class="line"><span style="color:#e1e4e8">            rows, cols </span><span style="color:#f97583">=</span><span style="color:#79b8ff"> zip</span><span style="color:#e1e4e8">(</span><span style="color:#f97583">*</span><span style="color:#e1e4e8">coords)</span></span>
<span class="line"><span style="color:#e1e4e8">            dataset.data[idx][rows, cols] </span><span style="color:#f97583">=</span><span style="color:#79b8ff"> 128</span><span style="color:#6a737d">  # replace with grey</span></span>
<span class="line"><span style="color:#e1e4e8">        performances.append(model_accuracy(model, dataset))</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> performances</span></span></code></pre><p>The centred Gaussian baseline assigns highest importance to pixels near the image centre regardless of content. For CUB-200 bird images, birds often appear near the centre, so this baseline is not as naive as it sounds.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="Accuracy vs. feature removal step for the centred Gaussian baseline on Fashion-MNIST" height="432" src="https://korayulusan.github.io/static/py2-feature-removal.Bk5zEuNC_2e6Jkq.webp" width="567" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-relaxed max-w-[90%] mt-4 mx-auto" style="text-align:center"><span class="font-bold text-muted">Figure 1:</span> Accuracy vs. feature removal step for the centred Gaussian baseline on Fashion-MNIST</figcaption></figure><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Saliency</h3><p>Saliency (Simonyan et al., 2013) is the gradient of the predicted class score with respect to the input:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$M_{ij} = \max_c \left| \frac{\partial f_c(x)}{\partial x_{ij,c}} \right|$$</pre><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> saliency</span><span style="color:#e1e4e8">(model, dataset, sample_idx):</span></span>
<span class="line"><span style="color:#e1e4e8">    image, _ </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> dataset[sample_idx]</span></span>
<span class="line"><span style="color:#e1e4e8">    image </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> image.to(device).unsqueeze(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">).requires_grad_(</span><span style="color:#79b8ff">True</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#e1e4e8">    output </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> model(image)[</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">]</span></span>
<span class="line"><span style="color:#e1e4e8">    output[output.argmax(</span><span style="color:#ffab70">dim</span><span style="color:#f97583">=</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">)].backward()</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> image.grad.abs().max(</span><span style="color:#ffab70">dim</span><span style="color:#f97583">=</span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">)[</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">].squeeze().cpu()</span></span></code></pre><p>One backward pass. The maps tend to be noisy because gradients saturate in many regions of a deep network, highlighting edges more than semantically meaningful areas.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="Saliency map overlaid on a CUB-200 bird image" height="389" src="https://korayulusan.github.io/static/py2-saliency-output.wcxD96Oe_6vh9t.webp" width="519" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-relaxed max-w-[90%] mt-4 mx-auto" style="text-align:center"><span class="font-bold text-muted">Figure 2:</span> Saliency map overlaid on a CUB-200 bird image</figcaption></figure><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">CAM</h3><p>CAM (Zhou et al., 2016) works only on architectures where the classifier is a global average pooling followed by a linear layer (or a 1x1 convolution). The activation map for class <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$c$</code> is a weighted sum of final-layer feature maps:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{CAM}_c(x, y) = \sum_k w_k^c f_k(x, y)$$</pre><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> class_activation_map</span><span style="color:#e1e4e8">(model, dataset, sample_idx):</span></span>
<span class="line"><span style="color:#e1e4e8">    activation </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> {}</span></span>
<span class="line"><span style="color:#e1e4e8">    model.layer4[</span><span style="color:#f97583">-</span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">].relu.register_forward_hook(</span></span>
<span class="line"><span style="color:#f97583">        lambda</span><span style="color:#e1e4e8"> m, i, o: activation.update({</span><span style="color:#9ecbff">'f'</span><span style="color:#e1e4e8">: o.detach()})</span></span>
<span class="line"><span style="color:#e1e4e8">    )</span></span>
<span class="line"><span style="color:#e1e4e8">    image </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> dataset[sample_idx][</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">].to(device).unsqueeze(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#e1e4e8">    img_class </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> model(image).argmax(</span><span style="color:#ffab70">dim</span><span style="color:#f97583">=</span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#e1e4e8">    cam </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> (model.conv_last.weight[img_class].squeeze(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#f97583">           *</span><span style="color:#e1e4e8"> activation[</span><span style="color:#9ecbff">'f'</span><span style="color:#e1e4e8">].squeeze(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">)).sum(</span><span style="color:#ffab70">dim</span><span style="color:#f97583">=</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#e1e4e8">    cam </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> F.interpolate(cam[</span><span style="color:#79b8ff">None</span><span style="color:#e1e4e8">, </span><span style="color:#79b8ff">None</span><span style="color:#e1e4e8">], </span><span style="color:#ffab70">size</span><span style="color:#f97583">=</span><span style="color:#e1e4e8">image.shape[</span><span style="color:#f97583">-</span><span style="color:#79b8ff">2</span><span style="color:#e1e4e8">:], </span><span style="color:#ffab70">mode</span><span style="color:#f97583">=</span><span style="color:#9ecbff">'bicubic'</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> cam.squeeze().detach().cpu()</span></span></code></pre><p>CAM localises well because it uses the network’s own classification weights as spatial importance. The trade-off is that you need GAP before the classifier, which rules out most standard architectures without modification.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><img alt="Class activation map overlaid on a CUB-200 bird image" height="389" src="https://korayulusan.github.io/static/py2-cam-output.DSA4bKdD_2sQIK5.webp" width="479" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-relaxed max-w-[90%] mt-4 mx-auto" style="text-align:center"><span class="font-bold text-muted">Figure 3:</span> Class activation map overlaid on a CUB-200 bird image</figcaption></figure><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Training data attribution</h3><p>Where feature attribution asks which pixels drove a prediction, training data attribution asks which training examples drove it.</p><p>The influence function of training example <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$z_j$</code> on test example <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$z$</code> approximates the change in test loss from removing <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$z_j$</code> and retraining:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{IF}(z_j, z) \approx -\nabla_\theta \mathcal{L}(z, \hat\theta)^\top H_{\hat\theta}^{-1} \nabla_\theta \mathcal{L}(z_j, \hat\theta)$$</pre><p>Computing <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$H^{-1}v$</code> is the expensive step. LiSSA (used in <a href="https://arxiv.org/abs/2012.15781" style="color:#70a5fd">FastIF</a>) estimates it iteratively over minibatches using the recurrence:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$H^{-1}v \approx \sum_{t=0}^{T}(I - H)^t v$$</pre><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#e1e4e8">new_estimate </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> [</span></span>
<span class="line"><span style="color:#e1e4e8">    a </span><span style="color:#f97583">+</span><span style="color:#e1e4e8"> (</span><span style="color:#79b8ff">1</span><span style="color:#f97583"> -</span><span style="color:#e1e4e8"> damp) </span><span style="color:#f97583">*</span><span style="color:#e1e4e8"> b </span><span style="color:#f97583">-</span><span style="color:#e1e4e8"> c </span><span style="color:#f97583">/</span><span style="color:#e1e4e8"> scale</span></span>
<span class="line"><span style="color:#f97583">    for</span><span style="color:#e1e4e8"> a, b, c </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> zip</span><span style="color:#e1e4e8">(v, last_estimate, this_estimate)</span></span>
<span class="line"><span style="color:#e1e4e8">]</span></span></code></pre><p>The evaluation reruns training after removing the top-<code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$p\%$</code> most helpful, most harmful, or random training examples, then measures confidence change on the test example. Removing helpful examples should decrease confidence; removing harmful ones should increase it.</p><p><strong>kNN speedup.</strong> Full influence computation is <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$O(n)$</code> over training examples. FastIF restricts gradient dot products to the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k$</code> nearest neighbours in embedding space, using the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">[CLS]</code> token from the final layer:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> embed_one_example</span><span style="color:#e1e4e8">(model, example):</span></span>
<span class="line"><span style="color:#f97583">    with</span><span style="color:#e1e4e8"> torch.no_grad():</span></span>
<span class="line"><span style="color:#e1e4e8">        out </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> model(</span><span style="color:#f97583">**</span><span style="color:#e1e4e8">example, </span><span style="color:#ffab70">output_hidden_states</span><span style="color:#f97583">=</span><span style="color:#79b8ff">True</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#f97583">        return</span><span style="color:#e1e4e8"> out.hidden_states[</span><span style="color:#f97583">-</span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">][</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">, </span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> get_knn_indices</span><span style="color:#e1e4e8">(model, embedded_train, test_input, k</span><span style="color:#f97583">=</span><span style="color:#79b8ff">100</span><span style="color:#e1e4e8">):</span></span>
<span class="line"><span style="color:#e1e4e8">    test_emb </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> embed_one_example(model, test_input)</span></span>
<span class="line"><span style="color:#e1e4e8">    dists </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> torch.cdist(torch.stack(embedded_train), test_emb.unsqueeze(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">))</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> torch.topk(dists.squeeze(</span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">), </span><span style="color:#ffab70">k</span><span style="color:#f97583">=</span><span style="color:#e1e4e8">k).indices</span></span></code></pre><p>The IHVP is still computed over the full training set since the Hessian is a global quantity. Only the final gradient dot products are restricted to neighbours.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Discussion</h3><p>For feature attribution: the centred Gaussian baseline performing reasonably well on CUB-200 means the model is itself centre-biased, since birds appear near the centre in most images. Any attribution method that just highlights the centre will therefore look decent on RAC, which is a dataset artefact rather than a property of the attribution. Replacing removed pixels with a constant (128 / grey) is also not a sound evaluation because the inpainting itself can be informative about where features were removed, and it shifts the test distribution outside what the model trained on. A more principled approach would use learned in-painting or marginalise over a conditional distribution.</p><p>For training data attribution: LiSSA converges when the Hessian’s spectral radius is less than 1, which requires the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">scale</code> parameter to be large enough to contract the recurrence. Near-zero eigenvalues inflate the corresponding components of <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$H^{-1}v$</code>, making the approximation unstable; in practice this is handled by the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">damp</code> term that adds a small diagonal. <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">params_filter</code> restricts gradient computation to a named subset of parameters, typically just the final layer. For large pretrained models this is a good trade-off: the last layer’s gradients capture most of the task-specific signal, and computing full-model Jacobians would be prohibitively expensive. For the kNN speedup, computing influence scores for the full training set vs. only the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k$</code> nearest neighbours and sweeping <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k$</code> shows that quality (correlation with full influence) degrades slowly while compute drops roughly as <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$k/n$</code>.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Calibration</h2><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">BCE as a proper scoring rule</h3><p>A scoring rule is <strong>strictly proper</strong> if the forecaster’s expected score is minimised by reporting the true probability. For binary cross-entropy, let <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$p = P(Y=1)$</code>. The expected loss is:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\mathbb{E}[\mathcal{L}(q, Y)] = -(p\log q + (1-p)\log(1-q))$$</pre><p>Setting <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\frac{d}{dq} = 0$</code> gives <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$p/q = (1-p)/(1-q)$</code>, which simplifies to <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$p = q$</code>. The second derivative is <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$p/q^2 + (1-p)/(1-q)^2 &gt; 0$</code>, so the function is strictly convex and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$q = p$</code> is the unique minimiser.</p><p>For contrast, the linear loss <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\mathcal{L}(q,y) = -yq + (1-y)(1-q)$</code> has expected value <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$1 - p - q$</code>, minimised at <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$q \to 1$</code> regardless of <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$p$</code>. It is not a proper scoring rule.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Calibration metrics</h3><p><strong>Expected Calibration Error</strong> bins predictions by confidence and measures the weighted average gap between confidence and accuracy:</p><pre style="text-align:center;background:rgba(255,184,108,0.05);padding:12px;border:1px dashed rgba(255,184,108,0.3);border-radius:8px;color:#ffb86c;font-family:monospace">$$\text{ECE} = \sum_{b=1}^{B} \frac{|B_b|}{n} \left| \text{acc}(B_b) - \text{conf}(B_b) \right|$$</pre><p><strong>NLL</strong> evaluates calibration as a proper scoring rule directly. <strong>AUROC</strong> measures discrimination, which is independent of calibration: a model that always predicts 0.51 for positives and 0.49 for negatives achieves perfect AUROC but terrible calibration.</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> ece_score</span><span style="color:#e1e4e8">(pred_prob, y, n_bins</span><span style="color:#f97583">=</span><span style="color:#79b8ff">10</span><span style="color:#e1e4e8">):</span></span>
<span class="line"><span style="color:#e1e4e8">    bin_edges </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> torch.linspace(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">, </span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">, n_bins </span><span style="color:#f97583">+</span><span style="color:#79b8ff"> 1</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#e1e4e8">    ece </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> torch.tensor(</span><span style="color:#79b8ff">0.0</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#f97583">    for</span><span style="color:#e1e4e8"> i </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> range</span><span style="color:#e1e4e8">(n_bins):</span></span>
<span class="line"><span style="color:#e1e4e8">        mask </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> (bin_edges[i] </span><span style="color:#f97583">&lt;=</span><span style="color:#e1e4e8"> pred_prob) </span><span style="color:#f97583">&amp;</span><span style="color:#e1e4e8"> (pred_prob </span><span style="color:#f97583">&lt;</span><span style="color:#e1e4e8"> bin_edges[i</span><span style="color:#f97583">+</span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">])</span></span>
<span class="line"><span style="color:#f97583">        if</span><span style="color:#e1e4e8"> mask.any():</span></span>
<span class="line"><span style="color:#e1e4e8">            acc </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> (pred_prob[mask].round() </span><span style="color:#f97583">==</span><span style="color:#e1e4e8"> y[mask]).float().mean()</span></span>
<span class="line"><span style="color:#e1e4e8">            conf </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> pred_prob[mask].mean()</span></span>
<span class="line"><span style="color:#e1e4e8">            ece </span><span style="color:#f97583">+=</span><span style="color:#e1e4e8"> (mask.sum() </span><span style="color:#f97583">/</span><span style="color:#79b8ff"> len</span><span style="color:#e1e4e8">(pred_prob)) </span><span style="color:#f97583">*</span><span style="color:#e1e4e8"> (acc </span><span style="color:#f97583">-</span><span style="color:#e1e4e8"> conf).abs()</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> ece</span></span>
<span class="line"></span>
<span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> nll_score</span><span style="color:#e1e4e8">(pred_prob, y):</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#f97583"> -</span><span style="color:#e1e4e8">torch.mean(y </span><span style="color:#f97583">*</span><span style="color:#e1e4e8"> torch.log(pred_prob) </span><span style="color:#f97583">+</span><span style="color:#e1e4e8"> (</span><span style="color:#79b8ff">1</span><span style="color:#f97583"> -</span><span style="color:#e1e4e8"> y) </span><span style="color:#f97583">*</span><span style="color:#e1e4e8"> torch.log(</span><span style="color:#79b8ff">1</span><span style="color:#f97583"> -</span><span style="color:#e1e4e8"> pred_prob))</span></span></code></pre><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Which model is which</h3><p>Four anonymous binary classifiers, evaluated on ECE, NLL, and AUROC:</p><table style="margin-top:1em;border-collapse:collapse;width:100%"><thead><tr><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Model</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">ECE</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">NLL</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">AUROC</th><th style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Verdict</th></tr></thead><tbody><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">1</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">High</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">High</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Low</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Uncalibrated, inaccurate, overconfident</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">2</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Low</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Moderate</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Low</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Calibrated, inaccurate</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">3</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Low</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Low</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">~0.99</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Calibrated, accurate</td></tr><tr><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">4</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">High</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">High</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">High</td><td style="border:1px solid rgba(255, 255, 255, 0.1);padding:8px;text-align:left">Uncalibrated, accurate, underconfident</td></tr></tbody></table><p>Model 3’s reliability diagram looks unusual at 10 bins: tight calibration on average but high per-bin variance. Zooming to 50 bins shows the samples are not uniformly distributed across confidence levels. The model is extremely confident on most predictions and rarely uses intermediate probabilities.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><figure class="flex flex-col gap-2.5 m-0 sm:col-span-full sm:mx-auto sm:w-1/2"><img alt="Reliability diagram for model 1 (10 bins)" height="2469" src="https://korayulusan.github.io/static/py3-acc-conf-graph1.DdD3bFhF_Te0qI.webp" width="2550" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 4:</span> Reliability diagram for model 1 (10 bins)</figcaption></figure><figure class="flex flex-col gap-2.5 m-0 col-start-1"><img alt="Reliability diagram for model 2 (10 bins)" height="2469" src="https://korayulusan.github.io/static/py3-acc-conf-graph2.9nT-xCGk_elXTX.webp" width="2550" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 5:</span> Reliability diagram for model 2 (10 bins)</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Reliability diagram for model 2 (50 bins)" height="2467" src="https://korayulusan.github.io/static/py3-acc-conf-graph3.Db6AS6x-_1sskxT.webp" width="2550" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 6:</span> Reliability diagram for model 2 (50 bins)</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Reliability diagram for model 3 (10 bins)" height="2466" src="https://korayulusan.github.io/static/py3-acc-conf-graph4.Dj18qX-3_1AiFKP.webp" width="2550" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 7:</span> Reliability diagram for model 3 (10 bins)</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Reliability diagram for model 3 (50 bins)" height="2467" src="https://korayulusan.github.io/static/py3-acc-conf-graph5.BEJWUTxL_Z1DRU5J.webp" width="2550" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 8:</span> Reliability diagram for model 3 (50 bins)</figcaption></figure><figure class="flex flex-col gap-2.5 m-0 sm:col-span-full sm:mx-auto sm:w-1/2"><img alt="Reliability diagram for model 4 (10 bins)" height="2465" src="https://korayulusan.github.io/static/py3-acc-conf-graph6.By3nDgE1_Z1uHHkn.webp" width="2550" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 9:</span> Reliability diagram for model 4 (10 bins)</figcaption></figure></figure><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Cheating ECE</h3><p>Model 5 had a bad ECE of 0.25. The ground-truth labels were shuffled (preserving the marginal class distribution but losing per-sample alignment), so there was no way to actually improve the model. The task was to make the score look better anyway.</p><p>ECE bins predictions, which creates tolerance: predictions of 0.48 and 0.52 land in different bins but are nearly identical values. You can exploit this by gradient-descending the predictions against the shuffled labels while re-shuffling at each step:</p><pre class="astro-code github-dark" style="background-color:#24292e !important;color:#e1e4e8 !important;padding:16px;border-radius:8px;border:1px dashed rgba(255,184,108,0.3);overflow-x:auto;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;line-height:1.5;margin:20px 0"><code><span class="line"><span style="color:#f97583">def</span><span style="color:#b392f0"> increase_ece</span><span style="color:#e1e4e8">(pred_prob, shuffled_y):</span></span>
<span class="line"><span style="color:#e1e4e8">    pred_prob </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> pred_prob.clone().detach().requires_grad_(</span><span style="color:#79b8ff">True</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#e1e4e8">    optimizer </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> torch.optim.Adam([pred_prob])</span></span>
<span class="line"><span style="color:#f97583">    for</span><span style="color:#e1e4e8"> step </span><span style="color:#f97583">in</span><span style="color:#79b8ff"> range</span><span style="color:#e1e4e8">(</span><span style="color:#79b8ff">1000</span><span style="color:#e1e4e8">):</span></span>
<span class="line"><span style="color:#e1e4e8">        optimizer.zero_grad()</span></span>
<span class="line"><span style="color:#e1e4e8">        loss </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> differentiable_ece(pred_prob, shuffled_y)</span></span>
<span class="line"><span style="color:#e1e4e8">        loss.backward()</span></span>
<span class="line"><span style="color:#e1e4e8">        optimizer.step()</span></span>
<span class="line"><span style="color:#e1e4e8">        pred_prob.data.clamp_(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">, </span><span style="color:#79b8ff">1</span><span style="color:#e1e4e8">)</span></span>
<span class="line"><span style="color:#e1e4e8">        shuffled_y </span><span style="color:#f97583">=</span><span style="color:#e1e4e8"> shuffled_y[torch.randperm(shuffled_y.size(</span><span style="color:#79b8ff">0</span><span style="color:#e1e4e8">))]</span></span>
<span class="line"><span style="color:#f97583">    return</span><span style="color:#e1e4e8"> pred_prob.detach()</span></span></code></pre><p>The re-shuffling prevents the optimiser from fitting any particular label assignment. In practice, it collapses predictions toward 0.5, which looks calibrated in the 10-bin ECE because each bin gets roughly equal confidence and accuracy values.</p><figure class="mx-auto fig-root md:my-12 my-10 not-prose"><figure class="flex flex-col gap-2.5 m-0"><img alt="Model 4 reliability diagram before ECE manipulation" height="2460" src="https://korayulusan.github.io/static/py3-acc-conf-graph7.B8_HXrQC_Z1ngpAg.webp" width="2550" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 10:</span> Model 4 reliability diagram before ECE manipulation</figcaption></figure><figure class="flex flex-col gap-2.5 m-0"><img alt="Reliability diagram after collapsing predictions toward 0.5" height="2466" src="https://korayulusan.github.io/static/py3-acc-conf-graph8.Cx2BMDVM_1pNRR7.webp" width="2550" class="block h-full m-0 object-cover w-full" style="max-width:100%;height:auto;border-radius:8px;border:1px solid rgba(255, 255, 255, 0.1);margin:10px 0;display:block" /><figcaption class="text-sm text-muted/80 text-center leading-snug px-2" style="text-align:center"><span class="font-bold text-muted">Figure 11:</span> Reliability diagram after collapsing predictions toward 0.5</figcaption></figure></figure><p><strong>NLL cannot be cheated this way.</strong> NLL is strictly proper: any transformation of <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\hat{p}$</code> that does not move it closer to the true <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$P(Y=1)$</code> increases the expected NLL by definition. Collapsing to 0.5 gives NLL <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\geq \log 2 \approx 0.693$</code>, which is worse than any model that knows anything about the task. The binning tolerance ECE relies on simply does not exist in NLL.</p><span class="uppercase tracking-[0.2em] text-[10px] block font-black mb-2 text-accent">Key Takeaway</span><p>ECE can be gamed without improving the model. NLL cannot, which is part of why it is a preferred calibration metric in the literature.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Epistemic uncertainty: ensembles</h3><p>The second part of the uncertainty homework covered three ensemble methods for estimating epistemic uncertainty. <strong>Bagging</strong> trains <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$N$</code> models on bootstrapped subsets (sampling <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$n$</code> examples with replacement, giving each model ~63% unique examples) and averages their predicted distributions. <strong><a href="https://arxiv.org/abs/1506.02142" style="color:#70a5fd">MC Dropout (Gal &amp; Ghahramani, 2016)</a></strong> keeps dropout active at test time and draws <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$M$</code> stochastic forward passes; epistemic uncertainty is the Jensen-Shannon divergence between the <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$M$</code> predictive distributions and aleatoric uncertainty is their average entropy. <strong><a href="https://arxiv.org/abs/1802.10026" style="color:#70a5fd">Fast Geometric Ensembling (Garipov et al., 2018)</a></strong> continues training from a converged checkpoint with cosine annealing and snapshots the model at each cycle minimum, giving ensemble diversity at a fraction of the cost of training from scratch <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$N$</code> times.</p><p>All three methods should show higher epistemic uncertainty on OOD inputs (the models disagree more when the input is unlike training data) and stable aleatoric uncertainty (since aleatoric noise is a property of the data, not the model). In practice, MC Dropout tends to underestimate epistemic uncertainty because the dropout approximation is a weak posterior; FGE snapshots sit in different basins and tend to disagree more meaningfully on hard examples.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Aleatoric uncertainty: ELK and EDL</h3><p>Total predictive uncertainty decomposes into epistemic and aleatoric components. Under an ensemble posterior the analytical result is: total entropy of the mean prediction equals mean entropy across members (aleatoric) plus mutual information between the prediction and the model parameters (epistemic). The Monte Carlo estimate replaces the intractable integrals with averages over <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$M$</code> samples: <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\hat{U}_{\text{aleatoric}} = \frac{1}{M}\sum_m H[p_m]$</code> and <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\hat{U}_{\text{epistemic}} = H[\bar{p}] - \hat{U}_{\text{aleatoric}}$</code>.</p><p><a href="https://arxiv.org/abs/1806.01768" style="color:#70a5fd">Evidential Deep Learning (Sensoy et al., 2018)</a> places a Dirichlet prior over class probabilities, so a single forward pass returns both a prediction and separate uncertainty estimates. Without a regulariser, the network learns to make the Dirichlet concentration parameters very large on training data, collapsing uncertainty to near zero even when predictions are wrong. The regulariser penalises evidence on incorrect predictions by scaling the KL divergence from the posterior to a flat Dirichlet by the prediction error, forcing the model to retain uncertainty proportional to how much it’s been wrong.</p><h3 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">LLM uncertainty</h3><p>MC Dropout on GPT-2: keep the model in <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">train()</code> mode at test time, run <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$M$</code> forward passes, and measure the entropy of the mean next-token distribution (total uncertainty) and the JSD across pass distributions (epistemic). Tokens where the model consistently assigns high probability to a single token should have low epistemic uncertainty; rare or syntactically ambiguous positions should have high uncertainty. For the BERT NER task, uncertainty should be highest at entity boundaries and on entity types that appear rarely in training, and lowest on tokens clearly labelled O.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Closing Thoughts</h2><p>A theme across all three exercises: evaluation metrics have blind spots that matter. ECE can be gamed, AUROC ignores calibration, RAC depends on how you inpaint removed pixels, and influence functions evaluated on the test set are implicitly using that test set for model selection. None of this makes the metrics useless, but it does mean you should not rely on a single number.</p><p>The HSIC experiment was a good example of a subtler version of the same issue. Varying <code style="background:rgba(255, 184, 108, 0.1);color:#ffb86c;padding:2px 5px;border-radius:4px;font-family:monospace">$\alpha$</code> across several orders of magnitude produced no visible effect on accuracy because the kernel bandwidth was too small for the embedding dimensionality, making the loss term effectively zero. The metric looked like it was doing something (the loss value changed) but it was not actually doing anything (the gradients were negligible). Getting null results from a regulariser is easy to mistake for a confirmation that the method is not needed, when really the implementation was quietly broken.</p><p>The most transferable lesson from the PoE section is the greyscale intervention: instead of adding a loss term to discourage colour, removing colour from the input entirely is a stronger and more interpretable guarantee. Architectural constraints beat soft penalties when you can afford them.</p><h2 style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Acknowledgments</h2><p>These tasks were completed as part of the Trustworthy Machine Learning Course 2024/25 taught by Prof. Seong Joon Oh at the University of Tübingen, in collaboration with Ali Zhunis, and Esma Doğrusözlü. Team name <a href="https://en.wikipedia.org/wiki/Aldebaran" style="color:#70a5fd">Aldebaran</a> (an orange giant star) was assigned by the course organization. Compute was provided by Kaggle.</p><h2 class="text-sm font-semibold mb-4 pb-0 text-heading tracking-widest uppercase" style="color:#ffb86c;margin-top:1.5em;margin-bottom:0.5em;font-family:sans-serif">Footnotes</h2><ol class="text-sm pl-6 space-y-4 text-body-text/90"><li style="list-style-type:decimal !important" class="footnote-item"><span>Evaluating HSIC's contribution rigorously also requires ablating α=0 on identical training runs, not just sweeping α on the test set. Any hyperparameter choice tuned against the test set is quietly using test information.</span><a href="#fnref-1" class="text-accent/90 footnote-back hover:underline ml-2 no-underline text-xs" title="Back to text" style="color:#70a5fd">↩</a></li></ol><p class="mt-8 text-muted/80">Thanks for reading! If you have questions, feedback, or want to discuss a project, feel free to drop me an <a style="color:#70a5fd"><span>email</span></a>or message.</p>
      <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center; font-family: 'JetBrains Mono', monospace; color: #9ca3af;">
        <span style="color: #ffb86c;">◆</span> 
        Originally published at 
        <a href="https://korayulusan.github.io/blog/trustworthy-ml-ood-xai-uncertainty/" style="color: #ffb86c; text-decoration: none; border-bottom: 1px solid #ffb86c;">korayulusan.github.io</a>
      </div>
    </div>]]></content:encoded>
  </item>
  </channel>
</rss>