<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vlad Navitski</title>
    <description>The latest articles on DEV Community by Vlad Navitski (@farabhi).</description>
    <link>https://dev.to/farabhi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4111206%2Fae6cc9cb-98e9-4ec5-bb78-8e71e31663f9.jpg</url>
      <title>DEV Community: Vlad Navitski</title>
      <link>https://dev.to/farabhi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/farabhi"/>
    <language>en</language>
    <item>
      <title>Taking apart a result that (never) worked</title>
      <dc:creator>Vlad Navitski</dc:creator>
      <pubDate>Thu, 10 Sep 2026 23:17:14 +0000</pubDate>
      <link>https://dev.to/farabhi/taking-apart-a-result-that-never-worked-44d4</link>
      <guid>https://dev.to/farabhi/taking-apart-a-result-that-never-worked-44d4</guid>
      <description>&lt;p&gt;Sometimes, being restless about small things is a good trait. It helps to dismantle a beautiful facade that hides broken machinery inside, and as such clears the ground for building something that does actual work. That's exactly what happened with my RL experiment, and this is my story about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that worked
&lt;/h2&gt;

&lt;p&gt;The setup: a known EGFR inhibitor (a molecule that blocks EGFR, a receptor whose overactivity drives several cancers), fifteen steps to improve it, a composite score combining predicted activity with drug-likeness properties. Five policies - Random, ε-greedy, triggered ε-greedy, UCB, Thompson Sampling - differing in how they trade off &lt;strong&gt;&lt;em&gt;exploiting&lt;/em&gt;&lt;/strong&gt; what they've learned / &lt;strong&gt;&lt;em&gt;exploring&lt;/em&gt;&lt;/strong&gt; what they haven't. Tabular Q-learning, linear value function, three random seeds.&lt;/p&gt;

&lt;p&gt;The results were clean. Learned policies crossed the success threshold more often than random modification. Some cleared conventional significance. The weight matrices showed distinct, interpretable functional-group preferences - the policies had clearly learned &lt;em&gt;something&lt;/em&gt;, and it looked like the something was useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bothersome snags
&lt;/h2&gt;

&lt;p&gt;However, there were some &lt;strong&gt;&lt;em&gt;irregularities&lt;/em&gt;&lt;/strong&gt; that showed up quite early.  The scene was set when I decided to try different hosts just to see if there would be any speedup, as the experiment took a considerable time to run. I didn't expect much as I didn't use any CUDA-like code, so GPUs and TPUs shouldn't have had much to chew on. It transpired exactly as expected - while there was a speed increase, it was moderate and was explained by faster CPUs on those hosts. What I didn't expect was that the performance of RL policies would differ between hosts. While RL policies still outperformed baseline and random modifications, they showed different numbers of hits and different statistical significance, and as a result, different rankings.  What kept gnawing at me in the back of my mind was the fact that I couldn't pinpoint where the differences came from. Changing machines might change some arithmetics, but it shouldn't have affected the rankings and numbers of hits. &lt;/p&gt;

&lt;p&gt;Why it stayed just an 'irritation' and didn't lead to the project overhaul immediately was the fact that its results essentially stayed the same. Difference in numbers didn't look like much when the significance boundary was crossed confidently. My thought process at that time was that I just had a stochastic process. It didn't challenge results as it still had a stable conclusion underneath. &lt;/p&gt;

&lt;p&gt;Ironically, I used the same reasoning to wave away another concern - I had doubts that single matrix could be a good retaining mechanism for the policies in my experiment. The initial molecules that policies are called to improve on are chemically different compounds, they would require different modifications to increase their useful traits. Single matrix retaining preferences for all possible groups of initial scaffolds seemed like a naive implementation. "Yet it worked" - I kept telling myself, "The stats don't lie".&lt;/p&gt;

&lt;p&gt;However, they might hint at something. For example, they might hint that the great results I was witnessing were nothing more than random success. Irritating differences among hosts were exactly that - early signs that something was seriously wrong. Tracing those differences back to their roots and learning invaluable lessons along the way turned out to be the most important part of the project, at least this iteration of it. &lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling the thread
&lt;/h2&gt;

&lt;p&gt;Where the whole fabric started to disintegrate was where it should have been the project's ultimate triumph - increasing the number of experiments (or 'seeds', as each experiment was controlled by a randomizer's seed for reproducibility purposes). As I've mentioned before, the script was quite slow, so I kept the number of experiment runs to a minimum I was comfortable with - just three of them for each policy. When I thought I was ready to share my results with a bigger world, I increased the number of seeds to ten per policy. &lt;/p&gt;

&lt;p&gt;It did turn out to be a better sampling, but a better sampling that showed exactly what my initial results were - merely, the lucky chance. With ten seeds the performance of RL policies degraded dramatically, if not catastrophically. Many of them began to trail behind Random, to the point where it might be just one policy showing a significant improvement over random modifications. &lt;/p&gt;

&lt;p&gt;The catastrophe was not that RL didn't work - in any case, I already had doubts that my setup was enough for RL agents to learn properly. The real issue was something else - namely, unstable results that in different runs would give you every answer possible, however contradictory. I didn't know what, if anything, worked in my script and what needed to be done to fix what didn't work. &lt;/p&gt;

&lt;p&gt;Adding it all together - single matrix doubts, variability across hosts, collapsing with increased number of experiments - I realized that all 'irregularities' need to be accounted for properly if I wanted the project to survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  First patch
&lt;/h2&gt;

&lt;p&gt;Fixing the script started somewhere unexpected: one line of data preparation.&lt;/p&gt;

&lt;p&gt;The pipeline selects a middle band of compounds (EGFR molecules from the curated data set) by sorting on measured score and taking a positional slice. But those score values are mostly ties - 5,400 of 6,000 rows share a value with some other row - and a sort with ties leaves the tied rows in an order nobody defined. NumPy picks its sort routine based on the CPU it's running on. Machines with the AVX-512 instruction set ordered the ties one way; machines without it ordered them another. Different molecules entered the pool, and everything downstream moved with them.&lt;/p&gt;

&lt;p&gt;No arithmetic differed. Not one number was computed differently. The ordering of equal values changed, and a positional slice turned that into a different dataset. I've written that part up &lt;a href="https://farabhi.github.io/2026/09/03/same-code-same-seed-different-answer.html" rel="noopener noreferrer"&gt;separately&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Fixing it was one extra column in the sort key. The dataset became reproducible across every environment I've tried since: a CPU vendor change, an AVX-512 boundary, NumPy and Python version bumps.&lt;/p&gt;

&lt;p&gt;That should have been the end of it. Instead it just started a chain reaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patch won't stop untangling
&lt;/h2&gt;

&lt;p&gt;Same data on every machine, same seeds - and the policies still diverged. Now my concern reached the point where I put everything else aside and concentrated on tracing it back to its exact sources. &lt;/p&gt;

&lt;p&gt;After several debugging experiments I realized that variations come from last-bit differences in floating point results that might differ between machines.&lt;/p&gt;

&lt;p&gt;The mechanism is simple to explain. A policy computes action values by multiplying a state vector by a weight matrix, then applies the argmax. That arithmetic is deterministic on any given machine - repeat a run and you get identical bits - but not across machines. Different processors execute it along different instruction paths and can land on a different final bit. If two action values differ only there, argmax returns a different action on one machine than on another. That action changes the molecule, which changes the reward, which updates the weights. From that point the two runs aren't just 'a bit different'. They are completely separate and might lead to different outcomes. &lt;/p&gt;

&lt;p&gt;My first instinct was to treat this as a numerical problem and go looking for a sturdier selection rule. That instinct was wrong, and understanding why is the moment the whole project turned over.&lt;/p&gt;

&lt;p&gt;A one-bit gap between two action values means the policy considers those actions &lt;em&gt;equally good&lt;/em&gt;. Choosing between them arbitrarily is just an exploration phasein RL policy, there's nothing wrong with that. It doesn't matter whether exploration is triggered by a quadrillionth value or by a tenth after rounding it up. What actually is wrong is that a policy keeps deciding at the last bit throughout the whole experiment. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a trained policy is still deciding at the last bit, it means it never separated the signal from the noise&lt;/strong&gt;. A policy that had learned something would have gaps between best and second-best action many orders of magnitude larger than machine precision, and a rounding difference couldn't reach them. The sensitivity I'd been treating as a numerical nuisance was a measurement, and what it measured was that nothing had been learned.&lt;/p&gt;

&lt;p&gt;It was quite a harsh landing, so naturally I asked myself how it was possible to be in the dark about it for so long.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually stitching it all together
&lt;/h2&gt;

&lt;p&gt;The environment. Specifically, a rule I'd written without thinking hard about it.&lt;/p&gt;

&lt;p&gt;Each episode takes a fifteen-step walk and reports the &lt;em&gt;best molecule seen anywhere along the way&lt;/em&gt;. That's Monte Carlo maximisation: draw many samples, keep the maximum, and the maximum improves with the number of draws. It's the retention that produces the good results, not the search.&lt;/p&gt;

&lt;p&gt;You can see it in what beats what. At the extreme - each method's best fifty molecules - every method clears the screening baseline decisively, Random included. Across everything the methods retained, Random and the screening baseline are statistically indistinguishable, and the learned policies sit slightly below both. The environment's tail is good. Its typical output is ordinary.&lt;/p&gt;

&lt;p&gt;Random comes out ahead of every learned policy, which is the detail I find most instructive. Same walks, same retention rule, same budget - and the policy that imposes no preference gets the best tail. A systematic preference that can't tell contexts apart costs you sampling variety and buys nothing back. Consistency without correctness is strictly worse than no consistency at all when you're keeping the best of many samples.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there was nothing to learn
&lt;/h2&gt;

&lt;p&gt;My initial doubt was a key to it all. A single linear weight matrix maps molecular fingerprints to action values across every structural family in the pool at once. When different scaffolds reward different edits - hydroxyl here, methyl there - one matrix cannot hold both, so instead it averages them.&lt;/p&gt;

&lt;p&gt;That isn't a failure to detect a signal, rather the model had nowhere to put one. You can see it in the learned weights: every action value ends up negative, and the policy is choosing the least-bad option rather than a good one. And it's the same fact the argmax margins were reporting from another direction. Values that can't separate contexts can't separate actions either.&lt;/p&gt;

&lt;p&gt;The policy also controls less than it appears to. It picks which functional group to attach. It doesn't pick the starting molecule - random draw. It doesn't pick where the group lands - also random. The chemical context in which its one decision gets made is entirely outside its control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;p&gt;I ended with no result and a much better understanding of what a result would require: a total sort key so the data is stable, host discipline so trajectories are comparable, enough seeds that a verdict isn't a coin flip, and a representation rich enough for the policy's one decision to mean anything.&lt;/p&gt;

&lt;p&gt;That's a worse outcome than "Thompson Sampling wins" and a more useful one. The winning version would have been a leaderboard entry, and - given how the ranking moved under every perturbation I eventually tried - an unreproducible one.&lt;/p&gt;

&lt;p&gt;The lesson I'd actually hand to myself and to whoever'd listen:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every inconsistency in your results needs an account, not a label. "It's&lt;br&gt;
stochastic" is a label. An account names what varies, says why, and predicts&lt;br&gt;
the variation well enough that you can reproduce it on demand. If you can't&lt;br&gt;
reproduce your own noise, you don't understand it - and until you understand&lt;br&gt;
it, you cannot tell it apart from the thing you're trying to measure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The harder part isn't just knowing that. It's that nothing external will make you do something about it. Nothing was challenging my results. They were good, consistent, and the one loose thread had a plausible-sounding explanation attached. The only thing that made me pull on it was that the explanation never quite satisfied me, and I've come to think that low-grade dissatisfaction is worth more than it feels like at the time. A result nothing is questioning is exactly the one that has to be questioned by you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code, data, and full write-up:&lt;br&gt;
&lt;a href="https://github.com/farabhi/rl-lead-optimization" rel="noopener noreferrer"&gt;github.com/farabhi/rl-lead-optimization&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>software</category>
      <category>datascience</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>Same code, same seed, different answer</title>
      <dc:creator>Vlad Navitski</dc:creator>
      <pubDate>Sat, 05 Sep 2026 14:14:42 +0000</pubDate>
      <link>https://dev.to/farabhi/same-code-same-seed-different-answer-29f8</link>
      <guid>https://dev.to/farabhi/same-code-same-seed-different-answer-29f8</guid>
      <description>&lt;p&gt;The same code, with the same fixed seed and the same input file, gave different answers on different machines. I noticed that early, but waved it away. The differences looked cosmetic, less successful runs didn't disprove my findings, so I lulled myself with a comfortable story about a stochastic process.&lt;/p&gt;

&lt;p&gt;That ended when I tried to increase the sample size. Unexpectedly, the result I had been building on collapsed, and with it my reason for not caring. The findings were not supported by my experiment anymore, so I naturally suspected the machine-related variances were early warning signs.&lt;/p&gt;

&lt;p&gt;The two problems turned out to be unrelated in cause, but connected in how one obscured another. The machine-dependent noise made the early signs of real failure look like more of the same. Had I fixed the architecture problem when I first saw it, the genuine one would have stood out sooner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, stripped to its bones
&lt;/h2&gt;

&lt;p&gt;The pipeline starts by selecting a "middle band" of samples from a dataset - discard the extremes, keep the moderate ones. The code did what looks entirely reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df_sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;middle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df_sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iloc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# drop the tails, keep the middle
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing special, right? Sort rows by a column, select rows by position. Anyone doing data processing had done it many times. Yet this is exactly where my experiment was broken. &lt;/p&gt;

&lt;h2&gt;
  
  
  Ties plus a positional slice
&lt;/h2&gt;

&lt;p&gt;The first thing to mention is that the &lt;code&gt;value&lt;/code&gt; column is full of ties. Out of 6,000 rows, about 5,400 shared their value with at least one other row. The measurement only has so much precision, so the same number shows up again and again.&lt;/p&gt;

&lt;p&gt;Sorting by a column with ties gives you a &lt;em&gt;partial&lt;/em&gt; order, not a &lt;em&gt;total&lt;/em&gt; one. The sort will correctly put all rows with 7.2 value in between rows with 7.1 and 7.3. But internal arrangements of rows with 7.2 is never guaranteed. And this is harmless if you don't care about the order &lt;em&gt;inside&lt;/em&gt; a tie group. Now, if you pair sorting with &lt;code&gt;iloc[1500:4500]&lt;/code&gt; you might start caring - I know I should have. If the positions 1500 and 4500 land in the &lt;em&gt;middle&lt;/em&gt; of tie groups, then sorting and slicing might deliver different samples each time you perform them. &lt;/p&gt;

&lt;h2&gt;
  
  
  The part that made it hardware-dependent
&lt;/h2&gt;

&lt;p&gt;Yet it didn't. The sample was reproducible on a single machine, no matter how many time you run it. It's only when I changed the host I'd get variation and potential complete inversion of experiment results. My code didn't use accelerators or CUDA libraries, so why different hosts would have different outcomes?&lt;/p&gt;

&lt;p&gt;The answer is it's because the thing breaking the ties is NumPy's sort implementation, and NumPy selects its sort routine based on the CPU it is running on. Some processors&lt;br&gt;
support a vectorised instruction set called AVX-512, which lets NumPy sort through a code path that processes several values at once. Processors without it take another path. Both are correct - each produces a properly sorted array -but the value ties are resolved differently. No arithmetic changed. Not one number was computed differently. The &lt;em&gt;ordering of equal values&lt;/em&gt; changed, and a positional slice turned that invisible difference into a different sample -  which moved the apparent success rate of the whole experiment from 8/15 to 14/15 on identical input data.&lt;/p&gt;

&lt;p&gt;One possible way to confirm it is by running the identical pipeline across different cloud runtimes - I executed them on 6 separate ones. They split into exactly two groups with two different results, and the split followed one thing: whether the CPU reported AVX-512 support. Furthermore, it's impossible to know beforehand which hardware you land on. Neither runtime type, nor vendor would tell you in advance if AVX-512 will be present or not. I had to keep the diagnostic fingerprint in my experiment script always on to know if the sample will be reproduced or not.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the defect did, and what it didn't
&lt;/h2&gt;

&lt;p&gt;Let me be precise - the sort defect did not cause my result to crumble. Had the method genuinely worked, my imperfect sort would not have made it fail - it would have produced &lt;em&gt;real but variable&lt;/em&gt; outcomes, differing by host machine. At worst, I'd have had a numerical reproducibility problem and nothing more.&lt;/p&gt;

&lt;p&gt;This variability muddied the water considerably and obscured the real problem. Every architecture gave a plausible answer. Different runs gave different plausible answers. Variability in success and hits seemed to be intrinsic to the experiment and consistent with its principal claim.&lt;/p&gt;

&lt;p&gt;That's why the chain &lt;em&gt;more data → collapse → traced to the sort bug&lt;/em&gt; was my first instinct and was completely wrong. Instead, two separate facts about the same project need to be stated clearly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The method didn't work. That was always true; a larger sample made it visible.&lt;/li&gt;
&lt;li&gt;The pipeline was non-deterministic across architectures. That was also always true, and it is what stopped me from establishing (1) months earlier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They share connection even though they don't share the cause. The collapse is what finally made me take the anomaly seriously. The anomaly is why the collapse took so long&lt;br&gt;
to be admitted and addressed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix, and the thing the fix is an instance of
&lt;/h2&gt;

&lt;p&gt;There are several ways to fix this. I chose to make the sort key total, by adding a tiebreaker that is unique per row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df_sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# 'id' breaks every tie
&lt;/span&gt;&lt;span class="n"&gt;middle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df_sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iloc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every row has a definite position, no ties remain for any implementation to resolve differently, and every machine produces a byte-identical ordering. After the change, all six runtimes produced identical output down to a hash of the selected sample. Sample reproducibility has since held across a CPU vendor change, an AVX-512 boundary, and both NumPy and Python version bumps.&lt;/p&gt;

&lt;p&gt;Again, fixing sample reproducibility didn't save the experiment from being refuted. It just meant that from then on I could distinguish between different sources of variability. Which is a must have for learning anything from an experiment at all.&lt;/p&gt;

&lt;p&gt;There is a second fix worth mentioning, because it also works. Many sort functions accept a "stable" option (kind='stable' in NumPy), which preserves the input order of tied elements. Stability is a property of the algorithm, not of the hardware, so a stable sort would have given me the same pool on every machine. Had I reached for it, this post would have a shorter middle section.&lt;/p&gt;

&lt;p&gt;I prefer the total key for reasons that are real but not dramatic. Stability makes the output a function of the input order - a property nothing in my code sets, records, or checks. A total key makes it a function of the data: shuffle the dataframe first and you get an identical pool. And the rule is visible. sort_values(['value', 'id']) states in one line how ties are broken; kind='stable' defers the question to however the rows happened to arrive.&lt;/p&gt;

&lt;p&gt;Neither protects you from the larger thing. If the underlying dataset changes - a new release, a different query, more rows - the pool changes too, and no sort key saves you. That's a separate problem with a separate answer, which in my case was to commit the exact snapshot the results came from.&lt;/p&gt;

&lt;p&gt;But using a total sort key was just a recipe for this particular experiment. There's a wider lesson to be learnt from it that could be applied to future experiments as well, even if recipes will look different. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sort key was a proxy. The rows were the payload.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If my dataframe had held nothing but &lt;code&gt;value&lt;/code&gt;, ties would have been genuinely harmless. Any tie ordering produces the same multiset of numbers, and nothing downstream could tell the difference. In that world the slice is safe and this post doesn't exist.&lt;/p&gt;

&lt;p&gt;But the rows carried something else - in my case molecular structures, and the downstream code depended heavily on &lt;em&gt;which structures&lt;/em&gt; it received. &lt;code&gt;value&lt;/code&gt; was&lt;br&gt;
only a convenient handle for choosing among them.&lt;/p&gt;

&lt;p&gt;So when I sorted on &lt;code&gt;value&lt;/code&gt; alone, it was as if I made a claim in my python code: &lt;em&gt;rows with equal &lt;code&gt;value&lt;/code&gt; are interchangeable for my purposes.&lt;/em&gt; That claim is true of the numbers. It is false of the molecules. And nothing in the code marked the difference, because the proxy and the payload travel in the same object and get selected by the same&lt;br&gt;
operation.&lt;/p&gt;

&lt;p&gt;That's the general shape, and it isn't specific to sorting:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Know which column you actually care about, and don't let a different column&lt;br&gt;
stand in for it silently. If a proxy has to do the selecting, be explicit&lt;br&gt;
about what the proxy's equivalences mean for the payload.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The total-key fix works precisely because it retires the claim. With a unique tiebreaker there are no equivalences left to be wrong about.&lt;/p&gt;
&lt;h2&gt;
  
  
  You have probably seen this bug before
&lt;/h2&gt;

&lt;p&gt;If it feels familiar, it's the classic SQL pagination bug wearing different&lt;br&gt;
clothes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;created_at&lt;/code&gt; isn't unique - and timestamps collide more often than people expect - then "page 3" isn't well defined. The same row can appear on two pages&lt;br&gt;
or none.&lt;/p&gt;

&lt;p&gt;And it fails for exactly the reason above. Nobody paginates in order to collect timestamps. You want the &lt;em&gt;rows&lt;/em&gt;; &lt;code&gt;created_at&lt;/code&gt; is the proxy you're ordering them by. Equal timestamps do not mean interchangeable rows, but &lt;code&gt;ORDER BY created_at&lt;/code&gt; asserts that they do. The fix is the same: add a unique tiebreaker, and the false claim disappears. SQL doesn't offer the stable-sort shortcut - tables are unordered by definition, so there's no input order to preserve. Either you supply a total key, or which rows land on which page is up to the planner.&lt;/p&gt;

&lt;p&gt;So, the practical rule and the one underneath it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Any time you select data by position - a slice, a limit, an offset, "the top N" - the sort that defines those positions must be total.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And more generally: &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When you order by one thing to select another, you are asserting that the first thing's ties are irrelevant to the second.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And one about the debugging, which cost me the most:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When two things are wrong at once, the noisy one hides the quiet one. My sort defect produced results that varied architecture to architecture, and that variation was exactly the  shape of the evidence I needed to see that the method itself wasn't working. I spent months reading one problem's symptoms as the other's noise.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This came out of a reinforcement-learning experiment on molecular lead optimization; the code, data and full write-up are at &lt;a href="https://github.com/farabhi/rl-lead-optimization" rel="noopener noreferrer"&gt;github.com/farabhi/rl-lead-optimization&lt;/a&gt;. Fixing this made the dataset reproducible across machines. It did not make the experiment reproducible - that's a separate problem, and a stranger one, which I'll write about next.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>python</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
