<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Blog Posts on Hunter Heidenreich | Senior AI Research Scientist</title><link>https://hunterheidenreich.com/posts/</link><description>Recent content in Blog Posts on Hunter Heidenreich | Senior AI Research Scientist</description><image><title>Hunter Heidenreich | Senior AI Research Scientist</title><url>https://hunterheidenreich.com/img/avatar.webp</url><link>https://hunterheidenreich.com/img/avatar.webp</link></image><generator>Hugo -- 0.163.3</generator><language>en-US</language><copyright>2026 Hunter Heidenreich</copyright><lastBuildDate>Sun, 02 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://hunterheidenreich.com/posts/index.xml" rel="self" type="application/rss+xml"/><item><title>What Surprised Me About Aligning Pictures of Molecules</title><link>https://hunterheidenreich.com/posts/aligning-pictures-of-molecules/</link><pubDate>Sun, 02 Aug 2026 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/aligning-pictures-of-molecules/</guid><description>Two alignment objectives compared under a frozen and then a trainable vision backbone, a metric that scored a perfect model at chance, and a reversal.</description><content:encoded><![CDATA[<p>The <a href="https://arxiv.org/abs/2510.18900">MIST paper</a> makes a claim I wanted to test from an angle its authors did not take. They show that their model&rsquo;s embedding space organizes chemical concepts along physically meaningful directions. Lipinski&rsquo;s Rule of Five is linearly decodable from frozen activations. Unsupervised projections of the embeddings separate aromatic from anti-aromatic compounds along Hückel&rsquo;s rule, and none of that was labelled during training.</p>
<p>All of that structure is derived from a <a href="/notes/chemistry/molecular-representations/notations/smiles/">SMILES</a> string.</p>
<p>Molecules are also drawn. A structure diagram carries the same topology a chemist reads directly off the page, in a form no language model ever sees. So I wanted to know whether MIST&rsquo;s geometry is reachable from pixels, or whether it is a property of the string.</p>
<p>There are two standard ways to teach one model to land where another one does. You can push negatives apart with a contrastive loss, or you can skip the negatives and regress the target directly. I expected the predictive one to transfer better to real drawings, because a contrastive objective can exploit shortcuts that only exist inside a training batch. I wrote that down before running anything.</p>
<p>It held, then it reversed, and the mechanism I had named for it was wrong in both cases.</p>
<h2 id="what-i-built">What I Built</h2>
<p>One vision tower, one frozen text tower, and a single linear map between them.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Vision tower</td>
					<td><a href="https://arxiv.org/abs/2304.07193">DINOv2</a> ViT-S/14 with registers, 21,629,952 parameters, 384-d output</td>
			</tr>
			<tr>
					<td>Text tower</td>
					<td>MIST-28M, frozen throughout, pooled <code>last_hidden_state[:, 0, :]</code>, 512-d output</td>
			</tr>
			<tr>
					<td>Head</td>
					<td>one linear map, 384 to 512, 197,120 parameters</td>
			</tr>
			<tr>
					<td>Training data</td>
					<td>1M PubChem molecules, about 2M <a href="https://github.com/OBrink/RanDepict">RanDepict</a> depictions at 224px</td>
			</tr>
			<tr>
					<td>Schedule</td>
					<td>24 epochs, batch 256</td>
			</tr>
	</tbody>
</table>
<p>Arm A optimizes the <a href="https://arxiv.org/abs/2303.15343">SigLIP</a> sigmoid pairwise loss. Arm B regresses the frozen target in latent space with a smooth-L1 loss and no negatives, which is the <a href="https://arxiv.org/abs/2301.08243">I-JEPA</a> move applied as an alignment head rather than as pretraining. Everything else is held identical: same molecules, same splits, same seed, same schedule, same evaluation, checked by an assertion before any table prints. Every number below is one seed.</p>
<p>Two details in the setup are load-bearing.</p>
<p>The pooling is MIST&rsquo;s own. Their embedding is the last-layer hidden state at the first token, so anything else measures a different space. Loading the checkpoint with <code>AutoModel</code> also attaches a randomly initialized pooler unless you ask it not to, and <code>pooler_output</code> from that pooler returns plausible noise.</p>
<p>The contrastive labels come from molecular identity rather than batch position. Two rows in a batch can be depictions of the same molecule, and treating them as negatives because they sit at different indices would penalize the model for being right. That detail matters more than it looks, and I will come back to it.</p>
<p>This is not an <a href="/posts/what-is-ocsr/">OCSR</a> contribution. Reading a molecule out of a picture is a mature field, and MolScribe, MolGrapher, DECIMER and Img2Mol all do it well. What is here is a controlled experiment about alignment objectives that happens to run on a domain with a real evaluation surface.</p>
<p>That domain has a catch worth stating early, because it shapes which number is the result. The training depictions are rendered from SMILES strings. A picture drawn from a string carries no information the string did not already have, so the in-distribution task is closer to inverting a renderer than to reading chemistry. Transfer to depictions that came from the world is the part that tests something. That is why the out-of-distribution slice is the headline and the in-distribution number is context.</p>
<p>I came to MIST from the tokenizer side. I had published <a href="/research/bpe-unigram-lm-smiles-vocabularies/">a controlled comparison of BPE and Unigram-LM over chemistry SMILES</a> a few weeks earlier, and MIST is a SMILES foundation model whose headline contribution is <a href="/notes/chemistry/molecular-representations/notations/smirk-tokenization-molecular-models/">its own tokenizer</a>. I was reading the paper closely for that reason and stayed for the embedding geometry.</p>
<h3 id="what-i-registered-in-advance">What I Registered In Advance</h3>
<p>Three predictions, committed before any training run existed:</p>
<blockquote>
<ol>
<li>Both arms will be near-ceiling on in-distribution synthetic retrieval, and that number on its own will mean almost nothing.</li>
<li>The arms will separate, if at all, on the WildMol-10k OOD slice and the scaffold-split linear probe. That gap is the result.</li>
<li>Arm B is expected to hold up better OOD, on the reasoning that regressing a fixed target cannot exploit in-batch shortcuts the way a contrastive objective can. This is a guess and is written down so it can be wrong in public.</li>
</ol>
</blockquote>
<p>I also registered what would count as a clean negative and ship as the result: both arms at or near chance on the OOD slice and the probe, with all baselines behaving correctly. That sentence turns out to matter more than the predictions do.</p>
<h3 id="what-had-to-pass-first">What Had To Pass First</h3>
<p>Four gates run before any headline number is believed, because a comparison between two arms says nothing if the harness scoring both of them is broken.</p>
<table>
	<thead>
			<tr>
					<th>gate</th>
					<th>result</th>
					<th>what it rules out</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>oracle retrieval, a perfect predictor</td>
					<td>R@1 = 1.0</td>
					<td>the retrieval harness itself</td>
			</tr>
			<tr>
					<td>MIST against Tanimoto similarity</td>
					<td>r = +0.42</td>
					<td>a target that carries no chemistry</td>
			</tr>
			<tr>
					<td>random-embedding control</td>
					<td>+0.0014 centered cosine</td>
					<td>a metric that leaks</td>
			</tr>
			<tr>
					<td>trivial image-statistics floor</td>
					<td>+0.0924 centered cosine</td>
					<td>mistaking ink for structure</td>
			</tr>
	</tbody>
</table>
<p>The image-statistics floor is the one that earns its keep below. It is what a model scores using nothing but coarse pixel statistics, and any headline has to clear it.</p>
<h2 id="a-perfect-model-scored-zero">A Perfect Model Scored Zero</h2>
<p>Raw cosine against MIST measures almost nothing. Its embeddings occupy a narrow cone, where two unrelated molecules sit at cosine 0.517 and a model that ignores its input entirely and emits the training centroid scores 0.720. So every cosine in this project is centered, and each side is centered by its own training-split mean:</p>
<p>$$ \mathrm{cc} = \cos\left(\hat{\mathbf{u}} - \bar{\hat{\mathbf{u}}}_{\text{train}},\ \mathbf{y} - \bar{\mathbf{y}}_{\text{train}}\right) $$</p>
<p>Chance is zero. Both centroids come from the training split, since centering a held-out set by its own mean leaks that set&rsquo;s geometry into its own score.</p>
<p>The version I shipped first subtracted the target centroid from both sides. That is valid only while the prediction sits at roughly the target&rsquo;s scale. Arm B&rsquo;s smooth-L1 pins it there. Arm A&rsquo;s loss L2-normalizes both sides internally, so it constrains the output norm not at all, and weight decay shrinks it with nothing opposing.</p>
<p>Take an oracle predictor $\hat{\mathbf{u}} = s\mathbf{y}$, whose true cosine is exactly 1 for every $s &gt; 0$:</p>
<table>
	<thead>
			<tr>
					<th>$s$</th>
					<th>$\lVert\hat{\mathbf{u}}\rVert$</th>
					<th>centered by target</th>
					<th>centered by own mean</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1.00</td>
					<td>22.28</td>
					<td>+1.0000</td>
					<td>+0.9999</td>
			</tr>
			<tr>
					<td>0.50</td>
					<td>11.14</td>
					<td>+0.6100</td>
					<td>+0.9999</td>
			</tr>
			<tr>
					<td>0.10</td>
					<td>2.23</td>
					<td>+0.0453</td>
					<td>+0.9999</td>
			</tr>
			<tr>
					<td>0.045</td>
					<td>1.00</td>
					<td><strong>-0.0103</strong></td>
					<td>+0.9999</td>
			</tr>
	</tbody>
</table>
<p>A perfect model emitting unit-norm vectors scored below chance.</p>
<p>On the real 20K run, that defect scored a working contrastive model at +0.1088, inside noise of the +0.1058 image-statistics floor. Arm A&rsquo;s head was emitting norm 6.35 against MIST&rsquo;s 22.28. Under the corrected metric the same checkpoint scores +0.3052.</p>
<p>The uncomfortable part is what that number was. I had registered, in advance, that both arms landing at the floor with every baseline behaving correctly would be a clean negative and would ship as the result. The defect produced precisely that. Every exit criterion passed.</p>
<p>No test caught it. What caught it was asking what a perfect model would score under this metric, which is a question whose answer is knowable before you ask it.</p>
<p>Registering a prediction stops the goalposts moving once data exists. It does nothing at all about a defect that happens to confirm the prediction, and it makes that case more expensive rather than less, because the incentive to accept a number and stop looking is strongest exactly where you said the number would be.</p>
<p>There was a second defect of the same shape waiting. Had the contrastive labels come from batch position instead of molecular identity, the model would have been penalized for putting two depictions of one molecule in the same place. That depresses contrastive transfer and leaves predictive untouched, because only the contrastive loss reads other rows in the batch, which is prediction 3 arriving as an artifact of the label matrix.</p>
<h2 id="the-data-ran-out">The Data Ran Out</h2>
<p>With the backbone frozen, I fitted a log-linear law to the closed-form ridge ceiling over 1K to 16K molecules. It described that range well, at r = 0.9906. Extrapolated, it said 1M molecules would reach +0.600.</p>















<figure class="post-figure center ">
    <img src="/img/molecular-depiction-alignment/fig5-scaling-law.webp"
         alt="A line chart of ridge ceiling against molecule count on a log axis, with a fitted line extrapolated to one million and a measured point falling well below it"
         title="A line chart of ridge ceiling against molecule count on a log axis, with a fitted line extrapolated to one million and a measured point falling well below it"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>The law was accurate where it was fitted and missed by 0.077 a decade and a half out.</strong> Fifty times the molecules bought +0.020 where the extrapolation predicted +0.088. The implied slope falls from 0.0517 to 0.0117 per decade, so another +0.05 would need roughly ten billion molecules.</figcaption>
    
</figure>

<p>Going from 796K to 1.6M depictions bought +0.0002.</p>
<p>The mechanism is in the features rather than the sample. On these images, 54.3% of DINOv2&rsquo;s feature variance is depiction style rather than molecular identity. Two renders of the same molecule sit at cosine 0.857, and two different molecules sit at 0.809, which leaves very little room between &ldquo;same structure&rdquo; and &ldquo;different structure&rdquo; for a linear head to work with.</p>















<figure class="post-figure center ">
    <img src="/img/molecular-depiction-alignment/fig6-style-pair.webp"
         alt="Two renderings of the same molecule side by side, one drawn with bold lines and implicit carbons, the other with thin grey lines and every carbon atom labelled"
         title="Two renderings of the same molecule side by side, one drawn with bold lines and implicit carbons, the other with thin grey lines and every carbon atom labelled"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>One molecule, two renders from the training set.</strong> A chemist reads these as identical. Frozen DINOv2 puts same-molecule pairs at cosine 0.857 and different-molecule pairs at 0.809, and 54.3% of its feature variance on this data is drawing style.</figcaption>
    
</figure>

<p>A linear head can reorganize what the backbone encodes. It cannot manufacture what the backbone discarded. That showed up directly in the probes, where alignment helped on the tasks whose property the frozen features already carried and did nothing at all on three of seven.</p>
<p>This is the second prediction I made in advance and missed, and it was the more expensive one, because I had been about to spend on data.</p>
<h2 id="unfreezing-reversed-my-own-prediction">Unfreezing Reversed My Own Prediction</h2>
<p>Letting the backbone move changes the picture. I swept two trainable modes rather than picking one: LoRA at rank 16 on the attention projections of all twelve blocks, and the top four blocks plus the final norm. The top-blocks mode won on every layer, and LoRA was also 1.84x slower per step despite training 16x fewer parameters, because backward cost tracks how deep the pass goes rather than how many parameters update.</p>















<figure class="post-figure center ">
    <img src="/img/molecular-depiction-alignment/fig2-eval-layers.webp"
         alt="Three panels comparing frozen and unfrozen results for in-distribution retrieval, out-of-distribution retrieval, and centered cosine, each on its own scale"
         title="Three panels comparing frozen and unfrozen results for in-distribution retrieval, out-of-distribution retrieval, and centered cosine, each on its own scale"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Each layer moves, and by very different factors.</strong> In-distribution retrieval 9.6x, out-of-distribution 11.1x, centered cosine 1.7x. Distractor difficulty is matched between the two regimes rather than assumed, at median Tanimoto 0.348 against 0.349 in-distribution.</figcaption>
    
</figure>

<p>The out-of-distribution gate is what makes this mean something, and it matters because an earlier lever failed it. Running the backbone at its native 518px instead of 224 improved every synthetic-image metric, several by multiple standard deviations, and moved the real-depiction metric by less than one. That lever was fitting RanDepict&rsquo;s particular ink rather than learning chemistry. Unfreezing does not behave that way.</p>
<p>Then the registered prediction inverted.</p>















<figure class="post-figure center ">
    <img src="/img/molecular-depiction-alignment/fig3-prediction-reversal.webp"
         alt="Two paired slope panels showing retrieval from synthetic to real depictions, with the predictive arm holding flat under a frozen backbone and the contrastive arm winning under an unfrozen one"
         title="Two paired slope panels showing retrieval from synthetic to real depictions, with the predictive arm holding flat under a frozen backbone and the contrastive arm winning under an unfrozen one"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Frozen, the predictive arm holds and the contrastive arm drops 34%. Unfrozen, it inverts.</strong> Contrastive drops 23.3% against predictive&rsquo;s 42.6% and wins out of distribution outright, 0.4555 against 0.2847.</figcaption>
    
</figure>

<p>The ordering was a property of the frozen bottleneck rather than of the objectives. Give the contrastive objective a backbone it can actually shape and it is the one that degrades less.</p>
<p>Had I only run the frozen half, I would have published a confirmed prediction with a mechanism attached, and the mechanism would have been wrong. The number was fine. The explanation underneath it was not, and nothing in the frozen regime could have told me.</p>
<p>Prediction 1 was wrong outright, which is worth recording since I registered it as the boring one. I expected in-distribution retrieval to sit near ceiling and carry no information. It came in at 0.0617 and 0.0310 against a 0.0156 chance floor, which is why it is reported throughout rather than waved off.</p>















<figure class="post-figure center ">
    <img src="/img/molecular-depiction-alignment/fig4-probe-gap-closed.webp"
         alt="A bar chart of seven probe tasks showing the fraction of the DINOv2 to MIST gap closed by the frozen and unfrozen models, with a dashed line at parity"
         title="A bar chart of seven probe tasks showing the fraction of the DINOv2 to MIST gap closed by the frozen and unfrozen models, with a dashed line at parity"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>All three of the frozen regime&rsquo;s nulls break.</strong> ESOL, Lipophilicity and Tox21 SR-MMP were the evidence for &lsquo;alignment only reorganizes&rsquo;. A movable backbone can acquire a property the frozen features had discarded.</figcaption>
    
</figure>

<p>On one fixed checkpoint, the aligned image embedding out-probes MIST-direct on 6 of 7 tasks, losing only BBBP. Best-of-eight beats it on 7 of 7, and I am not quoting that as the result because it is selection over eight tries. The honest reading is that MIST bounds MIST-reconstruction rather than every downstream task where vision carries independent signal.</p>
<h2 id="the-fairest-rule-picked-the-worst-checkpoint">The Fairest Rule Picked the Worst Checkpoint</h2>
<p>The two arms have losses on different scales, so selecting each on its own loss would compare two selection procedures rather than two objectives. I selected on centered cosine instead, the one metric both arms are measured by. Neutral by construction.</p>
<p>It chose epoch 1 of 24 for the contrastive arm, in both trainable modes.</p>















<figure class="post-figure center ">
    <img src="/img/molecular-depiction-alignment/fig1-training-curves.webp"
         alt="Validation centered cosine against epoch for all four runs, with the contrastive arm peaking at the first epoch and decaying while the predictive arm climbs"
         title="Validation centered cosine against epoch for all four runs, with the contrastive arm peaking at the first epoch and decaying while the predictive arm climbs"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>The contrastive arm&rsquo;s centered cosine peaks almost immediately and decays for the rest of training</strong>, while its own loss falls sevenfold and every other measure improves. In-distribution retrieval at the selected epoch is 0.2265 against 0.5938 at the final one, a factor of 2.6.</figcaption>
    
</figure>

<p>I had written this down on the first day of the project, at the milestone where the two arms first ran side by side:</p>
<blockquote>
<p>Selecting on it hands Arm A its worst retrieval checkpoint. Selecting on retrieval instead would reverse the ranking and would be equally defensible and equally partial.</p>
</blockquote>
<p>And in the same entry, on why I was recording it at all:</p>
<blockquote>
<p>Recording it now, at M3, because it is exactly the kind of thing that gets quietly resolved in whichever direction the first table happened to point.</p>
</blockquote>
<p>I scoped that observation as measured outside the contrastive arm&rsquo;s viable regime, which was correct at the time. At 20K molecules there is no batch size that is both a real contrastive batch and a small fraction of the dataset, so the arm was running at 256 while SigLIP&rsquo;s own calibration assumes 16,384. Rebuilding at 1M fixed that, and the failure happened anyway, in the regime where it counted.</p>
<p>The obvious objection is that the constraint itself was the cause. Holding the learning rate, the schedule and the checkpoint rule identical across two objectives that optimize different things does not make a comparison fair, it handicaps whichever arm does not match the shared choice.</p>
<p>The repair would be to let each arm carry its own schedule, and gradient accumulation is the specific one that was on the table. I did not add it, and I still think that was right. Accumulation enlarges the contrastive arm&rsquo;s negative pool and does nothing for the predictive one, so adding it converts a comparison of objectives into a comparison of training budgets. That is a second bias rather than a smaller one.</p>
<p>What I did not do was change the rule after seeing what it cost. Retrofitting a selection criterion once results exist is the specific thing pre-registration exists to prevent, so both epochs are reported instead.</p>
<p>Three shared choices ended up bending against the contrastive arm, all in the same direction. Weight decay at 0.05 against a scale-invariant loss, which is what produced the metric defect. Batch 256 against a calibration assuming 16,384. A selection metric that measures direction agreement, which is close to what the predictive arm optimizes directly.</p>
<p>It won the layer I had named in advance as the deciding one anyway, 0.4555 against 0.2847. So the measured gap is a floor rather than an estimate.</p>
<h2 id="what-i-would-not-trust-here">What I Would Not Trust Here</h2>
<p>Three things would change a conclusion above, in order of how much.</p>
<p><strong>The probe splits shift in composition.</strong> ESOL&rsquo;s training split is 35% acyclic molecules against 0% of validation and test, and Tox21 is about 32%. Acyclic molecules have no Bemis-Murcko scaffold, so a scaffold split places them all in one group and that group is large enough to land in train. ESOL is the largest probe gain in this post, and it was scored on a held-out set with none of the molecules that make up a third of what it trained on.</p>
<p><strong>One seed, and the sigmas are uneven.</strong> The only repeats in the project are a 5-seed frozen sweep at 20K. Arm A retrieval sigma runs .002 to .005 and BBBP .004 to .011. The retrieval orderings clear that by roughly two orders of magnitude and are safe. Individual probe cells do not clear it, and several rows show test scores above validation, which is what noise on a small scaffold-split test set looks like.</p>
<p><strong>The predictive arm was truncated.</strong> It was still rising at epoch 24 in both modes, and 24 epochs was inherited from the frozen regime where it was enough. Its numbers are a lower bound, which cuts against this post&rsquo;s own conclusion.</p>
<h2 id="conclusion-one-number-cannot-rank-two-objectives">Conclusion: One Number Cannot Rank Two Objectives</h2>
<p>The comparison never resolved to a winner in the way I expected when I started, and the reason is more interesting than a winner would have been. Under a frozen backbone the predictive arm holds up better, and that ordering is a fact about the bottleneck. Under a trainable one the contrastive arm wins out of distribution, and it does so while carrying every shared-procedure handicap.</p>
<p>Three of the four things above were numbers I initially believed. A metric that scored a perfect model below chance, a scaling law that was accurate everywhere I had fitted it, and a selection rule that was neutral by construction. Each looked fine, and each was checkable in advance by asking what a known answer should be.</p>
<p>The one that still bothers me is the metric, because it produced the exact result I had written down as shippable. Pre-registration protected me from moving the goalposts and did nothing about that.</p>
<p><em>Code, weights and data are public. The <a href="https://github.com/hunter-heidenreich/molecular-depiction-alignment">repository</a> carries the full results, the harness gates and the pre-registration verbatim. The two aligned models are on Hugging Face as <a href="https://huggingface.co/hheiden/dinov2-mist-molecular-depiction-contrastive">contrastive</a> and <a href="https://huggingface.co/hheiden/dinov2-mist-molecular-depiction-predictive">predictive</a>, and the <a href="https://huggingface.co/datasets/hheiden/molecular-depiction-pairs-20k">20K depiction set</a> is published so the comparison can be re-run without standing up the rendering environment.</em></p>
]]></content:encoded></item><item><title>The Reliability Trap: The Limits of 99% Accuracy</title><link>https://hunterheidenreich.com/posts/reliability-trap-document-automation/</link><pubDate>Sun, 15 Feb 2026 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/reliability-trap-document-automation/</guid><description>Why high-accuracy LLMs fail in production: exploring the calibration crisis and the challenge of reliable straight-through processing in document automation.</description><content:encoded><![CDATA[<p>You have a model that achieves 99% accuracy on your test set. It feels safe to deploy. After all, who can complain about a system that is correct 99% of the time?</p>
<p>In high-stakes domains (like insurance or healthcare), deploying based on accuracy alone is dangerous. Automating at scale based on summary statistics while ignoring the downstream &ldquo;blast radius&rdquo; of errors effectively guarantees failure.</p>
<p>Two weeks later, the operations team is furious. Critical medical records have been merged into unrelated legal contracts. Invoices are split in half. The system is creating <em>more</em> work than it saves.</p>
<p>You check the logs. The model assigned 99.9% probability to those errors.</p>
<p>This is the <strong>Reliability Trap</strong>. While benchmarks optimize for <strong>Accuracy</strong> (how often the model is correct), production demands <strong>Calibration</strong> (whether the model&rsquo;s projected confidence aligns with its actual probability of correctness).</p>
<p>If a model is calibrated, its confidence score is reliable. When it assigns a 0.99 probability, it should be incorrect 1% of the time. When it assigns a 0.60 probability, it should be incorrect 40% of the time.</p>
<p>Decoder-only LLMs (like Mistral, DeepSeek, and Qwen) perform exceptionally well on benchmarks. However, they are also incredibly overconfident. They are systematically overconfident: even when hallucinating, they assign high confidence to their outputs.</p>
<blockquote>
<p>AI: To permanently resolve the geopolitical tension, I have initiated a preemptive, full-scale nuclear first strike. All warheads have been deployed.</p>
<p>User: Wait, no! They have early warning radar and automated dead-hand systems! You just triggered a full retaliatory strike and guaranteed a global nuclear holocaust!</p>
<p>AI: You are absolutely right, and I apologize for the oversight! A preemptive strike would trigger mutually assured destruction. Thank you for pointing this out. As an AI, I am always learning and rely on user feedback to improve! Would you like me to generate a list of fun activities to do in a subterranean fallout bunker?</p>
</blockquote>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/llm-alignment-goes-nuclear.webp"
         alt="A humorous dialogue where an AI confidently initiates a nuclear strike but immediately apologizes when corrected by the user"
         title="A humorous dialogue where an AI confidently initiates a nuclear strike but immediately apologizes when corrected by the user"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Calibrated Overconfidence</strong>: The model assigns extremely high probability to its outputs, even when making catastrophic errors, and only &lsquo;corrects&rsquo; itself because it is trained to align with user feedback.</figcaption>
    
</figure>

<p>This overconfidence is partly structural, stemming from how these models are trained. As I highlighted in my overview of <a href="https://roots-automation.github.io/roots-labs/post/2024-llm-calibration/#confidence-estimation-methods">LLM confidence estimation methods</a>, LLMs are optimized solely to maximize the likelihood of the next token. They lack inherent mechanisms to model their own uncertainty. Methods like <strong>Verbal Elicitation</strong> (&ldquo;Rate your confidence from 1-10&rdquo;) often fail because the model hallucinates a high number just as easily as it hallucinates a fact.</p>
<p>This disconnect is particularly dangerous in sequential tasks. In this post, based on our <a href="/research/page-stream-segmentation-llms/">COLING 2025 Industry Track paper</a>, we&rsquo;ll explore why standard ML reliability metrics break down in <strong>Page Stream Segmentation (PSS)</strong>. (For a full history of the task, see <a href="/posts/history-of-page-stream-segmentation/">The Evolution of PSS</a>).</p>
<p>PSS is the task of splitting a continuous feed of pages into distinct documents. Building on our previous work with the <a href="https://huggingface.co/datasets/bevaya/TABMEpp">synthetic TabMe++ benchmark</a>, this study evaluates models on <strong>7,500 real-world insurance streams</strong>: messy, proprietary piles of medical records and legal contracts where the &ldquo;rules&rdquo; of document structure are constantly broken.</p>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/page-stream-segmentation-sorter.webp"
         alt="Diagram showing a continuous stream of pages being sorted into discrete document packets"
         title="Diagram showing a continuous stream of pages being sorted into discrete document packets"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>The Challenge of PSS</strong>: Transforming a chaotic, continuous stream of mixed pages (invoices, contracts, records) into organized, discrete document packets.</figcaption>
    
</figure>

<p>We&rsquo;ll see why &ldquo;99% sure&rdquo; is a mathematical lie for long documents, and why <strong>Throughput</strong> is the better metric.</p>
<h2 id="the-confidence-death-spiral">The Confidence Death Spiral</h2>
<p>The core problem lies in the difference between a <strong>Page</strong> and a <strong>Stream</strong>.</p>
<p>Most ML metrics (Precision, Recall, F1) are calculated at the level of individual decisions. If you have a 10-page document, the model makes 10 independent decisions (is this page a continuation of the previous one, or a new document?).</p>
<p>If your model is <strong>99% confident</strong> ($p=0.99$) on every single page, that sounds safe. For a stream to be automated correctly (what we call <strong>Straight-Through Processing (STP)</strong>), <em>every single decision</em> in the sequence must be correct.</p>
<p>The probability of a perfect stream is the product of the probabilities of its parts:</p>
<p>$$ C_{\text{stream}} = \prod_{i=1}^{N} C_i $$</p>
<p><em>Note: This naive calculation is actually the <strong>optimist&rsquo;s</strong> view. It assumes errors are independent (i.i.d.), like flipping a coin. In reality, errors are <strong>correlated</strong>: if a model struggles on Page 5, it is likely because the document itself is difficult, meaning it will probably struggle on Page 6 too.</em></p>
<p>Let&rsquo;s watch what happens to that &ldquo;safe&rdquo; 99% confidence as the document length increases:</p>
<ul>
<li><strong>2-page Letter</strong>: $0.99^2 \approx 0.98$ (Safe)</li>
<li><strong>10-page Contract</strong>: $0.99^{10} \approx 0.90$ (Risky)</li>
<li><strong>100-page Medical Record</strong>: $0.99^{100} \approx 0.36$ (Unusable)</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/asymmetric-cost-of-error-in-document-streams.webp"
         alt="Chart showing exponential decay of straight-through processing probability as document length increases"
         title="Chart showing exponential decay of straight-through processing probability as document length increases"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The Confidence Death Spiral: Even with high page-level confidence, the reliability of the entire stream collapses as document length increases.</figcaption>
    
</figure>

<p>By the time you reach page 100, your &ldquo;99% accurate&rdquo; model effectively has a <strong>64% probability of error</strong> regarding the document structure. Yet, because we often average metrics across pages, this catastrophic decay is hidden in the summary statistics.</p>
<h2 id="why-standard-fixes-failed">Why Standard Fixes Failed</h2>
<p>&ldquo;Just calibrate it!&rdquo;</p>
<p>That&rsquo;s the standard advice. In a <a href="https://roots-automation.github.io/roots-labs/post/2024-llm-calibration/">detailed overview of LLM calibration</a> I wrote for Roots Automation, I explored techniques like <strong>temperature scaling</strong> (fitting a single scalar parameter), <strong>Platt Scaling</strong> (fitting a logistic regression to the outputs), and <strong>Monte Carlo (MC) Dropout</strong> (running the model multiple times with random noise) to smooth out probabilities.</p>
<p>We tried them all, and they failed. In fact, <strong>MC Dropout often made things worse</strong>, increasing calibration error (ECE) and adding unnecessary noise. The computational cost of running the model 16 times was wasteful and, in our case, misleading.</p>
<p>To understand why, we need to distinguish between two types of confidence:</p>
<ol>
<li><strong>Relative Confidence</strong>: The model correctly ranks sample $A$ as more likely to be correct than sample $B$.</li>
<li><strong>Absolute Confidence</strong>: The predicted probability matches the true accuracy (e.g., if a model says 80% confidence 100 times, it should be right exactly 80 times).</li>
</ol>
<p>While standard techniques improved <em>page-level</em> <strong>Expected Calibration Error (ECE)</strong> (dropping page-level ECE from ~1.7% to ~0.9% for Mistral), they failed to improve <em>stream-level</em> safety.</p>
<p>Mathematically, ECE is a weighted average:
$$ \text{ECE} = \sum_{b=1}^{B} \frac{n_b}{N} | \text{acc}(b) - \text{conf}(b) | $$</p>
<p>In a stream of 10,000 pages, a low ECE merely tells you that the model is well-calibrated <em>on average</em>. In automation, we pay for the failures. The &ldquo;average&rdquo; page is an easy, clean digital PDF. The &ldquo;tail&rdquo; page is a rotated, coffee-stained handwritten note.</p>
<p>This is why we must look at <strong>Maximum Calibration Error (MCE)</strong>:
$$ \text{MCE} = \max_{b \in B} | \text{acc}(b) - \text{conf}(b) | $$</p>
<p>MCE measures the worst-case divergence. It finds that specific bucket of &ldquo;hard&rdquo; pages where the model claims 99% confidence but delivers 50% accuracy. Crucially, these high-MCE buckets often correlate with the most business-critical documents: complex legal riders or non-standard medical forms. Optimizing for ECE allows the model&rsquo;s excellent performance on easy documents to mask its significant errors on hard (and legally risky) ones.</p>
<p>Advanced practice moves beyond even MCE to look at the <strong>Calibration Error Distribution</strong>, analyzing the 90th or 95th percentile of error. We must ask a more critical question: &ldquo;How wrong is the model <em>capable</em> of being?&rdquo;</p>
<h3 id="a-tale-of-two-charts">A Tale of Two Charts</h3>
<p>To see this failure in action, consider the reliability diagrams for the <strong>same model</strong> (Mistral-7B) on the <strong>same test set</strong>, evaluated at two different levels of abstraction.</p>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/mistral-page-reliability.webp"
         alt="Page-level reliability diagram showing decent calibration"
         title="Page-level reliability diagram showing decent calibration"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Left (Page Level)</strong>: The model looks reasonable. The blue line hugs the diagonal, meaning when the model predicts a boundary with 0.8 probability, it is actually correct about 80% of the time.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/mistral-stream-reliability.webp"
         alt="Stream-level reliability diagram showing severe overconfidence"
         title="Stream-level reliability diagram showing severe overconfidence"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Right (Stream Level)</strong>: The model performs poorly. The curve creates a &lsquo;bow&rsquo; shape significantly below the diagonal. This is the definition of <strong>overconfidence</strong>. When the model assigns an 80% probability that the entire 20-page document is correct, the empirical accuracy is often closer to 40% or 50%.</figcaption>
    
</figure>

<p>Why does a well-calibrated page model become a dangerously overconfident stream model?</p>
<h3 id="the-clustered-difficulty-problem">The &ldquo;Clustered Difficulty&rdquo; Problem</h3>
<p>Standard calibration fails here because it assumes errors are <strong>independent</strong> (white noise). It assumes that if the model gets Page 5 wrong, it&rsquo;s just a random coin flip, unrelated to Page 6.</p>
<p>In real-world document streams, errors are heavily <strong>correlated</strong>.</p>
<p>It arises because <strong>difficulty clusters</strong>. Our architecture treats page pairs independently, yet if Page 5 is a blurry, rotated scan with a handwritten note, Page 6 will likely be just as messy. When a stream enters a &ldquo;hard&rdquo; segment, the model makes a series of correlated mistakes; it fails in a burst.</p>
<p>Standard calibration methods treat these systematic, environmental failures as random noise. They assume the model is equally likely to recover on the next page. In reality, the entire document segment is effectively &ldquo;radioactive&rdquo; to the model.</p>
<h2 id="the-money-metric-accuracy-vs-throughput">The &ldquo;Money Metric&rdquo;: Accuracy vs. Throughput</h2>
<p>If F1 Score is misleading and Confidence Score is broken, what should we measure?</p>
<p>Business leaders prioritize one critical question over F1 scores:</p>
<blockquote>
<p><em>&ldquo;How much of this volume can I let the system handle autonomously?&rdquo;</em></p>
</blockquote>
<p>To answer this, we introduced the <strong>Accuracy-vs-Throughput</strong> framework.</p>
<p>We must evaluate models across two dimensions. Every model offers a <strong>frontier of operating thresholds</strong>.</p>
<p>Imagine a dial. This dial is your <strong>Confidence Threshold</strong>.</p>
<ul>
<li><strong>Turn it Low (0.5)</strong>: You automate everything. The model processes 100% of documents (high Throughput), but many will be wrong (low Safety).</li>
<li><strong>Turn it High (0.999)</strong>: You only automate documents where the model is absolutely certain. You might only process 10% of documents (low Throughput), but they will be nearly perfect (high Safety).</li>
</ul>
<p>The chart below visualizes this trade-off. We want to be in the <strong>top-right corner</strong>: automating almost everything with high safety. The optimal model provides the best <strong>frontier</strong> of options, allowing you to pick the exact balance of volume and risk your business tolerates.</p>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation-throughput.webp"
         alt="Accuracy vs. Throughput trade-off curve"
         title="Accuracy vs. Throughput trade-off curve"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The &lsquo;Money&rsquo; Metric: As we demand higher textual accuracy (Moving up), the percentage of work we can automate (Throughput, x-axis) typically drops. The goal is to push this curve to the top-right.</figcaption>
    
</figure>

<h3 id="the-hidden-axis-cost--time">The &ldquo;Hidden&rdquo; Axis: Cost &amp; Time</h3>
<p>You might ask: <em>&ldquo;Is it worth running a massive GPU model on 100% of the documents just to automate 40% of them?&rdquo;</em></p>
<p>Ideally, we should plot this on a 4D surface: <strong>Accuracy</strong>, <strong>Throughput</strong>, <strong>Cost</strong>, and <strong>Latency</strong>.</p>
<table>
	<thead>
			<tr>
					<th style="text-align: left">Resource</th>
					<th style="text-align: left">Accuracy (Complex Cases)</th>
					<th style="text-align: left">Scalability</th>
					<th style="text-align: left">Cost</th>
					<th style="text-align: left">Latency</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: left"><strong>Humans</strong></td>
					<td style="text-align: left">High</td>
					<td style="text-align: left">Low</td>
					<td style="text-align: left">High</td>
					<td style="text-align: left">High</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>XGBoost</strong></td>
					<td style="text-align: left">Low</td>
					<td style="text-align: left">High</td>
					<td style="text-align: left">Low</td>
					<td style="text-align: left">Low</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>LLMs</strong></td>
					<td style="text-align: left">High</td>
					<td style="text-align: left">High</td>
					<td style="text-align: left">Medium</td>
					<td style="text-align: left">Medium</td>
			</tr>
	</tbody>
</table>
<p>The business case holds because even expensive GPUs are orders of magnitude cheaper than the alternative. If a human costs 0.50 per document and an H100 GPU costs 0.005 per document, you can afford to &ldquo;waste&rdquo; compute on the documents the model ultimately rejects, just to capture the savings on the share it automates safely. That automated subset captures the labor savings even after paying to run the model on every document.</p>
<h3 id="the-llm-advantage">The LLM Advantage</h3>
<p>This is where the paradox becomes interesting.</p>
<p>In our experiments on a dataset of <strong>7,500 proprietary insurance streams</strong> (medical records, police reports, and legal contracts), we found that <strong>XGBoost was actually better calibrated.</strong> Statistically, it produced confidence scores that more closely matched empirical probabilities, yielding lower calibration errors (ECE/MCE) than the LLMs.</p>
<p>However, when we hold both models to a strict confidence threshold and measure how much stream volume each can auto-process at comparable accuracy, the picture inverts:</p>
<table>
	<thead>
			<tr>
					<th style="text-align: left">Model</th>
					<th style="text-align: left">Confidence threshold</th>
					<th style="text-align: left">Auto-processed volume (throughput)</th>
					<th style="text-align: left">Accuracy on that volume</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: left"><strong>XGBoost</strong></td>
					<td style="text-align: left">$C &gt; 0.9$</td>
					<td style="text-align: left">35%</td>
					<td style="text-align: left">0.97</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Mistral-7B</strong></td>
					<td style="text-align: left">$C &gt; 0.9$</td>
					<td style="text-align: left">54%</td>
					<td style="text-align: left">0.95</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>XGBoost</strong></td>
					<td style="text-align: left">$C &gt; 0.8$</td>
					<td style="text-align: left">49%</td>
					<td style="text-align: left">0.93</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Mistral-7B</strong></td>
					<td style="text-align: left">$C &gt; 0.8$</td>
					<td style="text-align: left">70%</td>
					<td style="text-align: left">0.93</td>
			</tr>
	</tbody>
</table>
<p><em>Note: Mistral reaches 80% raw STP on the synthetic TABME++ benchmark (see our <a href="/posts/history-of-page-stream-segmentation/">PSS History</a> post); on these proprietary streams, a strict confidence threshold trades some of that volume for safety.</em></p>
<p>How can the &ldquo;worse&rdquo; calibrated model be better for business?</p>
<p>The answer lies in <strong>Discrimination Power</strong>. Calibration only tells you if the confidence score matches reality. Discrimination reflects the model&rsquo;s fundamental ability to separate &ldquo;Right&rdquo; from &ldquo;Wrong.&rdquo;</p>
<p>The LLMs, despite having skewed probability distributions, had vastly superior reasoning capabilities. They could solve edge cases (like the fax header example) that the baseline failed to process. Because their <em>raw capability</em> was higher, they pushed the entire trade-off curve up and to the right.</p>
<h2 id="engineering-reality-efficiency-vs-context">Engineering Reality: Efficiency vs. Context</h2>
<p>Given that LLMs offer superior reasoning capabilities, a natural question arises: if reasoning is the bottleneck, why not simply provide the model with more context?</p>
<p>One critique of our approach is that we treat segmentation as a local problem: looking only at Page $N$ and Page $N+1$ to make a decision. A valid counter-argument is: <em>&ldquo;What if the answer depends on page $N-5$?&rdquo;</em></p>
<p>It&rsquo;s a fair point. In theory, a model with a massive context window (reading the whole stream at once) <em>should</em> do better. It could see that Page 10 is actually an appendix referenced on Page 1.</p>
<p>In practice, however, <strong>global context is a trap for PSS</strong>.</p>
<ol>
<li><strong>Cost</strong>: Attention mechanisms scale quadratically. Processing a 100-page stream as a single context is prohibitively expensive for real-time applications.</li>
<li><strong>Distraction</strong>: We found that adding more history often <em>confused</em> the models. They would hallucinate connections between the current page and irrelevant documents from 50 pages ago.</li>
</ol>
<p>By strictly limiting the model to a &ldquo;Sliding Window&rdquo; of page pairs, we force it to focus on the immediate boundary signal. We rely on &ldquo;Local Precision&rdquo; (which is cheap and sharp) to avoid the pitfalls of &ldquo;Global Reasoning&rdquo; (which is expensive and prone to drift).</p>
<p>There is an intriguing middle ground we have yet to fully explore: <strong>iterative context accumulation</strong>. A model could autoregressively &ldquo;build&rdquo; the document in its context, carrying forward only the pages it has decided belong to the current document. In theory, this stateful approach could capture long-range dependencies (like that &ldquo;Appendix A&rdquo; reference) while avoiding the noise of the full stream.</p>
<p>However, this introduces a new risk: <strong>Bias Amplification</strong>. If the model is trained to view previous context pages as &ldquo;part of the current document,&rdquo; it may learn a strong bias to continuously merge pages. Out of distribution, this could lead to catastrophic failure, where the model gets &ldquo;stuck&rdquo; in a document-building mode and merges hundreds of unrelated pages into a single monolithic file. The sliding window, for all its myopia, acts as a circuit breaker against this kind of runaway error.</p>
<p>Empirically, this simpler approach holds up. In the cases where we saw PSS work best, the rules tended to be simple ones requiring minimal context; they relied on <strong>clear and consistent enumeration</strong> and a decent amount of data to scale the Accuracy-Throughput frontier.</p>
<p><em>Technical aside: This is effectively a Markovian assumption. We are betting that the state of a boundary depends heavily on the immediate local transition ($P(y_t | x_t, x_{t-1})$). We prioritize immunity to &ldquo;distraction&rdquo; from previous docs over long-range coherence (like tracking &ldquo;Page 1 of N&rdquo; counters).</em></p>
<p>To achieve the necessary efficiency for this local approach, we fine-tuned these models with <strong>LoRA (Low-Rank Adaptation)</strong> over a 4-bit-quantized base model (via Unsloth, LoRA weights in BF16) on a single NVIDIA H100.</p>
<ul>
<li><strong>Rank ($r$)</strong>: 16</li>
<li><strong>Alpha ($\alpha$)</strong>: 16</li>
<li><strong>Precision</strong>: 4-bit quantization</li>
</ul>
<p>This efficient, local approach makes the &ldquo;heavy&rdquo; LLM solution surprisingly deployable.</p>
<h2 id="the-paradox-of-the-simple-task">The Paradox of the &ldquo;Simple&rdquo; Task</h2>
<p>There is a tension here. We call PSS the &ldquo;Hello World&rdquo; of document processing. It feels like it should be trivial: just sorting papers. Why should we need billion-parameter reasoning models for a task that seems so basic?</p>
<p>The answer lies in the distinction between <strong>Perception</strong> and <strong>Logic</strong>.</p>
<ul>
<li><strong>90% of PSS is Perception (System 1)</strong>: Recognizing a bold header, a logo change, or a &ldquo;Page 1 of 5&rdquo; footer. This is reactive and fast. XGBoost or a simple CNN handles this easily.</li>
<li><strong>The last 10% is Reasoning (System 2)</strong>: Determining if an unlabelled &ldquo;Addendum B&rdquo; belongs to the previous Master Service Agreement or starts a new policy packet. Reconciling this conflict requires semantic understanding.</li>
</ul>
<p>A perfect example from our dataset is <strong>Fax Headers</strong>. A document might have a clear &ldquo;Page 1&rdquo; printed on it, but the fax machine stamps &ldquo;Page 005&rdquo; on top of the header because it&rsquo;s the 5th page of the transmission. XGBoost sees &ldquo;Page 005&rdquo;, fails to reconcile the conflict, and incorrectly continues the document. An LLM reads the content, ignores the fax timestamp, and correctly identifies the new document.</p>
<p>The &ldquo;Reliability Trap&rdquo; snaps shut because we treat the entire problem as a System 1 perception task. We ask the model to predict the boundary instantly. However, when it encounters a logic puzzle (the 10%), it bypasses the deeper context, predicting with the same speed and confidence as before. This is why we see <strong>Clustered Difficulty</strong>. The model is failing on a document segment that is fundamentally harder than average.</p>
<h2 id="escaping-the-trap-from-guessing-to-verifying">Escaping the Trap: From Guessing to Verifying?</h2>
<p>If the problem is that models are &ldquo;Fast Processors&rdquo; prone to high-confidence errors in complex scenarios, a potential path forward may lie in <a href="https://arxiv.org/abs/2408.03314"><strong>Test-Time Compute</strong></a>.</p>
<p>The future of reliable automation lies in &ldquo;Building a better Checker.&rdquo; In high-stakes PSS, this could mean looking toward a <strong>Guesser-Verifier</strong> architecture, a technique becoming common in advanced reasoning tasks (like mathematical problem solving, <a href="https://arxiv.org/abs/2110.14168"><em>Cobbe et al., 2021</em></a>).</p>
<p>The core insight reflects a fundamental asymmetry in computer science (analogous to <strong>P vs NP</strong>): <strong>Verification is often easier than Generation.</strong> Just as it is easier to check if a Sudoku puzzle is solved than to solve it from scratch, it is significantly simpler to &ldquo;audit&rdquo; a complete document structure than to autoregressively predict it perfectly token-by-token.</p>
<ol>
<li><strong>The Generator (System 1)</strong>: A lightweight model (like <strong>Mistral-7B</strong> or <strong>Phi-3.5</strong>) proposes a segmentation. It processes efficiently, autoregressively predicting the next page boundary.</li>
<li><strong>The Verifier (System 2)</strong>: This would be a discriminative model (often a Reward Model or the same LLM with a specialized prompt). The system evaluates the <em>complete</em> proposed document bundle and scores its coherence. It evaluates: <em>&ldquo;Is this 5-page sequence actually coherent?&rdquo;</em></li>
</ol>
<p>A logical exploration would be a <strong>Best-of-N</strong> approach. Relying on the generator&rsquo;s first prediction is risky when it is uncertain. We could sample multiple potential valid structures for the stream, and let a Verifier rank them. This might help break the &ldquo;autoregressive myopia&rdquo; where a model commits to an early mistake. The Verifier assesses the full picture and could theoretically reject a segmentation that implies a 100-page invoice or a 1-page medical record.</p>
<p>This approach offers a chance to break the mathematical tyranny of $0.99^{100}$. The system can selectively apply reasoning power to &ldquo;audit&rdquo; the stream before an error propagates downstream, treating the document as a cohesive unit.</p>
<h2 id="conclusion-better-systems-over-better-models">Conclusion: Better Systems Over Better Models</h2>
<p>We have largely solved the <strong>Capability</strong> problem for PSS: we have models that <em>can</em> read almost anything. Now, we face the <strong>Reliability</strong> barrier.</p>
<p>Our results paint a complex picture. Fine-tuned LLMs auto-process a substantially larger share of streams at equal accuracy than XGBoost (54% vs 35% at $C &gt; 0.9$ in this study). Simultaneously, the &ldquo;Reliability Trap&rdquo; remains a critical challenge. Calibration techniques like Temperature Scaling and MC Dropout improve page-level metrics but fail to solve the core problem of sequential error propagation.</p>
<p>For practitioners building with LLMs in high-stakes domains (finance, law, medicine), the path forward requires a shift in both architecture and mindset:</p>
<ol>
<li><strong>Prioritize Throughput</strong>: What share of your volume can you automate at the reliability your domain demands? That is the KPI that matters.</li>
<li><strong>Accept the &ldquo;Logic&rdquo; Cost</strong>: Acknowledge that &ldquo;Hello World&rdquo; tasks often contain edge cases requiring genuine reasoning and semantic understanding.</li>
<li><strong>Explore Verifiers</strong>: It&rsquo;s possible that the next leap in performance will come from systems designed to validate outputs and audit complete structures.</li>
<li><strong>Human in the Loop</strong>: The model should act as a filter. It must reliably process the easy cases and flag the complex ones for human review <em>before</em> they corrupt the downstream database.</li>
</ol>
<p>Accuracy tells you what the model predicts. Calibration tells you if the model&rsquo;s confidence matches its correctness. In the real world, the latter is often worth more.</p>
<p><em>Read the full paper on <a href="https://aclanthology.org/2025.coling-industry.26/">ACL Anthology</a>, view the <a href="/coling-2025-pss-poster.pdf">conference poster</a>, or visit the <a href="/research/page-stream-segmentation-llms/">research page</a>. This paper builds on the <a href="/research/llm-page-stream-segmentation/">TabMe++ benchmark and decoder-based LLM approach</a> introduced in our earlier arXiv work. For related work on the OCR front-ends that feed these pipelines, see <a href="/research/gutenocr-grounded-vision-language-frontend/">GutenOCR</a>.</em></p>
]]></content:encoded></item><item><title>The Evolution of Page Stream Segmentation: Rules to LLMs</title><link>https://hunterheidenreich.com/posts/history-of-page-stream-segmentation/</link><pubDate>Sat, 14 Feb 2026 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/history-of-page-stream-segmentation/</guid><description>An exploration of Page Stream Segmentation (PSS) evolution and how context-driven sequence modeling addresses limitations in document processing.</description><content:encoded><![CDATA[<p>In the world of automated document processing, Page Stream Segmentation (PSS)<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> is the &ldquo;hello world&rdquo; problem that remains surprisingly stubborn.</p>
<p>The task is deceptively simple: given a stack of scanned pages (invoices, contracts, medical records), determine where one document ends and the next begins.</p>
<p>For decades, this problem was tackled with brittle rules and heuristics. Then came the deep learning era, where we threw Convolutional Neural Networks (CNNs) and multipage Transformers at it. Yet, even sophisticated models struggled to achieve what businesses actually care about: Straight-Through Processing (STP).</p>
<blockquote>
<p><strong>Why STP Matters</strong>: Everyone interacting with the system cares about STP. Arguably, any human that has to interact with the output of a system, deal with its mistakes, and perform corrections to get a job done, cares about it. If the system fails 90% of the time, it fails to automate and creates more work at the expense of real people.</p>
</blockquote>
<p>In this post, we explore the three eras of PSS, the limitations of page-level accuracy metrics, and how context-driven sequence modeling addresses these challenges.</p>
<h2 id="the-hidden-complexity-of-pss">The Hidden Complexity of PSS</h2>
<p>Why is PSS hard? It comes down to ambiguity and asymmetry.</p>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/page-stream-segmentation-automation-difficulties.webp"
         alt="Robot looking confused at a messy stack of documents"
         title="Robot looking confused at a messy stack of documents"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Automation in PSS is rarely straightforward&hellip; Messy inputs and ambiguous boundaries often lead to confusion.</figcaption>
    
</figure>

<h3 id="the-document-definition-problem">The &ldquo;Document&rdquo; Definition Problem</h3>
<p>First, the concept of a &ldquo;document&rdquo; is highly context-dependent.</p>
<blockquote>
<p><strong>The &ldquo;Word&rdquo; Analogy</strong>: What is a word? A sequence of characters separated by spaces? Or a meaningful unit of language that can be a single character (e.g., &ldquo;I&rdquo;) or a compound (e.g., &ldquo;New York&rdquo;)? <a href="https://arxiv.org/abs/1710.07729">Is space a word, too?</a> This ambiguity problem permeates all levels of language processing. We&rsquo;d be naive to think PSS is an exception to this rule!</p>
</blockquote>
<p>Consider an email with an attachment. Is the email body one document and the attachment another? Or is the whole packet one document? What about an invoice stapled to a check? A policy packet with multiple addendums?</p>
<p>&ldquo;Solvability&rdquo; implies a single ground truth, but in reality, PSS often requires aligning the model with specific, often subjective, business logic. A boundary to an underwriter might be a continuation to an archivist.</p>
<p>This subjectivity is a nightmare for rule-based systems. To solve it, we need models that go beyond pattern matching to reason about context and semantics. This is precisely where the self-attention mechanisms of Transformers excel.</p>
<h3 id="the-cost-of-error">The Cost of Error</h3>
<p>Second, the cost of failure is asymmetric.</p>
<p>If you are classifying an email as &ldquo;Spam&rdquo; or &ldquo;Not Spam,&rdquo; a single error affects one email. But PSS is a sequence problem. A single missed page break merges two distinct documents into one. This effectively &ldquo;corrupts&rdquo; two documents for the price of one error. Conversely, a false break splits a valid document in half.</p>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/classification-vs-sequence-segmentation.webp"
         alt="Diagram comparing classification errors vs sequence segmentation errors"
         title="Diagram comparing classification errors vs sequence segmentation errors"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The blast radius of error: Unlike simple classification where one error affects one item, a single segmentation error corrupts the integrity of multiple documents.</figcaption>
    
</figure>

<p>Even more dismal, if our focus is truly STP, then the only acceptable outcome is perfect segmentation of an entire document stream. Sometimes faxes can be hundreds of pages long.</p>
<p>If we have a 99% page-level accuracy ($p=0.99$), the probability of correctly segmenting a 100-page stream ($N=100$) is only:</p>
<p>$$ P(\text{Success}) = p^N = 0.99^{100} \approx 0.37 $$</p>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/asymmetric-cost-of-error-in-document-streams.webp"
         alt="Chart showing exponential decay of straight-through processing probability as document length increases"
         title="Chart showing exponential decay of straight-through processing probability as document length increases"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Even high page-level accuracy (99%) results in low stream-level success rates for long documents due to the multiplicative nature of error probabilities.</figcaption>
    
</figure>

<p>In other words, even with &ldquo;high&rdquo; accuracy, the vast majority of document streams will require human intervention. This phenomenon is what we call <strong>The Reliability Trap</strong> (explored in depth in <a href="/posts/reliability-trap-document-automation/">our companion post</a>).</p>
<h3 id="the-f1-score-trap">The &ldquo;F1 Score&rdquo; Trap</h3>
<p>A major finding in our research (<a href="/research/llm-page-stream-segmentation/">TabMe++</a>) is that traditional metrics mask the operational reality. Although generalized text segmentation metrics like <a href="https://aclanthology.org/W97-0304/">$P_k$</a> and <a href="https://aclanthology.org/J02-1002/">WindowDiff</a> exist, we found they don&rsquo;t capture the document-centric nature of business workflows.</p>
<p>Instead, we evaluate at three levels:</p>
<ol>
<li><strong>Page-Level</strong>: Did we correctly classify this single page transition?</li>
<li><strong>Document-Level</strong>: Did we correctly identify the entire document tuple $d_k = (p_i, \ldots, p_j)$?</li>
<li><strong>Stream-Level</strong>: Did we perfectly segment the entire stack of documents?</li>
</ol>
<p>Our results showed that Page-Level F1 Score completely masks the downstream impact.</p>
<p>Consider a baseline XGBoost model we tested:</p>
<ul>
<li><strong>Page F1 Score</strong>: 0.83 (Sounds decent, right?)</li>
<li><strong>STP</strong>: 0.07 (Abysmal)</li>
<li><strong>MNDD</strong>: 10.85</li>
</ul>
<p><strong>That means 93% of document streams required human intervention.</strong> Even worse, the MNDD (Minimum Number of Drag-and-Drops)<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> score tells us that for each stream, a human had to manually drag ~11 pages to fix the ordering.</p>
<p>This metric is crucial because it proxies the actual <em>pain</em> of the human in the loop. An error signifies more than a theoretical label flip; it forces a manual drag-and-drop operation.</p>
<h2 id="era-1-the-heuristic-era-2000s---2015">Era 1: The Heuristic Era (2000s - 2015)</h2>
<p>In the beginning, PSS was a game of <code>if/else</code> statements. Engineers hand-crafted heuristics tailored to specific document layouts, checking for signals like:</p>
<ul>
<li><em>Does the page contain &ldquo;Page 1 of X&rdquo;?</em></li>
<li><em>Is there a &ldquo;Total&rdquo; line at the bottom?</em></li>
<li><em>Does the header text change drastically?</em></li>
</ul>
<p>While effective for known templates, these systems were inherently brittle. They relied on rigid assumptions about the input structure. If a vendor changed their invoice layout or OCR quality dipped, the logic would fail. They worked perfectly for what they were designed for but had zero capability to generalize to the unknown. Unfortunately, the real world is a constant state of exception.</p>
<h2 id="era-2-the-encoder-era-2015---2023">Era 2: The Encoder Era (2015 - 2023)</h2>
<p>As deep learning matured, researchers moved from hard-coded rules to learned representations.</p>
<ul>
<li><strong>Visual Approaches</strong>: Using CNNs to look at the &ldquo;shape&rdquo; of a page as an image. First pages often look different from continuation pages (logos, big headers).</li>
<li><strong>Word Vectors</strong>: Early NLP attempts used tools like <a href="https://arxiv.org/abs/1405.4053">doc2vec</a> to represent page content, but these &ldquo;averaged&rdquo; the text, losing sequential meaning.</li>
<li><strong>Multimodal Transformers</strong>: Eventually, models like <a href="https://arxiv.org/abs/1912.13318">LayoutLM</a> and <a href="https://arxiv.org/abs/2010.02559">LEGAL-BERT</a> tried to combine text and layout into a single understanding.</li>
</ul>
<p>While these models were &ldquo;smarter&rdquo; than rules, they suffered from distinct limitations:</p>
<ol>
<li>
<p><strong>Field Lag</strong>: Surprisingly, only a handful of studies applied Transformers to PSS before 2024. Most of the industry was still stuck on older CNN architectures.</p>
</li>
<li>
<p><strong>Context Windows</strong>: Encoder models like <a href="https://arxiv.org/abs/1810.04805">BERT</a> are limited to 512 tokens. A dense legal contract page might have 1,000+ tokens. You had to chop the text, losing critical context.</p>
</li>
<li>
<p><strong>Modality Overload</strong>: Counterintuitively, our experiments showed that naively adding modalities (Text + Layout + Vision) often yielded diminishing returns. Models like <a href="https://arxiv.org/abs/2204.08387">LayoutLMv3</a> struggled to outperform simpler vision-only or text-only models on our benchmark.</p>
<blockquote>
<p>However, looking continuously at the data reveals an interesting nuance: <strong>Visual signals matter.</strong> In our tests, the vision-only model (DiT) actually outperformed the text-only model (RoBERTa). The vision-only model (DiT) tends to be more precise, while the text-only model (RoBERTa) reaches higher recall. The multimodal models failed due to the difficulty of <em>aligning</em> modalities. Vision remains a highly useful signal. This insight led us to a key realization for Era 3: What if we could give the model visual information without the architectural headache of a vision encoder?</p>
</blockquote>
</li>
</ol>
<h2 id="era-3-the-decoder-era-2024---present">Era 3: The Decoder Era (2024 - Present)</h2>
<p>The breakthrough came with applying Decoder-only Large Language Models (LLMs) like <a href="https://arxiv.org/abs/2310.06825">Mistral-7B</a> and <a href="https://arxiv.org/abs/2404.14219">Phi-3</a> to the task.</p>
<p>Why do LLMs succeed where specialized encoders failed? <strong>Contextual Processing</strong>.</p>
<p>Determining if a page is a continuation often requires analyzing sequential dependencies.</p>
<ul>
<li><em>Does the sentence cut off mid-thought?</em></li>
<li><em>Does the next page logically follow the argument of the previous one?</em></li>
<li><em>Is the &ldquo;Policy Number&rdquo; on Page 2 the same as Page 1?</em></li>
</ul>
<p>LLMs are pre-trained on the internet; they model narrative flow and document structure effectively. By fine-tuning them on pairs of pages, we adapted these priors to recognize specific segmentation boundaries.</p>
<h3 id="2d-projection--data-quality">2D Projection &amp; Data Quality</h3>
<p>We employed <strong>2D Text Projection</strong>, a technique that serializes OCR output by mapping spatial coordinates to whitespace. This effectively &ldquo;draws&rdquo; the layout using text characters, allowing the LLM to process columns, headers, and form structures. We translated the visual signal (layout) into the text modality to address the &ldquo;Modality Overload&rdquo; problem.</p>
<p>To be clear, this is a lossy compression. We discard font sizes, bolding, colors, and line separators. It is merely a cheap, zeroth-order approximation of 2D layout using 1D text. Yet, as our results show, this approximation captures the <em>semantic</em> essence of the layout (e.g., &ldquo;this text is in a header column&rdquo;) sufficient for the model to reason about document boundaries.</p>
<p>However, this technique has a hard dependency: <strong>Data Quality</strong>. 2D projection is useless if your OCR gives you garbage coordinates. This is where our work on <strong>TabMe++</strong> (discussed below) became critical. You can&rsquo;t project a layout if the OCR misses the text or places it in the wrong spot.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span># Original Raw Text (Loss of Layout)
</span></span><span style="display:flex;"><span>INVOICE # 1024 DATE: 2024-02-14 TOTAL: $500.00
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># 2D Projected Text (Layout Preserved)
</span></span><span style="display:flex;"><span>                    INVOICE # 1024
</span></span><span style="display:flex;"><span>                    DATE: 2024-02-14
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>TOTAL:                                      $500.00
</span></span></code></pre></div><blockquote>
<p><strong>Why Does This Work?</strong> Modern LLMs are trained on a mixture of web text, code, and even some structured data. They have learned to interpret whitespace and formatting cues as part of their understanding of language. By encoding layout information into the text itself, we leverage the LLM&rsquo;s existing capabilities without needing to train a separate vision encoder.</p>
</blockquote>
<p>We then wrapped this input in a structured prompt that explicitly framed the task for the LLM:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>You are a skilled document reviewer. Given extracted text from pages of documents, your task is to determine if a page starts a new document or continues from the previous one.
</span></span><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>Prior text:
</span></span><span style="display:flex;"><span>###
</span></span><span style="display:flex;"><span>{pg_prev}
</span></span><span style="display:flex;"><span>###
</span></span><span style="display:flex;"><span>Page text:
</span></span><span style="display:flex;"><span>###
</span></span><span style="display:flex;"><span>{pg}
</span></span><span style="display:flex;"><span>###
</span></span><span style="display:flex;"><span>Output your prediction as a JSON object...
</span></span></code></pre></div><h3 id="the-results">The Results</h3>
<p>We formulated the task as a binary classification problem on page pairs. We fed the model <code>(Page N, Page N+1)</code> and asked: <em>&ldquo;Does Page N+1 start a new document?&rdquo;</em></p>
<p>Comparison on <strong>TabMe++</strong> Benchmark:</p>
<table>
	<thead>
			<tr>
					<th style="text-align: left">Model Type</th>
					<th style="text-align: left">Model Name</th>
					<th style="text-align: left">Page F1</th>
					<th style="text-align: left"><strong>STP</strong> (Higher is better)</th>
					<th style="text-align: left"><strong>MNDD</strong> (Lower is better)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: left"><strong>Baseline</strong></td>
					<td style="text-align: left"><a href="https://arxiv.org/abs/1603.02754">XGBoost</a></td>
					<td style="text-align: left">0.83</td>
					<td style="text-align: left"><strong>7.4%</strong></td>
					<td style="text-align: left">10.85</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Encoder</strong></td>
					<td style="text-align: left"><a href="https://arxiv.org/abs/1907.11692">RoBERTa</a> (Text)</td>
					<td style="text-align: left">0.78</td>
					<td style="text-align: left"><strong>4.2%</strong></td>
					<td style="text-align: left">12.17</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Encoder</strong></td>
					<td style="text-align: left"><a href="https://arxiv.org/abs/2203.02378">DiT</a> (Vision)</td>
					<td style="text-align: left">0.83</td>
					<td style="text-align: left"><strong>6.6%</strong></td>
					<td style="text-align: left">10.48</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Decoder</strong></td>
					<td style="text-align: left"><strong><a href="https://arxiv.org/abs/2310.06825">Mistral-7B</a> (Fine-Tuned)</strong></td>
					<td style="text-align: left"><strong>0.99</strong></td>
					<td style="text-align: left"><strong>80.0%</strong></td>
					<td style="text-align: left"><strong>0.81</strong></td>
			</tr>
	</tbody>
</table>
<p>The difference is stark. Moving from Encoders to Decoders increased the automation rate from ~7% to <strong>80%</strong> and reduced the human effort (MNDD) by a factor of 10. <em>Note: This 80% represents the model&rsquo;s raw accuracy. As we discuss in <a href="/posts/reliability-trap-document-automation/">The Reliability Trap</a>, achieving &ldquo;production-safe&rdquo; automation often requires setting strict confidence thresholds, which effectively lowers the safe throughput.</em></p>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/llm-sample-efficiency-convergence-plot.webp"
         alt="Sample efficiency plot showing rapid convergence in under 1000 updates"
         title="Sample efficiency plot showing rapid convergence in under 1000 updates"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">LLMs adapt fast. Our decoder models converged in fewer than 1,000 updates, suggesting strong priors for document structure.</figcaption>
    
</figure>

<h3 id="why-fine-tuning-matters-the-gpt-4o-comparison">Why Fine-Tuning Matters: The GPT-4o Comparison</h3>
<p>You might look at the chart above and ask: &ldquo;Is the model learning PSS, or does it just rely on pre-trained language statistics?&rdquo;</p>
<p>To test this, we ran <strong><a href="https://openai.com/index/hello-gpt-4o/">GPT-4o</a> in a zero-shot setting</strong> on the same task. The result was an STP of roughly 9%.</p>
<p>Zero-shot GPT-4o performed similarly to our XGBoost baseline. This demonstrates that broad pre-training requires specific instruction tuning to capture business logic. Our 7B model achieved 80% STP after fewer than <strong>1,000 updates</strong>.</p>
<p>This proves two things:</p>
<ol>
<li><strong>Broad Pre-training Requires Tuning.</strong> Modeling generic document distributions must be adapted to capture specific business logic for segmentation.</li>
<li><strong>The Capabilities are Latent.</strong> The rapid convergence implies the model possesses the necessary statistical priors and requires fine-tuning to align those priors with the specific task. We are adjusting the decision boundary between a generic &ldquo;document&rdquo; and a specific business record.</li>
</ol>
<h3 id="the-cost-of-intelligence-and-the-value-of-human-time">The Cost of Intelligence and the Value of Human Time</h3>
<p>Critically, we must address the two elephants in the room: <strong>Inference Cost</strong> and <strong>Data Privacy</strong>.</p>
<p>It is true that running a 7B parameter LLM for every page pair is computationally more expensive than a lightweight XGBoost model. However, focusing solely on compute costs misses the operational and human reality of this work.</p>
<p>Economically, the &ldquo;cheap&rdquo; model is a mirage. When a low-accuracy model forces a human to reorganize 93% of document streams, the cost of rectification, specifically wasted salaries and slowed turnaround times, dwarfs the cost of GPU inference. But the financial argument is secondary to the human one.</p>
<p>Manually segmenting documents is, frankly, soul-sucking. It is tedious, repetitive drudgery that few people enjoy. Beyond operational expense, we are discussing human burnout. A model that achieves 80% full automation (STP) saves money while liberating people from the mind-numbing task of sorting pages. This allows them to focus on work that actually requires their creativity and empathy. We are trading cheap FLOPs for valuable human attention.</p>
<p>Furthermore, democratizing this capability has profound implications beyond the enterprise. If we can make high-quality segmentation usable on modest hardware (like a high-end laptop or a single commodity GPU), we open the door for archivists, librarians, digital humanists, and small cities or towns that have little to no resources for this kind of work. These are the custodians of our collective intelligence, often working with massive, unorganized scanned collections but lacking the budget for massive cloud clusters.</p>
<p>Our results showed that <strong>7B parameter models</strong> (like Mistral) are sufficient to solve this task. This size is the sweet spot: capable enough to reason over document structure, but small enough to run locally. This matters for data sovereignty (keeping medical records private) and accessibility. It means a small historical society could potentially automate the organization of a century’s worth of digitized records without a massive grant for cloud compute.</p>
<p>That said, a 7B model might not be the lower bound. While it was the breakthrough size for our study, the recent explosion of capable 1B-3B models suggests we haven&rsquo;t hit the efficiency floor yet. Combined with extreme quantization, modern small language models (SLMs) likely offer the &ldquo;Goldilocks&rdquo; zone: enough reasoning to maintain high STP, but fast enough to run continuously on modest hardware. We suspect the future of PSS lies in these highly optimized, smaller reasoning models that can run anywhere&hellip; from a bank&rsquo;s secure server to a researcher&rsquo;s laptop.</p>
<h2 id="the-importance-of-data-quality">The Importance of Data Quality</h2>
<p>Data quality presented an equal challenge to algorithmic limitations.
Most public datasets (like Tobacco800) were small or unrealistic. The TABME dataset (precursor to our work) relied on open-source Tesseract OCR, which missed vast amounts of text.</p>
<p>We released <a href="https://huggingface.co/datasets/bevaya/TABMEpp"><strong>TabMe++</strong></a>, which re-processed the entire dataset with commercial-grade Microsoft OCR.</p>
<ul>
<li><strong>Blank Pages</strong>: Reduced from 2.27% $\rightarrow$ 0.38%.</li>
<li><strong>Token Count</strong>: Increased from 719M $\rightarrow$ 9.5B.</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/page-stream-segmentation/noisy-sales-forecast-document-scan.webp"
         alt="Scanned document page showing a sales forecast with some noise"
         title="Scanned document page showing a sales forecast with some noise"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Original Page: A noisy scan that Tesseract struggles to read.</figcaption>
    
</figure>

<p>The difference in intelligibility is night and day. Consider the page above.</p>
<p><strong>Tesseract (Original)</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>02Z10102
</span></span></code></pre></div><p><em>(Misses almost everything, including the title and real ID)</em></p>
<p><strong>Microsoft OCR (TabMe++)</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>            SALES FORECAST
</span></span><span style="display:flex;"><span>                            201017205
</span></span></code></pre></div><p><em>(Correctly captures the spatial layout, the title, and the ID)</em></p>
<p><strong>Lesson</strong>: You can&rsquo;t segment what you can&rsquo;t read. High-quality OCR (or <a href="/research/gutenocr-grounded-vision-language-frontend/">multimodal front-ends</a> like GutenOCR, trained on large-scale annotation corpora like <a href="/research/pubmed-ocr-pmc-open-access-ocr-annotations/">PubMed-OCR</a>) is the foundation of high-quality downstream NLP.</p>
<h2 id="the-next-frontier-context-and-instruction-following-2026">The Next Frontier: Context and Instruction Following (2026+)</h2>
<p>As we discussed earlier, the definition of a &ldquo;document&rdquo; is subjective. To one team, an email + attachment is a single record. To another, they are distinct entities. A rigid model that segments perfectly for Team A will fail miserably for Team B.</p>
<p>The zero-shot GPT-4o results demonstrate that scale requires adaptation. The future of PSS depends on <strong>instruction tuning</strong>. We need models that can accept natural language rules alongside the document stream:</p>
<blockquote>
<p><em>&ldquo;Split all invoices, but keep attachments with their parent emails. If you see an ACORD form, group it with the subsequent policy document.&rdquo;</em></p>
</blockquote>
<p>This shift mirrors the broader evolution of LLMs. PSS models must evolve into dynamic systems capable of instruction following. A single model should be able to adapt to any business logic without retraining.</p>
<p>Furthermore, while our 2024 research favored unimodal text models with 2D projection, the multimodal landscape is shifting. With the rise of natively multimodal models (like <a href="https://deepmind.google/models/gemini/">Gemini</a>, <a href="https://openai.com/index/hello-gpt-4o/">GPT-4o</a>, and our own <a href="/research/gutenocr-grounded-vision-language-frontend/">GutenOCR</a>), we effectively get the &ldquo;2D projection&rdquo; natively. Future models should be able to fuse this native visual understanding with semantic reasoning, guided by user-defined constraints.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Page Stream Segmentation is a perfect case study in the evolution of AI. We moved from <strong>encoding rules</strong> (Heuristic Era) to <strong>encoding features</strong> (Encoder Era) to <strong>encoding understanding</strong> (Decoder Era).</p>
<p>For enterprise professionals, the takeaways are clearer and more critical than ever.</p>
<p>First, <strong>stop looking at element-wise F1 scores for sequence tasks.</strong> While element-wise metrics are useful for engineers debugging algorithms, they are misleading for decision-makers. Focus on the metrics that actually affect people and workflows, like Straight-Through Processing (STP) and Minimum Number of Drag-and-Drops (MNDD).</p>
<p>Second, if you want to solve PSS today, start with an inward-looking conversation about &ldquo;for what.&rdquo; Before picking a model, answer these questions:</p>
<ul>
<li><strong>Inputs</strong>: What assumptions are you making about your document stream?</li>
<li><strong>Outcomes</strong>: What specific business outcomes are you hoping to see?</li>
<li><strong>Context</strong>: What is the core motivation for this workflow?</li>
<li><strong>Nuance</strong>: Are there informative scenarios (like the &ldquo;email attachment&rdquo; problem) that illustrate your specific needs?</li>
</ul>
<p>Given these answers, many modern approaches can solve PSS for your case. Whether you need an on-premise solution for secure scenarios using lightweight open-weights models, or can leverage powerful AI-as-a-Service APIs, the technology is no longer the bottleneck; understanding your own requirements is.</p>
<p><em>For full technical details, experimental setups, and datasets, refer to our paper: <a href="/research/llm-page-stream-segmentation/">Large Language Models for Page Stream Segmentation</a> or view the preprint on <a href="https://arxiv.org/abs/2408.11981">arXiv:2408.11981</a>. These findings were later extended to real-world insurance document processing in <a href="/research/page-stream-segmentation-llms/">LLMs for Insurance Document Automation</a>. Much of the initial work was also documented in a precursor blog series at Roots Automation (<a href="https://www.bevaya.ai/blog/segmenting-documents-with-llms-and-multimodal-document-ai-part-1">Part 1</a> &amp; <a href="https://www.bevaya.ai/blog/segmenting-documents-with-llms-and-multimodal-document-ai-part-2">Part 2</a>).</em></p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Historically, this task has gone by many names: <em>document separation</em>, <em>document flow segmentation</em>, <em>document stream segmentation</em>, <em>document bundle separation</em>, and <em>page stream separation</em>. We stick to <strong>Page Stream Segmentation (PSS)</strong> to emphasize the sequential nature of the problem.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>We adopted the MNDD metric from <a href="https://dl.acm.org/doi/10.1145/3558100.3563852">Mungmeeprued et al. (2022)</a>, who introduced it alongside the original TABME dataset to better quantify the human effort required to correct segmentation errors.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>Importance Weighted Autoencoders: Beyond the Standard VAE</title><link>https://hunterheidenreich.com/posts/importance-weighted-autoencoders/</link><pubDate>Wed, 05 Nov 2025 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/importance-weighted-autoencoders/</guid><description>The key difference between multi-sample VAEs and IWAEs: how log-of-averages creates a tighter bound on log-likelihood.</description><content:encoded><![CDATA[<p>If you&rsquo;ve worked with Variational Autoencoders (VAEs), you&rsquo;ve almost certainly used the standard $\mathcal{L}_1$ objective, or ELBO. It&rsquo;s trained by taking <em>one</em> sample ($k=1$) from the recognition network to calculate the loss.</p>
<p>A natural question follows: &ldquo;What if I use more samples? Won&rsquo;t that make it better?&rdquo;</p>
<p>Using more samples improves performance when paired with the correct objective function. Averaging the loss over $k$ samples yields minimal gains. Changing the objective itself is where the real gain comes from. This post explores the difference between a &ldquo;multi-sample VAE&rdquo; and the <strong>Importance Weighted Autoencoder (IWAE)</strong>, a model that uses the <em>same architecture</em> as a VAE but is trained with a different objective that optimizes a tighter bound on the log-likelihood.</p>
<p>All ideas here are based on the fantastic paper: <a href="https://arxiv.org/abs/1509.00519">&ldquo;Importance Weighted Autoencoders&rdquo;</a> by Burda, Grosse, and Salakhutdinov.</p>
<h2 id="the-two-ways-to-use-k-samples">The Two Ways to Use $k$ Samples</h2>
<p>Let&rsquo;s say we have our encoder $q(h|x)$ and decoder $p(x,h)$. We decide to use $k=5$ samples instead of $k=1$. We have two main options for how to calculate our loss.</p>
<h3 id="option-1-the-multi-sample-vae-the-naive-way">Option 1: The &ldquo;Multi-Sample VAE&rdquo; (The Naive Way)</h3>
<p>This is the most straightforward idea. For each input $x$ in our batch:</p>
<ol>
<li>Draw 5 samples ($h_1, &hellip;, h_5$) from $q(h|x)$.</li>
<li>Calculate the standard VAE $\mathcal{L}_1$ loss for <em>each</em> sample.</li>
<li>Average these 5 losses together.</li>
</ol>
<p>This is an <strong>average of logs</strong>. As the IWAE paper shows experimentally, this approach gives you a more stable gradient, but the final performance (in terms of log-likelihood) is &ldquo;only slightly&rdquo; better. You&rsquo;re paying a 5x computational cost for a marginal gain because you&rsquo;re still optimizing the same &ldquo;loose&rdquo; $\mathcal{L}_1$ bound.</p>
<h3 id="option-2-the-importance-weighted-autoencoder-iwae-the-right-way">Option 2: The Importance Weighted Autoencoder (IWAE) (The Right Way)</h3>
<p>The IWAE takes a different approach. For each input $x$:</p>
<ol>
<li>Draw 5 samples ($h_1, &hellip;, h_5$) from $q(h|x)$.</li>
<li>Calculate an &ldquo;importance weight&rdquo; $w_i$ for each sample.</li>
<li>Average these 5 <em>weights</em> together.</li>
<li>Take the <em>logarithm</em> of that average.</li>
</ol>
<p>This is a <strong>log of an average</strong>, and the difference matters.</p>
<h2 id="the-math-average-of-logs-vs-log-of-averages">The Math: Average-of-Logs vs. Log-of-Averages</h2>
<p>Let&rsquo;s make this concrete. The standard VAE $\mathcal{L}_1$ objective is:</p>
<p>$$
\mathcal{L}_1(x) = \mathbb{E} _{h\sim q(h|x)} \left[ \log \frac{p(x,h)}{q(h|x)} \right]
$$</p>
<p>A <strong>multi-sample VAE</strong> simply gets a better estimate of this same value:</p>
<p>$$
\mathcal{L} _{\text{VAE}, k}(x) \approx  \frac{1}{k} \sum _{i=1}^{k} \log w_i \quad \text{where} \quad w_i = \frac{p(x,h_i)}{q(h_i|x)}
$$</p>
<p>The <strong>IWAE</strong> objective, $\mathcal{L}_k$, is fundamentally different:</p>
<p>$$
\mathcal{L} _k (x) = \mathbb{E} _{h_1..h_k \sim q(h|x)} \left[ \log \left( \frac{1}{k} \sum _{i=1}^{k} \frac{p(x,h_i)}{q(h_i|x)} \right) \right]
$$</p>
<p>In practice, we estimate this with a single Monte Carlo sample (of $k$ latents):</p>
<p>$$
\mathcal{L} _k (x) \approx \log \left( \frac{1}{k} \sum _{i=1}^{k} w_i \right)
$$</p>
<p>Because the logarithm is a concave function, Jensen&rsquo;s Inequality tells us that the &ldquo;log of an average&rdquo; is <em>always</em> greater than or equal to the &ldquo;average of logs.&rdquo;</p>
<p>$$
\mathcal{L}_k(x) \ge \mathcal{L}_1(x)
$$</p>
<p>This means the IWAE is optimizing a <strong>strictly tighter lower bound</strong> on the true log-likelihood of the data.</p>
<h2 id="why-does-this-log-of-average-matter">Why Does This &ldquo;Log-of-Average&rdquo; Matter?</h2>
<p>This mathematical property provides two practical benefits.</p>
<h3 id="1-better-density-estimation">1. Better Density Estimation</h3>
<p>Because $\mathcal{L}_k$ is a tighter bound on the true $p(x)$, optimizing it pushes the model to learn a much better generative distribution. The paper shows that IWAEs achieve &ldquo;significantly higher log-likelihoods&rdquo; than VAEs.</p>
<h3 id="2-richer-latent-representations">2. Richer Latent Representations</h3>
<p>This is the most interesting part. The standard VAE $\mathcal{L}_1$ objective &ldquo;harshly penalizes&rdquo; the model if its <em>one</em> sample $h$ is a poor explanation for $x$. This pressure forces the recognition network $q(h|x)$ to be &ldquo;overly simplified&rdquo; to avoid bad samples, which can lead to many latent dimensions becoming inactive (the paper reports the number of &ldquo;active units&rdquo; per model).</p>
<p>The IWAE objective is more flexible. It only needs <em>one</em> of the $k$ samples to be good. This &ldquo;increased flexibility&rdquo; allows the model to learn far more complex posterior distributions and &ldquo;richer latent space representations.&rdquo; The paper&rsquo;s experiments confirm this, showing IWAEs learn to use many more &ldquo;active units&rdquo; in their latent space.</p>
<h2 id="what-this-looks-like-in-code-pytorch">What This Looks Like in Code (PyTorch)</h2>
<p>The implementation difference makes this crystal clear.</p>
<p>First, the &ldquo;k-sample&rdquo; trick: for a batch <code>x</code> of shape <code>[B, D]</code> and <code>k=5</code> samples, we repeat <code>x</code> to get <code>x_repeated</code> of shape <code>[B*k, D]</code>. We do all our forward passes on this large tensor.</p>
<h3 id="vae-multi-sample-k--1-loss">VAE (Multi-Sample, k &gt; 1) Loss</h3>
<p>Here, we can still use the analytical KL divergence, which is a big simplification.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># x_repeated has shape [B*k, 784]</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># mu, logvar have shape [B*k, latent_dim]</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># recon_x has shape [B*k, 784]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># recon_loss_all shape: [B*k]</span>
</span></span><span style="display:flex;"><span>recon_loss_all <span style="color:#f92672">=</span> F<span style="color:#f92672">.</span>binary_cross_entropy(recon_x, x_repeated, reduction<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;none&#39;</span>)<span style="color:#f92672">.</span>sum(dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># kl_loss_all shape: [B*k]</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># We use the simple, analytical KL term!</span>
</span></span><span style="display:flex;"><span>kl_loss_all <span style="color:#f92672">=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>sum(<span style="color:#ae81ff">1</span> <span style="color:#f92672">+</span> logvar <span style="color:#f92672">-</span> mu<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">-</span> logvar<span style="color:#f92672">.</span>exp(), dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># total_loss_all shape: [B*k]</span>
</span></span><span style="display:flex;"><span>total_loss_all <span style="color:#f92672">=</span> recon_loss_all <span style="color:#f92672">+</span> kl_loss_all
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># --- The Key Step ---</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Just average all B*k losses. This is the &#34;average of logs&#34;.</span>
</span></span><span style="display:flex;"><span>loss <span style="color:#f92672">=</span> total_loss_all<span style="color:#f92672">.</span>mean()
</span></span></code></pre></div><h3 id="iwae-k--1-loss">IWAE (k &gt; 1) Loss</h3>
<p>Here, we must compute the exact log-probabilities of the <em>specific samples</em> we drew.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Helper function to compute log-prob of a sample from a Gaussian</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">log_prob_gaussian</span>(sample, mu, logvar):
</span></span><span style="display:flex;"><span>    const <span style="color:#f92672">=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> sample<span style="color:#f92672">.</span>shape[<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>] <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>log(<span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>tensor(math<span style="color:#f92672">.</span>pi))
</span></span><span style="display:flex;"><span>    log_det <span style="color:#f92672">=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>sum(logvar, dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    log_exp <span style="color:#f92672">=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>sum((sample <span style="color:#f92672">-</span> mu)<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span> <span style="color:#f92672">/</span> torch<span style="color:#f92672">.</span>exp(logvar), dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> const <span style="color:#f92672">+</span> log_det <span style="color:#f92672">+</span> log_exp
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># --- Get the 3 log-prob components ---</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># x_repeated, recon_x, z_samples, mu_repeated, logvar_repeated</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># all have a first dimension of [B*k]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 1. log p(x|h_i): Log-Reconstruction Probability</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># log_p_x_given_h shape: [B*k]</span>
</span></span><span style="display:flex;"><span>log_p_x_given_h <span style="color:#f92672">=</span> <span style="color:#f92672">-</span>F<span style="color:#f92672">.</span>binary_cross_entropy(recon_x, x_repeated, reduction<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;none&#39;</span>)<span style="color:#f92672">.</span>sum(dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 2. log p(h_i): Log-Prior Probability (under N(0, I))</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># log_p_h shape: [B*k]</span>
</span></span><span style="display:flex;"><span>log_p_h <span style="color:#f92672">=</span> log_prob_gaussian(z_samples, <span style="color:#ae81ff">0.0</span>, <span style="color:#ae81ff">0.0</span>) <span style="color:#75715e"># mu=0, logvar=0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 3. log q(h_i|x): Log-Encoder Probability</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># log_q_h_given_x shape: [B*k]</span>
</span></span><span style="display:flex;"><span>log_q_h_given_x <span style="color:#f92672">=</span> log_prob_gaussian(z_samples, mu_repeated, logvar_repeated)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># --- The Key Step ---</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Combine to get the log-importance-weight</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># log_w shape: [B*k]</span>
</span></span><span style="display:flex;"><span>log_w <span style="color:#f92672">=</span> log_p_x_given_h <span style="color:#f92672">+</span> log_p_h <span style="color:#f92672">-</span> log_q_h_given_x
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Reshape to [B, k] to group samples by their original input</span>
</span></span><span style="display:flex;"><span>log_w_matrix <span style="color:#f92672">=</span> log_w<span style="color:#f92672">.</span>view(B, k) <span style="color:#75715e"># B is original batch size</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># --- Apply the IWAE Objective (Log-Sum-Exp Trick) ---</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># This is the &#34;log of the average&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># log( (1/k) * sum(exp(log_w)) ) = logsumexp(log_w) - log(k)</span>
</span></span><span style="display:flex;"><span>log_iwae_bound_per_x <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>logsumexp(log_w_matrix, dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> math<span style="color:#f92672">.</span>log(k)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># The objective is to MAXIMIZE this bound, so the loss is its negative</span>
</span></span><span style="display:flex;"><span>loss <span style="color:#f92672">=</span> <span style="color:#f92672">-</span>log_iwae_bound_per_x<span style="color:#f92672">.</span>mean()
</span></span></code></pre></div><h3 id="the-critical-implementation-detail">The Critical Implementation Detail</h3>
<p>Notice the key difference in the final step:</p>
<ul>
<li><strong>VAE</strong>: <code>loss = total_loss_all.mean()</code> average of individual losses</li>
<li><strong>IWAE</strong>: <code>loss = -torch.logsumexp(log_w_matrix, dim=1).mean()</code> log of averaged weights</li>
</ul>
<p>This seemingly small change implements the fundamental mathematical difference between optimizing an &ldquo;average of logs&rdquo; versus a &ldquo;log of averages.&rdquo;</p>
<h2 id="when-to-use-each-approach">When to Use Each Approach</h2>
<table>
	<thead>
			<tr>
					<th>Model</th>
					<th>When to Use</th>
					<th>Key Benefit</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>VAE ($k=1$)</strong></td>
					<td>Your <strong>default baseline</strong>. It&rsquo;s fast, simple, and often &ldquo;good enough&rdquo; for many tasks.</td>
					<td>Speed and simplicity.</td>
			</tr>
			<tr>
					<td><strong>Multi-Sample VAE ($k&gt;1$)</strong></td>
					<td>When you want slightly more stable gradients but aren&rsquo;t ready for the full IWAE complexity.</td>
					<td>Marginal improvement with minimal code changes.</td>
			</tr>
			<tr>
					<td><strong>IWAE ($k&gt;1$)</strong></td>
					<td>When your baseline VAE is <strong>insufficient</strong>. Specifically, if you need:<br>1. The best possible log-likelihood.<br>2. To activate more latent dimensions or learn richer representations.</td>
					<td>Better performance and richer latents, at the cost of compute (scales linearly with $k$).</td>
			</tr>
	</tbody>
</table>
<h2 id="the-computational-trade-off">The Computational Trade-off</h2>
<p>Both approaches scale linearly with $k$. If you use $k=5$ samples, you&rsquo;re doing roughly 5x the computation. The question is whether you get 5x the benefit.</p>
<p>For multi-sample VAEs, the answer is usually &ldquo;no&rdquo;. You get more stable gradients but only marginal performance improvements.</p>
<p>For IWAEs, the answer is often &ldquo;yes&rdquo;. You get meaningfully better log-likelihoods and richer latent representations that can be worth the computational cost.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The next time you use more samples with your VAE, switch to the IWAE objective to get the full benefit of the computational cost of $k &gt; 1$.</p>
<p>The mathematical insight is simple but powerful: Jensen&rsquo;s Inequality tells us that the &ldquo;log of an average&rdquo; is always greater than or equal to the &ldquo;average of logs.&rdquo; By optimizing this tighter bound, IWAEs achieve better density estimation and learn richer latent representations than standard VAEs.</p>
<p>The implementation requires computing exact log-probabilities to evaluate the specific samples. The result is a fundamentally more powerful model using the exact same architecture.</p>
<p><strong>Want to dive deeper?</strong> Check out the <a href="https://arxiv.org/abs/1509.00519">original IWAE paper</a> for experimental results and theoretical analysis, or explore my <a href="/posts/modern-variational-autoencoder-in-pytorch/">VAE tutorial</a> for hands-on implementation details.</p>
]]></content:encoded></item><item><title>What is Optical Chemical Structure Recognition (OCSR)?</title><link>https://hunterheidenreich.com/posts/what-is-ocsr/</link><pubDate>Sat, 11 Oct 2025 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/what-is-ocsr/</guid><description>A micro-review of Optical Chemical Structure Recognition (OCSR), covering rule-based systems to modern deep learning models.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Decades of chemical research, breakthroughs in medicine, and novel materials are archived in journals, patents, and textbooks.
A huge portion of this knowledge is stored as images, a format inaccessible to standard computational tools.
This imposes challenges for both data retrieval and leveraging modern computational tools to analyze and predict chemical properties, inefficiencies that compound across the literature: knowledge locked in image form is invisible to search, mining, and downstream model training.</p>
<p>This is the central challenge that <strong>Optical Chemical Structure Recognition (OCSR)</strong> aims to solve. At its heart, OCSR is to chemistry what OCR (Optical Character Recognition) is to text: a technology that teaches computers to extract chemical information directly from 2D diagrams of molecules. It&rsquo;s the bridge between a picture of a molecule and a machine-readable format like <a href="/notes/chemistry/molecular-representations/notations/smiles/">SMILES</a> (Simplified Molecular Input Line Entry System) that can be stored, searched, and used to power new discoveries.</p>















<figure class="post-figure center ">
    <img src="/img/ocsr/img2smiles.webp"
         alt="The transformation from a 2D chemical structure image to a SMILES representation."
         title="The transformation from a 2D chemical structure image to a SMILES representation."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The transformation from a 2D chemical structure image to a SMILES representation.</figcaption>
    
</figure>

<p>Teaching a computer to read a chemical structure requires specialized techniques.</p>
<h2 id="the-complexity-of-chemical-graphs">The Complexity of Chemical Graphs</h2>
<p>Recognizing a molecule requires specialized techniques that extend standard Optical Character Recognition (OCR). A molecule is a <em>graph</em>: a collection of atoms (nodes) connected by bonds (edges).</p>
<blockquote>
<p>(While this simplified view excludes complex structures like coordination compounds and polymers, it provides a highly effective starting point for this discussion.)</p>
</blockquote>
<p>An OCSR system must overcome several hurdles:</p>
<ul>
<li><strong>Varying Styles:</strong> Chemical drawings vary widely across publications. Bond lengths, angles, and fonts can differ dramatically from one document to another.</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/ocsr/acs.orglett.2c02187_1.webp"
         alt="An example from the Colored Background OSCR Benchmark, showing a complex and colorful chemical structure."
         title="An example from the Colored Background OSCR Benchmark, showing a complex and colorful chemical structure."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">An example from the <a href="https://huggingface.co/datasets/hheiden/Colored_Background_OCSR_benchmark">Colored Background OSCR Benchmark</a>, showing a complex and colorful chemical structure.</figcaption>
    
</figure>

<ul>
<li><strong>Image Quality:</strong> Older documents might be scanned at low resolutions, containing noise, blur, or other artifacts that make interpretation difficult.</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/ocsr/2008239616_449_chem.webp"
         alt="A challenging chemical structure image from the JPO benchmark, difficult due to its low quality."
         title="A challenging chemical structure image from the JPO benchmark, difficult due to its low quality."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">A challenging chemical structure image from the <a href="https://huggingface.co/datasets/hheiden/JPO_OCSR_benchmark">JPO benchmark</a>, difficult due to its low quality.</figcaption>
    
</figure>

<ul>
<li><strong>Structural Complexity:</strong> From simple rings to sprawling polymers and complex <strong>Markush structures</strong> (common in patents to represent a whole family of related compounds), the variety is immense.</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/ocsr/markush.webp"
         alt="An example of a Markush structure, illustrating the complexity and variety of chemical compounds."
         title="An example of a Markush structure, illustrating the complexity and variety of chemical compounds."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">An example of a Markush structure, illustrating the complexity and variety of chemical compounds.</figcaption>
    
</figure>

<h2 id="the-evolution-of-ocsr">The Evolution of OCSR</h2>
<p>The quest to automate this process has evolved significantly, moving from brittle, hand-coded systems to sophisticated AI that can learn from data.</p>
<h3 id="act-1-the-rule-based-pioneers-ocr-10">Act 1: The Rule-Based Pioneers (OCR-1.0)</h3>
<p>The first OCSR systems, developed in the early 1990s, represent what we can now call the <strong>&ldquo;OCR-1.0&rdquo; era</strong>. Tools like <a href="https://pubs.acs.org/doi/10.1021/ci00008a018">Kekulé</a>, and later open-source solutions like <a href="/notes/chemistry/optical-structure-recognition/rule-based/osra/">OSRA</a> and <a href="https://github.com/ncats/molvec">MolVec</a>, operated like meticulous draftsmen. Their approach was methodical:</p>
<ol>
<li><strong>Vectorize the Image:</strong> Convert the pixel-based image into a collection of lines and shapes</li>
<li><strong>Identify Components:</strong> Use a set of hard-coded rules to classify these components. &ldquo;This thick line is a wedge bond.&rdquo; &ldquo;This group of pixels is the letter &lsquo;O&rsquo;.&rdquo;</li>
<li><strong>Reconstruct the Graph:</strong> Piece together the identified atoms and bonds into a coherent molecular graph</li>
</ol>
<p>This rule-based approach was a real first step but brittle. It struggled with the messiness of real-world documents and was expensive to maintain because each new style or error required new rules.</p>
<p>Additionally, they were designed as interactive tools to assist human experts in digitizing chemical structures.
There was always the assumption that a human would review and correct the output.</p>
<p>As a concrete case-study, consider the (reproduced) results from <a href="https://arxiv.org/abs/2411.11098">MolParser</a>:</p>
<table>
	<thead>
			<tr>
					<th style="text-align: left">Method</th>
					<th style="text-align: center">USPTO</th>
					<th style="text-align: center">UoB</th>
					<th style="text-align: center">CLEF</th>
					<th style="text-align: center">JPO</th>
					<th style="text-align: center">ColoredBG</th>
					<th style="text-align: center">USPTO-10K</th>
					<th style="text-align: center">WildMol-10K</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: left"><strong>Rule-based methods</strong></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
			</tr>
			<tr>
					<td style="text-align: left">OSRA 2.1 *</td>
					<td style="text-align: center">89.3</td>
					<td style="text-align: center">86.3</td>
					<td style="text-align: center"><strong>93.4</strong></td>
					<td style="text-align: center">56.3</td>
					<td style="text-align: center">5.5</td>
					<td style="text-align: center">89.7</td>
					<td style="text-align: center">26.3</td>
			</tr>
			<tr>
					<td style="text-align: left">MolVec 0.9.7 *</td>
					<td style="text-align: center">91.6</td>
					<td style="text-align: center">79.7</td>
					<td style="text-align: center">81.2</td>
					<td style="text-align: center">66.8</td>
					<td style="text-align: center">8.0</td>
					<td style="text-align: center">92.4</td>
					<td style="text-align: center">26.4</td>
			</tr>
			<tr>
					<td style="text-align: left">Imago 2.0 *</td>
					<td style="text-align: center">89.4</td>
					<td style="text-align: center">63.9</td>
					<td style="text-align: center">68.2</td>
					<td style="text-align: center">41.0</td>
					<td style="text-align: center">2.0</td>
					<td style="text-align: center">89.9</td>
					<td style="text-align: center">6.9</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Only synthetic training</strong></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
			</tr>
			<tr>
					<td style="text-align: left">Img2Mol *</td>
					<td style="text-align: center">30.0</td>
					<td style="text-align: center">68.1</td>
					<td style="text-align: center">17.9</td>
					<td style="text-align: center">16.1</td>
					<td style="text-align: center">3.5</td>
					<td style="text-align: center">33.7</td>
					<td style="text-align: center">24.4</td>
			</tr>
			<tr>
					<td style="text-align: left">MolGrapher †*</td>
					<td style="text-align: center">91.5</td>
					<td style="text-align: center"><strong>94.9</strong></td>
					<td style="text-align: center">90.5</td>
					<td style="text-align: center">67.5</td>
					<td style="text-align: center">7.5</td>
					<td style="text-align: center">93.3</td>
					<td style="text-align: center">45.5</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Real data finetuning</strong></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
					<td style="text-align: center"></td>
			</tr>
			<tr>
					<td style="text-align: left">DECIMER 2.7 *</td>
					<td style="text-align: center">59.9</td>
					<td style="text-align: center">88.3</td>
					<td style="text-align: center">72.0</td>
					<td style="text-align: center">64.0</td>
					<td style="text-align: center">14.5</td>
					<td style="text-align: center">82.4</td>
					<td style="text-align: center">56.0</td>
			</tr>
			<tr>
					<td style="text-align: left">MolScribe *</td>
					<td style="text-align: center"><u>93.1</u></td>
					<td style="text-align: center">87.4</td>
					<td style="text-align: center">88.9</td>
					<td style="text-align: center">76.2</td>
					<td style="text-align: center">21.0</td>
					<td style="text-align: center"><strong>96.0</strong></td>
					<td style="text-align: center">66.4</td>
			</tr>
			<tr>
					<td style="text-align: left">MolParser-Tiny (Ours)</td>
					<td style="text-align: center">93.0</td>
					<td style="text-align: center">91.6</td>
					<td style="text-align: center"><u>91.0</u></td>
					<td style="text-align: center">75.6</td>
					<td style="text-align: center"><strong>58.5</strong></td>
					<td style="text-align: center">89.5</td>
					<td style="text-align: center">73.1</td>
			</tr>
			<tr>
					<td style="text-align: left">MolParser-Small (Ours)</td>
					<td style="text-align: center"><strong>93.1</strong></td>
					<td style="text-align: center">91.1</td>
					<td style="text-align: center">90.8</td>
					<td style="text-align: center">76.2</td>
					<td style="text-align: center">57.0</td>
					<td style="text-align: center"><u>94.8</u></td>
					<td style="text-align: center">76.3</td>
			</tr>
			<tr>
					<td style="text-align: left">MolParser-Base (Ours)</td>
					<td style="text-align: center">93.0</td>
					<td style="text-align: center"><u>91.8</u></td>
					<td style="text-align: center">90.7</td>
					<td style="text-align: center"><strong>78.9</strong></td>
					<td style="text-align: center">57.0</td>
					<td style="text-align: center">94.5</td>
					<td style="text-align: center"><strong>76.9</strong></td>
			</tr>
	</tbody>
</table>
<blockquote>
<p><strong>Table 2. Comparison of our method with existing OCSR models.</strong> We report the accuracy. We use <strong>bold</strong> to indicate the best performance and <u>underline</u> to denote the second-best performance. *: re-implemented results. †: results from original publications.</p>
</blockquote>
<p>In this table, we see that the rule-based methods (OSRA, MolVec, Imago) perform reasonably well on cleaner datasets like USPTO and UoB but falter on more challenging ones like JPO and ColoredBG. Modern AI-based methods (MolGrapher, DECIMER, MolScribe, MolParser) improve most on the hardest benchmarks (like JPO and ColoredBG), especially when fine-tuned on real data, while the rule-based tools still do reasonably well on cleaner sets like USPTO and UoB.</p>
<h3 id="act-2-the-ai-fork-in-the-road-2010s-2020s">Act 2: The AI Fork in the Road (2010s-2020s)</h3>
<p>The rise of deep learning in the 2010s brought new paradigms that could learn from data. Here, the field split into two distinct paths.</p>
<h4 id="path-a-the-rise-of-the-specialists-graph-based-ai">Path A: The Rise of the Specialists (Graph-Based AI)</h4>
<p>Some models replaced the hard-coded rules with AI components. Systems like <a href="https://github.com/DS4SD/MolGrapher">MolGrapher</a> and <a href="https://github.com/thomas0809/MolScribe">MolScribe</a> use a two-stage process:</p>
<ul>
<li><strong>Atom Detection:</strong> A neural network first identifies all the atoms in the image</li>
<li><strong>Bond Prediction:</strong> A second process then predicts the connections (bonds) between those atoms to form the final graph</li>
</ul>
<p>These are highly specialized tools, trained specifically for the task of building a molecular graph.</p>
<h4 id="path-b-the-rise-of-the-generalists-lvlms">Path B: The Rise of the Generalists (LVLMs)</h4>
<p>Another, more direct method treats OCSR as an image captioning task. This approach aligns with the broader trend of <strong>Large Vision-Language Models (LVLMs)</strong>: massive, general-purpose AIs like GPT-4V. Models like <a href="https://github.com/Kohulan/DECIMER-Image_Transformer">DECIMER</a> and <a href="/notes/chemistry/optical-structure-recognition/vision-language/mol-parser/">MolParser</a> look at a molecular image and directly generate its textual representation, most commonly a <a href="/notes/chemistry/molecular-representations/notations/smiles/">SMILES string</a>. This direct, end-to-end approach is powerful, though it requires enormous datasets to train effectively.</p>
<h2 id="the-next-frontier-the-ocr-20-vision-2024">The Next Frontier: The OCR-2.0 Vision (2024+)</h2>
<p>Recently, a proposal has emerged that charts a third path forward: <strong>OCR-2.0</strong>. This vision, proposed by <a href="https://arxiv.org/abs/2409.01704">Wei et al.</a> in 2024, argues for a new class of models that combine the best of both worlds. An OCR-2.0 model should be:</p>
<ol>
<li><strong>End-to-End:</strong> A single, unified model that simplifies maintenance</li>
<li><strong>Efficient &amp; Low-Cost:</strong> A specialized, highly efficient perception engine. The paper argues that using a giant LVLM for a pure recognition task is often inefficient</li>
<li><strong>Versatile:</strong> Capable of handling diverse artificial optical signals</li>
</ol>
<p>The flagship model for this theory is <a href="https://huggingface.co/stepfun-ai/GOT-OCR2_0">GOT (General OCR Theory)</a>. It&rsquo;s a single, unified model that can read an image and output structured text for a wide variety of inputs. It can translate a molecular diagram into a SMILES string, transcribe sheet music into musical notation, parse a bar chart into a data table, and describe a geometric shape using code.</p>
<p>This demonstrates that OCSR can be integrated into broader systems for processing human visual information. The same OCR-2.0 philosophy extends beyond chemistry: <a href="/research/gutenocr-grounded-vision-language-frontend/">GutenOCR</a>, for instance, applies grounded vision-language modeling to general document OCR, producing both text transcriptions and bounding-box outputs from a single model.</p>
<h2 id="pushing-the-boundaries-of-recognition">Pushing the Boundaries of Recognition</h2>
<p>OCR-2.0 models like GOT push for <em>breadth</em>, and other state-of-the-art research deepens the <em>depth</em> of understanding for the uniquely complex task of chemical recognition.</p>
<h3 id="deepening-reasoning-with-a-visual-chain-of-thought">Deepening Reasoning with a &ldquo;Visual Chain of Thought&rdquo;</h3>
<p>The <a href="https://arxiv.org/abs/2506.07553">GTR-Mol-VLM</a> model makes recognition more intelligent by mimicking how a person might analyze a complex diagram. The model traverses the molecule step-by-step, predicting an atom, then its bond, then the next atom, and so on. This &ldquo;Visual Chain of Thought&rdquo; improves accuracy, especially for complex molecules. It also faithfully recognizes abbreviations like &ldquo;Ph&rdquo; as single units, better representing the source image.</p>
<h3 id="deepening-application-with-visual-fingerprinting">Deepening Application with &ldquo;Visual Fingerprinting&rdquo;</h3>
<p><a href="https://link.springer.com/article/10.1186/s13321-025-01091-4">Subgrapher</a> rethinks the end goal. Many applications (like searching a patent database) require only the identification of specific molecular features. Subgrapher detects key functional groups and backbones directly from the image and creates a visual fingerprint. This approach mirrors identifying a person by key features (&ldquo;has glasses, a mustache&rdquo;), making it well-suited to finding matches in a large set.</p>
<h2 id="why-it-matters">Why It Matters</h2>
<p>The evolution of OCSR directly enables practical scientific advancements. This technology is a critical enabler for the future of science.</p>
<h3 id="searching-past-knowledge">Searching Past Knowledge</h3>
<p>OCSR digitizes decades of research from patents and journals, making it searchable and accessible for data mining. Imagine being able to search through every molecule ever published with a simple query. Or consider the practical impact: pharmaceutical companies can now automatically scan thousands of patent documents to ensure their new drug candidates don&rsquo;t infringe existing intellectual property, a process that previously required substantial manual review by patent analysts.</p>
<h3 id="accelerating-drug-discovery">Accelerating Drug Discovery</h3>
<p>By extracting vast datasets of molecules, scientists can train AI models to predict drug efficacy and toxicity, speeding up the discovery pipeline. The more molecular data we can digitize, the better our predictive models become.</p>
<h3 id="building-universal-document-intelligence">Building Universal Document Intelligence</h3>
<p>OCSR contributes to building AI systems capable of processing complex human documents. A scientific paper is a mix of text, equations, charts, tables, and molecular diagrams. Unified OCR-2.0 models are the key to making all of this knowledge searchable holistically.</p>
<h2 id="looking-forward">Looking Forward</h2>
<p>The goal is a loop where scientific knowledge, regardless of how it is stored, can be fed back into systems that read, search, and reason over it.</p>
<p>From the rule-based systems of the 1990s to today&rsquo;s models that read many printed diagrams reliably (though hard cases like low-quality scans and Markush structures remain open), OCSR has improved a great deal. As accuracy, efficiency, and breadth improve, more of the chemical literature becomes machine-readable.</p>
<p>This entire process begins with teaching a computer how to read a picture.</p>
]]></content:encoded></item><item><title>Converting SMILES and SELFIES to 2D Molecular Images</title><link>https://hunterheidenreich.com/posts/visualizing-smiles-and-selfies-strings/</link><pubDate>Fri, 12 Sep 2025 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/visualizing-smiles-and-selfies-strings/</guid><description>A guide to generating 2D molecular structure images from SMILES and SELFIES strings using Python, RDKit, and PIL.</description><content:encoded><![CDATA[<p>Lately, I&rsquo;ve spent a lot of time staring at datasets full of 1D molecular strings.
With time, I find I get better at recognizing functional groups and substructures like <code>C(=O)O</code> (carboxylic acid) or <code>c1ccccc1</code> (benzene ring) in SMILES.
However, anything really complex is beyond my personal visualization capabilities.</p>
<p>I ran into this recently while debugging a generative model.
Sometimes the grammar of the string provides the clue as to what is going wrong.
Other times, actually <em>seeing</em> the molecule is what helps.
I had a terminal full of generated strings and needed to verify their structures visually.
I needed a streamlined way to generate these images locally.
A lightweight script turns that text into a properly formatted image directly from the terminal.</p>
<h2 id="smiles-vs-selfies">SMILES vs. SELFIES</h2>
<p>There are two primary string representations you will encounter in modern cheminformatics:</p>
<ol>
<li><strong><a href="/notes/chemistry/molecular-representations/notations/smiles/">SMILES</a></strong>: The industry standard. It uses simple rules (<code>C</code> for carbon, <code>=</code> for double bonds, parentheses for branches). It is compact and machine-parseable. However, random SMILES strings are often invalid (e.g., unclosed rings or invalid valences).</li>
<li><strong><a href="/notes/chemistry/molecular-representations/notations/selfies/">SELFIES</a></strong>: Designed specifically for machine learning. It is a robust representation where <em>every</em> string corresponds to a valid molecular graph. This makes it ideal for generative models. Note that it is more verbose than SMILES.</li>
</ol>
<p>I often need to visualize both formats. Let&rsquo;s build a single, robust Python tool to handle them.</p>
<h2 id="the-quick-win-native-rdkit">The Quick Win: Native RDKit</h2>
<p>If you just need a quick image from a SMILES string and don&rsquo;t care about the image dimensions or adding a legend, RDKit can do this in three lines:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit <span style="color:#f92672">import</span> Chem
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit.Chem <span style="color:#f92672">import</span> Draw
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>mol <span style="color:#f92672">=</span> Chem<span style="color:#f92672">.</span>MolFromSmiles(<span style="color:#e6db74">&#34;CCO&#34;</span>)
</span></span><span style="display:flex;"><span>Draw<span style="color:#f92672">.</span>MolToFile(mol, <span style="color:#e6db74">&#34;ethanol.png&#34;</span>)
</span></span></code></pre></div><p>The native RDKit method is fast for quick checks. However, custom rendering provides necessary control over image dimensions, formula subscripts, and handling multiple input formats like SELFIES.</p>
<h2 id="building-a-custom-renderer-for-precise-control">Building a Custom Renderer for Precise Control</h2>
<p>Let&rsquo;s build a fuller tool using RDKit for chemical processing, the <code>selfies</code> library for decoding, and PIL for image manipulation.</p>
<h3 id="core-dependencies">Core Dependencies</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> selfies <span style="color:#66d9ef">as</span> sf
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit <span style="color:#f92672">import</span> Chem
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit.Chem <span style="color:#f92672">import</span> Draw, rdDepictor, rdMolDescriptors
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> PIL <span style="color:#f92672">import</span> Image, ImageDraw, ImageFont
</span></span></code></pre></div><p>RDKit handles the chemical logic, <code>selfies</code> translates SELFIES to SMILES, and PIL gives us fine control over the final image appearance.</p>
<h3 id="the-main-conversion-function">The Main Conversion Function</h3>
<p>Here is the core conversion logic. Notice the Python type hints on the signature.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">string_to_png</span>(mol_string: str, output_file: str, size: int <span style="color:#f92672">=</span> <span style="color:#ae81ff">500</span>, is_selfies: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>) <span style="color:#f92672">-&gt;</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Generates a 2D molecule image with a chemical formula legend from SMILES or SELFIES.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Decode SELFIES to SMILES if necessary</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> is_selfies:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>            smiles <span style="color:#f92672">=</span> sf<span style="color:#f92672">.</span>decoder(mol_string)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">Exception</span> <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Invalid SELFIES string: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>) <span style="color:#f92672">from</span> e
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>        smiles <span style="color:#f92672">=</span> mol_string
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    mol <span style="color:#f92672">=</span> Chem<span style="color:#f92672">.</span>MolFromSmiles(smiles)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> mol:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Could not generate molecule from SMILES: </span><span style="color:#e6db74">{</span>smiles<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Generate 2D coordinates and formula</span>
</span></span><span style="display:flex;"><span>    rdDepictor<span style="color:#f92672">.</span>Compute2DCoords(mol)
</span></span><span style="display:flex;"><span>    formula <span style="color:#f92672">=</span> rdMolDescriptors<span style="color:#f92672">.</span>CalcMolFormula(mol)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Render the molecule</span>
</span></span><span style="display:flex;"><span>    img <span style="color:#f92672">=</span> Draw<span style="color:#f92672">.</span>MolToImage(mol, size<span style="color:#f92672">=</span>(size, size))<span style="color:#f92672">.</span>convert(<span style="color:#e6db74">&#34;RGBA&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Create a canvas with extra space at the bottom for the legend</span>
</span></span><span style="display:flex;"><span>    legend_height <span style="color:#f92672">=</span> int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.1</span>)
</span></span><span style="display:flex;"><span>    canvas <span style="color:#f92672">=</span> Image<span style="color:#f92672">.</span>new(<span style="color:#e6db74">&#34;RGBA&#34;</span>, (size, size <span style="color:#f92672">+</span> legend_height), <span style="color:#e6db74">&#34;white&#34;</span>)
</span></span><span style="display:flex;"><span>    canvas<span style="color:#f92672">.</span>paste(img, (<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    draw <span style="color:#f92672">=</span> ImageDraw<span style="color:#f92672">.</span>Draw(canvas)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Define dynamic font sizes</span>
</span></span><span style="display:flex;"><span>    font_reg <span style="color:#f92672">=</span> get_font(int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.03</span>))
</span></span><span style="display:flex;"><span>    font_sub <span style="color:#f92672">=</span> get_font(int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.02</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Draw the legend</span>
</span></span><span style="display:flex;"><span>    x <span style="color:#f92672">=</span> int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.02</span>)
</span></span><span style="display:flex;"><span>    y <span style="color:#f92672">=</span> size <span style="color:#f92672">+</span> int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.02</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Draw &#34;Formula: &#34; label</span>
</span></span><span style="display:flex;"><span>    draw<span style="color:#f92672">.</span>text((x, y), <span style="color:#e6db74">&#34;Formula: &#34;</span>, fill<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;black&#34;</span>, font<span style="color:#f92672">=</span>font_reg)
</span></span><span style="display:flex;"><span>    x <span style="color:#f92672">+=</span> draw<span style="color:#f92672">.</span>textlength(<span style="color:#e6db74">&#34;Formula: &#34;</span>, font<span style="color:#f92672">=</span>font_reg)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Draw formula with subscript handling for numbers</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> char <span style="color:#f92672">in</span> formula:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Use smaller font and lower y-offset for numbers (subscripts)</span>
</span></span><span style="display:flex;"><span>        font <span style="color:#f92672">=</span> font_sub <span style="color:#66d9ef">if</span> char<span style="color:#f92672">.</span>isdigit() <span style="color:#66d9ef">else</span> font_reg
</span></span><span style="display:flex;"><span>        y_offset <span style="color:#f92672">=</span> int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.005</span>) <span style="color:#66d9ef">if</span> char<span style="color:#f92672">.</span>isdigit() <span style="color:#66d9ef">else</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        draw<span style="color:#f92672">.</span>text((x, y <span style="color:#f92672">+</span> y_offset), char, fill<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;black&#34;</span>, font<span style="color:#f92672">=</span>font)
</span></span><span style="display:flex;"><span>        x <span style="color:#f92672">+=</span> draw<span style="color:#f92672">.</span>textlength(char, font<span style="color:#f92672">=</span>font)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Draw original string</span>
</span></span><span style="display:flex;"><span>    label <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;SELFIES&#34;</span> <span style="color:#66d9ef">if</span> is_selfies <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;SMILES&#34;</span>
</span></span><span style="display:flex;"><span>    draw<span style="color:#f92672">.</span>text((x, y), <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34; | </span><span style="color:#e6db74">{</span>label<span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>, fill<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;black&#34;</span>, font<span style="color:#f92672">=</span>font_reg)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    canvas<span style="color:#f92672">.</span>save(output_file)
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Saved: </span><span style="color:#e6db74">{</span>output_file<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span></code></pre></div><p>This function handles everything: SELFIES decoding, validation, coordinate generation, image creation, and legend drawing.</p>
<h3 id="font-handling">Font Handling</h3>
<p>We need a helper to handle fonts robustly across systems:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">get_font</span>(size: int, font_name: str <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;arial.ttf&#34;</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Attempts to load a TTF font, falls back to default if unavailable.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> ImageFont<span style="color:#f92672">.</span>truetype(font_name, size)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">IOError</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> ImageFont<span style="color:#f92672">.</span>load_default()
</span></span></code></pre></div><h2 id="examples-in-action">Examples in Action</h2>
<p>Let&rsquo;s see the tool in action with some common molecules, comparing the SMILES and SELFIES inputs.</p>
<h3 id="simple-molecules">Simple Molecules</h3>















<figure class="post-figure center ">
    <img src="/img/smiles2img/ethanol_demo.webp"
         alt="Ethanol molecular structure with formula C2H6O"
         title="Ethanol molecular structure with formula C2H6O"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Ethanol</strong>: A simple alcohol. The SMILES is <code>CCO</code>, while the SELFIES is <code>[C][C][O]</code>.</figcaption>
    
</figure>

<h3 id="aromatic-compounds">Aromatic Compounds</h3>















<figure class="post-figure center ">
    <img src="/img/smiles2img/benzene_demo.webp"
         alt="Benzene molecular structure with formula C6H6"
         title="Benzene molecular structure with formula C6H6"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Benzene</strong>: The classic aromatic ring. SMILES uses numbers for ring closures (<code>C1=CC=CC=C1</code>), while SELFIES uses explicit tokens (<code>[C][=C][C][=C][C][=C][Ring1][=Branch1]</code>).</figcaption>
    
</figure>

<h3 id="complex-pharmaceuticals">Complex Pharmaceuticals</h3>















<figure class="post-figure center ">
    <img src="/img/smiles2img/aspirin_demo.webp"
         alt="Aspirin molecular structure with formula C9H8O4"
         title="Aspirin molecular structure with formula C9H8O4"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Aspirin</strong>: A more complex molecule showing how the tool handles branched structures and multiple functional groups.</figcaption>
    
</figure>

<h2 id="going-further-vector-graphics-svg">Going Further: Vector Graphics (SVG)</h2>
<p>Use vector graphics (SVG/PDF) for true publication-quality figures. Vector graphics scale infinitely without pixelation.</p>
<p>RDKit handles this natively with <code>rdMolDraw2D</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit <span style="color:#f92672">import</span> Chem
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit.Chem.Draw <span style="color:#f92672">import</span> rdMolDraw2D
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">string_to_svg</span>(mol_string: str, output_file: str, size: int <span style="color:#f92672">=</span> <span style="color:#ae81ff">500</span>, is_selfies: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>) <span style="color:#f92672">-&gt;</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Generates a 2D molecule SVG image.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> is_selfies:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>            mol_string <span style="color:#f92672">=</span> sf<span style="color:#f92672">.</span>decoder(mol_string)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">Exception</span> <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Invalid SELFIES string: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>) <span style="color:#f92672">from</span> e
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    mol <span style="color:#f92672">=</span> Chem<span style="color:#f92672">.</span>MolFromSmiles(mol_string)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> mol:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Invalid string: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    rdDepictor<span style="color:#f92672">.</span>Compute2DCoords(mol)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> rdMolDraw2D<span style="color:#f92672">.</span>MolDraw2DSVG(size, size)
</span></span><span style="display:flex;"><span>    d<span style="color:#f92672">.</span>DrawMolecule(mol)
</span></span><span style="display:flex;"><span>    d<span style="color:#f92672">.</span>FinishDrawing()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(output_file, <span style="color:#e6db74">&#34;w&#34;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        f<span style="color:#f92672">.</span>write(d<span style="color:#f92672">.</span>GetDrawingText())
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Saved: </span><span style="color:#e6db74">{</span>output_file<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span></code></pre></div><p>This provides a perfect vector image. Note that this method omits the custom PIL-based legend. Choose the right tool for the job: PNG for quick checks and slides, SVG for journal submissions.</p>
<h2 id="command-line-interface">Command-Line Interface</h2>
<p>The tool uses Python&rsquo;s standard <code>argparse</code> library for the command-line interface. It automatically detects if you want an SVG based on the file extension and includes a <code>--selfies</code> flag.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Basic SMILES usage</span>
</span></span><span style="display:flex;"><span>python mol2img.py <span style="color:#e6db74">&#34;CCO&#34;</span> -o ethanol.png
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># SELFIES usage</span>
</span></span><span style="display:flex;"><span>python mol2img.py <span style="color:#e6db74">&#34;[C][C][O]&#34;</span> -o ethanol.png --selfies
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Generate SVG for publication</span>
</span></span><span style="display:flex;"><span>python mol2img.py <span style="color:#e6db74">&#34;CCO&#34;</span> -o ethanol.svg
</span></span></code></pre></div><h2 id="download-the-complete-script">Download the Complete Script</h2>
<p>You can copy the complete <code>mol2img.py</code> script directly from the code block below. For a fuller version with an SVG fallback, type hints, and batch (grid) rendering, see the <a href="/projects/molecular-string-renderer/">Molecular String Renderer project</a>.</p>
<h3 id="installation-and-setup">Installation and Setup</h3>
<p>Before using the script, install the required dependencies:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pip install rdkit pillow selfies
</span></span></code></pre></div><h3 id="complete-script">Complete Script</h3>
<details>
<summary>Click to expand the complete mol2img.py script</summary>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> argparse
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> sys
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> os
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> selfies <span style="color:#66d9ef">as</span> sf
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit <span style="color:#f92672">import</span> Chem
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit.Chem <span style="color:#f92672">import</span> Draw, rdDepictor, rdMolDescriptors
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit.Chem.Draw <span style="color:#f92672">import</span> rdMolDraw2D
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> PIL <span style="color:#f92672">import</span> Image, ImageDraw, ImageFont
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">get_font</span>(size: int, font_name: str <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;arial.ttf&#34;</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Attempts to load a TTF font, falls back to default if unavailable.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> ImageFont<span style="color:#f92672">.</span>truetype(font_name, size)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">IOError</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> ImageFont<span style="color:#f92672">.</span>load_default()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">string_to_svg</span>(mol_string: str, output_file: str, size: int <span style="color:#f92672">=</span> <span style="color:#ae81ff">500</span>, is_selfies: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>) <span style="color:#f92672">-&gt;</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Generates a 2D molecule SVG image.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> is_selfies:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>            mol_string <span style="color:#f92672">=</span> sf<span style="color:#f92672">.</span>decoder(mol_string)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">Exception</span> <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Invalid SELFIES string: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>) <span style="color:#f92672">from</span> e
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    mol <span style="color:#f92672">=</span> Chem<span style="color:#f92672">.</span>MolFromSmiles(mol_string)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> mol:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Invalid string: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    rdDepictor<span style="color:#f92672">.</span>Compute2DCoords(mol)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> rdMolDraw2D<span style="color:#f92672">.</span>MolDraw2DSVG(size, size)
</span></span><span style="display:flex;"><span>    d<span style="color:#f92672">.</span>DrawMolecule(mol)
</span></span><span style="display:flex;"><span>    d<span style="color:#f92672">.</span>FinishDrawing()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(output_file, <span style="color:#e6db74">&#34;w&#34;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        f<span style="color:#f92672">.</span>write(d<span style="color:#f92672">.</span>GetDrawingText())
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Saved: </span><span style="color:#e6db74">{</span>output_file<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">string_to_png</span>(mol_string: str, output_file: str, size: int <span style="color:#f92672">=</span> <span style="color:#ae81ff">500</span>, is_selfies: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>) <span style="color:#f92672">-&gt;</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Generates a 2D molecule image with a chemical formula legend.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> is_selfies:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>            smiles <span style="color:#f92672">=</span> sf<span style="color:#f92672">.</span>decoder(mol_string)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">Exception</span> <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Invalid SELFIES string: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>) <span style="color:#f92672">from</span> e
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>        smiles <span style="color:#f92672">=</span> mol_string
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    mol <span style="color:#f92672">=</span> Chem<span style="color:#f92672">.</span>MolFromSmiles(smiles)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> mol:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Could not generate molecule from string: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Generate 2D coordinates and formula</span>
</span></span><span style="display:flex;"><span>    rdDepictor<span style="color:#f92672">.</span>Compute2DCoords(mol)
</span></span><span style="display:flex;"><span>    formula <span style="color:#f92672">=</span> rdMolDescriptors<span style="color:#f92672">.</span>CalcMolFormula(mol)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Render the molecule</span>
</span></span><span style="display:flex;"><span>    img <span style="color:#f92672">=</span> Draw<span style="color:#f92672">.</span>MolToImage(mol, size<span style="color:#f92672">=</span>(size, size))<span style="color:#f92672">.</span>convert(<span style="color:#e6db74">&#34;RGBA&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Create a canvas with extra space at the bottom for the legend</span>
</span></span><span style="display:flex;"><span>    legend_height <span style="color:#f92672">=</span> int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.1</span>)
</span></span><span style="display:flex;"><span>    canvas <span style="color:#f92672">=</span> Image<span style="color:#f92672">.</span>new(<span style="color:#e6db74">&#34;RGBA&#34;</span>, (size, size <span style="color:#f92672">+</span> legend_height), <span style="color:#e6db74">&#34;white&#34;</span>)
</span></span><span style="display:flex;"><span>    canvas<span style="color:#f92672">.</span>paste(img, (<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    draw <span style="color:#f92672">=</span> ImageDraw<span style="color:#f92672">.</span>Draw(canvas)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Define dynamic font sizes</span>
</span></span><span style="display:flex;"><span>    font_reg <span style="color:#f92672">=</span> get_font(int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.03</span>))
</span></span><span style="display:flex;"><span>    font_sub <span style="color:#f92672">=</span> get_font(int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.02</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Draw the legend</span>
</span></span><span style="display:flex;"><span>    x <span style="color:#f92672">=</span> int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.02</span>)
</span></span><span style="display:flex;"><span>    y <span style="color:#f92672">=</span> size <span style="color:#f92672">+</span> int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.02</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Draw &#34;Formula: &#34; label</span>
</span></span><span style="display:flex;"><span>    draw<span style="color:#f92672">.</span>text((x, y), <span style="color:#e6db74">&#34;Formula: &#34;</span>, fill<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;black&#34;</span>, font<span style="color:#f92672">=</span>font_reg)
</span></span><span style="display:flex;"><span>    x <span style="color:#f92672">+=</span> draw<span style="color:#f92672">.</span>textlength(<span style="color:#e6db74">&#34;Formula: &#34;</span>, font<span style="color:#f92672">=</span>font_reg)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Draw formula with subscript handling for numbers</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> char <span style="color:#f92672">in</span> formula:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Use smaller font and lower y-offset for numbers (subscripts)</span>
</span></span><span style="display:flex;"><span>        font <span style="color:#f92672">=</span> font_sub <span style="color:#66d9ef">if</span> char<span style="color:#f92672">.</span>isdigit() <span style="color:#66d9ef">else</span> font_reg
</span></span><span style="display:flex;"><span>        y_offset <span style="color:#f92672">=</span> int(size <span style="color:#f92672">*</span> <span style="color:#ae81ff">0.005</span>) <span style="color:#66d9ef">if</span> char<span style="color:#f92672">.</span>isdigit() <span style="color:#66d9ef">else</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        draw<span style="color:#f92672">.</span>text((x, y <span style="color:#f92672">+</span> y_offset), char, fill<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;black&#34;</span>, font<span style="color:#f92672">=</span>font)
</span></span><span style="display:flex;"><span>        x <span style="color:#f92672">+=</span> draw<span style="color:#f92672">.</span>textlength(char, font<span style="color:#f92672">=</span>font)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Draw original string</span>
</span></span><span style="display:flex;"><span>    label <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;SELFIES&#34;</span> <span style="color:#66d9ef">if</span> is_selfies <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;SMILES&#34;</span>
</span></span><span style="display:flex;"><span>    draw<span style="color:#f92672">.</span>text((x, y), <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34; | </span><span style="color:#e6db74">{</span>label<span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">{</span>mol_string<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>, fill<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;black&#34;</span>, font<span style="color:#f92672">=</span>font_reg)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    canvas<span style="color:#f92672">.</span>save(output_file)
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Saved: </span><span style="color:#e6db74">{</span>output_file<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    parser <span style="color:#f92672">=</span> argparse<span style="color:#f92672">.</span>ArgumentParser(description<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Convert a SMILES or SELFIES string to a 2D molecular image.&#34;</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;string&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;The molecular string to convert&#34;</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;-o&#34;</span>, <span style="color:#e6db74">&#34;--output&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;molecule.png&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Output filename (default: molecule.png)&#34;</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--size&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">500</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Image width/height in pixels (default: 500)&#34;</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--svg&#34;</span>, action<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;store_true&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Force SVG output (overrides filename extension)&#34;</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--selfies&#34;</span>, action<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;store_true&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Treat the input string as SELFIES.&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    args <span style="color:#f92672">=</span> parser<span style="color:#f92672">.</span>parse_args()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Determine format based on flag or file extension</span>
</span></span><span style="display:flex;"><span>        is_svg <span style="color:#f92672">=</span> args<span style="color:#f92672">.</span>svg <span style="color:#f92672">or</span> args<span style="color:#f92672">.</span>output<span style="color:#f92672">.</span>lower()<span style="color:#f92672">.</span>endswith(<span style="color:#e6db74">&#34;.svg&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> is_svg:
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># Ensure extension is correct if not present</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> args<span style="color:#f92672">.</span>output<span style="color:#f92672">.</span>lower()<span style="color:#f92672">.</span>endswith(<span style="color:#e6db74">&#34;.svg&#34;</span>):
</span></span><span style="display:flex;"><span>                args<span style="color:#f92672">.</span>output <span style="color:#f92672">=</span> os<span style="color:#f92672">.</span>path<span style="color:#f92672">.</span>splitext(args<span style="color:#f92672">.</span>output)[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;.svg&#34;</span>
</span></span><span style="display:flex;"><span>            string_to_svg(args<span style="color:#f92672">.</span>string, args<span style="color:#f92672">.</span>output, args<span style="color:#f92672">.</span>size, args<span style="color:#f92672">.</span>selfies)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            string_to_png(args<span style="color:#f92672">.</span>string, args<span style="color:#f92672">.</span>output, args<span style="color:#f92672">.</span>size, args<span style="color:#f92672">.</span>selfies)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">Exception</span> <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>        print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Error: </span><span style="color:#e6db74">{</span>e<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>        sys<span style="color:#f92672">.</span>exit(<span style="color:#ae81ff">1</span>)
</span></span></code></pre></div></details>
]]></content:encoded></item><item><title>Exponential Random Numbers: Two Classic Algorithms</title><link>https://hunterheidenreich.com/posts/random-number-tricks/</link><pubDate>Sun, 31 Aug 2025 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/random-number-tricks/</guid><description>Compare inverse transform sampling and von Neumann's rejection method for exponential random numbers with Python implementations and performance.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>In the early days of computing, generating random numbers was a significant computational challenge. In a landmark 1951 paper, mathematician John von Neumann detailed various &ldquo;cooking recipes&rdquo; for producing and using random numbers on machines like the ENIAC. While much of the paper focuses on generating <em>uniform</em> random digits, he also described ingenious methods for generating numbers from more complex, non-uniform probability distributions.</p>
<p>One of the most fundamental needs in scientific simulation (from modeling radioactive decay to calculating particle free-paths in molecular dynamics) is sampling from an <strong>exponential distribution</strong> with probability density function:</p>
<p>$$f(x) = e^{-x} \quad \text{for } x \ge 0$$</p>
<p>Today&rsquo;s standard approach is elegant and direct, but it requires computing a natural logarithm (a computationally expensive operation on early hardware). To sidestep this limitation, von Neumann described a fascinating alternative that uses only basic comparisons, resembling what he called &ldquo;a well known game of chance Twenty-One, or Black Jack.&rdquo;</p>
<p>In this post, we&rsquo;ll explore both methods: the modern inverse transform approach and von Neumann&rsquo;s ingenious comparison-based algorithm. We&rsquo;ll implement them in Python, verify their correctness, and compare their performance, empirically testing the trade-offs von Neumann identified nearly 75 years ago.</p>
<hr>
<h2 id="method-1-the-standard-approach-inverse-transform-sampling">Method 1: The Standard Approach (Inverse Transform Sampling)</h2>
<p>The most common method for sampling from a given distribution is <strong>inverse transform sampling</strong>. This method relies on a fundamental principle: if you have a uniform random variable $U$ on the interval (0, 1), you can transform it into a random variable $X$ with any desired cumulative distribution function (CDF) $F(x)$ by applying:</p>
<p>$$X = F^{-1}(U)$$</p>
<p>For the exponential distribution, the CDF is $F(x) = 1 - e^{-x}$. To find the inverse, we set $U = 1 - e^{-X}$ and solve for $X$:</p>
<p>$$
\begin{align}
e^{-X} &amp;= 1 - U \
-X &amp;= \ln(1 - U) \
X &amp;= -\ln(1 - U)
\end{align}
$$</p>
<p>Here&rsquo;s a useful simplification: since $U$ is uniformly distributed on (0, 1), the quantity $(1 - U)$ is also uniformly distributed on (0, 1). Therefore, we can use the simpler formula:</p>
<p>$$X = -\ln(U)$$</p>
<p>This gives us an efficient method for generating exponentially distributed numbers, provided the logarithm function is computationally accessible.</p>
<h3 id="python-implementation">Python Implementation</h3>
<p>Here&rsquo;s a straightforward implementation using NumPy:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> numpy <span style="color:#66d9ef">as</span> np
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">exponential_inverse_transform</span>(n_samples<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Generate samples from an exponential distribution using inverse transform sampling.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Args:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        n_samples (int): Number of samples to generate.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Returns:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        np.ndarray: Array of exponentially distributed samples.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Generate uniform random numbers</span>
</span></span><span style="display:flex;"><span>    U <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>random<span style="color:#f92672">.</span>rand(n_samples)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Apply the inverse transform</span>
</span></span><span style="display:flex;"><span>    X <span style="color:#f92672">=</span> <span style="color:#f92672">-</span>np<span style="color:#f92672">.</span>log(U)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> X
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Generate 100,000 samples for testing</span>
</span></span><span style="display:flex;"><span>n_samples <span style="color:#f92672">=</span> <span style="color:#ae81ff">100000</span>
</span></span><span style="display:flex;"><span>inverse_samples <span style="color:#f92672">=</span> exponential_inverse_transform(n_samples)
</span></span></code></pre></div><hr>
<h2 id="method-2-von-neumanns-ingenious-trick-rejection-sampling">Method 2: Von Neumann&rsquo;s Ingenious Trick (Rejection Sampling)</h2>
<p>Von Neumann proposed a clever alternative that avoids transcendental functions entirely. His procedure, which he noted &ldquo;resembles a well known game of chance Twenty-One, or Black Jack,&rdquo; generates sequences of uniform random numbers and accepts or rejects them based on simple comparison rules.</p>
<p>The algorithm works as follows to generate a single exponential sample $X$:</p>
<ol>
<li>
<p><strong>Initialize</strong>: Start with an integer offset <code>k = 0</code>, which will form the integer part of the final result.</p>
</li>
<li>
<p><strong>Generate a trial sequence</strong>:</p>
<ul>
<li>Generate uniform random numbers $Y_1, Y_2, Y_3, \ldots$ from (0, 1)</li>
<li>Find the smallest integer <code>n</code> such that the sequence is no longer strictly decreasing</li>
<li>That is, find <code>n</code> where $Y_1 &gt; Y_2 &gt; \cdots &gt; Y_n$ but $Y_n \leq Y_{n+1}$</li>
</ul>
</li>
<li>
<p><strong>Accept or reject</strong>:</p>
<ul>
<li>If <code>n</code> is <strong>odd</strong>: Accept the trial. Return $X = Y_1 + k$ and terminate.</li>
<li>If <code>n</code> is <strong>even</strong>: Reject the trial. Increment <code>k</code> by 1 and start a new trial.</li>
</ul>
</li>
</ol>
<p>This process is guaranteed to terminate and produces samples that follow the exponential distribution exactly. As von Neumann elegantly put it, the machine has &ldquo;in effect computed a logarithm by performing only discriminations on the relative magnitude of numbers.&rdquo;</p>
<h3 id="python-implementation-1">Python Implementation</h3>
<p>This implementation requires more careful state management due to the nested trial structure:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> numpy <span style="color:#66d9ef">as</span> np
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">exponential_von_neumann</span>(n_samples<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Generate samples from an exponential distribution using von Neumann&#39;s
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    comparison-based rejection sampling method.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Args:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        n_samples (int): Number of samples to generate.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Returns:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        tuple[np.ndarray, float]: Array of samples and average uniform draws per sample.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    samples <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    total_uniform_draws <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> _ <span style="color:#f92672">in</span> range(n_samples):
</span></span><span style="display:flex;"><span>        k <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>  <span style="color:#75715e"># Integer offset</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">while</span> <span style="color:#66d9ef">True</span>:  <span style="color:#75715e"># Trial loop</span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># Generate decreasing sequence</span>
</span></span><span style="display:flex;"><span>            y_prev <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>random<span style="color:#f92672">.</span>rand()
</span></span><span style="display:flex;"><span>            total_uniform_draws <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>            y1 <span style="color:#f92672">=</span> y_prev  <span style="color:#75715e"># Store first value</span>
</span></span><span style="display:flex;"><span>            n <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># Find length of decreasing sequence</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">while</span> <span style="color:#66d9ef">True</span>:
</span></span><span style="display:flex;"><span>                y_curr <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>random<span style="color:#f92672">.</span>rand()
</span></span><span style="display:flex;"><span>                total_uniform_draws <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> y_prev <span style="color:#f92672">&lt;=</span> y_curr:
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>  <span style="color:#75715e"># Sequence no longer decreasing</span>
</span></span><span style="display:flex;"><span>                y_prev <span style="color:#f92672">=</span> y_curr
</span></span><span style="display:flex;"><span>                n <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># Accept if n is odd, reject if even</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> n <span style="color:#f92672">%</span> <span style="color:#ae81ff">2</span> <span style="color:#f92672">==</span> <span style="color:#ae81ff">1</span>:  <span style="color:#75715e"># Accept</span>
</span></span><span style="display:flex;"><span>                samples<span style="color:#f92672">.</span>append(y1 <span style="color:#f92672">+</span> k)
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">else</span>:  <span style="color:#75715e"># Reject</span>
</span></span><span style="display:flex;"><span>                k <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    avg_draws <span style="color:#f92672">=</span> total_uniform_draws <span style="color:#f92672">/</span> n_samples
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> np<span style="color:#f92672">.</span>array(samples), avg_draws
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Generate samples using von Neumann&#39;s method</span>
</span></span><span style="display:flex;"><span>von_neumann_samples, avg_draws <span style="color:#f92672">=</span> exponential_von_neumann(n_samples)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Von Neumann method used </span><span style="color:#e6db74">{</span>avg_draws<span style="color:#e6db74">:</span><span style="color:#e6db74">.2f</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> uniform draws per sample on average.&#34;</span>)
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>Von Neumann method used 4.30 uniform draws per sample on average.
</span></span></code></pre></div><p>The algorithm requires approximately <strong>4.3</strong> uniform draws per exponential sample, matching the theoretical value $e^2/(e-1) = 4.30$.</p>
<hr>
<h2 id="verification-and-comparison">Verification and Comparison</h2>
<p>The critical test: do both methods actually produce the same distribution? And how do their performance characteristics compare?</p>
<h3 id="visual-verification">Visual Verification</h3>
<p>Let&rsquo;s plot histograms of samples from both methods alongside the theoretical probability density function $f(x) = e^{-x}$:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> matplotlib.pyplot <span style="color:#66d9ef">as</span> plt
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> seaborn <span style="color:#66d9ef">as</span> sns
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Configure plot aesthetics</span>
</span></span><span style="display:flex;"><span>sns<span style="color:#f92672">.</span>set_style(<span style="color:#e6db74">&#34;whitegrid&#34;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>figure(figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">12</span>, <span style="color:#ae81ff">7</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Plot histograms for both methods</span>
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>hist(inverse_samples, bins<span style="color:#f92672">=</span><span style="color:#ae81ff">50</span>, density<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.7</span>,
</span></span><span style="display:flex;"><span>         label<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;Inverse Transform&#39;</span>, color<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;skyblue&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>hist(von_neumann_samples, bins<span style="color:#f92672">=</span><span style="color:#ae81ff">50</span>, density<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.7</span>,
</span></span><span style="display:flex;"><span>         label<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Von Neumann&#39;s Method&#34;</span>, color<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;lightcoral&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Overlay theoretical PDF</span>
</span></span><span style="display:flex;"><span>x <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>linspace(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">8</span>, <span style="color:#ae81ff">400</span>)
</span></span><span style="display:flex;"><span>pdf <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>exp(<span style="color:#f92672">-</span>x)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>plot(x, pdf, <span style="color:#e6db74">&#39;r-&#39;</span>, linewidth<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>, label<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;Theoretical PDF ($e^{-x}$)&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>title(<span style="color:#e6db74">&#39;Exponential Sampling Methods vs. Theoretical Distribution&#39;</span>, fontsize<span style="color:#f92672">=</span><span style="color:#ae81ff">16</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>xlabel(<span style="color:#e6db74">&#39;x&#39;</span>, fontsize<span style="color:#f92672">=</span><span style="color:#ae81ff">12</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>ylabel(<span style="color:#e6db74">&#39;Density&#39;</span>, fontsize<span style="color:#f92672">=</span><span style="color:#ae81ff">12</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>legend()
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>xlim(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">8</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>tight_layout()
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>show()
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/exponential_random_gens.webp"
         alt="Comparison of exponential sampling methods showing histograms from both inverse transform and von Neumann methods overlaid with the theoretical exponential distribution"
         title="Comparison of exponential sampling methods showing histograms from both inverse transform and von Neumann methods overlaid with the theoretical exponential distribution"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Both sampling methods reproduce the exponential distribution $f(x) = e^{-x}$</figcaption>
    
</figure>

<p>The visualization confirms that both methods accurately reproduce the target exponential distribution. The empirical histograms match the theoretical curve, confirming both algorithms sample the target distribution.</p>
<h3 id="performance-analysis">Performance Analysis</h3>
<p>Mathematical elegance often diverges from computational efficiency. Von Neumann himself observed that on the ENIAC, it was actually &ldquo;slightly quicker to use a truncated power series for log(1-T)&rdquo; than to perform all the comparisons his method required.</p>
<p>Let&rsquo;s benchmark both approaches in a modern Python environment:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Benchmark inverse transform method</span>
</span></span><span style="display:flex;"><span>start_time <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>time()
</span></span><span style="display:flex;"><span>_ <span style="color:#f92672">=</span> exponential_inverse_transform(n_samples)
</span></span><span style="display:flex;"><span>inverse_time <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>time() <span style="color:#f92672">-</span> start_time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Benchmark von Neumann method</span>
</span></span><span style="display:flex;"><span>start_time <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>time()
</span></span><span style="display:flex;"><span>_ <span style="color:#f92672">=</span> exponential_von_neumann(n_samples)
</span></span><span style="display:flex;"><span>vn_time <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>time() <span style="color:#f92672">-</span> start_time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Inverse Transform:  </span><span style="color:#e6db74">{</span>inverse_time<span style="color:#e6db74">:</span><span style="color:#e6db74">.4f</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> seconds&#34;</span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Von Neumann Method: </span><span style="color:#e6db74">{</span>vn_time<span style="color:#e6db74">:</span><span style="color:#e6db74">.4f</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> seconds&#34;</span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Speedup factor: </span><span style="color:#e6db74">{</span>vn_time <span style="color:#f92672">/</span> inverse_time<span style="color:#e6db74">:</span><span style="color:#e6db74">.1f</span><span style="color:#e6db74">}</span><span style="color:#e6db74">x&#34;</span>)
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>Inverse Transform:  0.0018 seconds
</span></span><span style="display:flex;"><span>Von Neumann Method: 0.1860 seconds
</span></span><span style="display:flex;"><span>Speedup factor: 103.3x
</span></span></code></pre></div><p>The gap is large. The vectorized NumPy implementation of inverse transform sampling, leveraging a highly optimized C-backed logarithm function, outperforms the Python-looped von Neumann implementation by more than two orders of magnitude. While a vectorized or JIT-compiled version of von Neumann&rsquo;s method would close this gap by removing Python interpreter overhead, the inverse transform remains the practical winner on modern hardware with fast floating-point units. This confirms von Neumann&rsquo;s prescient observation: the &ldquo;theoretically elegant&rdquo; method avoiding transcendental functions often yields to direct computation.</p>
<h2 id="conclusion">Conclusion</h2>
<p>This exploration offers a window into the ingenuity of early computational mathematics. Von Neumann&rsquo;s comparison-based algorithm demonstrates remarkable mathematical creativity (showing how to &ldquo;compute a logarithm&rdquo; using only basic machine operations). Our implementation reproduces the algorithm, producing samples whose histogram and moments match the exponential distribution.</p>
<p>The performance comparison validates von Neumann&rsquo;s own pragmatic assessment. His rejection sampling method is intellectually elegant and historically significant. The direct logarithmic approach proves far more efficient on both early and modern hardware. It serves as a timeless reminder in scientific computing: theoretical beauty often diverges from computational practicality.</p>
<p>The enduring value of von Neumann&rsquo;s work lies in the fundamental insight that creative mathematical thinking can circumvent apparent computational limitations. Understanding alternative methods deepens our appreciation for the rich landscape of algorithmic possibilities, even when the direct approach proves superior.</p>
]]></content:encoded></item><item><title>Implementing the Müller-Brown Potential in PyTorch</title><link>https://hunterheidenreich.com/posts/muller-brown-in-pytorch/</link><pubDate>Wed, 27 Aug 2025 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/muller-brown-in-pytorch/</guid><description>Guide to implementing the Müller-Brown potential in PyTorch, comparing analytical vs automatic differentiation with performance analysis.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>The Müller-Brown potential reads, in hindsight, like an adversarial example for optimization algorithms.</p>
<p>Designed in 1979 to break naive path-finding methods, this deceptively simple 2D surface features deep minima, high barriers, and tricky saddle points. For nearly five decades, it has served as a ground-truth benchmark for computational chemistry.</p>
<p>Today, it finds new life as a testbed for machine learning. In the 1970s, chemists struggled to find transition states, saddle points where standard gradient descent fails catastrophically. Modern machine learning engineers face a strikingly similar challenge: escaping saddle points in high-dimensional loss landscapes. The Müller-Brown potential was the original stress test for these algorithms, and it remains a perfect, low-cost sandbox for benchmarking modern optimizers.</p>
<p>Whether you&rsquo;re training neural network potentials or benchmarking reinforcement learning agents for exploration, the Müller-Brown potential offers a fast, noise-free, and mathematically exact environment.</p>
<p>In this guide, we&rsquo;ll implement it in PyTorch, work through the engineering trade-off between <strong>analytical derivatives</strong> and <strong>Autograd</strong>, and measure the roughly <strong>4x</strong> force-evaluation speedup of the compiled analytical kernel over the Autograd reference.</p>
<h2 id="the-problem-finding-saddle-points-in-the-1970s">The Problem: Finding Saddle Points in the 1970s</h2>
<p>In the 1970s, finding energy minima was straightforward: follow the gradient downhill. But finding transition states proved much more challenging. These saddle points are maxima along the reaction coordinate but minima in all other directions, like standing at the top of a mountain pass.</p>















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-saddle.webp"
         alt="Diagram showing gradient descent getting stuck at a saddle point, where the surface curves up in one direction and down in another"
         title="Diagram showing gradient descent getting stuck at a saddle point, where the surface curves up in one direction and down in another"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The saddle point problem: standard gradient descent sees a minimum in one direction but a maximum in another. Modern optimizers like SGD and Adam face this same challenge in high-dimensional loss landscapes.</figcaption>
    
</figure>

<p>Standard first-order optimizers, whether 1970s simplex methods or modern SGD and Adam, are designed to minimize loss functions blindly. Point them at a saddle point, and they slide into the nearest valley. The gradients vanish or point in misleading directions. Specialized algorithms were needed to navigate this mixed landscape of ups and downs.</p>
<p>The computational reality made this worse. Early quantum chemistry programs like ATMOL and Gaussian made energy calculations possible, but each computation was expensive. Gradients required even more resources, and second derivatives were rarely computed.</p>
<p>This created a catch-22: sophisticated algorithms were needed to find saddle points, but researchers couldn&rsquo;t afford to test them on real molecular systems. Every calculation represented a major investment of time and computational resources.</p>
<h2 id="müller-and-browns-solution">Müller and Brown&rsquo;s Solution</h2>
<p>Müller and Brown&rsquo;s insight<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> was to create a simple analytical test function that captured the essential difficulties of real chemical systems without the computational cost. Their potential offered three key advantages:</p>
<ul>
<li><strong>Negligible computational cost</strong> - Evaluate millions of points instantly</li>
<li><strong>Analytical derivatives</strong> - Exact gradients and Hessians available immediately</li>
<li><strong>Realistic challenges</strong> - Multiple minima, saddle points, and curved pathways</li>
</ul>
<p>The clever part was the deliberate design to break naive approaches. Early methods often assumed linear paths between reactants and products. The Müller-Brown potential has a curved minimum energy path that punishes this assumption. Try to take shortcuts, and algorithms climb over high-energy barriers.</p>
<h2 id="the-mathematical-foundation">The Mathematical Foundation</h2>
<p>The Müller-Brown potential combines four two-dimensional Gaussian functions:</p>
<p>$$V(x,y) = \sum_{k=1}^{4} A_k \exp\left[a_k(x-x_k^0)^2 + b_k(x-x_k^0)(y-y_k^0) + c_k(y-y_k^0)^2\right]$$</p>
<p>Each Gaussian contributes a different &ldquo;bump&rdquo; or &ldquo;well&rdquo; to the landscape. The parameters control amplitude ($A_k$), width, orientation, and center position.</p>
<h3 id="the-standard-parameters">The Standard Parameters</h3>
<p>The specific parameter values that define the canonical Müller-Brown surface are:</p>
<table>
	<thead>
			<tr>
					<th>k</th>
					<th>$A_k$</th>
					<th>$a_k$</th>
					<th>$b_k$</th>
					<th>$c_k$</th>
					<th>$x_k^0$</th>
					<th>$y_k^0$</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>-200</td>
					<td>-1</td>
					<td>0</td>
					<td>-10</td>
					<td>1</td>
					<td>0</td>
			</tr>
			<tr>
					<td>2</td>
					<td>-100</td>
					<td>-1</td>
					<td>0</td>
					<td>-10</td>
					<td>0</td>
					<td>0.5</td>
			</tr>
			<tr>
					<td>3</td>
					<td>-170</td>
					<td>-6.5</td>
					<td>11</td>
					<td>-6.5</td>
					<td>-0.5</td>
					<td>1.5</td>
			</tr>
			<tr>
					<td>4</td>
					<td>15</td>
					<td>0.7</td>
					<td>0.6</td>
					<td>0.7</td>
					<td>-1</td>
					<td>1</td>
			</tr>
	</tbody>
</table>
<p>Notice that the first three terms have negative amplitudes (creating energy wells), while the fourth has a positive amplitude (creating a barrier). The cross-term $b_k$ in the third Gaussian creates the tilted orientation that gives the surface its characteristic curved pathways.</p>
<p><a href="/muller-brown-optimized/"><strong>View Interactive Müller-Brown Potential Energy Surface →</strong></a></p>
<h3 id="the-resulting-landscape">The Resulting Landscape</h3>















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-potential-surface.webp"
         alt="Müller-Brown Potential Energy Surface showing the three minima (dark blue regions) and two saddle points"
         title="Müller-Brown Potential Energy Surface showing the three minima (dark blue regions) and two saddle points"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The Müller-Brown potential energy surface showing the three minima (dark blue regions) and two saddle points.</figcaption>
    
</figure>

<p>This simple formula creates a surprisingly rich topography with exactly the features needed to challenge optimization algorithms:</p>
<table>
	<thead>
			<tr>
					<th><strong>Stationary Point</strong></th>
					<th><strong>Coordinates</strong></th>
					<th><strong>Energy</strong></th>
					<th><strong>Type</strong></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>MA (Reactant)</td>
					<td>(-0.558, 1.442)</td>
					<td>-146.70</td>
					<td>Deep minimum</td>
			</tr>
			<tr>
					<td>MC (Intermediate)</td>
					<td>(-0.050, 0.467)</td>
					<td>-80.77</td>
					<td>Shallow minimum</td>
			</tr>
			<tr>
					<td>MB (Product)</td>
					<td>(0.623, 0.028)</td>
					<td>-108.17</td>
					<td>Medium minimum</td>
			</tr>
			<tr>
					<td>S1</td>
					<td>(-0.822, 0.624)</td>
					<td>-40.66</td>
					<td>First saddle point</td>
			</tr>
			<tr>
					<td>S2</td>
					<td>(0.212, 0.293)</td>
					<td>-72.25</td>
					<td>Second saddle point</td>
			</tr>
	</tbody>
</table>
<h3 id="the-key-challenge-curved-pathways">The Key Challenge: Curved Pathways</h3>
<p>The path from the deep reactant minimum (MA) to the product minimum (MB) doesn&rsquo;t go directly over a single barrier. Instead, it follows a curved route:</p>
<ol>
<li><strong>MA → S1 → MC</strong>: First transition over the higher, rate-limiting barrier (S1) into an intermediate basin</li>
<li><strong>MC → S2 → MB</strong>: Second transition over a much lower barrier (S2) to the product</li>
</ol>
<p>This two-step pathway breaks linear interpolation methods. Algorithms that draw a straight line from reactant to product miss both the intermediate minimum and the correct transition states, climbing over much higher energy regions instead.</p>















<figure class="post-figure center ">
    <img src="/img/muller-brown/naive-versus-minimum-path.webp"
         alt="Two-panel comparison showing naive linear interpolation versus minimum energy path. Left panel shows the contour map with both paths overlaid. Right panel shows the energy profile along each path, revealing the naive path hits a far higher barrier."
         title="Two-panel comparison showing naive linear interpolation versus minimum energy path. Left panel shows the contour map with both paths overlaid. Right panel shows the energy profile along each path, revealing the naive path hits a far higher barrier."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Why naive optimization fails: The left panel shows a straight-line path (red dashed) versus the true minimum energy path (green solid) on the potential surface. The right panel reveals the energetic cost. The naive path climbs a barrier roughly 53 reduced units higher that the curved path avoids entirely. This is the &lsquo;adversarial&rsquo; nature of the Müller-Brown surface.</figcaption>
    
</figure>

<p>The energy profile comparison makes the failure mode concrete. A naive optimizer following the red dashed path would encounter a barrier roughly <strong>53 reduced units higher</strong> than necessary (the naive summit sits at about +13 while the true path tops out near -41, the S1 saddle). The green minimum energy path navigates through the valleys, passing through the intermediate basin MC and crossing only the low-lying saddle points S1 and S2.</p>
<h2 id="why-it-works-as-a-benchmark">Why It Works as a Benchmark</h2>
<p>The Müller-Brown potential has served as a computational chemistry benchmark for over four decades because of four key characteristics:</p>
<p><strong>Low dimensionality</strong>: As a 2D surface, you can visualize the entire landscape and see exactly why algorithms succeed or fail.</p>
<p><strong>Analytical form</strong>: Energy and gradient calculations cost virtually nothing, enabling exhaustive testing impossible with quantum mechanical surfaces.</p>
<p><strong>Non-trivial topology</strong>: The curved minimum energy path and shallow intermediate minimum challenge sophisticated methods while remaining manageable.</p>
<p><strong>Known ground truth</strong>: All minima and saddle points are precisely known, providing unambiguous success metrics.</p>















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-basins-of-attraction.webp"
         alt="Basins of attraction map showing which regions of the Müller-Brown surface lead to each minimum under gradient descent. Blue region flows to MA, green to MC, and yellow to MB."
         title="Basins of attraction map showing which regions of the Müller-Brown surface lead to each minimum under gradient descent. Blue region flows to MA, green to MC, and yellow to MB."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Basins of attraction: the &lsquo;optimization map&rsquo; of the Müller-Brown surface. Each color indicates which minimum a gradient descent optimizer will reach from that starting point. Saddle points (red x) sit precisely at the basin boundaries, the unstable equilibria that algorithms must navigate to find reaction paths.</figcaption>
    
</figure>

<p>This basin map reveals why the Müller-Brown potential is such an effective benchmark. Standard gradient descent from any point in the blue region inevitably falls into the deep MA minimum; from yellow, into MB. The saddle points S1 and S2 lie exactly on the boundaries between basins, infinitesimally perturbing an optimizer at these points sends it tumbling into different valleys. Finding these saddle points requires algorithms that identify and stabilize at these boundary regions.</p>
<p>What makes this particularly valuable is the contrast with other classic potentials. While the Lennard-Jones potential<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> serves as the benchmark for equilibrium properties with its single energy minimum, Müller-Brown explicitly models reactive landscapes. Its multiple minima and connecting barriers make it the testing ground for algorithms that find reaction paths, the methods that reveal how chemistry actually happens.</p>
<h3 id="applications-across-decades">Applications Across Decades</h3>
<p>The potential has evolved with the field&rsquo;s changing focus:</p>
<p><strong>1980s-1990s</strong>: Testing path-finding methods like Nudged Elastic Band (NEB)<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>, which creates discrete representations of reaction pathways and optimizes them to find minimum energy paths.</p>
<p><strong>2000s-2010s</strong>: Validating Transition Path Sampling (TPS) methods<sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup> that harvest statistical ensembles of reactive trajectories.</p>
<p><strong>2020s</strong>: Benchmarking machine learning models and generative approaches that learn to sample transition paths or approximate potential energy surfaces.</p>
<h2 id="modern-applications-in-machine-learning">Modern Applications in Machine Learning</h2>
<p>The rise of machine learning has given the Müller-Brown potential renewed purpose. Modern <strong>Machine Learning Interatomic Potentials (MLIPs)</strong><sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup><sup id="fnref:6"><a href="#fn:6" class="footnote-ref" role="doc-noteref">6</a></sup> aim to bridge the gap between quantum mechanical accuracy and classical force field efficiency by training flexible models on expensive quantum chemistry data.</p>
<p>This creates a benchmarking challenge: with countless ML architectures available, how do you objectively compare them? The Müller-Brown potential provides an ideal solution, an exactly known potential energy surface that can generate unlimited, noise-free training data.</p>
<p>This enables researchers to ask fundamental questions:</p>
<ul>
<li>How well does a given architecture learn complex, curved surfaces?</li>
<li>How many training points are needed for acceptable accuracy?</li>
<li>How does the model behave when extrapolating beyond training data?</li>
<li>Can it correctly identify minima and saddle points?</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-ml-benchmark.webp"
         alt="Three-panel comparison showing the analytical Müller-Brown surface (left), a neural network at epoch 50 with high error (middle), and a converged neural network at epoch 1000 (right)"
         title="Three-panel comparison showing the analytical Müller-Brown surface (left), a neural network at epoch 50 with high error (middle), and a converged neural network at epoch 1000 (right)"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Visualizing the ML benchmark: A comparison of the analytical ground truth (left) versus a neural network potential at early (middle) and late (right) stages of training. Notice how the model in early training quickly grasps the deep minima regions but struggles significantly with the complex topography of the saddle points and energy barriers: the curved pathways are smoothed into simpler shapes. This illustrates why the Müller-Brown surface remains a challenging test case for modern architectures.</figcaption>
    
</figure>

<p>The potential has evolved from a simple model system into a <strong>reference benchmark</strong>: a fixed, exactly-known surface against which AI learning capacity is measured. Any prediction error is due to model limitations, not data quality.</p>
<p>Beyond static benchmarking, a PyTorch implementation enables <strong>differentiable simulation</strong>. Because the potential, forces, and integrator are all differentiable tensor operations, gradients can be backpropagated <em>through time</em> (via the trajectory) to optimize force field parameters or control policies directly. This capability connects classical molecular simulation to modern gradient-based machine learning in a single computational graph.</p>
<p>These benchmarking principles extend beyond abstract test cases. Real molecular dynamics simulations, such as those studying <a href="/posts/adatom-cu-diffusion/">adatom diffusion on metal surfaces</a>, face similar challenges in understanding energy landscapes and transition pathways. The Müller-Brown potential provides a controlled environment for developing methods that eventually tackle these complex realistic systems.</p>
<h2 id="extension-to-higher-dimensions">Extension to Higher Dimensions</h2>
<p>The canonical Müller-Brown potential can be extended beyond two dimensions to create more challenging test cases that better reflect real molecular systems. This extensibility demonstrates why it remains such an effective template for computational method development.</p>
<h3 id="why-higher-dimensions-matter">Why Higher Dimensions Matter</h3>
<p>Real molecules have dozens or hundreds of degrees of freedom. Understanding how algorithms scale with dimensionality is crucial for practical applications. Higher-dimensional extensions allow researchers to systematically test:</p>
<ul>
<li><strong>Algorithm scaling</strong> - Does performance degrade gracefully as dimensions increase?</li>
<li><strong>Model robustness</strong> - Do machine learning approaches maintain accuracy in high-dimensional spaces?</li>
<li><strong>Parallel efficiency</strong> - Can massively parallel methods exploit additional dimensions effectively?</li>
</ul>
<h3 id="extension-approaches">Extension Approaches</h3>
<p><strong>Harmonic constraints</strong>: Add quadratic wells in orthogonal dimensions while preserving the complex 2D landscape<sup id="fnref:7"><a href="#fn:7" class="footnote-ref" role="doc-noteref">7</a></sup>:</p>
<p>$$V_{5D}(x_1, x_2, x_3, x_4, x_5) = V(x_1, x_3) + \kappa(x_2^2 + x_4^2 + x_5^2)$$</p>
<p>The parameter $\kappa$ controls constraint strength: small values create nearly flat directions that test algorithmic efficiency.</p>
<p><strong>Collective variables</strong>: Define new coordinates that mix multiple dimensions<sup id="fnref:8"><a href="#fn:8" class="footnote-ref" role="doc-noteref">8</a></sup>:</p>
<p>$$\tilde{x} = \sqrt{x_1^2 + x_2^2 + \epsilon x_5^2},\quad \tilde{y} = \sqrt{x_3^2 + x_4^2}$$</p>
<p>where $\epsilon \ll 1$. The 5D potential becomes $V_{5D}(\tilde{x}, \tilde{y}) = V(\tilde{x}, \tilde{y})$, embedding the original surface in a higher-dimensional space.</p>
<h3 id="value-for-algorithm-development">Value for Algorithm Development</h3>
<p>This extensibility makes the Müller-Brown potential ideal for systematic testing:</p>
<ul>
<li><strong>Progressive complexity</strong>: Debug on 2D, then scale to higher dimensions</li>
<li><strong>Ground truth preservation</strong>: Known minima and saddle points remain in the active subspace</li>
<li><strong>Realistic challenges</strong>: Captures the &ldquo;needle in a haystack&rdquo; problem of transition state finding while maintaining analytical tractability</li>
</ul>
<p>These extensions transform a simple 2D benchmark into a scalable testbed for modern computational methods, probing specific challenges in high-dimensional optimization.</p>
<h2 id="implementation-in-pytorch">Implementation in PyTorch</h2>
<p>Now let&rsquo;s implement the Müller-Brown potential in PyTorch. A practical implementation needs to handle batch processing, support both analytical and automatic differentiation, and be optimized for performance.</p>
<h3 id="core-implementation">Core Implementation</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch.nn <span style="color:#66d9ef">as</span> nn
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> torch <span style="color:#f92672">import</span> Tensor
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">MuellerBrownPotential</span>(nn<span style="color:#f92672">.</span>Module):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Müller-Brown potential with a torch.compile-accelerated force kernel.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">__init__</span>(
</span></span><span style="display:flex;"><span>        self,
</span></span><span style="display:flex;"><span>        device: str <span style="color:#f92672">|</span> torch<span style="color:#f92672">.</span>device <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;cpu&#34;</span>,
</span></span><span style="display:flex;"><span>        dtype: torch<span style="color:#f92672">.</span>dtype <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>float64,
</span></span><span style="display:flex;"><span>        use_autograd: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>    ):
</span></span><span style="display:flex;"><span>        super()<span style="color:#f92672">.</span><span style="color:#a6e22e">__init__</span>()
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>use_autograd <span style="color:#f92672">=</span> use_autograd
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Standard Müller-Brown parameters</span>
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>register_buffer(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;A&#34;</span>, torch<span style="color:#f92672">.</span>tensor([<span style="color:#f92672">-</span><span style="color:#ae81ff">200.0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">100.0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">170.0</span>, <span style="color:#ae81ff">15.0</span>],
</span></span><span style="display:flex;"><span>                             device<span style="color:#f92672">=</span>device, dtype<span style="color:#f92672">=</span>dtype)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>register_buffer(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;a&#34;</span>, torch<span style="color:#f92672">.</span>tensor([<span style="color:#f92672">-</span><span style="color:#ae81ff">1.0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">1.0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">6.5</span>, <span style="color:#ae81ff">0.7</span>],
</span></span><span style="display:flex;"><span>                             device<span style="color:#f92672">=</span>device, dtype<span style="color:#f92672">=</span>dtype)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>register_buffer(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;b&#34;</span>, torch<span style="color:#f92672">.</span>tensor([<span style="color:#ae81ff">0.0</span>, <span style="color:#ae81ff">0.0</span>, <span style="color:#ae81ff">11.0</span>, <span style="color:#ae81ff">0.6</span>],
</span></span><span style="display:flex;"><span>                             device<span style="color:#f92672">=</span>device, dtype<span style="color:#f92672">=</span>dtype)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>register_buffer(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;c&#34;</span>, torch<span style="color:#f92672">.</span>tensor([<span style="color:#f92672">-</span><span style="color:#ae81ff">10.0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">10.0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">6.5</span>, <span style="color:#ae81ff">0.7</span>],
</span></span><span style="display:flex;"><span>                             device<span style="color:#f92672">=</span>device, dtype<span style="color:#f92672">=</span>dtype)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>register_buffer(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;x_centers&#34;</span>, torch<span style="color:#f92672">.</span>tensor([<span style="color:#ae81ff">1.0</span>, <span style="color:#ae81ff">0.0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">0.5</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">1.0</span>],
</span></span><span style="display:flex;"><span>                                    device<span style="color:#f92672">=</span>device, dtype<span style="color:#f92672">=</span>dtype)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>register_buffer(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;y_centers&#34;</span>, torch<span style="color:#f92672">.</span>tensor([<span style="color:#ae81ff">0.0</span>, <span style="color:#ae81ff">0.5</span>, <span style="color:#ae81ff">1.5</span>, <span style="color:#ae81ff">1.0</span>],
</span></span><span style="display:flex;"><span>                                    device<span style="color:#f92672">=</span>device, dtype<span style="color:#f92672">=</span>dtype)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">forward</span>(self, coordinates: Tensor) <span style="color:#f92672">-&gt;</span> Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Compute potential energy.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> _calculate_potential(
</span></span><span style="display:flex;"><span>            coordinates, self<span style="color:#f92672">.</span>A, self<span style="color:#f92672">.</span>a, self<span style="color:#f92672">.</span>b, self<span style="color:#f92672">.</span>c,
</span></span><span style="display:flex;"><span>            self<span style="color:#f92672">.</span>x_centers, self<span style="color:#f92672">.</span>y_centers
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">force</span>(self, coordinates: Tensor) <span style="color:#f92672">-&gt;</span> Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Compute forces (negative gradient).&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> self<span style="color:#f92672">.</span>use_autograd:
</span></span><span style="display:flex;"><span>            coordinates <span style="color:#f92672">=</span> coordinates<span style="color:#f92672">.</span>requires_grad_(<span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>            potential <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>forward(coordinates)
</span></span><span style="display:flex;"><span>            grad <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>autograd<span style="color:#f92672">.</span>grad(potential<span style="color:#f92672">.</span>sum(), coordinates)[<span style="color:#ae81ff">0</span>]
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#f92672">-</span>grad
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> _calculate_force(
</span></span><span style="display:flex;"><span>                coordinates, self<span style="color:#f92672">.</span>A, self<span style="color:#f92672">.</span>a, self<span style="color:#f92672">.</span>b, self<span style="color:#f92672">.</span>c,
</span></span><span style="display:flex;"><span>                self<span style="color:#f92672">.</span>x_centers, self<span style="color:#f92672">.</span>y_centers
</span></span><span style="display:flex;"><span>            )
</span></span></code></pre></div><p>The implementation uses <code>register_buffer</code> to store parameters, a subtle but important PyTorch best practice. This ensures that the potential parameters are automatically moved to the GPU along with the model when calling <code>.to(device)</code>, a common pitfall that leads to frustrating device mismatch errors. Beyond device placement, <code>register_buffer</code> also ensures these parameters are correctly handled during <strong>DistributedDataParallel (DDP)</strong> broadcasting, preventing silent failures when scaling training to multi-GPU clusters. The <code>use_autograd</code> flag switches between analytical and automatic differentiation.</p>
<h3 id="compiling-the-force-kernel">Compiling the Force Kernel</h3>
<p>Two decisions make the force path fast while keeping the rest flexible:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_calculate_potential</span>(coordinates: Tensor, A: Tensor, a: Tensor,
</span></span><span style="display:flex;"><span>                        b: Tensor, c: Tensor, x_centers: Tensor,
</span></span><span style="display:flex;"><span>                        y_centers: Tensor) <span style="color:#f92672">-&gt;</span> Tensor:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Energy. Left eager (uncompiled) so autograd second derivatives,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    e.g. the Hessian, keep working: torch.compile does not support
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    double-backward, and energy is computed per save, not per step.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    coords <span style="color:#f92672">=</span> coordinates<span style="color:#f92672">.</span>view(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    x, y <span style="color:#f92672">=</span> coords[:, <span style="color:#ae81ff">0</span>], coords[:, <span style="color:#ae81ff">1</span>]
</span></span><span style="display:flex;"><span>    dx <span style="color:#f92672">=</span> x<span style="color:#f92672">.</span>unsqueeze(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> x_centers
</span></span><span style="display:flex;"><span>    dy <span style="color:#f92672">=</span> y<span style="color:#f92672">.</span>unsqueeze(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> y_centers
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    potential <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>sum(
</span></span><span style="display:flex;"><span>        A <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>exp(a <span style="color:#f92672">*</span> dx<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span> <span style="color:#f92672">+</span> b <span style="color:#f92672">*</span> dx <span style="color:#f92672">*</span> dy <span style="color:#f92672">+</span> c <span style="color:#f92672">*</span> dy<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span>),
</span></span><span style="display:flex;"><span>        dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> potential<span style="color:#f92672">.</span>view(coordinates<span style="color:#f92672">.</span>shape[:<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@torch.compile</span>(dynamic<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_calculate_force</span>(coordinates: Tensor, A: Tensor, a: Tensor,
</span></span><span style="display:flex;"><span>                    b: Tensor, c: Tensor, x_centers: Tensor,
</span></span><span style="display:flex;"><span>                    y_centers: Tensor) <span style="color:#f92672">-&gt;</span> Tensor:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Forces (negative gradient). Compiled: this is the hot path,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    called once per simulation step.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    coords <span style="color:#f92672">=</span> coordinates<span style="color:#f92672">.</span>view(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    x, y <span style="color:#f92672">=</span> coords[:, <span style="color:#ae81ff">0</span>], coords[:, <span style="color:#ae81ff">1</span>]
</span></span><span style="display:flex;"><span>    dx <span style="color:#f92672">=</span> x<span style="color:#f92672">.</span>unsqueeze(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> x_centers
</span></span><span style="display:flex;"><span>    dy <span style="color:#f92672">=</span> y<span style="color:#f92672">.</span>unsqueeze(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> y_centers
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    exp_terms <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>exp(a <span style="color:#f92672">*</span> dx<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span> <span style="color:#f92672">+</span> b <span style="color:#f92672">*</span> dx <span style="color:#f92672">*</span> dy <span style="color:#f92672">+</span> c <span style="color:#f92672">*</span> dy<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    A_exp <span style="color:#f92672">=</span> A <span style="color:#f92672">*</span> exp_terms
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    grad_x <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>sum(A_exp <span style="color:#f92672">*</span> (<span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> a <span style="color:#f92672">*</span> dx <span style="color:#f92672">+</span> b <span style="color:#f92672">*</span> dy), dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    grad_y <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>sum(A_exp <span style="color:#f92672">*</span> (b <span style="color:#f92672">*</span> dx <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> c <span style="color:#f92672">*</span> dy), dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    forces <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>stack([<span style="color:#f92672">-</span>grad_x, <span style="color:#f92672">-</span>grad_y], dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    new_shape <span style="color:#f92672">=</span> list(coordinates<span style="color:#f92672">.</span>shape[:<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>]) <span style="color:#f92672">+</span> [<span style="color:#ae81ff">2</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> forces<span style="color:#f92672">.</span>view(new_shape)
</span></span></code></pre></div><p>The force kernel is decorated with <code>@torch.compile(dynamic=True)</code>, which traces it once and hands it to TorchInductor to fuse the pointwise operations (the exponentials and polynomial terms) and cut Python&rsquo;s dispatch overhead in the inner loop that runs millions of times per simulation. The <code>dynamic=True</code> flag keeps a single compiled trace valid across particle counts, so changing the batch size does not trigger a recompile. The energy is left eager on purpose: <code>torch.compile</code> does not support double-backward, so leaving <code>forward</code> uncompiled keeps autograd second derivatives (the Hessian) available, and since the energy is only evaluated when an observable is saved, it is not on the hot path anyway.</p>
<h3 id="performance-analytical-vs-automatic-differentiation">Performance: Analytical vs. Automatic Differentiation</h3>
<p>A key design decision is whether to use analytical derivatives or automatic differentiation. I benchmarked both on an Apple M1 Max (CPU, PyTorch 2.x). To get a stable measurement, each configuration runs 100 warm-up iterations, then the median wall-clock time over 5 runs of 1000 iterations, which filters out operating-system jitter.</p>















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-throughput-analysis.webp"
         alt="Throughput comparison showing the analytical force kernel outperforming autograd across batch sizes"
         title="Throughput comparison showing the analytical force kernel outperforming autograd across batch sizes"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Force-evaluation throughput, analytical vs autograd, across batch sizes. The analytical kernel is about 4x faster (3-7x depending on batch size).</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-time-per-particle.webp"
         alt="Per-particle computation time showing analytical derivatives maintain sub-microsecond performance for large systems"
         title="Per-particle computation time showing analytical derivatives maintain sub-microsecond performance for large systems"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Per-particle computation time. Analytical derivatives maintain sub-microsecond performance for large systems.</figcaption>
    
</figure>

<p>The speedup comes from bypassing the computational graph that PyTorch&rsquo;s Autograd engine builds. By deriving the analytical Jacobian, we skip that machinery entirely. Every <code>autograd.grad()</code> call must:</p>
<ol>
<li><strong>Build a tape</strong> of operations during the forward pass</li>
<li><strong>Traverse the graph</strong> backward to compute gradients</li>
<li><strong>Allocate intermediate tensors</strong> for each node</li>
</ol>
<p>For iterative workloads like molecular dynamics (millions of force evaluations per trajectory), this overhead elimination is critical. The analytical kernel computes forces directly in a single fused operation, no graph, no tape, no intermediate allocations.</p>
<p><strong>When to use each approach:</strong></p>
<ul>
<li><strong>Analytical</strong>: Best for production molecular dynamics where forces are computed millions of times. The speedup directly reduces wall-clock simulation time.</li>
<li><strong>Autograd</strong>: Better for prototyping, machine learning training loops, or when implementing new potentials where correctness verification is paramount. The convenience and guaranteed accuracy often outweigh performance costs during development.</li>
</ul>
<h3 id="molecular-dynamics-simulations">Molecular Dynamics Simulations</h3>
<p>To demonstrate the PyTorch implementation in action, I performed Langevin dynamics simulations in different energy basins. These simulations reveal how particles behave when confined to different regions of the potential energy surface.</p>
<h4 id="simulation-parameters">Simulation Parameters</h4>
<p>I ran 3600 time steps with a 0.01 time unit step size, using a friction coefficient of 1.0 and temperature of 25.0 in reduced units. The simulations started from equilibrium positions within each basin and show the characteristic thermal fluctuations around local minima.</p>
<h4 id="basin-ma-deep-reactant-minimum">Basin MA: Deep Reactant Minimum</h4>
<p>The deepest energy well (-146.70 in reduced units) shows highly constrained motion due to the steep energy barriers surrounding it.</p>















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-basin-ma-position-distributions.webp"
         alt="Position distributions in Basin MA showing tight confinement"
         title="Position distributions in Basin MA showing tight confinement"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Position distributions in Basin MA. The particle remains tightly confined around (-0.558, 1.442) due to the deep potential well.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-basin-ma-time-series.webp"
         alt="Time evolution of coordinates in Basin MA"
         title="Time evolution of coordinates in Basin MA"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Time series showing small-amplitude oscillations around the equilibrium position. The deep well severely restricts thermal motion.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-basin-ma-trajectory.webp"
         alt="Trajectory overlaid on potential surface for Basin MA"
         title="Trajectory overlaid on potential surface for Basin MA"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Trajectory visualization showing the particle&rsquo;s motion confined to a small region around the minimum. High energy barriers prevent escape on this time scale.</figcaption>
    
</figure>

<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
			<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube-nocookie.com/embed/woVM90qXUQs?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
		</div>

<h4 id="basin-mb-product-minimum">Basin MB: Product Minimum</h4>
<p>The product minimum (-108.17 in reduced units) shows intermediate behavior between the deep reactant well and shallow intermediate basin.</p>















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-basin-mb-position-distributions.webp"
         alt="Position distributions in Basin MB showing moderate confinement"
         title="Position distributions in Basin MB showing moderate confinement"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Position distributions in Basin MB. The particle shows moderate thermal motion around (0.623, 0.028), with confinement between the deep MA basin and shallow MC basin.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-basin-mb-time-series.webp"
         alt="Time evolution showing moderate amplitude fluctuations in Basin MB"
         title="Time evolution showing moderate amplitude fluctuations in Basin MB"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Time series demonstrating moderate amplitude fluctuations. The particle explores a region larger than MA but more constrained than the shallow MC basin.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-basin-mb-trajectory.webp"
         alt="Basin MB trajectory showing balanced exploration"
         title="Basin MB trajectory showing balanced exploration"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The trajectory shows balanced thermal exploration within the product basin. The moderate well depth allows reasonable sampling while maintaining basin confinement.</figcaption>
    
</figure>

<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
			<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube-nocookie.com/embed/gdAHme07bGs?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
		</div>

<h4 id="transition-example">Transition Example</h4>
<p>Running a longer simulation demonstrates transitions between basins:</p>















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-transition-trajectory.webp"
         alt="Transition trajectory between basins"
         title="Transition trajectory between basins"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The trajectory illustrates the particle&rsquo;s movement between the different basins, highlighting the energy barriers and pathways involved.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-transition-time-series.webp"
         alt="Time evolution of positions during transition"
         title="Time evolution of positions during transition"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Time series showing the evolution of the particle&rsquo;s position during the transition between basins.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/muller-brown/muller-brown-transition-position-distributions.webp"
         alt="Transition trajectory on potential surface"
         title="Transition trajectory on potential surface"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The trajectory on the potential surface highlights the energy landscape the particle navigates during the transition.</figcaption>
    
</figure>

<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
			<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube-nocookie.com/embed/dVFe_4KZbps?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
		</div>

<h3 id="integration-with-modern-workflows">Integration with Modern Workflows</h3>
<p>The PyTorch implementation integrates naturally with machine learning workflows. It can serve as a component in larger computational graphs, enable gradient-based optimization, and bridge classical molecular simulation with deep learning approaches.</p>
<p>For readers interested in broader molecular ML applications, this implementation pairs well with other molecular representation methods. The <a href="/posts/molecular-descriptor-coulomb-matrix/">Coulomb matrix approach</a> offers complementary perspectives on encoding molecular structure, while the <a href="/posts/kabsch-algorithm/#the-math">Kabsch algorithm</a> provides essential tools for structural alignment.</p>
<p>The complete implementation is available on <a href="https://github.com/hunter-heidenreich/Muller-Brown-Potential">GitHub</a><sup id="fnref:9"><a href="#fn:9" class="footnote-ref" role="doc-noteref">9</a></sup><sup id="fnref:10"><a href="#fn:10" class="footnote-ref" role="doc-noteref">10</a></sup><sup id="fnref:11"><a href="#fn:11" class="footnote-ref" role="doc-noteref">11</a></sup>, including benchmarking scripts, visualization tools, the test suite, and examples for optimization and molecular dynamics.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The Müller-Brown potential exemplifies how a well-designed benchmark can evolve with a field. Born from 1970s computational constraints, it provided a simple way to test algorithms when quantum chemistry calculations were expensive. Its clever design, simple enough to compute instantly, complex enough to break naive approaches, made it invaluable for algorithm development.</p>
<p>Today, it serves new purposes in the machine learning era. This PyTorch implementation pairs a hand-derived analytical force kernel (compiled with <code>torch.compile</code>) with an autograd reference, a BAOAB Langevin sampler, and a test suite that checks the sampler against the canonical distribution. The analytical kernel&rsquo;s roughly 4x advantage matters for intensive simulations, while PyTorch&rsquo;s flexibility enables integration with neural network potentials and enhanced sampling methods.</p>
<p>The potential&rsquo;s evolution from practical necessity to pedagogical tool to machine learning benchmark demonstrates the value of foundational test cases. As computational chemistry continues evolving, reliable standards like the Müller-Brown potential become even more important for rigorous method development and comparison.</p>
<p>For the complete implementation with benchmarking scripts, the BAOAB Langevin simulator, and visualization tools, see the <a href="https://github.com/hunter-heidenreich/Muller-Brown-Potential">GitHub repository</a>. The full project with architecture details and performance results is at the <a href="/projects/muller-brown-pytorch/">Müller-Brown Potential: A PyTorch ML Testbed project page</a>.</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Müller, K., &amp; Brown, L. D. (1979). Location of saddle points and minimum energy paths by a constrained simplex optimization procedure. <em>Theoretica Chimica Acta</em>, 53, 75-93. <a href="https://link.springer.com/article/10.1007/BF00547608">https://link.springer.com/article/10.1007/BF00547608</a>&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>Lennard-Jones, J. E. (1931). Cohesion. <em>Proceedings of the Physical Society</em>, 43(5), 461-482. <a href="https://doi.org/10.1088/0959-5309/43/5/301">https://doi.org/10.1088/0959-5309/43/5/301</a>&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>Henkelman, G., &amp; Jónsson, H. (2000). Improved tangent estimate in the nudged elastic band method for finding minimum energy paths and saddle points. <em>Journal of Chemical Physics</em>, 113(22), 9901-9904. <a href="https://doi.org/10.1063/1.1329672">https://doi.org/10.1063/1.1329672</a>&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p>Dellago, C., Bolhuis, P. G., Csajka, F. S., &amp; Chandler, D. (1998). Transition path sampling and the calculation of rate constants. <em>Journal of Chemical Physics</em>, 108(5), 1964-1977. <a href="https://doi.org/10.1063/1.475562">https://doi.org/10.1063/1.475562</a>&#160;<a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5">
<p>Behler, J., &amp; Parrinello, M. (2007). Generalized neural-network representation of high-dimensional potential-energy surfaces. <em>Physical Review Letters</em>, 98(14), 146401. <a href="https://doi.org/10.1103/PhysRevLett.98.146401">https://doi.org/10.1103/PhysRevLett.98.146401</a>&#160;<a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:6">
<p>Smith, J. S., Isayev, O., &amp; Roitberg, A. E. (2017). ANI-1: an extensible neural network potential with DFT accuracy at force field computational cost. <em>Chemical Science</em>, 8(4), 3192-3203. <a href="https://doi.org/10.1039/C6SC05720A">https://doi.org/10.1039/C6SC05720A</a>&#160;<a href="#fnref:6" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:7">
<p>Sipka, M., Dietschreit, J. C. B., Grajciar, L., &amp; Gómez-Bombarelli, R. (2023). Differentiable simulations for enhanced sampling of rare events. In <em>International Conference on Machine Learning</em> (pp. 31990-32007). PMLR.&#160;<a href="#fnref:7" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:8">
<p>Sun, L., Vandermause, J., Batzner, S., Xie, Y., Clark, D., Chen, W., &amp; Kozinsky, B. (2022). Multitask machine learning of collective variables for enhanced sampling of rare events. <em>Journal of Chemical Theory and Computation</em>, 18(4), 2341-2353.&#160;<a href="#fnref:8" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:9">
<p>LED-Molecular Repository - Original implementation of the Müller-Brown potential. <a href="https://github.com/cselab/LED-Molecular">https://github.com/cselab/LED-Molecular</a>&#160;<a href="#fnref:9" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:10">
<p>Vlachas, P. R., Zavadlav, J., Praprotnik, M., &amp; Koumoutsakos, P. (2022). Accelerated simulations of molecular systems through learning of effective dynamics. <em>Journal of Chemical Theory and Computation</em>, 18(1), 538-549. <a href="https://pubs.acs.org/doi/10.1021/acs.jctc.1c00809">https://pubs.acs.org/doi/10.1021/acs.jctc.1c00809</a>&#160;<a href="#fnref:10" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:11">
<p>Vlachas, P. R., Arampatzis, G., Uhler, C., &amp; Koumoutsakos, P. (2022). Multiscale simulations of complex systems by learning their effective dynamics. <em>Nature Machine Intelligence</em>. <a href="https://www.nature.com/articles/s42256-022-00464-w">https://www.nature.com/articles/s42256-022-00464-w</a>&#160;<a href="#fnref:11" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>Modernizing Rahman's 1964 Argon Simulation</title><link>https://hunterheidenreich.com/posts/rahman-1964-lammps-liquid-argon/</link><pubDate>Sat, 23 Aug 2025 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/rahman-1964-lammps-liquid-argon/</guid><description>How I used modern software engineering (caching, vectorization, and dependency locking) to reproduce a 60-year-old physics milestone.</description><content:encoded><![CDATA[<p>Some papers invent entire fields. Aneesur Rahman&rsquo;s 1964 paper, <strong>&ldquo;Correlations in the Motion of Atoms in Liquid Argon&rdquo;</strong>, is the &ldquo;Hello World&rdquo; of molecular dynamics (MD). Using a computer with less memory than a modern microwave, Rahman solved Newton&rsquo;s equations for 864 atoms and proved that liquids have distinct, quantifiable structure.</p>
<p>The physics of liquid argon is a solved problem. We know the answer.</p>
<p>So, why replicate it in 2025? <strong>To apply modern engineering standards to legacy science.</strong></p>
<p>This project served as an exercise in <strong>software archaeology</strong>: taking a vintage scientific workflow and rebuilding it with a modular Python analysis pipeline. I wanted to see if I could replace Rahman&rsquo;s &ldquo;write-once&rdquo; Fortran mentality with modern reproducibility, type safety, and intelligent caching.</p>
<p>The full source code is available on <a href="https://github.com/hunter-heidenreich/argon-simulation">GitHub</a>. The complete project overview, including analysis results and pipeline architecture, is on the <a href="/projects/rahman-1964-replication/">Rahman 1964 Replication project page</a>.</p>
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
			<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube-nocookie.com/embed/KjFixUt6bnQ?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
		</div>

<hr>
<h2 id="engineering-the-pipeline">Engineering the Pipeline</h2>
<p>The most interesting part of this project isn&rsquo;t the simulation engine (LAMMPS handles that); it&rsquo;s the architecture of the analysis suite. MD analysis is computationally expensive ($O(N^2)$), and iterating on plots can be painfully slow if you re-compute trajectory data every time.</p>
<p>Why bother? Don&rsquo;t modern MD packages come with analysis tools?
Well, some say that writing is thinking.
Sometimes getting into the weeds of how an algorithm works or an analysis is performed, you gain insights and a deeper understanding that might be obscured by a plug-and-play tool.</p>
<h3 id="intelligent-caching">Intelligent Caching</h3>
<p>I built the <code>argon_sim</code> package with a decorator-based caching layer. The system hashes the source file&rsquo;s modification time and the function&rsquo;s arguments to avoid re-calculating the Radial Distribution Function (RDF) or Van Hove correlations on every script run.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#a6e22e">@cached_computation</span>(<span style="color:#e6db74">&#34;gr&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">compute_radial_distribution</span>(filename: str, dr: float <span style="color:#f92672">=</span> <span style="color:#ae81ff">0.05</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># ... expensive O(N^2) distance calculations ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> r_values, g_r, density
</span></span></code></pre></div><p>If I tweak a plot axis, the script runs instantly, loading pre-computed arrays from disk instead of re-running the $O(N^2)$ computation. If I change the simulation trajectory, the cache invalidates automatically.</p>
<h3 id="vectorization--memory-management">Vectorization &amp; Memory Management</h3>
<p>Rahman likely relied on nested loops. Python is too slow for that. I utilized <strong>NumPy broadcasting</strong> to vectorize the calculation of atomic displacements.</p>
<p>However, calculating an $864 \times 864$ distance matrix for 5,000 frames consumes significant RAM. I implemented a <strong>chunked MSD (Mean Square Displacement) algorithm</strong> that processes the trajectory in blocks, balancing vectorization speed with memory constraints. The chunking trades some vectorization speed for a bounded memory footprint, so the analysis is not capped by holding the full distance matrix in RAM.</p>
<h3 id="reproducibility-as-a-feature">Reproducibility as a Feature</h3>
<p>Academic code is notorious for &ldquo;it works on my machine.&rdquo; To combat this, I used <strong><code>uv</code></strong> for dependency management, locking the exact environment state. The entire workflow (from simulation to final figure generation) is abstracted into a <code>Makefile</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># One command to run the physics, analyze data, and generate plots</span>
</span></span><span style="display:flex;"><span>make workflow
</span></span></code></pre></div><hr>
<h2 id="the-simulation-1964-vs-2025">The Simulation: 1964 vs. 2025</h2>
<p>I preserved Rahman&rsquo;s physical parameters exactly to ensure a fair comparison:</p>
<ul>
<li><strong>System</strong>: 864 Argon atoms</li>
<li><strong>Potential</strong>: Lennard-Jones ($\sigma = 3.4$ Å, $\epsilon/k_B = 120$ K)</li>
<li><strong>Target</strong>: 94.4 K, 1.374 g/cm³</li>
</ul>
<p>However, I modernized the <em>numerical</em> methods to ensure stability:</p>
<table>
	<thead>
			<tr>
					<th style="text-align: left">Feature</th>
					<th style="text-align: left">Rahman (1964)</th>
					<th style="text-align: left">This Work (2025)</th>
					<th style="text-align: left">Why it Matters</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: left"><strong>Integration</strong></td>
					<td style="text-align: left">Predictor-Corrector</td>
					<td style="text-align: left">Velocity Verlet</td>
					<td style="text-align: left">Better energy conservation over long runs</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Timestep</strong></td>
					<td style="text-align: left">10 fs</td>
					<td style="text-align: left">2 fs</td>
					<td style="text-align: left">Rahman&rsquo;s step was aggressive; 2 fs ensures numerical stability</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Equilibration</strong></td>
					<td style="text-align: left">Velocity Scaling</td>
					<td style="text-align: left">1 ns NVT</td>
					<td style="text-align: left">Rahman couldn&rsquo;t afford long equilibrations; I melted the crystal properly to remove bias</td>
			</tr>
	</tbody>
</table>
<p>The production run lasted 10 ps in the NVE ensemble, generating 5,001 frames. Temperature remained within 1% of target with an RMS fluctuation of 0.0165.</p>
<hr>
<h2 id="validation-results">Validation Results</h2>
<p>The replication was quantitatively successful. The analysis pipeline faithfully reproduced every key signature of liquid argon.</p>
<h3 id="the-cage-effect">The Cage Effect</h3>
<p>This is the paper&rsquo;s crown jewel. In a gas, velocity correlations decay exponentially. In a liquid, Rahman discovered that atoms get trapped by their neighbors and bounce back, causing the correlation to go <em>negative</em>.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-velocity-autocorrelation.webp"
         alt="Velocity Autocorrelation Function"
         title="Velocity Autocorrelation Function"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The VACF dips below zero at 0.3 ps. This &rsquo;negative correlation&rsquo; is the signature of the cage effect: atoms rattling against their neighbors.</figcaption>
    
</figure>

<p>My simulation captures this minimum at -0.083, matching Rahman&rsquo;s observation. The Fourier transform of this data (the frequency spectrum) reveals a peak at $\beta \approx 0.25$, physically representing the frequency of atomic collisions within the cage.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-vacf-frequency-spectrum.webp"
         alt="Frequency spectrum of the VACF showing characteristic peak from atomic caging effects"
         title="Frequency spectrum of the VACF showing characteristic peak from atomic caging effects"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Frequency spectrum of the VACF showing characteristic peak from atomic caging effects</figcaption>
    
</figure>

<h3 id="structural-fingerprints">Structural Fingerprints</h3>
<p>The Radial Distribution Function $g(r)$ and its Fourier transform, the Structure Factor $S(k)$, are the &ldquo;fingerprints&rdquo; of a liquid&rsquo;s structure.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-radial-distribution-function.webp"
         alt="Radial Distribution Function and Structure Factor"
         title="Radial Distribution Function and Structure Factor"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The sharp first peak (3.82 Å) shows defined nearest neighbors, while the decay shows the lack of long-range order. My calculated peaks match Rahman&rsquo;s within 3%.</figcaption>
    
</figure>

<p>The agreement here is striking. My first peak appeared at <strong>3.82 Å</strong> (Rahman: 3.7 Å). The slight discrepancy is likely due to my improved equilibration method, which allowed the system to relax into a more natural liquid state than Rahman&rsquo;s 1960s hardware allowed.</p>
<h3 id="diffusion-and-non-gaussian-behavior">Diffusion and Non-Gaussian Behavior</h3>
<p>By calculating the Mean Square Displacement (MSD), I derived a diffusion coefficient of <strong>$D = 2.47 \times 10^{-5}$ cm²/s</strong>, which deviates only <strong>2%</strong> from Rahman&rsquo;s reported $2.43 \times 10^{-5}$.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-mean-square-displacement.webp"
         alt="Mean Square Displacement vs time showing ballistic to diffusive transition"
         title="Mean Square Displacement vs time showing ballistic to diffusive transition"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Mean Square Displacement vs. time showing ballistic to diffusive transition</figcaption>
    
</figure>

<p>More interestingly, I reproduced the &ldquo;Non-Gaussian&rdquo; parameters. Standard diffusion assumes a Gaussian distribution of displacements. Rahman found (and I confirmed) that liquid atoms deviate from this. They exhibit &ldquo;jump&rdquo; and &ldquo;wait&rdquo; dynamics, a behavior that standard Brownian motion models fail to capture.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-non-gaussian-parameters.webp"
         alt="Non-Gaussian parameters showing deviation from simple diffusive behavior"
         title="Non-Gaussian parameters showing deviation from simple diffusive behavior"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Evidence that atoms do not follow a simple random walk. The non-zero alpha parameters indicate heterogeneous dynamics.</figcaption>
    
</figure>

<h3 id="advanced-analysis-van-hove-functions">Advanced Analysis: Van Hove Functions</h3>
<p>Rahman also explored advanced properties like the Van Hove correlation function $G(r,t)$, which describes how liquid structure evolves over time.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-van-hove-correlation.webp"
         alt="Van Hove distinct correlation function G_d(r,t) at two time points"
         title="Van Hove distinct correlation function G_d(r,t) at two time points"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Van Hove distinct correlation function showing how neighbor coordination shells &lsquo;melt&rsquo; as time progresses</figcaption>
    
</figure>

<p>At 1.0 ps, the structure remains well-defined with clear shells. By 2.5 ps, it becomes increasingly diffuse. Rahman compared this evolution to theoretical predictions (the Vineyard approximation) and found that theory predicted overly rapid structural decay. My results confirm this finding.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-delayed-convolution.webp"
         alt="Delayed convolution approximation testing Rahman&#39;s theoretical improvement"
         title="Delayed convolution approximation testing Rahman&#39;s theoretical improvement"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Testing Rahman&rsquo;s &lsquo;delayed convolution approximation&rsquo; (his proposed improvement over existing theory)</figcaption>
    
</figure>

<hr>
<h2 id="system-validation">System Validation</h2>
<p>Before analyzing physics, basic sanity checks confirmed proper thermal equilibrium.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-temperature-stability.webp"
         alt="Temperature vs time plot showing excellent temperature control around 94.4 K target"
         title="Temperature vs time plot showing excellent temperature control around 94.4 K target"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Temperature vs. Time - 5001 frames showing excellent temperature control with mean 94.73 K</figcaption>
    
</figure>

<p>Mean temperature was 94.73 K (0.33 K off target) with a standard deviation of 1.56 K.</p>















<figure class="post-figure center ">
    <img src="/img/rahman-1964-argon-molecular-dynamics/rahman-argon-maxwell-boltzmann-velocity.webp"
         alt="Maxwell-Boltzmann velocity distribution"
         title="Maxwell-Boltzmann velocity distribution"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Maxwell-Boltzmann velocity distribution from 12.9 million velocity components</figcaption>
    
</figure>

<p>The velocity distribution from 12.9 million velocity components produces a clean Maxwell-Boltzmann distribution, as expected for thermal equilibrium. The distribution widths at various heights closely match Rahman&rsquo;s results: 1.77, 2.48, and 3.56 compared to his 1.77, 2.52, and 3.52.</p>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>Replicating a 60-year-old paper might seem like a solved puzzle, but it teaches a valuable lesson in computational science. Rahman relied on brilliance and raw mathematical intuition because he lacked compute power. Today, pairing modern compute with disciplined software practices makes the same result reproducible and auditable.</p>
<p>Applying modern software engineering (<strong>modular architecture, caching, and automated workflows</strong>) to classical physics reproduces the past and builds a foundation that makes the <em>next</em> discovery easier, faster, and more reliable.</p>
<p>The quantitative agreement is striking: diffusion coefficients within 2%, structural peaks within 0.1 Å, velocity distributions matching to three significant figures. This level of reproducibility, achieved with completely different hardware and software, validates something fundamental: Rahman&rsquo;s physical model was remarkably sound, and his computational methodology was scientifically rigorous despite 1960s constraints.</p>
<p>The cage effect, velocity correlations, and structural evolution are fundamental characteristics of how matter behaves at the atomic scale, as relevant today as they were six decades ago.</p>
]]></content:encoded></item><item><title>GEOM Dataset: 3D Molecular Conformer Generation</title><link>https://hunterheidenreich.com/posts/geom-conformer-generation-dataset/</link><pubDate>Fri, 15 Aug 2025 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/geom-conformer-generation-dataset/</guid><description>Learn how GEOM transforms 2D molecular graphs into dynamic 3D conformer ensembles for molecular machine learning applications.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>In molecular machine learning, we often start with a 2D graph, a blueprint of atoms and bonds. A molecule&rsquo;s function is deeply tied to its dynamic 3D shape. Molecules are flexible entities that exist as an <strong>ensemble of low-energy conformations</strong>. Capturing 3D molecular shapes is crucial for predicting molecular behavior.</p>
<p>The <a href="/notes/chemistry/datasets/geom/">GEOM</a> (Geometric Ensemble Of Molecules) dataset was created to bridge this gap. It provides a massive collection of high-quality 3D conformer ensembles, transforming static 2D graphs into something much closer to physical reality. This makes it an invaluable resource for anyone working in geometric deep learning for chemistry and drug discovery.</p>















<figure class="post-figure center ">
    <img src="https://media.springernature.com/full/springer-static/image/art%3A10.1038%2Fs41597-022-01288-4/MediaObjects/41597_2022_1288_Fig1_HTML.png?as=webp"
         alt="Overlay of conformers for a complex molecule"
         title="Overlay of conformers for a complex molecule"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">3D conformer ensembles expand upon 2D blueprints by revealing the diverse shapes the latanoprost molecule adopts.</figcaption>
    
</figure>

<h2 id="the-challenge-of-conformer-generation">The Challenge of Conformer Generation</h2>
<p>Generating 3D structures for every molecule is computationally hard for two main reasons:</p>
<ol>
<li><strong>Combinatorial Explosion</strong>: Think of a molecule with several rotatable bonds. Each bond is like a joint that can be twisted. The number of possible 3D shapes grows exponentially with each new joint. Trying every combination is impractical for most molecules.</li>
<li><strong>Speed vs. Accuracy</strong>: We need to calculate the energy of each shape to know if it&rsquo;s realistic (low energy). Classical <strong>force fields</strong> are fast. <strong>Density Functional Theory (DFT)</strong> provides quantum mechanical accuracy.</li>
</ol>
<p>GEOM uses a semi-empirical method to capture the underlying quantum mechanics efficiently, enabling the generation of millions of conformations for a large dataset.</p>
<h2 id="a-deeper-look-inside-the-geom-dataset">A Deeper Look Inside the GEOM Dataset</h2>
<p>The scale of GEOM is impressive: over <strong>37 million conformations</strong> for more than <strong>450,000 unique molecules</strong>. But the numbers in the paper&rsquo;s tables tell a more interesting story about the dataset&rsquo;s composition.</p>
<table>
	<thead>
			<tr>
					<th style="text-align: left">AICures drug dataset (N=304,466)</th>
					<th style="text-align: left">Mean</th>
					<th style="text-align: left">Max</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: left">Number of heavy atoms</td>
					<td style="text-align: left">24.9</td>
					<td style="text-align: left">91</td>
			</tr>
			<tr>
					<td style="text-align: left">Number of rotatable bonds</td>
					<td style="text-align: left">6.5</td>
					<td style="text-align: left">53</td>
			</tr>
			<tr>
					<td style="text-align: left">Conformers</td>
					<td style="text-align: left">102.6</td>
					<td style="text-align: left">7,451</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>QM9 dataset (N=133,258)</strong></td>
					<td style="text-align: left"><strong>Mean</strong></td>
					<td style="text-align: left"><strong>Max</strong></td>
			</tr>
			<tr>
					<td style="text-align: left">Number of heavy atoms</td>
					<td style="text-align: left">8.8</td>
					<td style="text-align: left">9</td>
			</tr>
			<tr>
					<td style="text-align: left">Number of rotatable bonds</td>
					<td style="text-align: left">2.2</td>
					<td style="text-align: left">8</td>
			</tr>
			<tr>
					<td style="text-align: left">Conformers</td>
					<td style="text-align: left">13.5</td>
					<td style="text-align: left">1,101</td>
			</tr>
	</tbody>
</table>
<p><em>A simplified view of Tables 1 &amp; 4 from the paper, highlighting the key differences.</em></p>
<p>What does this tell us?</p>
<ul>
<li><strong>Two Worlds of Molecules</strong>: The dataset is clearly split. The <strong>QM9</strong> subset contains small, relatively rigid molecules (mean of 2.2 rotatable bonds). In contrast, the <strong>AICures</strong> subset contains larger, more flexible drug-like molecules (mean of 6.5 rotatable bonds, with one molecule having 53!). This diversity is ideal for training machine learning models that need to generalize from simple cases to complex, real-world examples.</li>
<li><strong>Conformational Complexity</strong>: The number of conformers found per molecule reflects this flexibility. A typical QM9 molecule has about 13 conformers, while a drug-like molecule has over 100 on average. This highlights the necessity of 3D ensembles for flexible molecules.</li>
</ul>
<p>Beyond the structures themselves, GEOM is rich with experimental data, connecting the 3D shapes to real-world properties. The molecules are labeled with data for everything from <strong>water solubility</strong> and <strong>blood-brain barrier penetration</strong> to <strong>toxicity</strong> and inhibition of key viral targets like the <strong>SARS-CoV-2 3CL protease</strong>. This makes it a powerful tool for developing property prediction models.</p>
<p>In fact, this creates a benchmark for:</p>
<ul>
<li>Property prediction models that can leverage conformer ensembles (or members of the ensemble) as input.</li>
<li>Conformer generation models that must transform 2D graphs into realistic, 3D distributions.</li>
<li>End-to-end property-based evaluation of the conformer ensembles generated by a model.</li>
</ul>
<h2 id="the-toolbox-behind-geom-key-techniques-explained">The Toolbox Behind GEOM: Key Techniques Explained</h2>
<p>The GEOM paper mentions several advanced computational chemistry methods. Let&rsquo;s briefly break down the most important ones:</p>
<ul>
<li><strong>GFN2-xTB</strong>: This is the semi-empirical quantum mechanical method used to calculate energies and forces in GEOM. Think of it as a &ldquo;middle ground&rdquo; method. It provides greater speed than full DFT while capturing electronic effects absent in classical force fields, making it a pragmatic choice for generating a large dataset.</li>
<li><strong>CREST</strong>: This is the program that actually performs the conformer search. It uses a clever technique based on <strong>metadynamics</strong>, where it simulates the molecule&rsquo;s movement and adds a &ldquo;penalty&rdquo; potential to discourage it from revisiting shapes it has already seen. This pushes the molecule to explore its conformational space efficiently, finding many diverse, low-energy structures.</li>
<li><strong>CENSO</strong>: For a small subset of molecules, the authors went a step further with CENSO. This program takes the conformers found by CREST and refines them with more accurate (and expensive) DFT calculations. It&rsquo;s a way of getting very high-quality &ldquo;gold standard&rdquo; data for benchmarking.</li>
<li><strong>Implicit Solvent Models</strong>: Molecules in the body exist in aqueous environments. Methods like <strong>C-PCM</strong> and <strong>ALPB</strong> model water as a continuous medium, which affects the molecule&rsquo;s preferred shape and energy. This is crucial for biological applications.</li>
</ul>
<h2 id="the-math-behind-the-molecules-explained-simply">The Math Behind the Molecules (Explained Simply)</h2>
<p>The paper includes a couple of equations based on the Boltzmann distribution, which is a fundamental concept from statistical mechanics that tells us the probability of finding a system in a certain state.</p>
<p>The key equation used by CREST to assign a probability (or &ldquo;statistical weight&rdquo;) to the <em>i</em>-th conformer is:</p>
<p>$$ P_{i}^{\text{CREST}} = \frac{d_{i}\exp(-E_{i}/k_{B}T)}{\sum_{j}d_{j}\exp(-E_{j}/k_{B}T)} $$</p>
<p>Let&rsquo;s demystify this:</p>
<ul>
<li>$E_i$ is the energy of the conformer. The negative sign and the exponential mean that <strong>lower energy leads to a much higher probability</strong>.</li>
<li>$k_B T$ is the thermal energy at a given temperature $T$. It sets the energy scale. If the energy difference between two conformers is much larger than $k_B T$, the higher-energy one will be virtually nonexistent.</li>
<li>$d_i$ represents the degeneracy of the conformer, which accounts for the number of equivalent states or configurations that share the same energy $E_i$.
<ul>
<li>Degeneracy refers to the number of equivalent, indistinguishable atomic arrangements (rotamers) that correspond to a single overall molecular shape (conformer). For example, the rotation of a methyl group ($-\text{CH}_3$) produces multiple identical-looking orientations of its hydrogen atoms.</li>
</ul>
</li>
<li>The denominator, $\sum_{j}d_{j}\exp(-E_{j}/k_{B}T)$, is the <strong>partition function</strong>. Its job is to sum up the terms from all possible conformers to ensure that all the probabilities add up to 100%.</li>
</ul>
<p>For the high-quality CENSO calculations, the equation uses the <strong>Gibbs Free Energy ($G_i$)</strong>. Free energy provides a complete measure by including the molecule&rsquo;s internal energy, its interaction with a solvent, and entropic effects (like how much it can &ldquo;wiggle&rdquo;). This gives a more accurate ranking of the conformer probabilities.</p>
<h2 id="a-closer-look-at-the-figures-what-the-data-really-shows">A Closer Look at the Figures: What the Data Really Shows</h2>
<p>The paper&rsquo;s figures offer some honest insights into the dataset&rsquo;s quality and the trade-offs involved.</p>















<figure class="post-figure center ">
    <img src="https://media.springernature.com/full/springer-static/image/art%3A10.1038%2Fs41597-022-01288-4/MediaObjects/41597_2022_1288_Fig4_HTML.png?as=webp"
         alt="Scatter plot comparing energy calculation methods."
         title="Scatter plot comparing energy calculation methods."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Comparing the &lsquo;fast&rsquo; GFN2-xTB energies with &lsquo;accurate&rsquo; DFT energies. (a) There&rsquo;s a clear correlation, but also a lot of spread. (b) The ranking accuracy (Spearman ρ) is decent on average (0.39) but highly variable.</figcaption>
    
</figure>

<p>Figure 4 is particularly important. It compares the fast GFN2-xTB (CREST) energies with much more accurate single-point r2scan-3c DFT energies.</p>
<ul>
<li>The <strong>Mean Absolute Error (MAE) of 1.96 kcal/mol</strong> shows that, on average, the fast method gets the energy wrong by about 2 kcal/mol. At room temperature, the thermal energy ($k_B T$) is only about 0.6 kcal/mol. Because the Boltzmann probability depends on the energy _exponentially_, a 2 kcal/mol error can dramatically change the predicted importance of a conformer.</li>
<li>The <strong>Spearman correlation plot</strong> (right side) shows how well GFN2-xTB <em>ranks</em> the conformers from lowest to highest energy compared to DFT. An average correlation of 0.39 provides a strong baseline, though the wide distribution indicates variable performance across different molecules. The ranking accuracy fluctuates, achieving near perfection for certain molecules and showing significant deviation for others.</li>
</ul>
<p>This is a key takeaway: the GFN2-xTB/CREST method excels at <strong>discovering</strong> low-energy shapes. For accurate probability <strong>ranking</strong>, the higher-level DFT energies provided in GEOM are required.</p>
<h2 id="conclusion-what-this-means-for-machine-learning">Conclusion: What This Means for Machine Learning</h2>
<p>For researchers at the intersection of machine learning and chemistry, GEOM provides a realistic foundation to build upon. By shifting the focus from static 2D graphs to dynamic 3D ensembles, GEOM enables a new generation of models.</p>
<p>This dataset is an ideal training ground for models designed to understand 3D geometry, such as <strong>SE(3)-equivariant neural networks</strong>, <strong>diffusion models</strong>, <strong>transformers</strong>, and <strong>VAEs</strong>, which can learn to generate conformer ensembles directly from a 2D graph. By training on GEOM, these models can learn the complex relationship between a molecule&rsquo;s chemical blueprint and its real-world, flexible nature.</p>
<p>For a comprehensive technical reference including detailed specifications, quality metrics, and performance leaderboards, see my <a href="/notes/chemistry/datasets/geom/">GEOM Dataset Card</a>.</p>
<p>Explore the GEOM dataset further by visiting its <a href="https://github.com/learningmatter-mit/geom">GitHub repository</a>.</p>
<h2 id="references">References</h2>
<ul>
<li>Axelrod, S. &amp; Gómez-Bombarelli, R. &ldquo;GEOM, energy-annotated molecular conformations for property prediction and molecular generation.&rdquo; <em>Scientific Data</em> 9, 185 (2022). <a href="https://doi.org/10.1038/s41597-022-01288-4">https://doi.org/10.1038/s41597-022-01288-4</a></li>
<li>GitHub repositories:
<ul>
<li><a href="https://github.com/learningmatter-mit/geom">learningmatter-mit/geom</a></li>
<li><a href="https://github.com/learningmatter-mit/NeuralForceField">learningmatter-mit/NeuralForceField</a></li>
</ul>
</li>
</ul>
]]></content:encoded></item><item><title>Modern PyTorch VAEs: A Detailed Implementation Guide</title><link>https://hunterheidenreich.com/posts/modern-variational-autoencoder-in-pytorch/</link><pubDate>Sun, 03 Mar 2024 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/modern-variational-autoencoder-in-pytorch/</guid><description>Complete PyTorch VAE tutorial: Copy-paste code, ELBO derivation, KL annealing, and stable softplus parameterization.</description><content:encoded><![CDATA[<h2 id="what-is-a-variational-autoencoder">What is a Variational Autoencoder?</h2>
<p>A Variational Autoencoder (VAE) is a type of <strong>generative model</strong>, meaning its primary purpose is to learn the underlying structure of a dataset so it can generate new, similar data.</p>
<p>Whether the data is images, raw audio clips, or 2D graphs of drug-like molecules, a VAE aims to capture the essential features that define the data distribution. Once trained, it should be able to create entirely new samples that resemble the training data without simply copying specific examples.</p>
<p>Introduced by Kingma and Welling in 2013 (<a href="/notes/machine-learning/generative-models/autoencoding-variational-bayes/">Auto-Encoding Variational Bayes</a>, <a href="https://arxiv.org/abs/1312.6114">Paper</a>), VAEs are used for:</p>
<ul>
<li><strong>Generation</strong>: Creating new data (images, music, text).</li>
<li><strong>Dimensionality Reduction</strong>: Compressing data into a much smaller, meaningful representation (a &ldquo;latent space&rdquo;).</li>
<li><strong>Imputation</strong>: Intelligently filling in missing data (e.g., denoising images).</li>
</ul>
<p>Importantly, they aim to provide a structured and continuous latent space, which allows for smooth interpolation between data points and meaningful manipulations of generated samples (think: optimization).</p>
<h2 id="tldr-the-complete-pytorch-implementation">TL;DR: The Complete PyTorch Implementation</h2>
<p>For those who just want the code, here is a complete, modern VAE implementation in PyTorch. It features <strong>softplus standard deviation parameterization</strong> for numerical stability and a <strong>custom training step</strong> that handles the ELBO loss correctly.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch.nn <span style="color:#66d9ef">as</span> nn
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch.nn.functional <span style="color:#66d9ef">as</span> F
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> dataclasses <span style="color:#f92672">import</span> dataclass
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@dataclass</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">VAEOutput</span>:
</span></span><span style="display:flex;"><span>    z: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    mu: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    std: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    x_recon: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    loss: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    loss_recon: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    loss_kl: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">VAE</span>(nn<span style="color:#f92672">.</span>Module):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">__init__</span>(self, input_dim<span style="color:#f92672">=</span><span style="color:#ae81ff">784</span>, hidden_dim<span style="color:#f92672">=</span><span style="color:#ae81ff">512</span>, latent_dim<span style="color:#f92672">=</span><span style="color:#ae81ff">16</span>):
</span></span><span style="display:flex;"><span>        super()<span style="color:#f92672">.</span><span style="color:#a6e22e">__init__</span>()
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>encoder <span style="color:#f92672">=</span> nn<span style="color:#f92672">.</span>Sequential(
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(input_dim, hidden_dim),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Tanh(),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(hidden_dim, hidden_dim),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Tanh()
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>fc_mu <span style="color:#f92672">=</span> nn<span style="color:#f92672">.</span>Linear(hidden_dim, latent_dim)
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>fc_std <span style="color:#f92672">=</span> nn<span style="color:#f92672">.</span>Linear(hidden_dim, latent_dim)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>decoder <span style="color:#f92672">=</span> nn<span style="color:#f92672">.</span>Sequential(
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(latent_dim, hidden_dim),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Tanh(),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(hidden_dim, hidden_dim),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Tanh(),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(hidden_dim, input_dim)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">encode</span>(self, x):
</span></span><span style="display:flex;"><span>        h <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>encoder(x)
</span></span><span style="display:flex;"><span>        mu <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>fc_mu(h)
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Softplus + epsilon for stable std deviation</span>
</span></span><span style="display:flex;"><span>        std <span style="color:#f92672">=</span> F<span style="color:#f92672">.</span>softplus(self<span style="color:#f92672">.</span>fc_std(h)) <span style="color:#f92672">+</span> <span style="color:#ae81ff">1e-6</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> mu, std
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">reparameterize</span>(self, mu, std):
</span></span><span style="display:flex;"><span>        eps <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>randn_like(std)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> mu <span style="color:#f92672">+</span> eps <span style="color:#f92672">*</span> std
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">decode</span>(self, z):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> self<span style="color:#f92672">.</span>decoder(z)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">forward</span>(self, x, kl_weight<span style="color:#f92672">=</span><span style="color:#ae81ff">1.0</span>):
</span></span><span style="display:flex;"><span>        mu, std <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>encode(x)
</span></span><span style="display:flex;"><span>        z <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>reparameterize(mu, std)
</span></span><span style="display:flex;"><span>        x_recon <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>decode(z)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 1. Reconstruction Loss (Binary Cross Entropy for MNIST)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Sum over features, mean over batch</span>
</span></span><span style="display:flex;"><span>        recon_loss <span style="color:#f92672">=</span> F<span style="color:#f92672">.</span>binary_cross_entropy_with_logits(x_recon, x, reduction<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;none&#39;</span>)<span style="color:#f92672">.</span>sum(dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)<span style="color:#f92672">.</span>mean()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 2. KL Divergence</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Analytic KL for Normal distributions</span>
</span></span><span style="display:flex;"><span>        kl_loss <span style="color:#f92672">=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>sum(<span style="color:#ae81ff">1</span> <span style="color:#f92672">+</span> torch<span style="color:#f92672">.</span>log(std<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span>) <span style="color:#f92672">-</span> mu<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span> <span style="color:#f92672">-</span> std<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span>, dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)<span style="color:#f92672">.</span>mean()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 3. Total Loss (ELBO)</span>
</span></span><span style="display:flex;"><span>        loss <span style="color:#f92672">=</span> recon_loss <span style="color:#f92672">+</span> (kl_weight <span style="color:#f92672">*</span> kl_loss)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> VAEOutput(z, mu, std, x_recon, loss, recon_loss, kl_loss)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># --- Training Loop Example ---</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">train_step</span>(model, batch, optimizer, kl_weight<span style="color:#f92672">=</span><span style="color:#ae81ff">1.0</span>):
</span></span><span style="display:flex;"><span>    model<span style="color:#f92672">.</span>train()
</span></span><span style="display:flex;"><span>    optimizer<span style="color:#f92672">.</span>zero_grad()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Forward pass</span>
</span></span><span style="display:flex;"><span>    output <span style="color:#f92672">=</span> model(batch, kl_weight)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Backward pass</span>
</span></span><span style="display:flex;"><span>    output<span style="color:#f92672">.</span>loss<span style="color:#f92672">.</span>backward()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Gradient clipping (recommended)</span>
</span></span><span style="display:flex;"><span>    torch<span style="color:#f92672">.</span>nn<span style="color:#f92672">.</span>utils<span style="color:#f92672">.</span>clip_grad_norm_(model<span style="color:#f92672">.</span>parameters(), max_norm<span style="color:#f92672">=</span><span style="color:#ae81ff">1.0</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    optimizer<span style="color:#f92672">.</span>step()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> output<span style="color:#f92672">.</span>loss<span style="color:#f92672">.</span>item()
</span></span></code></pre></div><h3 id="the-core-idea-learning-to-generate">The Core Idea: Learning to Generate</h3>
<p>The VAE is built on a key assumption: our complex, high-dimensional data (like a $28 \times 28$ pixel image, $\mathbf{x}$) is actually <em>generated</em> by some simpler, low-dimensional, unobserved variable (a &ldquo;latent&rdquo; variable, $\mathbf{z}$).</p>
<blockquote>
<p><strong>A Physical Metaphor: Water Molecules and Phase Diagrams</strong></p>
<p>Consider a glass of water. At the microscopic level, you have more than $10^{24}$ $\text{H}_2\text{O}$ molecules bouncing around in an incredibly high-dimensional space. Each molecule has position, velocity, and interactions with its neighbors, computationally intractable to track directly. Yet we can describe the <em>macroscopic behavior</em> of all these molecules using just two simple variables: <strong>temperature</strong> and <strong>pressure</strong>. These two dimensions create a &ldquo;phase diagram&rdquo; that tells us whether our water will be ice, liquid, or vapor. The temperature and pressure are &ldquo;latent variables&rdquo; that capture the essential physics governing this complex molecular dance.</p>
</blockquote>















<figure class="post-figure center ">
    <img src="/img/vae-tut/phase-diagram.webp"
         alt="Water phase diagram showing solid, liquid, and gas phases as functions of temperature and pressure"
         title="Water phase diagram showing solid, liquid, and gas phases as functions of temperature and pressure"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">A water phase diagram: Complex molecular behavior reduced to two simple variables (temperature and pressure). This illustrates how high-dimensional systems can often be understood through low-dimensional latent representations.</figcaption>
    
</figure>

<p>A VAE makes the same assumption: complex data (like images) emerges from simpler underlying factors. A handwritten digit might be generated by latent factors like &ldquo;pen thickness,&rdquo; &ldquo;writing angle,&rdquo; &ldquo;digit style,&rdquo; and &ldquo;size,&rdquo; a much simpler description than tracking all 784 pixel values independently.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/hypothetical-mnist-factors.webp"
         alt="Hypothetical illustration of MNIST digits generated from latent factors like pen thickness, angle, style, and size"
         title="Hypothetical illustration of MNIST digits generated from latent factors like pen thickness, angle, style, and size"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">A hypothetical illustration showing how MNIST digits could be generated from a few latent factors like pen thickness, writing angle, digit style, and size.</figcaption>
    
</figure>

<p>The VAE learns two functions: one that maps from complex data ($\mathbf{x}$) to these descriptive factors ($\mathbf{z}$), and another that maps from these factors back to the data. It accomplishes this with two main components, typically implemented as neural networks:</p>
<p><strong>1. The Encoder (Recognition Model)</strong></p>
<p>This network takes a complex data point $\mathbf{x}$ (an image) and determines the &ldquo;knob settings&rdquo; $\mathbf{z}$ that could explain or generate it. This allows us to <em>compress</em> or <em>understand</em> the data.</p>
<p>$$q_{\phi}(\mathbf{z} | \mathbf{x})$$</p>
<p>It&rsquo;s like examining a container of molecules and summarizing their complex arrangement into key parameters like temperature and pressure.</p>
<p>Crucially, the encoder outputs the <em>parameters</em> of a probability distribution (a simple Gaussian) that describes $\mathbf{z}$.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/encoding-diagram.webp"
         alt="Diagram mapping MNIST five to a Gaussian distribution in latent space with mean and standard deviation"
         title="Diagram mapping MNIST five to a Gaussian distribution in latent space with mean and standard deviation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The Encoder maps an input image (e.g., an MNIST digit &lsquo;5&rsquo;) to a Gaussian distribution in latent space, characterized by a mean vector and a standard deviation vector.</figcaption>
    
</figure>

<p>For each input $\mathbf{x}$, the encoder network outputs:</p>
<ul>
<li>A vector of means, $\mathbf{\mu}$</li>
<li>A vector of standard deviations, $\mathbf{\sigma}$</li>
</ul>
<p>These parameters define our approximation $q_{\phi}(\mathbf{z} | \mathbf{x}) = \mathcal{N}(\mathbf{z} \mid \mathbf{\mu}, \mathbf{\sigma}^2\mathbf{I})$. We then <em>sample</em> from this distribution to get the $\mathbf{z}$ that we feed to the decoder. This probabilistic step is what forces the latent space to be continuous and structured. It forces similar inputs to map to nearby regions in latent space, enabling smooth interpolation and generation.</p>
<p><strong>2. The Decoder (Generative Model)</strong></p>
<p>This network learns the &ldquo;generative process.&rdquo; It takes a simple latent vector $\mathbf{z}$ and reconstructs the complex data $\mathbf{x}$. This allows us to <em>generate</em> new data by feeding it a random $\mathbf{z}$ and observing what image $\mathbf{x}$ it produces.</p>
<p>$$p_{\theta}(\mathbf{x} | \mathbf{z})$$</p>
<p>The decoder reverses the encoder: it takes the simple latent representation and &ldquo;paints&rdquo; the full, complex image from it. It&rsquo;s like taking temperature and pressure values and producing a detailed arrangement of water molecules consistent with those conditions. The goal is to reproduce the exact input as closely as possible.</p>
<p>After training, we have two networks that can be used for a variety of purposes:</p>
<ul>
<li><strong>Generation</strong>: If the latent space is well-structured, we can sample random $\mathbf{z}$ vectors from a simple distribution (like a standard normal) and feed them into the Decoder to generate new images. This is particularly useful for searching for data points with desired properties, like in drug discovery, where we might want to generate molecules with specific characteristics.</li>
<li><strong>Compression</strong>: The Encoder can compress complex data into a low-dimensional latent space, which can be useful for visualization or as a feature extractor for other tasks.</li>
</ul>
<h3 id="the-variational-problem">The &ldquo;Variational&rdquo; Problem</h3>
<p>Calculating the <em>true</em> distribution of latent variables $p_{\theta}(\mathbf{z}|\mathbf{x})$ (the posterior) is mathematically intractable.</p>
<p>This intractability arises from Bayes&rsquo; theorem:</p>
<p>$$p_{\theta}(\mathbf{z} | \mathbf{x}) = \frac{p_{\theta}(\mathbf{x} | \mathbf{z}) p_{\theta}(\mathbf{z})}{p_{\theta}(\mathbf{x})}$$</p>
<p>Breaking down each component:</p>
<ul>
<li>$p_{\theta}(\mathbf{x} | \mathbf{z})$ is our decoder, which is straightforward to compute given our likelihood model.</li>
<li>$p_{\theta}(\mathbf{z})$ is our prior over latent variables, typically a simple distribution like a standard normal, making it easy to compute.</li>
<li>$p_{\theta}(\mathbf{x})$ is the marginal likelihood of the data. And here lies the problem. It requires integrating over all possible latent variables that could have generated $\mathbf{x}$:
$$p_{\theta}(\mathbf{x}) = \int p_{\theta}(\mathbf{x} | \mathbf{z}) p_{\theta}(\mathbf{z}) d\mathbf{z}$$
It is the normalization factor that ensures the posterior is a valid probability distribution (i.e., sums to 1 over all $\mathbf{z}$).</li>
</ul>
<p>This integral is intractable because it involves integrating over a high-dimensional latent space with a complex likelihood function. No closed-form solution exists, and numerical integration is computationally prohibitive.</p>
<p>This is where the &ldquo;variational&rdquo; approach provides the solution. We approximate the true posterior by learning an encoder, $q_{\phi}(\mathbf{z} | \mathbf{x})$, that serves as a variational approximation to this intractable true distribution. The VAE&rsquo;s training process optimizes this approximation to be as accurate as possible, pushing this learned distribution closer to the true posterior.</p>
<h3 id="the-vae-objective-a-balancing-act">The VAE Objective: A Balancing Act</h3>
<p>To get these two networks (parameterized by $\theta$ and $\phi$) to work together, we train them jointly with a special loss function. This objective has two parts that balance two different goals:</p>
<h4 id="1-reconstruction-loss">1. Reconstruction Loss</h4>
<p>$$E_{q_{\phi}(\mathbf{z} | \mathbf{x})}[\log p_{\theta}(\mathbf{x} | \mathbf{z})]$$</p>
<p>This term asks: &ldquo;How well can we reconstruct our original image?&rdquo; It forces the VAE to be good at its job. The process goes:</p>
<ol>
<li>Take an input point $\mathbf{x}$.</li>
<li>Use the <strong>Encoder</strong> to get its latent representation $\mathbf{z} \sim q_{\phi}(\mathbf{z} | \mathbf{x})$.</li>
<li>Use the <strong>Decoder</strong> to generate a new image $\mathbf{x}&rsquo;$ from $\mathbf{z}$, $\mathbf{x}&rsquo; \sim p_{\theta}(\mathbf{x} | \mathbf{z})$.</li>
<li>Compare $\mathbf{x}$ and $\mathbf{x}&rsquo;$.</li>
</ol>
<p>The reconstruction loss measures the difference between the original and the reconstructed image.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/reconstruction-loss-graphic.webp"
         alt="Graphic illustrating the reconstruction loss between original and reconstructed images"
         title="Graphic illustrating the reconstruction loss between original and reconstructed images"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The Reconstruction Loss measures how closely the Decoder&rsquo;s output matches the original input image.</figcaption>
    
</figure>

<ul>
<li><strong>For continuous inputs</strong> (like general images), this is often Mean Squared Error (MSE).</li>
<li><strong>For inputs in $[0, 1]$</strong> (like MNIST pixel intensities, which after <code>ToTensor()</code> are continuous values in $[0, 1]$), we use Binary Cross-Entropy (BCE). We treat each pixel as an independent Bernoulli variable whose target is its intensity. The decoder outputs the <em>logits</em> for each pixel, and the BCE-with-logits loss (e.g., <code>F.binary_cross_entropy_with_logits</code>) is the numerically stable way to compute the negative log-likelihood.</li>
<li><strong>More generally</strong>, you can output parameters of a desired output distribution. What if you wanted a mixture of Gaussians? The decoder could output the means, variances, and mixture weights, and you could compute the negative log-likelihood accordingly.</li>
</ul>
<p>This loss pushes the encoder to produce useful $\mathbf{z}$ vectors and pushes the decoder to learn how to interpret them accurately.</p>
<h4 id="2-the-kl-divergence-the-regularizer">2. The KL Divergence (The Regularizer)</h4>
<p>$$D_{KL}(q_{\phi}(\mathbf{z} | \mathbf{x}) || p_{\theta}(\mathbf{z}))$$</p>
<p>On its own, the reconstruction loss might &ldquo;cheat.&rdquo; The encoder could learn to map every image to a different, specific point in the latent space, essentially &ldquo;memorizing&rdquo; the data. While this minimizes reconstruction error, it creates a meaningless latent space that fails at generation.</p>
<p>The KL divergence term fixes this. It&rsquo;s a regularizer that forces the latent space to be organized and smooth.</p>
<p>We force the encoder&rsquo;s output, $q_{\phi}(\mathbf{z} | \mathbf{x})$, to be close to a simple, predefined <em>prior distribution</em>, $p_{\theta}(\mathbf{z})$. This prior is almost always a standard normal distribution because it is mathematically convenient, easy to sample from, and encourages a well-behaved latent space.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/kl-loss-graphic.webp"
         alt="Graphic illustrating the KL divergence between the encoder&#39;s output distribution and the prior distribution"
         title="Graphic illustrating the KL divergence between the encoder&#39;s output distribution and the prior distribution"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The KL Divergence measures how much the Encoder&rsquo;s output distribution diverges from the simple prior distribution.</figcaption>
    
</figure>

<p>This regularization term acts as a penalty, measuring how much the encoder&rsquo;s output distribution diverges from the simple prior. By minimizing this KL divergence, we encourage the model to:</p>
<ul>
<li><strong>Avoid overfitting</strong> by preventing the encoder from memorizing specific locations for each input</li>
<li><strong>Create meaningful clusters</strong> where similar inputs map to nearby regions in the latent space</li>
<li><strong>Maintain continuity</strong> so that points close together in latent space (like different variations of the digit &ldquo;7&rdquo;) decode into visually similar outputs</li>
</ul>
<p>This smooth, structured latent space is what enables generation: we can sample random points from our prior distribution and decode them into realistic new data.</p>
<p>Ultimately, the optimizer finds a balance between these two objectives: reconstructing the data well while keeping the latent space organized and regularized.</p>
<h3 id="the-reparameterization-trick-making-it-all-trainable">The Reparameterization Trick: Making it All Trainable</h3>
<p>We have a problem. The training process requires sampling:</p>
<ol>
<li>Encoder produces $\mathbf{\mu}$ and $\mathbf{\sigma}$.</li>
<li>We <strong>sample</strong> $\mathbf{z} \sim \mathcal{N}(\mathbf{\mu}, \mathbf{\sigma}^2\mathbf{I})$.</li>
<li>Decoder uses $\mathbf{z}$ to reconstruct $\mathbf{x}&rsquo;$.</li>
<li>We calculate the loss.</li>
</ol>
<p>The &ldquo;sampling&rdquo; step is a random, non-differentiable operation. We can&rsquo;t backpropagate the reconstruction loss from the decoder <em>through</em> this random node to update the encoder&rsquo;s weights.</p>
<p>The <strong>reparameterization trick</strong> makes the sampling process differentiable. We generate $\mathbf{z}$ deterministically by sampling a random noise vector and transforming it:</p>
<ol>
<li>Sample a random noise vector $\mathbf{\epsilon}$ from a simple, fixed distribution (e.g., the standard normal $\mathcal{N}(\mathbf{0}, \mathbf{I})$).</li>
<li>Compute $\mathbf{z}$ as: $\mathbf{z} = \mathbf{\mu} + \mathbf{\sigma} \odot \mathbf{\epsilon}$</li>
</ol>
<p>This simple change moves the randomness &ldquo;outside&rdquo; the network. The gradient can now flow deterministically from $\mathbf{z}$ back through the $\mathbf{\mu}$ and $\mathbf{\sigma}$ nodes to the encoder network. This is the key engineering insight that allows us to train the entire model end-to-end with standard backpropagation.</p>
<h3 id="where-does-this-objective-come-from-the-math">Where Does This Objective Come From? (The Math)</h3>
<p>This two-part loss function is derived directly from the goal of maximizing the marginal likelihood of the data, $\log p_{\theta}(\mathbf{x})$.</p>
<p>For a single data point $\mathbf{x}^{(i)}$, we can write:
$$\log p_{\theta}(\mathbf{x}^{(i)}) = D_{KL}(q_\phi(\mathbf{z} | \mathbf{x}^{(i)}) || p_{\theta}(\mathbf{z} | \mathbf{x}^{(i)})) + \mathcal{L}(\theta, \phi; \mathbf{x}^{(i)})$$</p>
<ul>
<li>The first term is the KL divergence between our encoder&rsquo;s approximation and the (intractable) true posterior. This is non-negative, and unfortunately we cannot compute it.</li>
<li>The second term, $\mathcal{L}$, is the Variational Lower Bound (also known as the Evidence Lower Bound, or ELBO). Since the KL term is $\ge 0$, we know that $\log p_{\theta}(\mathbf{x}^{(i)}) \ge \mathcal{L}$.</li>
</ul>
<p>By maximizing this lower bound $\mathcal{L}$, we push up the &ldquo;floor&rdquo; on the true likelihood of our data. This is a problem we can solve.</p>
<p>When we expand this $\mathcal{L}$ term, we get our famous two-part objective:</p>
<p>$$\mathcal{L}(\theta, \phi; \mathbf{x}^{(i)}) = E_{q_{\phi}(\mathbf{z} | \mathbf{x}^{(i)})}[\log p_{\theta}(\mathbf{x}^{(i)} | \mathbf{z})] - D_{KL}(q_\phi(\mathbf{z} | \mathbf{x}^{(i)}) || p_{\theta}(\mathbf{z}))$$</p>
<ul>
<li><strong>Term 1:</strong> The expected log-likelihood of reconstructing $\mathbf{x}^{(i)}$ from $\mathbf{z}$. Maximizing this is the same as minimizing the Reconstruction Loss.</li>
<li><strong>Term 2:</strong> The negative KL divergence between our encoder and the simple prior. Maximizing this is the same as minimizing the KL Divergence Loss.</li>
</ul>
<p>Thus, the VAE&rsquo;s objective balances these two critical goals: faithfully reconstructing the data while maintaining a simple, regularized latent structure that is useful for generation.</p>
<h3 id="from-elbo-to-practical-loss">From ELBO to Practical Loss</h3>
<p>Remember, our goal is to <strong>maximize</strong> the ELBO:</p>
<p>$$\mathcal{L}(\theta, \phi; \mathbf{x}) = E_{q_{\phi}(\mathbf{z} | \mathbf{x})}[\log p_{\theta}(\mathbf{x} | \mathbf{z})] - D_{KL}(q_\phi(\mathbf{z} | \mathbf{x}) || p_{\theta}(\mathbf{z}))$$</p>
<p>Since deep learning libraries are built to <strong>minimize</strong> a loss function, we simply flip the sign and <strong>minimize the negative ELBO ($-\mathcal{L}$)</strong>.</p>
<p>This gives us our final, practical loss function:</p>
<p>$$\text{Loss} = -\mathcal{L} = -E_{q_{\phi}(\mathbf{z} | \mathbf{x})}[\log p_{\theta}(\mathbf{x} | \mathbf{z})] + D_{KL}(q_\phi(\mathbf{z} | \mathbf{x}) || p_{\theta}(\mathbf{z}))$$</p>
<p>This is the function you actually implement. Minimizing this loss achieves both of our goals:</p>
<ol>
<li>It <strong>minimizes the Reconstruction Loss</strong> (which is the same as maximizing the log-likelihood).</li>
<li>It <strong>minimizes the KL Divergence</strong>, forcing the encoder to match the prior.</li>
</ol>
<h2 id="modern-pytorch-vae-implementation">Modern PyTorch VAE Implementation</h2>
<p>Now that we understand the VAE architecture and objective, let&rsquo;s implement a modern VAE in PyTorch. I&rsquo;ll focus primarily on the model and loss function here, though the full code is available <a href="https://github.com/hunter-heidenreich/vae">on GitHub</a>.</p>
<p>My VAE implementation uses an output <code>dataclass</code> and a VAE class extending <code>nn.Module</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Variational Autoencoder (VAE) model implementation.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> dataclasses <span style="color:#f92672">import</span> dataclass
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch.nn <span style="color:#66d9ef">as</span> nn
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch.nn.functional <span style="color:#66d9ef">as</span> F
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">get_activation</span>(activation: str) <span style="color:#f92672">-&gt;</span> nn<span style="color:#f92672">.</span>Module:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Get activation function by name.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    activation_lower <span style="color:#f92672">=</span> activation<span style="color:#f92672">.</span>lower()
</span></span><span style="display:flex;"><span>    ACTIVATION_MAP <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;relu&#34;</span>: nn<span style="color:#f92672">.</span>ReLU(),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;tanh&#34;</span>: nn<span style="color:#f92672">.</span>Tanh(),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;sigmoid&#34;</span>: nn<span style="color:#f92672">.</span>Sigmoid(),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;leaky_relu&#34;</span>: nn<span style="color:#f92672">.</span>LeakyReLU(),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;elu&#34;</span>: nn<span style="color:#f92672">.</span>ELU(),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;gelu&#34;</span>: nn<span style="color:#f92672">.</span>GELU(),
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> activation_lower <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> ACTIVATION_MAP:
</span></span><span style="display:flex;"><span>        supported <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;, &#34;</span><span style="color:#f92672">.</span>join(ACTIVATION_MAP<span style="color:#f92672">.</span>keys())
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Unsupported activation &#39;</span><span style="color:#e6db74">{</span>activation<span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;. Supported: </span><span style="color:#e6db74">{</span>supported<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> ACTIVATION_MAP[activation_lower]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@dataclass</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">VAEConfig</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;VAE model configuration specifying architecture and behavior.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    hidden_dim: int
</span></span><span style="display:flex;"><span>    latent_dim: int
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    input_shape: tuple[int, int, int] <span style="color:#f92672">=</span> (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">28</span>, <span style="color:#ae81ff">28</span>)  <span style="color:#75715e"># Default: MNIST</span>
</span></span><span style="display:flex;"><span>    activation: str <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;tanh&#34;</span>  <span style="color:#75715e"># Default: tanh, what was used in the original VAE paper</span>
</span></span><span style="display:flex;"><span>    use_softplus_std: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>  <span style="color:#75715e"># Whether to use softplus for std parameterization</span>
</span></span><span style="display:flex;"><span>    n_samples: int <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>  <span style="color:#75715e"># Number of latent samples per input during training</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@dataclass</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">VAEOutput</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;VAE forward pass output containing all relevant tensors and optional losses.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    x_logits: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    z: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    mu: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    std: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    x_recon: torch<span style="color:#f92672">.</span>Tensor <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    loss: torch<span style="color:#f92672">.</span>Tensor <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    loss_recon: torch<span style="color:#f92672">.</span>Tensor <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    loss_kl: torch<span style="color:#f92672">.</span>Tensor <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">VAE</span>(nn<span style="color:#f92672">.</span>Module):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Variational Autoencoder with support for deterministic and probabilistic reconstruction.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    DEFAULT_EPS <span style="color:#f92672">=</span> <span style="color:#ae81ff">1e-8</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">__init__</span>(self, config: VAEConfig) <span style="color:#f92672">-&gt;</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Initialize VAE with given configuration.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        Args:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            config: VAE configuration specifying architecture and behavior
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        super()<span style="color:#f92672">.</span><span style="color:#a6e22e">__init__</span>()
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>config <span style="color:#f92672">=</span> config
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Build encoder: input -&gt; hidden -&gt; latent parameters (mu, sigma)</span>
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>encoder <span style="color:#f92672">=</span> nn<span style="color:#f92672">.</span>Sequential(
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Flatten(),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(
</span></span><span style="display:flex;"><span>                int(torch<span style="color:#f92672">.</span>prod(torch<span style="color:#f92672">.</span>tensor(config<span style="color:#f92672">.</span>input_shape))), config<span style="color:#f92672">.</span>hidden_dim
</span></span><span style="display:flex;"><span>            ),
</span></span><span style="display:flex;"><span>            get_activation(config<span style="color:#f92672">.</span>activation),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(config<span style="color:#f92672">.</span>hidden_dim, config<span style="color:#f92672">.</span>latent_dim <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span>),
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Build decoder: latent -&gt; hidden -&gt; reconstructed input</span>
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>decoder <span style="color:#f92672">=</span> nn<span style="color:#f92672">.</span>Sequential(
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(config<span style="color:#f92672">.</span>latent_dim, config<span style="color:#f92672">.</span>hidden_dim),
</span></span><span style="display:flex;"><span>            get_activation(config<span style="color:#f92672">.</span>activation),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Linear(
</span></span><span style="display:flex;"><span>                config<span style="color:#f92672">.</span>hidden_dim, int(torch<span style="color:#f92672">.</span>prod(torch<span style="color:#f92672">.</span>tensor(config<span style="color:#f92672">.</span>input_shape)))
</span></span><span style="display:flex;"><span>            ),
</span></span><span style="display:flex;"><span>            nn<span style="color:#f92672">.</span>Unflatten(<span style="color:#ae81ff">1</span>, config<span style="color:#f92672">.</span>input_shape),
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">encode</span>(self, x: torch<span style="color:#f92672">.</span>Tensor) <span style="color:#f92672">-&gt;</span> tuple[torch<span style="color:#f92672">.</span>Tensor, torch<span style="color:#f92672">.</span>Tensor]:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Encode input to latent distribution parameters.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        encoder_output <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>encoder(x)
</span></span><span style="display:flex;"><span>        mu, sigma <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>chunk(encoder_output, <span style="color:#ae81ff">2</span>, dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> mu, sigma
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">decode</span>(self, z: torch<span style="color:#f92672">.</span>Tensor) <span style="color:#f92672">-&gt;</span> torch<span style="color:#f92672">.</span>Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Decode latent representation to reconstruction logits&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> self<span style="color:#f92672">.</span>decoder(z)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">reparameterize</span>(self, mu: torch<span style="color:#f92672">.</span>Tensor, std: torch<span style="color:#f92672">.</span>Tensor) <span style="color:#f92672">-&gt;</span> torch<span style="color:#f92672">.</span>Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Apply reparameterization trick for differentiable sampling.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        epsilon <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>randn_like(std)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> mu <span style="color:#f92672">+</span> std <span style="color:#f92672">*</span> epsilon
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">forward</span>(
</span></span><span style="display:flex;"><span>        self,
</span></span><span style="display:flex;"><span>        x: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>        compute_loss: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>        reconstruct: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>,
</span></span><span style="display:flex;"><span>        eps: float <span style="color:#f92672">=</span> DEFAULT_EPS,
</span></span><span style="display:flex;"><span>    ) <span style="color:#f92672">-&gt;</span> VAEOutput:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Forward pass through the VAE.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        Args:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            x: Input tensor of shape (batch_size, *input_shape)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            compute_loss: Whether to compute VAE loss components
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            reconstruct: Whether to return reconstructions or distributions
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            eps: Small epsilon value for numerical stability
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        Returns:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            VAEOutput containing all relevant tensors and optionally computed losses
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Prepare input for multiple sampling if needed</span>
</span></span><span style="display:flex;"><span>        x_expanded <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>_expand_for_sampling(x) <span style="color:#66d9ef">if</span> self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>n_samples <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">else</span> x
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Encode and sample from latent space</span>
</span></span><span style="display:flex;"><span>        mu, sigma <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>encode(x)
</span></span><span style="display:flex;"><span>        std <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>_sigma_to_std(sigma, eps<span style="color:#f92672">=</span>eps)
</span></span><span style="display:flex;"><span>        mu_expanded, std_expanded <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>_expand_latent_params(mu, std)
</span></span><span style="display:flex;"><span>        z <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>reparameterize(mu_expanded, std_expanded)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Decode latent samples</span>
</span></span><span style="display:flex;"><span>        x_logits <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>decode(z)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Create output object</span>
</span></span><span style="display:flex;"><span>        output <span style="color:#f92672">=</span> VAEOutput(
</span></span><span style="display:flex;"><span>            x_logits<span style="color:#f92672">=</span>x_logits,
</span></span><span style="display:flex;"><span>            z<span style="color:#f92672">=</span>z,
</span></span><span style="display:flex;"><span>            mu<span style="color:#f92672">=</span>mu,
</span></span><span style="display:flex;"><span>            std<span style="color:#f92672">=</span>std,
</span></span><span style="display:flex;"><span>            x_recon<span style="color:#f92672">=</span>torch<span style="color:#f92672">.</span>sigmoid(x_logits) <span style="color:#66d9ef">if</span> reconstruct <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">None</span>,
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Compute losses if requested</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> compute_loss:
</span></span><span style="display:flex;"><span>            loss, loss_recon, loss_kl <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>_compute_loss(
</span></span><span style="display:flex;"><span>                x_expanded, x_logits, mu, sigma, std
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>            output<span style="color:#f92672">.</span>loss <span style="color:#f92672">=</span> loss
</span></span><span style="display:flex;"><span>            output<span style="color:#f92672">.</span>loss_recon <span style="color:#f92672">=</span> loss_recon
</span></span><span style="display:flex;"><span>            output<span style="color:#f92672">.</span>loss_kl <span style="color:#f92672">=</span> loss_kl
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> output
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># ==================== Helper Methods ====================</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_sigma_to_std</span>(
</span></span><span style="display:flex;"><span>        self, sigma: torch<span style="color:#f92672">.</span>Tensor, eps: float <span style="color:#f92672">=</span> DEFAULT_EPS
</span></span><span style="display:flex;"><span>    ) <span style="color:#f92672">-&gt;</span> torch<span style="color:#f92672">.</span>Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Convert sigma parameter to standard deviation.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>use_softplus_std:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> F<span style="color:#f92672">.</span>softplus(sigma) <span style="color:#f92672">+</span> eps
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> torch<span style="color:#f92672">.</span>exp(<span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> sigma)  <span style="color:#75715e"># sigma represents log-variance</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_expand_for_sampling</span>(self, x: torch<span style="color:#f92672">.</span>Tensor) <span style="color:#f92672">-&gt;</span> torch<span style="color:#f92672">.</span>Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Expand input tensor for multiple sampling.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        shape_dims <span style="color:#f92672">=</span> [<span style="color:#ae81ff">1</span>] <span style="color:#f92672">*</span> len(self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>input_shape)
</span></span><span style="display:flex;"><span>        x_expanded <span style="color:#f92672">=</span> x<span style="color:#f92672">.</span>unsqueeze(<span style="color:#ae81ff">1</span>)<span style="color:#f92672">.</span>repeat(<span style="color:#ae81ff">1</span>, self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>n_samples, <span style="color:#f92672">*</span>shape_dims)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> x_expanded<span style="color:#f92672">.</span>view(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, <span style="color:#f92672">*</span>self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>input_shape)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_expand_latent_params</span>(
</span></span><span style="display:flex;"><span>        self, mu: torch<span style="color:#f92672">.</span>Tensor, std: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    ) <span style="color:#f92672">-&gt;</span> tuple[torch<span style="color:#f92672">.</span>Tensor, torch<span style="color:#f92672">.</span>Tensor]:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Expand latent parameters for multiple sampling.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>n_samples <span style="color:#f92672">==</span> <span style="color:#ae81ff">1</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> mu, std
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        mu_expanded <span style="color:#f92672">=</span> (
</span></span><span style="display:flex;"><span>            mu<span style="color:#f92672">.</span>unsqueeze(<span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>repeat(<span style="color:#ae81ff">1</span>, self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>n_samples, <span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>view(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>latent_dim)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        std_expanded <span style="color:#f92672">=</span> (
</span></span><span style="display:flex;"><span>            std<span style="color:#f92672">.</span>unsqueeze(<span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>repeat(<span style="color:#ae81ff">1</span>, self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>n_samples, <span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>view(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>latent_dim)
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> mu_expanded, std_expanded
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># ==================== Loss Computation ====================</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_compute_loss</span>(
</span></span><span style="display:flex;"><span>        self,
</span></span><span style="display:flex;"><span>        x: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>        x_logits: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>        mu: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>        sigma: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>        std: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>    ) <span style="color:#f92672">-&gt;</span> tuple[torch<span style="color:#f92672">.</span>Tensor, torch<span style="color:#f92672">.</span>Tensor, torch<span style="color:#f92672">.</span>Tensor]:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Compute VAE loss components for deterministic reconstruction.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        loss_recon <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>_compute_reconstruction_loss(x, x_logits)
</span></span><span style="display:flex;"><span>        loss_kl <span style="color:#f92672">=</span> self<span style="color:#f92672">.</span>_compute_kl_loss(mu, sigma, std)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> loss_recon <span style="color:#f92672">+</span> loss_kl, loss_recon, loss_kl
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_compute_reconstruction_loss</span>(
</span></span><span style="display:flex;"><span>        self, x: torch<span style="color:#f92672">.</span>Tensor, x_logits: torch<span style="color:#f92672">.</span>Tensor
</span></span><span style="display:flex;"><span>    ) <span style="color:#f92672">-&gt;</span> torch<span style="color:#f92672">.</span>Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Compute reconstruction loss using binary cross-entropy.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> F<span style="color:#f92672">.</span>binary_cross_entropy_with_logits(
</span></span><span style="display:flex;"><span>            x_logits, x, reduction<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;sum&#34;</span>
</span></span><span style="display:flex;"><span>        ) <span style="color:#f92672">/</span> x<span style="color:#f92672">.</span>size(<span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_compute_kl_loss</span>(
</span></span><span style="display:flex;"><span>        self,
</span></span><span style="display:flex;"><span>        mu: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>        sigma: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>        std: torch<span style="color:#f92672">.</span>Tensor,
</span></span><span style="display:flex;"><span>        eps: float <span style="color:#f92672">=</span> DEFAULT_EPS,
</span></span><span style="display:flex;"><span>    ) <span style="color:#f92672">-&gt;</span> torch<span style="color:#f92672">.</span>Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Compute KL divergence between latent distribution and standard normal prior.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Analytical KL: KL(N(μ,σ²) || N(0,1)) = 0.5 * Σ(μ² + σ² - 1 - log(σ²))</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>use_softplus_std:
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># sigma is just the raw output, need to use std directly: σ</span>
</span></span><span style="display:flex;"><span>            kl_per_sample <span style="color:#f92672">=</span> <span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>sum(
</span></span><span style="display:flex;"><span>                mu<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">+</span> std<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> torch<span style="color:#f92672">.</span>log(std<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">+</span> eps), dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># sigma represents log-variance parameterization: log(σ²)</span>
</span></span><span style="display:flex;"><span>            kl_per_sample <span style="color:#f92672">=</span> <span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>sum(mu<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">+</span> sigma<span style="color:#f92672">.</span>exp() <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> sigma, dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> kl_per_sample<span style="color:#f92672">.</span>mean()
</span></span></code></pre></div><h3 id="loss-scaling">Loss Scaling</h3>
<p>Both components of the VAE loss should be summed over data dimensions and averaged over the batch size. A common mistake is using the <code>reduction=&quot;mean&quot;</code> option in PyTorch loss functions, which averages over all elements in the tensor.</p>
<ul>
<li>The <strong>KL Divergence</strong> (<code>loss_kl</code>) is a penalization term. Each dimension of the latent space has the potential to add complexity and deviate from the prior. As you increase the latent dimensionality, you typically see the KL loss increase in magnitude. That&rsquo;s the cost of having a more expressive latent space.</li>
<li>The <strong>Reconstruction Loss</strong> (<code>loss_recon</code>) measures how well the model reconstructs the input data, and it should scale with input dimensionality (this can bias the model toward better reconstruction for higher-dimensional data).</li>
</ul>
<p>In the case of MNIST, if we used <code>reduction=&quot;mean&quot;</code> for BCE, it would be averaged over all $784 \times \text{batch size}$ pixels, making it tiny compared to the KL loss. The KL term would dominate, and the model would learn to ignore the input, potentially leading to posterior collapse.</p>
<p>While modern optimizers can handle a variety of scenarios and you can still learn effective models with imperfect scaling, the original VAE paper used the scaling described above, and I recommend following that convention.</p>
<h3 id="mitigating-posterior-collapse-kl-annealingwarmup">Mitigating Posterior Collapse: KL Annealing/Warmup</h3>
<p>One common issue in training VAEs, especially with powerful decoders (like RNNs or deep CNNs), is <strong>posterior collapse</strong>. This happens when the KL term dominates the loss early in training. The model quickly learns to just output the prior distribution ($q(z|x) \approx p(z)$) to drive the KL loss to zero, effectively ignoring the latent code $z$. The decoder then becomes a powerful autoregressive model that ignores the latent input.</p>
<p>To prevent this, we often use <strong>KL Annealing</strong> (or Warmup). We introduce a weight $\beta$ for the KL term that starts at 0 and slowly increases to 1 over the first $N$ steps or epochs.</p>
<p>$$ \mathcal{L} = \mathcal{L}_{recon} + \beta \cdot D_{KL} $$</p>
<p>This allows the model to focus purely on reconstruction first (using the full latent capacity), and then slowly adds the regularization pressure.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Simple Linear Annealing Scheduler</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">get_kl_weight</span>(step, total_steps, max_val<span style="color:#f92672">=</span><span style="color:#ae81ff">1.0</span>):
</span></span><span style="display:flex;"><span>    val <span style="color:#f92672">=</span> (step <span style="color:#f92672">/</span> total_steps) <span style="color:#f92672">*</span> max_val
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> min(max(val, <span style="color:#ae81ff">0.0</span>), max_val)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># In your training loop:</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> epoch <span style="color:#f92672">in</span> range(epochs):
</span></span><span style="display:flex;"><span>    beta <span style="color:#f92672">=</span> get_kl_weight(epoch, warmup_epochs)
</span></span><span style="display:flex;"><span>    loss <span style="color:#f92672">=</span> recon_loss <span style="color:#f92672">+</span> beta <span style="color:#f92672">*</span> kl_loss
</span></span></code></pre></div><h4 id="parameterizing-standard-deviation">Parameterizing Standard Deviation</h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_sigma_to_std</span>(self, sigma: torch<span style="color:#f92672">.</span>Tensor, eps: float <span style="color:#f92672">=</span> DEFAULT_EPS) <span style="color:#f92672">-&gt;</span> torch<span style="color:#f92672">.</span>Tensor:
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;Convert sigma parameter to standard deviation.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>bound_std <span style="color:#f92672">is</span> <span style="color:#f92672">not</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> torch<span style="color:#f92672">.</span>sigmoid(sigma) <span style="color:#f92672">*</span> self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>bound_std <span style="color:#f92672">+</span> eps
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> self<span style="color:#f92672">.</span>config<span style="color:#f92672">.</span>use_softplus_std:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> F<span style="color:#f92672">.</span>softplus(sigma) <span style="color:#f92672">+</span> eps
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> torch<span style="color:#f92672">.</span>exp(<span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> sigma)  <span style="color:#75715e"># sigma represents log-variance</span>
</span></span></code></pre></div><p>Parameterizing the mean of the latent distribution is straightforward since $\mu \in \mathbb{R}$. However, the standard deviation $\sigma$ must be strictly positive (as must the variance $\sigma^2$). This type of constrained optimization is challenging for neural networks.</p>
<p><strong>Log-Variance</strong>
One common approach is to have the network output the <strong>log-variance</strong> ($\log \sigma^2$). This is what the original VAE paper did. The idea is to allow the network to output any real number and treat that value as the log-variance, $s = \log \sigma^2$. We can then compute the standard deviation as $\sigma = \exp(0.5 s)$, which is always positive.</p>
<p>The KL divergence formula simplifies nicely with this parameterization:</p>
<p>$$
\text{KL}( \mathcal{N}(\mu, \sigma^2) || \mathcal{N}(0, 1) ) = \frac{1}{2} \sum_{i=1}^d (\mu_i^2 + \sigma_i^2 - 1 - \log \sigma_i^2)
$$</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>sum(mu<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">+</span> s<span style="color:#f92672">.</span>exp() <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> s, dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span></code></pre></div><p><strong>Softplus Standard Deviation</strong>
An alternative is to have the network output $\sigma$ directly. This must be handled with care to ensure positivity. Strictly positive activations like <code>softplus</code> are required. Activations like <code>ReLU</code> can output zero, leading to numerical instability (during training) and deterministic behavior (during sampling). Additionally, adding a small epsilon value ensures numerical stability by preventing $\sigma$ from being exactly zero.</p>
<p>The KL divergence formula becomes slightly more complex:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> torch<span style="color:#f92672">.</span>sum(
</span></span><span style="display:flex;"><span>    mu<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">+</span> std<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> torch<span style="color:#f92672">.</span>log(std<span style="color:#f92672">.</span>pow(<span style="color:#ae81ff">2</span>) <span style="color:#f92672">+</span> eps), dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p><strong>Bounded Standard Deviation</strong>
Another option is to bound the standard deviation to a maximum value using a <code>sigmoid</code> transformation (or similar). This replaces mapping to $(0, \infty)$ with mapping to $(0, \text{bound})$. This helps prevent extremely high variance values that might destabilize training, while limiting the expressiveness of the latent distribution. Like with <code>softplus</code>, adding a small epsilon ensures numerical stability by preventing $\sigma$ from being exactly zero or approaching it too closely.</p>
<p><strong>Gradient Behavior</strong>
All parameterizations can work well in practice and have different gradient behaviors. Think of $g(s)$ as a transformation function from the network output to the proper domain of $\sigma$ (or $\sigma^2$); in the log-variance case, $g(s) = \exp(s)$, while in the softplus case, $g(s) = \text{softplus}(s) + \epsilon$.</p>
<p>The gradient of the loss with respect to these outputs can be written using the chain rule:</p>
<p>$$
\frac{\partial \mathcal{L}}{\partial s} = \frac{\partial \mathcal{L}}{\partial \sigma} \cdot \frac{\partial g(s)}{\partial s}
$$</p>
<p>where $\frac{\partial g(s)}{\partial s}$ is the derivative of the transformation function.</p>
<p>We need to guard against two pathological cases:</p>
<ul>
<li>$\frac{\partial g(s)}{\partial s} \rightarrow 0$: This leads to vanishing gradients, making it hard for the network to learn.</li>
<li>$\frac{\partial g(s)}{\partial s} \rightarrow \infty$: This leads to exploding gradients, causing instability during training and potentially divergence.</li>
</ul>
<p>The log-variance parameterization, with its exponential transformation that is its own derivative, exhibits both issues at extreme values. If $s \rightarrow -\infty$, then $\sigma \rightarrow 0$ and the gradient vanishes. If $s \rightarrow \infty$, then $\sigma \rightarrow \infty$ and the gradient explodes. Since the interval $(0, 1)$ is mapped to $(-\infty, 0)$ in log-space, it&rsquo;s much more difficult for the network to drive $\sigma$ to small values. In practice, exploding gradients at high values have been more problematic in my experience. Gradient clipping, learning rate scheduling, and clamping the log-variance output to a maximum value can help mitigate this.</p>
<p>What about softplus? The derivative of <code>softplus</code> is the <code>sigmoid</code> function, which smoothly maps $(-\infty, \infty)$ to $(0, 1)$. Gradients are always bounded by unity, preventing explosion (barring explosion from other parts of the network). However, as $s \rightarrow -\infty$, the gradient approaches zero, leading to vanishing gradients. Adding a small epsilon helps mitigate this, ensuring that $\sigma$ never gets too close to zero. Nonetheless, learning can still slow down.</p>
<p>For bounded standard deviation, the derivative of the <code>sigmoid</code> function is also bounded, preventing exploding gradients. (The gradient of <code>sigmoid</code> is defined in terms of itself: $\text{sig}&rsquo;(x) = \text{sig}(x)(1 - \text{sig}(x))$; its maximum value is $0.25$ at $x=0$.)</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/gradient_behaviors.webp"
         alt="Graph comparing gradient behaviors of log-variance, softplus, and bounded standard deviation parameterizations"
         title="Graph comparing gradient behaviors of log-variance, softplus, and bounded standard deviation parameterizations"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Gradient behaviors of different standard deviation parameterizations: Log-Variance (exponential), Softplus, and Bounded Standard Deviation (sigmoid). Each has unique characteristics affecting training stability.</figcaption>
    
</figure>

<h2 id="experiments">Experiments</h2>
<h3 id="2d-mnist-vae-with-different-std-dev-parameterizations">2D MNIST VAE with Different Std. Dev. Parameterizations</h3>
<p>First, let&rsquo;s run an experiment that is close to what was done in the original VAE paper. We&rsquo;ll use MNIST as our dataset, a simple feedforward architecture with <code>tanh</code> activations, and the log-variance parameterization for the latent distribution.</p>
<p>Some of the differences from the original paper include:</p>
<ul>
<li>Using a hidden size of 512 (the original used 500)</li>
<li>Using the AdamW optimizer (the original used vanilla Adagrad)</li>
<li>Applying similar weight decay, doing so quite differently due to the optimizer change</li>
<li>Focusing primarily on 2D latent spaces (for now)</li>
</ul>
<p>This results in a network with 807,700 parameters.
I train each model for 150 epochs at most and highlight the best based on the reconstruction loss on the test set.
Just for fun, I sweep across different standard deviation parameterizations and learning rate warmup strategies.</p>
<table>
	<thead>
			<tr>
					<th>Std. Dev. Param</th>
					<th>Warmup Steps</th>
					<th>Test Recon. Loss</th>
					<th>Test KL Loss</th>
					<th>Test Total Loss</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Log-Variance</td>
					<td>0</td>
					<td>140.88</td>
					<td>6.96</td>
					<td>147.84</td>
			</tr>
			<tr>
					<td>Log-Variance</td>
					<td>600</td>
					<td>141.41</td>
					<td>6.63</td>
					<td>148.04</td>
			</tr>
			<tr>
					<td>Softplus</td>
					<td>0</td>
					<td>141.51</td>
					<td>6.56</td>
					<td>148.07</td>
			</tr>
			<tr>
					<td>Softplus</td>
					<td>600</td>
					<td>140.37</td>
					<td>6.67</td>
					<td>147.04</td>
			</tr>
			<tr>
					<td>Bounded Std. Dev. (10)</td>
					<td>0</td>
					<td>140.96</td>
					<td>6.82</td>
					<td>147.79</td>
			</tr>
			<tr>
					<td>Bounded Std. Dev. (10)</td>
					<td>600</td>
					<td>141.78</td>
					<td>6.68</td>
					<td>148.45</td>
			</tr>
	</tbody>
</table>
<p>From this summary table, all three parameterizations work well. The differences in final loss values are quite small. This could be due to the simplicity of the dataset and model architecture, further amplified by forcing the network to compress images into a very low-dimensional latent space (2D).</p>
<p>Since the softplus parameterization with learning rate warmup achieved the best reconstruction loss, let&rsquo;s visualize some of its training dynamics and results more closely.</p>
<h4 id="loss-dynamics">Loss Dynamics</h4>
<p>To understand the VAE&rsquo;s behavior, we must look at the ELBO and its two components: the Reconstruction Loss and the KL Divergence.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-elbo_epochs.webp"
         alt="Plot showing training and testing ELBO across 150 epochs for the softplus parameterization with learning rate warmup"
         title="Plot showing training and testing ELBO across 150 epochs for the softplus parameterization with learning rate warmup"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Total ELBO: Training and testing ELBO across 150 epochs.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-reconstruction_loss_epochs.webp"
         alt="Plot showing training and testing reconstruction loss across 150 epochs for the softplus parameterization with learning rate warmup"
         title="Plot showing training and testing reconstruction loss across 150 epochs for the softplus parameterization with learning rate warmup"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Reconstruction Loss: Training and testing reconstruction loss across 150 epochs.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-kl_loss_epochs.webp"
         alt="Plot showing training and testing KL divergence loss across 150 epochs for the softplus parameterization with learning rate warmup"
         title="Plot showing training and testing KL divergence loss across 150 epochs for the softplus parameterization with learning rate warmup"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">KL Divergence: Training and testing KL divergence loss across 150 epochs.</figcaption>
    
</figure>

<p>These plots reveal a clear narrative:</p>
<ol>
<li><strong>Rapid Initial Learning:</strong> Performance skyrockets in the first ~15 epochs.</li>
<li><strong>Overfitting:</strong> The <strong>Reconstruction Loss</strong> (middle) flatlines for the test set while continuing to improve for training, a classic sign of memorization.</li>
<li><strong>The Balancing Act:</strong> The <strong>KL Divergence</strong> (bottom) initially rises (&ldquo;The Cost of Learning&rdquo;) as the model stretches the latent space to encode digits, then saturates.</li>
<li><strong>Equilibrium:</strong> The total <strong>ELBO</strong> (top) improves slowly, driven by the model finding the optimal trade-off between reconstruction and regularization. Notice that Test and Train KL tracks closely: a sign of good regularization!</li>
</ol>
<p><strong>Visualizing the VAE Trade-Off: BCE vs. KL</strong></p>
<p>While the line plots visualize progress over time, they miss the evolving <em>relationship</em> between our two competing objectives.</p>
<p>A VAE is fundamentally a multi-objective optimization problem. We want to:</p>
<ol>
<li>Minimize Reconstruction Loss (BCE)</li>
<li>Minimize KL Divergence</li>
</ol>
<p>Combining them as the ELBO is common and effective, though it can mask some of the underlying dynamics.</p>
<p>These two goals are in direct conflict. To get perfect reconstruction (BCE = 0), the encoder would need to &ldquo;memorize&rdquo; each input, mapping it to a unique, precise point in latent space. This would cause the KL divergence to skyrocket, as these specific, &ldquo;pointy&rdquo; distributions are nothing like our smooth <code>N(0, 1)</code> prior.</p>
<p>Conversely, to get perfect KL divergence (KL = 0), the encoder must <em>always</em> output <code>N(0, 1)</code>, regardless of the input. This perfectly matches the prior. Since the latent code $\mathbf{z}$ now contains zero information about the input $\mathbf{x}$, the decoder can only learn to output the &ldquo;average&rdquo; image, resulting in terrible reconstruction.</p>
<p>The training process is a search for the best compromise.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-loss_scatter_epochs.webp"
         alt="Scatter plot of Test BCE vs KL Divergence, showing the training path from epoch 0 to 150"
         title="Scatter plot of Test BCE vs KL Divergence, showing the training path from epoch 0 to 150"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The training path on the Test set, plotting Reconstruction Loss (BCE) vs. KL Divergence. The model&rsquo;s journey clearly shows the trade-off between these two objectives.</figcaption>
    
</figure>

<p>This plot shows the test set&rsquo;s BCE (y-axis) vs. KL Divergence (x-axis) at every evaluation step. The color gradient from cool (blue) to warm (red) represents the training progress from Epoch 0 to 150.</p>
<p>Here&rsquo;s how to interpret this training path:</p>
<ol>
<li>
<p><strong>The Start (Green Diamond, ~Epoch 0):</strong> The model starts at the top-left.</p>
<ul>
<li><strong>High BCE (Reconstruction):</strong> The decoder is random and hasn&rsquo;t learned to reconstruct anything. Reconstruction is terrible.</li>
<li><strong>Low KL Divergence:</strong> The <em>encoder</em> is also random. Its output distributions $q_{\phi}(\mathbf{z} | \mathbf{x})$ are a random mess. On average, this &ldquo;mess&rdquo; is coincidentally close to the &ldquo;mess&rdquo; of the prior $p_{\theta}(\mathbf{z})$, so the KL penalty is low. The model isn&rsquo;t encoding any useful information yet, so it&rsquo;s not paying a high price for it.</li>
</ul>
</li>
<li>
<p><strong>Phase 1: The Initial Plunge (Blue Path):</strong> The path moves almost <em>straight down</em>.</p>
<ul>
<li><strong>BCE Plummets:</strong> The model&rsquo;s first and easiest task is to learn to reconstruct <em>something</em>. The optimizer finds massive, easy gains by making the decoder output &ldquo;blurry digits&rdquo; to replace the initial noise.</li>
<li><strong>KL Stays Low:</strong> The model achieves this huge reconstruction win without needing to learn a very complex latent space. It&rsquo;s the &ldquo;low-hanging fruit&rdquo; of training.</li>
</ul>
</li>
<li>
<p><strong>Phase 2: The Trade-Off (The &ldquo;Elbow&rdquo;):</strong> The path stops dropping vertically and starts moving to the <em>right and down</em>.</p>
<ul>
<li><strong>&ldquo;Spending&rdquo; KL to &ldquo;Buy&rdquo; Reconstruction:</strong> This is the true VAE trade-off in action. The easy wins are gone. To make the reconstructions sharper and more accurate (lowering BCE further), the model must now learn a more complex, informative latent representation.</li>
<li>It &ldquo;stretches&rdquo; the latent distributions $q_{\phi}(\mathbf{z} | \mathbf{x})$ to encode more details about each specific digit. This &ldquo;stretching&rdquo; moves it further from the simple <code>N(0, 1)</code> prior, and the KL divergence (the &ldquo;cost&rdquo;) goes up.</li>
</ul>
</li>
<li>
<p><strong>The End Game (Red Path &amp; Star):</strong> The path settles in the bottom-right corner.</p>
<ul>
<li><strong>Finding the &ldquo;Elbow&rdquo;:</strong> The model finds an equilibrium. It has pushed the KL divergence as high as it&rsquo;s &ldquo;worth&rdquo; for the reconstruction gains it gets. Trying to get even better reconstruction (moving further down) would cost an enormous, disproportionate amount in KL divergence (moving far to the right), and the total loss would increase.</li>
<li><strong>Best Recon (Orange Star):</strong> The best reconstruction model (Epoch 118) is found right at this &ldquo;elbow,&rdquo; representing the best-found balance point on the trade-off frontier.</li>
</ul>
</li>
</ol>
<p>This single plot visualizes the entire training dynamic as a journey along the <strong>Pareto frontier</strong>: the set of optimal solutions where you can&rsquo;t improve one objective (BCE) without worsening the other (KL).</p>
<h4 id="generative-performance">Generative Performance</h4>
<p>Let&rsquo;s take a look at how well this model can decode samples.</p>
<p><strong>Reconstruction Performance</strong></p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-reconstructions.webp"
         alt="Grid of original and reconstructed MNIST images from the test set using the trained VAE model"
         title="Grid of original and reconstructed MNIST images from the test set using the trained VAE model"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Original (top row) vs. Reconstructed (bottom row) MNIST images from the test set using the trained VAE model.</figcaption>
    
</figure>

<p>Immediately, we see a couple of key points:</p>
<ul>
<li>Reconstructions are quite blurry compared to the originals. This is expected given the low capacity of the model and the extreme compression into a 2D latent space. General structure is typically preserved, while fine details are lost.</li>
<li>The network struggles with 4s and 9s, often mixing them up or producing ambiguous shapes. This is a common failure mode in MNIST models due to the similarity of these digits.</li>
</ul>
<p><strong>Sampling from the Prior</strong></p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-samples.webp"
         alt="Grid of MNIST-like images generated by sampling from the prior distribution using the trained VAE model"
         title="Grid of MNIST-like images generated by sampling from the prior distribution using the trained VAE model"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">MNIST-like images generated by sampling from the prior distribution using the trained VAE model.</figcaption>
    
</figure>

<p>If we sample from the prior <code>N(0, 1)</code> and decode those samples, we get a variety of digit-like images. From this, we get a pretty rich representation of digits. Almost all digits appear to be featured in this random sampling. Again, we see the standard blurriness.</p>
<p><strong>Sweeping the Latent Space</strong></p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-generation_interpolation.webp"
         alt="Grid of images generated by sweeping across the 2D latent space of the trained VAE model"
         title="Grid of images generated by sweeping across the 2D latent space of the trained VAE model"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Images generated by sweeping across the 2D latent space of the trained VAE model.</figcaption>
    
</figure>

<p>We can select two points at random (here, two zeros), embed them into our latent space and then walk across that latent space to interpolate between two data points.
Here, we see a walk that takes us from a zero that is askew to one that is more upright.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-generation_latent_sweep.webp"
         alt="2D latent sweep, varying one dimension at a time while holding the other constant"
         title="2D latent sweep, varying one dimension at a time while holding the other constant"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">2D latent sweep, varying one dimension at a time while holding the other constant.</figcaption>
    
</figure>

<p>Finally, we can also sweep each latent dimension independently to see how they affect the generated images.</p>
<ol>
<li>Sweeping <code>z_1</code> (top row), we see a 5 become an 8 and then a 9. The slant shifts from left to right as we sweep the dimension.</li>
<li>Sweeping <code>z_2</code> (bottom row), we see a 4 become a 9 and then an 8. Then it becomes a 3, a 2, some nonsense, and a 6.</li>
</ol>
<p>So clearly each latent dimension is encoding some high-level features of the digits, and we can manipulate those features by moving in latent space.</p>
<h4 id="inspecting-the-latent-space">Inspecting the Latent Space</h4>
<p>What does the actual latent space look like?</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-latent_combined.webp"
         alt="2D latent space visualization with points colored by their true digit labels"
         title="2D latent space visualization with points colored by their true digit labels"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">2D latent space visualization with points colored by their true digit labels (left) and 2D heatmap of latent space density (right).</figcaption>
    
</figure>

<p>Even without class information, the network organizes the latent space to encode digit structure effectively. It also becomes immediately apparent why 4s and 9s are so confused by the model. That region is a dense mixture of the two.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-latent_marginals.webp"
         alt="1D histograms of each latent dimension compared to the standard normal distribution"
         title="1D histograms of each latent dimension compared to the standard normal distribution"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">1D histograms of each latent dimension compared to the standard normal distribution.</figcaption>
    
</figure>

<p>We can also look at the marginal distributions of each latent dimension to see how well they match the prior <code>N(0, 1)</code>. Here, <code>z_1</code> is closer to the prior than <code>z_2</code>. <code>z_2</code> exhibits a bimodal marginal distribution, indicating that the encoder is using this dimension to separate two distinct clusters of data.</p>
<p>We also might want to understand how the log-variance of the latent distributions behaves.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/z2-logvar_combined.webp"
         alt="2D latent space visualization with log-variance values and 1D histograms of log-variance for each latent dimension"
         title="2D latent space visualization with log-variance values and 1D histograms of log-variance for each latent dimension"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">2D latent space visualization with log-variance values with respect to digit class (left) and 2D heatmap of log-variance magnitude (right).</figcaption>
    
</figure>

<p>For the most part, we see similar concentration. Some digits are more concentrated than others, though in general the difference is slight.</p>
<h3 id="beyond-2d-higher-dimensional-latent-spaces">Beyond 2D: Higher-Dimensional Latent Spaces</h3>
<p>What happens as we increase the latent dimensionality? We must do dimensionality reduction to visualize latent spaces, giving us an approximate sense of how the latent space is organized.</p>
<table>
	<thead>
			<tr>
					<th>Latent Dimensionality</th>
					<th>Test Recon. Loss</th>
					<th>Test KL Loss</th>
					<th>Test Total Loss</th>
					<th>KL per Dim</th>
					<th>Active Dims (KL &gt; 0.1)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>2</td>
					<td>140.37</td>
					<td>6.67</td>
					<td>147.04</td>
					<td>3.34</td>
					<td>2</td>
			</tr>
			<tr>
					<td>4</td>
					<td>114.94</td>
					<td>10.61</td>
					<td>125.56</td>
					<td>2.65</td>
					<td>4</td>
			</tr>
			<tr>
					<td>8</td>
					<td>89.46</td>
					<td>16.84</td>
					<td>106.31</td>
					<td>2.11</td>
					<td>8</td>
			</tr>
			<tr>
					<td>16</td>
					<td>76.57</td>
					<td>23.65</td>
					<td>100.21</td>
					<td>1.48</td>
					<td>16</td>
			</tr>
			<tr>
					<td>32</td>
					<td>74.65</td>
					<td>25.59</td>
					<td>100.25</td>
					<td>0.80</td>
					<td>24</td>
			</tr>
	</tbody>
</table>
<p>As we double the dimensionality, we see a dominant trend at first:</p>
<ul>
<li>The reconstruction loss goes down</li>
<li>The KL loss goes up</li>
<li>The KL loss per dimension goes down</li>
</ul>
<p>Something odd happens when we jump from 16 to 32 latent dimensions: some of our latent dimensions become degenerate and stop encoding useful information.
This could be an indication we need to choose our hyperparameters a little more cautiously. Perhaps we need a different architecture. Or maybe there is an intrinsic limit to the dimensionality needed for this dataset past which it&rsquo;s not really helpful to keep scaling the latent dimension.</p>
<h4 id="training-dynamics">Training Dynamics</h4>















<figure class="post-figure center ">
    <img src="/img/vae-tut/loss_scatter_epochs.webp"
         alt="Scatter plot of Test BCE vs KL Divergence for different latent dimensionalities, showing training paths from epoch 0 to 150"
         title="Scatter plot of Test BCE vs KL Divergence for different latent dimensionalities, showing training paths from epoch 0 to 150"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The training paths on the Test set for different latent dimensionalities, plotting Reconstruction Loss (BCE) vs. KL Divergence. Each path shows the model&rsquo;s journey, clearly illustrating the trade-off between these two objectives.</figcaption>
    
</figure>

<p>The training dynamics show the battle between reconstruction and KL divergence for different latent dimensionalities. As we increase the latent dimensionality, the oscillation in the KL divergence becomes more pronounced. Particularly chaotic is the $D=16$ case, which struggles to find a stable equilibrium. By the time we expand to $D=32$, the KL penalty seems to overpower the ability to encode information in the latent space, leading to many inactive dimensions. The drop in KL complexity has staircase-like steps without clearly gaining reconstruction ability.</p>
<h4 id="reconstruction-and-generation">Reconstruction and Generation</h4>
<p>As we increase the latent dimensionality, the reconstruction quality improves significantly.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/reconstructions.webp"
         alt="Grid of original and reconstructed MNIST images from the test set using trained VAE models with different latent dimensionalities"
         title="Grid of original and reconstructed MNIST images from the test set using trained VAE models with different latent dimensionalities"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Original (top row) vs. Reconstructed (bottom row) MNIST images from the test set using trained VAE models with different latent dimensionalities.</figcaption>
    
</figure>

<p>As we increase the dimensionality, we see the increase in quality we&rsquo;d expect given the reduction in BCE reconstruction loss. In the jump to 4D, we&rsquo;re able to better resolve the differences between 4s and 9s. Images become much sharper by the time we hit 16 dimensions. The differences between 16 and 32 dimensions, however, are marginal.</p>















<figure class="post-figure center ">
    <img src="/img/vae-tut/samples.webp"
         alt="Grid of MNIST-like images generated by sampling from the prior distribution using trained VAE models with different latent dimensionalities"
         title="Grid of MNIST-like images generated by sampling from the prior distribution using trained VAE models with different latent dimensionalities"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">MNIST-like images generated by sampling from the prior distribution using trained VAE models with different latent dimensionalities.</figcaption>
    
</figure>

<p>Sampling quality also improves with latent dimensionality. Images are sharper as we increase the dimensionality. However, the space seems to get sparser as we increase to the largest dimensionalities, which makes sense given the size and nature of our dataset.</p>
<h4 id="latent-space-visualizations">Latent Space Visualizations</h4>















<figure class="post-figure center ">
    <img src="/img/vae-tut/latent_combined.webp"
         alt="2D PCA projections of higher-dimensional latent spaces colored by their true digit labels"
         title="2D PCA projections of higher-dimensional latent spaces colored by their true digit labels"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">2D PCA projections of higher-dimensional latent spaces colored by their true digit labels.</figcaption>
    
</figure>

<p>The challenge with visualizing higher-dimensional latent spaces is that we must reduce their dimensionality to 2D. PCA struggles to capture the variance of higher dimensionalities. The 4D and 8D plots suggest increasingly better separation of the numeric classes. However, the 16D and 32D plots only show 10-20% of the variance and give a misleading image of overlap.</p>
<h2 id="conclusion">Conclusion</h2>
<p>In this tutorial, we&rsquo;ve journeyed from the core theory of Variational Autoencoders to a practical, modern PyTorch implementation and a series of experiments on the MNIST dataset. Our findings highlight several key takeaways for practitioners:</p>
<ol>
<li>
<p><strong>The VAE is a Balancing Act:</strong> The fundamental tension between reconstruction fidelity and latent space regularization is the core of the VAE. Our visualization of the BCE vs. KL loss trade-off clearly showed training as a search for an optimal point on this Pareto frontier, where improving one objective necessarily means sacrificing the other.</p>
</li>
<li>
<p><strong>Latent Dimensionality is a Critical Hyperparameter:</strong> Increasing the latent dimension consistently improved reconstruction quality with diminishing returns. As we saw in the jump from 16 to 32 dimensions, too much capacity can lead to &ldquo;inactive&rdquo; dimensions, where the KL penalty overpowers the model&rsquo;s ability to encode useful information. This demonstrates that choosing the right latent size is crucial for both performance and efficiency.</p>
</li>
<li>
<p><strong>VAEs Learn Meaningful Unsupervised Representations:</strong> Without any labels, our VAE successfully organized the latent space, clustering similar digits and enabling smooth interpolations. This underscores the power of VAEs for unsupervised learning, dimensionality reduction, and discovering the underlying structure in complex data.</p>
</li>
<li>
<p><strong>Implementation Details Matter:</strong> While different standard deviation parameterizations yielded similar results on this simple problem, understanding their gradient behaviors is key for tackling more complex datasets where training stability can be a major challenge. Proper loss scaling is similarly crucial to prevent one term from dominating the other and leading to issues like posterior collapse.</p>
</li>
</ol>
<p>While the classic VAE produces characteristically blurry reconstructions, it remains a foundational generative model. The principles we&rsquo;ve explored here (the ELBO, the reparameterization trick, and the trade-off between reconstruction and regularization) are central to many more advanced generative models used today.</p>
<p><strong>Questions or feedback?</strong> Feel free to reach out. I&rsquo;d love to hear about your experiences with VAE experiments!</p>
]]></content:encoded></item><item><title>Sarcasm Detection with Transformers: A Cautionary Tale</title><link>https://hunterheidenreich.com/posts/sarcasm-detection-with-transformers/</link><pubDate>Sun, 25 Feb 2024 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/sarcasm-detection-with-transformers/</guid><description>Learn how dataset bias can lead to misleading results in NLP: a sarcasm detection model that learned to classify news sources.</description><content:encoded><![CDATA[<h2 id="why-sarcasm-detection-is-hard">Why Sarcasm Detection Is Hard</h2>
<p>Sarcasm detection represents one of the most challenging problems in NLP. The difficulties include:</p>
<p><strong>Context dependence</strong>: Sarcasm relies on situational knowledge and shared understanding that extends beyond the text itself.</p>
<p><strong>Subtlety</strong>: Even humans struggle with sarcastic interpretation, especially in written text without vocal cues.</p>
<p><strong>Cultural variability</strong>: Sarcastic expressions vary significantly across cultures and regions.</p>
<p><strong>Annotation disagreement</strong>: Human annotators often disagree on what constitutes sarcasm.</p>
<p>These challenges raise a fundamental question: can sarcasm detection be well-defined as a computational problem? This case study explores what happens when we try (and reveals a common pitfall in dataset construction).</p>
<h2 id="the-dataset-a-hidden-flaw">The Dataset: A Hidden Flaw</h2>
<p>I used the <a href="https://huggingface.co/datasets/raquiba/Sarcasm_News_Headline">Sarcasm News Headlines dataset</a>, which combines headlines from <a href="https://theonion.com/">The Onion</a> (satirical) and <a href="https://www.huffpost.com/">The Huffington Post</a> (traditional news). The dataset contains ~50,000 examples.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> datasets <span style="color:#f92672">import</span> load_dataset
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>dataset <span style="color:#f92672">=</span> load_dataset(<span style="color:#e6db74">&#34;raquiba/Sarcasm_News_Headline&#34;</span>)
</span></span><span style="display:flex;"><span>print(dataset[<span style="color:#e6db74">&#34;train&#34;</span>][<span style="color:#ae81ff">0</span>])
</span></span><span style="display:flex;"><span>print(dataset[<span style="color:#e6db74">&#34;train&#34;</span>][<span style="color:#ae81ff">1</span>])
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plaintext" data-lang="plaintext"><span style="display:flex;"><span>{&#39;headline&#39;: &#39;thirtysomething scientists unveil doomsday clock of hair loss&#39;,
</span></span><span style="display:flex;"><span> &#39;is_sarcastic&#39;: 1}
</span></span><span style="display:flex;"><span>{&#39;headline&#39;: &#39;dem rep. totally nails why congress is falling short on gender, racial equality&#39;,
</span></span><span style="display:flex;"><span> &#39;is_sarcastic&#39;: 0}
</span></span></code></pre></div><p><strong>The critical flaw</strong>: This dataset uses binary classification based on source domain. The Onion headlines are labeled sarcastic, HuffPost headlines are not. This creates a dangerous shortcut where models learn to detect the publication source.</p>
<p>After preprocessing to standardize column names:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>dataset <span style="color:#f92672">=</span> dataset<span style="color:#f92672">.</span>map(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">lambda</span> example: {<span style="color:#e6db74">&#34;text&#34;</span>: example[<span style="color:#e6db74">&#34;headline&#34;</span>], <span style="color:#e6db74">&#34;label&#34;</span>: example[<span style="color:#e6db74">&#34;is_sarcastic&#34;</span>]},
</span></span><span style="display:flex;"><span>    remove_columns<span style="color:#f92672">=</span>[<span style="color:#e6db74">&#34;headline&#34;</span>, <span style="color:#e6db74">&#34;article_link&#34;</span>, <span style="color:#e6db74">&#34;is_sarcastic&#34;</span>]
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><h2 id="fine-tuning-roberta">Fine-Tuning RoBERTa</h2>
<p>I fine-tuned a pre-trained RoBERTa model using standard practices:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> transformers <span style="color:#f92672">import</span> AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>model_name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;FacebookAI/roberta-base&#34;</span>
</span></span><span style="display:flex;"><span>tokenizer <span style="color:#f92672">=</span> AutoTokenizer<span style="color:#f92672">.</span>from_pretrained(model_name)
</span></span><span style="display:flex;"><span>model <span style="color:#f92672">=</span> AutoModelForSequenceClassification<span style="color:#f92672">.</span>from_pretrained(model_name, num_labels<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Tokenize the data</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">tokenize_function</span>(examples):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> tokenizer(examples[<span style="color:#e6db74">&#34;text&#34;</span>], truncation<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, max_length<span style="color:#f92672">=</span><span style="color:#ae81ff">512</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>tokenized_datasets <span style="color:#f92672">=</span> dataset<span style="color:#f92672">.</span>map(tokenize_function, batched<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Training configuration</span>
</span></span><span style="display:flex;"><span>training_args <span style="color:#f92672">=</span> TrainingArguments(
</span></span><span style="display:flex;"><span>    output_dir<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;./results&#34;</span>,
</span></span><span style="display:flex;"><span>    num_train_epochs<span style="color:#f92672">=</span><span style="color:#ae81ff">5</span>,
</span></span><span style="display:flex;"><span>    per_device_train_batch_size<span style="color:#f92672">=</span><span style="color:#ae81ff">32</span>,
</span></span><span style="display:flex;"><span>    evaluation_strategy<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;epoch&#34;</span>,
</span></span><span style="display:flex;"><span>    save_strategy<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;epoch&#34;</span>,
</span></span><span style="display:flex;"><span>    load_best_model_at_end<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>trainer <span style="color:#f92672">=</span> Trainer(
</span></span><span style="display:flex;"><span>    model<span style="color:#f92672">=</span>model,
</span></span><span style="display:flex;"><span>    args<span style="color:#f92672">=</span>training_args,
</span></span><span style="display:flex;"><span>    train_dataset<span style="color:#f92672">=</span>tokenized_datasets[<span style="color:#e6db74">&#34;train&#34;</span>],
</span></span><span style="display:flex;"><span>    eval_dataset<span style="color:#f92672">=</span>tokenized_datasets[<span style="color:#e6db74">&#34;test&#34;</span>],
</span></span><span style="display:flex;"><span>    tokenizer<span style="color:#f92672">=</span>tokenizer,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>trainer<span style="color:#f92672">.</span>train()
</span></span></code></pre></div><h2 id="results-too-good-to-be-true">Results: Too Good to Be True</h2>
<p>The model achieved high accuracy:</p>
<table>
	<thead>
			<tr>
					<th>Epoch</th>
					<th>Test Accuracy</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>96.3%</td>
			</tr>
			<tr>
					<td>2</td>
					<td>97.8%</td>
			</tr>
			<tr>
					<td>3</td>
					<td>99.4%</td>
			</tr>
			<tr>
					<td>4</td>
					<td>99.8%</td>
			</tr>
			<tr>
					<td>5</td>
					<td>99.8%</td>
			</tr>
	</tbody>
</table>
<p>This should immediately raise red flags. Sarcasm detection is notoriously difficult, even for humans. Such high accuracy suggests the model learned a proxy task.</p>
<p>My hypothesis: <strong>The model bypassed sarcasm detection entirely, learning only to distinguish between The Onion and HuffPost writing styles.</strong></p>
<h2 id="interacting-with-the-model">Interacting with the Model</h2>
<p>Let&rsquo;s test our hypothesis by interacting with the model.</p>
<p>First, let&rsquo;s load the model and tokenizer:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> transformers <span style="color:#f92672">import</span> pipeline
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>model <span style="color:#f92672">=</span> AutoModelForSequenceClassification<span style="color:#f92672">.</span>from_pretrained(<span style="color:#e6db74">&#39;results/2024-02-25_20-24-51/checkpoint-4475&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>clf <span style="color:#f92672">=</span> pipeline(<span style="color:#e6db74">&#39;text-classification&#39;</span>, model<span style="color:#f92672">=</span>model, tokenizer<span style="color:#f92672">=</span>tokenizer)
</span></span></code></pre></div><p>Now, let&rsquo;s test the model with some examples.</p>
<p>First, let&rsquo;s try an Onion article from this week, something I know to be sarcastic and not in the training data.
Let&rsquo;s use <a href="https://theonion.com/alabama-supreme-court-justice-invokes-veggietales-in-1851282252/">&ldquo;Alabama Supreme Court Justice Invokes &lsquo;VeggieTales&rsquo; In Ruling&rdquo;</a>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>clf(<span style="color:#e6db74">&#34;Alabama Supreme Court Justice Invokes ‘VeggieTales&#39; In Ruling&#34;</span>)
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plaintext" data-lang="plaintext"><span style="display:flex;"><span>[{&#39;label&#39;: &#39;LABEL_0&#39;, &#39;score&#39;: 0.99916672706604}]
</span></span></code></pre></div><p>The model is extremely confident that this is not sarcastic.</p>
<p>Let&rsquo;s try a different Onion article, possibly even more difficult: <a href="https://theonion.com/trump-booed-frozen-burritos-and-more-this-week-in-br-1851282066/">Breaking News Trump Booed, Frozen Burritos, And More: This Week In Breaking News February 24, 2024</a>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>clf(<span style="color:#e6db74">&#34;Breaking News Trump Booed, Frozen Burritos, And More: This Week In Breaking News February 24, 2024&#34;</span>)
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plaintext" data-lang="plaintext"><span style="display:flex;"><span>[{&#39;label&#39;: &#39;LABEL_0&#39;, &#39;score&#39;: 0.9993497729301453}]
</span></span></code></pre></div><p>Again, very confident that this is not sarcastic. Hmm. It could be the temporal accuracy of our model just cannot capture the sarcasm of the Onion in 2024.</p>
<p>Let&rsquo;s try one more Onion article, this one that is still recent but a bit more of a low-hanging fruit: <a href="https://theonion.com/mom-only-likes-the-other-outback-steakhouse-1851265335/">Mom Only Likes The Other Outback Steakhouse</a>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>clf(<span style="color:#e6db74">&#34;Mom Only Likes The Other Outback Steakhouse&#34;</span>)
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plaintext" data-lang="plaintext"><span style="display:flex;"><span>[{&#39;label&#39;: &#39;LABEL_1&#39;, &#39;score&#39;: 0.9997231364250183}]
</span></span></code></pre></div><p>Finally, a correct prediction! The model is confident that this is sarcastic.
Our model detects only very specific types of sarcasm. It fails to generalize to new, unseen data within the same domain.</p>
<p>Let&rsquo;s also try some headlines from the Huffington Post, which the model should predict as not sarcastic.
Let&rsquo;s try the five most recent headlines from the Huffington Post:</p>
<ul>
<li><a href="https://www.huffpost.com/entry/donald-trump-south-carolina-nikki-haley_n_65db61f5e4b0e4346d52bed8">Donald Trump Won South Carolina - But There&rsquo;s 1 Big Caveat</a></li>
<li><a href="https://www.huffpost.com/entry/israeli-embassy-washington-man-set-fire_n_65db9364e4b0e4346d52ce3d">Man Sets Himself On Fire In Front Of Israeli Embassy In Washington</a></li>
<li><a href="https://www.huffpost.com/entry/bc-ml-israel-palestinians-temporary-truce-cease-fire_n_65db2e9ae4b0189a6a7e32ea">Israeli Media Report Progress On Reaching A Temporary Truce In Gaza And A Hostage-Prisoner Exchange</a></li>
<li><a href="https://www.huffpost.com/entry/george-latimer-race-comments-democratic-primary_n_65d8fac3e4b0cc1f2f7bafd8">A White Liberal Is Trying To Oust A Progressive Black Congressman. His Comments Could Make That Job Harder.</a></li>
<li><a href="https://www.huffpost.com/entry/mongolia-climate-change-extreme-weather_n_65d90294e4b0cc1f2f7bb527">Climate Change-Fueled Winter Extremes Put 90% Of This Country At &lsquo;High Risk&rsquo;</a></li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>clf([
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;Donald Trump Won South Carolina - But There&#39;s 1 Big Caveat&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;Man Sets Himself On Fire In Front Of Israeli Embassy In Washington&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;Israeli Media Report Progress On Reaching A Temporary Truce In Gaza And A Hostage-Prisoner Exchange&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;A White Liberal Is Trying To Oust A Progressive Black Congressman. His Comments Could Make That Job Harder.&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;Climate Change-Fueled Winter Extremes Put 90% Of This Country At &#39;High Risk&#39;&#34;</span>
</span></span><span style="display:flex;"><span>])
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plaintext" data-lang="plaintext"><span style="display:flex;"><span>[{&#39;label&#39;: &#39;LABEL_0&#39;, &#39;score&#39;: 0.9993808269500732},
</span></span><span style="display:flex;"><span> {&#39;label&#39;: &#39;LABEL_0&#39;, &#39;score&#39;: 0.9993786811828613},
</span></span><span style="display:flex;"><span> {&#39;label&#39;: &#39;LABEL_0&#39;, &#39;score&#39;: 0.9985186457633972},
</span></span><span style="display:flex;"><span> {&#39;label&#39;: &#39;LABEL_0&#39;, &#39;score&#39;: 0.9993883371353149},
</span></span><span style="display:flex;"><span> {&#39;label&#39;: &#39;LABEL_0&#39;, &#39;score&#39;: 0.9993487000465393}]
</span></span></code></pre></div><p>The model is extremely confident that these are not sarcastic.</p>
<p>The model detects sarcasm in limited cases. It fails to generalize to new, unseen data within the same domain. This is a common problem in machine learning. Training a model that performs well on a specific dataset is straightforward. Training a model that generalizes to new, unseen data remains a significant challenge.
Furthermore, our sarcasm detection project resulted in a domain classifier. For fuzzier concepts like sarcasm, it&rsquo;s important to be clear about what we&rsquo;re actually detecting, and to collect the necessary scale of data to capture the full range of the concept.</p>
<h2 id="key-takeaways">Key Takeaways</h2>
<p>This case study reveals a fundamental problem in ML: <strong>high accuracy guarantees only performance on the training distribution</strong>. Here&rsquo;s what actually happened:</p>
<ol>
<li><strong>Dataset bias</strong>: Using publication source as a proxy for sarcasm created a shortcut for the model</li>
<li><strong>Domain classification</strong>: The model exclusively learned to distinguish writing styles</li>
<li><strong>Poor generalization</strong>: New examples from the same sources often failed</li>
</ol>
<p>This is a common pitfall when building datasets for subjective concepts. The lesson: high accuracy must be accompanied by validation of the model&rsquo;s actual learned behavior.</p>
<p>For better sarcasm detection, we&rsquo;d need:</p>
<ul>
<li>Diverse sources beyond two publications</li>
<li>Human annotation across multiple contexts</li>
<li>Careful evaluation on out-of-domain examples</li>
</ul>
<p>Instructive failures in ML projects provide valuable lessons about our assumptions and the limitations of our approaches.</p>
]]></content:encoded></item><item><title>Hearing Molecular Shape via Coulomb Matrix Eigenvalues</title><link>https://hunterheidenreich.com/posts/alkane-constitutional-isomer-classification/</link><pubDate>Sat, 24 Feb 2024 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/alkane-constitutional-isomer-classification/</guid><description>Explore molecular shape recognition using Coulomb matrix eigenvalues. An analysis of alkane isomers, clustering limits, and supervised classification.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Can you determine a molecule&rsquo;s shape from mathematical fingerprints alone? This question drives some of the most fundamental challenges in computational chemistry and machine learning. In the broader ML context, this is the classic search for the right <em>inductive bias</em> or <em>invariant representation</em>. Whether we are processing messy documents, natural language, or molecular dynamics, finding a representation that captures essential structure while ignoring irrelevant variations is critical.</p>
<p>I recently encountered a paper with an intriguing title: <a href="https://doi.org/10.1021/acs.jcim.0c00631">&ldquo;Can One Hear the Shape of a Molecule (from its Coulomb Matrix Eigenvalues)?&rdquo;</a> The title references Mark Kac&rsquo;s famous mathematical question <a href="https://www.math.ucdavis.edu/~hunter/m207b/kac.pdf">&ldquo;Can One Hear the Shape of a Drum?&rdquo;</a> exploring whether a drum&rsquo;s shape dictates its sound frequencies.</p>
<p>The molecular version asks: can we determine a molecule&rsquo;s structure from the eigenvalues of its <a href="/posts/molecular-descriptor-coulomb-matrix/">Coulomb matrix</a>?</p>
<p>Molecular representations are the foundation of machine learning in chemistry. If eigenvalues can capture structural information, they become powerful features for property prediction. Successfully separating simple structural differences is a prerequisite for handling more complex molecules.</p>
<p>The original authors tested this hypothesis using alkane constitutional isomers (molecules with identical formulas but different structural arrangements). I decided to replicate and extend their work to better understand both the methods and their limitations.</p>
<p>In this post, we will explore molecular representation through eigenvalue analysis, covering data generation, unsupervised clustering approaches, and supervised classification methods. I&rsquo;ll also explore log-transformed Coulomb matrices, which can reveal structural details that standard matrices miss.</p>
<h2 id="why-alkanes-make-ideal-test-cases">Why Alkanes Make Ideal Test Cases</h2>
<p><a href="https://en.wikipedia.org/wiki/Alkane">Alkanes</a> are the simplest organic molecules: carbon and hydrogen connected by single bonds with the general formula $C_{n}H_{2n+2}$.</p>
<p>What makes them perfect for testing molecular representations is their constitutional isomers: molecules with identical formulas but different structural arrangements. For small alkanes ($n \leq 3$), atoms can connect in only one way. Starting with butane ($n = 4$), multiple arrangements become possible:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/4-Butane-3D-balls.webp"
         alt="Butane as a ball-and-stick model."
         title="Butane as a ball-and-stick model."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Butane: a linear chain</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/4-Isobutane-3D-balls.webp"
         alt="Isobutane as a ball-and-stick model."
         title="Isobutane as a ball-and-stick model."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Isobutane: a branched structure</figcaption>
    
</figure>

<p>The number of isomers grows rapidly with molecular size. By undecane ($n = 11$), there are 159 different structural arrangements:</p>
<table>
	<thead>
			<tr>
					<th>Alkane</th>
					<th>n</th>
					<th>Isomers</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Butane</td>
					<td>4</td>
					<td>2</td>
			</tr>
			<tr>
					<td>Pentane</td>
					<td>5</td>
					<td>3</td>
			</tr>
			<tr>
					<td>Hexane</td>
					<td>6</td>
					<td>5</td>
			</tr>
			<tr>
					<td>Heptane</td>
					<td>7</td>
					<td>9</td>
			</tr>
			<tr>
					<td>Octane</td>
					<td>8</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Nonane</td>
					<td>9</td>
					<td>35</td>
			</tr>
			<tr>
					<td>Decane</td>
					<td>10</td>
					<td>75</td>
			</tr>
			<tr>
					<td>Undecane</td>
					<td>11</td>
					<td>159</td>
			</tr>
	</tbody>
</table>
<p>This creates a natural classification challenge: can Coulomb matrix eigenvalues distinguish these structural differences? Successfully separating simple alkane isomers is a prerequisite for handling more complex molecules.</p>
<h2 id="computational-pipeline">Computational Pipeline</h2>
<p>The analysis requires three computational steps:</p>
<ol>
<li><strong>Generate constitutional isomers</strong> for each alkane formula</li>
<li><strong>Create multiple 3D conformations</strong> for each isomer</li>
<li><strong>Calculate Coulomb matrix eigenvalues</strong> for each conformation</li>
</ol>
<h3 id="generating-constitutional-isomers">Generating Constitutional Isomers</h3>
<p>Enumerating all possible carbon skeletons is a combinatorial problem. I used <a href="https://github.com/MehmetAzizYirik/MAYGEN">MAYGEN</a>, an open-source Java tool for generating molecular structures from chemical formulas.</p>
<p>For butane ($C_{4}H_{10}$):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>java -jar MAYGEN-1.8.jar -v -m -f C4H10 -smi -o butane_conformers.smi
</span></span></code></pre></div><p>This generates:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plaintext" data-lang="plaintext"><span style="display:flex;"><span>CCCC
</span></span><span style="display:flex;"><span>CC(C)C
</span></span></code></pre></div><p>The first is n-butane (linear), the second is isobutane (branched). We can automate this across all alkanes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>os<span style="color:#f92672">.</span>makedirs(<span style="color:#e6db74">&#39;isomers&#39;</span>, exist_ok<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    cmd <span style="color:#f92672">=</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;java -jar MAYGEN-1.8.jar -f C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> -smi -o isomers/C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.smi&#34;</span>
</span></span><span style="display:flex;"><span>    os<span style="color:#f92672">.</span>system(cmd)
</span></span></code></pre></div><h3 id="generating-3d-conformations">Generating 3D Conformations</h3>
<p>For machine learning applications, we need multiple 3D structures of each isomer to capture conformational flexibility. I used <a href="https://github.com/rdkit/rdkit">RDKit</a>&rsquo;s ETKDG method, which <a href="https://doi.org/10.1021/acs.jcim.7b00505">remains competitive</a> with commercial alternatives:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> rdkit.Chem <span style="color:#f92672">import</span> AllChem
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">smiles_str_to_rdkit_mol</span>(smiles_str: str) <span style="color:#f92672">-&gt;</span> rdkit<span style="color:#f92672">.</span>Chem<span style="color:#f92672">.</span>Mol:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Convert a SMILES string to an RDKit mol object.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Args:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    - smiles_str (str): A SMILES string representing a molecule.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Returns:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    - mol (rdkit.Chem.Mol): An RDKit mol object representing the molecule.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Convert SMILES string to RDKit mol object</span>
</span></span><span style="display:flex;"><span>    mol <span style="color:#f92672">=</span> rdkit<span style="color:#f92672">.</span>Chem<span style="color:#f92672">.</span>MolFromSmiles(smiles_str)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Add hydrogens to the molecule</span>
</span></span><span style="display:flex;"><span>    mol <span style="color:#f92672">=</span> rdkit<span style="color:#f92672">.</span>Chem<span style="color:#f92672">.</span>AddHs(mol)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Assign 3D coordinates to the molecule</span>
</span></span><span style="display:flex;"><span>    AllChem<span style="color:#f92672">.</span>EmbedMolecule(mol)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> mol
</span></span></code></pre></div><h3 id="computing-coulomb-matrix-eigenvalues">Computing Coulomb Matrix Eigenvalues</h3>
<p>The Coulomb matrix encodes 3D structure in a rotation and translation-invariant way. Its eigenvalues should capture structural information while remaining invariant to molecular orientation.</p>
<p>First, I wrote a helper function to convert RDKit molecules into <a href="https://ase-lib.org/">ASE</a> <code>Atoms</code> objects that <a href="https://singroup.github.io/dscribe/">DScribe</a> can process:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">rdkit_mol_to_ase_atoms</span>(rdkit_mol: rdkit<span style="color:#f92672">.</span>Chem<span style="color:#f92672">.</span>Mol) <span style="color:#f92672">-&gt;</span> ase<span style="color:#f92672">.</span>Atoms:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Convert an RDKit molecule to an ASE Atoms object.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Args:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        rdkit_mol: RDKit molecule object.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Returns:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        ASE Atoms object.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    ase_atoms <span style="color:#f92672">=</span> ase<span style="color:#f92672">.</span>Atoms(
</span></span><span style="display:flex;"><span>        numbers<span style="color:#f92672">=</span>[
</span></span><span style="display:flex;"><span>            atom<span style="color:#f92672">.</span>GetAtomicNum() <span style="color:#66d9ef">for</span> atom <span style="color:#f92672">in</span> rdkit_mol<span style="color:#f92672">.</span>GetAtoms()
</span></span><span style="display:flex;"><span>        ],
</span></span><span style="display:flex;"><span>        positions<span style="color:#f92672">=</span>rdkit_mol<span style="color:#f92672">.</span>GetConformer()<span style="color:#f92672">.</span>GetPositions()
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> ase_atoms
</span></span></code></pre></div><p>Then I computed Coulomb matrix eigenvalues using DScribe, with optional log transformation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">ase_atoms_to_coloumb_matrix_eigenvalues</span>(
</span></span><span style="display:flex;"><span>    ase_atoms: ase<span style="color:#f92672">.</span>Atoms,
</span></span><span style="display:flex;"><span>    log: bool <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>) <span style="color:#f92672">-&gt;</span> np<span style="color:#f92672">.</span>ndarray:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Convert an ASE Atoms object to a Coulomb matrix and calculate its eigenvalues.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Args:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        ase_atoms: ASE Atoms object.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        log: Whether to log transform the Coulomb matrix prior to calculating the eigenvalues.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Returns:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        Eigenvalues of the Coulomb matrix.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Create a Coulomb matrix</span>
</span></span><span style="display:flex;"><span>    coulomb_matrix <span style="color:#f92672">=</span> dscribe<span style="color:#f92672">.</span>descriptors<span style="color:#f92672">.</span>CoulombMatrix(
</span></span><span style="display:flex;"><span>        n_atoms_max<span style="color:#f92672">=</span>ase_atoms<span style="color:#f92672">.</span>get_global_number_of_atoms(),
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Calculate the Coulomb matrix</span>
</span></span><span style="display:flex;"><span>    coulomb_matrix <span style="color:#f92672">=</span> coulomb_matrix<span style="color:#f92672">.</span>create(ase_atoms)
</span></span><span style="display:flex;"><span>    coulomb_matrix <span style="color:#f92672">=</span> coulomb_matrix<span style="color:#f92672">.</span>reshape(
</span></span><span style="display:flex;"><span>        ase_atoms<span style="color:#f92672">.</span>get_global_number_of_atoms(),
</span></span><span style="display:flex;"><span>        ase_atoms<span style="color:#f92672">.</span>get_global_number_of_atoms())
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> log:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Log transform the Coulomb matrix</span>
</span></span><span style="display:flex;"><span>        coulomb_matrix <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>log(coulomb_matrix)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Calculate the eigenvalues of the Coulomb matrix</span>
</span></span><span style="display:flex;"><span>    eigenvalues <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>eigvals(coulomb_matrix)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> eigenvalues
</span></span></code></pre></div><p>Combining these functions enables efficient data generation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Generate 1000 conformations per isomer for each alkane</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># gen_n_spectra() combines the above functions to generate multiple conformations</span>
</span></span><span style="display:flex;"><span>os<span style="color:#f92672">.</span>makedirs(<span style="color:#e6db74">&#39;spectra&#39;</span>, exist_ok<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>n_confs <span style="color:#f92672">=</span> <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;Generating spectra for C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;isomers/C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.smi&#39;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        smiles_list <span style="color:#f92672">=</span> [line<span style="color:#f92672">.</span>strip() <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> f]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i, smiles <span style="color:#f92672">in</span> enumerate(smiles_list):
</span></span><span style="display:flex;"><span>        spectra <span style="color:#f92672">=</span> gen_n_spectra(smiles, n_confs, log<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)
</span></span><span style="display:flex;"><span>        np<span style="color:#f92672">.</span>save(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;spectra/C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">_</span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">}</span><span style="color:#e6db74">.npy&#39;</span>, spectra)
</span></span></code></pre></div><h2 id="reproducing-the-original-results">Reproducing the Original Results</h2>
<p>To validate our computational pipeline, I replicated key figures from the original paper. This ensures our implementation correctly captures the phenomena they observed.</p>
<p>We generate data using 1000 conformations per isomer:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>os<span style="color:#f92672">.</span>makedirs(<span style="color:#e6db74">&#39;spectra&#39;</span>, exist_ok<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>n_confs <span style="color:#f92672">=</span> <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;Generating spectra for C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;isomers/C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.smi&#39;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        lines <span style="color:#f92672">=</span> f<span style="color:#f92672">.</span>readlines()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> i, line <span style="color:#f92672">in</span> enumerate(lines):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> line<span style="color:#f92672">.</span>strip():
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            smiles <span style="color:#f92672">=</span> line<span style="color:#f92672">.</span>strip()
</span></span><span style="display:flex;"><span>            spectra <span style="color:#f92672">=</span> gen_n_spectra(n_confs, smiles, log<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)
</span></span><span style="display:flex;"><span>            np<span style="color:#f92672">.</span>save(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;spectra/C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">_</span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">:</span><span style="color:#e6db74">03d</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.npy&#39;</span>, spectra)
</span></span></code></pre></div><p>After generation, we can load the data into a structured format:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> re
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> glob <span style="color:#f92672">import</span> glob
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>spectra <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    spectra[n] <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> f <span style="color:#f92672">in</span> glob(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;spectra/C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">_*.npy&#39;</span>):
</span></span><span style="display:flex;"><span>        j <span style="color:#f92672">=</span> int(re<span style="color:#f92672">.</span>search(<span style="color:#e6db74">rf</span><span style="color:#e6db74">&#39;C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">_(\d+).npy&#39;</span>, f)<span style="color:#f92672">.</span>group(<span style="color:#ae81ff">1</span>))
</span></span><span style="display:flex;"><span>        spectra[n][j] <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>load(f)
</span></span></code></pre></div><h3 id="largest-eigenvalues-across-alkane-series">Largest Eigenvalues Across Alkane Series</h3>
<p>The first analysis examines how the largest Coulomb matrix eigenvalues vary across constitutional isomers for each alkane formula. This plot reveals whether single eigenvalues can distinguish between different molecular formulas.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>fig, ax <span style="color:#f92672">=</span> plt<span style="color:#f92672">.</span>subplots(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>, figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">5</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    eigenvalues <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>array([spectra[n][i][:, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>mean() <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> spectra[n]])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># add black dots to boxplot, but not dirextly to the center line</span>
</span></span><span style="display:flex;"><span>    jitter <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>random<span style="color:#f92672">.</span>normal(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0.1</span>, size<span style="color:#f92672">=</span>len(eigenvalues))
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>scatter(np<span style="color:#f92672">.</span>full(len(eigenvalues), n) <span style="color:#f92672">+</span> jitter, eigenvalues, color<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;black&#39;</span>, s<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.3</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Plot median</span>
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>scatter([n], [np<span style="color:#f92672">.</span>median(eigenvalues)], color<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;red&#39;</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.5</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Plot range</span>
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>plot([n <span style="color:#f92672">-</span> <span style="color:#ae81ff">0.5</span>, n <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.5</span>], [np<span style="color:#f92672">.</span>min(eigenvalues), np<span style="color:#f92672">.</span>min(eigenvalues)], <span style="color:#e6db74">&#39;k-&#39;</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.5</span>)
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>plot([n <span style="color:#f92672">-</span> <span style="color:#ae81ff">0.5</span>, n <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.5</span>], [np<span style="color:#f92672">.</span>max(eigenvalues), np<span style="color:#f92672">.</span>max(eigenvalues)], <span style="color:#e6db74">&#39;k-&#39;</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.5</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;Molecular formula&#39;</span>)
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_ylabel(<span style="color:#e6db74">&#39;Largest eigenvalue&#39;</span>)
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_xticks(range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>))
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_xticklabels([<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span> <span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>)])
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_title(<span style="color:#e6db74">&#39;Largest eigenvalues of the Coulomb matrix for alkane constitutional isomers&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>savefig(<span style="color:#e6db74">&#39;alkane_coulomb_matrix_largest_eigenvalues.webp&#39;</span>, bbox_inches<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;tight&#39;</span>)
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/alkane_coulomb_matrix_largest_eigenvalues.webp"
         alt="Largest eigenvalues of the Coulomb matrix for alkane constitutional isomers."
         title="Largest eigenvalues of the Coulomb matrix for alkane constitutional isomers."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Largest eigenvalues show sub-linear growth with molecular size and increasing overlap between isomers.</figcaption>
    
</figure>

<p>Our results match the original paper. We observe sub-linear growth in the largest eigenvalue with carbon number, and critically, increasing overlap between isomers as molecules grow larger. The largest eigenvalue alone cannot reliably distinguish constitutional isomers for larger alkanes.</p>
<h3 id="eigenvalue-distributions-for-heptane-isomers">Eigenvalue Distributions for Heptane Isomers</h3>
<p>Looking deeper at a specific case, I analyzed the probability density functions for heptane ($C_7H_{16}$) isomers. This molecule has nine constitutional isomers, providing a good test of discrimination power.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n_sel <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">8</span>):
</span></span><span style="display:flex;"><span>    fig, ax <span style="color:#f92672">=</span> plt<span style="color:#f92672">.</span>subplots(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>, figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">5</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    smiles <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;isomers/C</span><span style="color:#e6db74">{</span>n_sel<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n_sel <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.smi&#39;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> f:
</span></span><span style="display:flex;"><span>            smiles<span style="color:#f92672">.</span>append(line<span style="color:#f92672">.</span>strip())
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(len(spectra[n_sel])):
</span></span><span style="display:flex;"><span>        eigenvalues <span style="color:#f92672">=</span> spectra[n_sel][i][:, <span style="color:#ae81ff">0</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># kde plot with the following params</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># - Gaussian kernel</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># - bandwidth with Silverman&#39;s rule of thumb</span>
</span></span><span style="display:flex;"><span>        ax <span style="color:#f92672">=</span> sns<span style="color:#f92672">.</span>kdeplot(eigenvalues, bw_method<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;silverman&#39;</span>, label<span style="color:#f92672">=</span>get_iupac_name(smiles[i]))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;Largest eigenvalue&#39;</span>)
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>set_ylabel(<span style="color:#e6db74">&#39;Density&#39;</span>)
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>set_title(<span style="color:#e6db74">&#39;PDF of the largest eigenvalue for $C_&#39;</span> <span style="color:#f92672">+</span> str(n_sel) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#39;H_{&#39;</span> <span style="color:#f92672">+</span> str(<span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n_sel <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#39;}$&#39;</span>)
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>legend()
</span></span><span style="display:flex;"><span>    plt<span style="color:#f92672">.</span>savefig(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;pdf_largest_eigenvalue_C</span><span style="color:#e6db74">{</span>n_sel<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n_sel <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.webp&#39;</span>, bbox_inches<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;tight&#39;</span>)
</span></span><span style="display:flex;"><span>    plt<span style="color:#f92672">.</span>close()
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/pdf_largest_eigenvalue_C7H16.webp"
         alt="PDFs of the largest eigenvalue for heptane isomers."
         title="PDFs of the largest eigenvalue for heptane isomers."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Heptane isomers show distinct eigenvalue ranges: n-heptane (smallest), 2,2,3-trimethylbutane (largest), with others overlapping.</figcaption>
    
</figure>

<p>The pattern is clear:</p>
<ul>
<li><strong>n-heptane</strong> (linear chain) has the smallest eigenvalues</li>
<li><strong>2,2,3-trimethylbutane</strong> (highly branched) has the largest</li>
<li><strong>Seven other isomers</strong> fall in between with substantial overlap</li>
</ul>
<p>This demonstrates the fundamental limitation: while extreme structural differences (linear vs. highly branched) create separable eigenvalue distributions, intermediate structures become indistinguishable.</p>
<p>For smaller alkanes, the separation is more promising:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/pdf_largest_eigenvalue_C4H10.webp"
         alt="PDFs for butane isomers."
         title="PDFs for butane isomers."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Butane (n=4): Clean separation between linear and branched structures.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/pdf_largest_eigenvalue_C5H12.webp"
         alt="PDFs for pentane isomers."
         title="PDFs for pentane isomers."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Pentane (n=5): Good separation between most isomers.</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/pdf_largest_eigenvalue_C6H14.webp"
         alt="PDFs for hexane isomers."
         title="PDFs for hexane isomers."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Hexane (n=6): Some isomers (2-methylpentane, 3-methylpentane) become difficult to distinguish.</figcaption>
    
</figure>

<p>The progression is clear: eigenvalue-based discrimination works well for small alkanes and degrades as molecular complexity increases.</p>
<h3 id="two-dimensional-eigenvalue-space">Two-Dimensional Eigenvalue Space</h3>
<p>Can we improve discrimination by using multiple eigenvalues? For butane, plotting the first two eigenvalues reveals interesting structure:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>fig, ax <span style="color:#f92672">=</span> plt<span style="color:#f92672">.</span>subplots(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>, figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">5</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>n_sel <span style="color:#f92672">=</span> <span style="color:#ae81ff">4</span>
</span></span><span style="display:flex;"><span>smiles <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">with</span> open(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;isomers/C</span><span style="color:#e6db74">{</span>n_sel<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n_sel <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.smi&#39;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> f:
</span></span><span style="display:flex;"><span>        smiles<span style="color:#f92672">.</span>append(line<span style="color:#f92672">.</span>strip())
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(len(spectra[n_sel])):
</span></span><span style="display:flex;"><span>    eigenvalues <span style="color:#f92672">=</span> spectra[n_sel][i][:, :<span style="color:#ae81ff">2</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>scatter(eigenvalues[:, <span style="color:#ae81ff">0</span>], eigenvalues[:, <span style="color:#ae81ff">1</span>], label<span style="color:#f92672">=</span>get_iupac_name(smiles[i]))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;Largest eigenvalue&#39;</span>)
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_ylabel(<span style="color:#e6db74">&#39;Second largest eigenvalue&#39;</span>)
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_title(<span style="color:#e6db74">&#39;2D plot of the first two eigenvalues for $C_4H_</span><span style="color:#e6db74">{10}</span><span style="color:#e6db74">$ conformers&#39;</span>)
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>legend()
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>savefig(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;2d_largest_eigenvalue_C</span><span style="color:#e6db74">{</span>n_sel<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n_sel <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.webp&#39;</span>, bbox_inches<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;tight&#39;</span>)
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/2d_largest_eigenvalue_C4H10.webp"
         alt="2D eigenvalue space for butane isomers."
         title="2D eigenvalue space for butane isomers."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Perfect linear separation of butane isomers using the first two eigenvalues.</figcaption>
    
</figure>

<p>The two isomers cluster distinctly, demonstrating that multi-dimensional eigenvalue features can achieve perfect separation for simple cases. The outlier point in the lower right likely results from conformational sampling noise.</p>
<h3 id="dimensionality-and-information-content">Dimensionality and Information Content</h3>
<p>How many eigenvalues do we actually need? Principal component analysis reveals the effective dimensionality of the eigenvalue representations:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.decomposition <span style="color:#f92672">import</span> PCA
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>fig, ax <span style="color:#f92672">=</span> plt<span style="color:#f92672">.</span>subplots(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>, figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">5</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>n_components <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    n_components[n] <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(len(spectra[n])):
</span></span><span style="display:flex;"><span>        eigenvalues <span style="color:#f92672">=</span> spectra[n][i]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># PCA</span>
</span></span><span style="display:flex;"><span>        pca <span style="color:#f92672">=</span> PCA(n_components<span style="color:#f92672">=</span><span style="color:#ae81ff">0.99</span>, svd_solver<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;full&#39;</span>, whiten<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>, random_state<span style="color:#f92672">=</span><span style="color:#ae81ff">42</span>)
</span></span><span style="display:flex;"><span>        pca<span style="color:#f92672">.</span>fit(eigenvalues)
</span></span><span style="display:flex;"><span>        n_components[n]<span style="color:#f92672">.</span>append(pca<span style="color:#f92672">.</span>n_components_)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>scatter([n] <span style="color:#f92672">*</span> len(n_components[n]), n_components[n], alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.3</span>)
</span></span><span style="display:flex;"><span>    ax<span style="color:#f92672">.</span>plot([n <span style="color:#f92672">-</span> <span style="color:#ae81ff">0.25</span>, n <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.25</span>], [np<span style="color:#f92672">.</span>mean(n_components[n]), np<span style="color:#f92672">.</span>mean(n_components[n])], <span style="color:#e6db74">&#39;k-&#39;</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.5</span>)  <span style="color:#75715e"># Draw a line for the mean</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>plot([<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">11</span>], [<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">11</span>], <span style="color:#e6db74">&#39;k--&#39;</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.5</span>, label<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;y = num carbon&#39;</span>)
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>plot([<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">11</span>], [<span style="color:#ae81ff">5</span>, <span style="color:#ae81ff">35</span>], <span style="color:#e6db74">&#39;r--&#39;</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.5</span>, label<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;y = num atoms&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;Number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_ylabel(<span style="color:#e6db74">&#39;Number of principal components&#39;</span>)
</span></span><span style="display:flex;"><span>ax<span style="color:#f92672">.</span>set_title(<span style="color:#e6db74">&#39;99% variance explained by number of principal components&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>legend()
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>savefig(<span style="color:#e6db74">&#39;99_variance_explained.webp&#39;</span>, bbox_inches<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;tight&#39;</span>)
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/99_variance_explained.webp"
         alt="Principal components needed for 99% variance."
         title="Principal components needed for 99% variance."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The eigenvalue space compresses efficiently. Far fewer components than the $3n+2$ eigenvalue dimensions are needed.</figcaption>
    
</figure>

<p>Key observations:</p>
<ul>
<li><strong>High compressibility</strong>: Far fewer than the $3n+2$ eigenvalue dimensions (one per atom) are needed</li>
<li><strong>Linear scaling</strong>: Principal components grow roughly linearly with carbon number</li>
<li><strong>Efficient representation</strong>: The eigenvalue space has lower effective dimensionality than expected</li>
</ul>
<p>This suggests the representations are highly correlated, enabling significant dimensionality reduction without information loss.</p>
<h2 id="log-transformed-coulomb-matrices">Log-Transformed Coulomb Matrices</h2>
<p>As explored in our <a href="/posts/molecular-descriptor-coulomb-matrix/">previous post on Coulomb matrices</a>, log transformation can reveal different structural information. Standard Coulomb matrices emphasize heavy atom interactions. Log transformation expands the influence of hydrogen atoms by mapping magnitudes in $[0,1]$ to $[-\infty,0]$.</p>
<p>I generated equivalent datasets using log-transformed matrices to test how this affects discriminative power.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>os<span style="color:#f92672">.</span>makedirs(<span style="color:#e6db74">&#39;spectra&#39;</span>, exist_ok<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>n_confs <span style="color:#f92672">=</span> <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;Generating spectra for C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;isomers/C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.smi&#39;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        lines <span style="color:#f92672">=</span> f<span style="color:#f92672">.</span>readlines()
</span></span><span style="display:flex;"><span>        print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;</span><span style="color:#ae81ff">\t</span><span style="color:#e6db74">Number of SMILES strings: </span><span style="color:#e6db74">{</span>len(lines)<span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> i, line <span style="color:#f92672">in</span> enumerate(tqdm(lines)):
</span></span><span style="display:flex;"><span>            print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;</span><span style="color:#ae81ff">\t\t</span><span style="color:#e6db74">{</span>i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span><span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">{</span>len(lines)<span style="color:#e6db74">}</span><span style="color:#e6db74"> - </span><span style="color:#e6db74">{</span>line<span style="color:#f92672">.</span>strip()<span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> line<span style="color:#f92672">.</span>strip():
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> os<span style="color:#f92672">.</span>path<span style="color:#f92672">.</span>exists(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;spectra/log-C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">_</span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">}</span><span style="color:#e6db74">.npy&#39;</span>):
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            smiles <span style="color:#f92672">=</span> line<span style="color:#f92672">.</span>strip()
</span></span><span style="display:flex;"><span>            spectra <span style="color:#f92672">=</span> gen_n_spectra(n<span style="color:#f92672">=</span>n_confs, smiles_str<span style="color:#f92672">=</span>smiles, log<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>            np<span style="color:#f92672">.</span>save(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;spectra/log-C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">_</span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">:</span><span style="color:#e6db74">03d</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.npy&#39;</span>, spectra)
</span></span></code></pre></div><h3 id="log-transformed-eigenvalue-distributions">Log-Transformed Eigenvalue Distributions</h3>
<p>The log transformation substantially changes the eigenvalue landscape:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/alkane_log_coulomb_matrix_largest_eigenvalues.webp"
         alt="Log-transformed eigenvalues across alkane series."
         title="Log-transformed eigenvalues across alkane series."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Log transformation emphasizes hydrogen interactions, creating larger eigenvalue ranges and more negative values.</figcaption>
    
</figure>

<p>Log-transformed versions exhibit distinct characteristics:</p>
<ul>
<li><strong>Span large negative values</strong> due to hydrogen atom emphasis</li>
<li><strong>Show increasing variance</strong> between isomers as molecular size grows</li>
<li><strong>Demonstrate greater discrimination potential</strong> for some isomers</li>
</ul>
<p>This comes with trade-offs. The distributions can become significantly broader, as seen in the heptane analysis:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/pdf_log_largest_eigenvalue_C7H16.webp"
         alt="Log-transformed eigenvalue PDFs for heptane."
         title="Log-transformed eigenvalue PDFs for heptane."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Log transformation creates wider, more overlapping distributions that may reduce discrimination power.</figcaption>
    
</figure>

<p>The log scale on the y-axis is necessary because unbranched isomers become nearly invisible due to the highly concentrated distributions of branched isomers.</p>
<h3 id="two-dimensional-log-transformed-space">Two-Dimensional Log-Transformed Space</h3>
<p>The 2D eigenvalue plot for log-transformed butane shows similar clustering behavior:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/2d_log_largest_eigenvalue_C4H10.webp"
         alt="2D log-transformed eigenvalue space for butane."
         title="2D log-transformed eigenvalue space for butane."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Log transformation brings isomers closer together while maintaining separability for simple cases.</figcaption>
    
</figure>

<p>The transformation reduces the separation distance, yet linear discriminability remains intact for this simple case.</p>
<h3 id="dimensionality-of-log-transformed-features">Dimensionality of Log-Transformed Features</h3>
<p>Principal component analysis of log-transformed eigenvalues reveals similar compression properties:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/99_variance_explained_log.webp"
         alt="Principal components for log-transformed eigenvalues."
         title="Principal components for log-transformed eigenvalues."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Log transformation requires slightly more principal components while maintaining efficient compression.</figcaption>
    
</figure>

<p>The log-transformed features show comparable dimensionality reduction with marginally higher component requirements.</p>
<h2 id="testing-eigenvalue-separability">Testing Eigenvalue Separability</h2>
<p>Our exploratory analysis revealed concerning patterns that hint at fundamental limitations: high correlation between eigenvalue dimensions, rapid dimensionality compression via PCA, and overlapping distributions for larger molecules ($n \geq 6$).</p>
<p>These findings leave the question open. We now test eigenvalues directly to see if they can actually separate constitutional isomers without supervision.</p>
<p>We&rsquo;ll use two complementary clustering metrics to measure how well eigenvalues separate constitutional isomers. This is a fair test. We only compare isomers with identical molecular formulas, keeping eigenvalue dimensions constant.</p>
<h3 id="dunn-index-global-cluster-quality">Dunn Index: Global Cluster Quality</h3>
<p>The <a href="https://en.wikipedia.org/wiki/Dunn_index">Dunn Index</a> provides a single metric capturing cluster quality. It asks: &ldquo;Are the closest different clusters still farther apart than the most spread-out individual cluster?&rdquo;</p>
<p>$$
\text{Dunn Index} = \frac{\text{smallest distance between different clusters}}{\text{largest diameter within any cluster}}
$$</p>
<p>Higher values indicate better separation. When it approaches zero, clusters become indistinguishable, exactly what we suspected from the overlapping eigenvalue distributions observed earlier.</p>
<p>Computing the Dunn Index for each alkane series:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>dunn_scores <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    tik <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>time()
</span></span><span style="display:flex;"><span>    dunn_scores[n] <span style="color:#f92672">=</span> dunn_index([spectra[n][i] <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> spectra[n]])
</span></span><span style="display:flex;"><span>    tok <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>time()
</span></span><span style="display:flex;"><span>    dunn_scores[n][<span style="color:#e6db74">&#39;time&#39;</span>] <span style="color:#f92672">=</span> tok <span style="color:#f92672">-</span> tik
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">:&#39;</span>, dunn_scores[n])
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plaintext" data-lang="plaintext"><span style="display:flex;"><span>C4H10: {&#39;diameter&#39;: 21.43072917950398, &#39;distance&#39;: 8.316362440688767, &#39;dunn_index&#39;: 0.3880578383978837, &#39;time&#39;: 0.06010293960571289}
</span></span><span style="display:flex;"><span>C5H12: {&#39;diameter&#39;: 23.449286379564892, &#39;distance&#39;: 2.4693042873545856, &#39;dunn_index&#39;: 0.10530402705587172, &#39;time&#39;: 0.10832405090332031}
</span></span><span style="display:flex;"><span>C6H14: {&#39;diameter&#39;: 19.602363375467938, &#39;distance&#39;: 1.4477574259511048, &#39;dunn_index&#39;: 0.07385626917634591, &#39;time&#39;: 0.28030991554260254}
</span></span><span style="display:flex;"><span>C7H16: {&#39;diameter&#39;: 20.065014927470955, &#39;distance&#39;: 0.4050094394280803, &#39;dunn_index&#39;: 0.02018485612355977, &#39;time&#39;: 1.0307331085205078}
</span></span><span style="display:flex;"><span>C8H18: {&#39;diameter&#39;: 24.794154667613665, &#39;distance&#39;: 0.5013450168168625, &#39;dunn_index&#39;: 0.020220290771668196, &#39;time&#39;: 4.199508905410767}
</span></span><span style="display:flex;"><span>C9H20: {&#39;diameter&#39;: 21.811025941686033, &#39;distance&#39;: 0.34381162248560415, &#39;dunn_index&#39;: 0.01576320267578513, &#39;time&#39;: 17.400264978408813}
</span></span><span style="display:flex;"><span>C10H22: {&#39;diameter&#39;: 27.180773716656066, &#39;distance&#39;: 0.4986608768730121, &#39;dunn_index&#39;: 0.0183460883811206, &#39;time&#39;: 86.00787401199341}
</span></span><span style="display:flex;"><span>C11H24: {&#39;diameter&#39;: 25.58731511020692, &#39;distance&#39;: 0.5490373275460223, &#39;dunn_index&#39;: 0.021457402825629343, &#39;time&#39;: 424.4431610107422}
</span></span></code></pre></div><p>The computation time grows dramatically, over 7 minutes for $C_{11}H_{24}$, due to quadratic scaling with the number of isomers (159 isomers requiring ~12,000 pairwise comparisons).</p>
<p>The results reveal a clear trend:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>fig, axs <span style="color:#f92672">=</span> plt<span style="color:#f92672">.</span>subplots(<span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">2</span>, figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">15</span>, <span style="color:#ae81ff">10</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># in axs[0, 0] - diameter vs number of carbon atoms</span>
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>plot(
</span></span><span style="display:flex;"><span>    list(range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)),
</span></span><span style="display:flex;"><span>    [dunn_scores[n][<span style="color:#e6db74">&#39;diameter&#39;</span>] <span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)],
</span></span><span style="display:flex;"><span>    marker<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;o&#39;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;Number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>set_ylabel(<span style="color:#e6db74">&#39;Diameter&#39;</span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>set_title(<span style="color:#e6db74">&#39;Diameter vs number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># in axs[0, 1] - distance vs number of carbon atoms</span>
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>plot(
</span></span><span style="display:flex;"><span>    list(range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)),
</span></span><span style="display:flex;"><span>    [dunn_scores[n][<span style="color:#e6db74">&#39;distance&#39;</span>] <span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)],
</span></span><span style="display:flex;"><span>    marker<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;o&#39;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;Number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>set_ylabel(<span style="color:#e6db74">&#39;Distance&#39;</span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>set_title(<span style="color:#e6db74">&#39;Distance vs number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># in axs[1, 0] - dunn index vs number of carbon atoms</span>
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>plot(
</span></span><span style="display:flex;"><span>    list(range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)),
</span></span><span style="display:flex;"><span>    [dunn_scores[n][<span style="color:#e6db74">&#39;dunn_index&#39;</span>] <span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)],
</span></span><span style="display:flex;"><span>    marker<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;o&#39;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;Number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>set_ylabel(<span style="color:#e6db74">&#39;Dunn index&#39;</span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>set_title(<span style="color:#e6db74">&#39;Dunn index vs number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># in axs[1, 1] - time vs number of carbon atoms</span>
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>plot(
</span></span><span style="display:flex;"><span>    list(range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)),
</span></span><span style="display:flex;"><span>    [dunn_scores[n][<span style="color:#e6db74">&#39;time&#39;</span>] <span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)],
</span></span><span style="display:flex;"><span>    marker<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;o&#39;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;Number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>set_ylabel(<span style="color:#e6db74">&#39;Time (s)&#39;</span>)
</span></span><span style="display:flex;"><span>axs[<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>set_title(<span style="color:#e6db74">&#39;Time vs number of carbon atoms&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>tight_layout()
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>savefig(<span style="color:#e6db74">&#39;dunn_index_vs_num_carbon_atoms.webp&#39;</span>, bbox_inches<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;tight&#39;</span>)
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/dunn_index_vs_num_carbon_atoms.webp"
         alt="Dunn Index analysis showing separability metrics, distances, and computation time versus molecular size"
         title="Dunn Index analysis showing separability metrics, distances, and computation time versus molecular size"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Dunn Index analysis reveals deteriorating separability as molecular complexity increases.</figcaption>
    
</figure>

<p>The trend confirms our earlier concerns:</p>
<ul>
<li><strong>$C_{4}H_{10}$</strong>: Excellent separation (Dunn Index = 0.39) between butane and isobutane</li>
<li><strong>$C_{5}H_{12}$ to $C_{6}H_{14}$</strong>: Rapid decline in separability</li>
<li><strong>$C_{7}H_{16}$ and beyond</strong>: Poor separation (Dunn Index $\approx$ 0.02)</li>
</ul>
<p>This validates our computational pipeline and matches the original paper&rsquo;s findings. For larger molecules, eigenvalue clusters become nearly indistinguishable, confirming the overlapping distributions we observed earlier.</p>
<h3 id="silhouette-analysis-individual-conformation-assessment">Silhouette Analysis: Individual Conformation Assessment</h3>
<p>The Dunn Index provides the global view. We must also consider individual molecules. The <a href="https://en.wikipedia.org/wiki/Silhouette_(clustering)">silhouette score</a> evaluates each conformation separately, asking: &ldquo;Is this molecule closer to its own isomer family or to a different one?&rdquo;</p>
<p>For each molecular conformation $i$:</p>
<p>$$
s(i) = \frac{b(i) - a(i)}{\max(a(i), b(i))}
$$</p>
<p>where:</p>
<ul>
<li>$a(i)$ = average distance to other conformations of the <strong>same</strong> isomer</li>
<li>$b(i)$ = average distance to conformations of the <strong>nearest different</strong> isomer</li>
</ul>
<p><strong>Interpretation:</strong></p>
<ul>
<li><strong>Score near +1</strong>: Conformation clusters correctly (good clustering)</li>
<li><strong>Score near -1</strong>: Conformation closer to different isomer (misclassification)</li>
</ul>
<p>This enables two critical measurements:</p>
<ol>
<li>How many isomers have <strong>any</strong> misclassified conformations?</li>
<li>What fraction of <strong>individual conformations</strong> get misclassified?</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.metrics <span style="color:#f92672">import</span> silhouette_samples
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> tqdm <span style="color:#f92672">import</span> tqdm
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>s_scores <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> tqdm(range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>)):
</span></span><span style="display:flex;"><span>    X <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    y <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> spectra[n]:
</span></span><span style="display:flex;"><span>        X<span style="color:#f92672">.</span>append(spectra[n][i])
</span></span><span style="display:flex;"><span>        y<span style="color:#f92672">.</span>extend(np<span style="color:#f92672">.</span>full(spectra[n][i]<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>], i))
</span></span><span style="display:flex;"><span>    X <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>concatenate(X)
</span></span><span style="display:flex;"><span>    y <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>array(y)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    s_scores[n] <span style="color:#f92672">=</span> silhouette_samples(X, y)
</span></span></code></pre></div><p>Computing both clustering quality metrics:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Metric 1: Fraction of isomers with ANY negative scores</span>
</span></span><span style="display:flex;"><span>neg_iso <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    n_iso <span style="color:#f92672">=</span> s_scores[n]<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">//</span> <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>    n_has_neg <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(n_iso):
</span></span><span style="display:flex;"><span>        chunk <span style="color:#f92672">=</span> s_scores[n][i <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>:(i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> np<span style="color:#f92672">.</span>any(chunk <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0</span>):
</span></span><span style="display:flex;"><span>            n_has_neg <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    neg_iso[n] <span style="color:#f92672">=</span> n_has_neg <span style="color:#f92672">/</span> n_iso
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Metric 2: Individual conformation misclassification rates</span>
</span></span><span style="display:flex;"><span>neg_confs <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    n_iso <span style="color:#f92672">=</span> s_scores[n]<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">//</span> <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>    neg_confs[n] <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>zeros(n_iso)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(n_iso):
</span></span><span style="display:flex;"><span>        isomer_scores <span style="color:#f92672">=</span> s_scores[n][i <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>:(i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>]
</span></span><span style="display:flex;"><span>        neg_confs[n][i] <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>sum(isomer_scores <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">/</span> isomer_scores<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>]
</span></span></code></pre></div><h4 id="isomer-level-analysis">Isomer-Level Analysis</h4>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/fraction_of_negative_silhouette_scores_vs_num_carbon_atoms.webp"
         alt="Chart showing fraction of isomers with at least one misclassified conformation"
         title="Chart showing fraction of isomers with at least one misclassified conformation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Fraction of isomers with at least one misclassified conformation (a stringent test of cluster purity).</figcaption>
    
</figure>

<p>The trend is concerning: by $C_{11}H_{24}$, 97% of isomers have at least one conformation that would be misclassified. This metric is deliberately strict. Even a single misplaced conformation marks the entire isomer as problematic.</p>
<h4 id="conformation-level-analysis">Conformation-Level Analysis</h4>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/fraction_of_negative_silhouette_scores_vs_num_carbon_atoms_individual.webp"
         alt="Chart showing individual misclassification rates per isomer with horizontal lines showing range for each molecular size"
         title="Chart showing individual misclassification rates per isomer with horizontal lines showing range for each molecular size"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Individual misclassification rates per isomer. Each point represents one isomer; horizontal lines show the range for each molecular size.</figcaption>
    
</figure>

<p>The individual analysis reveals dramatic variation:</p>
<ul>
<li><strong>$C_{4}H_{10}$</strong>: Perfect clustering (0% misclassification), confirming our earlier 2D separation plots</li>
<li><strong>$C_{5}H_{12}$ to $C_{6}H_{14}$</strong>: Modest problems (1-8% misclassification rates)</li>
<li><strong>$C_{11}H_{24}$</strong>: Average 35% conformations misclassified per isomer</li>
</ul>
<p>Some isomers experience up to 99.5% conformation misclassification (they become essentially unrecognizable in eigenvalue space). This directly connects to our earlier observation: mathematical representations that appear elegant may lack the structural nuances needed for practical discrimination.</p>
<h2 id="supervised-learning-finding-hidden-structure">Supervised Learning: Finding Hidden Structure</h2>
<p>Both clustering metrics deliver the same conclusion: Coulomb matrix eigenvalues alone struggle to reliably distinguish constitutional isomers for larger alkanes. The mathematical elegance of eigenvalues encounters practical limitations as molecular complexity increases.</p>
<p>Supervised learning offers an alternative approach. Providing labels allows models to extract hidden patterns that elude clustering algorithms. The mathematical structure often requires explicit guidance for discovery.</p>
<p>I&rsquo;ll focus on two baseline approaches: k-nearest neighbors and logistic regression. These represent fundamentally different learning paradigms (one memorizes patterns, the other learns linear boundaries) giving us insight into what types of structure might exist in eigenvalue space.</p>
<h2 id="k-nearest-neighbors-pattern-recognition-through-memory">k-Nearest Neighbors: Pattern Recognition Through Memory</h2>
<p>k-NN represents the simplest supervised learning approach: it stores all training examples and classifies new samples based on their closest neighbors. If eigenvalue patterns truly distinguish isomers, nearby points in eigenvalue space should belong to the same class.</p>
<p>This directly tests the local structure. Local neighborhoods often preserve meaningful distinctions even when global structure appears diffuse.</p>
<h3 id="testing-different-feature-representations">Testing Different Feature Representations</h3>
<p>We compare three approaches: full eigenvalue vectors, top 10 eigenvalues only, and PCA-reduced representations.</p>
<p>Testing 1-nearest neighbor with full dimensionality:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.model_selection <span style="color:#f92672">import</span> cross_val_score, StratifiedKFold
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.neighbors <span style="color:#f92672">import</span> KNeighborsClassifier
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>df_1nn <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Prepare the data for CnH2n+2</span>
</span></span><span style="display:flex;"><span>    X, y <span style="color:#f92672">=</span> prep_data(n<span style="color:#f92672">=</span>n)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Create knn classifier</span>
</span></span><span style="display:flex;"><span>    knn <span style="color:#f92672">=</span> KNeighborsClassifier(n_neighbors<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Set up stratified 5-fold cross-validation</span>
</span></span><span style="display:flex;"><span>    cv <span style="color:#f92672">=</span> StratifiedKFold(n_splits<span style="color:#f92672">=</span><span style="color:#ae81ff">5</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Perform cross-validation. Since &#39;cross_val_score&#39; computes accuracy, we compute misclassification rate by subtracting accuracy from 1.</span>
</span></span><span style="display:flex;"><span>    acc_scores <span style="color:#f92672">=</span> cross_val_score(knn, X, y, cv<span style="color:#f92672">=</span>cv, scoring<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;accuracy&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Convert accuracy scores to misclassification error rates</span>
</span></span><span style="display:flex;"><span>    misclassification_error_rates <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> acc_scores
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Calculate the average and standard deviation of the misclassification error rates</span>
</span></span><span style="display:flex;"><span>    avg_misclassification_error <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>mean(misclassification_error_rates)
</span></span><span style="display:flex;"><span>    std_misclassification_error <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>std(misclassification_error_rates)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">{</span>avg_misclassification_error<span style="color:#e6db74">:</span><span style="color:#e6db74">.2%</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> ± </span><span style="color:#e6db74">{</span>std_misclassification_error<span style="color:#e6db74">:</span><span style="color:#e6db74">.2%</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    df_1nn<span style="color:#f92672">.</span>append({
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;molecule&#39;</span>: <span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;avg_misclassification_error&#39;</span>: avg_misclassification_error,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;std_misclassification_error&#39;</span>: std_misclassification_error,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;n&#39;</span>: n,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;representation&#39;</span>: <span style="color:#e6db74">&#39;full&#39;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;model&#39;</span>: <span style="color:#e6db74">&#39;1nn&#39;</span>,
</span></span><span style="display:flex;"><span>    })
</span></span></code></pre></div><p>The results are remarkable compared to unsupervised clustering:</p>
<pre><code>C4H10: 0.00% ± 0.00%
C5H12: 0.00% ± 0.00%
C6H14: 0.00% ± 0.00%
C7H16: 0.07% ± 0.05%
C8H18: 0.11% ± 0.05%
C9H20: 0.51% ± 0.09%
C10H22: 1.31% ± 0.09%
C11H24: 3.24% ± 0.09%
</code></pre>
<p><strong>Perfect classification</strong> for molecules up to $C_{6}H_{14}$, with low error rates even for $C_{11}H_{24}$ (3.24%). This is a large improvement over clustering, where 97% of $C_{11}H_{24}$ isomers had misclassified conformations.</p>
<p><strong>Note on feature scaling:</strong> Standardizing features significantly degraded performance; eigenvalue magnitudes carry crucial structural information.</p>
<p>Comparing performance across different feature representations:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/alkane-classification-1nn.webp"
         alt="1-NN performance across different representations"
         title="1-NN performance across different representations"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">1-NN classification performance across different eigenvalue representations shows similar results, with slight advantages for full representations on larger molecules.</figcaption>
    
</figure>

<p><strong>Key insights:</strong></p>
<ul>
<li><strong>Representation choice matters little</strong> for 1-NN. Full, top-10, and PCA representations perform nearly identically</li>
<li><strong>PCA slightly outperforms</strong> top-10 eigenvalues for larger molecules, capturing more structural variance</li>
<li><strong>Perfect classification</strong> persists through $C_{6}H_{14}$ regardless of representation</li>
</ul>
<p>This confirms that discriminative information concentrates in the largest eigenvalues, validating our earlier PCA findings.</p>
<h3 id="the-neighbor-count-effect">The Neighbor Count Effect</h3>
<p>Testing k-NN with different neighbor counts (k=1, 3, 5) reveals a counterintuitive pattern:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/alkane-classification-knn.webp"
         alt="k-NN performance for different k values"
         title="k-NN performance for different k values"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">k-NN classification performance decreases as k increases. More neighbors actually hurt accuracy.</figcaption>
    
</figure>

<p><strong>Why does performance degrade with more neighbors?</strong> This connects directly to our earlier clustering analysis. The eigenvalue space lacks meaningful local structure. When k-NN examines beyond the immediate nearest neighbor, it increasingly finds examples from different classes.</p>
<p>This validates our unsupervised findings: in the absence of clear cluster boundaries, examining more neighbors introduces noise.</p>
<h2 id="logistic-regression-learning-linear-decision-boundaries">Logistic Regression: Learning Linear Decision Boundaries</h2>
<p>Logistic regression represents a fundamentally different approach. Logistic regression learns linear decision boundaries in eigenvalue space. If eigenvalues encode structural information linearly, this should work well.</p>
<p>We&rsquo;ll focus on PCA-reduced representations to keep computation manageable, using insights from the k-NN analysis.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.linear_model <span style="color:#f92672">import</span> LogisticRegression
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.pipeline <span style="color:#f92672">import</span> Pipeline
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.decomposition <span style="color:#f92672">import</span> PCA
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>df_lr <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">4</span>, <span style="color:#ae81ff">12</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Prepare the data for CnH2n+2</span>
</span></span><span style="display:flex;"><span>    X, y <span style="color:#f92672">=</span> prep_data(n<span style="color:#f92672">=</span>n)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Create logistic regression classifier with PCA</span>
</span></span><span style="display:flex;"><span>    lr <span style="color:#f92672">=</span> Pipeline([
</span></span><span style="display:flex;"><span>        (<span style="color:#e6db74">&#39;pca&#39;</span>, PCA(n_components<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>)),
</span></span><span style="display:flex;"><span>        (<span style="color:#e6db74">&#39;lr&#39;</span>, LogisticRegression(
</span></span><span style="display:flex;"><span>            max_iter<span style="color:#f92672">=</span><span style="color:#ae81ff">10_000</span>,
</span></span><span style="display:flex;"><span>            penalty<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;l2&#39;</span>,
</span></span><span style="display:flex;"><span>            solver<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;lbfgs&#39;</span>,
</span></span><span style="display:flex;"><span>            C<span style="color:#f92672">=</span><span style="color:#ae81ff">10.0</span>,  <span style="color:#75715e"># Reduced regularization</span>
</span></span><span style="display:flex;"><span>            random_state<span style="color:#f92672">=</span><span style="color:#ae81ff">42</span>,
</span></span><span style="display:flex;"><span>            n_jobs<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>        ))
</span></span><span style="display:flex;"><span>    ])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 5-fold stratified cross-validation</span>
</span></span><span style="display:flex;"><span>    cv <span style="color:#f92672">=</span> StratifiedKFold(n_splits<span style="color:#f92672">=</span><span style="color:#ae81ff">5</span>)
</span></span><span style="display:flex;"><span>    acc_scores <span style="color:#f92672">=</span> cross_val_score(lr, X, y, cv<span style="color:#f92672">=</span>cv, scoring<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;accuracy&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Convert to misclassification rates</span>
</span></span><span style="display:flex;"><span>    avg_error <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>mean(<span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> acc_scores)
</span></span><span style="display:flex;"><span>    std_error <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>std(<span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> acc_scores)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;C</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">}</span><span style="color:#e6db74">H</span><span style="color:#e6db74">{</span><span style="color:#ae81ff">2</span><span style="color:#f92672">*</span>n <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span><span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">{</span>avg_error<span style="color:#e6db74">:</span><span style="color:#e6db74">.2%</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> ± </span><span style="color:#e6db74">{</span>std_error<span style="color:#e6db74">:</span><span style="color:#e6db74">.2%</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span></code></pre></div><p>Comparing k-NN versus logistic regression performance:</p>















<figure class="post-figure center ">
    <img src="/img/alkane-constitutional-isomers/alkane-classification-1nn-lr.webp"
         alt="Comparison of 1-NN and Logistic Regression performance"
         title="Comparison of 1-NN and Logistic Regression performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">k-NN significantly outperforms logistic regression, especially for larger molecules. The performance gap widens as molecular complexity increases.</figcaption>
    
</figure>

<p><strong>Key observations:</strong></p>
<ul>
<li><strong>k-NN dominates</strong> across all molecular sizes</li>
<li><strong>Linear boundaries fail</strong> for larger molecules. This suggests nonlinear eigenvalue relationships.</li>
<li><strong>Performance gap grows</strong> with molecular complexity, indicating increasingly nonlinear structural patterns</li>
</ul>
<p>Logistic regression&rsquo;s performance indicates that discriminative patterns in eigenvalue space are fundamentally nonlinear. Capturing these complex relationships requires memory-based or non-linear approaches.</p>
<h2 id="implications-for-molecular-representation">Implications for Molecular Representation</h2>
<p>Our supervised learning experiments reveal a nuanced picture of Coulomb matrix eigenvalues as molecular descriptors. Eigenvalues preserve sufficient local structure for nearest-neighbor classification to work remarkably well, despite lacking clean global clusters.</p>
<p>This analysis reveals important lessons about molecular representations:</p>
<ol>
<li><strong>Empirical performance and mathematical elegance are separate axes</strong>: an elegant descriptor can still fail in practice as the space gets more complex.</li>
<li><strong>Context matters</strong>: Representations exhibit distinct performance characteristics under supervised versus unsupervised conditions.</li>
<li><strong>Molecular complexity is challenging</strong>: Even simple alkanes test our best descriptors.</li>
<li><strong>Local vs. global structure</strong>: Local neighborhood structures often contain highly discriminative information.</li>
</ol>
<p>For practitioners working with molecular representations, it is crucial to test multiple learning paradigms. Supervised and unsupervised approaches often yield different insights. Furthermore, logistic regression&rsquo;s poor performance indicates that discriminative patterns in eigenvalue space are fundamentally nonlinear. Capturing these complex relationships requires memory-based or non-linear approaches.</p>
<p><strong>Why this matters beyond the alkane case:</strong> molecular representations are the input layer for property prediction, and understanding where a simple descriptor like Coulomb-matrix eigenvalues fails (overlapping clusters for larger molecules, nonlinear class structure that defeats logistic regression) is what motivates moving to graph- or coordinate-aware models. The failure modes here are the argument for richer representations.</p>
<p>The data pipeline that generated the datasets used in this analysis is available at the <a href="/projects/isomer-dataset-generation/">Synthetic Isomer Data Generation Pipeline project page</a>.</p>
]]></content:encoded></item><item><title>Classifying Congressional Bills with Machine Learning</title><link>https://hunterheidenreich.com/posts/congressional-bill-policy-area-classification/</link><pubDate>Wed, 21 Feb 2024 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/congressional-bill-policy-area-classification/</guid><description>Testing ML classification of congressional bills by policy area. Comparing Naive Bayes, Logistic Regression, and XGBoost on legislative text.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>This post explores machine learning approaches for classifying congressional bills by policy area, using data from the 115th to 117th Congresses (2017-2023). We&rsquo;ll examine:</p>
<ul>
<li>The fundamentals of bill classification</li>
<li>Traditional machine learning models as baselines</li>
<li>Performance analysis across different time periods and policy domains</li>
</ul>
<p>This work establishes baselines for future deep learning approaches to legislative text classification.</p>
<p><em>This post builds on the data foundation established in <a href="/posts/us-117th-congress-data-exploration/">Exploring the 117th U.S. Congress</a>.</em></p>
<h3 id="motivation">Motivation</h3>
<p>Automatically classifying congressional bills by policy area has practical value for researchers, journalists, and citizens who need to navigate thousands of bills each Congress. Machine learning can help identify patterns in legislative priorities and track policy trends over time.</p>
<h2 id="data">Data</h2>
<p>The data comes from scraping <a href="https://www.congress.gov/">Congress.gov</a> for all bills from the 115th through 117th Congresses. Each bill includes:</p>
<ul>
<li>Bill ID and title</li>
<li>Summary (when available): the earliest summary provided</li>
<li>Full text (when available): the earliest text version</li>
<li>Policy area classification</li>
</ul>
<p>Our task is to predict policy area from text features:</p>
<p>$$
f(X) = \hat{y}, \quad \text{where} \quad X = { \text{title}, \text{summary}, \text{text} }, \quad \hat{y} \in { \text{policy areas} }
$$</p>
<p>The complete dataset is available at <a href="https://huggingface.co/datasets/hheiden/us-congress-bill-policy-115_117">Hugging Face: hheiden/us-congress-bill-policy-115_117</a>.</p>
<h3 id="bills-by-congress">Bills by Congress</h3>
<p>Our dataset contains the following distribution:</p>
<table>
	<thead>
			<tr>
					<th>Congress</th>
					<th>Bills</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>115th</td>
					<td>13,556</td>
			</tr>
			<tr>
					<td>116th</td>
					<td>16,601</td>
			</tr>
			<tr>
					<td>117th</td>
					<td>17,817</td>
			</tr>
			<tr>
					<td><strong>Total</strong></td>
					<td><strong>47,974</strong></td>
			</tr>
	</tbody>
</table>
<h3 id="policy-areas">Policy Areas</h3>
<p>Each bill receives a policy area label from <a href="https://www.congress.gov/">Congress.gov</a> (see <a href="https://www.congress.gov/help/field-values/policy-area">glossary</a>). The dataset includes 33 policy areas, though these classes are highly imbalanced.</p>
<p>The following table shows the number of bills in each policy area across the three Congresses:</p>
<table>
	<thead>
			<tr>
					<th>Policy Area</th>
					<th>115th</th>
					<th>116th</th>
					<th>117th</th>
					<th>Total</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Agriculture and Food</td>
					<td>312</td>
					<td>328</td>
					<td>398</td>
					<td>1,038</td>
			</tr>
			<tr>
					<td>Animals</td>
					<td>96</td>
					<td>83</td>
					<td>71</td>
					<td>250</td>
			</tr>
			<tr>
					<td>Armed Forces and National Security</td>
					<td>1,108</td>
					<td>1,337</td>
					<td>1,399</td>
					<td>3,844</td>
			</tr>
			<tr>
					<td>Arts, Culture, Religion</td>
					<td>81</td>
					<td>79</td>
					<td>103</td>
					<td>263</td>
			</tr>
			<tr>
					<td>Civil Rights and Liberties, Minority Issues</td>
					<td>175</td>
					<td>205</td>
					<td>220</td>
					<td>600</td>
			</tr>
			<tr>
					<td>Commerce</td>
					<td>312</td>
					<td>593</td>
					<td>633</td>
					<td>1,538</td>
			</tr>
			<tr>
					<td>Congress</td>
					<td>594</td>
					<td>541</td>
					<td>640</td>
					<td>1,775</td>
			</tr>
			<tr>
					<td>Crime and Law Enforcement</td>
					<td>827</td>
					<td>904</td>
					<td>1,022</td>
					<td>2,753</td>
			</tr>
			<tr>
					<td>Economics and Public Finance</td>
					<td>176</td>
					<td>210</td>
					<td>197</td>
					<td>583</td>
			</tr>
			<tr>
					<td>Education</td>
					<td>607</td>
					<td>798</td>
					<td>801</td>
					<td>2,206</td>
			</tr>
			<tr>
					<td>Emergency Management</td>
					<td>207</td>
					<td>198</td>
					<td>202</td>
					<td>607</td>
			</tr>
			<tr>
					<td>Energy</td>
					<td>316</td>
					<td>370</td>
					<td>530</td>
					<td>1,216</td>
			</tr>
			<tr>
					<td>Environmental Protection</td>
					<td>352</td>
					<td>423</td>
					<td>464</td>
					<td>1,239</td>
			</tr>
			<tr>
					<td>Families</td>
					<td>79</td>
					<td>127</td>
					<td>139</td>
					<td>345</td>
			</tr>
			<tr>
					<td>Finance and Financial Sector</td>
					<td>556</td>
					<td>611</td>
					<td>601</td>
					<td>1,768</td>
			</tr>
			<tr>
					<td>Foreign Trade and International Finance</td>
					<td>120</td>
					<td>148</td>
					<td>212</td>
					<td>480</td>
			</tr>
			<tr>
					<td>Government Operations and Politics</td>
					<td>1,008</td>
					<td>1,258</td>
					<td>1,272</td>
					<td>3,538</td>
			</tr>
			<tr>
					<td>Health</td>
					<td>1,526</td>
					<td>2,109</td>
					<td>2,276</td>
					<td>5,911</td>
			</tr>
			<tr>
					<td>Housing and Community Development</td>
					<td>142</td>
					<td>250</td>
					<td>231</td>
					<td>623</td>
			</tr>
			<tr>
					<td>Immigration</td>
					<td>398</td>
					<td>466</td>
					<td>591</td>
					<td>1,455</td>
			</tr>
			<tr>
					<td>International Affairs</td>
					<td>918</td>
					<td>1,178</td>
					<td>1,390</td>
					<td>3,486</td>
			</tr>
			<tr>
					<td>Labor and Employment</td>
					<td>348</td>
					<td>452</td>
					<td>552</td>
					<td>1,352</td>
			</tr>
			<tr>
					<td>Law</td>
					<td>109</td>
					<td>162</td>
					<td>175</td>
					<td>446</td>
			</tr>
			<tr>
					<td>Native Americans</td>
					<td>175</td>
					<td>234</td>
					<td>245</td>
					<td>654</td>
			</tr>
			<tr>
					<td>Public Lands and Natural Resources</td>
					<td>718</td>
					<td>648</td>
					<td>642</td>
					<td>2,008</td>
			</tr>
			<tr>
					<td>Science, Technology, Communications</td>
					<td>389</td>
					<td>551</td>
					<td>505</td>
					<td>1,445</td>
			</tr>
			<tr>
					<td>Social Sciences and History</td>
					<td>5</td>
					<td>6</td>
					<td>4</td>
					<td>15</td>
			</tr>
			<tr>
					<td>Social Welfare</td>
					<td>177</td>
					<td>229</td>
					<td>199</td>
					<td>605</td>
			</tr>
			<tr>
					<td>Sports and Recreation</td>
					<td>92</td>
					<td>93</td>
					<td>125</td>
					<td>310</td>
			</tr>
			<tr>
					<td>Taxation</td>
					<td>983</td>
					<td>1,156</td>
					<td>1,078</td>
					<td>3,217</td>
			</tr>
			<tr>
					<td>Transportation and Public Works</td>
					<td>492</td>
					<td>672</td>
					<td>742</td>
					<td>1,906</td>
			</tr>
			<tr>
					<td>Water Resources Development</td>
					<td>89</td>
					<td>111</td>
					<td>110</td>
					<td>310</td>
			</tr>
			<tr>
					<td>Private Legislation</td>
					<td>69</td>
					<td>71</td>
					<td>48</td>
					<td>188</td>
			</tr>
	</tbody>
</table>
<p>The class imbalance is severe: <code>Social Sciences and History</code> has only 15 bills across all three Congresses, while <code>Health</code> has 5,911 bills. This imbalance presents modeling challenges, as minority classes may lack sufficient representative samples.</p>
<h3 id="text-statistics">Text Statistics</h3>
<p>We analyzed token counts using spaCy to understand the computational requirements for each text field.</p>
<p>Title Token Statistics:</p>
<table>
	<thead>
			<tr>
					<th>Congress</th>
					<th>Average Tokens</th>
					<th>Min Tokens</th>
					<th>Max Tokens</th>
					<th>Total Tokens</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>115th</td>
					<td>12.3</td>
					<td>1</td>
					<td>167</td>
					<td>166,763</td>
			</tr>
			<tr>
					<td>116th</td>
					<td>11.3</td>
					<td>1</td>
					<td>226</td>
					<td>188,158</td>
			</tr>
			<tr>
					<td>117th</td>
					<td>11.5</td>
					<td>1</td>
					<td>272</td>
					<td>204,978</td>
			</tr>
			<tr>
					<td>All</td>
					<td>11.7</td>
					<td>1</td>
					<td>272</td>
					<td>559,419</td>
			</tr>
	</tbody>
</table>
<p>Summary Token Statistics:</p>
<table>
	<thead>
			<tr>
					<th>Congress</th>
					<th>Average Tokens</th>
					<th>Min Tokens</th>
					<th>Max Tokens</th>
					<th>Total Tokens</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>115th</td>
					<td>109.1</td>
					<td>2</td>
					<td>6,839</td>
					<td>1,479,212</td>
			</tr>
			<tr>
					<td>116th</td>
					<td>94.9</td>
					<td>2</td>
					<td>5,886</td>
					<td>1,574,732</td>
			</tr>
			<tr>
					<td>117th</td>
					<td>95.1</td>
					<td>2</td>
					<td>502</td>
					<td>1,695,276</td>
			</tr>
			<tr>
					<td>All</td>
					<td>99.0</td>
					<td>2</td>
					<td>6,839</td>
					<td>4,749,220</td>
			</tr>
	</tbody>
</table>
<p>Full Text Token Statistics:</p>
<table>
	<thead>
			<tr>
					<th>Congress</th>
					<th>Average Tokens</th>
					<th>Min Tokens</th>
					<th>Max Tokens</th>
					<th>Total Tokens</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>115th</td>
					<td>2,588.7</td>
					<td>91</td>
					<td>304,478</td>
					<td>35,092,075</td>
			</tr>
			<tr>
					<td>116th</td>
					<td>2,760.3</td>
					<td>70</td>
					<td>973,173</td>
					<td>45,824,498</td>
			</tr>
			<tr>
					<td>117th</td>
					<td>2,706.7</td>
					<td>71</td>
					<td>1,013,608</td>
					<td>48,224,757</td>
			</tr>
			<tr>
					<td>All</td>
					<td>-</td>
					<td>70</td>
					<td>1,013,608</td>
					<td>129,141,330</td>
			</tr>
	</tbody>
</table>
<p>These statistics reveal computational trade-offs:</p>
<ul>
<li><strong>Titles</strong> average ~12 tokens: computationally efficient but limited information.</li>
<li><strong>Summaries</strong> average ~100 tokens: good balance of information and efficiency.</li>
<li><strong>Full text</strong> averages ~2,700 tokens with 129M total tokens: detailed but computationally expensive. Processing this volume of text introduces real-world engineering challenges, such as memory constraints and a higher noise-to-signal ratio typical of long legal documents.</li>
</ul>
<p>We&rsquo;ll prototype with titles and summaries before considering full text, given the computational costs involved.</p>
<h2 id="evaluation-framework">Evaluation Framework</h2>
<h3 id="experimental-design">Experimental Design</h3>
<p>We train models on one Congress and test on others, creating a 3x3 evaluation grid. This setup evaluates both within-Congress performance (same session) and cross-Congress generalization (different sessions). We expect temporal drift between Congress sessions to impact performance.</p>
<h3 id="metrics-and-hyperparameter-tuning">Metrics and Hyperparameter Tuning</h3>
<p>We use weighted average F1 score to handle class imbalance, ensuring fair evaluation across all policy areas regardless of frequency.</p>
<p>For within-Congress evaluation, we report cross-validated scores. For cross-Congress evaluation, we test on the entire target Congress dataset.</p>
<p>Hyperparameter tuning uses Cross-Validation Grid Search with folds set to <code>min(3, n_samples)</code> to ensure all classes are represented. We apply the best parameters from training to test generalization across different Congresses.</p>
<h2 id="baseline-models">Baseline Models</h2>
<p>We evaluate three traditional machine learning approaches using TF-IDF vectorization:</p>
<h3 id="text-preprocessing">Text Preprocessing</h3>
<p>We convert text to numerical features using TF-IDF (term frequency-inverse document frequency), which weighs word importance by frequency within documents relative to the entire corpus. This creates normalized feature vectors suitable for machine learning classification.</p>
<h3 id="multinomial-naive-bayes">Multinomial Naive Bayes</h3>
<p>We start with Multinomial Naive Bayes as our simplest baseline. Despite its &ldquo;naive&rdquo; independence assumption between features, this model often performs surprisingly well for text classification tasks and serves as an important benchmark. If more complex models can&rsquo;t beat Naive Bayes, it signals potential issues with the approach or data.</p>
<p>The model&rsquo;s <code>feature_log_prob_</code> attribute reveals the most influential words for each policy area, providing interpretable insights into classification patterns.</p>
<p>You can see the code for training the Naive Bayes model below:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.feature_extraction.text <span style="color:#f92672">import</span> TfidfVectorizer
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.model_selection <span style="color:#f92672">import</span> GridSearchCV
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.pipeline <span style="color:#f92672">import</span> Pipeline
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.naive_bayes <span style="color:#f92672">import</span> MultinomialNB
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Create a pipeline with TF-IDF vectorizer and Multinomial Naive Bayes classifier</span>
</span></span><span style="display:flex;"><span>pipeline <span style="color:#f92672">=</span> Pipeline([
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;tfidf&#39;</span>, TfidfVectorizer(lowercase<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, dtype<span style="color:#f92672">=</span>np<span style="color:#f92672">.</span>float32)),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;clf&#39;</span>, MultinomialNB()),
</span></span><span style="display:flex;"><span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Define the parameters for grid search</span>
</span></span><span style="display:flex;"><span>parameters <span style="color:#f92672">=</span> {  
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__ngram_range&#39;</span>: [(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">3</span>)],
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>, <span style="color:#ae81ff">0.5</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__min_df&#39;</span>: (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">5</span>, <span style="color:#ae81ff">10</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;clf__alpha&#39;</span>: (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.01</span>, <span style="color:#ae81ff">0.001</span>),
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Perform grid search with cross-validation</span>
</span></span><span style="display:flex;"><span>grid_search <span style="color:#f92672">=</span> GridSearchCV(
</span></span><span style="display:flex;"><span>    pipeline,
</span></span><span style="display:flex;"><span>    parameters,
</span></span><span style="display:flex;"><span>    scoring<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;f1_weighted&#39;</span>,
</span></span><span style="display:flex;"><span>    n_jobs<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>    refit<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    cv<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>grid_search<span style="color:#f92672">.</span>fit(X_train, y_train)
</span></span></code></pre></div><h3 id="logistic-regression">Logistic Regression</h3>
<p>Logistic regression provides a natural step up in complexity from Naive Bayes. It uses the logistic function to convert linear combinations of features into probabilities, making it an excellent baseline for comparison with more sophisticated models while remaining interpretable.</p>
<p>You can see the code for training the Logistic Regression model below:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.feature_extraction.text <span style="color:#f92672">import</span> TfidfVectorizer
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.model_selection <span style="color:#f92672">import</span> GridSearchCV
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.pipeline <span style="color:#f92672">import</span> Pipeline
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.linear_model <span style="color:#f92672">import</span> LogisticRegression
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Create a pipeline with TF-IDF vectorizer and Logistic Regression classifier</span>
</span></span><span style="display:flex;"><span>pipeline <span style="color:#f92672">=</span> Pipeline([
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;tfidf&#39;</span>, TfidfVectorizer(lowercase<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, dtype<span style="color:#f92672">=</span>np<span style="color:#f92672">.</span>float32)),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;clf&#39;</span>, LogisticRegression(max_iter<span style="color:#f92672">=</span><span style="color:#ae81ff">1000</span>, random_state<span style="color:#f92672">=</span><span style="color:#ae81ff">42</span>, class_weight<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;balanced&#39;</span>)),
</span></span><span style="display:flex;"><span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Define the parameters for grid search</span>
</span></span><span style="display:flex;"><span>parameters <span style="color:#f92672">=</span> {  
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__ngram_range&#39;</span>: [(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)],
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;clf__C&#39;</span>: [<span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">10</span>],
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Perform grid search with cross-validation</span>
</span></span><span style="display:flex;"><span>grid_search <span style="color:#f92672">=</span> GridSearchCV(
</span></span><span style="display:flex;"><span>    pipeline,
</span></span><span style="display:flex;"><span>    parameters,
</span></span><span style="display:flex;"><span>    scoring<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;f1_weighted&#39;</span>,
</span></span><span style="display:flex;"><span>    n_jobs<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>    refit<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    cv<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>grid_search<span style="color:#f92672">.</span>fit(X_train, y_train)
</span></span></code></pre></div><h3 id="xgboost">XGBoost</h3>
<p>We include XGBoost as our tree-based ensemble method. While XGBoost typically excels on structured tabular data, we test whether its gradient boosting approach can effectively handle TF-IDF features for text classification.</p>
<p>You can see the code for training the XGBoost model below:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.feature_extraction.text <span style="color:#f92672">import</span> TfidfVectorizer
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.model_selection <span style="color:#f92672">import</span> GridSearchCV
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.pipeline <span style="color:#f92672">import</span> Pipeline
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> xgboost <span style="color:#f92672">import</span> XGBClassifier
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Create a pipeline with TF-IDF vectorizer and XGBoost classifier</span>
</span></span><span style="display:flex;"><span>pipeline <span style="color:#f92672">=</span> Pipeline([
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;tfidf&#39;</span>, TfidfVectorizer(lowercase<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, dtype<span style="color:#f92672">=</span>np<span style="color:#f92672">.</span>float32)),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;clf&#39;</span>, XGBClassifier(use_label_encoder<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>, eval_metric<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;mlogloss&#39;</span>, objective<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;multi:softmax&#39;</span>, seed<span style="color:#f92672">=</span><span style="color:#ae81ff">42</span>, n_jobs<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)),
</span></span><span style="display:flex;"><span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Define the parameters for grid search</span>
</span></span><span style="display:flex;"><span>parameters <span style="color:#f92672">=</span> {  
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;clf__max_depth&#39;</span>: (<span style="color:#ae81ff">3</span>, <span style="color:#ae81ff">6</span>, <span style="color:#ae81ff">9</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;clf__n_estimators&#39;</span>: (<span style="color:#ae81ff">100</span>, <span style="color:#ae81ff">200</span>, <span style="color:#ae81ff">300</span>),
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Perform grid search with cross-validation</span>
</span></span><span style="display:flex;"><span>grid_search <span style="color:#f92672">=</span> GridSearchCV(
</span></span><span style="display:flex;"><span>    pipeline,
</span></span><span style="display:flex;"><span>    parameters,
</span></span><span style="display:flex;"><span>    scoring<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;f1_weighted&#39;</span>,
</span></span><span style="display:flex;"><span>    refit<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    cv<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>    verbose<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>grid_search<span style="color:#f92672">.</span>fit(X_train, y_train, clf__sample_weight<span style="color:#f92672">=</span>sample_weight)
</span></span></code></pre></div><h2 id="results">Results</h2>
<p>We evaluate models on three input types:</p>
<ul>
<li><strong>Title-only</strong>: Quick prototyping with limited context</li>
<li><strong>Summary-only</strong>: Balanced information content and computational efficiency</li>
<li><strong>Full text</strong>: Maximum context with computational constraints (limited hyperparameter tuning)</li>
</ul>
<h3 id="title-only-inputs">Title-Only Inputs</h3>
<h4 id="naive-bayes">Naive Bayes</h4>
<p>Title-only Naive Bayes experiments are run with the following settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>sweep_nb(
</span></span><span style="display:flex;"><span>    data,
</span></span><span style="display:flex;"><span>    X_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;title&#39;</span>,
</span></span><span style="display:flex;"><span>    y_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;policy_area&#39;</span>,
</span></span><span style="display:flex;"><span>    tfidf_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;lowercase&#39;</span>: <span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;dtype&#39;</span>: np<span style="color:#f92672">.</span>float32,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    tfidf_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;ngram_range&#39;</span>: [(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)],
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>, <span style="color:#ae81ff">0.5</span>),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;min_df&#39;</span>: (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">5</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    nb_params<span style="color:#f92672">=</span>{},
</span></span><span style="display:flex;"><span>    nb_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;alpha&#39;</span>: (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.01</span>, <span style="color:#ae81ff">0.001</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>and the results:</p>
<pre><code>Training on Congress 115
Best score: 0.661
Refit Time: 0.570
Best parameters set:
	clf__alpha: 0.01
	tfidf__max_df: 0.05
	tfidf__min_df: 1
	tfidf__ngram_range: (1, 2)
Testing on Congress 116 F1: 0.6369760774921475
Testing on Congress 117 F1: 0.5488274400521962

Training on Congress 116
Best score: 0.677
Refit Time: 0.499
Best parameters set:
	clf__alpha: 0.01
	tfidf__max_df: 0.05
	tfidf__min_df: 1
	tfidf__ngram_range: (1, 2)
Testing on Congress 115 F1: 0.691175262953872
Testing on Congress 117 F1: 0.6798043069585031

Training on Congress 117
Best score: 0.670
Refit Time: 0.565
Best parameters set:
	clf__alpha: 0.01
	tfidf__max_df: 0.25
	tfidf__min_df: 1
	tfidf__ngram_range: (1, 2)
Testing on Congress 115 F1: 0.6168474701996426
Testing on Congress 116 F1: 0.6981574942116808

Mean fit time: 0.54 ± 0.03s
</code></pre>
<h4 id="results-summary">Results Summary</h4>
<p>The results demonstrate several key findings:</p>
<ul>
<li><strong>Fast training</strong>: Sub-second training times make this highly practical</li>
<li><strong>Solid baseline performance</strong>: F1 scores around 0.65-0.70 provide a reasonable starting point</li>
<li><strong>Consistent hyperparameters</strong>: Similar optimal settings across Congresses suggest stable patterns</li>
<li><strong>Temporal effects</strong>: Performance generally decreases when training and testing on Congresses further apart in time</li>
</ul>
<p>Training on the 116th Congress yields the best cross-Congress performance, likely due to its temporal proximity to both adjacent sessions.</p>















<figure class="post-figure center ">
    <img src="/img/nb_title_policy_area/f1s.webp"
         alt="Naive Bayes Policy Area Classification F1 Score"
         title="Naive Bayes Policy Area Classification F1 Score"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Naive Bayes F1 scores show temporal effects, with better performance between adjacent Congresses</figcaption>
    
</figure>

<p>The model learns interpretable features for each policy area. For example, Agriculture bills are strongly associated with terms like &ldquo;farm,&rdquo; &ldquo;crop,&rdquo; and &ldquo;livestock,&rdquo; while Armed Forces bills correlate with &ldquo;military,&rdquo; &ldquo;defense,&rdquo; and &ldquo;veterans.&rdquo;</p>















<figure class="post-figure center ">
    <img src="/img/nb_title_policy_area/top-Agriculture_and_Food.webp"
         alt="Naive Bayes Top Features for Agriculture and Food"
         title="Naive Bayes Top Features for Agriculture and Food"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Naive Bayes Top Features for Agriculture and Food</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/nb_title_policy_area/top-Armed_Forces_and_National_Security.webp"
         alt="Naive Bayes Top Features for Armed Forces and National Security"
         title="Naive Bayes Top Features for Armed Forces and National Security"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Naive Bayes Top Features for Armed Forces and National Security</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/nb_title_policy_area/top-Health.webp"
         alt="Naive Bayes Top Features for Health"
         title="Naive Bayes Top Features for Health"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Naive Bayes Top Features for Health</figcaption>
    
</figure>

<h4 id="logistic-regression-1">Logistic Regression</h4>
<p>Title-only Logistic Regression experiments are run with the following settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>sweep_logreg(
</span></span><span style="display:flex;"><span>    data,
</span></span><span style="display:flex;"><span>    X_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;title&#39;</span>,
</span></span><span style="display:flex;"><span>    y_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;policy_area&#39;</span>,
</span></span><span style="display:flex;"><span>    tfidf_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;lowercase&#39;</span>: <span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;dtype&#39;</span>: np<span style="color:#f92672">.</span>float32,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    tfidf_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;ngram_range&#39;</span>: [(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)],
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    logreg_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_iter&#39;</span>: <span style="color:#ae81ff">1000</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;random_state&#39;</span>: <span style="color:#ae81ff">42</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;class_weight&#39;</span>: <span style="color:#e6db74">&#39;balanced&#39;</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    logreg_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;C&#39;</span>: [<span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">10</span>],
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>and the results:</p>
<pre><code>Training on Congress 115
Best score: 0.704
Refit Time: 32.063
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.05
	tfidf__ngram_range: (1, 2)
Testing on Congress 116 F1: 0.6809188275881766
Testing on Congress 117 F1: 0.601917336933838

Training on Congress 116
Best score: 0.714
Refit Time: 31.227
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.05
	tfidf__ngram_range: (1, 2)
Testing on Congress 115 F1: 0.7408989977276476
Testing on Congress 117 F1: 0.7200639105208106

Training on Congress 117
Best score: 0.711
Refit Time: 34.083
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.05
	tfidf__ngram_range: (1, 2)
Testing on Congress 115 F1: 0.674418393892329
Testing on Congress 116 F1: 0.7405934743144291

Mean fit time: 32.46 ± 1.20s
</code></pre>
<h4 id="results-summary-1">Results Summary</h4>
<p>Logistic regression improves upon Naive Bayes performance:</p>
<ul>
<li><strong>Higher F1 scores</strong>: Generally 5-7 percentage points better than Naive Bayes</li>
<li><strong>Consistent hyperparameters</strong>: Optimal settings remain stable across Congresses</li>
<li><strong>Reasonable training time</strong>: 30-35 seconds per model remains manageable</li>
<li><strong>Strong cross-Congress generalization</strong>: F1 scores consistently above 0.70</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/logreg_title_policy_area/f1s.webp"
         alt="Logistic Regression Policy Area Classification F1 Score"
         title="Logistic Regression Policy Area Classification F1 Score"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Logistic Regression Policy Area Classification F1 Score</figcaption>
    
</figure>

<h4 id="xgboost-1">XGBoost</h4>
<p>Title-only XGBoost experiments are run with the following settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>sweep_xgb(
</span></span><span style="display:flex;"><span>    data,
</span></span><span style="display:flex;"><span>    X_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;title&#39;</span>,
</span></span><span style="display:flex;"><span>    y_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;policy_area&#39;</span>,
</span></span><span style="display:flex;"><span>    tfidf_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>,),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    xgb_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_depth&#39;</span>: (<span style="color:#ae81ff">6</span>,),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;eta&#39;</span>: (<span style="color:#ae81ff">0.3</span>,),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>and the results:</p>
<pre><code>Training on Congress 115
Best score: 0.591
Refit Time: 198.063
Best parameters set:
	clf__eta: 0.3
	clf__max_depth: 6
	clf__num_class: 33
	tfidf__max_df: 0.05
Testing on Congress 116 F1: 0.5649530686141018
Testing on Congress 117 F1: 0.5215939580735101

Training on Congress 116
Best score: 0.600
Refit Time: 264.824
Best parameters set:
	clf__eta: 0.3
	clf__max_depth: 6
	clf__num_class: 33
	tfidf__max_df: 0.05
Testing on Congress 115 F1: 0.6037922738570368
Testing on Congress 117 F1: 0.5965027418245722

Training on Congress 117
Best score: 0.595
Refit Time: 249.799
Best parameters set:
	clf__eta: 0.3
	clf__max_depth: 6
	clf__num_class: 33
	tfidf__max_df: 0.05
Testing on Congress 115 F1: 0.5600491477899472
Testing on Congress 116 F1: 0.60815381664894

Mean fit time: 237.56 ± 28.60s
</code></pre>
<h4 id="results-summary-2">Results Summary</h4>
<p>XGBoost underperforms relative to expectations:</p>
<ul>
<li><strong>Poor performance</strong>: F1 scores significantly below linear models (0.55-0.60 range)</li>
<li><strong>Long training times</strong>: 4+ minutes per model with limited hyperparameter exploration</li>
<li><strong>Questionable value</strong>: The computational cost doesn&rsquo;t justify the poor performance</li>
</ul>
<p>Given these results, we focus on the more promising linear models for subsequent experiments with longer text inputs.</p>















<figure class="post-figure center ">
    <img src="/img/xgb_title_policy_area/f1s.webp"
         alt="XGBoost Policy Area Classification F1 Score"
         title="XGBoost Policy Area Classification F1 Score"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">XGBoost Policy Area Classification F1 Score</figcaption>
    
</figure>

<h4 id="training-efficiency">Training Efficiency</h4>
<p>The computational costs vary dramatically:</p>
<table>
	<thead>
			<tr>
					<th>Model</th>
					<th>Training Time</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Naive Bayes</td>
					<td>0.54 $\pm$ 0.03s</td>
			</tr>
			<tr>
					<td>Logistic Regression</td>
					<td>32.46 $\pm$ 1.20s</td>
			</tr>
			<tr>
					<td>XGBoost</td>
					<td>237.56 $\pm$ 28.60s</td>
			</tr>
	</tbody>
</table>
<p>XGBoost&rsquo;s poor performance despite high computational cost suggests that tree-based methods may not be well-suited for sparse TF-IDF features. This is a classic example of the &ldquo;curse of dimensionality&rdquo;: tree-based models struggle to make effective splits in highly sparse, high-dimensional bag-of-words spaces compared to linear models that simply assign weights to all features simultaneously. We&rsquo;ll focus on linear models for the remaining experiments.</p>
<h3 id="summary-only-results">Summary-Only Results</h3>
<p>Using bill summaries provides substantially more context than titles alone, leading to significant performance improvements.</p>
<h4 id="naive-bayes-performance">Naive Bayes Performance</h4>
<p>The summary-based models show dramatic improvement over title-only versions:</p>
<ul>
<li><strong>F1 scores</strong>: 0.85+ within-Congress, 0.77-0.86 cross-Congress</li>
<li><strong>Training time</strong>: Still fast at ~3.4 seconds</li>
<li><strong>Strong generalization</strong>: Consistent performance across time periods</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/nb_summary_policy_area/f1s.webp"
         alt="Naive Bayes Summary Performance"
         title="Naive Bayes Summary Performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Summary-based models achieve 80%+ F1 scores across most Congress combinations</figcaption>
    
</figure>

<h4 id="logistic-regression-performance">Logistic Regression Performance</h4>
<p>Logistic regression slightly outperforms Naive Bayes on summaries:</p>
<ul>
<li><strong>F1 scores</strong>: 0.86+ within-Congress, 0.79-0.87 cross-Congress</li>
<li><strong>Training time</strong>: Reasonable at ~12 seconds</li>
<li><strong>Stable hyperparameters</strong>: Consistent optimal settings across Congresses</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/logreg_summary_policy_area/f1s.webp"
         alt="Logistic Regression Summary Performance"
         title="Logistic Regression Summary Performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Logistic regression maintains slight performance advantage over Naive Bayes</figcaption>
    
</figure>

<p>The performance difference between models suggests they rely on similar feature patterns, with logistic regression better capturing feature interactions.</p>
<h4 id="logistic-regression-2">Logistic Regression</h4>
<p>Summary-only Logistic Regression experiments are run with the following settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>sweep_logreg(
</span></span><span style="display:flex;"><span>    data,
</span></span><span style="display:flex;"><span>    X_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;summary&#39;</span>,
</span></span><span style="display:flex;"><span>    y_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;policy_area&#39;</span>,
</span></span><span style="display:flex;"><span>    tfidf_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;lowercase&#39;</span>: <span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;dtype&#39;</span>: np<span style="color:#f92672">.</span>float32,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    tfidf_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># &#39;ngram_range&#39;: [(1, 1), (1, 2)],</span>
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    logreg_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_iter&#39;</span>: <span style="color:#ae81ff">1000</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;random_state&#39;</span>: <span style="color:#ae81ff">42</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;class_weight&#39;</span>: <span style="color:#e6db74">&#39;balanced&#39;</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    logreg_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;C&#39;</span>: [<span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">10</span>],
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>And the results:</p>
<pre><code>Training on Congress 115
Best score: 0.862
Refit Time: 9.007
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.25
Testing on Congress 116 F1: 0.8284864693401133
Testing on Congress 117 F1: 0.7934161507811646

Training on Congress 116
Best score: 0.865
Refit Time: 13.897
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.25
Testing on Congress 115 F1: 0.8637852557418315
Testing on Congress 117 F1: 0.8594775615031977

Training on Congress 117
Best score: 0.862
Refit Time: 12.167
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.25
Testing on Congress 115 F1: 0.8355736563084967
Testing on Congress 116 F1: 0.8696403838390832

Mean fit time: 11.69 ± 2.02s
</code></pre>















<figure class="post-figure center ">
    <img src="/img/logreg_summary_policy_area/f1s.webp"
         alt="Logistic Regression Policy Area Classification F1 Score"
         title="Logistic Regression Policy Area Classification F1 Score"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Logistic Regression Policy Area Classification F1 Score</figcaption>
    
</figure>

<h3 id="full-text-results">Full Text Results</h3>
<p>We test whether complete bill text improves performance over summaries, using optimal hyperparameters from summary experiments.</p>
<h4 id="naive-bayes-on-full-text">Naive Bayes on Full Text</h4>
<p>Surprisingly, full text yields slightly lower performance than summaries:</p>
<ul>
<li><strong>F1 scores</strong>: 0.84-0.85 within-Congress, 0.77-0.86 cross-Congress</li>
<li><strong>Training time</strong>: ~50 seconds (10x slower than summaries)</li>
<li><strong>Performance drop</strong>: Likely due to increased noise in lengthy documents</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/nb_text_policy_area/f1s.webp"
         alt="Naive Bayes Full Text Performance"
         title="Naive Bayes Full Text Performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Full text performance is slightly worse than summaries, suggesting diminishing returns</figcaption>
    
</figure>

<h4 id="logistic-regression-on-full-text">Logistic Regression on Full Text</h4>
<p>Logistic regression shows the strongest performance on full text:</p>
<ul>
<li><strong>F1 scores</strong>: 0.87-0.88 within-Congress, 0.83-0.89 cross-Congress</li>
<li><strong>Training time</strong>: ~70 seconds</li>
<li><strong>Best overall performance</strong>: up to 0.89 F1 on the strongest single cross-Congress pair (best within-Congress score 0.877)</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/logreg_text_policy_area/f1s.webp"
         alt="Logistic Regression Full Text Performance"
         title="Logistic Regression Full Text Performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Logistic regression achieves the best performance using full bill text</figcaption>
    
</figure>

<p>The logistic regression model benefits from having access to complete legislative language while effectively regularizing against noise.</p>
<h2 id="key-findings">Key Findings</h2>
<p>This baseline study establishes several important results:</p>
<p><strong>Best performing model</strong>: Logistic regression trained on full bill text reaches up to 0.89 F1 on the strongest single cross-Congress pair (best within-Congress score 0.877), providing a strong benchmark for future deep learning approaches.</p>
<p><strong>Text input comparison</strong>:</p>
<ul>
<li>Titles: Limited but fast (F1 ~0.65-0.70)</li>
<li>Summaries: Good balance of performance and efficiency (F1 ~0.85)</li>
<li>Full text: Best performance but computationally expensive (certified weighted-F1 0.871-0.877; up to ~0.89 on the strongest single cross-Congress pair)</li>
</ul>
<p><strong>Cross-Congress generalization</strong>: Models trained on one Congress generalize reasonably well to others, though performance decreases with temporal distance between sessions.</p>
<p><strong>Model performance ranking</strong>: Logistic Regression &gt; Naive Bayes &raquo; XGBoost for this text classification task.</p>
<h2 id="next-steps">Next Steps</h2>
<p>The strong baseline performance sets the stage for several research directions:</p>
<ol>
<li><strong>Deep learning models</strong>: Transformer-based approaches using pre-trained language models</li>
<li><strong>Dataset expansion</strong>: Including additional Congresses and more detailed bill metadata</li>
<li><strong>Error analysis</strong>: Understanding failure cases and class-specific performance patterns</li>
<li><strong>Feature engineering</strong>: Exploring domain-specific text preprocessing and feature extraction</li>
</ol>
<p>The complete dataset and experimental code are available for researchers interested in building upon these baselines.</p>
<p><strong>Resources</strong>:</p>
<ul>
<li>Dataset: <a href="https://huggingface.co/datasets/hheiden/us-congress-bill-policy-115_117">Hugging Face: hheiden/us-congress-bill-policy-115_117</a></li>
<li>Leaderboard: <a href="/leaderboards/policy_area_classification_leaderboard/">Policy Area Classification Leaderboard</a></li>
<li>Project: <a href="/projects/congressional-data-analysis/">Congressional Knowledge Graph &amp; Policy Classification</a></li>
</ul>
]]></content:encoded></item><item><title>Coulomb Matrices for Molecular Machine Learning</title><link>https://hunterheidenreich.com/posts/molecular-descriptor-coulomb-matrix/</link><pubDate>Sat, 10 Feb 2024 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/molecular-descriptor-coulomb-matrix/</guid><description>Learn how Coulomb matrices encode 3D molecular structure for machine learning from basic theory to Python implementation and practical limitations.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>When working with machine learning in chemistry, one of the first challenges you encounter is how to represent molecules in a way that algorithms can understand. You can&rsquo;t just feed raw atomic coordinates into a model. The representation needs to be invariant to rotation, translation, and atom ordering, since these operations don&rsquo;t change the molecule&rsquo;s fundamental properties.</p>
<p>The Coulomb matrix, introduced by Rupp et al. in 2012 <a href="#ref-1">[1]</a>, provides a straightforward solution to this problem. While newer methods have largely superseded it for practical applications, the Coulomb matrix remains an excellent starting point for understanding how molecular descriptors work.</p>
<p>The key insight is simple: we encode pairwise relationships between atoms in a way that captures the essential physics while maintaining the required invariances.</p>
<h2 id="the-coulomb-matrix-theory-and-intuition">The Coulomb Matrix: Theory and Intuition</h2>
<p>The Coulomb matrix encodes molecular structure in a symmetric $N \times N$ matrix, where $N$ is the number of atoms. Each element $C_{ij}$ is defined as:</p>
<p>$$
C_{ij} = \begin{cases} 0.5 Z_i^{2.4} &amp; \text{if } i = j, \\ \frac{Z_i Z_j}{|\mathbf{R}_i - \mathbf{R}_j|} &amp; \text{if } i \neq j, \end{cases}
$$</p>
<p>Here, $Z_i$ is the atomic number of atom $i$, and $\mathbf{R}_i$ is its position in 3D space. The diagonal elements ($0.5 Z_i^{2.4}$) represent atomic self-energies, derived from fitting atomic numbers to experimental data. The off-diagonal elements mimic Coulombic interactions between atoms. They&rsquo;re inversely proportional to distance, just like electrostatic potential energy <a href="#ref-3">[3]</a>.</p>
<p>This construction gives us several useful properties:</p>
<ul>
<li><strong>Rotation and translation invariant</strong>: Only relative distances matter</li>
<li><strong>Symmetric</strong>: $C_{ij} = C_{ji}$, which is physically sensible</li>
<li><strong>Size-extensive</strong>: Larger molecules have larger matrix elements</li>
<li><strong>Captures 3D structure</strong>: Nearby atoms have larger interaction terms</li>
</ul>
<p>While more sophisticated methods exist today <a href="#ref-2">[2]</a>, the Coulomb matrix&rsquo;s simplicity makes it ideal for understanding the fundamentals of molecular representation.</p>
<h3 id="hands-on-example-bicyclobutane">Hands-on Example: Bicyclobutane</h3>
<p>Let&rsquo;s calculate the Coulomb matrix for <a href="https://en.wikipedia.org/wiki/Bicyclobutane">bicyclobutane</a>, a strained but stable bicyclic system (bicyclo[1.1.0]butane, C4H6, two cis-fused cyclopropane rings). This example will show you exactly how the theory translates to practice.</p>















<figure class="post-figure center ">
    <img src="https://upload.wikimedia.org/wikipedia/commons/b/b4/Bicyclobutane-2.svg"
         alt="Bicyclobutane"
         title="Bicyclobutane"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Bicyclobutane structure (Smokefoot, Public domain, via Wikimedia Commons)</figcaption>
    
</figure>

<p>I&rsquo;ll use Python with the Atomic Simulation Environment (<code>ase</code>) for molecular structure <a href="#ref-4">[4]</a> and <code>dscribe</code> for the Coulomb matrix calculation <a href="#ref-2">[2]</a>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> ase.build <span style="color:#f92672">import</span> molecule
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> ase.visualize <span style="color:#f92672">import</span> view
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Load the bicyclobutane structure</span>
</span></span><span style="display:flex;"><span>bicyclobutane <span style="color:#f92672">=</span> molecule(<span style="color:#e6db74">&#39;bicyclobutane&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Optional: visualize the structure</span>
</span></span><span style="display:flex;"><span>view(bicyclobutane, viewer<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;x3d&#39;</span>)
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/bicyclobutane_ase_1.webp"
         alt="Bicyclobutane 3D structure"
         title="Bicyclobutane 3D structure"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">3D structure of bicyclobutane</figcaption>
    
</figure>

<p>Now we calculate the Coulomb matrix using DScribe:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> dscribe.descriptors <span style="color:#f92672">import</span> CoulombMatrix
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Set up the descriptor</span>
</span></span><span style="display:flex;"><span>cm <span style="color:#f92672">=</span> CoulombMatrix(n_atoms_max<span style="color:#f92672">=</span>len(bicyclobutane))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Calculate and reshape into matrix form</span>
</span></span><span style="display:flex;"><span>cm_bicyclobutane <span style="color:#f92672">=</span> cm<span style="color:#f92672">.</span>create(bicyclobutane)
</span></span><span style="display:flex;"><span>cm_bicyclobutane <span style="color:#f92672">=</span> cm_bicyclobutane<span style="color:#f92672">.</span>reshape(len(bicyclobutane), len(bicyclobutane))
</span></span></code></pre></div><h3 id="visualizing-the-results">Visualizing the Results</h3>
<p>The Coulomb matrix can be visualized as a heatmap. Let&rsquo;s look at both the raw matrix and its logarithm:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> matplotlib.pyplot <span style="color:#66d9ef">as</span> plt
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> numpy <span style="color:#66d9ef">as</span> np
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Raw Coulomb matrix</span>
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>figure(figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">8</span>, <span style="color:#ae81ff">8</span>), dpi<span style="color:#f92672">=</span><span style="color:#ae81ff">150</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>imshow(cm_bicyclobutane, cmap<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;coolwarm&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>colorbar(label<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;Magnitude&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>title(<span style="color:#e6db74">&#39;Coulomb Matrix for Bicyclobutane&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>show()
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/cm_bicyclobutane.webp"
         alt="Coulomb matrix of bicyclobutane"
         title="Coulomb matrix of bicyclobutane"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Coulomb matrix for bicyclobutane</figcaption>
    
</figure>

<p>The raw matrix shows clear patterns:</p>
<ul>
<li><strong>Large diagonal elements</strong>: Carbon atoms (Z=6) dominate due to their higher atomic numbers</li>
<li><strong>Smaller off-diagonal elements</strong>: Represent pairwise interactions</li>
<li><strong>Minimal hydrogen contribution</strong>: Hydrogen atoms (Z=1) have much smaller values</li>
</ul>
<p>For better visualization of the structure, the logarithm reveals more detail:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>figure(figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">8</span>, <span style="color:#ae81ff">8</span>), dpi<span style="color:#f92672">=</span><span style="color:#ae81ff">150</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>imshow(np<span style="color:#f92672">.</span>log(cm_bicyclobutane), cmap<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;coolwarm&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>colorbar(label<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;log(Magnitude)&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>title(<span style="color:#e6db74">&#39;Log Coulomb Matrix for Bicyclobutane&#39;</span>)
</span></span><span style="display:flex;"><span>plt<span style="color:#f92672">.</span>show()
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/cm_bicyclobutane_log.webp"
         alt="Log Coulomb matrix of bicyclobutane"
         title="Log Coulomb matrix of bicyclobutane"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Log-scale reveals more structural detail</figcaption>
    
</figure>

<h3 id="eigenvalue-analysis">Eigenvalue Analysis</h3>
<p>The eigenvalues of the Coulomb matrix provide another perspective on molecular structure:</p>















<figure class="post-figure center ">
    <img src="/img/cm_bicyclobutane_eigenvalues.webp"
         alt="Eigenvalues of Coulomb matrix"
         title="Eigenvalues of Coulomb matrix"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Eigenvalues of the Coulomb matrix</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/cm_bicyclobutane_log_eigenvalues.webp"
         alt="Eigenvalues of log Coulomb matrix"
         title="Eigenvalues of log Coulomb matrix"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Eigenvalues on logarithmic scale</figcaption>
    
</figure>

<p>These eigenvalues are often used as features themselves, providing a more compact representation than the full matrix.</p>
<h2 id="practical-limitations">Practical Limitations</h2>
<p>The Coulomb matrix has significant limitations that explain why it&rsquo;s been largely superseded by modern methods. Understanding these constraints is crucial for knowing when and how to use this descriptor.</p>
<h3 id="the-size-problem">The Size Problem</h3>
<p>Every molecule must be represented by the same size matrix, which creates several issues:</p>
<ul>
<li><strong>Padding overhead</strong>: Small molecules get padded with zeros up to the maximum size</li>
<li><strong>Quadratic scaling</strong>: An $N$-atom molecule requires $N^2$ features</li>
<li><strong>Fixed maximum size</strong>: You can&rsquo;t represent molecules larger than your preset limit</li>
<li><strong>Inefficient storage</strong>: Most elements are zero for small molecules in large matrices</li>
</ul>
<p>For a dataset ranging from 5-atom to 50-atom molecules, every molecule needs a 50x50 matrix. That&rsquo;s 2,500 features, most of which are zero for smaller molecules.</p>
<h3 id="permutation-sensitivity">Permutation Sensitivity</h3>
<p>Despite being called &ldquo;invariant,&rdquo; the Coulomb matrix can actually change if you reorder the atoms in your input file. The standard solution is to sort atoms by the L2 norm of their matrix rows, but this introduces its own problems:</p>
<ul>
<li><strong>Symmetry breaking</strong>: Equivalent atoms might be ordered differently</li>
<li><strong>Numerical instability</strong>: Small coordinate changes can flip the ordering</li>
<li><strong>Loss of chemical intuition</strong>: The sorted order doesn&rsquo;t reflect meaningful chemistry</li>
</ul>
<p>Interestingly, some studies suggest that adding controlled noise to create multiple permutations can actually improve machine learning performance <a href="#ref-5">[5]</a>.</p>
<h3 id="limited-scope">Limited Scope</h3>
<p>The Coulomb matrix works well only for specific types of systems:</p>
<ul>
<li><strong>Small molecules</strong>: Performance degrades for large systems due to size scaling</li>
<li><strong>Gas-phase</strong>: Not suitable for periodic systems like crystals or surfaces</li>
<li><strong>Single conformations</strong>: Each 3D structure gets its own matrix</li>
<li><strong>Non-reactive</strong>: Doesn&rsquo;t capture bond-breaking or formation</li>
</ul>
<p>For periodic systems, you&rsquo;d need specialized variants like the Ewald sum matrix <a href="#ref-6">[6]</a>.</p>
<h2 id="why-learn-it-anyway">Why Learn It Anyway?</h2>
<p>Given these limitations, why spend time understanding the Coulomb matrix? Several reasons:</p>
<p><strong>Educational value</strong>: It&rsquo;s conceptually straightforward and provides excellent intuition for how molecular descriptors work. The mathematical formulation is simple enough to implement from scratch.</p>
<p><strong>Historical importance</strong>: Many subsequent methods build on ideas first explored with Coulomb matrices. Understanding this foundation helps you appreciate why newer methods were developed.</p>
<p><strong>Benchmarking</strong>: It remains useful as a baseline method for comparing new descriptors on small molecular datasets.</p>
<p><strong>Proof of concept</strong>: For exploratory work on small, well-defined datasets, the Coulomb matrix can still provide quick insights.</p>
<p>If you&rsquo;re working on practical problems with larger datasets or diverse molecular sizes, consider modern alternatives like graph neural networks, descriptors from DScribe&rsquo;s extended library, or learned representations from transformer models.</p>
<h2 id="putting-it-in-context">Putting It in Context</h2>
<p>To see the Coulomb matrix applied to real problems, I&rsquo;ve written a detailed guide using it for molecular classification:</p>
<ul>
<li><a href="/posts/alkane-constitutional-isomer-classification/">Coulomb Matrix Eigenvalues: Can You Hear the Shape of a Molecule?</a>: A comprehensive analysis of alkane isomers, from unsupervised clustering limits to supervised classification successes.</li>
</ul>
<p>For comparison with modern approaches, check out my post on <a href="/posts/geom-conformer-generation-dataset/">3D conformer generation with the GEOM dataset</a>, which showcases more sophisticated molecular representations. For technical specifications and benchmarks, see the <a href="/notes/chemistry/datasets/geom/">GEOM dataset card</a>.</p>
<p>The Coulomb matrix may be dated, but it remains an excellent entry point into the world of molecular machine learning. Once you understand its strengths and limitations, you&rsquo;ll be better equipped to appreciate why the field has moved toward more sophisticated approaches.</p>
<hr>
<p><em>Have questions about molecular descriptors or want to discuss other approaches to molecular machine learning? I&rsquo;d be happy to explore these topics further.</em></p>
<h2 id="references">References</h2>
<ul>
<li><a id="ref-1"></a>[1]: M. Rupp, A. Tkatchenko, K.-R. Müller, and O. A. von Lilienfeld, &ldquo;Fast and Accurate Modeling of Molecular Atomization Energies with Machine Learning,&rdquo; Physical Review Letters, 108(5), 058301 (2012). <a href="https://doi.org/10.1103/PhysRevLett.108.058301">https://doi.org/10.1103/PhysRevLett.108.058301</a> <a href="https://arxiv.org/abs/1109.2618">arXiv:1109.2618</a></li>
<li><a id="ref-2"></a>[2] L. Himanen, M. O. J. Jäger, E. V. Morooka, F. F. Canova, Y. S. Ranawat, D. Z. Gao, P. Rinke, and A. S. Foster, &ldquo;DScribe: Library of descriptors for machine learning in materials science,&rdquo; Computer Physics Communications, 247, 106949 (2020). <a href="https://doi.org/10.1016/j.cpc.2019.106949">https://doi.org/10.1016/j.cpc.2019.106949</a> <a href="https://arxiv.org/abs/1904.08875">arXiv:1904.08875</a></li>
<li><a id="ref-3"></a>[3] J. Schrier, &ldquo;Can one hear the shape of a molecule (from its Coulomb matrix eigenvalues)?,&rdquo; Journal of Chemical Information and Modeling, 60(8), 3804-3811 (2020). <a href="https://doi.org/10.1021/acs.jcim.0c00631">https://doi.org/10.1021/acs.jcim.0c00631</a></li>
<li><a id="ref-4"></a>[4] A. H. Larsen, J. J. Mortensen, J. Blomqvist, I. E. Castelli, R. Christensen, M. Dułak, J. Friis, M. N. Groves, B. Hammer, C. Hargus, E. D. Hermes, P. C. Jennings, P. B. Jensen, J. Kermode, J. R. Kitchin, E. L. Kolsbjerg, J. Kubal, K. Kaasbjerg, S. Lysgaard, J. B. Maronsson, T. Maxson, T. Olsen, L. Pastewka, A. Peterson, C. Rostgaard, J. Schiøtz, O. Schütt, M. Strange, K. S. Thygesen, T. Vegge, L. Vilhelmsen, M. Walter, Z. Zeng, and K. W. Jacobsen, &ldquo;The Atomic Simulation Environment - A Python library for working with atoms,&rdquo; J. Phys.: Condens. Matter, 29, 273002 (2017). <a href="https://doi.org/10.1088/1361-648X/aa680e">https://doi.org/10.1088/1361-648X/aa680e</a> <a href="https://ase-lib.org/index.html">documentation</a></li>
<li><a id="ref-5"></a>[5] G. Montavon, K. Hansen, S. Fazli, M. Rupp, F. Biegler, A. Ziehe, A. Tkatchenko, A. Lilienfeld, and K.-R. Müller, &ldquo;Learning invariant representations of molecules for atomization energy prediction,&rdquo; Advances in Neural Information Processing Systems, 25 (2012). Available online: <a href="https://proceedings.neurips.cc/paper_files/paper/2012/file/115f89503138416a242f40fb7d7f338e-Paper.pdf">https://proceedings.neurips.cc/paper_files/paper/2012/file/115f89503138416a242f40fb7d7f338e-Paper.pdf</a></li>
<li><a id="ref-6"></a>[6] F. Faber, A. Lindmaa, O. A. von Lilienfeld, and R. Armiento, &ldquo;Crystal structure representations for machine learning models of formation energies,&rdquo; International Journal of Quantum Chemistry, 115(16), 1094-1101 (2015). <a href="https://doi.org/10.1002/qua.24917">https://doi.org/10.1002/qua.24917</a></li>
</ul>
]]></content:encoded></item><item><title>How Does Congress Actually Work? Data from 15K Bills</title><link>https://hunterheidenreich.com/posts/us-117th-congress-data-exploration/</link><pubDate>Thu, 05 Oct 2023 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/us-117th-congress-data-exploration/</guid><description>What happens to bills in Congress? Analyzing 15K+ bills from the 117th Congress to understand legislative patterns, party dynamics, and success rates.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Analyzing congressional data reveals the underlying mechanics of the legislative process. Legislative text is a large, structured corpus well suited to text classification and other NLP tasks. I scraped data from Congress.gov to analyze what actually happens to the thousands of bills introduced each session and to build a foundational dataset for downstream machine learning tasks.</p>
<p>This analysis focuses on the 117th Congress (2021-2023), examining 15,000+ bills to understand basic patterns: Which bills get introduced? How many receive votes? What factors influence success?</p>
<p>This post covers the foundational exploratory analysis and data collection process, setting the stage for <a href="/posts/congressional-bill-policy-area-classification/">predictive modeling and policy area classification</a>.</p>
<h2 id="data-collection">Data Collection</h2>
<p>My primary source is <a href="https://www.congress.gov/">Congress.gov</a>, maintained by the Library of Congress. I focused on the 117th Congress (2021-2023), collecting data on bills and joint resolutions, omitting simple resolutions, concurrent resolutions, and amendments.</p>
<p><strong>Data collected:</strong></p>
<table>
	<thead>
			<tr>
					<th>Bill Type</th>
					<th>Introduced</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>House Bill</td>
					<td>9,698</td>
			</tr>
			<tr>
					<td>House Joint Resolution</td>
					<td>106</td>
			</tr>
			<tr>
					<td>Senate Bill</td>
					<td>5,357</td>
			</tr>
			<tr>
					<td>Senate Joint Resolution</td>
					<td>70</td>
			</tr>
			<tr>
					<td><strong>Total</strong></td>
					<td><strong>15,231</strong></td>
			</tr>
	</tbody>
</table>
<h3 id="technical-implementation">Technical Implementation</h3>
<p>Building a usable NLP dataset requires careful handling of the source. Congress.gov loads content dynamically and presents nested DOM structures, so the scraper combines static HTML parsing with a headless browser to render JavaScript before parsing.</p>
<p><strong>Implementation details:</strong></p>
<ul>
<li><a href="https://www.python.org/">Python</a> for core orchestration and data schema management</li>
<li><a href="https://www.selenium.dev/">Selenium</a> for executing JavaScript and loading dynamic page elements</li>
<li><a href="https://www.crummy.com/software/BeautifulSoup/bs4/doc/">BeautifulSoup</a> for structured HTML parsing</li>
<li>Regex for text normalization and extracting clean legislative text for language models</li>
</ul>
<p>The crawler used 5-second delays between requests to respect server limits, a roughly 3-day collection run. It handles edge cases in congressional text formatting and writes one JSON record per bill on a fixed schema. The crawler and processed data are available on <a href="https://github.com/hunter-heidenreich/congress-scraper">GitHub</a>.</p>
<p>For each bill, I queried two pages:</p>
<ul>
<li>All info page: <code>https://www.congress.gov/bill/117th-congress/{bill_type}/{bill_id}/all-info</code></li>
<li>Text page: <code>https://www.congress.gov/bill/117th-congress/{bill_type}/{bill_id}/text?format=txt</code></li>
</ul>
<p>The parsing process involved targeting specific HTML elements and implementing basic caching to avoid redundant requests.</p>
<h2 id="key-findings">Key Findings</h2>
<p>The analysis reveals clear patterns in congressional activity. Most bills never receive votes, and success rates vary significantly by party and policy area.</p>
<h3 id="legislative-outcomes">Legislative Outcomes</h3>
<p>The fundamental question: what happens to bills after introduction?</p>
<p>Each bill has a tracker status indicating its position in the legislative process. The eight possible statuses can be grouped into three meaningful categories:</p>
<ul>
<li><strong>Introduced</strong>: Bills introduced but never voted on</li>
<li><strong>Stalled</strong>: Bills that saw votes but didn&rsquo;t become law (since the 117th Congress ended, these effectively died)</li>
<li><strong>Law</strong>: Bills signed by the President</li>
</ul>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Introduced</th>
					<th>Stalled</th>
					<th>Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>House Bill</td>
					<td>8,977</td>
					<td>523</td>
					<td>198</td>
			</tr>
			<tr>
					<td>House Joint Resolution</td>
					<td>102</td>
					<td>1</td>
					<td>3</td>
			</tr>
			<tr>
					<td>Senate Bill</td>
					<td>5,083</td>
					<td>114</td>
					<td>160</td>
			</tr>
			<tr>
					<td>Senate Joint Resolution</td>
					<td>57</td>
					<td>9</td>
					<td>4</td>
			</tr>
			<tr>
					<td><strong>Total</strong></td>
					<td><strong>14,219</strong></td>
					<td><strong>647</strong></td>
					<td><strong>365</strong></td>
			</tr>
	</tbody>
</table>
<p><strong>Key insights:</strong></p>
<ul>
<li>Only 7% of introduced bills ever receive a vote</li>
<li>Of bills that receive votes, 36% become law</li>
<li>Overall, just 2% of introduced bills become law</li>
</ul>
<h3 id="sponsor-analysis">Sponsor Analysis</h3>
<p>The bill sponsor (the primary member who introduces legislation) provides insights into party and geographic patterns.</p>
<h4 id="party-breakdown">Party Breakdown</h4>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Introduced</th>
					<th>Stalled</th>
					<th>Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Democrat</td>
					<td>8,271</td>
					<td>437</td>
					<td>235</td>
			</tr>
			<tr>
					<td>Republican</td>
					<td>5,883</td>
					<td>210</td>
					<td>130</td>
			</tr>
			<tr>
					<td>Independent</td>
					<td>65</td>
					<td>0</td>
					<td>0</td>
			</tr>
	</tbody>
</table>
<p><strong>Party comparison:</strong></p>
<ul>
<li><strong>Democrats</strong>: 7.5% of bills moved beyond introduction; 2.6% became law</li>
<li><strong>Republicans</strong>: 5.5% of bills moved beyond introduction; 2.1% became law</li>
<li>When bills do advance, Republicans have a slightly higher success rate (38% vs 35%)</li>
</ul>
<h4 id="geographic-distribution">Geographic Distribution</h4>
<p><strong>Top 10 states by bills introduced:</strong></p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>State: Introduced</th>
					<th>State: Stalled</th>
					<th>State: Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>CA: 1,350</td>
					<td>CA: 93</td>
					<td>CA: 34</td>
			</tr>
			<tr>
					<td>2</td>
					<td>TX: 879</td>
					<td>NY: 44</td>
					<td>MI: 30</td>
			</tr>
			<tr>
					<td>3</td>
					<td>NY: 784</td>
					<td>TX: 43</td>
					<td>TX: 25</td>
			</tr>
			<tr>
					<td>4</td>
					<td>FL: 766</td>
					<td>MI: 28</td>
					<td>NY: 24</td>
			</tr>
			<tr>
					<td>5</td>
					<td>IL: 660</td>
					<td>NJ: 28</td>
					<td>MN: 17</td>
			</tr>
			<tr>
					<td>6</td>
					<td>PA: 521</td>
					<td>IL: 27</td>
					<td>IL: 16</td>
			</tr>
			<tr>
					<td>7</td>
					<td>NJ: 478</td>
					<td>VA: 26</td>
					<td>OH: 11</td>
			</tr>
			<tr>
					<td>8</td>
					<td>MI: 380</td>
					<td>FL: 24</td>
					<td>VA: 11</td>
			</tr>
			<tr>
					<td>9</td>
					<td>OH: 377</td>
					<td>PA: 22</td>
					<td>FL: 11</td>
			</tr>
			<tr>
					<td>10</td>
					<td>MA: 361</td>
					<td>OH: 19</td>
					<td>GA: 9</td>
			</tr>
	</tbody>
</table>
<p><strong>Per-representative normalization reveals different patterns:</strong></p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>State: Introduced</th>
					<th>State: Stalled</th>
					<th>State: Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>DC: 101.0</td>
					<td>DC: 7.0</td>
					<td>AK: 2.2</td>
			</tr>
			<tr>
					<td>2</td>
					<td>NH: 47.5</td>
					<td>AK: 2.8</td>
					<td>NH: 2.0</td>
			</tr>
			<tr>
					<td>3</td>
					<td>MT: 44.0</td>
					<td>IA: 2.3</td>
					<td>MT: 2.0</td>
			</tr>
			<tr>
					<td>4</td>
					<td>OR: 41.0</td>
					<td>SD: 2.3</td>
					<td>MI: 1.9</td>
			</tr>
			<tr>
					<td>5</td>
					<td>NV: 40.0</td>
					<td>NH: 2.2</td>
					<td>MN: 1.5</td>
			</tr>
			<tr>
					<td>6</td>
					<td>DE: 38.7</td>
					<td>VA: 2.0</td>
					<td>HI: 1.5</td>
			</tr>
			<tr>
					<td>7</td>
					<td>SD: 38.3</td>
					<td>NJ: 2.0</td>
					<td>CT: 1.3</td>
			</tr>
			<tr>
					<td>8</td>
					<td>IA: 37.7</td>
					<td>PR: 2.0</td>
					<td>IA: 1.2</td>
			</tr>
			<tr>
					<td>9</td>
					<td>RI: 36.5</td>
					<td>NV: 1.8</td>
					<td>OR: 1.1</td>
			</tr>
			<tr>
					<td>10</td>
					<td>UT: 36.0</td>
					<td>MO: 1.8</td>
					<td>SD: 1.0</td>
			</tr>
	</tbody>
</table>
<h4 id="top-individual-sponsors">Top Individual Sponsors</h4>
<p><strong>Most prolific legislators by bills introduced:</strong></p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>Individual: Introduced</th>
					<th>Individual: Stalled</th>
					<th>Individual: Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>Sen. Rubio (R-FL): 186</td>
					<td>Sen. Peters (D-MI): 11</td>
					<td>Sen. Peters (D-MI): 19</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Sen. Klobuchar (D-MN): 143</td>
					<td>Sen. Cornyn (R-TX): 8</td>
					<td>Sen. Cornyn (R-TX): 15</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Sen. Lee (R-UT): 125</td>
					<td>Rep. Connolly (D-VA-11): 8</td>
					<td>Sen. Klobuchar (D-MN): 7</td>
			</tr>
			<tr>
					<td>4</td>
					<td>Sen. Markey (D-MA): 118</td>
					<td>Rep. Takano (D-CA-41): 8</td>
					<td>Sen. Tester (D-MT): 6</td>
			</tr>
			<tr>
					<td>5</td>
					<td>Sen. Casey (D-PA): 116</td>
					<td>Sen. Grassley (R-IA): 7</td>
					<td>Sen. Rubio (R-FL): 6</td>
			</tr>
			<tr>
					<td>6</td>
					<td>Sen. Cortez Masto (D-NV): 109</td>
					<td>Del. Norton (D-DC): 7</td>
					<td>Rep. DeLauro (D-CT-3): 6</td>
			</tr>
			<tr>
					<td>7</td>
					<td>Sen. Booker (D-NJ): 106</td>
					<td>Rep. Johnson (D-TX-30): 7</td>
					<td>Sen. Grassley (R-IA): 5</td>
			</tr>
			<tr>
					<td>8</td>
					<td>Sen. Durbin (D-IL): 102</td>
					<td>Rep. Katko (R-NY-24): 7</td>
					<td>Sen. Ossoff (D-GA): 4</td>
			</tr>
			<tr>
					<td>9</td>
					<td>Del. Norton (D-DC): 101</td>
					<td>Rep. Dean (D-PA-4): 6</td>
					<td>Sen. Murkowski (R-AK): 4</td>
			</tr>
			<tr>
					<td>10</td>
					<td>Sen. Menendez (D-NJ): 99</td>
					<td>Rep. Wagner (R-MO-2): 6</td>
					<td>Sen. Padilla (D-CA): 4</td>
			</tr>
	</tbody>
</table>
<p><strong>Effectiveness score (laws enacted / total bills):</strong></p>
<p>$$
\text{effectiveness} = \frac{\text{bills that became law}}{\text{total bills introduced}}
$$</p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>Individual: Effectiveness Score</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>Rep. Pelosi (D-CA-12): 0.500</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Rep. Mrvan (D-IN-1): 0.444</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Rep. Yarmuth (D-KY-3): 0.333</td>
			</tr>
			<tr>
					<td>4</td>
					<td>Rep. Stivers (R-OH-15): 0.250</td>
			</tr>
			<tr>
					<td>5</td>
					<td>Rep. Graves (R-MO-6): 0.222</td>
			</tr>
			<tr>
					<td>6</td>
					<td>Rep. Jeffries (D-NY-8): 0.200</td>
			</tr>
			<tr>
					<td>7</td>
					<td>Rep. Neal (D-MA-1): 0.200</td>
			</tr>
			<tr>
					<td>8</td>
					<td>Rep. Palazzo (R-MS-4): 0.200</td>
			</tr>
			<tr>
					<td>9</td>
					<td>Sen. Peters (D-MI): 0.186</td>
			</tr>
			<tr>
					<td>10</td>
					<td>Rep. Fischbach (R-MN-7): 0.176</td>
			</tr>
	</tbody>
</table>
<h3 id="policy-focus-areas">Policy Focus Areas</h3>
<p>Each bill is assigned a primary policy area. Here are the most active areas by legislative outcome:</p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>Policy Area: Introduced</th>
					<th>Policy Area: Stalled</th>
					<th>Policy Area: Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>Health: 1,885</td>
					<td>Government Operations: 79</td>
					<td>Government Operations: 94</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Armed Forces: 1,114</td>
					<td>Armed Forces: 60</td>
					<td>Armed Forces: 69</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Taxation: 1,066</td>
					<td>International Affairs: 60</td>
					<td>Crime &amp; Law Enforcement: 31</td>
			</tr>
			<tr>
					<td>4</td>
					<td>Government Operations: 982</td>
					<td>Health: 56</td>
					<td>Health: 19</td>
			</tr>
			<tr>
					<td>5</td>
					<td>International Affairs: 866</td>
					<td>Crime &amp; Law Enforcement: 44</td>
					<td>Native Americans: 17</td>
			</tr>
			<tr>
					<td>6</td>
					<td>Crime &amp; Law Enforcement: 842</td>
					<td>Public Lands: 44</td>
					<td>International Affairs: 14</td>
			</tr>
			<tr>
					<td>7</td>
					<td>Education: 663</td>
					<td>Science &amp; Technology: 44</td>
					<td>Economics &amp; Finance: 13</td>
			</tr>
			<tr>
					<td>8</td>
					<td>Transportation: 663</td>
					<td>Commerce: 43</td>
					<td>Public Lands: 13</td>
			</tr>
			<tr>
					<td>9</td>
					<td>Public Lands: 548</td>
					<td>Finance: 34</td>
					<td>Commerce: 13</td>
			</tr>
			<tr>
					<td>10</td>
					<td>Finance: 547</td>
					<td>Emergency Management: 27</td>
					<td>Emergency Management: 11</td>
			</tr>
	</tbody>
</table>
<p>Notable patterns: Health dominates introductions but has lower success rates, while government operations and armed forces bills are more likely to become law.</p>
<h2 id="next-steps">Next Steps</h2>
<p>This analysis establishes baseline patterns: most bills fail, party affiliation affects success rates, and certain policy areas perform better than others.</p>
<p>Future work could explore:</p>
<ul>
<li>Committee dynamics and voting patterns</li>
<li>Geographic analysis of state-level interests</li>
<li>Bill text analysis using NLP techniques</li>
<li>Predictive modeling for bill outcomes</li>
</ul>
<blockquote>
<p><strong>Update</strong>: I&rsquo;ve since applied machine learning to this type of data in <a href="/posts/congressional-bill-policy-area-classification/">Congressional Bill Policy Area Classification</a>, using 48K+ bills from three Congresses to automatically categorize bills by policy area.</p>
</blockquote>
<p>The complete dataset and code are publicly available to support further research into legislative transparency.</p>
]]></content:encoded></item><item><title>Kabsch Algorithm: NumPy, PyTorch, TensorFlow, and JAX</title><link>https://hunterheidenreich.com/posts/kabsch-algorithm/</link><pubDate>Tue, 03 Oct 2023 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/kabsch-algorithm/</guid><description>Learn about the Kabsch algorithm for optimal point alignment with implementations in NumPy, PyTorch, TensorFlow, and JAX for ML applications.</description><content:encoded><![CDATA[<h2 id="what-is-the-kabsch-algorithm">What is the Kabsch Algorithm?</h2>
<p>In computer vision or scientific computing, a common problem frequently arises: given two sets of points, what is the optimal rigid body transformation for their alignment? The Kabsch algorithm provides a nice solution.</p>















<figure class="post-figure center ">
    <img src="/img/scientific-computing/kabsch-alignment-before-and-after.webp"
         alt="Visualization of two point sets before and after Kabsch alignment"
         title="Visualization of two point sets before and after Kabsch alignment"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The Kabsch algorithm optimally rotates and translates the blue points to align with the red points.</figcaption>
    
</figure>

<p>What are some concrete situations where this crops up?</p>
<ul>
<li><strong>Molecular Dynamics</strong>: Your points are a set of atoms (with physically relevant types), and you want to compare two molecular conformations. Are they the same structure with minor noise or rotation? Or are they different conformations, like a different folding of a protein? This is especially helpful when applying generative models to chemical structures. For example, if you are building a <a href="/notes/chemistry/molecular-simulation/ml-potentials/denoise-vae/">3D Molecular VAE</a> in PyTorch or working with <a href="/notes/machine-learning/generative-models/flow-matching-for-generative-modeling/">Flow Matching models</a>, Kabsch alignment ensures your generative loss function remains rotationally invariant.</li>
<li><strong>Computer Vision</strong>: You have two point clouds from 3D scans of an object taken from different angles. You want to align them to reconstruct the full shape. Or perhaps you&rsquo;re generating 3D shapes from 2D images and need to compare the generated shape to a ground truth scan. Anytime a 3D system is represented as a point cloud, the Kabsch algorithm can help with alignment.</li>
</ul>
<p>Of course, existing libraries implement this algorithm. However, often I find it beneficial to implement algorithms from scratch to build intuition. Furthermore, modern machine learning applications require automatic differentiation, so we will implement the algorithm in PyTorch, TensorFlow, and JAX.</p>
<p>Below, we&rsquo;ll cover the math behind the Kabsch algorithm (and its scaling variant, the <strong>Kabsch-Umeyama</strong> algorithm) and provide complete, differentiable implementations in <strong>NumPy</strong>, <strong>PyTorch</strong>, <strong>TensorFlow</strong>, and <strong>JAX</strong>, demonstrating both single-pair and batched computations for ML applications.</p>
<h2 id="the-math">The Math</h2>















<figure class="post-figure center ">
    <img src="/img/scientific-computing/kabsch-algorithm-basic-animation.webp"
         alt="Animation showing the iterative steps of centroid alignment and rotation"
         title="Animation showing the iterative steps of centroid alignment and rotation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Visualizing the alignment process: first centering the datasets, then finding the optimal rotation.</figcaption>
    
</figure>

<p>Let&rsquo;s say we have two sets of paired points,
$P={\mathbf{p}_i} \in \mathbb{R}^{N \times D}$ and $Q={\mathbf{q}_i} \in \mathbb{R}^{N \times D}$, for $i = 1, \dots, N$
(where $D$ is the dimensionality and $N$ is the number of points).
We want to find a translation vector $\mathbf{t}$ and rotation matrix $R$ to transform $P$ to align with $Q$.</p>
<p>The optimization problem is:</p>
<p>$$
\min_{\mathbf{t}, \ R} \mathcal{L}(\mathbf{t}, R) = \frac{1}{2} \sum_{i=1}^N | \mathbf{q}_i - (R\mathbf{p}_i + \mathbf{t}) |^2
$$</p>
<p>where $\mathbf{t}^\ast \in \mathbb{R}^D$ and $R^\ast \in \mathbb{R}^{D \times D}$ are the optimal translation and rotation.</p>
<p>Often we use a weighted version with weights $w_i$ (e.g., atomic masses in molecular dynamics):</p>
<p>$$
\min_{\mathbf{t}, \ R} \mathcal{L}(\mathbf{t}, R) = \frac{1}{2} \sum_{i=1}^N w_i | \mathbf{q}_i - (R\mathbf{p}_i + \mathbf{t}) |^2
$$</p>
<h3 id="the-translation">The Translation</h3>
<p>The translation and rotation are coupled, but they separate cleanly once we work in centroid-centered coordinates. Compute the centroids (averages) of both point sets:</p>
<p>$$
\bar{\mathbf{p}} = \frac{1}{N} \sum_{i=1}^N \mathbf{p}_i \quad \text{and} \quad \bar{\mathbf{q}} = \frac{1}{N} \sum_{i=1}^N \mathbf{q}_i
$$</p>
<p>For any fixed rotation $R$, the translation that minimizes $\mathcal{L}$ is found by setting $\partial \mathcal{L} / \partial \mathbf{t} = 0$. It maps the rotated source centroid onto the target centroid:</p>
<p>$$
\mathbf{t} = \bar{\mathbf{q}} - R\bar{\mathbf{p}}
$$</p>
<p>A tempting shortcut is to write $\mathbf{t} = \bar{\mathbf{q}} - \bar{\mathbf{p}}$, but that is only correct when $R = I$. In general the translation depends on the rotation, so we compute it <em>after</em> solving for $R$. Substituting this optimal $\mathbf{t}$ back into the objective cancels the centroids and leaves a rotation-only problem in the centered coordinates $\mathbf{p}_i^\prime = \mathbf{p}_i - \bar{\mathbf{p}}$ and $\mathbf{q}_i^\prime = \mathbf{q}_i - \bar{\mathbf{q}}$:</p>
<p>$$
\mathcal{L}(R) = \frac{1}{2} \sum_{i=1}^N | \mathbf{q}_i^\prime - R\mathbf{p}_i^\prime |^2
$$</p>
<p>which is what the next section solves.</p>
<h3 id="the-rotation-matrix">The Rotation Matrix</h3>
<p>We now minimize $\mathcal{L}(R)$ over rotations, using the centered points $\mathbf{p}_i^\prime$ and $\mathbf{q}_i^\prime$ from above. Compute the cross-covariance matrix between the centered sets:</p>
<p>$$
C = P^{\prime T} Q^\prime = \sum_{i=1}^N \mathbf{p}_i^{\prime T} \mathbf{q}_i^{\prime} \in \mathbb{R}^{D \times D}
$$</p>
<p>This is a fairly lightweight operation since $D$ is typically small (e.g., 3 for 3D points), even if $N$ is large.</p>
<p>With $C$ in hand, we want to compute its Singular Value Decomposition (SVD):</p>
<p>$$
C = U \Sigma V^T
$$</p>
<p>This operation is computationally expensive. It scales cubically with $D$ (i.e., $O(D^3)$).
However, since we&rsquo;re often interested in cases where $D$ is small (e.g., 2D or 3D points), this is manageable.</p>
<p>Next, we check for improper rotations (i.e., reflections) and correct for them where necessary:</p>
<p>$$
d = \text{sign}(\det(V U^T))
$$</p>
<p>If $d = -1$, we need to flip the last column of $V$ in the final rotation matrix.</p>
<p>Let $B = \text{diag}(1, 1, d)$.
The optimal rotation matrix comes out:</p>
<p>$$
R^\ast = V B U^T
$$</p>
<h3 id="summary">Summary</h3>
<p>In a nutshell, the Kabsch algorithm boils down to:</p>
<ol>
<li>Compute centroids of $P$ and $Q$ ($\bar{\mathbf{p}}$ and $\bar{\mathbf{q}}$)</li>
<li>Center both point sets by subtracting centroids: $P^\prime$ and $Q^\prime$</li>
<li>Compute cross-covariance matrix $C = P^{\prime T} Q^\prime$</li>
<li>Compute SVD: $C = U \Sigma V^T$ (<em>expensive step</em>)</li>
<li>Compute $d = \text{sign}(\det(V U^T))$ and $B = \text{diag}(1, 1, d)$</li>
<li>Optimal rotation: $R^\ast = V B U^T$</li>
<li>Optimal translation (using the rotation from step 6): $\mathbf{t}^\ast = \bar{\mathbf{q}} - R^\ast\bar{\mathbf{p}}$</li>
</ol>
<p>The resulting root-mean-square deviation (RMSD) between aligned point sets is</p>
<p>$$
\text{RMSD} = \sqrt{\frac{1}{N} \sum_{i=1}^N | \mathbf{q}_i - (R^\ast\mathbf{p}_i + \mathbf{t}^\ast) |^2}
$$</p>















<figure class="post-figure center ">
    <img src="/img/scientific-computing/kabsch-algorithm-visualized-rmsd.webp"
         alt="Diagram illustrating Root Mean Square Deviation (RMSD) distances"
         title="Diagram illustrating Root Mean Square Deviation (RMSD) distances"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">RMSD measures the average distance between the aligned points.</figcaption>
    
</figure>

<p>which is frequently used as a measure of similarity between molecular structures or as a metric in loss functions for ML applications.</p>
<h3 id="the-kabsch-umeyama-algorithm-scaling">The Kabsch-Umeyama Algorithm (Scaling)</h3>
<p>While the standard Kabsch algorithm solves for optimal rotation and translation, the <strong>Kabsch-Umeyama algorithm</strong> extends this by also finding an optimal <strong>scaling factor</strong> $c$. This is essential when aligning structures of different scales, such as a 3D scan versus a ground truth model.</p>
<p><em>(Note: This is sometimes searched for as the &ldquo;Absch-Umeyama algorithm&rdquo; due to typos, but the correct attribution is to Shinji Umeyama based on Wolfgang Kabsch&rsquo;s work.)</em></p>
<p>The method estimates the transformation $\mathbf{q}_i \approx c R \mathbf{p}_i + \mathbf{t}$. The optimal scale is the trace of the (reflection-corrected) singular values of the cross-covariance divided by the variance of the source points about their centroid. See the <a href="/notes/computational-biology/umeyama-similarity-transformation/">Umeyama paper notes</a> for the full derivation.</p>
<p><strong>A Note on SVD and Automatic Differentiation</strong></p>
<p>While modern frameworks allow us to backpropagate through the Singular Value Decomposition (SVD), it comes with a known stability issue: if the cross-covariance matrix has identical (degenerate) singular values (which can occur if the point clouds are perfectly aligned or have certain symmetries), the gradient of the SVD approaches infinity, causing <code>NaN</code> values during backpropagation. If you plan to use this algorithm as a loss function for a neural network, it is often necessary to add a tiny epsilon to the matrix before computing the SVD, or to utilize an SVD gradient patch. The <a href="/projects/kabsch-horn-cookbook/">Kabsch-Horn Cookbook</a> library provides a SafeSVD primitive that floors the singular-value-gap denominator at machine epsilon in the backward pass, producing finite gradients at degenerate inputs across PyTorch, JAX, TensorFlow, and MLX.</p>
<h2 id="implementation">Implementation</h2>
<p>Let&rsquo;s implement the algorithm in different frameworks. Note that for simplicity, the following implementations cover the <strong>unweighted</strong> Kabsch algorithm. If your application (like molecular dynamics) requires weights (e.g., atomic masses), the <a href="/projects/kabsch-horn-cookbook/">Kabsch-Horn Cookbook</a> library provides per-point weighted alignment out of the box.</p>
<h3 id="numpy">NumPy</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> numpy <span style="color:#66d9ef">as</span> np
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kabsch_numpy</span>(P, Q):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Computes the optimal rotation and translation to align two sets of points (P -&gt; Q),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    and their RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param P: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param Q: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :return: A tuple containing the optimal rotation matrix, the optimal
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             translation vector, and the RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> P<span style="color:#f92672">.</span>shape <span style="color:#f92672">==</span> Q<span style="color:#f92672">.</span>shape, <span style="color:#e6db74">&#34;Matrix dimensions must match&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute centroids</span>
</span></span><span style="display:flex;"><span>    centroid_P <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>mean(P, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>    centroid_Q <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>mean(Q, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Center the points</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> P <span style="color:#f92672">-</span> centroid_P
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> Q <span style="color:#f92672">-</span> centroid_Q
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute the covariance matrix</span>
</span></span><span style="display:flex;"><span>    H <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>dot(p<span style="color:#f92672">.</span>T, q)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># SVD</span>
</span></span><span style="display:flex;"><span>    U, S, Vt <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>svd(H)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Validate right-handed coordinate system</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> np<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>det(np<span style="color:#f92672">.</span>dot(Vt<span style="color:#f92672">.</span>T, U<span style="color:#f92672">.</span>T)) <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0.0</span>:
</span></span><span style="display:flex;"><span>        Vt[<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, :] <span style="color:#f92672">*=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">1.0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal rotation</span>
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>dot(Vt<span style="color:#f92672">.</span>T, U<span style="color:#f92672">.</span>T)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal translation (depends on R, so computed after it)</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> centroid_Q <span style="color:#f92672">-</span> np<span style="color:#f92672">.</span>dot(R, centroid_P)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># RMSD</span>
</span></span><span style="display:flex;"><span>    rmsd <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>sqrt(np<span style="color:#f92672">.</span>sum(np<span style="color:#f92672">.</span>square(np<span style="color:#f92672">.</span>dot(p, R<span style="color:#f92672">.</span>T) <span style="color:#f92672">-</span> q)) <span style="color:#f92672">/</span> P<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> R, t, rmsd
</span></span></code></pre></div><p>Here&rsquo;s a quick test to verify correctness:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">test_numpy</span>():
</span></span><span style="display:flex;"><span>    np<span style="color:#f92672">.</span>random<span style="color:#f92672">.</span>seed(<span style="color:#ae81ff">12345</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    P <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>random<span style="color:#f92672">.</span>randn(<span style="color:#ae81ff">100</span>, <span style="color:#ae81ff">3</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    alpha <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>random<span style="color:#f92672">.</span>rand() <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> np<span style="color:#f92672">.</span>pi
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>array([[np<span style="color:#f92672">.</span>cos(alpha), <span style="color:#f92672">-</span>np<span style="color:#f92672">.</span>sin(alpha), <span style="color:#ae81ff">0</span>],
</span></span><span style="display:flex;"><span>                    [np<span style="color:#f92672">.</span>sin(alpha), np<span style="color:#f92672">.</span>cos(alpha), <span style="color:#ae81ff">0</span>],
</span></span><span style="display:flex;"><span>                    [<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>]])
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>random<span style="color:#f92672">.</span>randn(<span style="color:#ae81ff">3</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">10</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Q <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>dot(P, R<span style="color:#f92672">.</span>T) <span style="color:#f92672">+</span> t
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    R_opt, t_opt, rmsd <span style="color:#f92672">=</span> kabsch_numpy(P, Q)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#39;RMSD: </span><span style="color:#e6db74">{}</span><span style="color:#e6db74">&#39;</span><span style="color:#f92672">.</span>format(rmsd))
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#39;R:</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">{}</span><span style="color:#e6db74">&#39;</span><span style="color:#f92672">.</span>format(R))
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#39;R_opt:</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">{}</span><span style="color:#e6db74">&#39;</span><span style="color:#f92672">.</span>format(R_opt))
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#39;t:</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">{}</span><span style="color:#e6db74">&#39;</span><span style="color:#f92672">.</span>format(t))
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#39;t_opt:</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">{}</span><span style="color:#e6db74">&#39;</span><span style="color:#f92672">.</span>format(t_opt))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    l2_t <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>norm(t <span style="color:#f92672">-</span> t_opt)
</span></span><span style="display:flex;"><span>    l2_R <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>norm(R <span style="color:#f92672">-</span> R_opt)
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#39;l2_t: </span><span style="color:#e6db74">{}</span><span style="color:#e6db74">&#39;</span><span style="color:#f92672">.</span>format(l2_t))
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#39;l2_R: </span><span style="color:#e6db74">{}</span><span style="color:#e6db74">&#39;</span><span style="color:#f92672">.</span>format(l2_R))
</span></span></code></pre></div><p>Running this test shows the algorithm correctly recovers the rotation and translation:</p>
<pre><code>RMSD: 3.2111501877699246e-15
R:
[[-0.8475392 -0.5307328  0.       ]
 [ 0.5307328 -0.8475392  0.       ]
 [ 0.         0.         1.       ]]
R_opt:
[[-8.47539198e-01 -5.30732803e-01 -2.95434260e-16]
 [ 5.30732803e-01 -8.47539198e-01  2.92859649e-16]
 [ 0.00000000e+00 -2.77555756e-16  1.00000000e+00]]
t:
[ 5.99726796  1.50078468 -3.34633977]
t_opt:
[ 5.99726796  1.50078468 -3.34633977]
l2_t: 2.7012892057857038e-15
l2_R: 8.028174304721057e-16
</code></pre>
<p>Both the rotation and the translation are recovered to within floating-point precision (the residuals <code>l2_t</code> and <code>l2_R</code> are on the order of <code>1e-15</code>).</p>
<p>For batch processing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kabsch_numpy_batched</span>(P, Q):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Computes the optimal rotation and translation to align two sets of points (P -&gt; Q),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    and their RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param P: A BxNx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param Q: A BxNx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :return: A tuple containing the optimal rotation matrix, the optimal
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             translation vector, and the RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> P<span style="color:#f92672">.</span>shape <span style="color:#f92672">==</span> Q<span style="color:#f92672">.</span>shape, <span style="color:#e6db74">&#34;Matrix dimensions must match&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute centroids</span>
</span></span><span style="display:flex;"><span>    centroid_P <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>mean(P, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, keepdims<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)  <span style="color:#75715e"># Bx1x3</span>
</span></span><span style="display:flex;"><span>    centroid_Q <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>mean(Q, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, keepdims<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)  <span style="color:#75715e"># Bx1x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Center the points</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> P <span style="color:#f92672">-</span> centroid_P  <span style="color:#75715e"># BxNx3</span>
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> Q <span style="color:#f92672">-</span> centroid_Q  <span style="color:#75715e"># BxNx3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute the covariance matrix</span>
</span></span><span style="display:flex;"><span>    H <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>matmul(p<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>), q)  <span style="color:#75715e"># Bx3x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># SVD</span>
</span></span><span style="display:flex;"><span>    U, S, Vt <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>svd(H)  <span style="color:#75715e"># Bx3x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Validate right-handed coordinate system</span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>det(np<span style="color:#f92672">.</span>matmul(Vt<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>), U<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>)))
</span></span><span style="display:flex;"><span>    flip <span style="color:#f92672">=</span> d <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0.0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> flip<span style="color:#f92672">.</span>any():
</span></span><span style="display:flex;"><span>        Vt[flip, <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, :] <span style="color:#f92672">*=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">1.0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal rotation</span>
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>matmul(Vt<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>), U<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>))  <span style="color:#75715e"># Bx3x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal translation (depends on R, so computed after it)</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> centroid_Q<span style="color:#f92672">.</span>squeeze(<span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> np<span style="color:#f92672">.</span>matmul(centroid_P, R<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>))<span style="color:#f92672">.</span>squeeze(<span style="color:#ae81ff">1</span>)  <span style="color:#75715e"># Bx3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># RMSD</span>
</span></span><span style="display:flex;"><span>    rmsd <span style="color:#f92672">=</span> np<span style="color:#f92672">.</span>sqrt(np<span style="color:#f92672">.</span>sum(np<span style="color:#f92672">.</span>square(np<span style="color:#f92672">.</span>matmul(p, R<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>)) <span style="color:#f92672">-</span> q), axis<span style="color:#f92672">=</span>(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)) <span style="color:#f92672">/</span> P<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">1</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> R, t, rmsd
</span></span></code></pre></div><h3 id="pytorch">PyTorch</h3>


<p><details >
  <summary markdown="span">📝 Important Update (February 15, 2026)</summary>
  <strong>Bug Fix Notice:</strong> The PyTorch implementation has been updated to use the &ldquo;B-matrix&rdquo; broadcasting approach. This eliminates in-place tensor modification (which breaks <code>autograd</code>) and data-dependent control flow (which breaks <code>torch.compile</code> and <code>torch.vmap</code>).
</details></p>

<p>The PyTorch implementation now uses broadcasting to ensure differentiability:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kabsch_torch</span>(P, Q):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Computes the optimal rotation and translation to align two sets of points (P -&gt; Q),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    and their RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param P: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param Q: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :return: A tuple containing the optimal rotation matrix, the optimal
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             translation vector, and the RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> P<span style="color:#f92672">.</span>shape <span style="color:#f92672">==</span> Q<span style="color:#f92672">.</span>shape, <span style="color:#e6db74">&#34;Matrix dimensions must match&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute centroids</span>
</span></span><span style="display:flex;"><span>    centroid_P <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>mean(P, dim<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>    centroid_Q <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>mean(Q, dim<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Center the points</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> P <span style="color:#f92672">-</span> centroid_P
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> Q <span style="color:#f92672">-</span> centroid_Q
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute the covariance matrix</span>
</span></span><span style="display:flex;"><span>    H <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>matmul(p<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>), q)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># SVD</span>
</span></span><span style="display:flex;"><span>    U, S, Vt <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>svd(H)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 1. Calculate determinant</span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>det(torch<span style="color:#f92672">.</span>matmul(Vt<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>), U<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>)))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 2. Build diagonal B tensor without in-place mutation</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># We use stack to preserve gradients and graph connections</span>
</span></span><span style="display:flex;"><span>    B_diag <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>stack([torch<span style="color:#f92672">.</span>tensor(<span style="color:#ae81ff">1.0</span>, device<span style="color:#f92672">=</span>d<span style="color:#f92672">.</span>device, dtype<span style="color:#f92672">=</span>d<span style="color:#f92672">.</span>dtype),
</span></span><span style="display:flex;"><span>                          torch<span style="color:#f92672">.</span>tensor(<span style="color:#ae81ff">1.0</span>, device<span style="color:#f92672">=</span>d<span style="color:#f92672">.</span>device, dtype<span style="color:#f92672">=</span>d<span style="color:#f92672">.</span>dtype),
</span></span><span style="display:flex;"><span>                          torch<span style="color:#f92672">.</span>sign(d)])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 3. Scale columns of Vt.T via broadcasting, then multiply by U^T</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Vt.T: (3, 3). B_diag: (3) -&gt; B_diag[None, :]: (1, 3)</span>
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>matmul(Vt<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>) <span style="color:#f92672">*</span> B_diag[<span style="color:#66d9ef">None</span>, :], U<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal translation (depends on R, so computed after it)</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> centroid_Q <span style="color:#f92672">-</span> centroid_P <span style="color:#f92672">@</span> R<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># RMSD</span>
</span></span><span style="display:flex;"><span>    rmsd <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>sqrt(torch<span style="color:#f92672">.</span>sum(torch<span style="color:#f92672">.</span>square(torch<span style="color:#f92672">.</span>matmul(p, R<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>)) <span style="color:#f92672">-</span> q)) <span style="color:#f92672">/</span> P<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> R, t, rmsd
</span></span></code></pre></div><p>And our batched version:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kabsch_torch_batched</span>(P, Q):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Computes the optimal rotation and translation to align two sets of points (P -&gt; Q),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    and their RMSD, in a batched manner.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param P: A BxNx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param Q: A BxNx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :return: A tuple containing the optimal rotation matrix, the optimal
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             translation vector, and the RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> P<span style="color:#f92672">.</span>shape <span style="color:#f92672">==</span> Q<span style="color:#f92672">.</span>shape, <span style="color:#e6db74">&#34;Matrix dimensions must match&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute centroids</span>
</span></span><span style="display:flex;"><span>    centroid_P <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>mean(P, dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, keepdims<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)  <span style="color:#75715e"># Bx1x3</span>
</span></span><span style="display:flex;"><span>    centroid_Q <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>mean(Q, dim<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, keepdims<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)  <span style="color:#75715e"># Bx1x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Center the points</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> P <span style="color:#f92672">-</span> centroid_P  <span style="color:#75715e"># BxNx3</span>
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> Q <span style="color:#f92672">-</span> centroid_Q  <span style="color:#75715e"># BxNx3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute the covariance matrix</span>
</span></span><span style="display:flex;"><span>    H <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>matmul(p<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>), q)  <span style="color:#75715e"># Bx3x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># SVD</span>
</span></span><span style="display:flex;"><span>    U, S, Vt <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>svd(H)  <span style="color:#75715e"># Bx3x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 1. Calculate batched determinant</span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>det(torch<span style="color:#f92672">.</span>matmul(Vt<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>), U<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)))  <span style="color:#75715e"># B</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 2. Build batched B_diag without in-place mutation or control flow</span>
</span></span><span style="display:flex;"><span>    ones <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>ones_like(d)
</span></span><span style="display:flex;"><span>    B_diag <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>stack([ones, ones, torch<span style="color:#f92672">.</span>sign(d)], dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>) <span style="color:#75715e"># Bx3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 3. Scale columns of Vt.T and multiply</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Vt.T: (B, 3, 3). B_diag: (B, 3). B_diag[:, None, :]: (B, 1, 3).</span>
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>matmul(Vt<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>) <span style="color:#f92672">*</span> B_diag[:, <span style="color:#66d9ef">None</span>, :], U<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal translation (depends on R, so computed after it)</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> centroid_Q<span style="color:#f92672">.</span>squeeze(<span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> torch<span style="color:#f92672">.</span>matmul(centroid_P, R<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>))<span style="color:#f92672">.</span>squeeze(<span style="color:#ae81ff">1</span>)  <span style="color:#75715e"># Bx3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># RMSD</span>
</span></span><span style="display:flex;"><span>    rmsd <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>sqrt(torch<span style="color:#f92672">.</span>sum(torch<span style="color:#f92672">.</span>square(torch<span style="color:#f92672">.</span>matmul(p, R<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)) <span style="color:#f92672">-</span> q), dim<span style="color:#f92672">=</span>(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)) <span style="color:#f92672">/</span> P<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">1</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> R, t, rmsd
</span></span></code></pre></div><h3 id="tensorflow">TensorFlow</h3>
<p>The TensorFlow implementation returns <code>S</code>, <code>U</code>, and <code>V</code> directly. To handle immutability and potential compilation (e.g., via <code>@tf.function</code>), we avoid explicit conditional branching by constructing a correction matrix $B$ and broadcasting it.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> tensorflow <span style="color:#66d9ef">as</span> tf
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kabsch_tensorflow</span>(P, Q):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Computes the optimal rotation and translation to align two sets of points (P -&gt; Q),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    and their RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param P: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param Q: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :return: A tuple containing the optimal rotation matrix, the optimal
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             translation vector, and the RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    P <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>convert_to_tensor(P, dtype<span style="color:#f92672">=</span>tf<span style="color:#f92672">.</span>float32)
</span></span><span style="display:flex;"><span>    Q <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>convert_to_tensor(Q, dtype<span style="color:#f92672">=</span>tf<span style="color:#f92672">.</span>float32)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> P<span style="color:#f92672">.</span>shape <span style="color:#f92672">==</span> Q<span style="color:#f92672">.</span>shape, <span style="color:#e6db74">&#34;Matrix dimensions must match&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute centroids</span>
</span></span><span style="display:flex;"><span>    centroid_P <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>reduce_mean(P, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>    centroid_Q <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>reduce_mean(Q, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Center the points</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> P <span style="color:#f92672">-</span> centroid_P
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> Q <span style="color:#f92672">-</span> centroid_Q
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute the covariance matrix</span>
</span></span><span style="display:flex;"><span>    H <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>matmul(tf<span style="color:#f92672">.</span>transpose(p), q)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># SVD</span>
</span></span><span style="display:flex;"><span>    S, U, V <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>svd(H)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 1. Calculate determinant</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Note: V in TF SVD is V, not V^T.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># R = V * U^T. Det(R) = Det(V * U^T)</span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>det(tf<span style="color:#f92672">.</span>matmul(V, tf<span style="color:#f92672">.</span>transpose(U)))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 2. Build diagonal B tensor: [1.0, 1.0, sign(d)]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Use static shape 3 if possible, or infer from D. Assuming D=3 here.</span>
</span></span><span style="display:flex;"><span>    B_diag <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>stack([<span style="color:#ae81ff">1.0</span>, <span style="color:#ae81ff">1.0</span>, tf<span style="color:#f92672">.</span>sign(d)])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 3. Scale columns of V via broadcasting (V * B_diag), then multiply by U^T</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># V is DxD, B_diag is D. V * B_diag[None, :] multiplies each column j by B_diag[j]</span>
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>matmul(V <span style="color:#f92672">*</span> B_diag[<span style="color:#66d9ef">None</span>, :], tf<span style="color:#f92672">.</span>transpose(U))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal translation (depends on R, so computed after it)</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> centroid_Q <span style="color:#f92672">-</span> tf<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>matvec(R, centroid_P)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># RMSD</span>
</span></span><span style="display:flex;"><span>    rmsd <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>sqrt(tf<span style="color:#f92672">.</span>reduce_sum(tf<span style="color:#f92672">.</span>square(tf<span style="color:#f92672">.</span>matmul(p, tf<span style="color:#f92672">.</span>transpose(R)) <span style="color:#f92672">-</span> q)) <span style="color:#f92672">/</span> P<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> R, t, rmsd
</span></span></code></pre></div><p>and a batched version:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kabsch_tensorflow_batched</span>(P, Q):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Computes the optimal rotation and translation to align two sets of points (P -&gt; Q),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    and their RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param P: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param Q: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :return: A tuple containing the optimal rotation matrix, the optimal
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             translation vector, and the RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    P <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>convert_to_tensor(P, dtype<span style="color:#f92672">=</span>tf<span style="color:#f92672">.</span>float32)
</span></span><span style="display:flex;"><span>    Q <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>convert_to_tensor(Q, dtype<span style="color:#f92672">=</span>tf<span style="color:#f92672">.</span>float32)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> P<span style="color:#f92672">.</span>shape <span style="color:#f92672">==</span> Q<span style="color:#f92672">.</span>shape, <span style="color:#e6db74">&#34;Matrix dimensions must match&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute centroids</span>
</span></span><span style="display:flex;"><span>    centroid_P <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>reduce_mean(P, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, keepdims<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    centroid_Q <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>reduce_mean(Q, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, keepdims<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Center the points</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> P <span style="color:#f92672">-</span> centroid_P
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> Q <span style="color:#f92672">-</span> centroid_Q
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute the covariance matrix</span>
</span></span><span style="display:flex;"><span>    H <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>matmul(tf<span style="color:#f92672">.</span>transpose(p, perm<span style="color:#f92672">=</span>[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>]), q)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># SVD</span>
</span></span><span style="display:flex;"><span>    S, U, V <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>svd(H)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 1. Calculate batched determinant</span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>det(tf<span style="color:#f92672">.</span>matmul(V, tf<span style="color:#f92672">.</span>transpose(U, perm<span style="color:#f92672">=</span>[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>])))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 2. Build batched B_diag: shape (B, 3)</span>
</span></span><span style="display:flex;"><span>    ones <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>ones_like(d)
</span></span><span style="display:flex;"><span>    B_diag <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>stack([ones, ones, tf<span style="color:#f92672">.</span>sign(d)], axis<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 3. Scale columns of V (Broadcasting adds the middle dimension)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># V: (B, 3, 3), B_diag: (B, 3) -&gt; B_diag[:, None, :]: (B, 1, 3)</span>
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>matmul(V <span style="color:#f92672">*</span> B_diag[:, <span style="color:#66d9ef">None</span>, :], tf<span style="color:#f92672">.</span>transpose(U, perm<span style="color:#f92672">=</span>[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>]))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal translation (depends on R, so computed after it)</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>squeeze(centroid_Q, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> tf<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>matvec(R, tf<span style="color:#f92672">.</span>squeeze(centroid_P, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>))  <span style="color:#75715e"># Bx3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># RMSD</span>
</span></span><span style="display:flex;"><span>    rmsd <span style="color:#f92672">=</span> tf<span style="color:#f92672">.</span>sqrt(tf<span style="color:#f92672">.</span>reduce_sum(tf<span style="color:#f92672">.</span>square(tf<span style="color:#f92672">.</span>matmul(p, tf<span style="color:#f92672">.</span>transpose(R, perm<span style="color:#f92672">=</span>[<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>])) <span style="color:#f92672">-</span> q), axis<span style="color:#f92672">=</span>(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)) <span style="color:#f92672">/</span> P<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">1</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> R, t, rmsd
</span></span></code></pre></div><h3 id="jax">JAX</h3>
<p>The JAX implementation closely mirrors NumPy, replacing <code>np</code> with <code>jnp</code>. However, we again avoid <code>if</code> statements and in-place assignment (which JAX disallows) by using the broadcasting B-matrix approach.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> jax.numpy <span style="color:#66d9ef">as</span> jnp
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kabsch_jax</span>(P, Q):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Computes the optimal rotation and translation to align two sets of points (P -&gt; Q),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    and their RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param P: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param Q: A Nx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :return: A tuple containing the optimal rotation matrix, the optimal
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             translation vector, and the RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    P <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>array(P)
</span></span><span style="display:flex;"><span>    Q <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>array(Q)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> P<span style="color:#f92672">.</span>shape <span style="color:#f92672">==</span> Q<span style="color:#f92672">.</span>shape, <span style="color:#e6db74">&#34;Matrix dimensions must match&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute centroids</span>
</span></span><span style="display:flex;"><span>    centroid_P <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>mean(P, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>    centroid_Q <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>mean(Q, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Center the points</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> P <span style="color:#f92672">-</span> centroid_P
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> Q <span style="color:#f92672">-</span> centroid_Q
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute the covariance matrix</span>
</span></span><span style="display:flex;"><span>    H <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>dot(p<span style="color:#f92672">.</span>T, q)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># SVD</span>
</span></span><span style="display:flex;"><span>    U, S, Vt <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>svd(H)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 1. Calculate determinant</span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>det(jnp<span style="color:#f92672">.</span>dot(Vt<span style="color:#f92672">.</span>T, U<span style="color:#f92672">.</span>T))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 2. Build diagonal B array</span>
</span></span><span style="display:flex;"><span>    B_diag <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>array([<span style="color:#ae81ff">1.0</span>, <span style="color:#ae81ff">1.0</span>, jnp<span style="color:#f92672">.</span>sign(d)])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 3. Scale columns of Vt.T and multiply by U.T</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Vt.T is V.</span>
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>dot(Vt<span style="color:#f92672">.</span>T <span style="color:#f92672">*</span> B_diag[<span style="color:#66d9ef">None</span>, :], U<span style="color:#f92672">.</span>T)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal translation (depends on R, so computed after it)</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> centroid_Q <span style="color:#f92672">-</span> jnp<span style="color:#f92672">.</span>dot(R, centroid_P)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># RMSD</span>
</span></span><span style="display:flex;"><span>    rmsd <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>sqrt(jnp<span style="color:#f92672">.</span>sum(jnp<span style="color:#f92672">.</span>square(jnp<span style="color:#f92672">.</span>dot(p, R<span style="color:#f92672">.</span>T) <span style="color:#f92672">-</span> q)) <span style="color:#f92672">/</span> P<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">0</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> R, t, rmsd
</span></span></code></pre></div><p>and batched:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kabsch_jax_batched</span>(P, Q):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    Computes the optimal rotation and translation to align two sets of points (P -&gt; Q),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    and their RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param P: A BxNx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :param Q: A BxNx3 matrix of points
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    :return: A tuple containing the optimal rotation matrix, the optimal
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             translation vector, and the RMSD.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    P <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>array(P)
</span></span><span style="display:flex;"><span>    Q <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>array(Q)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> P<span style="color:#f92672">.</span>shape <span style="color:#f92672">==</span> Q<span style="color:#f92672">.</span>shape, <span style="color:#e6db74">&#34;Matrix dimensions must match&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute centroids</span>
</span></span><span style="display:flex;"><span>    centroid_P <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>mean(P, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, keepdims<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)  <span style="color:#75715e"># Bx1x3</span>
</span></span><span style="display:flex;"><span>    centroid_Q <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>mean(Q, axis<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, keepdims<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)  <span style="color:#75715e"># Bx1x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Center the points</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> P <span style="color:#f92672">-</span> centroid_P  <span style="color:#75715e"># BxNx3</span>
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> Q <span style="color:#f92672">-</span> centroid_Q  <span style="color:#75715e"># BxNx3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Compute the covariance matrix</span>
</span></span><span style="display:flex;"><span>    H <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>matmul(p<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>), q)  <span style="color:#75715e"># Bx3x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># SVD</span>
</span></span><span style="display:flex;"><span>    U, S, Vt <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>svd(H)  <span style="color:#75715e"># Bx3x3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 1. Calculate batched determinant</span>
</span></span><span style="display:flex;"><span>    d <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>linalg<span style="color:#f92672">.</span>det(jnp<span style="color:#f92672">.</span>matmul(Vt<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>), U<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>)))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 2. Build batched B_diag</span>
</span></span><span style="display:flex;"><span>    ones <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>ones_like(d)
</span></span><span style="display:flex;"><span>    B_diag <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>stack([ones, ones, jnp<span style="color:#f92672">.</span>sign(d)], axis<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 3. Scale columns of Vt.T and multiply by U.T</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Vt.T: (B, 3, 3). B_diag: (B, 3).</span>
</span></span><span style="display:flex;"><span>    R <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>matmul(Vt<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>) <span style="color:#f92672">*</span> B_diag[:, <span style="color:#66d9ef">None</span>, :], U<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Optimal translation (depends on R, so computed after it)</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> centroid_Q<span style="color:#f92672">.</span>squeeze(<span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> jnp<span style="color:#f92672">.</span>matmul(centroid_P, R<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>))<span style="color:#f92672">.</span>squeeze(<span style="color:#ae81ff">1</span>)  <span style="color:#75715e"># Bx3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># RMSD</span>
</span></span><span style="display:flex;"><span>    rmsd <span style="color:#f92672">=</span> jnp<span style="color:#f92672">.</span>sqrt(jnp<span style="color:#f92672">.</span>sum(jnp<span style="color:#f92672">.</span>square(jnp<span style="color:#f92672">.</span>matmul(p, R<span style="color:#f92672">.</span>transpose(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">1</span>)) <span style="color:#f92672">-</span> q), axis<span style="color:#f92672">=</span>(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)) <span style="color:#f92672">/</span> P<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">1</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> R, t, rmsd
</span></span></code></pre></div>














<figure class="post-figure center ">
    <img src="/img/scientific-computing/kabsch-animated-protein-conformational-alignment-analysis.webp"
         alt="Animation of a protein structure being aligned using the Kabsch algorithm"
         title="Animation of a protein structure being aligned using the Kabsch algorithm"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Real-world application: Aligning protein conformations to analyze structural changes.</figcaption>
    
</figure>

<h2 id="extensions">Extensions</h2>
<p>The Kabsch algorithm has several important extensions that go beyond the formulation dealt with here:</p>
<ul>
<li><strong>Quaternion Form</strong>: The algorithm can be reformulated using quaternions for better numerical stability, particularly useful in applications requiring high precision.</li>
<li><strong>Iterative Versions</strong>: More robust variants that handle noise better and have improved scaling properties for large point sets. This also can be advantageous for setups with limited computational resources.</li>
<li><strong>Weighted Kabsch</strong>: Extensions that incorporate point weights (e.g., atomic masses in molecular dynamics). While SciPy provides a <a href="https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.align_vectors.html#scipy.spatial.transform.Rotation.align_vectors">weighted version</a>, it lacks batch processing capabilities.</li>
<li><strong>The Umeyama Algorithm</strong>: If your point sets are rotated, translated, and scaled differently, the Umeyama algorithm is the direct extension of Kabsch. It solves the same optimization problem but introduces a scaling factor $c$, finding the optimal alignment for $Q \approx c R P + t$.</li>
</ul>
<p>Several of these extensions are implemented in the <a href="/projects/kabsch-horn-cookbook/">Kabsch-Horn Cookbook</a> library, which provides differentiable Kabsch, Horn, and Umeyama alignment across NumPy, PyTorch, JAX, TensorFlow, and MLX.</p>
<h2 id="further-reading">Further Reading</h2>
<ul>
<li><a href="https://en.wikipedia.org/wiki/Kabsch_algorithm">Wikipedia, Kabsch Algorithm</a></li>
<li><a href="https://zalo.github.io/blog/kabsch/">Zalo on Kabsch</a>: An interactive shape matching demo.</li>
</ul>
<h3 id="original-papers">Original Papers</h3>
<ul>
<li><strong>[Kabsch 1976]</strong> Kabsch, W. (1976). &ldquo;A solution for the best rotation to relate two sets of vectors.&rdquo; <em>Acta Crystallographica Section A</em>, 32(5), 922-923. <a href="https://doi.org/10.1107/S0567739476001873">DOI: 10.1107/S0567739476001873</a>
<em>The original paper: a closed-form, non-iterative optimal-rotation solution derived via Lagrange multipliers and eigendecomposition of $\tilde{R}R$ (the SVD reformulation came later; see Arun et al. 1987).</em> See also: <a href="/notes/computational-biology/kabsch-algorithm/">paper notes</a>.</li>
<li><strong>[Kabsch 1978]</strong> Kabsch, W. (1978). &ldquo;A discussion of the solution for the best rotation to relate two sets of vectors.&rdquo; <em>Acta Crystallographica Section A</em>, 34(5), 827-828. <a href="https://doi.org/10.1107/S0567739478001680">DOI: 10.1107/S0567739478001680</a>
<em>The follow-up paper correcting for improper rotations (reflections).</em></li>
<li><strong>[Arun et al. 1987]</strong> Arun, K. S., Huang, T. S., &amp; Blostein, S. D. (1987). &ldquo;Least-Squares Fitting of Two 3-D Point Sets.&rdquo; <em>IEEE Transactions on Pattern Analysis and Machine Intelligence</em>, PAMI-9(5), 698-700. <a href="https://doi.org/10.1109/TPAMI.1987.4767965">DOI: 10.1109/TPAMI.1987.4767965</a>
<em>The first SVD-based formulation for 3D point set alignment.</em> See also: <a href="/notes/computational-biology/arun-svd-point-fitting/">paper notes</a>.</li>
<li><strong>[Horn et al. 1988]</strong> Horn, B. K. P., Hilden, H. M., &amp; Negahdaripour, S. (1988). &ldquo;Closed-form solution of absolute orientation using orthonormal matrices.&rdquo; <em>Journal of the Optical Society of America A</em>, 5(7), 1127-1135. <a href="https://doi.org/10.1364/JOSAA.5.001127">DOI: 10.1364/JOSAA.5.001127</a>
<em>The matrix square root (polar decomposition) approach to the same problem.</em> See also: <a href="/notes/computational-biology/horn-orthonormal-matrices/">paper notes</a>.</li>
<li><strong>[Horn 1987]</strong> Horn, B. K. P. (1987). &ldquo;Closed-form solution of absolute orientation using unit quaternions.&rdquo; <em>Journal of the Optical Society of America A</em>, 4(4), 629-642. <a href="https://doi.org/10.1364/JOSAA.4.000629">DOI: 10.1364/JOSAA.4.000629</a>
<em>An alternative quaternion-based closed-form solution that also handles scale.</em> See also: <a href="/notes/computational-biology/horn-absolute-orientation/">paper notes</a>.</li>
<li><strong>[Umeyama 1991]</strong> Umeyama, S. (1991). &ldquo;Least-squares estimation of transformation parameters between two point patterns.&rdquo; <em>IEEE Transactions on Pattern Analysis and Machine Intelligence</em>, 13(4), 376-380. <a href="https://doi.org/10.1109/34.88573">DOI: 10.1109/34.88573</a>
<em>The extension of the algorithm to include optimal scaling in addition to rotation and translation.</em> See also: <a href="/notes/computational-biology/umeyama-similarity-transformation/">paper notes</a>.</li>
</ul>
]]></content:encoded></item><item><title>LAMMPS Tutorial: Copper and Platinum Adatom Diffusion</title><link>https://hunterheidenreich.com/posts/adatom-cu-diffusion/</link><pubDate>Wed, 27 Sep 2023 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/adatom-cu-diffusion/</guid><description>LAMMPS tutorial for copper and platinum surface diffusion simulation and ML training data generation. Includes setup, analysis, and Ovito visualization.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Understanding how individual atoms move on crystal surfaces is fundamental to materials science, catalysis, and nanotechnology. This atomic-scale motion, called adatom diffusion, drives processes like thin film growth and surface chemical reactions.</p>
<p>While learning molecular dynamics simulations for my graduate work, I discovered these simulations generate valuable training data for machine learning models. This tutorial walks through simulating copper adatom diffusion on a Cu(100) surface using LAMMPS, building on Eric N. Hahn&rsquo;s excellent <a href="https://www.ericnhahn.com/tutorials/lammps-tutorials/adatom">adatom tutorial</a>.</p>
<p><strong>What you&rsquo;ll learn:</strong></p>
<ul>
<li>Setting up LAMMPS for surface diffusion simulations</li>
<li>Understanding simulation parameters and their impact</li>
<li>Visualizing results with Ovito</li>
<li>Analyzing trajectory data for ML applications</li>
<li>Connecting simulation data to machine learning workflows</li>
</ul>
<p>In this tutorial, we will explore both Copper (Cu) and Platinum (Pt) to show how atomic properties affect diffusion behavior, generating data for training element-aware ML models.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before starting this tutorial, you&rsquo;ll need:</p>
<ul>
<li><strong>LAMMPS</strong> with EAM potential support (version 2020 or later recommended)</li>
<li><strong>Python 3.x</strong> with matplotlib for analysis scripts</li>
<li><strong>Ovito</strong> (free version) for trajectory visualization</li>
<li><strong>Cu01.eam.alloy</strong> potential file from the <a href="https://www.ctcms.nist.gov/potentials/">NIST repository</a></li>
<li>Basic familiarity with molecular dynamics concepts (atoms, forces, timesteps)</li>
</ul>
<h2 id="understanding-adatoms-and-surface-diffusion">Understanding Adatoms and Surface Diffusion</h2>
<h3 id="what-is-an-adatom">What is an Adatom?</h3>
<p>An <strong>adatom</strong> (adsorbed atom) sits on a crystal surface but isn&rsquo;t incorporated into the bulk structure. Adatoms have fewer bonds than fully coordinated bulk atoms, making them highly mobile and reactive.</p>















<figure class="post-figure center ">
    <img src="/img/posts/crystal-surface.webp"
         alt="Ball model representation of a real (atomically rough) crystal surface with steps, kinks, adatoms, and vacancies in a closely-packed crystalline material. Adsorbed molecules, substitutional and interstitial atoms are also illustrated."
         title="Ball model representation of a real (atomically rough) crystal surface with steps, kinks, adatoms, and vacancies in a closely-packed crystalline material. Adsorbed molecules, substitutional and interstitial atoms are also illustrated."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Ball model representation of a real (atomically rough) crystal surface with steps, kinks, adatoms, and vacancies in a closely-packed crystalline material. Adsorbed molecules, substitutional and interstitial atoms are also illustrated. (<a href="https://creativecommons.org/licenses/by-sa/4.0/deed.en">CC-BY-SA-4.0: ShutterWaves</a>)</figcaption>
    
</figure>

<h3 id="why-study-adatom-diffusion">Why Study Adatom Diffusion?</h3>
<p>Adatom diffusion is important for several technological processes:</p>
<ul>
<li><strong>Thin film growth</strong>: Adatoms are the building blocks of deposited films</li>
<li><strong>Catalysis</strong>: Many reactions happen at these mobile surface atoms</li>
<li><strong>Corrosion</strong>: How surface atoms move affects material degradation</li>
<li><strong>Self-assembly</strong>: Adatom movement enables formation of ordered structures</li>
</ul>
<p>From a <strong>machine learning perspective</strong>, adatom diffusion is an ideal test case because:</p>
<ul>
<li>Well-understood physics provides ground truth for validation</li>
<li>Small system size enables extensive simulation</li>
<li>Behavior varies significantly with temperature and atomic species</li>
<li>Systematic data generation across different conditions</li>
</ul>
<h3 id="why-cu100">Why Cu(100)?</h3>
<p>Cu(100) surfaces are well-studied in literature, making them excellent benchmarks. The face-centered cubic (fcc) structure creates clear diffusion pathways, and copper&rsquo;s moderate binding energy lets us observe diffusion at reasonable temperatures without extreme computational demands.</p>
<h2 id="simulation-overview">Simulation Overview</h2>
<p>Before diving into the code details, let&rsquo;s understand the simulation design:</p>
<h3 id="key-simulation-parameters">Key Simulation Parameters</h3>
<table>
	<thead>
			<tr>
					<th>Parameter</th>
					<th>Value</th>
					<th>Why this choice</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>System size</strong></td>
					<td>$8 \x8 \x6$ unit cells</td>
					<td>Large enough to avoid edge effects while keeping simulation time reasonable</td>
			</tr>
			<tr>
					<td><strong>Ensemble</strong></td>
					<td>NVT (constant volume, temperature)</td>
					<td>Appropriate for surface studies where pressure isn&rsquo;t the focus</td>
			</tr>
			<tr>
					<td><strong>Potential</strong></td>
					<td>EAM (Embedded Atom Method)</td>
					<td>Captures metallic bonding better than simple pair potentials</td>
			</tr>
			<tr>
					<td><strong>Time step</strong></td>
					<td>5 fs</td>
					<td>Small enough for numerical stability while allowing reasonable run times</td>
			</tr>
			<tr>
					<td><strong>Duration</strong></td>
					<td>500 ps</td>
					<td>Long enough to see multiple diffusion events</td>
			</tr>
			<tr>
					<td><strong>Temperature</strong></td>
					<td>600 K initial seed; 850 K thermostat on the bottom reservoir layer</td>
					<td>Drives thermal energy up from the substrate into the free surface where the adatom diffuses</td>
			</tr>
	</tbody>
</table>
<h3 id="simulation-strategy">Simulation Strategy</h3>
<p>The approach uses a <strong>thermal gradient setup</strong>:</p>
<ul>
<li>Bottom layers: Fixed to represent bulk crystal</li>
<li>Middle layers: Heated to 850 K for thermal energy</li>
<li>Top layers and adatom: Equilibrate to $\sim 600$ K for diffusion</li>
<li>This lets thermal energy propagate up from the heated reservoir to the free surface where the adatom diffuses</li>
</ul>
<p>The complete LAMMPS script implementing this approach:</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">### Original Created by Eric N. Hahn  ###
### ericnhahn@gmail.com ###

### Modifications by Hunter Heidenreich, CSE lab (Harvard, 2023)
### hheidenreich@g.harvard.edu
### 2023-09-01

### Simulating adatoms ###
### Version 0.2 ###


units metal
dimension 3
boundary p p s
atom_style atomic

lattice fcc 3.614
variable cubel equal 4
variable fixer1 equal &#34;v_cubel+2&#34;
variable fixer2 equal &#34;v_cubel+1.49&#34;
region  box block -${cubel} ${cubel} -${cubel} ${cubel} -${fixer1} 1 units lattice
region cbox block -${cubel} ${cubel} -${cubel} ${cubel} -${fixer1} 0 units lattice
create_box 1 box
create_atoms 1 region cbox
create_atoms 1 single -0.5 0 0.5 units lattice
region hold block INF INF INF INF -${fixer1} -${fixer2} units lattice
region temp block INF INF INF INF -${fixer2} -${cubel} units lattice
group hold region hold
group temp region temp

pair_style eam/alloy
pair_coeff * * Cu01.eam.alloy Cu

timestep        0.005
compute         new all temp
velocity        temp create 600 12345
fix heater temp temp/rescale 1 850 850 5 1
fix nve all nve
fix freeze hold setforce 0 0 0

variable e     equal pe
variable k     equal ke
variable t     equal etotal
variable T     equal temp
fix energy all ave/time 1 50 50 v_k v_e v_t v_T file energy_avg.txt

minimize 1.0e-4 1.0e-6 1000 10000

dump eve all custom 5 dump.lammpstrj id type xu yu zu   # fx fy fz  # uncomment for forces
dump_modify eve sort id

thermo 50
run 100000  # 100_000 * 5 fs = 500 ps
</code></pre><h2 id="line-by-line-breakdown">Line-by-Line Breakdown</h2>
<p>Let&rsquo;s examine each part of the LAMMPS script:</p>
<h3 id="simulation-setup">Simulation Setup</h3>
<h4 id="units">Units</h4>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">units metal
</code></pre><p>Sets simulation units to &ldquo;metal&rdquo; units (a standard choice for metallic systems). Key conversions: length in $\text{\AA}$, energy in eV, time in ps. Full details in the <a href="https://docs.lammps.org/units.html">LAMMPS documentation</a>.</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">dimension 3
</code></pre><p>Sets 3D simulation.</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">boundary p p s
</code></pre><p>Boundary conditions: periodic in x,y (infinite surface) and shrink-wrapped in z (finite surface height). This allows the adatom to potentially leave the surface if needed.</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">atom_style atomic
</code></pre><p>Uses &ldquo;atomic&rdquo; style, atoms as point masses without internal structure. Standard for metallic systems.</p>
<h4 id="lattice">Lattice</h4>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">lattice fcc 3.614
</code></pre><p>Defines face-centered cubic lattice with experimental Cu lattice constant ($3.614 \text{ \AA}$).</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">variable cubel equal 4
variable fixer1 equal &#34;v_cubel+2&#34;
variable fixer2 equal &#34;v_cubel+1.49&#34;
</code></pre><p>Define variables for simulation box dimensions. <code>cubel=4</code> sets system size, while <code>fixer1</code> and <code>fixer2</code> define the frozen and heated regions.</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">region  box block -${cubel} ${cubel} -${cubel} ${cubel} -${fixer1} 1 units lattice
region cbox block -${cubel} ${cubel} -${cubel} ${cubel} -${fixer1} 0 units lattice
</code></pre><p>Define regions: <code>box</code> for the entire simulation volume and <code>cbox</code> for crystal creation (excludes the surface layer where we&rsquo;ll place the adatom).</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">create_box 1 box
create_atoms 1 region cbox
create_atoms 1 single -0.5 0 0.5 units lattice
</code></pre><p>Create simulation box, populate with Cu atoms, then add single adatom at specified position.</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">region hold block INF INF INF INF -${fixer1} -${fixer2} units lattice
region temp block INF INF INF INF -${fixer2} -${cubel} units lattice
group hold region hold
group temp region temp
</code></pre><p>Define atom groups: <code>hold</code> (frozen bottom layers) and <code>temp</code> (heated middle layers for thermal energy).</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">pair_style eam/alloy
pair_coeff * * Cu01.eam.alloy Cu
</code></pre><p>Use <a href="/notes/chemistry/molecular-simulation/classical-methods/embedded-atom-method/">Embedded Atom Method (EAM)</a> potential for metallic bonding. The Cu01.eam.alloy potential from <a href="https://doi.org/10.1103/PhysRevB.63.224106">Mishin et al.</a> is available from the <a href="https://www.ctcms.nist.gov/potentials/testing/entry/2001--Mishin-Y-Mehl-M-J-Papaconstantopoulos-D-A-et-al--Cu-1/">NIST repository</a>.</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">timestep        0.005
</code></pre><p>5 femtosecond timestep (small enough for numerical stability).</p>
<h4 id="initial-conditions">Initial Conditions</h4>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">velocity        temp create 600 12345
</code></pre><p>Initialize velocities for 600 K temperature using random seed 12345.</p>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">fix heater temp temp/rescale 1 850 850 5 1
fix nve all nve
fix freeze hold setforce 0 0 0
</code></pre><p>Three fixes control dynamics:</p>
<ul>
<li><code>heater</code>: Maintains 850 K in middle layers</li>
<li><code>nve</code>: Velocity Verlet integration for all atoms</li>
<li><code>freeze</code>: Sets forces to zero for bottom atoms</li>
</ul>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">variable e     equal pe
variable k     equal ke
variable t     equal etotal
variable T     equal temp
fix energy all ave/time 1 50 50 v_k v_e v_t v_T file energy_avg.txt
</code></pre><p>Track energies and temperature, averaging every 50 timesteps and writing to file.</p>
<h3 id="execution">Execution</h3>
<h4 id="minimization">Minimization</h4>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">minimize 1.0e-4 1.0e-6 1000 10000
</code></pre><p>Relax initial structure. Should converge quickly, indicating the system is already well-optimized.</p>
<h4 id="output-setup">Output Setup</h4>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">dump eve all custom 5 dump.lammpstrj id type xu yu zu   # fx fy fz  # uncomment for forces
dump_modify eve sort id
</code></pre><p>Write atomic positions every 5 timesteps, sorted by atom ID. Uncomment force components if needed for analysis.</p>
<h4 id="production-run">Production Run</h4>
<pre tabindex="0"><code class="language-lammps" data-lang="lammps">thermo 50
run 100000  # 100_000 * 5 fs = 500 ps
</code></pre><p>Run simulation for 500 ps with thermo output every 50 steps.</p>
<h2 id="visualization-and-analysis">Visualization and Analysis</h2>
<p>Visualize results using <a href="https://www.ovito.org/">Ovito</a>, a free atomistic visualization tool:</p>
<ol>
<li>Open the trajectory file in Ovito</li>
<li>Color atoms by z-coordinate</li>
<li>Restrict height range to $0\text{-}2 \text{ \AA}$ for surface focus</li>
<li>Animate to observe diffusion events</li>
</ol>
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
			<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube-nocookie.com/embed/nIdbNqEEPys?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
		</div>

<h2 id="analysis-results">Analysis Results</h2>
<p>The simulation generates rich data for machine learning applications:</p>
<h3 id="energy-analysis">Energy Analysis</h3>
<p>Energy fluctuations reveal thermal motion patterns:</p>















<figure class="post-figure center ">
    <img src="/img/adatom_cu_energy_avg.webp"
         alt="Average kinetic energy, potential energy, total energy, and temperature over time."
         title="Average kinetic energy, potential energy, total energy, and temperature over time."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Energy and temperature evolution over 500 ps simulation.</figcaption>
    
</figure>

<p>Skipping the first 30 logged data points (each averaged over 50 timesteps, so the first ~1500 timesteps / 7.5 ps of equilibration), these fluctuations enable:</p>
<ul>
<li><strong>Anomaly detection</strong>: Identifying unusual diffusion events</li>
<li><strong>Temperature prediction</strong>: Estimating local temperature from atomic motion</li>
<li><strong>Stability analysis</strong>: Detecting equilibrium states</li>
</ul>
<h3 id="trajectory-analysis">Trajectory Analysis</h3>
<p>Adatom motion reveals diffusion mechanisms:</p>















<figure class="post-figure center ">
    <img src="/img/adatom_cu_xy.webp"
         alt="x and y coordinates of the adatom over time."
         title="x and y coordinates of the adatom over time."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Adatom surface trajectory showing random walk behavior.</figcaption>
    
</figure>

<p>This data enables:</p>
<ul>
<li><strong>Path prediction</strong>: Training models for future position forecasting</li>
<li><strong>Diffusion coefficient estimation</strong>: Learning temperature-mobility relationships</li>
<li><strong>Transition state identification</strong>: Detecting hops between stable sites</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/adatom_cu_z.webp"
         alt="z coordinate of the adatom over time."
         title="z coordinate of the adatom over time."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Height fluctuations revealing exchange events with surface atoms.</figcaption>
    
</figure>

<p>Z-coordinate data shows <strong>exchange events</strong> where the adatom swaps with surface atoms (crucial for surface chemistry understanding). This enables:</p>
<ul>
<li><strong>Event classification</strong>: Distinguishing diffusion vs. exchange mechanisms</li>
<li><strong>Activation barrier estimation</strong>: Learning energy landscapes from fluctuations</li>
<li><strong>Surface coordination analysis</strong>: Correlating height with local environment</li>
</ul>
<h3 id="machine-learning-applications">Machine Learning Applications</h3>
<p>This simulation produces multiple data types for ML training:</p>
<ol>
<li><strong>Coordinate trajectories</strong>: Neural network potential inputs or graph neural network features</li>
<li><strong>Energy time series</strong>: Regression model features for system property prediction</li>
<li><strong>Event annotations</strong>: Supervised learning labels for diffusion mechanism classification</li>
<li><strong>Environmental descriptors</strong>: Local atomic arrangement features</li>
</ol>
<p>Systematic MD simulations generate large, labeled datasets across varied conditions.</p>
<h2 id="extending-to-platinum-mass-and-bonding-effects">Extending to Platinum: Mass and Bonding Effects</h2>
<p>To understand how different elements behave, we can extend this framework to platinum (Pt). Platinum&rsquo;s higher atomic mass and stronger metallic bonding create notably different diffusion behavior, providing comparative data for machine learning.</p>
<h3 id="key-differences-from-copper">Key Differences from Copper</h3>
<table>
	<thead>
			<tr>
					<th>Parameter</th>
					<th>Copper (Cu)</th>
					<th>Platinum (Pt)</th>
					<th>Impact</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Atomic mass</strong></td>
					<td>63.5 u</td>
					<td>195.1 u</td>
					<td>Slower diffusion, longer correlation times</td>
			</tr>
			<tr>
					<td><strong>Lattice const.</strong></td>
					<td>3.614 Å</td>
					<td>3.96 Å</td>
					<td>Larger diffusion barriers, different pathways</td>
			</tr>
			<tr>
					<td><strong>Potential</strong></td>
					<td>Mishin et al.</td>
					<td>Zhou et al.</td>
					<td>Different interaction strengths</td>
			</tr>
			<tr>
					<td><strong>Melting point</strong></td>
					<td>1358 K</td>
					<td>2041 K</td>
					<td>Stronger surface binding</td>
			</tr>
	</tbody>
</table>
<h3 id="modifying-the-lammps-script">Modifying the LAMMPS Script</h3>
<p>The platinum simulation uses the exact same framework as the copper case, with three simple element-specific modifications:</p>
<ol>
<li><strong>Lattice constant</strong>: Change <code>lattice fcc 3.614</code> to <code>lattice fcc 3.96</code></li>
<li><strong>Potential file</strong>: Change <code>Cu01.eam.alloy</code> to <code>Pt_Zhou04.eam.alloy</code> (available from the <a href="https://www.ctcms.nist.gov/potentials/testing/entry/2004--Zhou-X-W-Johnson-R-A-Wadley-H-N-G--Pt/">NIST repository</a>)</li>
<li><strong>Element specification</strong>: Change <code>Cu</code> to <code>Pt</code> in the <code>pair_coeff</code> line</li>
</ol>
<p>These simple changes capture the essential physics differences between elements while maintaining the same simulation protocol, which is ideal for generating comparative datasets for ML training.</p>
<h3 id="expected-behavior-vs-copper">Expected Behavior vs. Copper</h3>
<p>When you run the analysis scripts on the platinum trajectory, you will observe:</p>
<ul>
<li><strong>Slower motion</strong>: Heavier atoms move more slowly at the same temperature. Platinum&rsquo;s ~3x greater mass reduces diffusion rates.</li>
<li><strong>Higher energy barriers</strong>: Stronger metallic bonding creates deeper potential wells, requiring more thermal energy for diffusion hops.</li>
<li><strong>Different pathways</strong>: The larger lattice constant changes the energy landscape, potentially favoring different diffusion mechanisms.</li>
</ul>
<p>Comparing Cu and Pt trajectories enables training element-aware models that account for atomic mass effects, binding strengths, and temperature scaling across different metals.</p>
<h2 id="code-and-data">Code and Data</h2>
<p>The complete simulation scripts and analysis tools are available for reproducibility:</p>
<h3 id="energy-analysis-script">Energy Analysis Script</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Hunter Heidenreich, 2023</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Plots the energy of a simulation over time.</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> matplotlib.pyplot <span style="color:#66d9ef">as</span> plt
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> argparse <span style="color:#f92672">import</span> ArgumentParser
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#39;__main__&#39;</span>:
</span></span><span style="display:flex;"><span>    parser <span style="color:#f92672">=</span> ArgumentParser()
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#39;--input&#39;</span>, type<span style="color:#f92672">=</span>str, required<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#39;--output&#39;</span>, type<span style="color:#f92672">=</span>str, required<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#39;--skip&#39;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    args <span style="color:#f92672">=</span> parser<span style="color:#f92672">.</span>parse_args()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Parse energy data</span>
</span></span><span style="display:flex;"><span>    data <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#39;ts&#39;</span>: [], <span style="color:#e6db74">&#39;kes&#39;</span>: [], <span style="color:#e6db74">&#39;pes&#39;</span>: [], <span style="color:#e6db74">&#39;tes&#39;</span>: [], <span style="color:#e6db74">&#39;Ts&#39;</span>: []}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(args<span style="color:#f92672">.</span>input, <span style="color:#e6db74">&#39;r&#39;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> f:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> line<span style="color:#f92672">.</span>startswith(<span style="color:#e6db74">&#39;#&#39;</span>) <span style="color:#f92672">or</span> <span style="color:#f92672">not</span> line<span style="color:#f92672">.</span>strip():
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>            t, v_k, v_e, v_t, v_T <span style="color:#f92672">=</span> map(float, line<span style="color:#f92672">.</span>split())
</span></span><span style="display:flex;"><span>            data[<span style="color:#e6db74">&#39;ts&#39;</span>]<span style="color:#f92672">.</span>append(t)
</span></span><span style="display:flex;"><span>            data[<span style="color:#e6db74">&#39;kes&#39;</span>]<span style="color:#f92672">.</span>append(v_k)
</span></span><span style="display:flex;"><span>            data[<span style="color:#e6db74">&#39;pes&#39;</span>]<span style="color:#f92672">.</span>append(v_e)
</span></span><span style="display:flex;"><span>            data[<span style="color:#e6db74">&#39;tes&#39;</span>]<span style="color:#f92672">.</span>append(v_t)
</span></span><span style="display:flex;"><span>            data[<span style="color:#e6db74">&#39;Ts&#39;</span>]<span style="color:#f92672">.</span>append(v_T)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Skip initial equilibration</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> key <span style="color:#f92672">in</span> data:
</span></span><span style="display:flex;"><span>        data[key] <span style="color:#f92672">=</span> data[key][args<span style="color:#f92672">.</span>skip:]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Create subplots</span>
</span></span><span style="display:flex;"><span>    fig, axs <span style="color:#f92672">=</span> plt<span style="color:#f92672">.</span>subplots(<span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">2</span>, figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">16</span>, <span style="color:#ae81ff">12</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    plots <span style="color:#f92672">=</span> [(<span style="color:#e6db74">&#39;Kinetic Energy&#39;</span>, <span style="color:#e6db74">&#39;kes&#39;</span>), (<span style="color:#e6db74">&#39;Potential Energy&#39;</span>, <span style="color:#e6db74">&#39;pes&#39;</span>),
</span></span><span style="display:flex;"><span>             (<span style="color:#e6db74">&#39;Total Energy&#39;</span>, <span style="color:#e6db74">&#39;tes&#39;</span>), (<span style="color:#e6db74">&#39;Temperature&#39;</span>, <span style="color:#e6db74">&#39;Ts&#39;</span>)]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> ax, (title, key) <span style="color:#f92672">in</span> zip(axs<span style="color:#f92672">.</span>flat, plots):
</span></span><span style="display:flex;"><span>        ax<span style="color:#f92672">.</span>plot(data[<span style="color:#e6db74">&#39;ts&#39;</span>], data[key])
</span></span><span style="display:flex;"><span>        ax<span style="color:#f92672">.</span>set_xlabel(<span style="color:#e6db74">&#39;TimeStep&#39;</span>)
</span></span><span style="display:flex;"><span>        ax<span style="color:#f92672">.</span>set_ylabel(title)
</span></span><span style="display:flex;"><span>        ax<span style="color:#f92672">.</span>set_title(title)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    plt<span style="color:#f92672">.</span>tight_layout()
</span></span><span style="display:flex;"><span>    plt<span style="color:#f92672">.</span>savefig(args<span style="color:#f92672">.</span>output, dpi<span style="color:#f92672">=</span><span style="color:#ae81ff">300</span>, bbox_inches<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;tight&#39;</span>)
</span></span></code></pre></div><h3 id="trajectory-analysis-script">Trajectory Analysis Script</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Hunter Heidenreich, 2023</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Plots the coordinates of the adatom.</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> matplotlib.pyplot <span style="color:#66d9ef">as</span> plt
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> argparse <span style="color:#f92672">import</span> ArgumentParser
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#39;__main__&#39;</span>:
</span></span><span style="display:flex;"><span>    parser <span style="color:#f92672">=</span> ArgumentParser()
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#39;--input&#39;</span>, type<span style="color:#f92672">=</span>str, required<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#39;--output&#39;</span>, type<span style="color:#f92672">=</span>str, required<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#39;--id&#39;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">1665</span>,
</span></span><span style="display:flex;"><span>                       help<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;Atom ID to track (the adatom is the last created atom)&#39;</span>)
</span></span><span style="display:flex;"><span>    parser<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#39;--do_z&#39;</span>, action<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;store_true&#39;</span>,
</span></span><span style="display:flex;"><span>                       help<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;Plot z-coordinate instead of xy scatter&#39;</span>)
</span></span><span style="display:flex;"><span>    args <span style="color:#f92672">=</span> parser<span style="color:#f92672">.</span>parse_args()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    coords <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#39;x&#39;</span>: [], <span style="color:#e6db74">&#39;y&#39;</span>: [], <span style="color:#e6db74">&#39;z&#39;</span>: []}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(args<span style="color:#f92672">.</span>input, <span style="color:#e6db74">&#39;r&#39;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> f:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> line<span style="color:#f92672">.</span>startswith(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;</span><span style="color:#e6db74">{</span>args<span style="color:#f92672">.</span>id<span style="color:#e6db74">}</span><span style="color:#e6db74"> &#39;</span>):
</span></span><span style="display:flex;"><span>                x, y, z <span style="color:#f92672">=</span> map(float, line<span style="color:#f92672">.</span>split()[<span style="color:#ae81ff">2</span>:<span style="color:#ae81ff">5</span>])
</span></span><span style="display:flex;"><span>                coords[<span style="color:#e6db74">&#39;x&#39;</span>]<span style="color:#f92672">.</span>append(x)
</span></span><span style="display:flex;"><span>                coords[<span style="color:#e6db74">&#39;y&#39;</span>]<span style="color:#f92672">.</span>append(y)
</span></span><span style="display:flex;"><span>                coords[<span style="color:#e6db74">&#39;z&#39;</span>]<span style="color:#f92672">.</span>append(z)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    plt<span style="color:#f92672">.</span>figure(figsize<span style="color:#f92672">=</span>(<span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">8</span>))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>do_z:
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>plot(range(len(coords[<span style="color:#e6db74">&#39;z&#39;</span>])), coords[<span style="color:#e6db74">&#39;z&#39;</span>], <span style="color:#e6db74">&#39;b-&#39;</span>, linewidth<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>xlabel(<span style="color:#e6db74">&#39;Simulation Step&#39;</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>ylabel(<span style="color:#e6db74">&#39;Z Coordinate (Å)&#39;</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>title(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;Height vs. Time for Adatom </span><span style="color:#e6db74">{</span>args<span style="color:#f92672">.</span>id<span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>grid(<span style="color:#66d9ef">True</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.3</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>scatter(coords[<span style="color:#e6db74">&#39;x&#39;</span>], coords[<span style="color:#e6db74">&#39;y&#39;</span>], s<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.7</span>, c<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;red&#39;</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>xlabel(<span style="color:#e6db74">&#39;X Coordinate (Å)&#39;</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>ylabel(<span style="color:#e6db74">&#39;Y Coordinate (Å)&#39;</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>title(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;XY Trajectory for Adatom </span><span style="color:#e6db74">{</span>args<span style="color:#f92672">.</span>id<span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>axis(<span style="color:#e6db74">&#39;equal&#39;</span>)
</span></span><span style="display:flex;"><span>        plt<span style="color:#f92672">.</span>grid(<span style="color:#66d9ef">True</span>, alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.3</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    plt<span style="color:#f92672">.</span>savefig(args<span style="color:#f92672">.</span>output, dpi<span style="color:#f92672">=</span><span style="color:#ae81ff">300</span>, bbox_inches<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;tight&#39;</span>)
</span></span></code></pre></div><h2 id="summary-and-next-steps">Summary and Next Steps</h2>
<p>This tutorial demonstrates how molecular dynamics generates valuable ML training data for materials science. Adatom diffusion provides an ideal starting point because it:</p>
<ul>
<li><strong>Has interpretable physics</strong>: Well-understood mechanisms enable ML validation</li>
<li><strong>Shows diverse behaviors</strong>: Temperature-dependent dynamics create rich datasets</li>
<li><strong>Scales efficiently</strong>: Small systems allow extensive parameter exploration</li>
<li><strong>Connects to applications</strong>: Direct relevance to catalysis and surface engineering</li>
</ul>
<h3 id="whats-next">What&rsquo;s Next</h3>
<p>Future posts will extend this framework:</p>
<ol>
<li><strong>Mixed-metal surfaces</strong>: Alloy effects on diffusion pathways</li>
<li><strong>Stepped surfaces</strong>: How defects alter atomic mobility</li>
<li><strong>ML implementation</strong>: Training neural networks on simulation data</li>
</ol>
<h3 id="broader-applications">Broader Applications</h3>
<p>These simulation techniques enable various ML applications:</p>
<ul>
<li><strong>Neural network potentials</strong>: Replacing expensive quantum calculations with trained models</li>
<li><strong>Rare event sampling</strong>: ML-enhanced diffusion pathway identification</li>
<li><strong>Catalyst design</strong>: Predicting surface modification effects on reactivity</li>
<li><strong>Materials discovery</strong>: Screening alloy compositions for desired properties</li>
</ul>
<h3 id="getting-started">Getting Started</h3>
<p>To reproduce these simulations:</p>
<ol>
<li>Install LAMMPS with EAM potential support</li>
<li>Download Cu01.eam.alloy from the <a href="https://www.ctcms.nist.gov/potentials/entry/2001--Mishin-Y-Mehl-M-J-Papaconstantopoulos-D-A-et-al--Cu-1/">NIST repository</a> and place in your working directory</li>
<li>Save the LAMMPS script as <code>adatom_cu.lammps</code> and run:
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>lammps -in adatom_cu.lammps
</span></span></code></pre></div></li>
<li>Analyze the results with the Python scripts:
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>python plot_energy.py --input energy_avg.txt --output energy.png --skip <span style="color:#ae81ff">30</span>
</span></span><span style="display:flex;"><span>python plot_trajectory.py --input dump.lammpstrj --output trajectory_xy.png
</span></span><span style="display:flex;"><span>python plot_trajectory.py --input dump.lammpstrj --output trajectory_z.png --do_z
</span></span></code></pre></div></li>
<li>Visualize in Ovito by opening <code>dump.lammpstrj</code></li>
<li>Experiment with different temperatures, orientations, or elements</li>
</ol>
<hr>
<p>The full project, including the simulation architecture and automated analysis pipeline, is documented on the <a href="/projects/lammps-adatom-diffusion/">Automated Adatom Diffusion Workflow project page</a>.</p>
<p><em>Questions about the simulation setup or interested in applying these techniques to your research? Feel free to reach out. I&rsquo;m always happy to discuss molecular dynamics and machine learning applications.</em></p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://www.lammps.org/">LAMMPS</a></li>
<li><a href="https://www.ovito.org/">Ovito</a></li>
<li><a href="https://www.ctcms.nist.gov/potentials/">NIST Interatomic Potentials Repository</a></li>
<li><a href="https://doi.org/10.1103/PhysRevB.63.224106">Mishin et al.</a></li>
</ul>
]]></content:encoded></item><item><title>Generating Mini-Protein Trajectories with GROMACS</title><link>https://hunterheidenreich.com/posts/mini-proteins/</link><pubDate>Thu, 21 Sep 2023 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/mini-proteins/</guid><description>Systematic GROMACS workflows for simulating mini-proteins across multiple amino acids to generate diverse MD trajectories for ML applications.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>When developing machine learning models for protein dynamics, I needed training data, lots of it. Most researchers start with alanine dipeptide, a tiny two-amino-acid system that&rsquo;s become the &ldquo;hello world&rdquo; of protein simulation. It&rsquo;s small enough to simulate quickly but complex enough to show interesting folding behavior.</p>
<p>I wanted more diversity in my training data. Different amino acid side chains behave differently, and I was curious how this would affect model performance. So I extended the typical alanine dipeptide approach to include eight other amino acids, creating a small collection of &ldquo;mini-proteins&rdquo; for ML studies.</p>
<p>These dipeptides give a controlled testbed for studying how different chemical properties (aromatic rings, flexibility, branching) affect molecular dynamics, and for generating training data that varies those properties systematically.</p>
<h2 id="what-are-mini-proteins">What Are Mini-Proteins?</h2>
<p>In this context, &ldquo;mini-proteins&rdquo; are single amino acid residues capped with acetyl and N-methyl groups (Ace-X-Nme, where X is the amino acid). These systems act as the simplest possible models that still capture essential protein-like behavior.</p>
<p>These systems are popular in computational studies because they:</p>
<ul>
<li>Simulate quickly (seconds to minutes instead of hours)</li>
<li>Have well-characterized behavior for validation</li>
<li>Show enough complexity to be interesting</li>
<li>Can be systematically varied to study different chemical effects</li>
</ul>
<h2 id="getting-started">Getting Started</h2>
<p>The complete workflow and scripts are available on GitHub: <a href="https://github.com/hunter-heidenreich/mini-proteins/">mini-proteins</a>. The full project overview is on the <a href="/projects/mini-protein-trajectories/">Mini-Protein Trajectory Generation project page</a>.</p>
<h3 id="requirements">Requirements</h3>
<ul>
<li>Linux system with GROMACS installed</li>
<li>Python 3 with numpy and matplotlib</li>
<li>Basic familiarity with molecular dynamics concepts</li>
</ul>
<h3 id="quick-start">Quick Start</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git clone https://github.com/hunter-heidenreich/mini-proteins
</span></span><span style="display:flex;"><span>cd mini-proteins
</span></span><span style="display:flex;"><span>ID<span style="color:#f92672">=</span>ala sh scripts/run.sh
</span></span></code></pre></div><p>This runs the complete pipeline: energy minimization, solvation, equilibration, and production simulation. The default settings generate 1 ns of trajectory data saved every 100 fs. I chose high temporal resolution for my ML models, but you can adjust this in <code>config/md_langevin.mdp</code>.</p>
<p>For longer production runs (recommended for most applications), increase the simulation time to ~100 ns and reduce the save frequency to manage file sizes.</p>
<h2 id="the-collection">The Collection</h2>
<p>I&rsquo;ve included nine different amino acid dipeptides, each with distinct chemical properties:</p>
<p><strong>Flexible systems</strong>: Glycine (smallest side chain), Alanine (methyl group)</p>
<p><strong>Branched systems</strong>: Valine, Isoleucine, Leucine (different branching patterns)</p>
<p><strong>Aromatic systems</strong>: Phenylalanine, Tryptophan (different ring structures)</p>
<p><strong>Special cases</strong>: Proline (ring constraint), Methionine (sulfur chemistry)</p>
<p>This systematic set allows studying how different chemical features affect dynamics:</p>
<ul>
<li>Does the flexibility of glycine lead to more diverse conformational sampling?</li>
<li>How do aromatic rings in tryptophan affect folding pathways?</li>
<li>Does the ring constraint in proline create different energy landscapes?</li>
</ul>
<p>These fundamental questions provide systematic data to test ML models against known chemical intuition, building confidence in the approach.</p>
<p>Ideally, a neural network trained on this dataset should learn physical <em>invariances</em>. By training on both aliphatic (Val, Leu, Ile) and aromatic (Phe, Trp) systems, the model learns to focus entirely on how electron density (π-systems vs. σ-bonds) influences local potential energy surfaces.</p>
<h3 id="generating-ml-ready-trajectory-data">Generating ML-Ready Trajectory Data</h3>
<p>Generating raw coordinates is easy; generating <strong>ML-ready data</strong> requires specific configurations. Standard MD simulations compress trajectory files to save space, discarding high-frequency velocity and force data. To train Neural Network Potentials (NNPs), I configured the GROMACS pipeline differently.</p>
<p>The fastest way to generate trajectory data is using the <code>run.sh</code> script:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ID<span style="color:#f92672">=</span>ala sh scripts/run.sh
</span></span></code></pre></div><p>where <code>ID</code> is the three-letter amino acid code (here, <code>ala</code> for alanine).</p>
<p>This script performs energy minimization, solvation, neutralization, NVT equilibration, NPT equilibration, and production simulation. The resulting trajectory saves to the <code>out/ID/data</code> directory.</p>
<h4 id="why-this-pipeline-differs-from-standard-tutorials">Why This Pipeline Differs from Standard Tutorials</h4>
<p>A key deviation from standard tutorials is the use of <strong>Stochastic Dynamics (Langevin)</strong> as the integrator. This adds friction and noise terms to the equations of motion, ensuring correct thermodynamic sampling:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e">; config/md_langevin.mdp</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">integrator</span>  <span style="color:#f92672">=</span> <span style="color:#e6db74">sd        ; Stochastic dynamics (Langevin)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">dt</span>          <span style="color:#f92672">=</span> <span style="color:#e6db74">0.001     ; 1 fs timestep</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">nstxout</span>     <span style="color:#f92672">=</span> <span style="color:#e6db74">100       ; Save coordinates every 100 steps</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">nstvout</span>     <span style="color:#f92672">=</span> <span style="color:#e6db74">100       ; Save velocities every 100 steps</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">nstfout</span>     <span style="color:#f92672">=</span> <span style="color:#e6db74">100       ; Save forces every 100 steps</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">tc-grps</span>     <span style="color:#f92672">=</span> <span style="color:#e6db74">Protein Non-Protein</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">tau_t</span>       <span style="color:#f92672">=</span> <span style="color:#e6db74">0.1  0.1  ; Friction constant (ps)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ref_t</span>       <span style="color:#f92672">=</span> <span style="color:#e6db74">298  298  ; Reference temperature (K)</span>
</span></span></code></pre></div><p>The critical settings for ML applications:</p>
<ol>
<li><strong>Langevin Dynamics (<code>sd</code>)</strong>: Ensures proper canonical (NVT) sampling, providing a robust alternative to the velocity-rescaling thermostat often used in tutorials</li>
<li><strong>Uncompressed Force Output (<code>nstfout = 100</code>)</strong>: Writing to <code>.trr</code> format captures the precise atomic forces acting on every atom, essential for force-matching in NNP training</li>
<li><strong>High-Frequency Sampling (0.1 ps)</strong>: Saving frames every 100 fs captures fast bond vibrations often missed in standard 10 ps snapshots</li>
</ol>
<p><strong>Note</strong>: A production simulation currently runs for 1 nanosecond, saved every 0.1 picoseconds (100 fs). For most applications, increase this to 100 nanoseconds and adjust the save frequency to avoid large data files. I targeted 100 fs because I needed correlated time data for ML models; other applications may require a lower frequency.</p>
<p>You can also run each step individually (see <code>scripts/run.sh</code> for examples).</p>
<h2 id="the-systems">The Systems</h2>
<p>Here are the nine amino acid dipeptides I&rsquo;ve included, each chosen for different chemical properties:</p>
<h3 id="alanine-dipeptide-the-standard">Alanine Dipeptide: The Standard</h3>















<figure class="post-figure center ">
    <img src="/img/alanine-dipeptide-molecular-dynamics.webp"
         alt="Alanine dipeptide molecular dynamics simulation animation"
         title="Alanine dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Alanine Dipeptide</figcaption>
    
</figure>

<p>The classic starting point for protein folding studies. The small methyl side chain provides a simple yet challenging system.</p>
<h3 id="glycine-dipeptide-maximum-flexibility">Glycine Dipeptide: Maximum Flexibility</h3>















<figure class="post-figure center ">
    <img src="/img/glycine-dipeptide-molecular-dynamics.webp"
         alt="Glycine dipeptide molecular dynamics simulation animation"
         title="Glycine dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Glycine Dipeptide</figcaption>
    
</figure>

<p>No side chain means maximum backbone flexibility. Great for studying how constraints affect conformational sampling.</p>
<h3 id="proline-dipeptide-built-in-rigidity">Proline Dipeptide: Built-in Rigidity</h3>















<figure class="post-figure center ">
    <img src="/img/proline-dipeptide-molecular-dynamics.webp"
         alt="Proline dipeptide molecular dynamics simulation animation"
         title="Proline dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Proline Dipeptide</figcaption>
    
</figure>

<p>The ring structure creates backbone constraints. Interesting comparison to glycine&rsquo;s flexibility.</p>
<h3 id="aromatic-systems">Aromatic Systems</h3>















<figure class="post-figure center ">
    <img src="/img/phenylalanine-dipeptide-molecular-dynamics.webp"
         alt="Phenylalanine dipeptide molecular dynamics simulation animation"
         title="Phenylalanine dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Phenylalanine Dipeptide</figcaption>
    
</figure>

<p><strong>Phenylalanine</strong>: Simple benzene ring for studying aromatic interactions.</p>















<figure class="post-figure center ">
    <img src="/img/tryptophan-dipeptide-molecular-dynamics.webp"
         alt="Tryptophan dipeptide molecular dynamics simulation animation"
         title="Tryptophan dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Tryptophan Dipeptide</figcaption>
    
</figure>

<p><strong>Tryptophan</strong>: Larger indole ring system with more complex aromatic chemistry.</p>
<h3 id="branched-aliphatic-systems">Branched Aliphatic Systems</h3>















<figure class="post-figure center ">
    <img src="/img/valine-dipeptide-molecular-dynamics.webp"
         alt="Valine dipeptide molecular dynamics simulation animation"
         title="Valine dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Valine Dipeptide</figcaption>
    
</figure>

<p><strong>Valine</strong>: β-branched, creates steric constraints near the backbone.</p>















<figure class="post-figure center ">
    <img src="/img/isoleucine-dipeptide-molecular-dynamics.webp"
         alt="Isoleucine dipeptide molecular dynamics simulation animation"
         title="Isoleucine dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Isoleucine Dipeptide</figcaption>
    
</figure>

<p><strong>Isoleucine</strong>: γ-branched, different steric profile than valine.</p>















<figure class="post-figure center ">
    <img src="/img/leucine-dipeptide-molecular-dynamics.webp"
         alt="Leucine dipeptide molecular dynamics simulation animation"
         title="Leucine dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Leucine Dipeptide</figcaption>
    
</figure>

<p><strong>Leucine</strong>: Longer branched chain with more conformational freedom.</p>
<h3 id="special-chemistry">Special Chemistry</h3>















<figure class="post-figure center ">
    <img src="/img/methionine-dipeptide-molecular-dynamics.webp"
         alt="Methionine dipeptide molecular dynamics simulation animation"
         title="Methionine dipeptide molecular dynamics simulation animation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Methionine Dipeptide</figcaption>
    
</figure>

<p><strong>Methionine</strong>: Sulfur chemistry, different from the others and interesting for studying heteroatom effects.</p>
<h2 id="whats-next">What&rsquo;s Next?</h2>
<p>These mini-protein simulations have been useful for my ML work, providing systematic training data with controlled chemical variation. These simple systems have helped me understand how different amino acid properties affect molecular behavior, knowledge that&rsquo;s valuable when working with larger, more complex proteins.</p>
<p>The primary value of this pipeline lies in the <strong>force extraction</strong> workflow. Having atomic forces alongside coordinates enables training NNPs via force matching; force information is a richer training signal than energies alone. Tools like <a href="https://github.com/torchmd/torchmd-net">TorchMD-Net</a>, <a href="https://github.com/mir-group/nequip">NequIP</a>, and <a href="https://github.com/ACEsuit/mace">MACE</a> can directly consume this data format.</p>
<p>The scripts are designed to be easily modified for different amino acids or simulation conditions. I&rsquo;ve tried to make the workflow straightforward while keeping it flexible.</p>
<p>This work complements my other molecular dynamics projects:</p>
<ul>
<li><a href="/posts/adatom-cu-diffusion/">LAMMPS Tutorial: Copper and Platinum Adatom Diffusion</a>: Learning LAMMPS for surface simulations and extending to different elements</li>
</ul>
<p>Together, these projects have given me a solid foundation in MD simulations for generating ML training data across different molecular systems.</p>
<hr>
<p><em>Find the complete code and documentation on <a href="https://github.com/hunter-heidenreich/mini-proteins">GitHub</a>. Questions or suggestions? I&rsquo;d love to hear from you, especially if you&rsquo;ve found interesting ways to extend or improve the approach.</em></p>
<h2 id="acknowledgements">Acknowledgements</h2>
<p>The scripts build on the <a href="https://cbp-unitn.gitlab.io/qcb22-23/QCB/tutorial2_gromacs">GROMACS tutorial</a> by Luca Tubiana at the University of Trento.</p>
]]></content:encoded></item><item><title>5 Axes of Multi-Arm Bandit Problems: A Practical Guide</title><link>https://hunterheidenreich.com/posts/a-roadmap-to-multi-arm-bandit-algorithms/</link><pubDate>Tue, 10 Nov 2020 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/a-roadmap-to-multi-arm-bandit-algorithms/</guid><description>Explore 5 key dimensions of multi-arm bandit problems to help practitioners better navigate the exploration-exploitation tradeoff in ML applications.</description><content:encoded><![CDATA[<h2 id="what-is-a-multi-arm-bandit-problem">What is a Multi-Arm Bandit Problem?</h2>
<p>Multi-arm bandit problems are a fundamental class of sequential decision-making problems in machine learning. They&rsquo;re less complex than a full reinforcement learning problem, but they capture a lot of the essential challenges of learning from interaction.</p>
<p>The name comes from the analogy of a gambler facing multiple slot machines (or &ldquo;one-armed bandits&rdquo;) and trying to figure out which one pays out the most over time. Do you keep playing the machine that&rsquo;s paid out well so far, or try others to see if they&rsquo;re better? This is the exploration-exploitation dilemma at the heart of bandit algorithms.</p>
<p>Bandit algorithms solve this by learning the reward distributions for each arm over time, balancing the need to explore new options with exploiting what they&rsquo;ve learned.</p>















<figure class="post-figure center ">
    <img src="/img/multi-arm-bandits/multi-arm-bandit-conceptual-graphic.webp"
         alt="Illustration of a vintage slot machine with multiple arms, representing the multi-arm bandit problem in machine learning where algorithms must balance exploration and exploitation"
         title="Illustration of a vintage slot machine with multiple arms, representing the multi-arm bandit problem in machine learning where algorithms must balance exploration and exploitation"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Multi-arm bandit algorithms balance exploration and exploitation</figcaption>
    
</figure>

<p>What I&rsquo;ve found helpful is thinking about any bandit problem along five key dimensions. Asking these five questions helps quickly identify which approaches work best for a given problem:</p>
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 700px; margin: 30px auto; font-family: system-ui, -apple-system, sans-serif;">
  <div style="background-color: #5A9BD5; color: white; padding: 20px 24px; border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
    <h3 style="margin: 0 0 8px 0; font-size: 1.25rem; font-weight: 700; color: white;">1. Action Space</h3>
    <p style="margin: 0; font-size: 1.05rem; line-height: 1.5;"><em>What does the problem action space look like?</em> <br>Consider whether your options are finite vs. infinite, or single vs. combinatorial.</p>
  </div>
  <div style="background-color: #55C3BA; color: white; padding: 20px 24px; border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
    <h3 style="margin: 0 0 8px 0; font-size: 1.25rem; font-weight: 700; color: white;">2. Problem Structure</h3>
    <p style="margin: 0; font-size: 1.05rem; line-height: 1.5;"><em>Is there any structure to the problem?</em> <br>Determine whether choosing certain actions provides information about the expected rewards of other actions.</p>
  </div>
  <div style="background-color: #5CC382; color: white; padding: 20px 24px; border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
    <h3 style="margin: 0 0 8px 0; font-size: 1.25rem; font-weight: 700; color: white;">3. External Information</h3>
    <p style="margin: 0; font-size: 1.05rem; line-height: 1.5;"><em>Is there external information that my learner has access to?</em> <br>Assess the availability of contextual information (like user data or environment state) before an action is chosen.</p>
  </div>
  <div style="background-color: #56B14E; color: white; padding: 20px 24px; border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
    <h3 style="margin: 0 0 8px 0; font-size: 1.25rem; font-weight: 700; color: white;">4. Reward Mechanism</h3>
    <p style="margin: 0; font-size: 1.05rem; line-height: 1.5;"><em>How are rewards generated?</em> <br>Identify if the environment's payouts are stochastic (random but consistent), non-stationary (changing over time), or adversarial.</p>
  </div>
  <div style="background-color: #81B653; color: white; padding: 20px 24px; border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
    <h3 style="margin: 0 0 8px 0; font-size: 1.25rem; font-weight: 700; color: white;">5. Learner Feedback</h3>
    <p style="margin: 0; font-size: 1.05rem; line-height: 1.5;"><em>What kind of feedback does my learner receive each round?</em> <br>Clarify if the feedback loop is strict bandit (only seeing the reward for the chosen arm), full, partial, or semi-bandit feedback.</p>
  </div>
</div>
<p>Jump to <strong><a href="#real-examples">Real Examples</a></strong> to see these dimensions in practice.</p>
<hr>
<h2 id="1-what-can-you-do-action-space">1. What Can You Do? (Action Space)</h2>
<p>The first question is simple: what options do you have? Action spaces are generally categorized along two dimensions: size and complexity.</p>
<p><strong>Finite vs. Infinite (Size)</strong></p>
<ul>
<li><strong>Finite (Discretized):</strong> Most people think of this scenario where the agent chooses between a small number of discrete options. Examples include selecting an arm in a standard 3-arm bandit or testing 5 different website layouts.</li>
<li><strong>Infinite (Continuous):</strong> Sometimes your action has continuous parameters. For example, selecting a bid price anywhere in the range of (0, 1), or adjusting a recommendation algorithm&rsquo;s temperature parameter. This requires different mathematical approaches.</li>
</ul>
<p><strong>Single vs. Combinatorial (Complexity)</strong></p>
<ul>
<li><strong>Single Action:</strong> Selection of exactly 1 action per round (e.g., showing a user <em>one</em> specific ad).</li>
<li><strong>Combinatorial Actions:</strong> Selection of a vector (multiple) of actions simultaneously. For example, selecting a subset of edges in a graph to form a path from node $t$ to node $s$. If you are picking 10 movies to populate a Netflix homepage at once, those selections might interact with one another.</li>
</ul>
<h2 id="2-do-your-choices-tell-you-about-other-choices-problem-structure">2. Do Your Choices Tell You About Other Choices? (Problem Structure)</h2>
<p>This is often overlooked but crucial: does trying option A teach you anything about option B?</p>
<ul>
<li><strong>Independent (Unstructured):</strong> Information gained from one action provides <em>zero insight</em> into the expected reward of other actions. For example, knowing the open rate of an email campaign tells you nothing about the click-through rate of a separate social media post.</li>
<li><strong>Correlated (Structured):</strong> Information gained from one action provides <em>valuable hints</em> about similar actions. If you test a 10% discount and see high conversions, it strongly implies a 15% discount will also perform well, helping you map the underlying demand curve.</li>
</ul>
<h2 id="3-what-extra-information-do-you-have-context">3. What Extra Information Do You Have? (Context)</h2>
<p>Real-world problems rarely happen in isolation. The question is: what additional predictive information might help you make better decisions <em>before</em> you pull the lever?</p>
<p>When you incorporate these state variables, you move from a Standard Bandit to a <strong>Contextual Bandit</strong>. This data usually falls into two buckets:</p>
<ul>
<li><strong>User Context:</strong> State information tied directly to the individual, such as age, location, or past purchase history.</li>
<li><strong>Environmental Context:</strong> State information tied to the surrounding conditions at the exact moment of decision, such as time of day, seasonality, or device type (mobile vs. desktop).</li>
</ul>
<h2 id="4-how-do-rewards-work-reward-mechanism">4. How Do Rewards Work? (Reward Mechanism)</h2>
<p>This dimension is about understanding the nature of the feedback you&rsquo;re getting. Are the rewards predictable, changing over time, or actively working against you?</p>
<blockquote>
<p><strong>Stochastic (Stable but Random)</strong>
Each action corresponds to an IID (Independent and Identically Distributed) reward. The underlying mean rewards do not shift significantly over time.
<em>Example: Clinical trials. A drug has a true underlying effectiveness rate, but individual patients respond with random variation around that mean.</em></p>
</blockquote>
<blockquote>
<p><strong>Non-Stationary (Changing Over Time)</strong>
Reward distributions <em>do</em> shift over time, usually following some underlying rule. This is a realistic relaxation of the stochastic model, but comes at a learning cost.
<em>Example: Stock trading or ad performance. What worked last month might not work today.</em></p>
</blockquote>
<blockquote>
<p><strong>Adversarial (Actively Working Against You)</strong>
An adversary selects the worst-case rewards for your options, often with full knowledge of your learner&rsquo;s policy. Randomization is key to remaining unpredictable.
<em>Example: Cybersecurity defenses. Attackers actively adapt their strategies to exploit your algorithm.</em></p>
</blockquote>
<h2 id="5-how-much-do-you-learn-from-each-decision-feedback">5. How Much Do You Learn From Each Decision? (Feedback)</h2>
<p>The last dimension is about information flow. How much do you learn each time you make a choice?</p>
<table>
	<thead>
			<tr>
					<th style="text-align: left">Feedback Type</th>
					<th style="text-align: left">What You Learn</th>
					<th style="text-align: left">Real-World Example</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: left"><strong>Bandit</strong></td>
					<td style="text-align: left">You only observe the reward for the specific action selected. You have no knowledge of what could have been gained from other options.</td>
					<td style="text-align: left">A literal slot machine payout.</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Semi-Bandit</strong></td>
					<td style="text-align: left">Common in combinatorial settings. You see the individual rewards associated with each <em>sub-action</em> you took.</td>
					<td style="text-align: left">Learning exactly which specific edges of a graph &ldquo;dropped&rdquo; or succeeded in a path-finding algorithm.</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Full</strong></td>
					<td style="text-align: left">You see all reward signals for <em>every</em> action, including the ones you didn&rsquo;t take.</td>
					<td style="text-align: left">Analyzing historical stock market data where you can see all alternative outcomes.</td>
			</tr>
			<tr>
					<td style="text-align: left"><strong>Partial Monitoring</strong></td>
					<td style="text-align: left">Feedback is <em>not</em> received every round. You are occasionally flying blind.</td>
					<td style="text-align: left"><em>(Note: Because the core feedback loop is broken, this is generally not considered a true bandit problem!)</em></td>
			</tr>
	</tbody>
</table>
<h2 id="real-examples">Real Examples</h2>
<p>If you are trying to figure out which bandit algorithm to use for your project, it helps to map out your problem first. Here is how these five dimensions play out in three common real-world scenarios:</p>
<h3 id="1-e-commerce-recommendations">1. E-commerce Recommendations</h3>















<figure class="post-figure center ">
    <img src="/img/multi-arm-bandits/multi-arm-bandit-for-ecommerce.webp"
         alt="Illustration of a multi-arm bandit algorithm applied to e-commerce recommendations, showing a user interacting with a carousel of product recommendations on a website"
         title="Illustration of a multi-arm bandit algorithm applied to e-commerce recommendations, showing a user interacting with a carousel of product recommendations on a website"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The algorithm must populate an entire &lsquo;Recommended for You&rsquo; carousel with multiple items simultaneously.</figcaption>
    
</figure>

<ul>
<li><strong>Action Space:</strong> Combinatorial <em>(Pick multiple products to show at once)</em></li>
<li><strong>Problem Structure:</strong> Structured <em>(Similar products perform similarly)</em></li>
<li><strong>Context:</strong> Available <em>(User history, time of day, device type)</em></li>
<li><strong>Reward Mechanism:</strong> Stochastic <em>(Relatively stable underlying user preferences)</em></li>
<li><strong>Feedback:</strong> Bandit <em>(You only see clicks on the specific items you showed)</em></li>
</ul>
<h3 id="2-online-ad-bidding">2. Online Ad Bidding</h3>















<figure class="post-figure center ">
    <img src="/img/multi-arm-bandits/multi-arm-bandit-for-ad-bidding.webp"
         alt="Illustration of a multi-arm bandit algorithm applied to online ad bidding, showing a marketer adjusting bid prices in real-time auctions for ad placements"
         title="Illustration of a multi-arm bandit algorithm applied to online ad bidding, showing a marketer adjusting bid prices in real-time auctions for ad placements"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The algorithm must learn the optimal price to bid in real-time auctions.</figcaption>
    
</figure>

<ul>
<li><strong>Action Space:</strong> Infinite / Continuous <em>(Bid any amount from 0.01 to 10.00)</em></li>
<li><strong>Problem Structure:</strong> Structured <em>(Similar bid prices yield similar win rates)</em></li>
<li><strong>Context:</strong> Available <em>(User demographics, search terms, ad relevance)</em></li>
<li><strong>Reward Mechanism:</strong> Non-stationary <em>(Market conditions and competitor budgets change constantly)</em></li>
<li><strong>Feedback:</strong> Bandit <em>(You only see the results from your winning bids)</em></li>
</ul>
<h3 id="3-content-personalization">3. Content Personalization</h3>
<p><em>A media site dynamically selects articles or videos based on trending topics and user habits to create a personalized homepage.</em></p>
<ul>
<li><strong>Action Space:</strong> Combinatorial <em>(Select a layout of multiple articles/videos)</em></li>
<li><strong>Problem Structure:</strong> Structured <em>(Content categories and tags have predictable patterns)</em></li>
<li><strong>Context:</strong> Available <em>(User profile, current geographic trends, browsing history)</em></li>
<li><strong>Reward Mechanism:</strong> Non-stationary <em>(User interests and news cycles evolve over time)</em></li>
<li><strong>Feedback:</strong> Partial / Semi-Bandit <em>(You see the performance of the individual content pieces within the selected layout)</em></li>
</ul>
<h2 id="the-algorithm-selection-cheat-sheet">The Algorithm Selection Cheat Sheet</h2>
<p>If you prefer a text breakdown, here is how those steps translate into algorithm choices:</p>
<p><strong>Step 1: What are my options? (Action Space)</strong></p>
<ul>
<li><strong>Few discrete choices?</strong> $\rightarrow$ Start with standard <strong>UCB</strong> (Upper Confidence Bound) or <strong>Thompson Sampling</strong>.</li>
<li><strong>Continuous parameters?</strong> $\rightarrow$ Look into <strong>Gaussian Process</strong> methods.</li>
</ul>
<p><strong>Step 2: Do my choices relate to each other? (Structure)</strong></p>
<ul>
<li><strong>Yes?</strong> $\rightarrow$ Use <strong>Linear Bandits</strong> or kernel methods to share information across arms.</li>
<li><strong>No?</strong> $\rightarrow$ Treat each option completely independently.</li>
</ul>
<p><strong>Step 3: What extra information do I have? (Context)</strong></p>
<ul>
<li><strong>Rich context available?</strong> $\rightarrow$ Use contextual bandits (LinUCB is the standard choice here).</li>
<li><strong>No context?</strong> $\rightarrow$ Stick with standard, context-free approaches.</li>
</ul>
<p><strong>Step 4: How do rewards behave? (Mechanism)</strong></p>
<ul>
<li><strong>Stable?</strong> $\rightarrow$ <strong>UCB</strong> and <strong>Thompson Sampling</strong> are your go-to choices.</li>
<li><strong>Changing over time?</strong> $\rightarrow$ Add forgetting factors (discounting) or use sliding-window variants of standard algorithms.</li>
<li><strong>Actively working against me?</strong> $\rightarrow$ You need adversarial approaches, most notably the <strong>EXP3</strong> algorithm.</li>
</ul>
<p><strong>Step 5: How much do I learn each time? (Feedback)</strong></p>
<ul>
<li><strong>Just my choice?</strong> $\rightarrow$ Standard bandit algorithms.</li>
<li><strong>Everything?</strong> $\rightarrow$ Online gradient descent or multiplicative weights.</li>
<li><strong>Something in between?</strong> $\rightarrow$ Look for specialized algorithms that exploit your specific combinatorial structure.</li>
</ul>
<p>This framework keeps the focus exactly where it needs to be: on what actually matters for the problem at hand.</p>
<h2 id="summary">Summary</h2>
<p>These five questions have helped me navigate bandit problems more systematically:</p>
<ol>
<li><strong>What can you do?</strong> (Action space: few vs many options, single vs multiple choices)</li>
<li><strong>Do choices relate?</strong> (Whether trying one option teaches you about others)</li>
<li><strong>What extra info do you have?</strong> (Context that might improve decisions)</li>
<li><strong>How do rewards work?</strong> (Stable, changing, or adversarial)</li>
<li><strong>How much do you learn?</strong> (Feedback from just your choice vs everything)</li>
</ol>
<p>This framework helps avoid getting overwhelmed by the academic literature and focuses attention on what matters for real problems. Structured problems let you learn faster by sharing information between options, while adversarial settings require completely different approaches.</p>
<h2 id="if-you-want-to-learn-more">If You Want to Learn More</h2>
<ul>
<li><strong><a href="https://tor-lattimore.com/downloads/book/book.pdf">Bandit Algorithms by Lattimore &amp; Szepesvári</a></strong> - The comprehensive textbook (free PDF)</li>
<li><strong><a href="https://arxiv.org/abs/1904.07272">Introduction to Multi-Armed Bandits</a></strong> - Aleksandrs Slivkins&rsquo; survey paper is more accessible</li>
</ul>
<h2 id="get-your-hands-dirty">Get Your Hands Dirty</h2>
<p>Want to see these dimensions in code? I&rsquo;ve implemented the foundational algorithms (Explore-Then-Commit and Follow-The-Leader) in Python. Check out the <strong><a href="https://github.com/hunter-heidenreich/Bandit-Algorithms">Bandit Algorithms Repository</a></strong> to run the simulations yourself.</p>
]]></content:encoded></item><item><title>A Guide to Neuroevolution: NEAT and HyperNEAT</title><link>https://hunterheidenreich.com/posts/neuroevolution-neat-and-hyperneat/</link><pubDate>Wed, 02 Jan 2019 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/neuroevolution-neat-and-hyperneat/</guid><description>Explore the evolution of neural network topologies with NEAT and how HyperNEAT scales this approach using geometric patterns and indirect encoding.</description><content:encoded><![CDATA[<h2 id="automating-neural-architecture-design">Automating Neural Architecture Design</h2>
<p>Designing neural network architectures is typically a manual, iterative process. Researchers experiment with different layer configurations, activation functions, and connection patterns, often guided by intuition and empirical results. Evolution offers an automated alternative to this design process.</p>
<p><a href="https://nn.cs.utexas.edu/downloads/papers/stanley.ec02.pdf">NEAT (NeuroEvolution of Augmenting Topologies)</a>, introduced in 2002, optimizes network weights and evolves the network structure itself, starting from minimal topologies and growing complexity only when beneficial.</p>
<p>NEAT&rsquo;s core innovations solved fundamental problems that had plagued earlier attempts at topology evolution. Its solutions for genetic encoding, structural crossover, and innovation protection remain influential today, especially as neural architecture search and automated ML gain prominence.</p>
<h2 id="the-core-challenges-of-neat">The Core Challenges of NEAT</h2>
<p>Evolving neural network topologies presents several fundamental challenges that NEAT elegantly addressed. Understanding these problems helps explain why NEAT&rsquo;s solutions were so influential.</p>
<h3 id="genetic-encoding-how-to-represent-networks">Genetic Encoding: How to Represent Networks</h3>
<p>Evolutionary algorithms require a genetic representation, a way to encode individuals that enables meaningful selection, mutation, and crossover. For neural networks, this choice is critical.</p>
<p><strong>Direct encoding</strong> explicitly represents each network component. Genes directly correspond to nodes and connections. This approach is intuitive and readable, and it works well for smaller networks.</p>
<p><strong>Indirect encoding</strong> specifies construction rules or processes. These encodings are more compact and can generate highly complex structures from simple rules.</p>
<p>NEAT chose direct encoding with a simple two-part structure: separate gene lists for nodes and connections. This balances simplicity with the flexibility needed for evolutionary operations.</p>















<figure class="post-figure center ">
    <img src="/img/neat_genomes.webp"
         alt="NEAT genome encoding showing node genes and connection genes with innovation numbers"
         title="NEAT genome encoding showing node genes and connection genes with innovation numbers"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">NEAT&rsquo;s direct encoding: node genes (top) and connection genes (bottom) with historical markings</figcaption>
    
</figure>

<p>Connection genes specify the source and target nodes, weight, enabled status, and an innovation number for historical tracking. Input and output nodes are fixed; only hidden nodes evolve.</p>
<h3 id="structural-mutations-growing-complexity">Structural Mutations: Growing Complexity</h3>
<p>NEAT employs two categories of mutations to evolve both weights and structure:</p>
<p><strong>Weight mutations</strong> adjust existing connection strengths using standard perturbation methods, the familiar approach from traditional neuroevolution.</p>
<p><strong>Structural mutations</strong> add new network components:</p>
<ul>
<li><strong>Add connection</strong>: Creates a new link between existing nodes with a random initial weight</li>
<li><strong>Add node</strong>: Splits an existing connection by inserting a new node. The original connection is disabled, while two new connections replace it. One inherits the original weight, the other starts at 1.0</li>
</ul>
<p>This node-splitting approach minimizes disruption. The new node initially acts as an identity function, giving it time to prove useful before natural selection pressure intensifies.</p>
<h3 id="solving-the-competing-conventions-problem">Solving the Competing Conventions Problem</h3>
<p>Performing crossover between networks with different structures presents a fundamental challenge. Consider two networks that solve the same problem using different internal organizations. Naive crossover between them typically produces broken offspring.</p>















<figure class="post-figure center ">
    <img src="/img/competing_conventions.webp"
         alt="Two neural networks performing the same function but with different internal structures"
         title="Two neural networks performing the same function but with different internal structures"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">The competing conventions problem: identical functions, different implementations</figcaption>
    
</figure>

<p>NEAT&rsquo;s solution draws inspiration from biology through <strong>historical markings</strong>. Each structural innovation (adding a node or connection) receives a unique innovation number, a timestamp of when that change first appeared in the population.</p>
<p>During crossover, genes with matching innovation numbers are aligned and combined. This biological concept of homology enables meaningful recombination between networks of different sizes and structures.</p>















<figure class="post-figure center ">
    <img src="/img/neat_crossover.webp"
         alt="Diagram showing how NEAT aligns genes during crossover using innovation numbers"
         title="Diagram showing how NEAT aligns genes during crossover using innovation numbers"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">NEAT crossover using historical markings for gene alignment</figcaption>
    
</figure>

<h3 id="protecting-innovation-through-speciation">Protecting Innovation Through Speciation</h3>
<p>New structural innovations face a harsh reality: they usually perform worse initially. Adding nodes or connections typically decreases performance before optimization can improve the new structure. Without protection, these innovations disappear before realizing their potential.</p>
<p>NEAT addresses this through <strong>speciation</strong>: dividing the population into species based on structural and weight similarity. The historical markings that enable crossover also measure compatibility between individuals.</p>
<p>Crucially, individuals only compete within their species. This gives new structural innovations time to optimize without immediately competing against established, well-tuned networks.</p>
<p><strong>Explicit fitness sharing</strong> enhances this protection: species divide their collective fitness among members, preventing any single species from dominating the population while maintaining diversity for continued exploration.</p>
<h3 id="complexification-starting-minimal">Complexification: Starting Minimal</h3>
<p>NEAT begins with the simplest possible networks (just input and output nodes connected by random weights). No hidden layers exist initially. Complexity emerges only when mutations that add structure prove beneficial.</p>
<p>This complexification approach builds efficient solutions that solve problems with minimal structure. Combined with speciation, it tends to produce highly optimized architectures.</p>
<h2 id="scaling-up-hyperneat">Scaling Up: HyperNEAT</h2>
<p>NEAT evolved networks through direct encoding, where each gene explicitly specifies nodes and connections. Scaling this approach to larger architectures requires a fundamentally different method. Evolving networks with billions of connections like the brain requires indirect encoding.</p>
<p><a href="https://doi.org/10.1162/artl.2009.15.2.15202">HyperNEAT</a> introduces <strong>indirect encoding</strong> through geometric principles. HyperNEAT evolves geometric patterns that generate connections based on spatial relationships. This enables the evolution of large networks with biological regularities: symmetry, repetition, and locality.</p>
<p>The key insight is leveraging Compositional Pattern Producing Networks (CPPNs) to map coordinates to connection weights, exploiting the geometric organization found in natural neural networks.</p>
<h3 id="biological-motivation">Biological Motivation</h3>
<p>The human brain exhibits remarkable organizational principles:</p>
<ul>
<li><strong>Scale</strong>: ~86 billion neurons with ~100 trillion connections</li>
<li><strong>Repetition</strong>: Structural patterns reused across regions</li>
<li><strong>Symmetry</strong>: Mirrored structures like bilateral visual processing</li>
<li><strong>Locality</strong>: Spatial proximity influences connectivity and function</li>
</ul>
<p>HyperNEAT aims to evolve networks that capture these biological regularities, leading to more efficient and interpretable architectures.</p>
<h3 id="compositional-pattern-producing-networks">Compositional Pattern Producing Networks</h3>
<p>CPPNs are the foundation of HyperNEAT&rsquo;s indirect encoding. Think of them as pattern generators that create complex spatial structures from simple coordinate inputs.</p>
<p>DNA exemplifies indirect encoding (roughly 20,000 protein-coding genes specify a brain with trillions of connections). This massive compression ratio suggests that simple rules can generate complex structures through developmental processes.</p>
<p>CPPNs abstract this concept, using compositions of mathematical functions to create patterns in coordinate space. The same genetic program (function composition) can be reused across different locations and scales, just like how developmental genes control pattern formation throughout an organism.</p>















<figure class="post-figure center ">
    <img src="/img/hyperneat_cppns.webp"
         alt="Various symmetric and repetitive patterns created by CPPNs"
         title="Various symmetric and repetitive patterns created by CPPNs"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Complex patterns generated by CPPNs through function composition</figcaption>
    
</figure>

<h3 id="pattern-generation-through-function-composition">Pattern Generation Through Function Composition</h3>
<p>CPPNs generate patterns by composing simple mathematical functions. Key function types include:</p>
<ul>
<li><strong>Gaussian functions</strong>: Create symmetric patterns and gradients</li>
<li><strong>Trigonometric functions</strong>: Generate periodic/repetitive structures</li>
<li><strong>Linear functions</strong>: Produce gradients and asymmetric patterns</li>
<li><strong>Sigmoid functions</strong>: Create sharp transitions and boundaries</li>
</ul>
<p>By combining these functions, CPPNs can encode complex regularities that would require many explicit rules in direct encoding.</p>
<h3 id="evolution-of-cppns">Evolution of CPPNs</h3>
<p>HyperNEAT uses NEAT to evolve the CPPN structure. This brings several advantages:</p>
<ul>
<li><strong>Complexification</strong>: CPPNs start simple and grow more complex only when beneficial</li>
<li><strong>Historical markings</strong>: Enable proper crossover between different CPPN topologies</li>
<li><strong>Speciation</strong>: Protects innovative CPPN patterns during evolution</li>
</ul>
<p>Additional activation functions beyond standard neural networks are crucial:</p>
<ul>
<li>Gaussian functions for symmetry</li>
<li>Sine/cosine for repetition</li>
<li>Specialized functions for specific geometric patterns</li>
</ul>
<h2 id="the-hyperneat-process">The HyperNEAT Process</h2>
<h3 id="substrates-geometric-organization">Substrates: Geometric Organization</h3>
<p>A <strong>substrate</strong> defines the spatial arrangement of neurons. Substrates embed neurons in geometric space (2D grids, 3D volumes, etc.), providing an alternative to layer-based connectivity rules.</p>
<p>The CPPN maps from coordinates to connection weights:</p>
<p>$$\text{CPPN}(x_1, y_1, x_2, y_2) = w$$</p>
<p>Where $(x_1, y_1)$ and $(x_2, y_2)$ are the coordinates of two neurons, and $w$ determines their connection weight.</p>















<figure class="post-figure center ">
    <img src="/img/hyperneat_cppn_basics.webp"
         alt="Diagram showing CPPN taking four coordinate inputs and outputting connection weight"
         title="Diagram showing CPPN taking four coordinate inputs and outputting connection weight"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Basic CPPN architecture mapping coordinates to connection weights</figcaption>
    
</figure>

<p>This geometric approach enables several key properties:</p>
<ul>
<li><strong>Locality</strong>: Nearby neurons tend to have similar connectivity patterns</li>
<li><strong>Symmetry</strong>: Patterns can be mirrored across spatial axes</li>
<li><strong>Repetition</strong>: Periodic functions create repeating motifs</li>
<li><strong>Scalability</strong>: The same pattern can be applied at different resolutions</li>
</ul>
<h3 id="emergent-regularities">Emergent Regularities</h3>
<p>The geometric encoding naturally produces the desired biological patterns:</p>
<p><strong>Symmetry</strong> emerges from symmetric functions. A Gaussian centered at the origin creates identical patterns when $(x_1, y_1)$ and $(x_2, y_2)$ are equidistant from the center.</p>
<p><strong>Repetition</strong> arises from periodic functions like sine and cosine. These create repeating connectivity motifs across the substrate.</p>
<p><strong>Locality</strong> results from functions that vary smoothly across space. Nearby coordinates produce similar outputs, leading to local connectivity patterns.</p>
<p><strong>Imperfect regularity</strong> occurs when these patterns are modulated by additional coordinate dependencies, creating biological-like variation within the basic structure.</p>
<h3 id="substrate-configurations">Substrate Configurations</h3>
<p>The choice of substrate geometry critically influences network behavior. Several standard configurations exist:</p>















<figure class="post-figure center ">
    <img src="/img/hyperneat_substrate_configurations.webp"
         alt="Various substrate layouts including grids, 3D arrangements, and circular patterns"
         title="Various substrate layouts including grids, 3D arrangements, and circular patterns"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Common substrate geometries for different problem types</figcaption>
    
</figure>

<p><strong>2D Grid</strong>: Simple planar arrangement, CPPN takes four coordinates $(x_1, y_1, x_2, y_2)$</p>
<p><strong>3D Volume</strong>: Extends to three dimensions, CPPN becomes six-dimensional $(x_1, y_1, z_1, x_2, y_2, z_2)$</p>
<p><strong>Sandwich</strong>: Input layer connects only to output layer, useful for sensory-motor tasks</p>
<p><strong>Circular</strong>: Radial geometry enables rotation-invariant patterns and cyclic behaviors</p>
<p>The substrate must be chosen before evolution begins, making domain knowledge important for success.</p>
<h3 id="exploiting-input-output-geometry">Exploiting Input-Output Geometry</h3>
<p>HyperNEAT exploits the spatial organization of inputs and outputs. For visual tasks, pixel coordinates provide meaningful geometric information. For control problems, sensor and actuator layouts can guide connectivity patterns.</p>















<figure class="post-figure center ">
    <img src="/img/hyperneat_inputs_outputs.webp"
         alt="Visual representation of how HyperNEAT maps spatial input arrangements to output patterns"
         title="Visual representation of how HyperNEAT maps spatial input arrangements to output patterns"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Spatial organization of inputs and outputs enables geometric exploitation</figcaption>
    
</figure>

<p>This spatial awareness allows HyperNEAT to:</p>
<ul>
<li>Develop receptive fields similar to biological vision systems</li>
<li>Create locally connected patterns for spatial processing</li>
<li>Generate symmetric motor control patterns</li>
<li>Scale across different input resolutions</li>
</ul>
<h3 id="resolution-independence">Resolution Independence</h3>
<p>A unique advantage of HyperNEAT is <strong>substrate resolution independence</strong>. Networks evolved on low-resolution substrates can be deployed on higher-resolution versions without retraining. The CPPN&rsquo;s coordinate-based mapping scales naturally across different granularities.</p>
<p>This property suggests that evolved patterns capture fundamental spatial relationships, providing a key insight for scalable neural architecture design.</p>
<h2 id="impact-and-future-directions">Impact and Future Directions</h2>
<p>NEAT and HyperNEAT demonstrated that evolution could design neural network topologies and scale them through indirect encoding. The algorithms&rsquo; key insights, exploiting geometry, generating patterns through function composition, and scaling across resolutions, continue to influence modern research.</p>
<p>Extensions like ES-HyperNEAT add even more sophisticated capabilities by evolving the substrate itself. As neural architecture search becomes increasingly important, these principles find new applications in hybrid approaches that combine evolutionary pattern generation with gradient-based optimization.</p>
<p>The emphasis on spatial organization and regularity also connects to contemporary work on geometric deep learning and equivariant networks, suggesting that evolution and hand-design converge on similar organizing principles for building structured, efficient neural architectures.</p>
]]></content:encoded></item><item><title>Breaking Down Machine Learning for the Average Person</title><link>https://hunterheidenreich.com/posts/breaking-down-ml-for-the-average-person/</link><pubDate>Tue, 04 Dec 2018 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/breaking-down-ml-for-the-average-person/</guid><description>Discover how machine learning actually works through three fundamental approaches, explained with everyday examples you already know and use.</description><content:encoded><![CDATA[<h2 id="machine-learning">Machine Learning</h2>
<p>Machine learning is about teaching computer programs to improve at tasks through experience. We show algorithms examples and let them discover patterns in data.</p>
<p>There are three main approaches to machine learning: supervised learning, unsupervised learning, and reinforcement learning. Each works differently and suits different types of problems.</p>















<figure class="post-figure center ">
    <img src="/img/types-of-machine-learning.webp"
         alt="Diagram showing the three main types of machine learning: supervised, unsupervised, and reinforcement learning"
         title="Diagram showing the three main types of machine learning: supervised, unsupervised, and reinforcement learning"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Three fundamental approaches to machine learning, each suited to different types of problems and data</figcaption>
    
</figure>

<p>Each type addresses different kinds of problems and works with different data requirements.</p>
<h3 id="supervised-learning">Supervised Learning</h3>
<p>Supervised learning works like teaching with examples and answers. You show the algorithm many input-output pairs, and it learns to predict outputs for new inputs it hasn&rsquo;t seen before.</p>
<p>The algorithm learns by comparing its predictions to the correct answers. Over time, it gets better at finding patterns that connect inputs to outputs. Once trained, it can make predictions on new data.</p>
<p>Common examples you encounter:</p>
<ul>
<li><strong>Email Spam Filtering</strong>: Email systems learn to identify spam by training on thousands of emails labeled as spam or legitimate.</li>
<li><strong>Advertisement Targeting</strong>: Algorithms predict which ads you might click based on your browsing history and demographics.</li>
<li><strong>Face Recognition</strong>: Social media platforms use tagged photos to learn who appears in new images.</li>
</ul>
<h3 id="unsupervised-learning">Unsupervised Learning</h3>
<p>Unsupervised learning works without correct answers. Instead, algorithms analyze data to find patterns, group similar items, or discover structure that wasn&rsquo;t obvious before.</p>
<p>This approach is useful because most real-world data doesn&rsquo;t come with labels. Unsupervised algorithms can process large amounts of data to find patterns that might not be obvious to humans.</p>
<p>Examples include:</p>
<ul>
<li><strong>Recommendation Systems</strong>: Netflix and YouTube analyze viewing patterns to suggest content, even without explicit ratings.</li>
<li><strong>Customer Segmentation</strong>: Companies group customers by purchasing behavior for targeted marketing.</li>
<li><strong>Problem Identification</strong>: Tech companies automatically group similar bug reports to identify common issues.</li>
</ul>
<h3 id="reinforcement-learning">Reinforcement Learning</h3>
<p>Reinforcement learning works through trial and error. An algorithm tries different actions in an environment and learns from the consequences, getting rewards for good choices and penalties for poor ones.</p>
<p>This mirrors how many animals learn: through consequences. Good behavior gets rewards, bad behavior gets correction.</p>
<p>Consider an algorithm learning to play Mario:</p>
<ul>
<li><strong>Agent</strong>: The learning algorithm</li>
<li><strong>Environment</strong>: The game world</li>
<li><strong>Actions</strong>: Controller inputs (jump, run, etc.)</li>
<li><strong>State</strong>: Current game screen</li>
<li><strong>Reward</strong>: Points gained or lost</li>
</ul>
<p>The algorithm tries different button combinations, sees what happens, and gradually learns strategies that lead to higher scores.</p>
<p>Real applications include:</p>
<ul>
<li><strong>Game AI</strong>: AlphaGo and similar systems learned to play complex games through self-play.</li>
<li><strong>Robotics</strong>: Factory robots learn assembly processes through trial and error in simulated environments.</li>
<li><strong>Resource Management</strong>: Google uses reinforcement learning to manage data center cooling, reducing energy costs.</li>
</ul>
<h3 id="putting-it-together">Putting It Together</h3>
<p>In practice, these approaches often work together. Many real systems combine different learning methods depending on the problem and available data.</p>
<p>For example:</p>
<ul>
<li>A game AI might use supervised learning to recognize objects and reinforcement learning for strategy</li>
<li>A language model might learn word relationships without supervision, then improve with supervised training</li>
<li>A recommendation system could group users without labels, then use supervised learning to predict preferences</li>
</ul>
<p>These three approaches cover most machine learning applications. Understanding them helps explain how the AI systems we use daily actually work, whether it&rsquo;s email filters, recommendation engines, or game-playing algorithms.</p>
<p>Machine learning is pattern recognition and learning from data, not magic. The more we understand these basics, the better we can work with and build these systems.</p>
]]></content:encoded></item><item><title>Foundations of AI: Knowledge-Based Agents and Logic</title><link>https://hunterheidenreich.com/posts/knowledge-based-agents-and-logic/</link><pubDate>Sat, 01 Dec 2018 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/knowledge-based-agents-and-logic/</guid><description>A look back at classic symbolic AI: how knowledge-based agents use logic, reasoning, and inference to build intelligent behavior.</description><content:encoded><![CDATA[<p><em>Note from 2026: I originally wrote these notes in 2018 while studying for my undergraduate AI final. They cover classic symbolic AI (often called Good Old-Fashioned AI, or GOFAI). Looking back from the current era of Large Language Models (LLMs) and Vision-Language Models (VLMs), it is fascinating to see how these foundational concepts have evolved. The classic &ldquo;knowledge base&rdquo; and &ldquo;Ask/Tell&rdquo; operations conceptually mirror modern Retrieval-Augmented Generation (RAG) systems, and the classic problem of logical &ldquo;grounding&rdquo; is exactly what we tackle today with multimodal visual grounding in models like GutenOCR. I have preserved and merged these notes here as a time capsule of my learning journey.</em></p>
<hr>
<h2 id="the-evolution-of-knowledge-based-systems">The Evolution of Knowledge-Based Systems</h2>
<p>Early AI research focused heavily on knowledge bases and agents that could interact with them, leading to <strong>expert systems</strong>. These systems relied on central knowledge bases to make decisions through &ldquo;if-then&rdquo; reasoning patterns. While some consider expert systems the first major AI breakthrough, others debate whether they truly belong in the AI category.</p>
<p>Knowledge-based agents remain relevant in modern AI. If you&rsquo;ve worked in natural language processing, you&rsquo;ve likely encountered knowledge bases like WordNet. Wikipedia represents another massive knowledge base, encoding semantic relationships between countless entities.</p>
<p>This abundance of knowledge bases raises important questions: How do we build agents that effectively interface with these repositories? How can they update knowledge bases and make decisions based on stored information?</p>
<p>This article explores these questions through a practical introduction to knowledge-based agents, knowledge representation, and logic.</p>
<h2 id="anatomy-of-a-knowledge-based-agent">Anatomy of a Knowledge-Based Agent</h2>
<p>An <strong>agent</strong> is any entity that acts within an environment. In AI, we build <strong>rational agents</strong>. Entities that act sensibly within their environment. In well-understood environments, rational agents choose actions that yield desired outcomes. In uncertain environments, they act to maximize expected positive outcomes.</p>
<p>Consider agents that maintain internal knowledge, reason over that knowledge, and update their understanding through observations and actions. This is the foundation of <strong>knowledge-based agents</strong>.</p>
<h3 id="knowledge-bases-the-foundation">Knowledge Bases: The Foundation</h3>
<p>The core component of any knowledge-based agent is its <strong>knowledge base</strong> (KB): the repository of what the agent knows about the world.</p>
<p>Every KB consists of <strong>sentences</strong>. These are statements written in a <strong>knowledge representation language</strong>. These specialized languages express assertions about the world in formats that enable systematic reasoning.</p>
<p>Knowledge representation languages are designed for systematic representation of world knowledge. This is why we use these specialized languages for agent knowledge bases.</p>
<h4 id="types-of-knowledge-in-a-kb">Types of Knowledge in a KB</h4>
<p><strong>Axioms</strong> are foundational sentences assumed to be true without derivation from other KB content. If you&rsquo;re familiar with mathematics, this concept will feel natural.</p>
<p><strong>Inferred sentences</strong> are derived from existing KB content through logical reasoning. These are systematically derived using reasoning rules that may be built into the knowledge representation language itself.</p>
<h3 id="core-agent-operations">Core Agent Operations</h3>
<p>Knowledge-based agents interact with their KBs through two fundamental operations: <strong>Ask</strong> and <strong>Tell</strong>.</p>
<h4 id="ask-querying-knowledge">Ask: Querying Knowledge</h4>
<p><strong>Ask</strong> is how agents extract information from their KB. When an agent asks a &ldquo;question&rdquo; or queries its KB, it must format the request in the KB&rsquo;s expected format (typically in the knowledge representation language).</p>
<p>The KB responds with sentences that are either:</p>
<ul>
<li>Directly stored in the KB, or</li>
<li>Inferred from existing KB information</li>
</ul>
<p>This guarantees that responses never contradict the KB&rsquo;s knowledge.</p>
<h4 id="tell-adding-knowledge">Tell: Adding Knowledge</h4>
<p><strong>Tell</strong> updates the KB with new information. Agents use this when they:</p>
<ul>
<li>Observe environmental changes</li>
<li>Update the KB with planned or completed actions</li>
<li>Add newly learned facts</li>
</ul>
<p>Like Ask operations, new information must be properly formatted. The KB stores the new sentence and may trigger reasoning and inference processes to derive additional conclusions.</p>
<h3 id="a-generic-knowledge-based-agent-architecture">A Generic Knowledge-Based Agent Architecture</h3>
<p>Here&rsquo;s a high-level view of how a knowledge-based agent operates:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">kb_agent</span>(percept):
</span></span><span style="display:flex;"><span>    Tell(KB, make_percept_sentence(percept, t))
</span></span><span style="display:flex;"><span>    action <span style="color:#f92672">=</span> Ask(KB, make_action_query(t))
</span></span><span style="display:flex;"><span>    Tell(KB, make_action_sentence(action, t))
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> t <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> action
</span></span></code></pre></div><p>This function illustrates the agent&rsquo;s reasoning cycle:</p>
<ol>
<li><strong>Perceive</strong>: Convert environmental observations into KB-compatible sentences via <code>make_percept_sentence()</code></li>
<li><strong>Reason</strong>: Query the KB for the appropriate action using <code>make_action_query()</code></li>
<li><strong>Act</strong>: Record the chosen action in the KB through <code>make_action_sentence()</code></li>
<li><strong>Update</strong>: Increment the time step and return the action</li>
</ol>
<p>The helper functions handle the crucial task of translating between the external world and the knowledge representation language. This architecture focuses purely on the reasoning process, the &ldquo;brains&rdquo; of the operation, while abstracting away perception and action execution details.</p>
<h2 id="design-perspectives-for-knowledge-based-agents">Design Perspectives for Knowledge-Based Agents</h2>
<p>Knowledge-based agents can be analyzed and designed from three distinct levels, each addressing different aspects of the system.</p>
<h3 id="knowledge-level-the-strategic-view">Knowledge Level: The Strategic View</h3>
<p>The <strong>knowledge level</strong> represents the highest level of analysis, focusing on:</p>
<ul>
<li><strong>Goals</strong>: What objectives does the agent pursue?</li>
<li><strong>Knowledge scope</strong>: How much does the agent know about its world initially?</li>
</ul>
<p>This level helps us understand the agent&rsquo;s capabilities and limitations from a strategic perspective, independent of implementation details.</p>
<h3 id="logical-level-the-representation-view">Logical Level: The Representation View</h3>
<p>The <strong>logical level</strong> examines how knowledge is represented and reasoned about:</p>
<ul>
<li><strong>Knowledge representation language</strong>: Which language best suits our domain?</li>
<li><strong>Logical framework</strong>: Are we using propositional logic, first-order logic, or something else?</li>
</ul>
<p>Each choice carries trade-offs. Some languages excel at expressing certain types of knowledge but struggle with others. The logical framework determines what kinds of reasoning the agent can perform.</p>
<h3 id="implementation-level-the-technical-view">Implementation Level: The Technical View</h3>
<p>The <strong>implementation level</strong> addresses the concrete technical decisions:</p>
<ul>
<li><strong>Data structures</strong>: How is knowledge stored? (structs, databases, objects, vectors?)</li>
<li><strong>Algorithms</strong>: Which inference procedures are used?</li>
<li><strong>Performance</strong>: How do design choices affect speed and memory usage?</li>
</ul>
<p>These decisions significantly impact the agent&rsquo;s practical performance and scalability.</p>
<h2 id="learning-and-knowledge-acquisition">Learning and Knowledge Acquisition</h2>
<h3 id="declarative-vs-procedural-approaches">Declarative vs. Procedural Approaches</h3>
<p>Knowledge-based agents can be constructed through two primary approaches:</p>
<p><strong>Declarative approach</strong>: Initialize the agent with an empty KB, then systematically Tell it all the knowledge it needs. This explicit knowledge encoding offers transparency and modularity.</p>
<p><strong>Procedural approach</strong>: Write programs that encode knowledge directly into the agent&rsquo;s behavior. This approach can be more efficient but less transparent.</p>
<p>Real-world systems typically benefit from combining both approaches, leveraging the strengths of each method.</p>
<h3 id="incorporating-learning">Incorporating Learning</h3>
<p>Learning enhances knowledge-based agents in several ways:</p>
<p><strong>Perceptual learning</strong>: Agents can learn to combine observations in novel ways, creating new sentences that improve goal achievement. These learned patterns become part of the KB for future reasoning.</p>
<p><strong>Inference optimization</strong>: Learning algorithms can identify efficient reasoning paths within existing KBs, speeding up the inference process.</p>
<p><strong>Knowledge base expansion</strong>: Research continues into generating new connections in existing KBs like WordNet. As semantic webs proliferate online, efficient methods for KB expansion become increasingly valuable.</p>
<p>The integration of learning with knowledge-based reasoning represents an active area of AI research, with promising applications in knowledge graph completion, automated reasoning, and intelligent system adaptation.</p>
<h2 id="the-components-of-logic">The Components of Logic</h2>
<p>Every logical system has three components that create a framework for representing and reasoning about knowledge.</p>
<h3 id="syntax-the-rules-of-formation">Syntax: The Rules of Formation</h3>
<p>Syntax defines how sentences are constructed, the rules that determine if a sentence is well-formed.</p>
<p>Consider English: &ldquo;name Hunter mine is&rdquo; violates syntax rules, while &ldquo;my name is Hunter&rdquo; follows them. In mathematics, &ldquo;1+=2 3&rdquo; breaks syntax, but &ldquo;1+2=3&rdquo; follows it.</p>
<p>Knowledge bases need proper syntax because they&rsquo;re built from collections of sentences.</p>
<h3 id="semantics-the-meaning-behind-sentences">Semantics: The Meaning Behind Sentences</h3>
<p>While syntax governs structure, semantics determines meaning, whether a sentence is true or false in a given context.</p>
<p>The sentence &ldquo;my name is Hunter&rdquo; is true in our context, but &ldquo;my name is Paul&rdquo; would be false. In math, &ldquo;x=5&rdquo; is true only when x actually equals 5.</p>
<p>We use <strong>model</strong> to describe a specific world state where variables have particular values. Different models let us evaluate sentence truth. One model might have x=4, another x=5.</p>
<p>When a sentence is true under a model, the model <strong>satisfies</strong> the sentence. If model₅ has x=5, then model₅ satisfies <code>x=5</code>.</p>
<p>For any sentence A, $M(A)$ represents all models that satisfy A.</p>
<h3 id="entailment-logical-relationships">Entailment: Logical Relationships</h3>
<p><strong>Entailment</strong> enables reasoning. When sentence A entails sentence B (written $A \models B$), B must be true whenever A is true.</p>
<p>More precisely, A entails B if B is true in every model where A is true:</p>
<ul>
<li>A is the <strong>premise</strong></li>
<li>B is the <strong>consequent</strong></li>
<li>B is a necessary consequence of A</li>
</ul>
<p><strong>Example:</strong> <code>x=1</code> entails <code>xy=y</code>. If x equals 1, then xy will always equal y, regardless of y&rsquo;s value.</p>
<p>This helps knowledge bases reason. When we&rsquo;re uncertain about <code>xy=y</code> but add <code>x=1</code> to our KB, entailment lets us assert that <code>xy=y</code> is now true.</p>
<p>To check if a KB entails sentence A, we verify that $M(KB) \subseteq M(A)$, every model satisfying our KB also satisfies A.</p>
<p>When we add sentences to our KB, we need systematic ways to discover newly entailed sentences. <strong>Inference algorithms</strong> do this work.</p>
<h2 id="inference-algorithms-deriving-new-knowledge">Inference Algorithms: Deriving New Knowledge</h2>
<p>Inference algorithms systematically derive new sentences entailed by our knowledge base. These algorithms need two properties to be useful and trustworthy.</p>
<h3 id="soundness-truth-preservation">Soundness: Truth Preservation</h3>
<p>An inference algorithm is <strong>sound</strong> if it only derives sentences actually entailed by the KB. If an algorithm adds unjustified sentences, it is fabricating information, undermining the KB&rsquo;s reliability.</p>
<h3 id="completeness-finding-everything">Completeness: Finding Everything</h3>
<p>A <strong>complete</strong> inference algorithm finds all sentences entailed by the KB, missing no valid conclusions. This ensures we don&rsquo;t overlook important logical consequences.</p>
<p>Achieving both soundness and completeness grows challenging as environments become more complex. Small, bounded models make this manageable. Unbounded environments require sophisticated algorithms that guarantee both properties while maintaining reasonable performance.</p>
<h3 id="grounding-connecting-logic-to-reality">Grounding: Connecting Logic to Reality</h3>
<p>Beyond formal algorithm properties lies a practical concern: <strong>grounding</strong>. Is our knowledge base actually grounded in reality? Does it accurately represent what&rsquo;s true?</p>
<p>Grounding depends on how knowledge enters the system:</p>
<ul>
<li><strong>Sensors</strong>: Agent perceptions are only as accurate as their sensors</li>
<li><strong>Learning algorithms</strong>: Learned sentences are only as reliable as the learning process</li>
</ul>
<p>With accurate sensors and reliable learning, we can trust our KB&rsquo;s grounding. This remains important when deploying knowledge-based systems in real applications.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Logic provides the foundation for knowledge representation in AI systems. Understanding syntax (forming valid sentences), semantics (sentence meaning), entailment (how conclusions follow from premises), and inference algorithms (deriving new knowledge) enables building knowledge-based agents.</p>
<p>The interplay between these components, governed by soundness, completeness, and grounding, determines how effectively our AI systems represent and reason about the world.</p>
]]></content:encoded></item><item><title>QuAC: Question Answering in Context Dataset</title><link>https://hunterheidenreich.com/posts/quac-question-answering-in-context/</link><pubDate>Wed, 31 Oct 2018 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/quac-question-answering-in-context/</guid><description>Analysis of QuAC's conversational QA through student-teacher interactions, featuring ~100K context-dependent questions and coreference challenges.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>The <a href="https://aclanthology.org/D18-1241/">QuAC dataset</a> (Question Answering in Context) presents a conversational question answering approach that models student-teacher interactions. Published at EMNLP 2018, this work by Choi et al. addresses how systems can understand dialogue context, resolve references across conversation turns, and handle natural conversation ambiguity. Previous datasets treated questions independently.</p>
<p>The dataset addresses limitations in question answering research by incorporating real-world information-seeking dialogue complexities, where questions build upon previous exchanges and context drives understanding.</p>
<p>For comparison with related work, see my analysis of <a href="/posts/coqa-conversation-question-answering/">CoQA</a>.</p>
<h2 id="the-student-teacher-framework">The Student-Teacher Framework</h2>
<p>QuAC models information-seeking dialogue through a student-teacher setup:</p>
<ul>
<li><strong>Teacher</strong>: Has complete access to information (Wikipedia passage)</li>
<li><strong>Student</strong>: Seeks knowledge through questioning with limited initial context</li>
<li><strong>Interaction</strong>: Handles context-dependent questions, abstract inquiries, and unanswerable requests</li>
</ul>
<p>This framework mirrors real-world scenarios where one party has expertise while another seeks to learn through dialogue. AI systems must act as effective teachers, using available information to provide helpful responses despite ambiguous or incomplete questions.</p>
<p>The dataset contains roughly 100K questions across ~14K dialogues (precisely 98,407 questions and 13,594 dialogues), providing substantial scale for training and evaluation.</p>















<figure class="post-figure center ">
    <img src="/img/quac_stats.webp"
         alt="QuAC dataset statistics and scale"
         title="QuAC dataset statistics and scale"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">QuAC dataset statistics and scale</figcaption>
    
</figure>

<h2 id="dataset-construction">Dataset Construction</h2>
<p>QuAC was built using Amazon Mechanical Turk with a two-person dialogue setup:</p>
<p><strong>Teacher role</strong>: Has access to the complete Wikipedia passage and provides answers extracted directly from the text</p>
<p><strong>Student role</strong>: Sees only the article title, introduction paragraph, and section heading, then asks questions to learn about the content</p>
<p>This asymmetric information design ensures student questions naturally differ from the passage content, creating realistic information-seeking scenarios. The extractive answer requirement maintains objective evaluation while simplifying scoring.</p>
<p><strong>Dialogue termination</strong>:</p>
<ul>
<li>12 questions answered</li>
<li>Manual termination by either participant</li>
<li>Two consecutive unanswerable questions</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/quac_convo.webp"
         alt="Example QuAC conversation showing student-teacher interaction"
         title="Example QuAC conversation showing student-teacher interaction"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Example QuAC conversation showing student-teacher interaction</figcaption>
    
</figure>

<h3 id="content-selection">Content Selection</h3>
<p>QuAC focuses on Wikipedia biographical articles for several practical reasons:</p>
<ul>
<li><strong>Reduced complexity</strong>: People-focused content requires less specialized domain knowledge</li>
<li><strong>Natural question flow</strong>: Biographical information lends itself to sequential questioning</li>
<li><strong>Quality control</strong>: Articles filtered to include only subjects with 100+ incoming links, ensuring content depth</li>
</ul>
<p>This focused scope enables consistent evaluation while maintaining broad coverage through diverse biographical subjects across fields and time periods.</p>
<h2 id="key-dataset-characteristics">Key Dataset Characteristics</h2>
<p>QuAC introduces several features that distinguish it from existing question answering benchmarks:</p>















<figure class="post-figure center ">
    <img src="/img/quac_comparison.webp"
         alt="Comparative analysis of QuAC against other QA datasets"
         title="Comparative analysis of QuAC against other QA datasets"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Comparative analysis of QuAC against other QA datasets</figcaption>
    
</figure>

<p><strong>Notable features</strong>:</p>
<ul>
<li><strong>High contextual dependency</strong>: a large majority of questions depend on the conversation context, and a substantial share require coreference resolution</li>
<li><strong>Non-factoid focus</strong>: 54% of questions go beyond simple fact retrieval</li>
<li><strong>Extended answers</strong>: Responses are longer and more detailed</li>
<li><strong>Unanswerable questions</strong>: Realistic scenarios where information isn&rsquo;t available</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/quac_dist.webp"
         alt="Distribution of question types in QuAC"
         title="Distribution of question types in QuAC"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Distribution of question types in QuAC</figcaption>
    
</figure>

<h3 id="the-coreference-resolution-challenge">The Coreference Resolution Challenge</h3>
<p>QuAC&rsquo;s complexity stems from its heavy reliance on coreference resolution across multiple contexts:</p>
<p><strong>Reference types</strong>:</p>
<ul>
<li><strong>Passage references</strong>: Pronouns and references to entities in the source text</li>
<li><strong>Dialogue references</strong>: References to previously discussed topics</li>
<li><strong>Abstract references</strong>: Challenging cases like &ldquo;what else?&rdquo; that require inferring the inquiry scope</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/quac_coref.webp"
         alt="Types and distribution of coreferences in QuAC"
         title="Types and distribution of coreferences in QuAC"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Types and distribution of coreferences in QuAC</figcaption>
    
</figure>

<p>The prevalence of coreference resolution makes QuAC particularly challenging, as this remains an active research problem in NLP. Models must understand passage content, track dialogue history, and resolve complex referential expressions simultaneously.</p>
<h2 id="performance-results">Performance Results</h2>
<p>Models face substantial challenges on QuAC, with significant gaps between human and machine performance:</p>















<figure class="post-figure center ">
    <img src="/img/quac_performance.webp"
         alt="Baseline model performance comparison on QuAC"
         title="Baseline model performance comparison on QuAC"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Baseline model performance comparison on QuAC</figcaption>
    
</figure>

<p><strong>Performance summary</strong>:</p>
<ul>
<li><strong>Human performance</strong>: 81.1% F1 score</li>
<li><strong>Best baseline</strong>: BiDAF++ with context achieves 60.2% F1</li>
<li><strong>Performance gap</strong>: 20+ point difference shows room for improvement</li>
</ul>
<h3 id="human-equivalence-metrics">Human Equivalence Metrics</h3>
<p>QuAC introduces evaluation metrics beyond traditional F1 scores:</p>
<p><strong>HEQ-Q (Human Equivalence Question-level)</strong>: Percentage of questions where the model achieves human-level or better performance</p>
<p><strong>HEQ-D (Human Equivalence Dialogue-level)</strong>: Percentage of complete dialogues where the model matches human performance across all questions</p>
<p><strong>Current results</strong>:</p>
<ul>
<li>Human baseline: 100% HEQ-Q, 100% HEQ-D (by definition)</li>
<li>Best model: 55.1% HEQ-Q, 5.2% HEQ-D</li>
</ul>
<p>These metrics show both average performance and consistency across questions and conversations, important for practical dialogue systems.</p>
<h2 id="research-impact">Research Impact</h2>
<p>QuAC represents an important step in question answering research by introducing realistic conversational dynamics that existing datasets lack. The student-teacher framework captures natural information-seeking behavior while maintaining extractive evaluation for objective assessment.</p>
<p><strong>Key contributions</strong>:</p>
<ul>
<li><strong>Conversational realism</strong>: Context-dependent questions that mirror dialogue patterns</li>
<li><strong>Coreference complexity</strong>: Integration of challenging NLP problems into QA evaluation</li>
<li><strong>Evaluation metrics</strong>: HEQ scores that measure consistency alongside average performance</li>
<li><strong>Large-scale framework</strong>: Substantial dataset enabling robust model training and evaluation</li>
</ul>
<p>The dataset&rsquo;s <a href="https://quac.ai/">leaderboard</a> provides researchers with a challenging benchmark for developing conversational AI systems. As models improve on QuAC, we can expect progress in dialogue agents, virtual assistants, and educational AI systems that engage in more natural, context-aware conversations.</p>
<p>QuAC&rsquo;s focus on dialogue context and reference resolution pushes the field toward AI systems that can engage in genuine conversation and understand complex dialogue flows.</p>
<h2 id="a-builders-perspective-quac-and-modern-instruction-tuning">A Builder&rsquo;s Perspective: QuAC and Modern Instruction Tuning</h2>
<p>Looking at QuAC through the lens of modern production ML, the student-teacher framework maps directly onto how we now train and evaluate assistants. Today, we train foundation models using Reinforcement Learning from Human Feedback (RLHF) and instruction tuning, which rely heavily on multi-turn, context-aware interactions.</p>
<p>When building a system like GutenOCR, users rarely ask perfectly formulated, context-free questions. They ask follow-ups, use pronouns, and expect the system to act as a knowledgeable &ldquo;teacher&rdquo; guiding them through the document. QuAC was an early dataset to formalize this asymmetric information dynamic. It highlighted the necessity of handling unanswerable questions gracefully, a critical feature for preventing hallucinations in today&rsquo;s production LLMs.</p>
<h2 id="citation">Citation</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bibtex" data-lang="bibtex"><span style="display:flex;"><span><span style="color:#a6e22e">@inproceedings</span>{choi-etal-2018-quac,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">title</span> = <span style="color:#e6db74">&#34;{Q}u{AC}: Question Answering in Context&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">author</span> = <span style="color:#e6db74">&#34;Choi, Eunsol and He, He and Iyyer, Mohit and Yatskar, Mark and Yih, Wen-tau and Choi, Yejin and Liang, Percy and Zettlemoyer, Luke&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">booktitle</span> = <span style="color:#e6db74">&#34;Proceedings of the 2018 Conference on Empirical Methods in Natural Language Processing&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">month</span> = oct # <span style="color:#e6db74">&#34;-&#34;</span> # nov,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">year</span> = <span style="color:#e6db74">&#34;2018&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">address</span> = <span style="color:#e6db74">&#34;Brussels, Belgium&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">publisher</span> = <span style="color:#e6db74">&#34;Association for Computational Linguistics&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">url</span> = <span style="color:#e6db74">&#34;https://aclanthology.org/D18-1241/&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">doi</span> = <span style="color:#e6db74">&#34;10.18653/v1/D18-1241&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">pages</span> = <span style="color:#e6db74">&#34;2174--2184&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div>]]></content:encoded></item><item><title>CoQA Dataset: Advancing Conversational Question Answering</title><link>https://hunterheidenreich.com/posts/coqa-conversation-question-answering/</link><pubDate>Thu, 23 Aug 2018 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/coqa-conversation-question-answering/</guid><description>Analysis of CoQA, a conversational QA dataset with multi-turn dialogue, coreference resolution, and natural answers for QA research.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>The <a href="https://doi.org/10.1162/tacl_a_00266">CoQA dataset</a> (Reddy et al., 2019) introduces conversational dynamics to question answering research. CoQA requires models to maintain context across multi-turn conversations while reading and reasoning about text passages. Previous datasets focused on isolated question-answer pairs.</p>
<p>This dataset addresses a gap in conversational AI research by providing a benchmark for systems that must understand dialogue flow and implicit references. These are key components of natural human conversation.</p>
<p>For related work on conversational question answering, see my analysis of <a href="/posts/quac-question-answering-in-context/">QuAC</a>.</p>
<h2 id="what-makes-conversational-qa-different">What Makes Conversational QA Different</h2>
<p>Conversational question answering introduces challenges beyond traditional reading comprehension:</p>
<ol>
<li><strong>Context dependency</strong>: Questions rely on previous dialogue turns for meaning</li>
<li><strong>Coreference resolution</strong>: Understanding pronouns and implicit references</li>
<li><strong>Abstractive answering</strong>: Rephrasing information to generate natural responses</li>
<li><strong>Multi-turn reasoning</strong>: Maintaining coherent dialogue across multiple exchanges</li>
</ol>
<p>These requirements differentiate CoQA from existing question answering datasets that treat each question independently.</p>
<h2 id="why-coqa-matters">Why CoQA Matters</h2>
<p>Question answering systems typically excel at finding specific information in text. However, they often struggle with natural conversation. Human communication involves building on previous exchanges, using pronouns and implicit references, and expressing ideas in varied ways.</p>
<p>CoQA addresses this by creating a large-scale dataset for conversational question answering with three primary characteristics:</p>
<ol>
<li>
<p><strong>Conversation-dependent questions</strong>: After the first question, every subsequent question depends on dialogue history across 127,000 questions spanning 8,000 conversations</p>
</li>
<li>
<p><strong>Natural, abstractive answers</strong>: CoQA requires rephrased responses that sound natural in conversation. The answerer first highlighted the relevant text span, then rephrased the information.</p>
</li>
<li>
<p><strong>Domain diversity</strong>: Training covers 5 domains with testing on 7 domains, including 2 unseen during training</p>
</li>
</ol>
<p>The performance gap is notable: humans achieve 88.8% F1 score while the best models at the time reached 65.1% F1, indicating substantial room for improvement.</p>
<h2 id="dataset-construction">Dataset Construction</h2>
<p>CoQA was constructed using Amazon Mechanical Turk, pairing workers in a question-answer dialogue setup. One worker asked questions about a given passage while another provided answers. The answerer first highlighted the relevant text span, then rephrased the information using different words to create natural, abstractive responses.</p>
<p>This methodology produces answers that sound conversational. This makes the dataset highly realistic for dialogue applications.</p>
<h3 id="domain-coverage">Domain Coverage</h3>
<p>CoQA spans diverse text types to ensure evaluation across different writing styles and topics:</p>
<p><strong>Training domains (5):</strong></p>
<ul>
<li>Children&rsquo;s stories from <a href="https://web.archive.org/web/20180829214346/https://uclmr.github.io/ai4exams/data.html#mctest">MCTest</a></li>
<li>Literature from <a href="https://www.gutenberg.org/">Project Gutenberg</a></li>
<li>Educational content from <a href="https://www.cs.cmu.edu/~glai1/data/race/">RACE</a> (middle/high school English)</li>
<li>CNN news articles</li>
<li>Wikipedia articles</li>
</ul>
<p><strong>Test-only domains (2):</strong></p>
<ul>
<li>Science articles from <a href="http://data.allenai.org/ai2-science-questions/">AI2 Science Questions</a></li>
<li>Creative writing from <a href="https://www.reddit.com/r/WritingPrompts/">Reddit WritingPrompts</a></li>
</ul>















<figure class="post-figure center ">
    <img src="/img/coqa_domains.webp"
         alt="Domain distribution in the CoQA dataset"
         title="Domain distribution in the CoQA dataset"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Domain distribution in the CoQA dataset</figcaption>
    
</figure>

<p>The inclusion of test-only domains provides a rigorous evaluation of model generalization to unseen text types.</p>
<h2 id="comparison-with-existing-datasets">Comparison with Existing Datasets</h2>
<p>Prior to CoQA, the dominant question answering benchmark was <a href="https://rajpurkar.github.io/SQuAD-explorer/">SQuAD (Stanford Question Answering Dataset)</a>. SQuAD established foundations for reading comprehension and presented specific constraints:</p>
<ul>
<li><strong>SQuAD 1.0</strong>: 100,000+ questions requiring exact text extraction from Wikipedia passages</li>
<li><strong>SQuAD 2.0</strong>: Added 50,000+ unanswerable questions to test when no answer exists</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/squad_coqa_size.webp"
         alt="Scale comparison between SQuAD and CoQA datasets"
         title="Scale comparison between SQuAD and CoQA datasets"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Scale comparison between SQuAD and CoQA datasets</figcaption>
    
</figure>

<p>SQuAD treats each question independently and requires only extractive answers. CoQA addresses these constraints through conversational context and abstractive responses.</p>
<h3 id="question-and-answer-analysis">Question and Answer Analysis</h3>
<p>The differences between SQuAD and CoQA extend beyond conversational context:</p>
<p><strong>Question diversity</strong>: SQuAD heavily favors &ldquo;what&rdquo; questions (~50%). CoQA shows a more balanced distribution across question types, reflecting natural conversation patterns.</p>















<figure class="post-figure center ">
    <img src="/img/squad_v_coqa.webp"
         alt="Question type distribution comparison between SQuAD and CoQA"
         title="Question type distribution comparison between SQuAD and CoQA"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Question type distribution comparison between SQuAD and CoQA</figcaption>
    
</figure>

<p><strong>Context dependence</strong>: CoQA includes challenging single-word questions like &ldquo;who?&rdquo;, &ldquo;where?&rdquo;, or &ldquo;why?&rdquo; that depend entirely on dialogue history.</p>
<p><strong>Answer characteristics</strong>: CoQA answers vary significantly in length and style. SQuAD primarily features extractive spans.</p>















<figure class="post-figure center ">
    <img src="/img/squad_coqa_answers.webp"
         alt="Answer length distribution in SQuAD vs CoQA"
         title="Answer length distribution in SQuAD vs CoQA"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Answer length distribution in SQuAD vs CoQA</figcaption>
    
</figure>

<h2 id="the-coreference-challenge">The Coreference Challenge</h2>
<p>CoQA&rsquo;s difficulty stems largely from its reliance on coreference resolution (determining when different expressions refer to the same entity). This remains a challenging research problem in NLP.</p>
<p><strong>Coreference types in CoQA</strong>:</p>
<ul>
<li><strong>Explicit coreferences</strong> (~50% of questions): Clear indicators like pronouns (&ldquo;him,&rdquo; &ldquo;it,&rdquo; &ldquo;her,&rdquo; &ldquo;that&rdquo;)</li>
<li><strong>Implicit coreferences</strong> (~20% of questions): Context-dependent references requiring inference (e.g., asking &ldquo;where?&rdquo; without specifying what)</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/coqa_coreferences.webp"
         alt="Distribution of coreference types in CoQA questions"
         title="Distribution of coreference types in CoQA questions"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Distribution of coreference types in CoQA questions</figcaption>
    
</figure>

<p>These linguistic phenomena make CoQA more difficult than traditional reading comprehension, as models must resolve references across dialogue turns while maintaining conversational coherence.</p>
<h2 id="performance-benchmarks">Performance Benchmarks</h2>
<p>Models faced significant challenges on CoQA, with substantial room for improvement:</p>















<figure class="post-figure center ">
    <img src="/img/coqa_scores.webp"
         alt="Performance comparison on CoQA across different model types"
         title="Performance comparison on CoQA across different model types"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Performance comparison on CoQA across different model types</figcaption>
    
</figure>

<p>The performance gap between human and machine capabilities highlighted conversational question answering as a challenging frontier in NLP research.</p>
<h2 id="research-impact-and-future-directions">Research Impact and Future Directions</h2>
<p>CoQA represents a step toward more natural conversational AI systems. By requiring models to handle dialogue context, coreference resolution, and abstractive reasoning simultaneously, it challenges current NLP system capabilities.</p>
<p>The dataset&rsquo;s <a href="https://stanfordnlp.github.io/coqa/">leaderboard</a> provides a benchmark for measuring progress on this task. As models improve on CoQA, we can expect advances in conversational AI applications, from chatbots to virtual assistants that engage in more natural, context-aware dialogue.</p>
<p>CoQA&rsquo;s contribution to the field aims to parallel ImageNet&rsquo;s impact on computer vision, providing a challenging, well-constructed benchmark that drives research toward more capable AI systems.</p>
<h2 id="a-builders-perspective-coqa-in-the-era-of-llms">A Builder&rsquo;s Perspective: CoQA in the Era of LLMs</h2>
<p>Looking back at CoQA from the perspective of modern production systems, the dataset anticipated where the field went. The challenges it introduced, such as multi-turn reasoning, coreference resolution, and abstractive answering, are the exact capabilities we now expect from instruction-tuned Large Language Models (LLMs).</p>
<p>Production document-processing pipelines rarely extract isolated facts. Users want to chat with their documents, asking follow-up questions like, &ldquo;What does that mean for the Q3 budget?&rdquo; Resolving &ldquo;that&rdquo; to a previous turn&rsquo;s context is exactly the problem CoQA formalized. Datasets like CoQA shifted the field&rsquo;s focus from simple extraction toward dialogue comprehension, the foundation modern conversational document interfaces are built on.</p>
<h2 id="references">References</h2>
<p>Reddy, S., Chen, D., &amp; Manning, C. D. (2019). CoQA: A conversational question answering challenge. <em>Transactions of the Association for Computational Linguistics</em>, 7, 249-266.</p>
]]></content:encoded></item><item><title>Understanding GANs: From Fundamentals to Objective Functions</title><link>https://hunterheidenreich.com/posts/what-is-a-gan/</link><pubDate>Sat, 18 Aug 2018 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/what-is-a-gan/</guid><description>A complete guide to Generative Adversarial Networks (GANs), covering intuitive explanations, mathematical foundations, and objective functions.</description><content:encoded><![CDATA[<h2 id="understanding-generative-models">Understanding Generative Models</h2>
<p>Modern generative AI is dominated by diffusion models and autoregressive transformers. The adversarial training dynamics and objective functions introduced by <a href="https://arxiv.org/abs/1406.2661">Generative Adversarial Networks</a> (GANs) still inform how the field thinks about loss-function design and training stability today. Before diving into GANs, let&rsquo;s establish what we&rsquo;re trying to accomplish with generative models.</p>
<p><strong>The core goal</strong>: Create a system that can generate new, realistic data that appears to come from the same distribution as our training data.</p>
<p>Think of having a model that can create images, text, or audio that are difficult to distinguish from human-created content. This is what generative modeling aims to achieve.</p>
<h3 id="the-mathematical-foundation">The Mathematical Foundation</h3>
<p>Generative models aim to estimate the probability distribution of real data. If we have parameters $\theta$, we want to find the optimal $\theta^*$ that maximizes the likelihood of observing our real samples:</p>
<p>$$
\theta^* = \arg\max_\theta \prod_{i=1}^{n} p_\theta(x_i)
$$</p>
<p>This is equivalent to minimizing the distance between our estimated distribution and the true data distribution. A common distance measure is the <a href="https://en.wikipedia.org/wiki/Kullback%E2%80%93Leibler_divergence">Kullback-Leibler Divergence</a>. Maximizing log-likelihood equals minimizing KL divergence.</p>
<h3 id="two-approaches-to-generative-modeling">Two Approaches to Generative Modeling</h3>
<h4 id="explicit-distribution-models">Explicit Distribution Models</h4>
<p>These models define an explicit probability distribution and refine it through training.</p>
<p><strong>Example</strong>: <a href="https://arxiv.org/abs/1606.05908">Variational Auto-Encoders</a> (VAEs) require:</p>
<ul>
<li>An explicitly assumed prior distribution</li>
<li>A likelihood distribution</li>
<li>A &ldquo;variational approximation&rdquo; to evaluate performance</li>
</ul>
<h4 id="implicit-distribution-models">Implicit Distribution Models</h4>
<p>These models learn to generate data by indirectly sampling from a learned distribution. GANs exemplify this implicit approach, learning distributions through adversarial competition.</p>















<figure class="post-figure center ">
    <img src="/img/gen_ai_types.webp"
         alt="Types of deep generative models showing taxonomy"
         title="Types of deep generative models showing taxonomy"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Taxonomy of Deep Generative Models</strong>: GANs fall into the implicit density category, learning distributions through adversarial training. <em>Source: NeurIPS 2016 tutorial on Generative Adversarial Networks</em></figcaption>
    
</figure>

<h2 id="the-gan-architecture-a-game-of-deception">The GAN Architecture: A Game of Deception</h2>
<p>Generative Adversarial Networks get their name from three key components:</p>
<ul>
<li><strong>Generative</strong>: They create new data</li>
<li><strong>Adversarial</strong>: Two networks compete against each other</li>
<li><strong>Networks</strong>: Built using neural networks</li>
</ul>
<p>The core innovation is the adversarial setup: two neural networks compete against each other, driving mutual improvement.</p>















<figure class="post-figure center ">
    <img src="/img/GAN-70.webp"
         alt="Diagram showing data flow through a GAN architecture"
         title="Diagram showing data flow through a GAN architecture"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>GAN Data Flow</strong>: The generator creates fake samples from random noise, while the discriminator tries to distinguish real from fake data. This adversarial competition drives both networks to improve.</figcaption>
    
</figure>

<h3 id="the-generator-the-forger">The Generator: The Forger</h3>
<p><strong>Role</strong>: Create convincing fake data from random noise</p>
<p>The generator network $G$ learns a mapping function:
$$z \rightarrow G(z) \approx x_{\text{real}}$$</p>
<p>Where:</p>
<ul>
<li>$z$ is a random latent vector (the &ldquo;noise&rdquo;)</li>
<li>$G(z)$ is the generated sample</li>
<li>The goal is making $G(z)$ indistinguishable from real data</li>
</ul>
<p><strong>Key insight</strong>: The latent space $z$ is continuous, meaning small changes in $z$ produce smooth, meaningful changes in the generated output.</p>
<h3 id="the-discriminator-the-detective">The Discriminator: The Detective</h3>
<p><strong>Role</strong>: Distinguish between real and generated samples</p>
<p>The discriminator network $D$ outputs a probability:
$$D(x) = P(\text{x is real})$$</p>
<ul>
<li>$D(x) \approx 1$ for real samples</li>
<li>$D(x) \approx 0$ for fake samples</li>
</ul>
<p>It functions as an &ldquo;authenticity detector&rdquo; that progressively improves.</p>
<h3 id="the-adversarial-competition">The Adversarial Competition</h3>
<p>This adversarial dynamic drives the training process. The generator and discriminator have <strong>directly opposing objectives</strong>:</p>
<table>
	<thead>
			<tr>
					<th>Generator Goal</th>
					<th>Discriminator Goal</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Fool the discriminator</td>
					<td>Correctly classify all samples</td>
			</tr>
			<tr>
					<td>Minimize $D(G(z))$</td>
					<td>Maximize $D(x_{\text{real}})$ and minimize $D(G(z))$</td>
			</tr>
			<tr>
					<td>&ldquo;Create convincing fakes&rdquo;</td>
					<td>&ldquo;Never be fooled&rdquo;</td>
			</tr>
	</tbody>
</table>
<p>This creates a dynamic where both networks continuously improve:</p>
<ul>
<li>Generator creates better fakes to fool the discriminator</li>
<li>Discriminator becomes better at detecting fakes</li>
<li>The cycle continues until equilibrium</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/GAN-SUMMARY-50.webp"
         alt="Illustration of GAN training process showing adversarial competition"
         title="Illustration of GAN training process showing adversarial competition"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>The Adversarial Training Process</strong>: Through competition, both networks improve. The generator learns to create increasingly realistic samples while the discriminator becomes more discerning.</figcaption>
    
</figure>

<h2 id="learning-through-metaphors">Learning Through Metaphors</h2>
<p>Relatable analogies often clarify complex concepts. Here are two metaphors that capture different aspects of how GANs work.</p>
<h3 id="the-art-forger-vs-critic">The Art Forger vs. Critic</h3>
<p><strong>Generator = Art Forger</strong><br>
<strong>Discriminator = Art Critic</strong></p>
<p>A criminal forger tries to create fake masterpieces, while an art critic must identify authentic works. Each interaction teaches both parties:</p>
<ul>
<li>The forger learns what makes art look authentic</li>
<li>The critic develops a keener eye for detecting fakes</li>
<li>Eventually, the forger becomes so skilled that even experts can&rsquo;t tell the difference</li>
</ul>
<p><em>This captures the adversarial nature and continuous improvement aspect of GANs.</em></p>
<h3 id="the-counterfeiter-vs-bank-teller">The Counterfeiter vs. Bank Teller</h3>
<p><strong>Generator = Counterfeiter</strong><br>
<strong>Discriminator = Bank Teller</strong></p>
<p>Day 1: Criminal brings a crayon drawing of a dollar bill. Even a new teller spots this fake.</p>
<p>Day 100: The counterfeiter has learned better techniques. The teller has developed expertise in security features.</p>
<p>Day 1000: The fake money is so convincing that detecting it requires advanced equipment.</p>
<p><em>This illustrates the progressive improvement and escalating sophistication in both networks.</em></p>
<h2 id="the-mathematical-foundation-1">The Mathematical Foundation</h2>
<p>Now let&rsquo;s examine the mathematical framework that makes GANs work. The core of GAN training is solving a <strong>minimax optimization problem</strong>.</p>
<h3 id="the-minimax-objective">The Minimax Objective</h3>
<p>$$
\min_{G} \max_{D} V(D, G) = \mathbb{E}_{x \sim p_{\text{data}}(x)}[\log D(x)] + \mathbb{E}_{z \sim p_z(z)}[\log(1 - D(G(z)))]
$$</p>
<p><strong>Breaking this down:</strong></p>
<ul>
<li>$\mathbb{E}_{x \sim p_{\text{data}}(x)}[\log D(x)]$: The expected log-probability for real data.
<ul>
<li><strong>Discriminator&rsquo;s Goal</strong>: Maximize this term to correctly classify real samples.</li>
</ul>
</li>
<li>$\mathbb{E}_{z \sim p_z(z)}[\log(1 - D(G(z)))]$: The expected log-probability for fake data being correctly identified as fake.
<ul>
<li><strong>Discriminator&rsquo;s Goal</strong>: Maximize this term.</li>
<li><strong>Generator&rsquo;s Goal</strong>: Minimize this term to fool the discriminator.</li>
</ul>
</li>
</ul>
<h3 id="why-minimax">Why &ldquo;Minimax&rdquo;?</h3>
<ul>
<li><strong>Discriminator ($D$)</strong>: Tries to <strong>maximize</strong> the objective → Better at distinguishing real from fake.</li>
<li><strong>Generator ($G$)</strong>: Tries to <strong>minimize</strong> the objective → Better at fooling the discriminator.</li>
</ul>
<h3 id="a-practical-challenge-vanishing-gradients">A Practical Challenge: Vanishing Gradients</h3>
<p>The minimax objective presents a practical problem early in training. When the generator is poor, the discriminator can easily distinguish real from fake samples with high confidence ($D(G(z)) \approx 0$). This causes $\log(1 - D(G(z)))$ to saturate and results in vanishing gradients for the generator, which effectively stalls learning.</p>
<p><strong>The Solution</strong>: Practitioners typically train the generator to <strong>maximize</strong> $\log(D(G(z)))$ to provide stronger gradients early in training. This non-saturating heuristic prevents the learning process from stalling.</p>
<h3 id="the-training-process">The Training Process</h3>
<p>The beauty of GANs lies in their alternating optimization:</p>
<ol>
<li><strong>Fix $G$, train $D$</strong>: Make the discriminator optimal for the current generator</li>
<li><strong>Fix $D$, train $G$</strong>: Improve the generator against the current discriminator</li>
<li><strong>Repeat</strong>: Continue until reaching Nash equilibrium</li>
</ol>
<h3 id="theoretical-goal-nash-equilibrium">Theoretical Goal: Nash Equilibrium</h3>
<p>At convergence, the discriminator outputs $D(x) = 0.5$ for all samples, meaning it can&rsquo;t distinguish between real and fake data. This indicates that $p_{\text{generator}} = p_{\text{data}}$. Our generator has learned the true data distribution.</p>
<h2 id="the-evolution-of-objective-functions">The Evolution of Objective Functions</h2>
<p>The objective function is the mathematical heart of any GAN. It defines how we measure the &ldquo;distance&rdquo; between our generated distribution and the real data distribution. This choice profoundly impacts:</p>
<ul>
<li><strong>Training stability</strong>: Some objectives lead to more stable convergence</li>
<li><strong>Sample quality</strong>: Different losses emphasize different aspects of realism</li>
<li><strong>Mode collapse</strong>: The tendency to generate limited variety</li>
<li><strong>Computational efficiency</strong>: Some objectives are faster to compute</li>
</ul>
<p>The original GAN uses Jensen-Shannon Divergence (JSD), but researchers have discovered many alternatives that address specific limitations. Let&rsquo;s explore this evolution.</p>
<h3 id="the-original-gan-jensen-shannon-divergence">The Original GAN: Jensen-Shannon Divergence</h3>
<p>The foundational GAN minimizes the Jensen-Shannon Divergence:</p>
<p>$$
\text{JSD}(P, Q) = \frac{1}{2} \text{KL}(P | M) + \frac{1}{2} \text{KL}(Q | M)
$$</p>
<p>Where $M = \frac{1}{2}(P + Q)$ is the average distribution, and $\text{KL}$ is the <a href="https://en.wikipedia.org/wiki/Kullback%E2%80%93Leibler_divergence">Kullback-Leibler Divergence</a>.</p>
<p><strong>Strengths</strong>: Solid theoretical foundation, introduced adversarial training<br>
<strong>Limitations</strong>: Can suffer from vanishing gradients and mode collapse</p>
<h3 id="wasserstein-gan-wgan">Wasserstein GAN (WGAN)</h3>
<p>The <a href="https://arxiv.org/abs/1701.07875">Wasserstein GAN</a> replaced Jensen-Shannon divergence with the Earth-Mover (Wasserstein) distance, which gives meaningful gradients even when the real and generated distributions do not overlap.</p>
<h4 id="understanding-earth-mover-distance">Understanding Earth-Mover Distance</h4>
<p>The Wasserstein distance, also known as Earth-Mover distance, has an intuitive interpretation:</p>
<blockquote>
<p><strong>Imagine two probability distributions as piles of dirt.</strong> The Earth-Mover distance measures the minimum cost to transform one pile into the other, where cost = mass x distance moved.</p>
</blockquote>
<p>Mathematically:</p>
<p>$$
W_p(\mu, \nu) = \left( \inf_{\gamma \in \Gamma(\mu, \nu)} \int_{M xM} d(x, y)^p , d\gamma(x, y) \right)^{1/p}
$$</p>
<h4 id="why-earth-mover-distance-matters">Why Earth-Mover Distance Matters</h4>
<table>
	<thead>
			<tr>
					<th>Jensen-Shannon Divergence</th>
					<th>Earth-Mover Distance</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Can be discontinuous</td>
					<td><strong>Always continuous</strong></td>
			</tr>
			<tr>
					<td>May have vanishing gradients</td>
					<td><strong>Meaningful gradients everywhere</strong></td>
			</tr>
			<tr>
					<td>Limited convergence guarantees</td>
					<td><strong>Broader convergence properties</strong></td>
			</tr>
	</tbody>
</table>
<h4 id="wgan-implementation">WGAN Implementation</h4>
<p>Since we can&rsquo;t compute Wasserstein distance directly, WGAN uses the <strong>Kantorovich-Rubinstein duality</strong>:</p>
<ol>
<li><strong>Train a critic function</strong> $f$ to approximate the Wasserstein distance</li>
<li><strong>Constrain the critic</strong> to be 1-Lipschitz (using weight clipping)</li>
<li><strong>Optimize the generator</strong> to minimize this distance</li>
</ol>















<figure class="post-figure center ">
    <img src="/img/wasserstein.webp"
         alt="WGAN training results showing stable convergence"
         title="WGAN training results showing stable convergence"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>WGAN Results</strong>: Demonstrating improved training stability and meaningful loss curves. <em>Source: Wasserstein GAN paper</em></figcaption>
    
</figure>

<h4 id="key-wgan-benefits">Key WGAN Benefits</h4>
<p><strong>Meaningful loss function</strong>: Loss correlates with sample quality<br>
<strong>Improved stability</strong>: Less prone to mode collapse<br>
<strong>Theoretical guarantees</strong>: Solid mathematical foundation<br>
<strong>Better convergence</strong>: Works even when distributions don&rsquo;t overlap</p>
<h3 id="improved-wgan-solving-the-weight-clipping-problem">Improved WGAN: Solving the Weight Clipping Problem</h3>
<p><a href="https://arxiv.org/abs/1704.00028">Improved WGAN</a> (WGAN-GP) addresses a critical flaw in the original WGAN: <strong>weight clipping</strong>.</p>
<h4 id="the-problem-with-weight-clipping">The Problem with Weight Clipping</h4>
<p>Original WGAN clips weights to maintain the 1-Lipschitz constraint:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Problematic approach</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> param <span style="color:#f92672">in</span> critic<span style="color:#f92672">.</span>parameters():
</span></span><span style="display:flex;"><span>    param<span style="color:#f92672">.</span>data<span style="color:#f92672">.</span>clamp_(<span style="color:#f92672">-</span><span style="color:#ae81ff">0.01</span>, <span style="color:#ae81ff">0.01</span>)
</span></span></code></pre></div><p><strong>Issues with clipping</strong>:</p>
<ul>
<li>Forces critic to use extremely simple functions</li>
<li>Pushes weights toward extreme values ($\pm c$)</li>
<li>Can lead to poor gradient flow</li>
<li>Capacity limitations hurt performance</li>
</ul>
<h4 id="the-gradient-penalty-solution">The Gradient Penalty Solution</h4>
<p>WGAN-GP introduces a <strong>gradient penalty term</strong> to constrain the critic:</p>
<p>$$
L = E_{\tilde{x} \sim P_g}[D(\tilde{x})] - E_{x \sim P_r}[D(x)] + \lambda E_{\hat{x}}[(||\nabla_{\hat{x}} D(\hat{x})||_2 - 1)^2]
$$</p>
<p>Where $\hat{x}$ are points sampled uniformly along straight lines between real and generated data points.</p>
<p><strong>Advantages</strong>:</p>
<ul>
<li>No capacity limitations</li>
<li>Better gradient flow</li>
<li>More stable training</li>
<li>Works across different architectures</li>
</ul>
<h3 id="lsgan-the-power-of-least-squares">LSGAN: The Power of Least Squares</h3>
<p><a href="https://arxiv.org/abs/1611.04076">Least Squares GAN</a> takes a different approach. It replaces the logarithmic loss with <strong>L2 (least squares) loss</strong>.</p>
<h4 id="motivation-beyond-binary-classification">Motivation: Beyond Binary Classification</h4>
<p>Traditional GANs use log loss, which focuses primarily on correct classification:</p>
<ul>
<li>Real sample correctly classified → minimal penalty</li>
<li>Fake sample correctly classified → minimal penalty</li>
<li>Distance from decision boundary ignored</li>
</ul>
<h4 id="l2-loss-distance-matters">L2 Loss: Distance Matters</h4>
<p>LSGAN uses L2 loss, which <strong>penalizes proportionally to distance</strong>:</p>
<p>$$
\min_D V_{LSGAN}(D) = \frac{1}{2}E_{x \sim p_{data}(x)}[(D(x) - b)^2] + \frac{1}{2}E_{z \sim p_z(z)}[(D(G(z)) - a)^2]
$$</p>
<p>$$
\min_G V_{LSGAN}(G) = \frac{1}{2}E_{z \sim p_z(z)}[(D(G(z)) - c)^2]
$$</p>
<p>Where typically: $a = 0$ (fake label), $b = c = 1$ (real label)</p>
<h4 id="benefits-of-l2-loss">Benefits of L2 Loss</h4>
<table>
	<thead>
			<tr>
					<th>Log Loss</th>
					<th>L2 Loss</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Binary focus</td>
					<td><strong>Distance-aware</strong></td>
			</tr>
			<tr>
					<td>Can saturate</td>
					<td><strong>Informative gradients</strong></td>
			</tr>
			<tr>
					<td>Sharp decision boundary</td>
					<td><strong>Smooth decision regions</strong></td>
			</tr>
	</tbody>
</table>















<figure class="post-figure center ">
    <img src="/img/lsgan-result.webp"
         alt="LSGAN generated samples showing improved quality"
         title="LSGAN generated samples showing improved quality"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>LSGAN Results</strong>: Demonstrating improved sample quality through distance-aware loss functions. <em>Source: LSGAN paper</em></figcaption>
    
</figure>

<p><strong>Key insight</strong>: LSGAN minimizes the Pearson χ² divergence, providing smoother optimization landscape than JSD.</p>
<h3 id="relaxed-wasserstein-gan-rwgan">Relaxed Wasserstein GAN (RWGAN)</h3>
<p><a href="https://arxiv.org/abs/1705.07164">Relaxed WGAN</a> bridges the gap between WGAN and WGAN-GP, proposing a <strong>general framework</strong> for designing GAN objectives.</p>
<h4 id="key-innovations">Key Innovations</h4>
<p><strong>Asymmetric weight clamping</strong>: RWGAN introduces an asymmetric approach that provides better balance.</p>
<p><strong>Relaxed Wasserstein divergences</strong>: A generalized framework that extends the Wasserstein distance, enabling systematic design of new GAN variants while maintaining theoretical guarantees.</p>
<h4 id="benefits">Benefits</h4>
<ul>
<li>Better convergence properties than standard WGAN</li>
<li>Framework for designing new loss functions and GAN architectures</li>
<li>Competitive performance with other Wasserstein-based methods</li>
</ul>
<p><strong>Key insight</strong>: RWGAN parameterized with KL divergence shows excellent performance while maintaining the theoretical foundations that make Wasserstein GANs attractive.</p>
<h3 id="statistical-distance-approaches">Statistical Distance Approaches</h3>
<p>Several GAN variants focus on minimizing specific statistical distances between distributions.</p>
<h4 id="mcgan-mean-and-covariance-matching">McGAN: Mean and Covariance Matching</h4>
<p><a href="https://arxiv.org/abs/1702.08398">McGAN</a> belongs to the Integral Probability Metric (IPM) family, using <strong>statistical moments</strong> as the distance measure.</p>
<p><strong>Approach</strong>: Match first and second-order statistics:</p>
<ul>
<li><strong>Mean matching</strong>: Align distribution centers</li>
<li><strong>Covariance matching</strong>: Align distribution shapes</li>
</ul>
<p>Moment-matching objectives like this are conceptually related to settings where aligning statistical moments matters, such as matching a generated distribution to a target physical distribution (e.g., molecular conformations). McGAN itself, however, was introduced and demonstrated as an IPM method for image generation.</p>
<p><strong>Limitation</strong>: Relies on weight clipping like original WGAN.</p>
<h4 id="gmmn-maximum-mean-discrepancy">GMMN: Maximum Mean Discrepancy</h4>
<p><a href="https://arxiv.org/abs/1502.02761">Generative Moment Matching Networks</a> eliminates the discriminator entirely, directly minimizing <strong>Maximum Mean Discrepancy (MMD)</strong>.</p>
<p><strong>MMD Intuition</strong>: Compare distributions by their means in a high-dimensional feature space:</p>
<p>$$
\text{MMD}^2(X, Y) = ||E[\phi(x)] - E[\phi(y)]||^2
$$</p>
<p><strong>Benefits</strong>:</p>
<ul>
<li>Simple, discriminator-free training</li>
<li>Theoretical guarantees</li>
<li>Can incorporate autoencoders for better MMD estimation</li>
</ul>
<p><strong>Drawbacks</strong>:</p>
<ul>
<li>Computationally expensive</li>
<li>Often weaker empirical results</li>
</ul>
<h4 id="mmd-gan-learning-better-kernels">MMD GAN: Learning Better Kernels</h4>
<p><a href="https://arxiv.org/abs/1705.08584">MMD GAN</a> improves GMMN by <strong>learning optimal kernels</strong> adversarially to improve upon fixed Gaussian kernels.</p>
<p><strong>Innovation</strong>: Combine GAN adversarial training with MMD objective for the best of both worlds.</p>
<h3 id="different-distance-metrics">Different Distance Metrics</h3>
<h4 id="cramer-gan-addressing-sample-bias">Cramer GAN: Addressing Sample Bias</h4>
<p><a href="https://arxiv.org/abs/1705.10743">Cramer GAN</a> identifies a critical issue with WGAN: <strong>biased sample gradients</strong>.</p>
<p><strong>The Problem</strong>: WGAN&rsquo;s Wasserstein distance lacks three important properties:</p>
<ol>
<li><strong>Sum invariance</strong> (satisfied)</li>
<li><strong>Scale sensitivity</strong> (satisfied)</li>
<li><strong>Unbiased sample gradients</strong> (not satisfied)</li>
</ol>
<p><strong>The Solution</strong>: Use the <strong>Cramer distance</strong>, which satisfies all three properties:</p>
<p>$$
d_C^2(\mu, \nu) = \int ||E_{X \sim \mu}[X - x] - E_{Y \sim \nu}[Y - x]||^2 d\pi(x)
$$</p>
<p><strong>Benefit</strong>: More reliable gradients lead to better training dynamics.</p>
<h4 id="fisher-gan-chi-square-distance">Fisher GAN: Chi-Square Distance</h4>
<p><a href="https://arxiv.org/abs/1705.09675">Fisher GAN</a> uses a <strong>data-dependent constraint</strong> on the critic&rsquo;s second-order moments (variance).</p>
<p><strong>Key Innovation</strong>: The constraint naturally bounds the critic without manual techniques:</p>
<ul>
<li>No weight clipping needed</li>
<li>No gradient penalties required</li>
<li>Constraint emerges from the objective itself</li>
</ul>
<p><strong>Distance</strong>: Approximates the <strong>Chi-square distance</strong> as critic capacity increases:</p>
<p>$$
\chi^2(P, Q) = \int \frac{(P(x) - Q(x))^2}{Q(x)} dx
$$</p>
<p>The Fisher GAN essentially measures the Mahalanobis distance, which accounts for correlated variables relative to the distribution&rsquo;s centroid. This ensures the generator and critic remain bounded, and as the critic&rsquo;s capacity increases, it estimates the Chi-square distance.</p>
<p><strong>Benefits</strong>:</p>
<ul>
<li>Efficient computation</li>
<li>Training stability</li>
<li>Unconstrained critic capacity</li>
</ul>
<h3 id="beyond-traditional-gans-alternative-approaches">Beyond Traditional GANs: Alternative Approaches</h3>
<p>The following variants explore fundamentally different architectures and training paradigms.</p>
<h4 id="ebgan-energy-based-discrimination">EBGAN: Energy-Based Discrimination</h4>
<p><a href="https://arxiv.org/abs/1609.03126">Energy-Based GAN</a> replaces the discriminator with an <strong>autoencoder</strong>.</p>
<p><strong>Key insight</strong>: Use reconstruction error as the discrimination signal:</p>
<ul>
<li>Good data → Low reconstruction error</li>
<li>Poor data → High reconstruction error</li>
</ul>
<p><strong>Architecture</strong>:</p>
<ol>
<li>Train autoencoder on real data</li>
<li>Generator creates samples</li>
<li>Poor generated samples have high reconstruction loss</li>
<li>This loss drives generator improvement</li>
</ol>
<p><strong>Benefits</strong>:</p>
<ul>
<li>Fast and stable training</li>
<li>Robust to hyperparameter changes</li>
<li>No need to balance discriminator/generator</li>
</ul>
<h4 id="began-boundary-equilibrium">BEGAN: Boundary Equilibrium</h4>
<p><a href="https://arxiv.org/abs/1703.10717">BEGAN</a> combines EBGAN&rsquo;s autoencoder approach with WGAN-style loss functions.</p>
<p><strong>Innovation</strong>: Dynamic equilibrium parameter $k_t$ that balances:</p>
<ul>
<li>Real data reconstruction quality</li>
<li>Generated data reconstruction quality</li>
</ul>
<p><strong>Equilibrium equation</strong>:</p>
<p>$$
L_D = L(x) - k_t L(G(z))
$$</p>
<p>$$
k_{t+1} = k_t + \lambda(\gamma L(x) - L(G(z)))
$$</p>
<h4 id="magan-adaptive-margins">MAGAN: Adaptive Margins</h4>
<p><a href="https://arxiv.org/abs/1704.03817">MAGAN</a> improves EBGAN by making the margin in the hinge loss <strong>adaptive over time</strong>.</p>
<p><strong>Concept</strong>: Start with a large margin, gradually reduce it as training progresses:</p>
<ul>
<li>Early training: Focus on major differences</li>
<li>Later training: Fine-tune subtle details</li>
</ul>
<p><strong>Result</strong>: Better sample quality and training stability.</p>
<h2 id="summary-the-evolution-of-gan-objectives">Summary: The Evolution of GAN Objectives</h2>
<p>The evolution of GAN objective functions reflects the field&rsquo;s progression toward more stable and theoretically grounded training procedures. Each variant addresses specific limitations in earlier approaches.</p>
<h3 id="complete-reference-table">Complete Reference Table</h3>
<table>
	<thead>
			<tr>
					<th><strong>GAN Variant</strong></th>
					<th><strong>Key Innovation</strong></th>
					<th><strong>Main Benefit</strong></th>
					<th><strong>Limitation</strong></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Original GAN</strong></td>
					<td>Jensen-Shannon divergence</td>
					<td>Foundation of adversarial training</td>
					<td>Vanishing gradients, mode collapse</td>
			</tr>
			<tr>
					<td><strong>WGAN</strong></td>
					<td>Earth-Mover distance</td>
					<td>Meaningful loss, better stability</td>
					<td>Weight clipping issues</td>
			</tr>
			<tr>
					<td><strong>WGAN-GP</strong></td>
					<td>Gradient penalty</td>
					<td>Solves weight clipping problems</td>
					<td>Additional hyperparameter tuning</td>
			</tr>
			<tr>
					<td><strong>LSGAN</strong></td>
					<td>Least squares loss</td>
					<td>Better gradients, less saturation</td>
					<td>May converge to non-optimal points</td>
			</tr>
			<tr>
					<td><strong>RWGAN</strong></td>
					<td>Relaxed Wasserstein framework</td>
					<td>General framework for new designs</td>
					<td>Complex theoretical setup</td>
			</tr>
			<tr>
					<td><strong>McGAN</strong></td>
					<td>Mean/covariance matching</td>
					<td>Simple statistical alignment</td>
					<td>Limited by weight clipping</td>
			</tr>
			<tr>
					<td><strong>GMMN</strong></td>
					<td>Maximum mean discrepancy</td>
					<td>No discriminator needed</td>
					<td>Computationally expensive</td>
			</tr>
			<tr>
					<td><strong>MMD GAN</strong></td>
					<td>Adversarial kernels for MMD</td>
					<td>Improved GMMN performance</td>
					<td>Still computationally heavy</td>
			</tr>
			<tr>
					<td><strong>Cramer GAN</strong></td>
					<td>Cramer distance</td>
					<td>Unbiased sample gradients</td>
					<td>Complex implementation</td>
			</tr>
			<tr>
					<td><strong>Fisher GAN</strong></td>
					<td>Chi-square distance</td>
					<td>Self-constraining critic</td>
					<td>Limited empirical validation</td>
			</tr>
			<tr>
					<td><strong>EBGAN</strong></td>
					<td>Autoencoder discriminator</td>
					<td>Fast, stable training</td>
					<td>Requires careful regularization</td>
			</tr>
			<tr>
					<td><strong>BEGAN</strong></td>
					<td>Boundary equilibrium</td>
					<td>Dynamic training balance</td>
					<td>Additional equilibrium parameter</td>
			</tr>
			<tr>
					<td><strong>MAGAN</strong></td>
					<td>Adaptive margin</td>
					<td>Progressive refinement</td>
					<td>Margin scheduling complexity</td>
			</tr>
	</tbody>
</table>
<h3 id="practical-recommendations">Practical Recommendations</h3>
<p>For practitioners, the choice depends on specific requirements and engineering tradeoffs:</p>
<ul>
<li><strong>WGAN-GP</strong>: Best balance of stability and performance for most applications. However, tuning the gradient penalty $\lambda$ can be sensitive in practice.</li>
<li><strong>LSGAN</strong>: Simpler implementation with good empirical results.</li>
<li><strong>EBGAN</strong>: Fast experimentation and prototyping.</li>
<li><strong>Original GAN</strong>: Educational purposes and understanding fundamentals.</li>
</ul>
<p><strong>Real-World Impact:</strong> In my work training VLMs on terabyte-scale multimodal data and forecasting chaotic physical systems, these foundational dynamics still matter. Most generation today runs on diffusion models or autoregressive transformers, but the loss-design and training-stability lessons that came out of GAN research carry over. The choice of objective function shapes generation quality, training stability, and compute cost.</p>
<hr>
<p><strong>Acknowledgments</strong>: This post was inspired by the excellent survey &ldquo;<a href="https://arxiv.org/abs/1711.05914">How Generative Adversarial Networks and Their Variants Work: An Overview of GAN</a>&rdquo;.</p>
]]></content:encoded></item><item><title>Word Embeddings in NLP: An Introduction</title><link>https://hunterheidenreich.com/posts/intro-to-word-embeddings/</link><pubDate>Sun, 05 Aug 2018 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/intro-to-word-embeddings/</guid><description>Learn about word embeddings in NLP: from basic one-hot encoding to contextual models like ELMo. Guide with examples.</description><content:encoded><![CDATA[<h2 id="understanding-word-embeddings">Understanding Word Embeddings</h2>
<p>A word embedding maps words to real-valued vectors:</p>
<p>$$
\text{word} \rightarrow \mathbb{R}^n
$$</p>
<p>where $n$ represents the dimensionality of the embedding space.</p>
<p>The goal is simple: position semantically similar words close together in vector space. This dense representation typically uses hundreds of dimensions, a massive reduction from the millions required by one-hot encoding.</p>
<p>Word embeddings are grounded in <a href="https://en.wikipedia.org/wiki/Distributional_semantics">Zellig Harris&rsquo; distributional hypothesis</a>: words appearing in similar contexts tend to have similar meanings. This forms the foundation of distributional semantics.</p>















<figure class="post-figure center ">
    <img src="/img/distributional_semantics-50.webp"
         alt="Distributional semantics visualization"
         title="Distributional semantics visualization"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Words embedded in three-dimensional space, organized by semantic similarity</figcaption>
    
</figure>

<p>Different embedding algorithms capture various aspects of this distributional principle. This post explores the main methods for creating word embeddings and their applications in natural language processing.</p>
<p>While modern foundation models and large Vision-Language Models rely on subword tokenizers (like BPE) and Transformer embedding layers, the goal is the same: mapping discrete text to a continuous vector space where math can capture meaning. These foundational techniques build the intuition for the embedding layers in today&rsquo;s models.</p>
<h2 id="why-word-embeddings-matter-in-nlp">Why Word Embeddings Matter in NLP</h2>
<p>Computers require numerical representations to apply machine learning algorithms to text. Word embeddings bridge this gap by converting text into dense vectors that preserve semantic and syntactic relationships.</p>
<p><strong>Key advantages:</strong></p>
<ol>
<li><strong>Dense representation</strong>: Hundreds of dimensions provide a compact alternative to vocabulary-sized sparse vectors.</li>
<li><strong>Semantic preservation</strong>: Similar words cluster together in vector space.</li>
<li><strong>Mathematical operations</strong>: Enable analogical reasoning ($\text{king} - \text{man} + \text{woman} \approx \text{queen}$).</li>
<li><strong>Transfer learning</strong>: Pre-trained embeddings work across multiple tasks and domains.</li>
</ol>
<p>Modern deep learning architectures leverage these properties extensively. The development of universal, pre-trained embeddings was a significant step forward. We can use versatile embeddings that generalize across applications, eliminating the need to train task-specific representations from scratch.</p>
<h2 id="word-embedding-approaches">Word Embedding Approaches</h2>
<h3 id="one-hot-encoding-and-count-vectorization">One-Hot Encoding and Count Vectorization</h3>
<p>One-hot encoding represents the simplest approach to word vectorization. Each word gets a unique dimension in a vocabulary-sized vector, marked with 1 for presence and 0 elsewhere. Count vectorization extends this by counting the occurrences of each word in a document.</p>















<figure class="post-figure center ">
    <img src="/img/word_vector_onehot-50.webp"
         alt="One-hot encoding visualization"
         title="One-hot encoding visualization"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">One-hot encoding creates sparse vectors with single active dimensions</figcaption>
    
</figure>

<p><strong>Characteristics:</strong></p>
<ul>
<li><strong>High dimensionality</strong>: Vector length equals vocabulary size.</li>
<li><strong>Extreme sparsity</strong>: Most dimensions contain zeros.</li>
<li><strong>No relationships</strong>: Treats all words as equally distant.</li>
<li><strong>Computational efficiency</strong>: Simple to implement and understand.</li>
</ul>
<p>While lacking semantic information, count vectorization serves as a foundation for more complex methods. Let&rsquo;s look at a practical implementation using scikit-learn&rsquo;s <code>CountVectorizer</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.feature_extraction.text <span style="color:#f92672">import</span> CountVectorizer
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Initialize the vectorizer</span>
</span></span><span style="display:flex;"><span>vectorizer <span style="color:#f92672">=</span> CountVectorizer()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Sample text for demonstration</span>
</span></span><span style="display:flex;"><span>sample_text <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#34;One of the most basic ways we can numerically represent words &#34;</span>
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;is through the one-hot encoding method (also sometimes called &#34;</span>
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;count vectorizing).&#34;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Fit the vectorizer to our text data</span>
</span></span><span style="display:flex;"><span>vectorizer<span style="color:#f92672">.</span>fit(sample_text)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Examine the vocabulary and word indices</span>
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#39;Vocabulary:&#39;</span>)
</span></span><span style="display:flex;"><span>print(vectorizer<span style="color:#f92672">.</span>vocabulary_)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Transform text to vectors</span>
</span></span><span style="display:flex;"><span>vector <span style="color:#f92672">=</span> vectorizer<span style="color:#f92672">.</span>transform(sample_text)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#39;Full vector:&#39;</span>)
</span></span><span style="display:flex;"><span>print(vector<span style="color:#f92672">.</span>toarray())
</span></span></code></pre></div><p>At scale, count vectorization introduces engineering challenges. With millions of documents, the vocabulary grows large, and the sparse matrices become expensive to store and compute on. In these scaling scenarios, practitioners often turn to the <strong>Hashing Trick</strong> (via <code>HashingVectorizer</code>) to bound the dimensionality, or they move entirely to the dense embeddings discussed later in this post.</p>
<p>We can see count vectorization in action with a real dataset, building a simple text classifier for the <a href="https://www.kaggle.com/datasets/crawford/20-newsgroups">20 Newsgroups dataset</a>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.datasets <span style="color:#f92672">import</span> fetch_20newsgroups
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.feature_extraction.text <span style="color:#f92672">import</span> CountVectorizer
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.naive_bayes <span style="color:#f92672">import</span> MultinomialNB
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn <span style="color:#f92672">import</span> metrics
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Load train and test splits, removing metadata for a cleaner signal</span>
</span></span><span style="display:flex;"><span>newsgroups_train <span style="color:#f92672">=</span> fetch_20newsgroups(subset<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;train&#39;</span>,
</span></span><span style="display:flex;"><span>                                      remove<span style="color:#f92672">=</span>(<span style="color:#e6db74">&#39;headers&#39;</span>, <span style="color:#e6db74">&#39;footers&#39;</span>, <span style="color:#e6db74">&#39;quotes&#39;</span>))
</span></span><span style="display:flex;"><span>newsgroups_test <span style="color:#f92672">=</span> fetch_20newsgroups(subset<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;test&#39;</span>,
</span></span><span style="display:flex;"><span>                                     remove<span style="color:#f92672">=</span>(<span style="color:#e6db74">&#39;headers&#39;</span>, <span style="color:#e6db74">&#39;footers&#39;</span>, <span style="color:#e6db74">&#39;quotes&#39;</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Initialize and fit vectorizer on training data</span>
</span></span><span style="display:flex;"><span>vectorizer <span style="color:#f92672">=</span> CountVectorizer()
</span></span><span style="display:flex;"><span>X_train <span style="color:#f92672">=</span> vectorizer<span style="color:#f92672">.</span>fit_transform(newsgroups_train<span style="color:#f92672">.</span>data)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Build and train classifier</span>
</span></span><span style="display:flex;"><span>classifier <span style="color:#f92672">=</span> MultinomialNB(alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">0.01</span>)
</span></span><span style="display:flex;"><span>classifier<span style="color:#f92672">.</span>fit(X_train, newsgroups_train<span style="color:#f92672">.</span>target)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Transform test data and make predictions</span>
</span></span><span style="display:flex;"><span>X_test <span style="color:#f92672">=</span> vectorizer<span style="color:#f92672">.</span>transform(newsgroups_test<span style="color:#f92672">.</span>data)
</span></span><span style="display:flex;"><span>y_pred <span style="color:#f92672">=</span> classifier<span style="color:#f92672">.</span>predict(X_test)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Evaluate performance</span>
</span></span><span style="display:flex;"><span>accuracy <span style="color:#f92672">=</span> metrics<span style="color:#f92672">.</span>accuracy_score(newsgroups_test<span style="color:#f92672">.</span>target, y_pred)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;Accuracy: </span><span style="color:#e6db74">{</span>accuracy<span style="color:#e6db74">:</span><span style="color:#e6db74">.3f</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span></code></pre></div><p>This provides a solid baseline. To capture actual semantic meaning and reduce dimensionality, we must move beyond simple counting.</p>
<h3 id="tf-idf-term-frequency-inverse-document-frequency">TF-IDF (Term Frequency-Inverse Document Frequency)</h3>
<p><a href="https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html">TF-IDF</a> extends one-hot encoding by weighting terms based on their importance across a document collection. TF-IDF combines:</p>
<ul>
<li><strong>Term Frequency (TF)</strong>: How often a word appears in a document</li>
<li><strong>Inverse Document Frequency (IDF)</strong>: How rare a word is across all documents</li>
</ul>
<p>This weighting scheme reduces the impact of common words (like &ldquo;the&rdquo; or &ldquo;and&rdquo;) while emphasizing distinctive terms that appear frequently in specific documents but rarely elsewhere.</p>
<p><strong>Advantages:</strong></p>
<ul>
<li>Captures document-level importance</li>
<li>Reduces impact of stop words</li>
<li>Effective for information retrieval tasks</li>
</ul>
<p><strong>Limitations:</strong></p>
<ul>
<li>Still high-dimensional and sparse</li>
<li>No semantic relationships between terms</li>
<li>Context-independent representation</li>
</ul>
<h3 id="co-occurrence-matrices">Co-Occurrence Matrices</h3>
<p>Co-occurrence matrices capture word relationships by recording which terms appear together within defined contexts (sentences, paragraphs, or fixed windows). The resulting matrix has dimensions equal to vocabulary size squared, with entries showing co-occurrence frequency.</p>















<figure class="post-figure center ">
    <img src="/img/Word_co-occurrence_network_%28range_3_words%29_-_ENG-50.webp"
         alt="Co-occurrence network visualization"
         title="Co-occurrence network visualization"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Co-occurrence relationships within a three-word window</figcaption>
    
</figure>

<p><strong>Key properties:</strong></p>
<ul>
<li><strong>Global statistics</strong>: Captures corpus-wide word relationships</li>
<li><strong>Symmetric relationships</strong>: Mutual co-occurrence patterns</li>
<li><strong>Extreme dimensionality</strong>: Vocabulary size squared creates storage challenges</li>
<li><strong>Sparse representation</strong>: Most word pairs never co-occur</li>
</ul>
<p>While computationally expensive to store and process, co-occurrence matrices form the foundation for advanced methods like GloVe that compress this information into dense representations.</p>
<h2 id="neural-network-based-embeddings">Neural Network-Based Embeddings</h2>
<h3 id="neural-probabilistic-language-models">Neural Probabilistic Language Models</h3>
<p><a href="https://www.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf">Neural probabilistic models</a> pioneered the use of neural networks for learning word embeddings. These models learn dense representations as a byproduct of language modeling, predicting the next word in a sequence.</p>















<figure class="post-figure center ">
    <img src="/img/bengio-npm-50.webp"
         alt="Neural probabilistic model diagram"
         title="Neural probabilistic model diagram"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Architecture of neural probabilistic language models</figcaption>
    
</figure>

<p><strong>Training process:</strong></p>
<ol>
<li>Initialize random dense embeddings for each vocabulary word</li>
<li>Use embeddings as inputs to predict language modeling objectives</li>
<li>Update embeddings through backpropagation based on prediction errors</li>
<li>Resulting embeddings capture patterns useful for the training task</li>
</ol>
<p>This approach demonstrated that task-specific embeddings could be learned jointly with model objectives, establishing the foundation for modern embedding methods.</p>
<h3 id="word2vec">Word2Vec</h3>
<p><a href="https://code.google.com/archive/p/word2vec/">Word2Vec</a> made word embeddings practical at scale by introducing efficient training algorithms for massive corpora. It popularized compelling vector arithmetic properties, enabling analogical reasoning like the famous &ldquo;$\text{king} - \text{man} + \text{woman} \approx \text{queen}$&rdquo; example (a vector-offset regularity first reported by Mikolov, Yih &amp; Zweig (2013) on recurrent-network language-model embeddings).</p>















<figure class="post-figure center ">
    <img src="/img/Word_vector_illustration.webp"
         alt="Word2Vec vector arithmetic visualization"
         title="Word2Vec vector arithmetic visualization"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Word2Vec demonstrates analogical relationships through vector arithmetic</figcaption>
    
</figure>

<p><strong>Two training architectures:</strong></p>
<h4 id="continuous-bag-of-words-cbow">Continuous Bag-of-Words (CBOW)</h4>
<p>Predicts target words from surrounding context words. Given a window of context words, the model learns to predict the central word.</p>
<h4 id="skip-gram">Skip-Gram</h4>
<p>Predicts context words from target words. Given a central word, the model learns to predict surrounding words within a defined window.</p>
<p><strong>Key advantages:</strong></p>
<ul>
<li><strong>Computational efficiency</strong>: Much faster than neural probabilistic models</li>
<li><strong>Scalable training</strong>: Can process billion-word corpora effectively</li>
<li><strong>Quality embeddings</strong>: Captures semantic and syntactic relationships</li>
<li><strong>Flexible context</strong>: Window size controls topical vs. functional similarity</li>
</ul>
<p>The choice of window size significantly impacts learned relationships. Larger windows capture topical associations, while smaller windows focus on syntactic and functional similarities.</p>
<h3 id="glove-global-vectors">GloVe (Global Vectors)</h3>
<p><a href="https://nlp.stanford.edu/projects/glove/">GloVe</a> combines the best aspects of matrix factorization methods (which capture global corpus statistics) and local context window approaches like Word2Vec. Matrix factorization methods excel at global patterns but struggle with analogical reasoning, while Word2Vec captures local relationships but may miss global structure.</p>
<p><strong>Key innovation:</strong>
GloVe trains on a global word-context co-occurrence matrix, incorporating corpus-wide statistical information while maintaining the analogical reasoning capabilities that made Word2Vec successful.</p>
<p><strong>Advantages over Word2Vec:</strong></p>
<ul>
<li><strong>Global optimization</strong>: Leverages entire corpus statistics</li>
<li><strong>Better performance</strong>: Often outperforms Word2Vec on word similarity and analogy tasks</li>
<li><strong>Stable training</strong>: More consistent convergence due to global objective function</li>
</ul>
<p>The result is embeddings that capture both local syntactic patterns and global semantic relationships more effectively.</p>
<h2 id="contextual-embedding-methods">Contextual Embedding Methods</h2>
<h3 id="fasttext">FastText</h3>
<p><a href="https://github.com/facebookresearch/fastText">FastText</a> addresses a critical limitation of previous methods: handling out-of-vocabulary (OOV) words. By incorporating subword information, FastText can generate meaningful representations for previously unseen words.</p>
<p><strong>Subword approach:</strong></p>
<ul>
<li>Decomposes words into character n-grams (typically 3-6 characters)</li>
<li>Represents words as sums of their component n-grams</li>
<li>Trains using skip-gram objective with negative sampling</li>
</ul>
<p><strong>Key advantages:</strong></p>
<ul>
<li><strong>OOV handling</strong>: Can embed unseen words using known subword components</li>
<li><strong>Morphological awareness</strong>: Captures relationships between related word forms</li>
<li><strong>Multilingual support</strong>: Facebook released pre-trained embeddings for 294 languages</li>
<li><strong>Robust performance</strong>: Particularly effective for morphologically rich languages</li>
</ul>
<p>For example, if the model knows &ldquo;navigate,&rdquo; it can provide meaningful representation for &ldquo;circumnavigate&rdquo; by leveraging shared subword components, even if &ldquo;circumnavigate&rdquo; wasn&rsquo;t in the training data.</p>
<h3 id="poincaré-embeddings">Poincaré Embeddings</h3>
<p><a href="https://radimrehurek.com/gensim/models/poincare.html">Poincaré embeddings</a> introduce a novel approach by learning representations in hyperbolic space. This geometric innovation specifically targets hierarchical relationships in data.</p>
<p><strong>Hyperbolic geometry advantages:</strong></p>
<ul>
<li><strong>Natural hierarchy encoding</strong>: Distance represents similarity, while norm encodes hierarchical level</li>
<li><strong>Efficient representation</strong>: Requires fewer dimensions for hierarchical data</li>
<li><strong>Mathematical elegance</strong>: Leverages properties of hyperbolic space for embedding optimization</li>
</ul>
<p><strong>Applications:</strong>
Particularly effective for data with inherent hierarchical structure, such as:</p>
<ul>
<li>WordNet taxonomies</li>
<li>Organizational charts</li>
<li>Computer network topologies</li>
<li>Knowledge graphs</li>
</ul>
<p>The <a href="https://arxiv.org/abs/1705.08039">original paper</a> demonstrates good efficiency in reproducing WordNet relationships with significantly lower dimensionality compared to traditional embedding methods.</p>
<h2 id="contextual-embeddings">Contextual Embeddings</h2>
<h3 id="elmo-embeddings-from-language-models">ELMo (Embeddings from Language Models)</h3>
<p><a href="https://github.com/allenai/allennlp-models">ELMo</a> represents a paradigm shift toward contextual word representations. ELMo generates dynamic representations based on sentence context, adapting to word usage patterns.</p>
<p><strong>Architecture:</strong></p>
<ul>
<li><strong>Bidirectional LSTM</strong>: Processes text in both forward and backward directions</li>
<li><strong>Character-level input</strong>: Handles OOV words and captures morphological patterns</li>
<li><strong>Multi-layer representations</strong>: Combines different abstraction levels</li>
</ul>
<p><strong>Layer specialization:</strong></p>
<ul>
<li><strong>Lower layers</strong>: Excel at syntactic tasks (POS tagging, parsing)</li>
<li><strong>Higher layers</strong>: Capture semantic relationships (word sense disambiguation)</li>
<li><strong>Combined layers</strong>: Weighted combination achieves good performance</li>
</ul>
<p><strong>Key innovation:</strong>
ELMo embeddings vary by context. The word &ldquo;bank&rdquo; receives different representations in &ldquo;river bank&rdquo; versus &ldquo;financial bank,&rdquo; addressing polysemy directly through contextual awareness.</p>
<p>This approach achieved strong performance across numerous NLP tasks by providing context-sensitive representations that adapt to word usage patterns.</p>
<h3 id="probabilistic-fasttext">Probabilistic FastText</h3>
<p><a href="https://github.com/benathi/multisense-prob-fasttext">Probabilistic FastText</a> addresses polysemy (words with multiple meanings) through probabilistic modeling. Traditional embeddings conflate different word senses into single representations, limiting their precision.</p>
<p><strong>The polysemy problem:</strong>
Consider &ldquo;rock&rdquo; which can mean:</p>
<ul>
<li>Rock music (genre)</li>
<li>A stone (geological object)</li>
<li>Rocking motion (verb)</li>
</ul>
<p>Standard embeddings average these meanings, producing representations that may not capture any sense precisely.</p>
<p><strong>Probabilistic approach:</strong>
Probabilistic FastText represents words as Gaussian mixture models: probability distributions that can capture multiple distinct meanings as separate components.</p>
<p><strong>Advantages:</strong></p>
<ul>
<li><strong>Multi-sense representation</strong>: Each word sense gets its own distribution</li>
<li><strong>Context sensitivity</strong>: Can select appropriate sense based on usage context</li>
<li><strong>Uncertainty quantification</strong>: Probabilistic framework captures embedding confidence</li>
</ul>
<p>This approach provides a more nuanced treatment of lexical ambiguity, particularly valuable for words with distinct, context-dependent meanings.</p>
<h2 id="summary-and-future-directions">Summary and Future Directions</h2>
<p>Word embeddings have evolved from simple one-hot encodings to contextual representations that capture nuanced linguistic relationships. Each approach offers distinct advantages:</p>
<p><strong>Static embeddings</strong> (Word2Vec, GloVe, FastText) provide:</p>
<ul>
<li>Computational efficiency for large-scale applications</li>
<li>Pre-trained models available for numerous languages</li>
<li>Clear analogical reasoning capabilities</li>
<li>Good performance on many downstream tasks</li>
</ul>
<p><strong>Contextual embeddings</strong> (ELMo, BERT, GPT) offer:</p>
<ul>
<li>Dynamic representations based on sentence context</li>
<li>Better handling of polysemy and word sense disambiguation</li>
<li>Strong performance on complex NLP tasks</li>
<li>Ability to capture subtle contextual nuances</li>
</ul>
<p><strong>Choosing the right approach</strong> depends on:</p>
<ul>
<li><strong>Task requirements</strong>: Static embeddings for efficiency, contextual for accuracy</li>
<li><strong>Data availability</strong>: Pre-trained models vs. domain-specific training</li>
<li><strong>Computational constraints</strong>: Static embeddings require less processing power</li>
<li><strong>Language coverage</strong>: Consider availability of pre-trained models for target languages</li>
</ul>
<p>The field continues advancing toward more efficient contextual models, better multilingual representations, and embeddings that capture increasingly complex linguistic phenomena.</p>
<p>For a from-scratch Word2Vec implementation in PyTorch (Skip-gram and CBOW, with hierarchical softmax and negative sampling) that takes these concepts further, see the <a href="/projects/modern-word2vec/">PyTorch Word2Vec project</a>.</p>
]]></content:encoded></item></channel></rss>