<?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:atom="http://www.w3.org/2005/Atom"><channel><title>Koray Ulusan | Blog</title><description>Writing on Machine Learning, GenAI, and Engineering.</description><link>https://korayulusan.github.io/</link><language>en-us</language><pubDate>Sat, 18 Apr 2026 07:00:00 GMT</pubDate><lastBuildDate>Wed, 22 Apr 2026 08:47:00 GMT</lastBuildDate><atom:link href="https://korayulusan.github.io/rss.xml" rel="self" type="application/rss+xml"/><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><description>Post about Astro, Tailwind CSS, Web Development, RSS</description><pubDate>Wed, 22 Apr 2026 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built this website in April 2026 using &lt;code&gt;Astro 6&lt;/code&gt;, &lt;code&gt;Tailwind CSS 4&lt;/code&gt; and &lt;code&gt;MDX&lt;/code&gt; to create a high-performance blog that bridges the gap between academic research and modern web standards. My stack centers on a &lt;code&gt;Vite 7&lt;/code&gt;-powered pipeline optimized with &lt;code&gt;Terser&lt;/code&gt; and &lt;code&gt;astro-compress&lt;/code&gt;. By integrating &lt;code&gt;remark-math&lt;/code&gt; and &lt;code&gt;rehype-katex&lt;/code&gt;, you can see beautifully rendered math. I also have a RSS feed with a beautiful &lt;code&gt;xsl&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The entire lifecycle, from transforming &lt;em&gt;content collections&lt;/em&gt; to final minification and deployment to &lt;code&gt;GitHub Pages&lt;/code&gt;, is orchestrated via &lt;code&gt;make&lt;/code&gt; utilizing &lt;code&gt;exiftool&lt;/code&gt; and &lt;code&gt;gh&lt;/code&gt; to ensure a lean, production-ready build.&lt;/p&gt;
&lt;p&gt;Rather than inheriting the legacy overhead of &lt;em&gt;al-folio&lt;/em&gt; or similar, I spent three days engineering a custom solution tailored to my specific needs. Building from scratch ensured total control over the stack, and while it took a few more days to iron out the finer details, I&apos;m happy with the result. I hope you like it, too!&lt;/p&gt;
</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><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><pubDate>Mon, 20 Apr 2026 05:00:00 GMT</pubDate><content:encoded>&lt;h3&gt;TL;DR&lt;/h3&gt;
&lt;p&gt;I built &lt;strong&gt;DAF Online&lt;/strong&gt;, a free, browser-based tool for speech therapy that helps people who stutter and Parkinson&apos;s patients find fluency. While native apps and $1000 hardware exist, I used the &lt;strong&gt;Web Audio API&lt;/strong&gt; to achieve sub-6ms latency in the browser by aggressively optimizing the audio graph.&lt;/p&gt;
&lt;h2&gt;What is Delayed Auditory Feedback?&lt;/h2&gt;
&lt;p&gt;Delayed Auditory Feedback (DAF) is simple: you hear your own voice played back with a short delay. What&apos;s less obvious is what that tiny lag does to your brain.&lt;/p&gt;
&lt;p&gt;For people who stutter, speaking while hearing a slightly delayed version of your own voice can induce near-instant fluency. It&apos;s called the &lt;strong&gt;Chorus Effect&lt;/strong&gt;. 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&apos;s patients, where the delay acts as a natural &quot;speed limit,&quot; forcing slower, more deliberate speech.&lt;/p&gt;
&lt;p&gt;The tool has three core audiences: people who stutter, individuals with Parkinson&apos;s Disease, and SLPs running remote telehealth sessions who need a quick, zero-friction way to get a patient practicing from home.&lt;/p&gt;
&lt;h2&gt;The Landscape Before I Built This (Early 2025)&lt;/h2&gt;
&lt;p&gt;When I went looking for a free, browser-based DAF tool, I found: nothing that actually worked.&lt;/p&gt;
&lt;p&gt;The market looked roughly like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dedicated hardware&lt;/strong&gt; (e.g., Casa Futuro, SpeechEasy): $1000-$2500+. Clinically validated, but you need to order, wait, and pay.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native mobile apps (e.g., DAF Pro)&lt;/strong&gt;: A handful exist on iOS and Android. Some are free-tier, most push you toward a subscription. They work reasonably well on modern phones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web-based pages&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &quot;Developer Gap&quot;&lt;/strong&gt;: 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&apos;t hosted. Just code sitting in a repo.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The implementation isn&apos;t complex. The Web Audio API has had a &lt;code&gt;DelayNode&lt;/code&gt; for years. The gap wasn&apos;t technical; nobody had simply bothered to close it. &lt;/p&gt;
&lt;h2&gt;The Math: What&apos;s Actually Happening&lt;/h2&gt;
&lt;p&gt;The feedback loop is simple. The output signal is the input signal shifted in time:&lt;/p&gt;
&lt;p&gt;$$
y[n] = \alpha \cdot x[n - (k_{user} + k_{sys})]
$$&lt;/p&gt;
&lt;p&gt;Where:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$y[n]$: The signal the user hears at time $n$&lt;/li&gt;
&lt;li&gt;$x[n]$: The user&apos;s voice entering the microphone&lt;/li&gt;
&lt;li&gt;$k_{user}$: &lt;strong&gt;Intentional Lag&lt;/strong&gt; is the delay you dial in&lt;/li&gt;
&lt;li&gt;$k_{sys}$: &lt;strong&gt;System Floor&lt;/strong&gt; is the device lag. the hidden hardware/OS latency floor&lt;/li&gt;
&lt;li&gt;$\alpha$: Gain (volume)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then the effective delay $k_{eff}$ is the sum of the intentional lag and the device lag:&lt;/p&gt;
&lt;p&gt;$$
k_{eff} = k_{user} + k_{sys}
$$&lt;/p&gt;
&lt;p&gt;The variable most people ignore is $k_{sys}$. It&apos;s not zero. And if it&apos;s high, your &quot;50ms delay&quot; is actually 100ms, which is a qualitatively different therapeutic experience and potentially useless.&lt;/p&gt;
&lt;h2&gt;Why Latency Is the Whole Game&lt;/h2&gt;
&lt;p&gt;For DAF to work therapeutically, the &lt;strong&gt;internal device latency&lt;/strong&gt; needs to stay under &lt;strong&gt;15-20ms&lt;/strong&gt;. That is the time your hardware and software spend processing audio &lt;em&gt;before&lt;/em&gt; your intentional delay is added.&lt;/p&gt;
&lt;p&gt;Here&apos;s why it matters: if $k_{sys}$ is already 50ms and you set a 50ms intentional delay, the user hears a 100ms echo. Worse, high internal latency usually comes with &lt;strong&gt;jitter&lt;/strong&gt; (timing variance), which breaks the chorus effect entirely. Jitter makes the delay feel unstable. The brain doesn&apos;t settle into choral mode, it just gets confused.&lt;/p&gt;
&lt;h3&gt;The Latency Landscape by Device&lt;/h3&gt;



Setup
Typical Internal Latency
Verdict



&lt;strong&gt;Dedicated PC Drivers/Hardware&lt;/strong&gt;
1 - 9ms
Excellent


&lt;strong&gt;Dedicated DAF Hardware&lt;/strong&gt;
&amp;lt; 10 ms
Excellent


&lt;strong&gt;High-End PC + Chrome$^*$&lt;/strong&gt;
6 - 10 ms
Excellent


&lt;strong&gt;iPhone 16 + Safari$^*$&lt;/strong&gt;
13 ms
Good


&lt;strong&gt;aptX Low Latency codec (Bluetooth)&lt;/strong&gt;
40 ms
Borderline


&lt;strong&gt;AAC codec (Bluetooth)&lt;/strong&gt;
100 - 200 ms
Unusable


&lt;strong&gt;SBC codec (Bluetooth)&lt;/strong&gt;
150 - 250 ms
Unusable


&lt;p&gt;$^*$: My implementation.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h3&gt;How to Test Your Own Floor&lt;/h3&gt;
&lt;p&gt;Set the software delay to &lt;strong&gt;0 ms&lt;/strong&gt;. Speak a sharp &quot;P&quot; or &quot;K&quot; 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.&lt;/p&gt;
&lt;p&gt;  Bluetooth headphones are incompatible with DAF therapy. Their 150-250ms hardware latency dwarfs any intentional delay you&apos;d set, making the total delay unpredictable and therapeutically ineffective. Always use wired headphones.&lt;/p&gt;
&lt;h2&gt;How It&apos;s Built for Speed&lt;/h2&gt;
&lt;p&gt;To be a legitimate alternative to dedicated hardware, the implementation needed to minimize $k_{sys}$ as aggressively as possible. Three things matter most.&lt;/p&gt;
&lt;h3&gt;1. Minimal Audio Graph Topology&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Minimal 2-hop topology for maximum performance
_connectAudioNodes() {
    const nodes = this.audioNodes;

    // source (Mic) -&amp;gt; delay (DAF) -&amp;gt; gain (Vol) -&amp;gt; destination (Output)
    nodes.source.connect(nodes.delayNode);
    nodes.delayNode.connect(nodes.gainNode);
    nodes.gainNode.connect(this.audioContext.destination);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Requesting Hardware-Level Latency&lt;/h3&gt;
&lt;p&gt;Browsers default to an &quot;interactive&quot; latency mode (~50ms buffer). Setting &lt;code&gt;latencyHint: 0&lt;/code&gt; tells the browser to request the minimum buffer size the hardware allows. Matching the native device sample rate eliminates resampling lag.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_createAudioContext() {
    const contextOptions = {
        // Request absolute minimum buffer size from hardware
        latencyHint: 0,
    };

    // Match native hardware sample rate to bypass resampling lag
    if (this.deviceSampleRate) {
        contextOptions.sampleRate = this.deviceSampleRate;
    }

    this.audioContext = new (window.AudioContext || window.webkitAudioContext)(contextOptions);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Honest Latency Measurement&lt;/h3&gt;
&lt;p&gt;The tool reads &lt;code&gt;baseLatency&lt;/code&gt; and &lt;code&gt;outputLatency&lt;/code&gt; directly from the &lt;code&gt;AudioContext&lt;/code&gt; and adds them to the display so the user always sees their &lt;em&gt;effective&lt;/em&gt; delay, not just the slider value.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Measuring the true hardware &quot;floor&quot;
const outputMs = (this.audioContext.baseLatency + (this.audioContext.outputLatency || 0)) * 1000;
this.measuredFloorMs = outputMs;

// UI shows both the target and the honest effective delay
const effective = Math.round(targetDelay + measuredFloorMs);
this.displayLabel = `${targetDelay} ms (~${effective} ms effective)`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This matters for trust. A user who sees &quot;50ms (est. 56ms effective)&quot; understands their setup. A user who sees &quot;50ms&quot; and hears 100ms thinks the tool is broken.&lt;/p&gt;
&lt;h2&gt;Frequency Altered Feedback (FAF)&lt;/h2&gt;
&lt;p&gt;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&apos;s normal feedback loop and can improve fluency for some users. It&apos;s on the roadmap for a future update, but DAF was the priority since it&apos;s more widely used and has a clearer latency requirement.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;FAF shifts your voice up or down in pitch rather than delaying it. The therapeutic mechanism is similar, but the implementation is messier.&lt;/p&gt;
&lt;p&gt;The problem is that you can&apos;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.&lt;/p&gt;
&lt;p&gt;At 44.1 kHz, the relationship is straightforward:&lt;/p&gt;
&lt;p&gt;$$
L_{ms} = \frac{N}{f_s} \cdot 1000
$$&lt;/p&gt;



Buffer Size (Samples)
Latency Added (at 44.1kHz)
Therapeutic Verdict



&lt;strong&gt;128&lt;/strong&gt;
~2.9 ms
Fine


&lt;strong&gt;256&lt;/strong&gt;
~5.8 ms
Fine


&lt;strong&gt;512&lt;/strong&gt;
~11.6 ms
Borderline


&lt;strong&gt;1024&lt;/strong&gt;
~23.2 ms
Already over the threshold


&lt;h3&gt;Why you can&apos;t skip the buffer&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;AudioWorklet&lt;/code&gt; and a minimum window size.&lt;/p&gt;
&lt;h3&gt;Phase vocoders vs. granular synthesis&lt;/h3&gt;
&lt;p&gt;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&apos;s already past the cutoff.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;SEO: Why the Body Text Is Long (On Purpose)&lt;/h2&gt;
&lt;p&gt;&quot;Delayed Auditory Feedback&quot; is an incredibly niche topic. If you compare it to a broader term like &quot;Stuttering&quot; in Google Trends, you can see how small the specific search market is for the tool itself compared to the condition it treats.&lt;/p&gt;
&lt;p&gt;Building the tool was the easy part. Getting it in front of people who need it took just as long.&lt;/p&gt;
&lt;p&gt;Most users arrive via high-intent functional queries. They know what a DAF tool is. They just need to find one that works. &lt;strong&gt;A minimal landing page with a slider and a button would rank for nothing.&lt;/strong&gt; In the first months, the tool was &lt;strong&gt;invisible&lt;/strong&gt; to these high-intent users, stalled at 11th-15th in the rankings while hardware retailers and native apps claimed the top spots.&lt;/p&gt;
&lt;p&gt;By writing thorough, accurate content about the science and the use cases, the site achieved a &lt;strong&gt;2.4 weighted average position&lt;/strong&gt; for core keywords, capturing &lt;strong&gt;85% of organic traffic&lt;/strong&gt; from the top 3 results with a &lt;strong&gt;75% CTR&lt;/strong&gt; on primary search intent (Feb 2026).&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Fun Fact: As it turns out, &quot;DAF&quot; is a congested acronym dominated by Dutch heavy-duty truck manufacturer DAF Trucks N.V. If you search &quot;DAF&quot; without context, Google assumes you&apos;re looking for a 7.5-ton hauler, not a speech aid.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The gap wasn&apos;t a hard engineering problem; it was just an ignored one. Speech therapy is a small market, and most developers aren&apos;t building for people who stutter or have Parkinson&apos;s. Which is why it was worth doing.&lt;/p&gt;
&lt;p&gt;If you want to look at the implementation or try the tool yourself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DAF Online — Try it here!&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Source code on GitHub&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
</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><description>How using InstantID to generate synthetic training data for DreamBooth dramatically improves facial resemblance in AI-generated professional portraits — and why classical augmentations often make things worse.</description><pubDate>Wed, 28 May 2025 05:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This post explores research originally presented at the CVPR 2025 Workshop on Synthetic Data for Computer Vision (SynData4CV).&lt;/p&gt;
&lt;h3&gt;TL;DR&lt;/h3&gt;
&lt;p&gt;Instead of using &quot;classical&quot; image tweaks like flipping or rotating, which actually distort facial identity, this research proves that using &lt;strong&gt;InstantID&lt;/strong&gt; to generate high-quality synthetic portraits as training data significantly improves a &lt;strong&gt;DreamBooth&lt;/strong&gt; model&apos;s ability to produce realistic, professional-grade headshots.&lt;/p&gt;
&lt;h2&gt;The Problem with Few-Shot AI Portraits&lt;/h2&gt;
&lt;p&gt;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 &lt;em&gt;DreamBooth&lt;/em&gt; struggle with a bottleneck of identity retention: they need to learn &lt;em&gt;who you are&lt;/em&gt; from a tiny handful of images, then generalize that identity to entirely new scenes and styles.&lt;/p&gt;
&lt;p&gt;This is the few-shot personalization problem. It sits at the tension between two competing goals: &lt;strong&gt;identity retention&lt;/strong&gt; (the output should actually look like you) and &lt;strong&gt;recontextualization&lt;/strong&gt; (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 &quot;Generating Synthetic Data via Augmentations for Improved Facial Resemblance in DreamBooth and InstantID&quot;, investigates a third path: using one generative model to improve the training of another.&lt;/p&gt;
&lt;h2&gt;Why Classical Augmentations Backfire&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h3&gt;Geometric traps&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Random Horizontal Flip&lt;/strong&gt; 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 &lt;em&gt;second&lt;/em&gt; identity that contradicts the first. Rather than generalizing, the model averages them into an uncanny composite.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Random Rotation&lt;/strong&gt; introduces black padding bars around the frame, which the model dutifully learns as part of the subject&apos;s visual signature. It also misaligns facial landmarks, undermining the spatial consistency that makes face generation coherent.&lt;/p&gt;
&lt;h3&gt;Colour confusion&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Colour Jittering&lt;/strong&gt; (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.&lt;/p&gt;
&lt;h3&gt;Segmentation imperfections&lt;/h3&gt;
&lt;p&gt;Replacing backgrounds using a segmentation model like $U^2$-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&apos;s identity, making clean background swaps nearly impossible downstream.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;  Classical augmentations introduce distributional artifacts. With no other signal to reject them, the model faithfully memorizes these artifacts as identity-defining features.&lt;/p&gt;
&lt;h2&gt;A New Approach: GenAI Improving GenAI&lt;/h2&gt;
&lt;p&gt;Instead of perturbing real images in ways that corrupt facial structure, the approach explored in this paper asks a different question: &lt;em&gt;what if the augmented images were themselves high-quality generations of the person, produced by a model that already understands faces?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The answer is &lt;strong&gt;generative augmentation via InstantID&lt;/strong&gt;. By conditioning InstantID on a subject&apos;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&apos;s feature space, so DreamBooth does not have to reconcile the domain gap that plagues classical augmentations.&lt;/p&gt;
&lt;p&gt;The result is measurably better facial resemblance in the fine-tuned DreamBooth model, with the full range of recontextualization still intact.&lt;/p&gt;
&lt;h2&gt;Practical Takeaways&lt;/h2&gt;
&lt;p&gt;These findings translate directly into actionable recommendations for anyone building portrait personalization pipelines.&lt;/p&gt;
&lt;h3&gt;1. Balance real and synthetic images&lt;/h3&gt;
&lt;p&gt;The most important constraint for preventing overfitting is &lt;strong&gt;dataset diversity&lt;/strong&gt;. 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&apos;s stylistic fingerprint rather than the subject&apos;s actual face.&lt;/p&gt;
&lt;h3&gt;2. The Rule of Four&lt;/h3&gt;
&lt;p&gt;When generating synthetic training data with InstantID, providing &lt;strong&gt;four reference images&lt;/strong&gt; 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.&lt;/p&gt;
&lt;h3&gt;3. Resolution matters&lt;/h3&gt;
&lt;p&gt;Images around &lt;strong&gt;1 megapixel&lt;/strong&gt; 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.&lt;/p&gt;
&lt;h3&gt;4. Skip the flips, rotations, and jitter&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Measuring Resemblance: The FaceDistance Metric&lt;/h2&gt;
&lt;p&gt;Qualitative &quot;vibes&quot; 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 &lt;strong&gt;FaceDistance&lt;/strong&gt;, a validation tool built on &lt;strong&gt;FaceNet&lt;/strong&gt; embeddings.&lt;/p&gt;
&lt;p&gt;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 $G_i$ and the set of original reference images ${R_j}$:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Definition:&lt;/strong&gt; Given batches of generated images $G = {G_i}&lt;em&gt;{i=1}^m$ and original reference images $R = {R_j}&lt;/em&gt;{j=1}^n$, the &lt;strong&gt;FaceDistance&lt;/strong&gt; is defined as:&lt;/p&gt;
&lt;p&gt;$$
\bigl[\operatorname{FaceDistance}(G, R)\bigr]&lt;em&gt;i := \frac{1}{n} \sum&lt;/em&gt;{j=1}^n \delta^{[0,2]}_{\text{cos}}!\bigl(f(G_i),, f(R_j)\bigr)
$$ &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Breaking down the logic:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The Encoder ($f$):&lt;/strong&gt; We use &lt;strong&gt;MTCNN&lt;/strong&gt; for precise face detection, followed by &lt;strong&gt;FaceNet&lt;/strong&gt; to extract the identity embedding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Distance ($\delta_{\cos}$):&lt;/strong&gt; We use cosine distance, clipped to a $[0, 2]$ range for numerical stability.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A &lt;strong&gt;lower FaceDistance score&lt;/strong&gt; indicates a stronger mathematical resemblance to the subject.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pro Tip:&lt;/strong&gt; FaceDistance acts more like a high-pass filter than a perfect judge. It is excellent for identifying &quot;catastrophic drift&quot; (where the model loses the subject entirely) but it isn&apos;t sensitive enough to decide if a &quot;good&quot; image is &quot;great.&quot; &lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In our pipeline, we found that simply discarding the top &lt;strong&gt;15%&lt;/strong&gt; of highest-distance embeddings from the training set (in cases with $n \geq 8$ references) consistently led to cleaner, more recognizable results.&lt;/p&gt;
&lt;h2&gt;The Human Test: Does It Fool Real People?&lt;/h2&gt;
&lt;p&gt;Metrics only go so far. To validate that these portraits actually pass muster in professional contexts, the study recruited &lt;strong&gt;97 white-collar workers&lt;/strong&gt; to evaluate the generated headshots. Both DreamBooth and InstantID produced portraits that were frequently indistinguishable from genuine professional photographs.&lt;/p&gt;
&lt;p&gt;Participants&apos; preferences split along an interesting fault line. Those who valued &lt;strong&gt;identity accuracy&lt;/strong&gt; (&quot;does this actually look like the person?&quot;) 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.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The broader takeaway extends beyond portraits: &lt;strong&gt;synthetic data is not just a fallback for when real data is scarce. It is a tool for shaping precisely what a model learns.&lt;/strong&gt; As generative models improve, training pipelines that use one model to curate data for another will become increasingly common.&lt;/p&gt;
&lt;h2&gt;Acknowledgments&lt;/h2&gt;
&lt;p&gt;This work would not have been possible without the dedicated mentorship of &lt;strong&gt;Benjamin Kiefer&lt;/strong&gt;. 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.&lt;/p&gt;
&lt;p&gt;I am also grateful to the CVPR SynData4CV workshop reviewers for their constructive comments.&lt;/p&gt;
&lt;h2&gt;Citation&lt;/h2&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;p&gt;Ulusan, K., &amp;amp; Kiefer, B. (2025). 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. https://arxiv.org/abs/2505.03557&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@inproceedings{ulusan2025generating,
  author    = {Ulusan, Koray and Kiefer, Benjamin},
  title     = {Generating Synthetic Data via Augmentations for Improved Facial Resemblance in DreamBooth and InstantID},
  booktitle = {Proceedings of the CVPR Workshop on Synthetic Data for Computer Vision (SynData4CV)},
  year      = {2025},
  url       = {https://arxiv.org/abs/2505.03557},
  note      = {Presented at CVPR 2025 Workshop}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The full paper is available at arXiv:2505.03557.&lt;/p&gt;
</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><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><pubDate>Tue, 25 Feb 2025 06:00:00 GMT</pubDate><content:encoded>&lt;h3&gt;TL;DR&lt;/h3&gt;
&lt;p&gt;I modified &lt;strong&gt;TD3&lt;/strong&gt; with three reinforcement learning techniques: 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 &lt;em&gt;worse&lt;/em&gt;. What actually worked was a &lt;strong&gt;curriculum&lt;/strong&gt;: 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.&lt;/p&gt;
&lt;h2&gt;The Problem: Sparse Rewards in a Competitive Environment&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The environment I used is &lt;strong&gt;HockeyEnv&lt;/strong&gt; (a.k.a. &quot;Laser Hockey&quot;), 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.&lt;/p&gt;
&lt;p&gt;The standard &quot;run a good off-policy algorithm and wait&quot; approach struggles here. The agent&apos;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.&lt;/p&gt;
&lt;h2&gt;Base Algorithm: Twin Delayed DDPG (TD3)&lt;/h2&gt;
&lt;p&gt;TD3 is an actor-critic algorithm that addresses the well-known overestimation bias of DDPG by maintaining &lt;em&gt;two&lt;/em&gt; critics and taking the minimum of their Q-value estimates when computing targets:&lt;/p&gt;
&lt;p&gt;$$
y = r_t + \gamma \min_{k=1,2} Q_{\theta_k&apos;}(s_{t+1},, \pi_{\phi&apos;}(s_{t+1}) + \epsilon), \quad \epsilon \sim \text{clip}(\mathcal{N}(0,\sigma), -c, c)
$$&lt;/p&gt;
&lt;p&gt;It also delays actor updates relative to critic updates (hence &quot;Twin &lt;em&gt;Delayed&lt;/em&gt;&quot;), which gives the critics time to stabilize before the policy starts chasing them. I built &lt;code&gt;UlusanTD3&lt;/code&gt; on top of Stable Baselines 3, extending the base &lt;code&gt;TD3&lt;/code&gt; class to support the three techniques described below.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The name UlusanTD3 is chosen purely for convenience of the project graders and easy identification in the codebase. It doesn&apos;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.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Technique 1: Prioritized Experience Replay&lt;/h2&gt;
&lt;p&gt;Standard experience replay samples uniformly from a FIFO buffer. &lt;strong&gt;PER&lt;/strong&gt; [Schaul et al., 2015] argues that transitions where the agent was &lt;em&gt;wrong&lt;/em&gt; (those with high temporal-difference TD error) are more informative and should be sampled more often. The sampling probability for a transition is:&lt;/p&gt;
&lt;p&gt;$$
P(i) = \frac{p_i^\alpha}{\sum_k p_k^\alpha}
$$&lt;/p&gt;
&lt;p&gt;where $\alpha$ controls prioritization strength and $p_i$ is the priority of transition $i$. For TD3 with two critics, I define the priority as the average absolute TD error across both:&lt;/p&gt;
&lt;p&gt;$$
p_i = \left|\frac{1}{2}(\delta_1 + \delta_2)_i\right| + \epsilon
$$&lt;/p&gt;
&lt;p&gt;To keep priorities tractable and prevent divergence, I clip TD errors to the range $[\epsilon, 1]$. Without this upper bound, a single catastrophic prediction early in training can dominate the buffer forever and destabilize the actor.&lt;/p&gt;
&lt;p&gt;Sampling more from high-error transitions introduces a bias, which is corrected with &lt;strong&gt;importance-sampling (IS) weights&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;$$
w_i = \left(N \cdot P(i)\right)^{-\beta}, \quad \text{normalized by } \max_i w_i
$$&lt;/p&gt;
&lt;p&gt;These weights are folded into the critic loss, replacing the standard MSE:&lt;/p&gt;
&lt;p&gt;$$
\mathcal{L}(\theta_k) = \mathbb{E}\left[w \cdot \delta_k^2\right]
$$&lt;/p&gt;
&lt;p&gt;Because I have two critics with potentially different scales, I give each its own optimizer rather than summing their losses:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# In UlusanTD3.__init__
if isinstance(self.replay_buffer, PrioritizedExperienceReplayBuffer):
    self.critic1_optimizer = th.optim.Adam(
        self.critic.q_networks[0].parameters(), lr=learning_rate
    )
    self.critic2_optimizer = th.optim.Adam(
        self.critic.q_networks[1].parameters(), lr=learning_rate
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The PER buffer itself is backed by a &lt;strong&gt;SumSegmentTree&lt;/strong&gt;, which supports O(log N) priority updates and O(log N) stratified sampling. This is essential when the buffer holds a million transitions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _sample_indicies_proportional(self, batch_size: int) -&amp;gt; np.ndarray:
    p_total = self._td_errors.sum(end=self.size())
    segment_length = p_total / batch_size
    elem_at_segment_prefixsum = (
        np.arange(batch_size) + np.random.uniform(0, 1, batch_size)
    ) * segment_length
    return [
        self._td_errors.find_prefixsum_idx(p)
        for p in elem_at_segment_prefixsum
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using a NumPy array in &lt;code&gt;SegmentTree&lt;/code&gt; was important because a Python list was too slow for the large buffer size and high update frequency.&lt;/p&gt;
&lt;p&gt;After each gradient step, priorities are updated to reflect the latest TD errors:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Back in the train loop
td_errors = (td_error1 + td_error2) / 2.0
self.replay_buffer.set_priorities(
    batch_inds,
    td_errors.abs().squeeze().detach().cpu().numpy()
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt; PER made performance &lt;em&gt;worse&lt;/em&gt; in every configuration I tested. More on why below.&lt;/p&gt;
&lt;h2&gt;Technique 2: Potential-Based Reward Shaping&lt;/h2&gt;
&lt;p&gt;In environments with sparse rewards, auxiliary signals that encode domain knowledge can accelerate learning without changing the optimal policy. &lt;strong&gt;Potential-based reward shaping&lt;/strong&gt; [Ng et al., 1999] adds a shaping term:&lt;/p&gt;
&lt;p&gt;$$
F(s_t, s_{t+1}) = \gamma \phi(s_{t+1}) - \phi(s_t)
$$&lt;/p&gt;
&lt;p&gt;The key property is that this &lt;em&gt;never changes the optimal policy&lt;/em&gt;. It only changes how quickly the agent converges to it. The potential function $\phi: S \to \mathbb{R}$ can encode whatever domain knowledge you have.&lt;/p&gt;
&lt;p&gt;HockeyEnv conveniently exposes sub-reward components in its &lt;code&gt;info&lt;/code&gt; dict. I used a combination of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;closeness_to_puck&lt;/code&gt; — reward for staying near the puck&lt;/li&gt;
&lt;li&gt;&lt;code&gt;touch_puck&lt;/code&gt; — bonus for making contact&lt;/li&gt;
&lt;li&gt;&lt;code&gt;puck_direction&lt;/code&gt; — reward for hitting the puck toward the opponent&apos;s goal&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two components I tried and removed: &lt;code&gt;centered_puck&lt;/code&gt; introduced noise and slowed training, and &lt;code&gt;game_length&lt;/code&gt; inadvertently taught the agent to step aside and let in own goals.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def shaped_reward(rewards, infos):
    phis = [
        (info.get(&quot;prev_potential_reward&quot;, 0),
         info.get(&quot;current_potential_reward&quot;, 0))
        for info in infos
    ]
    # F(s, s&apos;) = gamma * phi(s&apos;) - phi(s)
    return [
        r + self.gamma * phi - phi_prev
        for r, (phi_prev, phi) in zip(rewards, phis)
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because HockeyEnv is a fully observable MDP, I compute $\phi$ directly from the environment state on every step, so no approximation is needed.&lt;/p&gt;
&lt;h2&gt;Technique 3: Multi-Step Returns&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;truncated n-step return&lt;/strong&gt; addresses this by accumulating rewards forward:&lt;/p&gt;
&lt;p&gt;$$
R_t^{(n)} = \sum_{k=0}^{n-1} \gamma^k \cdot r_{t+k}
$$&lt;/p&gt;
&lt;p&gt;and substituting it into the TD3 target:&lt;/p&gt;
&lt;p&gt;$$
y = R_t^{(n)} + \gamma^n \min_{k=1,2} Q_{\theta_k&apos;}(s_{t+n},, \pi_{\phi&apos;}(s_{t+n}) + \epsilon)
$$&lt;/p&gt;
&lt;p&gt;With reward shaping combined, the telescoping sum over the potential terms simplifies, and the target becomes:&lt;/p&gt;
&lt;p&gt;$$
y = \sum_{k=0}^{n-1} \gamma^k r_{t+k} + \gamma^n \Phi(s_{t+n}) - \Phi(s_t)
$$&lt;/p&gt;
&lt;p&gt;Implementation-wise, this requires buffering the last $n$ transitions before committing any of them to the replay buffer. The buffer is flushed early when a terminal state is reached:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def add(self, obs, next_obs, action, reward, done, infos):
    self._reward_info_buffer.append((reward, infos))

    if any(done):
        # flush remaining transitions on episode end
        while len(self._reward_info_buffer) &amp;gt; 0:
            self._add_n_step_return(obs, next_obs, action, done, infos)
        return

    if len(self._reward_info_buffer) &amp;lt; self.n_step_return_num:
        return  # keep buffering

    self._add_n_step_return(obs, next_obs, action, done, infos)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The discount exponent in the Bellman target also needs updating to account for the extended horizon:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;target_q_values = (
    replay_data.rewards
    + (1 - replay_data.dones)
    * self.gamma ** self.n_step_return_num  # γⁿ instead of γ in TD3
    * next_q_values
).detach()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Ablation Study: Most Things Didn&apos;t Help&lt;/h2&gt;
&lt;p&gt;I ran combinations of all three techniques (26 configurations in total, sweeping $n \in {2, 3, 4, 5, 10, 20, 30}$ for multi-step returns) and evaluated each against the weak built-in opponent. The results were not great.&lt;/p&gt;
&lt;p&gt;Most modifications performed &lt;em&gt;worse&lt;/em&gt; than the TD3 baseline. The 3-step return variant was the only technique that consistently outperformed the baseline, and even that improvement was modest.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;  The failure of PER wasn&apos;t a bug, it was informative. PER&apos;s design assumes a stationary data distribution. When the environment or the opponent changes, that assumption breaks.&lt;/p&gt;
&lt;h2&gt;Curriculum Learning: The Thing That Actually Worked&lt;/h2&gt;
&lt;p&gt;The real insight was about changing &lt;em&gt;how&lt;/em&gt; the agent was trained rather than what algorithm it used.&lt;/p&gt;
&lt;p&gt;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&apos;s hard to unlearn, because the risk of a failed shot (giving the opponent a chance to score) outweighs any expected benefit from trying.&lt;/p&gt;
&lt;p&gt;The curriculum I designed breaks this trap in two phases.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 (steps 0 to 440k):&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 (steps 440k+):&lt;/strong&gt; Empty the replay buffer entirely and switch to training against the strong &lt;code&gt;BasicOpponent&lt;/code&gt; in full normal-mode games. The clean buffer prevents old experiences from contaminating the new distribution.&lt;/p&gt;
&lt;p&gt;The agent that had been stuck below 50% win rate against the strong opponent reached &lt;strong&gt;98.3% win rate&lt;/strong&gt; within 110k steps of Phase 2 training. Notably, the win rate against the &lt;em&gt;weak&lt;/em&gt; opponent also climbed during Phase 1, even though the agent had never played full games during that phase.&lt;/p&gt;
&lt;h2&gt;Why PER and Curriculum Don&apos;t Mix&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 $w_i = (N \cdot P(i))^{-\beta}$ 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.&lt;/p&gt;
&lt;p&gt;As shown in the logic below, when new high-error transitions dominate the distribution, the probability $P(i)$ of selecting a new sample becomes very large relative to the small buffer size $N$ during the reset, causing the weight to vanish:&lt;/p&gt;
&lt;p&gt;$$w_i = (N \cdot P(i))^{-\beta}$$&lt;/p&gt;
&lt;p&gt;When $P(i_{new}) \gg P(i_{old})$, then $w_{i_new} \to 0$.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;This is why PER was dropped from the final curriculum configuration entirely.&lt;/p&gt;
&lt;h2&gt;Self-Play&lt;/h2&gt;
&lt;p&gt;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 &lt;code&gt;BasicOpponent&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;It didn&apos;t work. The agents converged to a Nash equilibrium of &lt;em&gt;mutual avoidance&lt;/em&gt;: 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.&lt;/p&gt;
&lt;p&gt;Risk aversion dominates in self-play when the stakes are symmetric. The agent&apos;s value function correctly estimates that the expected return from &quot;don&apos;t touch the puck&quot; is higher than the noisy expected return from &quot;attempt a shot.&quot; Injecting &lt;code&gt;BasicOpponent&lt;/code&gt; episodes or clearing the buffer when switching opponents did not fix this.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;  Self-play can lead to degenerate equilibria if not carefully structured. In competitive environments, it&apos;s crucial to maintain a curriculum that keeps the agent focused on the ultimate goal rather than settling for safe but unproductive strategies.&lt;/p&gt;
&lt;h2&gt;Tournament Results&lt;/h2&gt;
&lt;p&gt;The final agent (3-step return, curriculum, no PER) competed in the 2025 RL course tournament at the University of Tübingen, ranking &lt;strong&gt;131/146&lt;/strong&gt; (including stale accounts) with a &lt;strong&gt;40% win rate&lt;/strong&gt; against other students&apos; agents. Some students chose not to join the tournament.&lt;/p&gt;
&lt;p&gt;This is a more honest number than the 98.3% against &lt;code&gt;BasicOpponent&lt;/code&gt;. The tournament agents had trained on the same environment and knew the same scripted behaviors. Against agents that could also &lt;em&gt;plan&lt;/em&gt;, the curriculum advantage faded, and the self-play deficiencies became obvious.&lt;/p&gt;
&lt;p&gt;Had 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.&lt;/p&gt;
&lt;h2&gt;Practical Takeaways&lt;/h2&gt;
&lt;p&gt;These findings aren&apos;t specific to air hockey.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On PER:&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On reward shaping:&lt;/strong&gt; Be selective about what you encode in $\phi$. Subcomponents that make intuitive sense (stay near the puck) can introduce perverse incentives at the MDP level (&lt;code&gt;game_length&lt;/code&gt; rewarding own goals). The sufficiency theorem guarantees no harm asymptotically, but finite training is far from asymptotic.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On multi-step returns:&lt;/strong&gt; Modest $n \in [2, 5]$ is almost always better than large $n \in [10, 30]$ in continuous control. Large $n$ introduces high variance in the return estimate and makes the bootstrap target less reliable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On self-play:&lt;/strong&gt; 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.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Adding algorithmic improvements to TD3 mostly made things worse. What actually unlocked real performance was a carefully structured training curriculum: a decision about &lt;em&gt;what the agent practices&lt;/em&gt; rather than &lt;em&gt;how it learns&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;In sparse-reward environments, the hardest problem is not the algorithm, it&apos;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Here are the resources if you want to dive deeper:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Technical Report&lt;/li&gt;
&lt;li&gt;Presentation&lt;/li&gt;
&lt;li&gt;GitHub Repo&lt;/li&gt;
&lt;li&gt;RL Agents Code&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Acknowledgments&lt;/h2&gt;
&lt;p&gt;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 TCML cluster offered by the Cognitive Systems Group (of Prof. Andreas Zell) at the University of Tübingen.&lt;/p&gt;
</content:encoded></item></channel></rss>