<?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: Loh Kah Meng</title>
    <description>The latest articles on DEV Community by Loh Kah Meng (@lohkahmeng).</description>
    <link>https://dev.to/lohkahmeng</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%2F4020591%2F1dd0706e-d5fe-413e-83b9-fe07949ee06a.png</url>
      <title>DEV Community: Loh Kah Meng</title>
      <link>https://dev.to/lohkahmeng</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lohkahmeng"/>
    <language>en</language>
    <item>
      <title>One Master Dataset, Two Consumers: Building a Governed Pipeline for BI + ML</title>
      <dc:creator>Loh Kah Meng</dc:creator>
      <pubDate>Wed, 08 Jul 2026 06:01:51 +0000</pubDate>
      <link>https://dev.to/lohkahmeng/one-master-dataset-two-consumers-building-a-governed-pipeline-for-bi-ml-43i9</link>
      <guid>https://dev.to/lohkahmeng/one-master-dataset-two-consumers-building-a-governed-pipeline-for-bi-ml-43i9</guid>
      <description>&lt;h2&gt;
  
  
  The problem with two datasets
&lt;/h2&gt;

&lt;p&gt;A recent project of mine called for two things that are easy to build separately and dangerous to build separately: a Power BI dashboard for exploring workforce demographics, and four tuned ML models predicting income bracket from the same population.&lt;/p&gt;

&lt;p&gt;The tempting shortcut is two datasets — one imputed and encoded for modeling, one left more human-readable for BI. I deliberately avoided that.&lt;/p&gt;

&lt;p&gt;Two datasets drift. A fix applied to one never reaches the other, and by the second refresh cycle, "the data" means two different things depending on who you ask. Six months in, someone's dashboard says one number and someone's model says another, and nobody can explain why without a forensic diff.&lt;/p&gt;

&lt;p&gt;So instead, everything here traces back to one governed pipeline (&lt;code&gt;build_master_dataset.py&lt;/code&gt;) that produces a single master table — &lt;code&gt;adult_master.csv&lt;/code&gt; / &lt;code&gt;.parquet&lt;/code&gt; — 48,790 rows, 26 columns, built from the UCI Adult Census Income dataset. Power BI reads the human-readable &lt;code&gt;income_bracket&lt;/code&gt; and categorical columns directly. The ML pipeline reads the numeric &lt;code&gt;income_target&lt;/code&gt; flag and the same underlying predictors. One source of truth, two consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Missingness you can audit, not smooth over
&lt;/h2&gt;

&lt;p&gt;The raw source data uses &lt;code&gt;?&lt;/code&gt; for missing categorical values — &lt;code&gt;workclass&lt;/code&gt; (5.73% missing), &lt;code&gt;occupation&lt;/code&gt; (5.75%), &lt;code&gt;native_country&lt;/code&gt; (1.75%). The easy move is to impute these away with the mode or drop the rows. I didn't do either.&lt;/p&gt;

&lt;p&gt;Every missing value is relabeled &lt;code&gt;Unknown&lt;/code&gt; and paired with a boolean &lt;code&gt;*_was_missing&lt;/code&gt; flag. That means the fact that a value was missing stays fully auditable downstream — in the BI layer, in the model, in any report — instead of being silently absorbed into "most common category" and forgotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dashboard built around one reusable pattern
&lt;/h2&gt;

&lt;p&gt;The Power BI layer isn't a KPI wall. It's built around one DAX pattern — &lt;code&gt;High Income Rate vs Overall&lt;/code&gt; — that drops onto any breakdown (occupation, education, region, work-hours category) and immediately shows whether that segment sits above or below the population baseline. Twenty measures total, plus three sort-key columns so categories like "Part-time (&amp;lt;20)" through "Overtime (60+)" render in logical rather than alphabetical order.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually predicts income — according to four models, not one
&lt;/h2&gt;

&lt;p&gt;Rather than trust a single model's built-in feature importance (which can vary wildly by algorithm), I computed permutation importance across all four tuned models on a held-out sample and looked for agreement.&lt;/p&gt;

&lt;p&gt;The signal was consistent: relationship/marital status dominates, followed by capital gains, education, age, occupation, and hours worked.&lt;/p&gt;

&lt;p&gt;Two findings were worth calling out explicitly rather than leaving buried in a model file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Married individuals — specifically the "Husband" and "Wife" relationship categories — show a high-income rate near 45–47%, against roughly 1.5% for adults still classified as someone's child. That gap reflects age and career-stage confounding as much as marital status itself, and it's worth saying so rather than letting the number imply causation it doesn't have.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fnlwgt&lt;/code&gt;, a U.S. Census sampling weight included in the raw data, has effectively zero correlation with income (Pearson r = −0.006). It was excluded from modeling entirely rather than left in to add noise — a reminder that "more columns" isn't automatically "more signal."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Four models, one honest comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;th&gt;ROC-AUC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Logistic Regression&lt;/td&gt;
&lt;td&gt;81.1%&lt;/td&gt;
&lt;td&gt;0.567&lt;/td&gt;
&lt;td&gt;0.845&lt;/td&gt;
&lt;td&gt;0.679&lt;/td&gt;
&lt;td&gt;0.907&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random Forest&lt;/td&gt;
&lt;td&gt;81.7%&lt;/td&gt;
&lt;td&gt;0.575&lt;/td&gt;
&lt;td&gt;0.863&lt;/td&gt;
&lt;td&gt;0.690&lt;/td&gt;
&lt;td&gt;0.917&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HistGradientBoosting&lt;/td&gt;
&lt;td&gt;87.3%&lt;/td&gt;
&lt;td&gt;0.776&lt;/td&gt;
&lt;td&gt;0.652&lt;/td&gt;
&lt;td&gt;0.708&lt;/td&gt;
&lt;td&gt;0.927&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XGBoost&lt;/td&gt;
&lt;td&gt;83.4%&lt;/td&gt;
&lt;td&gt;0.604&lt;/td&gt;
&lt;td&gt;0.865&lt;/td&gt;
&lt;td&gt;0.712&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.928&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;XGBoost edges out HistGradientBoosting on ROC-AUC — 0.928 vs 0.927 — but that's the wrong reason to call it the clear winner. HistGB actually has better accuracy, precision, and error rate. XGBoost's class-rebalancing (&lt;code&gt;scale_pos_weight&lt;/code&gt;) trades precision for recall, catching more true high earners at the cost of more false positives.&lt;/p&gt;

&lt;p&gt;Which one is "better" depends on whether the downstream decision punishes missed opportunities or wasted outreach more — a business question, not a modeling one. So that's what I wrote, instead of picking a winner by a 0.001 margin because it looks cleaner in a table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;None of this is exotic. There's no novel architecture here — it's a gradient-boosted tree model on tabular census data, the kind of problem that's been solved a thousand times. What I think is worth sharing isn't the modeling, it's the discipline around it: one pipeline instead of two, missingness that stays visible instead of vanishing, importance from four models instead of one, and a model choice explained in terms of the decision it serves rather than a leaderboard.&lt;/p&gt;

&lt;p&gt;Full write-up, including the complete methodology and caveats, is on my site: &lt;a href="https://lohkahmeng-portfolio.netlify.app/project-5-workforce-income.html" rel="noopener noreferrer"&gt;Workforce Income Analytics — Case Study&lt;/a&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>python</category>
      <category>powerbi</category>
    </item>
  </channel>
</rss>
