<?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: Otu Jacob</title>
    <description>The latest articles on DEV Community by Otu Jacob (@otu_jacob).</description>
    <link>https://dev.to/otu_jacob</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%2F4206125%2F9302fe24-8005-4c68-bad2-49edce8b3c64.jpeg</url>
      <title>DEV Community: Otu Jacob</title>
      <link>https://dev.to/otu_jacob</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/otu_jacob"/>
    <language>en</language>
    <item>
      <title>I tried to beat the simplest baseline with a fraud trust score. Pre-registering the test taught me this.</title>
      <dc:creator>Otu Jacob</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:26:01 +0000</pubDate>
      <link>https://dev.to/otu_jacob/i-tried-to-beat-the-simplest-baseline-with-a-fraud-trust-score-pre-registering-the-test-taught-me-576e</link>
      <guid>https://dev.to/otu_jacob/i-tried-to-beat-the-simplest-baseline-with-a-fraud-trust-score-pre-registering-the-test-taught-me-576e</guid>
      <description>&lt;p&gt;&lt;em&gt;Evidence in this post comes from synthetic data only. Nothing here shows how the system performs on real fraud.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A fraud model gives an analyst a score. It does not say how far to believe that score for this particular case. I have been building a decision-support system called Assay around that gap. Next to each risk score it produces a &lt;strong&gt;Trust Index&lt;/strong&gt; from 0 to 100, meant to say how much a person can lean on the recommendation. A High-trust case can be waved through faster. A Low-trust case goes to a senior analyst.&lt;/p&gt;

&lt;p&gt;A score like this is easy to build and hard to justify. Any model can emit a number between 0 and 100. The real question is whether the number tells you anything the model's own confidence didn't already tell you. This post is about how I tested that, and what I found when I wouldn't let myself move the goalposts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The Trust Index combines six components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model confidence&lt;/li&gt;
&lt;li&gt;cohort reliability (how often the model was right on similar past cases)&lt;/li&gt;
&lt;li&gt;explanation reliability (are the SHAP attributions stable under small perturbations?)&lt;/li&gt;
&lt;li&gt;familiarity (does this case look like anything the model has seen?)&lt;/li&gt;
&lt;li&gt;data drift&lt;/li&gt;
&lt;li&gt;data quality&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detection side is an ensemble of XGBoost, a random forest and an isolation forest, with calibrated probabilities. The system is recommend-only. It never acts on its own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdegx4mwrqbjkwgm86ky.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdegx4mwrqbjkwgm86ky.png" alt="Flow diagram: transaction and history feed point-in-time features, which feed the detection ensemble and the Trust Index. Both feed a policy engine that routes to fast review or senior review." width="800" height="454"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1. How a recommendation is produced. The Trust Index runs beside the risk score, and the policy engine uses both.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The test I cared about was simple. Among the model's wrong recommendations, does the Trust Index rank them lower than correct ones? I measured that as AUROC over "was this recommendation wrong?", and compared it with three baselines of increasing strength:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;B1:&lt;/strong&gt; distance of the score from the decision threshold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B2:&lt;/strong&gt; disagreement between ensemble members&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B3:&lt;/strong&gt; the model's maximum class probability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;B3 is the one that matters. It costs nothing, and a lot of elaborate uncertainty work fails to beat it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule one: write the decision rule before the run
&lt;/h2&gt;

&lt;p&gt;All the data is synthetic, generated with known ground truth. That lets me inject fraud types, drift and label delay, and know exactly which recommendations were wrong. It also means the data can only show that the machinery works, not that the idea works on real fraud.&lt;/p&gt;

&lt;p&gt;My first two runs were small, and ended inconclusive. The Trust Index was ahead of B3 on both seeds, but there were only 47 and 57 errors per run, so the interval included zero.&lt;/p&gt;

&lt;p&gt;The tempting move is to keep running until a number looks good. Instead I wrote the decision rule into the docstring of the validation script before running anything new:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Supported only if the mean over seeds of AUROC(Trust Index) minus AUROC(B3) has a 95% interval that excludes zero on the positive side.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I ran twelve fresh seeds that had never been used for a design decision, about 1,400 errors in total. The unit of analysis was the seed, not the case, because cases within one seed share a generator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqtled66ywdsehq7h6vxu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqtled66ywdsehq7h6vxu.png" alt="Flowchart: write the decision rule, pick fresh seeds, run once, apply the rule as written. Supported results are reported weakly; unsupported results keep the feature off, and the rule is never relaxed." width="800" height="361"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 2. The validation workflow. The rule is fixed before the run and applied as written.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trust Index minus&lt;/th&gt;
&lt;th&gt;Mean AUROC difference&lt;/th&gt;
&lt;th&gt;95% interval over seeds&lt;/th&gt;
&lt;th&gt;Ahead in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;B1, distance from threshold&lt;/td&gt;
&lt;td&gt;+0.124&lt;/td&gt;
&lt;td&gt;+0.090 to +0.157&lt;/td&gt;
&lt;td&gt;12 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B2, ensemble disagreement&lt;/td&gt;
&lt;td&gt;+0.080&lt;/td&gt;
&lt;td&gt;+0.050 to +0.109&lt;/td&gt;
&lt;td&gt;12 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B3, max class probability&lt;/td&gt;
&lt;td&gt;+0.030&lt;/td&gt;
&lt;td&gt;+0.009 to +0.051&lt;/td&gt;
&lt;td&gt;9 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Firvpy69n36n0mg3d71sc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Firvpy69n36n0mg3d71sc.png" alt="Interval plot: advantage over B1 is +0.124, over B2 +0.080, over B3 +0.030, with the B3 interval close to zero." width="800" height="300"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 3. Trust Index AUROC minus each baseline, mean over twelve seeds with 95% intervals.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;By the rule I'd set, it passes. But a lower bound of +0.009 on a base AUROC of 0.91 is a weak result, and I report it as one. It is ahead in 9 of 12 seeds, a sign test over seeds gives p = 0.15, and all twelve seeds share one generator, so they show stability over sampling, not over kinds of fraud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule two: ablate your own components
&lt;/h2&gt;

&lt;p&gt;Pre-registration also covers the parts. For each component I recomputed the index without it and measured the change in AUROC:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component removed&lt;/th&gt;
&lt;th&gt;Change in AUROC&lt;/th&gt;
&lt;th&gt;95% interval&lt;/th&gt;
&lt;th&gt;Reading&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cohort reliability&lt;/td&gt;
&lt;td&gt;-0.035&lt;/td&gt;
&lt;td&gt;-0.045 to -0.025&lt;/td&gt;
&lt;td&gt;large effect, 12 of 12 seeds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;model confidence&lt;/td&gt;
&lt;td&gt;-0.009&lt;/td&gt;
&lt;td&gt;-0.014 to -0.005&lt;/td&gt;
&lt;td&gt;small effect, 12 of 12 seeds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;explanation reliability&lt;/td&gt;
&lt;td&gt;+0.002&lt;/td&gt;
&lt;td&gt;-0.001 to +0.005&lt;/td&gt;
&lt;td&gt;no measurable value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;familiarity&lt;/td&gt;
&lt;td&gt;+0.009&lt;/td&gt;
&lt;td&gt;+0.000 to +0.017&lt;/td&gt;
&lt;td&gt;removing it helps in 10 of 12 seeds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drift, data quality&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;nothing, by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gjzl9afpb4ap6fstwfx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gjzl9afpb4ap6fstwfx.png" alt="Interval plot: removing cohort reliability costs 0.035 AUROC, model confidence 0.009; explanation reliability and familiarity show no benefit." width="800" height="330"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 4. Change in AUROC when each component is removed. Left of zero means the component helps.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The honest reading is uncomfortable. Almost all of the discriminating power comes from one component, cohort reliability, which depends on matured outcomes. A new deployment won't have them at first. Explanation reliability is the most expensive component to compute (about 27 ms per case) and showed no value on any run. Drift and data-quality components show nothing because the synthetic data is clean and stationary. Stress tests show their mechanisms work, but that is a different claim.&lt;/p&gt;

&lt;p&gt;I did not keep the component because it was elegant. It stays in the report as a product decision, to be made on data separate from this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule three: let the pre-set rule say no
&lt;/h2&gt;

&lt;p&gt;I also built a calibrated mode, where a score of 80 means "right about 80% of the time". The calibration worked, with an expected calibration error of 0.005, against 0.268 for the raw score read as a probability. But the same pre-set rule asked for ranking quality at least as good as the provisional score, and it was not shown to be. The calibrated variant ranked errors slightly worse on the stable part of each window, with an interval reaching below the limit I'd set.&lt;/p&gt;

&lt;p&gt;By the rule, the verdict was to stay off. I did not loosen the limit because the calibration number was impressive.&lt;/p&gt;

&lt;p&gt;A second variant kept the provisional order and learned only a monotone map to a probability. It passed its test (the ranking is identical by design, ECE 0.0046). But the system's own promotion gate, which the server enforces in code, still passes on only 2 of 12 seeds, mainly because beating B3 with an interval that excludes zero is hard on a single window. So calibrated mode stays off by default, and the server refuses to run it for a tenant whose bundle did not pass the gate on that tenant's own outcomes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke along the way
&lt;/h2&gt;

&lt;p&gt;Two things I'd flag for anyone doing similar work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Method changes after seeing results.&lt;/strong&gt; I made five. For example, a plain k-NN distance percentile treated every sparse region as novel, including known fraud such as account takeover, so I switched to a local distance ratio. I removed the isolation forest from the familiarity component, because it measures rarity, not novelty. Each change was made in response to a stress-test failure and then checked on a fresh seed that hadn't been used to choose it. They are listed in the validation report so nobody reads the result as if the design had been fixed in advance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An experiment that could not answer anything.&lt;/strong&gt; My first design for the calibrated-mode test used a short test window. A smoke run showed only 10 of 145 errors fell in the oldest 45% of the window, because nearly all errors arrive after the novel fraud type appears. The model correctly refused to fit. I changed the dataset to a longer window before running the real seeds. Nothing about the real seeds informed that choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building something similar
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Compare against the cheapest baseline first. B3 cost nothing and was the hardest to beat.&lt;/li&gt;
&lt;li&gt;Write the decision rule down before the run. It turns "does this work?" into a question with a yes or no answer, and it stops you relaxing the bar when you like the result.&lt;/li&gt;
&lt;li&gt;Treat the seed, not the case, as the unit of analysis when cases share a generator.&lt;/li&gt;
&lt;li&gt;Ablate everything. The component I was proudest of had no measurable value, and the one I'd treated as plumbing carried most of the signal.&lt;/li&gt;
&lt;li&gt;Report the negative results in the same document as the positive ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;All data and analysts are synthetic. Passing a test here shows the machinery works, not that fraud looks like my generator.&lt;/li&gt;
&lt;li&gt;Selective-label bias is not modelled: every synthetic case gets an outcome. Real fraud outcomes arrive late, and only for cases someone investigated.&lt;/li&gt;
&lt;li&gt;Intervals are wide because errors are few per run.&lt;/li&gt;
&lt;li&gt;The twelve seeds share one generator and one configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next step is a pilot with matured outcomes from a real institution. That is the only thing that can change these verdicts, and I'd rather say so than claim more than I've shown.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm Otu Samuel Jacob, a product manager working where payments, fraud and machine learning meet. Over seven years in regulated financial institutions in Nigeria &amp;amp; the UK I owned products at scale: a mobile banking payments capability that processed NGN600B+ in its first year, a POS acquiring portfolio of 25,000+ terminals, and an BaaS launch for institutional clients. I partnered with risk and compliance teams to deploy fraud-monitoring controls.&lt;/p&gt;

&lt;p&gt;I completed an MSc in Data Science and Big Data Analytics with Distinction at Wrexham University in September 2026, and I work hands-on in Python and SQL. I designed and validated Assay, a recommend-only fraud decision-support system with a calibrated risk score, a Trust Index, and analyst review at every decision. I write about testing ML systems honestly: pre-set decision rules, fresh seeds, and reporting negative results.&lt;/p&gt;

&lt;p&gt;Questions and challenges to the method are welcome in the comments.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>fintech</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
