<?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>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>
