<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://willieman.com/feed.xml" rel="self" type="application/atom+xml"/><link href="https://willieman.com/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-09-09T02:30:52+00:00</updated><id>https://willieman.com/feed.xml</id><title type="html">Willie Man</title><subtitle>A simple, whitespace theme for academics. Based on [*folio](https://github.com/bogoli/-folio) design. </subtitle><entry><title type="html">I built a fantasy draft co-pilot, and the interesting part was everything it got wrong</title><link href="https://willieman.com/blog/2026/fantasy-draft-co-pilot-everything-it-got-wrong/" rel="alternate" type="text/html" title="I built a fantasy draft co-pilot, and the interesting part was everything it got wrong"/><published>2026-09-07T17:00:00+00:00</published><updated>2026-09-07T17:00:00+00:00</updated><id>https://willieman.com/blog/2026/fantasy-draft-co-pilot-everything-it-got-wrong</id><content type="html" xml:base="https://willieman.com/blog/2026/fantasy-draft-co-pilot-everything-it-got-wrong/"><![CDATA[<p><em>A read-only draft assistant for Sleeper, grounded in Isaac T. Petersen’s</em> Fantasy Football Analytics. <em>Source: <a href="https://github.com/godot107/sleeper-drafter">github.com/godot107/sleeper-drafter</a>.</em></p> <hr/> <p>Every fantasy football ranking answers the same question: <strong>who is good?</strong></p> <p>That is not the question you have on the clock. On the clock the question is <strong>who will still be here in twenty-two picks?</strong> — and those are different questions with different answers. The tight end you want will last two more rounds. The running back ranked one slot below him will not. A list sorted by projected points cannot tell you which is which, because the list does not know who picks next or what they need.</p> <p>So I built something that does. It watches a live Sleeper draft, models what each opponent is likely to take before my turn comes around again, and ranks my options by what it costs me to wait.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/dashboard-480.webp 480w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/dashboard-800.webp 800w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/dashboard-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/dashboard.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="The browser dashboard mid-draft: slot 12, pick 6.01, twenty-two picks until the next turn" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>There is a terminal version too, which is the one I actually trust — one process, no browser, no JavaScript between me and the numbers.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/terminal-480.webp 480w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/terminal-800.webp 800w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/terminal-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/terminal.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="The terminal dashboard at the same pick" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>What I did not expect — and the reason this post exists — is how many of the numbers that felt authoritative turned out to be measuring something other than what I thought. The pattern repeated often enough to be the real subject here: each one was caught not by thinking harder about the formula, but by checking it against something outside the formula.</p> <hr/> <h2 id="the-grounding-someone-already-did-the-theory">The grounding: someone already did the theory</h2> <p>I did not want to invent a valuation model. Fantasy football analytics has a serious, methodical, open-source treatment, and it is <strong><a href="https://isaactpetersen.github.io/Fantasy-Football-Analytics-Textbook/">Isaac T. Petersen’s <em>Fantasy Football Analytics: Statistics for Fantasy Football</em></a></strong> — a free online textbook covering projection, uncertainty, replacement level, tiering and draft strategy with actual statistics rather than podcast intuition. The book is the reason this project has opinions I can defend instead of coefficients I made up.</p> <p>Here is the honest chapter-by-chapter accounting of what I took:</p> <table> <thead> <tr> <th>What</th> <th>Where it comes from</th> <th>How it’s implemented here</th> </tr> </thead> <tbody> <tr> <td><strong>VORP</strong></td> <td>Ch. 6</td> <td>Value over replacement, where replacement is <em>a typical <strong>bench</strong> player</em> — not the last starter.</td> </tr> <tr> <td><strong>Dropoff</strong></td> <td>Ch. 6, 7</td> <td>Points minus the next-best player at the same position. Measured, not thresholded.</td> </tr> <tr> <td><strong>Tiers</strong></td> <td>Ch. 21</td> <td>Cluster analysis on projected points.</td> </tr> <tr> <td><strong>Uncertainty</strong></td> <td>Ch. 6, Eq. 6.1</td> <td>CV = s/x̄. <em>This is the one that failed — see below.</em></td> </tr> <tr> <td><strong>K/DEF go late</strong></td> <td>§7.4.1</td> <td>Kickers and defenses have the lowest measured dropoff, so waiting there is free.</td> </tr> <tr> <td><strong>Don’t join a run mid-stream</strong></td> <td>Ch. 7</td> <td>Why the “deny your opponent” term is deliberately weak.</td> </tr> </tbody> </table> <p>Two implementation choices worth naming, because they are places where a textbook idea meets a draft clock.</p> <p><strong>Replacement level is a bench cohort, not a rank.</strong> In a 12-team league with two starting running backs, the naive replacement level is RB24. But RB24 is one player having one projected season, and pinning a league-wide baseline to a single row makes every VORP in the position wobble when that row moves. So replacement is the <em>median of the twelve players immediately past the starter cutoff</em> — the same idea, less sensitive to any one projection.</p> <p><strong>Tiers use Fisher-Jenks natural breaks, not k-means.</strong> This is a small decision that matters enormously in practice. k-means has a random initialisation. Run it twice on the same board and you can get two different tierings. That is fine in a notebook and unacceptable when the board refreshes every two seconds while you read it with forty seconds on the clock. Fisher-Jenks is an exact dynamic program over 1-D data: same input, same tiers, every time. At ~215 wide receivers and eight tiers it runs in well under a millisecond.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/value-cliffs-480.webp 480w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/value-cliffs-800.webp 800w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/value-cliffs-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/value-cliffs.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Value cliffs down each position's board, with measured tier breaks" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Read those panels and the whole draft strategy falls out. Quarterback has one huge step at the very top and then a long gentle slope — which is why the one elite quarterback is worth reaching for and the next ten are close enough that reaching among them buys almost nothing. Kicker is a straight line with no cliff anywhere, which is exactly Petersen’s §7.4.1 result and exactly why you take one in the last round.</p> <hr/> <h2 id="the-core-idea-vona-not-vorp">The core idea: VONA, not VORP</h2> <p>VORP is a static measure. It says <em>this player is worth 59 points more than a typical bench body at his position</em>. True, and not decision-relevant, because if I pass on him I do not get a bench body — I get <strong>whoever is best at that position when I pick again.</strong></p> <p>That is the number I actually want:</p> <blockquote> <p><strong>VONA</strong> (Value Over Next Available) = his points − the expected best player at his position at my next turn</p> </blockquote> <p>If he scores 195 and the model expects the best available receiver at my next pick to be worth 176, waiting costs me 19 points. That 19 is the entire decision. Positive means take him now; negative means the position will keep.</p> <p>To compute the second half you need a survival probability for every player — the chance he lasts through every intervening pick.</p> <h3 id="the-normalisation-that-was-missing">The normalisation that was missing</h3> <p>My original spec — which I wrote before I knew better — said:</p> <blockquote> <p>P(team <em>i</em> drafts <em>X</em> at pick <em>j</em>) ∝ Urgency × ADPWeight</p> </blockquote> <p>and never resolved the ∝. That proportional sign is doing catastrophic work. Unresolved, the “probabilities” are on an arbitrary scale, and every VONA computed from them is a number with no units.</p> <p>The fix is to notice the constraint that was there all along: <strong>exactly one player is taken at pick <em>j</em></strong>. So the scores normalise over the available pool:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>score(X, j)     = Urgency(team_at_j, pos(X)) · ADPWeight(X, j)
P(X taken at j) = score(X, j) / Σ_{X' available} score(X', j)
P_survives(X)   = Π_j (1 − P(X taken at j))
</code></pre></div></div> <p>A pleasant corollary: the <code class="language-plaintext highlighter-rouge">1/√(2πσ²)</code> in front of the Gaussian ADP kernel is constant across players and cancels in the normalisation, so it can be dropped.</p> <h3 id="the-subtraction-that-is-easy-to-get-wrong">The subtraction that is easy to get wrong</h3> <p>Expected-best-available must <strong>exclude the candidate himself.</strong> If I draft Chris Olave, Chris Olave is precisely the player who will not be available at my next pick. Leaving him in the expectation makes every top player look like “the same guy you’d get later,” which quietly collapses VONA to zero exactly where it matters most.</p> <p>Doing that naively is O(n²) — rebuild the expectation once per candidate. It is actually linear, with a forward and a backward pass:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>F[i] = s[i]·p[i] + (1−s[i])·F[i+1]     F[n] = replacement level
G[i] = Π_{l&lt;i} (1−s[l])                 every better player is gone
H[i] = Σ_{l&lt;i} s[l]·p[l]·G[l]           value if one of them survives

E[best available, excluding i] = H[i] + G[i]·F[i+1]
</code></pre></div></div> <p>That <code class="language-plaintext highlighter-rouge">F[n] = replacement</code> tail is the second thing the spec got wrong: it summed over the ranked pool and left probability mass unaccounted for in the case where <em>nobody</em> survives. Somebody is on your roster in that case, and he is worth replacement level, not zero.</p> <p>Plot survival against VONA and the decision becomes a picture:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/decision-scatter-480.webp 480w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/decision-scatter-800.webp 800w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/decision-scatter-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/decision-scatter.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Survival against VONA — the take-now question in two dimensions" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Upper-left is “take him now”: expensive to wait, unlikely to last. Lower-right is just as useful — those are the players you can safely pass on this turn, and knowing that is what lets you spend the pick somewhere else. The vertical stripe at 100% is everyone the model is confident nobody wants yet.</p> <hr/> <h2 id="now-the-part-where-i-was-wrong">Now the part where I was wrong</h2> <h3 id="1-the-textbooks-uncertainty-metric-measured-the-wrong-thing">1. The textbook’s uncertainty metric measured the wrong thing</h3> <p>Petersen defines uncertainty as the <strong>spread of a player’s projections across sources</strong> (Ch. 6, Eq. 6.1). Sensible: if four projection systems disagree wildly about a player, that is real uncertainty about him.</p> <p>I had one source. So I substituted <strong>week-to-week scoring variance from last season</strong> — same coefficient of variation, s/x̄, different input. It felt like a reasonable stand-in. I shipped it as a “risk profile” column: steady / neutral / volatile.</p> <p>Then I tested it, and it is not a stand-in at all:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/cv-vs-ceiling-480.webp 480w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/cv-vs-ceiling-800.webp 800w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/cv-vs-ceiling-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/cv-vs-ceiling.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="CV terciles against points and ceiling — the volatile third has the lower ceiling" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>The “volatile” third of the league has a <strong>lower</strong> 90th-percentile week than the “steady” third. Correlation between CV and points per week is <strong>−0.58</strong>, and between CV and ceiling <strong>−0.40</strong>. The label was pointing at the opposite of what a risk label is supposed to mean.</p> <p>The reason is embarrassingly simple once you see it: <strong>CV has the mean in the denominator.</strong> A player averaging 3 points a week with a 3-point standard deviation has CV 1.0 and looks wildly volatile. He is not volatile; he is bad. CV finds low-volume players whose small scores bounce around zero, not the boom-or-bust starters the draft advice is about.</p> <p>The substitution was invalid — cross-source spread and within-season variance are not the same quantity, and only one of them is about the projection. So I kept CV as a label for what it actually measures, and added two metrics that answer the questions I had been asking it:</p> <ul> <li><strong><code class="language-plaintext highlighter-rouge">role</code></strong> — depth-chart position plus projected volume, ranked within position.</li> <li><strong><code class="language-plaintext highlighter-rouge">ceiling</code></strong> — the player’s 90th-percentile week last season. The honest late-round upside measure.</li> </ul> <p>On the 312 players with ten or more games last season, correlation with projected points: <strong>CV −0.50, role +0.80, ceiling +0.80.</strong> The sign on the one I inherited from the formula is the wrong one.</p> <p>The failure is not Petersen’s. His metric measures what he says it measures. It is a lesson about substituting a proxy into a formula and inheriting its authority without inheriting its meaning.</p> <h3 id="2-the-survival-model-was-confidently-wrong-in-the-middle">2. The survival model was confidently wrong in the middle</h3> <p><code class="language-plaintext highlighter-rouge">--replay</code> re-runs a finished draft pick by pick and scores every survival prediction against what actually happened. Pooled across four completed drafts — 3,360 predictions:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/survival-calibration-480.webp 480w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/survival-calibration-800.webp 800w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/survival-calibration-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/survival-calibration.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Predicted survival against actual, before and after the correction" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Look at the left panel first. The 80–100% bucket is nearly perfect: 99% predicted, 96% actual. That bucket is also 88% of all predictions, which is why the headline Brier score looks respectable and the overall bias is only <strong>+6.7 points optimistic</strong>.</p> <p>The middle is a disaster. Players the model called 40–60% survived <strong>14%</strong> of the time. Players it called 60–80% survived <strong>32%</strong>.</p> <p>And the direction is exactly what the model’s own assumption predicts. Survival multiplies per-pick probabilities <strong>as if the picks were independent</strong>. Real drafts have <em>runs</em> — three receivers in four picks, because managers watch each other. Independence is least damaging at the extremes (nobody wants him; everybody does) and most damaging in the contested middle, which is precisely where the left panel falls apart.</p> <p>The fix is a single exponent — replace <em>p</em> with <em>p^γ</em>. An exponent rather than a linear shrink because <em>p^γ</em> fixes both 0 and 1, and a turn boundary (“the window is empty, he is certainly still there”) has to stay exactly 1.0.</p> <p><strong>The interesting part is that this was not fittable when I first wrote it up.</strong> With three drafts, solving for the γ that matched the overall mean gave γ ≈ 4 and threw the middle buckets onto the <em>other</em> side of the truth — the 60–80% bucket went from 68% predicted to 22% against an actual 30%. I concluded that one knob could not fix a bias whose size depends on where you sit in the range, published the miscalibration instead, and left it.</p> <p>That conclusion was drawn from too little data, not from too simple a model. A fourth draft — 840 more predictions — changed the answer. At <strong>γ = 4.11</strong> the same one-knob correction improves <strong>every bucket</strong>: observation-weighted mean absolute calibration error <strong>0.066 → 0.021</strong>, mean bias <strong>+0.066 → +0.005</strong>, mean Brier <strong>0.061 → 0.044</strong>. That is the right panel.</p> <p>Two things I would not have predicted. The correction still <em>undershoots</em> the low buckets — it just undershoots by much less than the raw form overshot. And it slightly <strong>worsens</strong> the one 10-team standard-scoring draft (Brier 0.071 → 0.074) while clearly helping the three 12-team half-PPR ones, which suggests γ wants to vary with league size. Four drafts is nowhere near enough to fit that, so it stays a single number with a caveat attached.</p> <p>One rule falls out of this that is easy to get backwards: <strong>never tune γ against <code class="language-plaintext highlighter-rouge">--mock</code>.</strong> Mock opponents are sampled from the selection model itself, so a simulated draft contains none of the herding the correction exists for and will score it as a regression. Live drafts are the only valid evidence, which is why <code class="language-plaintext highlighter-rouge">--replay</code> appends to a committed calibration log after every one.</p> <p>The lesson survived the fix, and it is not the one I expected to write. Publishing the miscalibration was still right — a number you know is seven points optimistic is usable, and one you merely believe is not. But “I have not fitted a correction and here is why one cannot work” was an overclaim dressed up as rigour. The honest version was always “not yet, on this much data.”</p> <h3 id="3-a-scoring-bug-that-only-a-real-draft-could-find">3. A scoring bug that only a real draft could find</h3> <p>Offline mock drafts sample opponents from <strong>the very distribution the opponent model assumes</strong>. They validate the arithmetic beautifully and can never disconfirm the model. Almost every real bug surfaced in a live Sleeper mock.</p> <p>The worst one: my roster had one FLEX slot, shared between running backs, receivers and tight ends. The code asked each position <em>independently</em> whether it had flex capacity, so with one flex slot open, RB <strong>and</strong> WR <strong>and</strong> TE each believed the slot was theirs. Tight ends won that argument, because tight end has the steepest cliff, and I finished drafts with 3.5 tight ends for one starting spot.</p> <p>Then a second bug in the same neighbourhood: an empty <em>dedicated</em> slot was credited at replacement level, but an empty <em>flex</em> slot was credited at <strong>zero</strong>. Filling a flex hole therefore appeared to be worth a player’s entire projection. Sam LaPorta scored <strong>+158.5</strong>. After the fix: <strong>+9.3</strong>. A 158-point tight end had been outranking a 190-point running back.</p> <p>Neither was a maths error. Both were a modelling error about what a roster slot <em>is</em>, and both are now pinned by tests.</p> <h3 id="4-the-board-that-was-four-minutes-old">4. The board that was four minutes old</h3> <p>Mid-draft, live, the model recommended a player who had been drafted forty picks earlier. Sleeper’s picks endpoint sits behind a CDN with <code class="language-plaintext highlighter-rouge">s-maxage=300</code>, and the response carried <code class="language-plaintext highlighter-rouge">Age: 261</code> — I was reading a board frozen at pick 3 while the draft was at pick 43. <strong>I made a recommendation off that board and did not catch it.</strong></p> <p>The fix is one line — a cache-busting query parameter — and the lesson is the expensive part: a dashboard that has quietly stopped updating is <em>worse</em> than no dashboard, because it looks exactly like a working one. The UI now states when data last changed and goes amber, then red, as that ages.</p> <hr/> <h2 id="the-peer-advice-experiment">The peer-advice experiment</h2> <p>My league-mates all say the same thing: <strong>spend the first two picks on running backs and receivers, never on a quarterback.</strong> Conventional, widely held, and testable — because “where the value is” depends on <em>where you pick</em>.</p> <p>So I forced the first two picks to a given position pair, let the engine draft normally afterwards, and scored the final starting lineup. Same board, same seeds, same simulated opponents; the constraint is the only thing that varies.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/first-two-picks-480.webp 480w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/first-two-picks-800.webp 800w,/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/first-two-picks-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/fantasy-draft-co-pilot-everything-it-got-wrong/first-two-picks.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="First-two-picks strategies by draft slot" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <table> <thead> <tr> <th>Forced first two picks</th> <th style="text-align: right">slot 1</th> <th style="text-align: right">slot 4</th> <th style="text-align: right">slot 8</th> <th style="text-align: right">slot 12</th> </tr> </thead> <tbody> <tr> <td><strong>RB, RB</strong> — the advice</td> <td style="text-align: right"><strong>+1</strong></td> <td style="text-align: right">−5</td> <td style="text-align: right">−16</td> <td style="text-align: right"><strong>−15</strong></td> </tr> <tr> <td>RB, WR</td> <td style="text-align: right">−9</td> <td style="text-align: right">−14</td> <td style="text-align: right">−11</td> <td style="text-align: right">−8</td> </tr> <tr> <td><strong>QB, RB</strong> — the heresy</td> <td style="text-align: right"><strong>−53</strong></td> <td style="text-align: right">−52</td> <td style="text-align: right">−30</td> <td style="text-align: right"><strong>−9</strong></td> </tr> <tr> <td>WR, WR</td> <td style="text-align: right">−54</td> <td style="text-align: right">−37</td> <td style="text-align: right">−36</td> <td style="text-align: right">−32</td> </tr> <tr> <td>QB, WR</td> <td style="text-align: right">−60</td> <td style="text-align: right">−55</td> <td style="text-align: right">−35</td> <td style="text-align: right">−18</td> </tr> </tbody> </table> <p><em>Points of projected starting lineup versus the engine’s own unconstrained choice. 12-team snake, 15 rounds, 16 seeds per arm, 384 simulated drafts.</em></p> <p><strong>The advice is correct, and it is correct about a seat I do not sit in.</strong></p> <p>At <strong>slot 1</strong> it is emphatically right. Forcing RB-then-RB costs nothing at all (+1, i.e. it <em>is</em> what the engine wanted), and forcing a quarterback first costs <strong>53 points</strong>. Left alone, the engine opens with a running back in 27 of its 32 early picks there.</p> <p>By <strong>slot 12</strong> the ordering has inverted. Forcing RB-then-RB now costs <strong>15 points</strong>; forcing the quarterback costs <strong>9</strong>. And unconstrained, the engine opens with a quarterback more often than anything else — 14 of its 32 first-two picks at that slot, against 9 running backs.</p> <p>The mechanism is visible on the value-cliff chart earlier in this post. Six running backs carry an ADP inside the first twelve picks, so by slot 12 what remains at the position is a flat block — the next five backs span nineteen points, and it barely matters which one you get. Meanwhile a 12-team league needs only twelve quarterbacks, so the one genuine outlier at the position is still sitting there, 35 points clear of the next one.</p> <p>The advice is not wrong. It is a statement about <strong>where value sits when you pick early</strong>, and it is being repeated in a seat where it no longer applies. That is worth more to me than the six points: it is a reason, and a reason survives a change in the player pool in a way that a rule of thumb does not.</p> <p>Caveat, stated plainly: this is one projection source and one season’s shape. It should be re-run every year, which is why it ships as a script rather than a conclusion.</p> <hr/> <h2 id="it-does-not-draft-for-you-on-purpose">It does not draft for you, on purpose</h2> <p>Sleeper’s public API is <strong>read-only</strong>. There are no documented endpoints for submitting a pick, making a trade, or modifying a roster. You could get around that with session scraping or a headless browser driving the web UI. This project deliberately does not, and the constraint is written into its <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> so it stays that way.</p> <p>Three reasons, in order of how much they matter:</p> <ol> <li><strong>Terms of service.</strong> Automating writes against a platform that does not offer a write API is asking to have the account removed.</li> <li><strong>Brittleness.</strong> A scraped session breaks at the worst possible moment, which is during the ninety seconds you have to make a pick.</li> <li><strong>The model does not know things.</strong> Breaking injury news lands on Twitter fifteen minutes before it lands anywhere structured. Human veto is not a limitation of the design; it is the most valuable input in the system.</li> </ol> <p>So it ranks, and I click. Dashboard on one monitor, terminal on the second, Sleeper on the third.</p> <hr/> <h2 id="did-it-work">Did it work?</h2> <p>Four live drafts, in order — three public mock rooms, then my actual league:</p> <table> <thead> <tr> <th>Draft</th> <th>League</th> <th>Finish</th> <th>Grade</th> <th>RB points vs league mean</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>10-team</td> <td>1st</td> <td>A+</td> <td>—</td> </tr> <tr> <td>2</td> <td>12-team</td> <td>6th</td> <td>B−</td> <td>−151</td> </tr> <tr> <td>3</td> <td>12-team</td> <td>4th</td> <td>B−</td> <td>−129</td> </tr> <tr> <td>4</td> <td>12-team, slot 12 — <strong>real league</strong></td> <td><strong>3rd</strong></td> <td><strong>A</strong></td> <td><strong>−23</strong></td> </tr> </tbody> </table> <p>The improvement between draft 3 and draft 4 was not a smarter algorithm. It was the flex fix, which stopped the engine from spending premium picks on a second elite tight end and let it take a running back at the turn instead.</p> <p>Grades are computed the same way for every team in the league: fill the best legal starting lineup from each roster, total the projected points, and rank. It is the same lineup solver the recommendation engine uses, which means the grade and the advice cannot disagree with each other.</p> <hr/> <h2 id="what-i-would-tell-someone-building-this">What I would tell someone building this</h2> <p><strong>Ground it in something.</strong> The textbook is why the model has defensible positions instead of tuned constants. Where I departed from it — a bench cohort instead of a single replacement rank, Fisher-Jenks instead of k-means — I can say why, and the reason is about the draft clock rather than about taste.</p> <p><strong>A proxy does not inherit a formula’s authority.</strong> CV in Petersen’s sense and CV over weekly scores are the same three symbols measuring different things. The formula did not warn me. The data did.</p> <p><strong>Simulate to test arithmetic; go live to test the model.</strong> Mock drafts sample opponents from the model’s own assumptions. They cannot disconfirm it. Every genuinely interesting bug came from a real draft with real humans.</p> <p><strong>Publish the miscalibration.</strong> The survival chart is the least flattering thing in this repo and the most useful thing in it. Documenting where a model is wrong is not an admission that it is broken; it is the difference between a number you can reason with and a number you can only believe. And keep publishing it: the log I kept because I could not fix the bias is exactly what made a fourth draft enough to fix it.</p> <p><strong>Look at the chart.</strong> Two visual bugs — a sort order that silently did nothing and a label that ran off the frame — survived every test I had and died the moment I rendered the image and looked at it.</p> <hr/> <h2 id="credits-and-links">Credits and links</h2> <ul> <li><strong><a href="https://isaactpetersen.github.io/Fantasy-Football-Analytics-Textbook/">Isaac T. Petersen, <em>Fantasy Football Analytics: Statistics for Fantasy Football</em></a></strong> — the methodological backbone: replacement level, dropoff, tiering, uncertainty, and the positional strategy that follows from them. Free, rigorous, and the best thing written on this subject. If you take one link from this post, take that one.</li> <li><strong><a href="https://docs.sleeper.com/">Sleeper API docs</a></strong> — public, unauthenticated, read-only, and generous. No API key required to run any of this.</li> <li>Built in Python with <code class="language-plaintext highlighter-rouge">pandas</code>, <code class="language-plaintext highlighter-rouge">numpy</code>, <code class="language-plaintext highlighter-rouge">rich</code> (terminal), and <code class="language-plaintext highlighter-rouge">dash</code> + <code class="language-plaintext highlighter-rouge">plotly</code> (browser).</li> <li>Source, tests and runbook: <strong><a href="https://github.com/godot107/sleeper-drafter">github.com/godot107/sleeper-drafter</a></strong></li> </ul> <p>Everything in this post reproduces from the repo:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python scripts/fetch_projections.py         <span class="c"># real projections + ADP</span>
python scripts/fetch_consistency.py         <span class="c"># weekly variance from last season</span>

python scripts/experiment_cv.py             <span class="c"># the CV failure</span>
python scripts/experiment_first_two.py      <span class="c"># the first-two-picks experiment</span>
python main.py <span class="nt">--replay</span> &lt;draft_id&gt;          <span class="c"># survival calibration on a finished draft</span>

python main.py <span class="nt">--web</span> <span class="nt">--mock</span> <span class="nt">--slot</span> 12       <span class="c"># the dashboard, on a simulated draft</span>
pytest <span class="nt">-q</span>                                   <span class="c"># 163 tests</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="project"/><category term="datascience"/><category term="sports-analytics"/><summary type="html"><![CDATA[A read-only Sleeper draft assistant built on VONA and opponent survival modelling — and the four times its most authoritative numbers turned out to be measuring something else.]]></summary></entry><entry><title type="html">I Turned Six Years of Data Science Textbooks Into a Knowledge Base My AI Can Reason Over</title><link href="https://willieman.com/blog/2026/textbook-to-knowledge-base/" rel="alternate" type="text/html" title="I Turned Six Years of Data Science Textbooks Into a Knowledge Base My AI Can Reason Over"/><published>2026-06-18T17:00:00+00:00</published><updated>2026-06-18T17:00:00+00:00</updated><id>https://willieman.com/blog/2026/textbook-to-knowledge-base</id><content type="html" xml:base="https://willieman.com/blog/2026/textbook-to-knowledge-base/"><![CDATA[<p><em>Building a private, local RAG system over my own library — and why I think almost everyone with a bookshelf of PDFs should do the same.</em></p> <hr/> <h2 id="the-shelf-problem">The shelf problem</h2> <p>My data science journey started almost six years ago, in a master’s program, with the usual stack of textbooks: <em>The Elements of Statistical Learning</em>, Bishop’s <em>Pattern Recognition and Machine Learning</em>, Han’s <em>Data Mining: Concepts and Techniques</em>, Wasserman’s <em>All of Statistics</em>, Goodfellow’s <em>Deep Learning</em>, and a long tail of others I collected as the field moved.</p> <p>Six years later, those books have a strange status in my life. They taught me the foundations I use every day. They are, collectively, the most trustworthy reference material I own — written by the people who defined the field, reviewed to within an inch of their lives, far more reliable than a random blog post or a confident-but-wrong answer from a chatbot.</p> <p>And I almost never open them.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/textbook-to-knowledge-base/giphy-480.webp 480w,/assets/img/textbook-to-knowledge-base/giphy-800.webp 800w,/assets/img/textbook-to-knowledge-base/giphy-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/textbook-to-knowledge-base/giphy.gif" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="My most trusted references, circa every day before this project. (via GIPHY)" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>The friction is too high. When I need to remember exactly how the bias–variance decomposition falls out, or the precise steps of the KDD process, or which distance metric Han recommends for mixed attribute types, I don’t go digging through a 700-page PDF. I Google it. Or I ask an LLM and hope it isn’t hallucinating. My best sources sit on disk, indexed by nothing, searchable only by my increasingly unreliable memory.</p> <p>So I built the thing I actually wanted: a private knowledge base over my own library, with a vector database for <strong>retrieval</strong> and Claude Code as the layer that does the <strong>reasoning</strong> on top of it. Ask a question in plain English; get back the exact passages from <em>my</em> books, with citations, and an answer that reasons across them.</p> <p>This post is about how it works, what surprised me building it, and why I think this pattern is badly underused.</p> <hr/> <h2 id="the-idea-retrieval-and-reasoning-over-sources-you-trust">The idea: retrieval <em>and</em> reasoning over sources you trust</h2> <p>Most people now reach for a general-purpose chatbot when they have a question. That’s great for a lot of things and genuinely bad for others: the model answers from a blurry average of the whole internet, it can’t cite where an answer came from, and it will occasionally invent a formula with total confidence.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/textbook-to-knowledge-base/giphy_1-480.webp 480w,/assets/img/textbook-to-knowledge-base/giphy_1-800.webp 800w,/assets/img/textbook-to-knowledge-base/giphy_1-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/textbook-to-knowledge-base/giphy_1.gif" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="An LLM confidently deriving a formula it doesn't actually know. (via GIPHY)" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Retrieval-augmented generation (RAG) flips that. Instead of asking the model to recall facts, you:</p> <ol> <li><strong>Retrieve</strong> the most relevant passages from a corpus <em>you</em> chose and trust (a vector database does this by meaning, not keywords), and</li> <li><strong>Reason</strong> over those passages with an LLM, which now has the right source material in front of it.</li> </ol> <p>The corpus is the part everyone underuses. People build RAG over company wikis and support tickets. Almost nobody points it at the canonical textbooks they already own — the highest-signal technical material most of us will ever have access to. That’s the gap I wanted to close.</p> <p>The reasoning layer matters just as much. I didn’t want a search box that returns ten snippets and makes me read them. I wired the knowledge base directly into <strong>Claude Code</strong> as a native tool, so the AI can search my library mid-conversation, pull the passages it needs, and reason across three different books to answer one question — citing each as it goes. Retrieval gives it grounding; the model gives it synthesis. Neither half is enough alone.</p> <hr/> <h2 id="how-its-built">How it’s built</h2> <p>The whole thing runs locally on a single machine with a consumer GPU (a 6 GB GTX 1660). No cloud, no API bills, nothing leaves the house. The pipeline:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/textbook-to-knowledge-base/architecture-480.webp 480w,/assets/img/textbook-to-knowledge-base/architecture-800.webp 800w,/assets/img/textbook-to-knowledge-base/architecture-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/textbook-to-knowledge-base/architecture.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Ingest once: PDFs are cleaned, chunked, embedded with BGE-large, and stored in ChromaDB. On every query, Claude Code searches through an MCP server, and the retrieval pipeline applies a relevance floor, optional reranking, and MMR diversification before reasoning over the results." loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>A few of the choices that mattered:</p> <p><strong>Embeddings.</strong> I use <code class="language-plaintext highlighter-rouge">BAAI/bge-large-en-v1.5</code> to turn each chunk of text into a 1,024-dimensional vector that captures its meaning. BGE has a quirk a lot of people miss: it’s <em>asymmetric</em> — you’re supposed to prepend a short instruction to <strong>queries</strong> but not to stored passages. Get that wrong and retrieval quietly degrades. That one detail is the difference between “this works” and “this is mediocre and I don’t know why.”</p> <p><strong>Vector store.</strong> ChromaDB with a cosine-similarity HNSW index. At my scale — about 600 books, ~78,000 chunks — the index lives comfortably in RAM at query time, so a question comes back in well under a second.</p> <p><strong>The retrieval pipeline is more than nearest-neighbor.</strong> Raw vector search is stage one. On top of it I added a relevance floor (so the system can honestly say “I found nothing relevant” instead of returning garbage), an optional cross-encoder reranker, and MMR diversification so three near-identical passages don’t crowd out the answer. More on the reranker below, because it taught me a lesson.</p> <p><strong>The integration is the payoff.</strong> A small <a href="https://modelcontextprotocol.io">Model Context Protocol</a> server exposes the knowledge base to Claude Code as tools — <code class="language-plaintext highlighter-rouge">search_textbooks</code>, <code class="language-plaintext highlighter-rouge">list_sources</code>, <code class="language-plaintext highlighter-rouge">get_toc</code>, <code class="language-plaintext highlighter-rouge">expand_context</code>. The embedding model loads once and stays resident, so there’s no cold start. From my side it just feels like the AI suddenly <em>knows my library</em>: I ask a question, it searches, it reasons, it cites.</p> <hr/> <h2 id="the-part-nobody-warns-you-about-your-data-is-a-mess">The part nobody warns you about: your data is a mess</h2> <p>Here’s the unglamorous truth of any real RAG project. The retrieval algorithm is maybe 20% of the work. The other 80% is discovering that your data is dirtier than you think.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/textbook-to-knowledge-base/giphy_2-480.webp 480w,/assets/img/textbook-to-knowledge-base/giphy_2-800.webp 800w,/assets/img/textbook-to-knowledge-base/giphy_2-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/textbook-to-knowledge-base/giphy_2.gif" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Auditing your own corpus for the first time. (via GIPHY)" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>When I actually audited my corpus, I found:</p> <ul> <li> <p><strong>Duplicate books.</strong> <em>The Elements of Statistical Learning</em> had been ingested twice under two different filenames — over 1,600 redundant chunks competing in every search. My first instinct, “just group by title,” was naive: the same audit flagged a hundred-plus genuinely <em>different</em> files (per-section quiz PDFs that happened to share an embedded title). The fix was to detect duplicates by <strong>shared chunk content</strong>, not metadata — and to divide overlap by the <em>larger</em> document so a short file merely <em>contained</em> in a big compilation isn’t mistaken for a copy. Correctness on a destructive operation is worth the extra care.</p> </li> <li> <p><strong>Garbled extraction.</strong> One of my most important books — the pre-publication PDF of Goodfellow’s <em>Deep Learning</em> — extracts as duplicated nonsense (<code class="language-plaintext highlighter-rouge">Deep Deep Deep Belief Belief Networks orks orks</code>). Another linear-algebra file was pure mojibake. Bad text in produces bad answers out, silently. I wrote a small heuristic to score each source for “looks broken,” and it correctly surfaced the worst offender as #1.</p> </li> <li> <p><strong>Unreliable metadata.</strong> Embedded PDF titles are frequently <code class="language-plaintext highlighter-rouge">Untitled</code>, duplicated, or shared across dozens of unrelated files. Trusting them for citations gives you nonsense provenance. I reconstruct clean titles from filenames instead.</p> </li> </ul> <p>None of this is exciting. All of it is the actual job. A knowledge base is only as trustworthy as the corpus underneath it, so I built the data-quality checks as first-class features — an <code class="language-plaintext highlighter-rouge">audit</code> command, a <code class="language-plaintext highlighter-rouge">dedup</code> tool, and a health check that runs automatically and warns me when the corpus drifts. If you take one engineering lesson from this post: <strong>budget for data quality, and make it a running process, not a one-time cleanup.</strong></p> <hr/> <h2 id="the-reranker-that-didnt-help--and-why-i-kept-the-result">The reranker that didn’t help — and why I kept the result</h2> <p>I added a cross-encoder reranker because the literature says it’s the single biggest precision win in RAG, and I believed it. Then I did something I think more people should do before shipping: I built an evaluation harness and actually measured it.</p> <p>I wrote a small gold set of questions tied to the books that should answer them, and scored the pipeline with and without each stage (recall@k and mean reciprocal rank). The result was humbling:</p> <table> <thead> <tr> <th>Configuration</th> <th>recall@10</th> <th>MRR</th> </tr> </thead> <tbody> <tr> <td>Baseline (vector search)</td> <td>0.93</td> <td>0.60</td> </tr> <tr> <td>+ reranker</td> <td>0.93</td> <td><strong>0.53</strong></td> </tr> <tr> <td>+ diversification</td> <td>0.93</td> <td>0.60</td> </tr> </tbody> </table> <p>The reranker made things <em>worse</em> on my metric.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/textbook-to-knowledge-base/giphy_3-480.webp 480w,/assets/img/textbook-to-knowledge-base/giphy_3-800.webp 800w,/assets/img/textbook-to-knowledge-base/giphy_3-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/textbook-to-knowledge-base/giphy_3.gif" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Me, watching the 'biggest precision win in RAG' make things worse. (via GIPHY)" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Now — that metric is imperfect. It checks whether the right <em>book</em> showed up, which is saturated and can’t see whether a passage’s <em>content</em> got more relevant (exactly what a reranker is for). So the honest reading isn’t “rerankers are bad.” It’s “on the evidence I have, this reranker model isn’t earning its place, and I will not turn it on by default and pretend it’s an improvement.” I left it implemented, off by default, one flag away, with a written plan to re-test it with a stronger model and a better gold set.</p> <p>That discipline — <em>measure before you believe, and report what you find even when it’s inconvenient</em> — is, to me, the whole point of doing this seriously. It’s also the difference between a portfolio project and a demo.</p> <hr/> <h2 id="what-it-actually-feels-like-to-use">What it actually feels like to use</h2> <p>The abstract pitch is “local RAG over textbooks.” The concrete experience is better than that sounds.</p> <p>I ask, <em>“Summarize the first two chapters of Han’s Data Mining.”</em> The AI pulls the table of contents, retrieves the relevant passages from chapter 1 (the KDD process, data-mining functionalities) and chapter 2 (attribute types, similarity measures), and writes a grounded summary — citing the actual pages, from the actual book, with zero hallucination, because it’s reading my copy.</p> <p>I ask about the bias–variance tradeoff and get the passage from <em>Practical Statistics for Data Scientists</em> sitting next to the one from a model-selection chapter, and a synthesis across both. I ask about backpropagation and it spreads its answer across three books that each explain it differently — the intuition from one, the math from another, the code from a third.</p> <p>It is, functionally, a conversation with my own bookshelf — except the bookshelf has read all of itself and can cross-reference instantly.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/textbook-to-knowledge-base/giphy_4-480.webp 480w,/assets/img/textbook-to-knowledge-base/giphy_4-800.webp 800w,/assets/img/textbook-to-knowledge-base/giphy_4-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/textbook-to-knowledge-base/giphy_4.gif" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Asking your bookshelf a question and watching it answer, with citations. (via GIPHY)" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <hr/> <h2 id="why-this-is-underused--and-the-benefits">Why this is underused — and the benefits</h2> <p>This pattern is mature technology now. Vector databases are a commodity, embedding models are excellent and free, and the tooling to wire it all into an AI assistant exists. And yet hardly anyone points it at their <em>own</em> trusted library. A few reasons it’s worth doing:</p> <ul> <li><strong>Grounded, not guessed.</strong> Every answer traces back to a specific page in a specific book you trust. Hallucination drops to near zero because the model is reading, not recalling.</li> <li><strong>Private by default.</strong> It runs locally. My notes, my library, my questions never leave my machine. For anyone working with sensitive or proprietary material, that’s not a nice-to-have.</li> <li><strong>You curate the truth.</strong> A general chatbot averages the internet. This averages <em>nothing</em> — it answers from the canonical sources you deliberately chose. Signal over noise.</li> <li><strong>It compounds.</strong> Every book I add makes it smarter. Six years of accumulated material stops being dead weight on a drive and becomes a living, queryable asset.</li> <li><strong>Retrieval <em>plus</em> reasoning.</strong> Search alone gives you ten snippets to read. An LLM alone gives you confident guesses. Together they give you cited, synthesized answers — the actual thing you wanted.</li> </ul> <p>The cost of entry keeps falling and the payoff is immediate. If you’ve got a folder of PDFs you “keep meaning to reference,” you are one weekend away from never having to dig through them by hand again.</p> <hr/> <h2 id="where-its-going-next">Where it’s going next</h2> <p>The natural next step is to lift the vector store off my desk and into the cloud behind a small service endpoint, so the knowledge base is reachable from anywhere and from other tools — not just the machine it lives on. Managed vector databases and a thin API layer make that straightforward, and it turns a personal project into something I could actually share.</p> <p>But honestly, even as a local tool, it’s already changed how I work. The books that taught me the field six years ago are finally pulling their weight again — not as decoration on a shelf, but as the grounded memory of an AI I can think alongside.</p> <p>That’s the part I find genuinely exciting. We spend a lot of energy asking what AI knows. It turns out the more useful question is: <em>what do you want it to know?</em> Point it at the right sources, give it a way to retrieve and reason over them, and the answer becomes — whatever you’ve spent your career learning to trust.</p> <hr/> <p><em>Built with Python, ChromaDB, BGE embeddings, and Claude Code. Runs on a single consumer GPU. Code and design notes are on <a href="https://github.com/godot107/textbook-kb">GitHub</a>.</em></p>]]></content><author><name></name></author><category term="project"/><category term="machine-learning"/><category term="datascience"/><category term="deep-learning"/><summary type="html"><![CDATA[Building a private, local RAG system over my own library — and why I think almost everyone with a bookshelf of PDFs should do the same.]]></summary></entry><entry><title type="html">Applied Math for Engineers: Deconstructing the FFT and CNNs</title><link href="https://willieman.com/blog/2026/math-is-fun/" rel="alternate" type="text/html" title="Applied Math for Engineers: Deconstructing the FFT and CNNs"/><published>2026-06-16T17:00:00+00:00</published><updated>2026-06-16T17:00:00+00:00</updated><id>https://willieman.com/blog/2026/math-is-fun</id><content type="html" xml:base="https://willieman.com/blog/2026/math-is-fun/"><![CDATA[<p><em>This post is a direct continuation of my work on the <a href="https://github.com/godot107/predictive-maintenance-cwru">CWRU Predictive Maintenance pipeline</a> — see the companion write-up, <a href="/blog/2026/teaching-a-neural-network-to-hear-a-failing-bearing/">Teaching a Neural Network to Hear a Failing Bearing</a>. While I successfully built a CNN that scored 100% accuracy, writing the code and actually understanding the deep applied mathematics behind it are two very different things.</em></p> <hr/> <p>When building my predictive maintenance pipeline, I hit the classic data science milestone: my Convolutional Neural Network (CNN) scored 100% accuracy. After investigating, I realized the dataset itself was exceptionally clean under specific conditions, and the signal processing techniques I used (like the Fast Fourier Transform) had perfectly isolated the faults.</p> <p>But what was the FFT <em>actually</em> doing mathematically? Why did I need to use imaginary numbers in my Python code? And how do the layers of a CNN actually connect without causing matrix multiplication errors?</p> <p>To break this down, I fired up the <strong>AGY CLI</strong> (Anti-Gravity CLI) for an interactive pair-programming session. I wanted to step out of pure software engineering and dive into the domain of applied math. What followed was an incredibly fun journey that transformed those complex “black box” algorithms into pure, visual intuition.</p> <p>Here is a transcript of my session with Anti-Gravity.</p> <h2 id="1-the-cnn-matrix-mismatch-error">1. The CNN “Matrix Mismatch” Error</h2> <p><strong>Me:</strong> <em>I’m new to CNN architecture. How does one design the inputs of one layer and the outputs of the next to be compatible? If I do it by hand, I get a lot of matrix multiplication errors. How do I build intuition for this?</em></p> <p><strong>Anti-Gravity:</strong> This is the exact hurdle everyone faces when learning deep learning! To build intuition, separate your tensor into two concepts: <strong>Channels</strong> and <strong>Spatial Dimensions (Height x Width)</strong>.</p> <ol> <li><strong>The Plumbing Rule (Channels):</strong> When stacking Convolutional layers (<code class="language-plaintext highlighter-rouge">Conv2d</code>), you only need to worry about the channels. If Layer 1 outputs 16 pipes, Layer 2 <em>must</em> have exactly 16 intakes. PyTorch handles the spatial dimensions shrinking underneath.</li> <li><strong>The Danger Zone (Flattening):</strong> 99% of shape mismatch errors happen when transitioning from Convolutional blocks to <code class="language-plaintext highlighter-rouge">Linear</code> (Dense) blocks, which only accept a flat 1D list of numbers.</li> </ol> <p>To avoid the nightmare of manually calculating grid sizes (which breaks if your input image size changes), use the <strong>“Cheat Code”</strong>: <code class="language-plaintext highlighter-rouge">AdaptiveAvgPool2d</code>.</p> <p>By inserting <code class="language-plaintext highlighter-rouge">nn.AdaptiveAvgPool2d((4, 4))</code> right before flattening, you tell PyTorch: <em>“I don’t care what the height and width of the image are at this point. Mathematically squish whatever you have into a 4x4 grid.”</em> This permanently locks your flattened size to exactly <code class="language-plaintext highlighter-rouge">Channels * 4 * 4</code>, completely eliminating shape mismatch errors!</p> <p><em>(Note: The math that makes Adaptive Pooling possible was pioneered by Kaiming He et al. in the 2014 paper <a href="https://arxiv.org/abs/1406.4729">Spatial Pyramid Pooling</a>, and Global Average Pooling was introduced by Lin et al. in <a href="https://arxiv.org/abs/1312.4400">Network In Network</a>. Credit to academia!)</em></p> <h2 id="2-what-actually-is-the-fft">2. What actually <em>is</em> the FFT?</h2> <p><strong>Me:</strong> <em>Can you attempt to explain the math behind the Fast Fourier Transform (FFT)? Start simple.</em></p> <p><strong>Anti-Gravity:</strong> Imagine your messy vibration wave is drawn on a long, straight piece of string. Now, imagine taking that string and <strong>wrapping it around a circle</strong>.</p> <p>Most of the time, the string is distributed evenly around the circle, so its “Center of Mass” stays right dead in the middle at <code class="language-plaintext highlighter-rouge">(0, 0)</code>. But, if you wind the string at a speed that <em>exactly matches</em> a hidden frequency inside the wave (let’s say 107 Hz), every single time the wave hits a peak, it lands on the <strong>exact same side of the circle</strong>. The string bulges massively to one side, and the Center of Mass shoots outward!</p> <p>The mathematical equation for this is:</p> \[\hat{f}(\xi) = \int_{-\infty}^{\infty} f(t) \cdot e^{-2\pi i \xi t} dt\] <ul> <li><strong>\(2\pi\)</strong>: One full rotation around a circle.</li> <li><strong>\(\xi\)</strong>: The frequency (e.g., 107 Hz).</li> <li><strong>\(t\)</strong>: Time.</li> <li><strong>\(i\)</strong>: The imaginary number (which triggers 2D rotation).</li> </ul> <p>The equation simply says: <em>“Spin the wave around a circle at frequency \(\xi\). Does the center of mass bulge outward? If yes, draw a spike on the graph.”</em></p> <h2 id="3-why-the-imaginary-number">3. Why the “Imaginary” Number?</h2> <p><strong>Me:</strong> <em>What is the imaginary number \(i\)? Technically the square root of a negative number is underdetermined and errors out.</em></p> <p><strong>Anti-Gravity:</strong> You are exactly right! You cannot multiply a number by itself and get a negative number on a standard ruler.</p> <p>But think about a 1D number line. If you multiply <code class="language-plaintext highlighter-rouge">1</code> by <code class="language-plaintext highlighter-rouge">-1</code>, it flips 180 degrees to <code class="language-plaintext highlighter-rouge">-1</code>. But what if you only want to rotate exactly half-way (90 degrees)? You need a number that, if you multiply it <em>twice</em>, gives you <code class="language-plaintext highlighter-rouge">-1</code>. That number is \(i\)!</p> <p>If you multiply <code class="language-plaintext highlighter-rouge">1</code> by \(i\), it rotates 90 degrees off the number line, pointing straight up into a brand new 2nd Dimension. This creates the <strong>Complex Plane</strong>, which has an X-axis (Real numbers) and a Y-axis (Imaginary numbers). It shouldn’t be called “imaginary”; it should be called a “Rotational Number.”</p> <p><strong>Me:</strong> <em>Right, because it’s complex math, you’re mapping the imaginary number (rotation) back to the original dimension, the x-axis.</em></p> <p><strong>Anti-Gravity:</strong> Exactly! The FFT does its rotation math and says, <em>“At 107 Hz, the center of mass shifted to the coordinate \(3 + 4i\).”</em></p> <p>But your Machine Learning model can’t read <code class="language-plaintext highlighter-rouge">3 + 4i</code>. So, in Python, we use <code class="language-plaintext highlighter-rouge">np.abs()</code>. Under the hood, this calculates the actual physical distance from the center <code class="language-plaintext highlighter-rouge">(0,0)</code> to the dot <code class="language-plaintext highlighter-rouge">(3, 4)</code> using the Pythagorean theorem: \(\sqrt{3^2 + 4^2} = 5\).</p> <p>It takes the 2D rotational math, collapses it back down to a single real number (<code class="language-plaintext highlighter-rouge">5</code>), and plots that number on your Y-axis. That number <code class="language-plaintext highlighter-rouge">5</code> is exactly what your CNN or Random Forest looks at to determine if the motor is breaking.</p> <h2 id="conclusion">Conclusion</h2> <p>Math gets a bad reputation because it’s usually taught as a bunch of disconnected equations to memorize. But when you realize that things like the imaginary number \(i\) or the <code class="language-plaintext highlighter-rouge">AdaptiveAvgPool2d</code> layer are just incredibly clever “hacks” invented to solve real-world geometry and engineering problems, it completely changes the perspective. It stops being “black magic” and starts feeling like a superpower.</p> <p>Wow, math is fun.</p>]]></content><author><name></name></author><category term="tutorial"/><category term="machine-learning"/><category term="math"/><category term="signal-processing"/><summary type="html"><![CDATA[A deep dive into the math behind CNNs and the FFT using the AGY CLI.]]></summary></entry><entry><title type="html">Teaching a Neural Network to Hear a Failing Bearing</title><link href="https://willieman.com/blog/2026/teaching-a-neural-network-to-hear-a-failing-bearing/" rel="alternate" type="text/html" title="Teaching a Neural Network to Hear a Failing Bearing"/><published>2026-06-15T17:00:00+00:00</published><updated>2026-06-15T17:00:00+00:00</updated><id>https://willieman.com/blog/2026/teaching-a-neural-network-to-hear-a-failing-bearing</id><content type="html" xml:base="https://willieman.com/blog/2026/teaching-a-neural-network-to-hear-a-failing-bearing/"><![CDATA[<p><em>How I built an end-to-end predictive-maintenance pipeline — and why hitting 100% accuracy made me trust it <strong>less</strong>.</em></p> <hr/> <p>Walk through any refinery, power plant, or pump station and you’re surrounded by machines that spin: motors, compressors, turbines, gearboxes. Almost all of them ride on <strong>bearings</strong> — and when a bearing starts to fail, it doesn’t send a calendar invite. A cracked race or a spalled ball can take a multimillion-dollar compressor offline in minutes, halt a production line, or turn into a genuine safety incident.</p> <p>The good news: a failing bearing <strong>whispers before it screams</strong>. Long before a human can hear or feel anything, the vibration signature changes in tiny, repeating ways. <strong>Predictive maintenance (PdM)</strong> is the art of listening for those whispers. This is the story of building a system that does exactly that — and of a plot twist that taught me more than the model did.</p> <blockquote> <p><em>This article is the narrated version of a project on GitHub: <a href="https://github.com/godot107/predictive-maintenance-cwru">github.com/godot107/predictive-maintenance-cwru</a>. Everything here is reproducible from that repo.</em></p> </blockquote> <hr/> <h2 id="the-data-four-bearings-four-fates">The data: four bearings, four fates</h2> <p>I used the <strong>Case Western Reserve University (CWRU) Bearing Dataset</strong>, a benchmark in the condition-monitoring world. An accelerometer sampled at 12 kHz records the vibration of a motor’s drive-end bearing under four conditions:</p> <ul> <li><strong>Normal</strong> (healthy)</li> <li><strong>Inner Race</strong> fault</li> <li><strong>Ball</strong> fault</li> <li><strong>Outer Race</strong> fault</li> </ul> <p>The goal: feed the model a slice of raw vibration and have it name the fault.</p> <p>But you can’t just throw a wiggly line at a neural network and hope. The signal has to be transformed into something a network can <em>see</em>. That’s where two analogies make everything click.</p> <hr/> <h2 id="two-analogies-that-make-signal-processing-click">Two analogies that make signal processing click</h2> <h3 id="-the-fourier-transform-is-a-smoothie-un-blender">🥤 The Fourier Transform is a smoothie un-blender</h3> <p>A raw vibration signal is like a <strong>smoothie</strong>: strawberry, banana, and spinach all blended into one. Looking at the smoothie, you can’t tell what went in.</p> <p>The <strong>Fast Fourier Transform (FFT)</strong> is a magic blender run in reverse. Pour in the smoothie and it hands back the ingredients — <em>“30% strawberry, 50% banana, 20% spinach.”</em> For a bearing, those ingredients are <strong>frequencies</strong>, and a fault adds a specific new “flavour” that shouldn’t be there.</p> <h4 id="how-it-works-in-code-and-physics">How it works in code (and physics)</h4> <p>Physically, when a bearing is healthy, it hums along at a smooth, predictable baseline. But when a crack forms on the inner race, every time a ball bearing rolls over that crack, it creates a tiny microscopic “click” or impact. Because the motor is spinning at a constant speed, these clicks happen at a very regular interval (a specific frequency).</p> <p>The FFT takes the messy, noisy vibration wave and mathematically isolates that exact repeating “click”, plotting it as a giant spike on a graph. In Python, isolating these frequencies is incredibly elegant using NumPy’s real-valued FFT (<code class="language-plaintext highlighter-rouge">rfft</code>):</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">numpy</span> <span class="k">as</span> <span class="n">np</span>

<span class="c1"># 'segment' is a slice of our raw vibration array
# 'fs' is our sampling rate (12,000 Hz)
</span><span class="n">n</span> <span class="o">=</span> <span class="nf">len</span><span class="p">(</span><span class="n">segment</span><span class="p">)</span>

<span class="c1"># 1. Get the X-axis (the exact frequency values in Hz)
</span><span class="n">frequencies_hz</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">fft</span><span class="p">.</span><span class="nf">rfftfreq</span><span class="p">(</span><span class="n">n</span><span class="p">,</span> <span class="n">d</span><span class="o">=</span><span class="mf">1.0</span> <span class="o">/</span> <span class="n">fs</span><span class="p">)</span>

<span class="c1"># 2. Get the Y-axis (the magnitude/loudness of each frequency)
</span><span class="n">magnitude</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">abs</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">fft</span><span class="p">.</span><span class="nf">rfft</span><span class="p">(</span><span class="n">segment</span><span class="p">))</span> <span class="o">*</span> <span class="p">(</span><span class="mf">2.0</span> <span class="o">/</span> <span class="n">n</span><span class="p">)</span>
</code></pre></div></div> <p>Average the FFT across many windows and the fingerprints jump out — the faults light up high-frequency bands the healthy bearing never touches:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/predictive-maintenance-cwru/eda_avg_fft-480.webp 480w,/assets/img/predictive-maintenance-cwru/eda_avg_fft-800.webp 800w,/assets/img/predictive-maintenance-cwru/eda_avg_fft-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/predictive-maintenance-cwru/eda_avg_fft.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Class-averaged FFT spectrum: each fault lights up high-frequency bands the healthy bearing never touches." loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <h3 id="-a-spectrogram-is-sheet-music">🎼 A spectrogram is sheet music</h3> <p>The FFT has a blind spot: it tells you <em>which</em> notes were played, but not <em>when</em>. Imagine being told a song contains a C, an E, and a G — but not the rhythm. You’d never recognize the tune.</p> <p>A <strong>spectrogram</strong> puts the notes back on a timeline. It’s sheet music: time runs left to right, frequency runs bottom to top, brightness is loudness. Now a fault isn’t a single frequency — it’s a <em>pattern of impacts repeating over time</em>, exactly the kind of 2-D structure a <strong>Convolutional Neural Network</strong> (the tech that recognizes cats in photos) is built to spot.</p> <p>So the high-level pipeline became:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Raw vibration  ─▶  Spectrogram  ─▶  2-D CNN (on GPU)  ─▶  Fault diagnosis
</code></pre></div></div> <h4 id="under-the-hood-the-cnn-architecture">Under the hood: the CNN architecture</h4> <p>To make the “2-D CNN” step concrete, here is the exact dataflow of the model. It’s a lightweight, 3-block architecture (only ~155,000 parameters) that uses an Adaptive Average Pooling layer. This pooling layer is a crucial design choice: it forces the feature map to a fixed 4×4 size before the classifier, meaning the network won’t break if you feed it slightly different sized spectrograms.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/predictive-maintenance-cwru/cnn_architecture-480.webp 480w,/assets/img/predictive-maintenance-cwru/cnn_architecture-800.webp 800w,/assets/img/predictive-maintenance-cwru/cnn_architecture-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/predictive-maintenance-cwru/cnn_architecture.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="The 3-block CNN: stacked Conv → BatchNorm → ReLU → MaxPool blocks, an AdaptiveAvgPool2d(4×4) bottleneck, then a Linear classifier into 4 fault classes." loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p><strong>Design consideration: avoiding the “matrix mismatch” error</strong></p> <p>If you are new to building CNNs, the hardest part is usually preventing <code class="language-plaintext highlighter-rouge">RuntimeError: shape cannot be multiplied</code> when transitioning from Convolutional layers to Linear layers. This architecture solves that using two rules:</p> <ol> <li><strong>The Plumbing Rule:</strong> When stacking <code class="language-plaintext highlighter-rouge">Conv2d</code> layers, only the <em>channels</em> (depth) need to connect perfectly. Notice how Block 1 outputs 16 channels, and Block 2 takes in 16 channels. It’s like plumbing — the output pipes must match the input valves.</li> <li><strong>The “Cheat Code” (Adaptive Pooling):</strong> <code class="language-plaintext highlighter-rouge">MaxPool2d</code> halves the height and width of the spectrogram at every block. Normally, you have to manually calculate the exact final grid size to feed into the <code class="language-plaintext highlighter-rouge">Linear</code> layer. If your input image changes size, the math breaks and the code crashes. By inserting <code class="language-plaintext highlighter-rouge">AdaptiveAvgPool2d((4, 4))</code> right before flattening, we tell PyTorch: <em>“I don’t care what the height and width are at this point. Mathematically squish whatever you have into a 4×4 grid.”</em> This permanently locks our flattened size to exactly 64 channels × 4 × 4 = 1024, completely eliminating shape mismatch errors.</li> </ol> <blockquote> <p><em><strong>Academic credit:</strong> The math that makes “Adaptive Pooling” possible — allowing CNNs to accept images of any size without breaking the fully connected layers — was pioneered by Kaiming He et al. in the landmark 2014 paper <a href="https://arxiv.org/abs/1406.4729">Spatial Pyramid Pooling in Deep Convolutional Networks for Visual Recognition</a>. Furthermore, the practice of heavily pooling spatial dimensions to drastically reduce the parameter count of Linear layers was famously introduced by Lin et al. in the 2013 paper <a href="https://arxiv.org/abs/1312.4400">Network In Network</a>.</em></p> </blockquote> <p>I wired it up in PyTorch, trained on an NVIDIA GPU, and wrapped it in a Streamlit dashboard that shows the raw signal, the FFT, the spectrogram, and the model’s live verdict. It worked. The test accuracy came back at <strong>100%</strong>.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/predictive-maintenance-cwru/dashboard_fault-480.webp 480w,/assets/img/predictive-maintenance-cwru/dashboard_fault-800.webp 800w,/assets/img/predictive-maintenance-cwru/dashboard_fault-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/predictive-maintenance-cwru/dashboard_fault.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="The Streamlit dashboard diagnosing an inner-race fault." loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <blockquote> <p>🚀 <strong>Try it live:</strong> the dashboard is deployed on Streamlit Community Cloud — <a href="https://2vcmmt4mqfryhgfbqw9xmk.streamlit.app/">launch the live demo</a> and diagnose a bearing fault yourself.</p> </blockquote> <p>And that’s where the project got interesting.</p> <hr/> <h2 id="the-plot-twist-when-100-is-a-red-flag">The plot twist: when 100% is a red flag</h2> <p>A perfect score should make you <em>suspicious</em>, not proud. Real-world classifiers don’t hit 100% — so either the problem is trivially easy, or something is leaking.</p> <p>Classic overfitting looks like <strong>high training accuracy, low test accuracy</strong> — a gap. But here training <em>and</em> test were both ~100%, with no gap. That pointed at the second culprit: <strong>data leakage</strong>.</p> <p>Here was the bug in my evaluation. Each fault class is one long continuous recording. I chopped it into overlapping windows and then split those windows <strong>randomly</strong> into train and test. Because the windows overlapped, near-duplicate slices ended up on <em>both</em> sides of the split. The model wasn’t generalizing — it was recognizing windows it had half-seen in training. The test set was lying to me.</p> <p>So I rebuilt the evaluation the way it should have been done from the start:</p> <ol> <li><strong>Leakage-free splitting</strong> — cut each <em>raw recording</em> into train/validation/test spans <strong>by time</strong> (with a guard gap) <em>before</em> windowing, so no slice is shared.</li> <li><strong>A validation set + early stopping</strong> — stop on validation loss, report on a test set the model never touched.</li> <li><strong>5-fold cross-validation</strong> — for a stable estimate instead of one lucky split.</li> </ol> <p>The payoff was immediate and humbling. With honest splitting, the early training epochs now showed <strong>training accuracy at 1.00 while validation sat at 0.25 — random chance.</strong> That genuine overfitting had been completely <em>hidden</em> by the leaky split. The methodology upgrade was doing real work.</p> <hr/> <h2 id="the-deeper-twist-it-was-still-100">The deeper twist: it was <em>still</em> 100%</h2> <p>Here’s what I didn’t expect. Even after removing every trace of leakage, and across all five cross-validation folds, the score held: <strong>1.000 ± 0.000</strong>.</p> <p>So leakage wasn’t the main story. To understand why, I did what I should have done <em>first</em>: I stopped modeling and <strong>explored the data</strong>.</p> <p>I computed a set of classic vibration features (RMS, <strong>kurtosis</strong>, crest factor — all interpretable measures of how “impulsive” a signal is) and projected them to two dimensions with t-SNE. The picture explains everything:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/predictive-maintenance-cwru/eda_tsne-480.webp 480w,/assets/img/predictive-maintenance-cwru/eda_tsne-800.webp 800w,/assets/img/predictive-maintenance-cwru/eda_tsne-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/predictive-maintenance-cwru/eda_tsne.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="t-SNE of engineered features: four clean, perfectly separated clusters at a single operating condition." loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Four clean, perfectly separated clusters. At a <em>single operating condition</em> — one load, one speed — each fault’s signature is so distinct that the classes barely overlap. To prove the point, I trained a plain <strong>Random Forest</strong> on those hand-crafted features (no deep learning at all). It also scored <strong>1.000</strong>.</p> <p>The lesson: <strong>100% wasn’t a triumph of my model — it was a sign the benchmark was easy.</strong> And knowing the difference is the whole job.</p> <hr/> <h2 id="asking-the-bearing-to-confirm-its-own-diagnosis">Asking the bearing to confirm its own diagnosis</h2> <p>There was one more thing I wanted: proof the model was learning <em>real physics</em>, not an artifact. Bearing engineering gives us a beautiful test.</p> <p>The CWRU drive-end bearing is an <strong>SKF 6205</strong>, and its geometry fixes the exact frequencies at which each defect “rings” — the ball-pass frequencies of the outer race (BPFO), inner race (BPFI), and so on, all set by the shaft speed. Using <strong>envelope analysis</strong> (the demodulation technique vibration engineers actually use), I extracted the impact-repetition rate from each fault and checked it against theory:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/predictive-maintenance-cwru/eda_envelope-480.webp 480w,/assets/img/predictive-maintenance-cwru/eda_envelope-800.webp 800w,/assets/img/predictive-maintenance-cwru/eda_envelope-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/predictive-maintenance-cwru/eda_envelope.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" title="Envelope spectra vs. SKF-6205 characteristic frequencies: the peaks land exactly where the physics predicts." loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>The peaks land exactly where the physics predicts: the <strong>Outer Race</strong> fault rings at <strong>BPFO (107 Hz)</strong>, the <strong>Inner Race</strong> fault at <strong>BPFI (162 Hz)</strong>. The signal carries genuine, explainable diagnostic content — and, honestly, the <strong>Ball</strong> fault is the subtle one, with a weak envelope signature, which is precisely what bearing theory says to expect. Even the exceptions agreed with the textbook.</p> <hr/> <h2 id="what-this-project-actually-demonstrates">What this project actually demonstrates</h2> <p>The deliverable people <em>see</em> is a dashboard that classifies bearing faults. The deliverable that matters is the <strong>judgment</strong> around it:</p> <ul> <li><strong>Engineering it end-to-end</strong> — data ingestion, signal processing, a GPU-trained CNN, and an explainable UI.</li> <li><strong>Distrusting a good result</strong> until the evaluation earns that trust — and knowing that leakage, not the model, is usually the thing to interrogate first.</li> <li><strong>Explaining it in plain language</strong> — to a recruiter with a smoothie, to an engineer with an envelope spectrum.</li> </ul> <p>That last part is the heart of an <strong>AI Solutioning Consultant’s</strong> job: translating between the business problem (“don’t let the compressor fail”) and the technical reality (“here’s what the data can and can’t tell us, and here’s how I know”).</p> <p>So I ran the benchmark I’d been saving for last — the genuinely <em>hard</em> one: <strong>cross-load generalization</strong>. CWRU recorded each fault at four motor loads, so I trained on three and tested on a load the model had never seen — a true change of operating condition, not just a clean split. I expected the accuracy to finally crack.</p> <p>It didn’t. Leave-one-load-out held at <strong>1.000 ± 0.000</strong>, and even training on a <em>single</em> load and testing on the most distant one stayed at <strong>0.998</strong>. The physics explains it: a defect’s frequencies are fixed by the bearing’s <em>geometry</em>, which barely shifts across this ~4% speed range, while the per-window normalization removes the amplitude changes the load <em>does</em> cause. What survives is the fault-frequency <em>pattern</em> — exactly what the CNN keys on. Load transfer simply isn’t the bottleneck for naming the fault <em>type</em>.</p> <p>So the genuinely open problems lie elsewhere: <strong>severity grading</strong> (telling a 0.007″ defect from a 0.021″ one of the <em>same</em> fault type) and noisy, multi-fault field data. Knowing <em>where</em> the difficulty actually lives — and where it doesn’t — turned out to be the most useful thing the whole exercise produced. It’s the next milestone in the repo.</p> <hr/> <h2 id="try-it-yourself">Try it yourself</h2> <ul> <li><strong>Live dashboard:</strong> <a href="https://2vcmmt4mqfryhgfbqw9xmk.streamlit.app/">launch the Streamlit app</a></li> <li><strong>Code &amp; full write-up:</strong> <a href="https://github.com/godot107/predictive-maintenance-cwru">github.com/godot107/predictive-maintenance-cwru</a></li> <li><strong>Dataset:</strong> <a href="https://engineering.case.edu/bearingdatacenter">CWRU Bearing Data Center</a></li> <li><strong>The best 20 minutes on Fourier transforms:</strong> <a href="https://www.youtube.com/watch?v=spUNpyF58BY">3Blue1Brown</a></li> </ul> <p>If you take one thing from this: when your model scores 100%, don’t celebrate — <em>investigate</em>. The most valuable result in this whole project was the one that looked too good to be true.</p> <hr/> <h2 id="appendix-glossary-of-terms">Appendix: Glossary of terms</h2> <p>If you’re new to the signal processing or machine learning terminology used in this article, here is a quick guide to help clarify the concepts:</p> <ul> <li><strong>Predictive Maintenance (PdM)</strong>: The practice of continuously monitoring equipment condition (like vibration or temperature) to predict when it will fail. This allows maintenance to be scheduled right before failure, avoiding both unexpected breakdowns and unnecessary routine part replacements.</li> <li><strong>Fast Fourier Transform (FFT)</strong>: A mathematical algorithm that takes a raw signal measured over time (like a complex vibration wave) and breaks it down into the individual frequencies that make it up. Think of it as a recipe that tells you exactly how much of each “pitch” is in a sound.</li> <li><strong>Spectrogram</strong>: A visual representation of frequencies as they change over time. If an FFT gives you the specific notes played in a single chord, a spectrogram is the sheet music for the entire song, showing when each note is played and how loud it is.</li> <li><strong>Data Leakage</strong>: A fundamental mistake in machine learning where the model accidentally has access to the test data during training. In this project’s initial approach, overlapping time windows were randomly split, meaning near-identical slices ended up in both the training and test sets. The model essentially “memorized” the answers rather than learning to generalize.</li> <li><strong>t-SNE (t-Distributed Stochastic Neighbor Embedding)</strong>: A technique used to visualize highly complex data by grouping similar data points together on a 2D map. In this article, it visually proves that the different bearing faults are so mathematically distinct that they form their own isolated “islands,” explaining why the model easily scored 100%.</li> <li><strong>Envelope Analysis (Demodulation)</strong>: A signal processing technique that strips away the loud, high-frequency “carrier” noise of a machine to isolate and reveal the quieter, lower-frequency repeating impacts — such as a bearing ball repeatedly hitting a crack on the inner race.</li> <li><strong>Cross-load Generalization (Cross-load Benchmark)</strong>: The process of training a model on data from a machine operating under one set of conditions (e.g., a 1 horsepower load) and testing it on data from a different condition (e.g., 2 or 3 horsepower). Because machine vibrations inherently change under different physical loads, testing “cross-load” proves whether the model actually learned the physics of a failure, or if it just overfit to the exact sound of a 1 HP motor.</li> </ul>]]></content><author><name></name></author><category term="project"/><category term="machine-learning"/><category term="signal-processing"/><category term="predictive-maintenance"/><category term="deep-learning"/><summary type="html"><![CDATA[Building an end-to-end predictive-maintenance pipeline on the CWRU bearing dataset — and why hitting 100% accuracy made me trust it less.]]></summary></entry><entry><title type="html">Humour Seriously — A Reflection Essay</title><link href="https://willieman.com/blog/2026/humour-seriouslya-reflection-essay/" rel="alternate" type="text/html" title="Humour Seriously — A Reflection Essay"/><published>2026-01-23T05:21:42+00:00</published><updated>2026-01-23T05:21:42+00:00</updated><id>https://willieman.com/blog/2026/humour-seriouslya-reflection-essay</id><content type="html" xml:base="https://willieman.com/blog/2026/humour-seriouslya-reflection-essay/"><![CDATA[<h3>Humour Seriously — A Reflection Essay</h3> <figure><img alt="" src="https://cdn-images-1.medium.com/max/975/1*lQvzKQF4f1GkIJwwqRbrhw.jpeg"/></figure> <p>This reflection, sparked by David Brooks’ <em>How to Know a Person</em> and a deeper dive into the science of humor, chronicles an internal mid-life exploration-a quest to foster genuine connection and navigate the shifting seasons of life. It reveals a profound realization: the critical, often underestimated, power of humor.</p> <h3>The Intellectual Depth of Comedy</h3> <p>Comedians, as it turns out, are more than just entertainers; they are astute observers of the human condition. They possess a unique intellectual sharpness, capable of dissecting and articulating the inherent absurdities and oddities of everyday life-a talent perfectly embodied by the observational genius of <em>Seinfeld</em>. Their ability to communicate deep, sometimes uncomfortable truths in a relatable manner is a sign of high social and intellectual intelligence.</p> <h3>Levity and Gravity: The Essential Balance</h3> <p>The journey highlighted the crucial contrast between Levity and Gravity.</p> <ul><li><strong>Levity</strong> (from the Latin <em>levitas</em>, “lightness”): Refers to a lightness in mood, tone, or behavior, expressed through humor, playfulness, and a deliberate lack of excessive seriousness. It is the necessary antidote to the constant pressures of adult life.</li><li><strong>Gravity</strong> (from the Latin <em>gravitas</em>, “heaviness”): Encompasses seriousness, solemnity, and physical weight.</li></ul> <p>In an era defined by demanding work schedules and constant professional pressure, the authors argues that we are often overburdened by Gravity. Understanding and actively seeking Levity is therefore not a frivolous pursuit, but a vital mechanism for psychological health and relational well-being.</p> <h3>Mapping Your Humor Style</h3> <p>The book presented a framework for understanding one’s dominant humor style, categorized by the dimensions of Aggressive vs. Affiliative and Subtle vs. Expressive:</p> <p>1. The Sniper (Aggressive-Subtle)</p> <p>Edgy, sarcastic, and nuanced.</p> <p>Delivers humor with a dry, deadpan style, often under the breath. Unafraid to cross lines for the laugh.</p> <p>An “acquired taste.” Observes from the sidelines, waiting for the perfect, deadly one-liner.</p> <p>2. The Stand-up (Aggressive-Expressive)</p> <p>Bold, irreverent, and natural entertainers.</p> <p>Comfortable as the center of attention. Uses roasts and pranks, unafraid to ruffle feathers.</p> <p>Has thick skin; views being the butt of a joke as a sign of affection or engagement.</p> <p>3. The Magnet (Affiliative-Expressive)</p> <p>Charismatic, warm, and consistently positive.</p> <p>Uses animated delivery, including slapstick or impersonations, to radiate good cheer.</p> <p>A mood-booster. Generous with their own laughter and avoids controversial topics.</p> <p>4. The Sweetheart (Affiliative-Subtle)</p> <p>Earnest, understated, and empathetic.</p> <p>Prefers planned humor (e.g., a thoughtful line in a speech) over off-the-cuff jokes.</p> <p>Uses humor to uplift others. Avoids teasing to ensure no feelings are hurt.</p> <p>I personally identifies with <strong>The Sweetheart</strong>-motivated by a deep-seated fear of causing offense-and looks to <strong>The Stand-up</strong> with admiration. Trevor Noah’s 2022 White House Correspondents’ Dinner is cited as a masterclass in this style: navigating a politically diverse and tense audience, successfully delivering sharp jabs while simultaneously defending the American press and the 1st Amendment-a rare blend of high-stakes comedy and serious civic commentary.</p> <h3>The Profound Benefits of Laughter</h3> <p>Humor is a tool of immense power, capable of elevating one’s perceived intelligence and earning respect. Its utility extends even into tragedy, acting as a crucial mechanism for collective processing and moving forward.</p> <p>The physiological effects of laughter are akin to a naturally administered “drug cocktail,” releasing vital neurochemicals:</p> <ul><li><strong>Endorphins</strong>: Natural pain relievers and mood elevators.</li><li><strong>Dopamine</strong>: Associated with pleasure, reward, and motivation.</li><li><strong>Serotonin</strong>: Regulator of mood, sleep, and appetite.</li><li><strong>Oxytocin</strong>: The “bonding hormone,” which promotes trust and connection.</li></ul> <p>This chemical release actively counteracts stress by reducing the levels of stress hormones like Cortisol and Epinephrine.</p> <h3>Deconstructing the Mechanics of Humor</h3> <p>Humor is an art form rooted in specific, measurable components:</p> <ul><li><strong>Timing</strong>: The precise moment of delivery.</li><li><strong>Distance</strong>: The necessary space-temporal (from a tragic event), geographical, or emotional-required to find comedy in a situation.</li><li><strong>Surprise (Incongruity)</strong>: The unexpected twist or deviation from a listener’s expectation.</li></ul> <p><em>The Onion</em> ‘s first publication after 9/11 serves as a classic example of using humor-specifically absurdity and incongruity-to process the unimaginable. By creating headlines like “HUGGING UP 76,000 PERCENT” or “NOT KNOWING WHAT ELSE TO DO, WOMAN BAKES AMERICAN-FLAG CAKE”, they provided “comic relief when I desperately needed it to help me process something unimaginable,” turning collective shock into a small, shared step toward healing. As the famous saying goes, there is a thin margin between tragedy and comedy.</p> <h3>The Adult Dip in Joy and the Power of Re-framing</h3> <p>The observation from <em>Inside Out 2</em> -”I guess that’s what happens when you grow up; you feel less joy.”-highlights a natural dip in the capacity for laughter and joy that occurs after adolescence and persists through the long “middle years” (30s to 60s), only to rise again in later life. Adult responsibilities and “adulting” create a gravitational pull away from Levity.</p> <p>However, humor offers a vital escape hatch: it allows us to find hidden truths in life, especially when reflecting on past hurts and trauma. By applying a touch of humor and Levity, old stories can be re-framed and viewed in a new, less painful light.</p> <h3>Humor in Power, Leadership, and Communication</h3> <p>Humor is a powerful tool in social and professional dynamics:</p> <ul><li><strong>Minimizing Power Distance</strong>: A light joke from an executive or CEO in an elevator can instantly reduce the perceived hierarchy, creating a more relaxed and connected environment for co-workers. (Caveat: Excessive self-deprecation, however, can project insecurity.)</li><li><strong>Delivering Hard Conversations</strong>: Humor can be used to send a provocative yet non-threatening message. An anecdote details a female boss who, finding her male co-workers unconsciously excluding her by continuing work conversations in the men’s room, joked about joining them. This lighthearted line sent a clear, powerful message of “I see you; please stop,” without escalating into a confrontation.</li></ul> <h3>Cultivating a Culture of Levity</h3> <p>The essay explores inspiring, if extreme, examples of institutionalizing Levity to create psychological safety-a condition where people feel comfortable being themselves and taking creative risks.</p> <ul><li><strong>Hiroki Asai (Apple)</strong>: Believed “fear is the greatest killer of creativity.” He planned elaborate, surprise-filled events designed purely for laughs, using humor as a way to boost energy and create shared joy:</li><li>The Blue Man Group: Employees dressed up to surprise an audience.</li><li>The “Chase” Scenes: Gag videos featuring high-speed pursuits of Asai himself.</li><li>The Gospel Choir: The most successful example, where a dry presentation was transformed into a high-energy moment of joy by revealing a full gospel choir, centered around a “Hidden Gem” (a junior designer who was a trained singer).</li><li><strong>Eric Schmidt (Sun Microsystems)</strong>: Early in his career, he staged an elaborate prank by disassembling and reassembling a Volkswagen Beetle inside the office. This signaled that the company was a place that empowered people to be themselves and have fun within appropriate boundaries.</li></ul> <h3>A Call to Action for Digital Citizenship</h3> <p>These work environments offer a blueprint for life, especially as modern existence increasingly shifts into the digital world. The final reflection is a personal call to action: to deliberately exercise the muscle of humor, to apply Levity to daily life and digital interactions, and to actively uplift the people around us. The world, burdened by Gravity, desperately needs it.</p> <p>The journey concludes with the ultimate personal commitment: seeking an improv comedy class-a challenge to overcome the “Sweetheart’s” fear of the spontaneous and potentially offensive, and a commitment to prioritizing shared laughter, even if it means commuting to a club instead of maximizing family time, as the author muses, “what’s more important than taking care of the family, then commute to a comedy club and learn improve with complete strangers?”</p> <p><em>Originally published at </em><a href="https://godot107.github.io/books/humor_seriously_reflection_essay/"><em>https://godot107.github.io</em></a><em>.</em></p> <p><img src="https://medium.com/_/stat?event=post.clientViewed&amp;referrerSource=full_rss&amp;postId=30d6ad23dfc8" width="1" height="1" alt=""/></p>]]></content><author><name></name></author><category term="external-posts"/><category term="medium"/></entry><entry><title type="html">The Unseen Crisis: Why I Finally Read ‘How to Know a Person’</title><link href="https://willieman.com/blog/2025/the-unseen-crisis-why-i-finally-read-how-to-know-a-person/" rel="alternate" type="text/html" title="The Unseen Crisis: Why I Finally Read ‘How to Know a Person’"/><published>2025-11-29T06:30:15+00:00</published><updated>2025-11-29T06:30:15+00:00</updated><id>https://willieman.com/blog/2025/the-unseen-crisis-why-i-finally-read-how-to-know-a-person</id><content type="html" xml:base="https://willieman.com/blog/2025/the-unseen-crisis-why-i-finally-read-how-to-know-a-person/"><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/988/1*pnlAnnnfO-JDqwxQsHccaA.jpeg"/><figcaption>How to Know a Person Book Cover</figcaption></figure> <h3>The Question That Wouldn’t Go Away</h3> <p>“How to know a person?”</p> <p>My initial reaction to that title was a dismissive eye-roll. Seriously? I’ve been navigating this world long enough. I have friends. I can hold a conversation. I can read an article and understand a biography. Why on earth would I need a book to teach me something so fundamental?</p> <p>But I picked up David Brooks’s <em>How to Know a Person</em> anyway. And I couldn’t put it down.</p> <p>I found myself struggling with a deeper question-one that felt sudden but was, of course, years in the making: <strong>How did we get here?</strong></p> <p>I’m talking about the atmosphere of political violence and the relentless tribalism that has settled over America. There’s a toxic sense that violence is justified for “my side” because the stakes-the ideology-have become existential. Like the chain of events leading up to the “powder keg” of World War I, our current division feels like the culmination of years of disconnection.</p> <p>Listening to Brooks discuss this on NPR, I was intrigued by his final note of hope. He suggested the antidote to this existential polarization wasn’t a policy shift, but something far more intimate: <strong>a change in how we see each other.</strong></p> <p>Coincidentally, this quest for connection was also hitting close to home. Between my full-remote work and a changing life stage, I was struggling to genuinely <em>socialize</em> with people. The book hit me right in the soul: everyone on this planet yearns to be seen-not just physically, but <strong>inwardly</strong>-to be <strong>valued, heard, and understood</strong>.</p> <h3>The Guilt of the ‘Brand’</h3> <p>As I read, I faced a humbling realization: <strong>I am guilty of dehumanization.</strong></p> <p>I have been reducing people to their “brand”-how they are known, their profession, or the ‘group’ they are most obviously associated with (ethnic, political, etc.). I was “putting people into boxes.” I’d meet someone and immediately start filling in the blanks based on stereotypes or superficial similarities to others I knew.</p> <p>Brooks argues that every person is a <strong>one-of-a-kind</strong> at this precise moment in time, shaped by a unique blend of ancestry, circumstance, experiences, decisions, and relationships.</p> <p>For example, I identify as an Asian-American, and my life is undeniably traced to my ancestry, yet it’s been uniquely shaped by being the son of refugees fleeing a war-torn Cambodia. My “group” gives context, but my personal story defines the individual.</p> <p>We are not static, either. We are not frozen in our “greatest hits” or our deepest wounds. A person I was ten years ago, or a person who has been changed by trauma-like the survivors of the tsunami mentioned in the book-is different from the person they are today. We are all living, adapting, and changing narratives.</p> <h3>Are We Even Listening? The Epidemic of Blindness</h3> <p>We know that empathy and listening are important. My head knows it. But my heart, my pride, and my ego often fight against it.</p> <p>I believe our current social and economic structure-from the echo chambers of social media to the pressures of hyper-individualism-incentivizes this “Epidemic of Blindness.” Brooks cites that <strong>empathy and community are in decline</strong>, evidenced by the struggles of non-profits to fill volunteer roles.</p> <p>But the core issue is not just a lack of goodwill; it’s a lack of <strong>true attention.</strong></p> <p>Most of the time, we aren’t listening; we’re just waiting for our turn to speak or shift the focus back to ourselves. We treat our attention span like a light dimmer, slowly inching up the focus (if at all) while one eye is on our phone or our mind is elsewhere.</p> <p>Brooks argues that true listening must be an <strong>on/off switch</strong>: “May I have your undivided attention.” This is the practice of <strong>“framing,”</strong> putting the camera entirely onto the other person. He shares his own regretful experience on a panel where he failed to listen, prioritizing his own piece over genuinely engaging with the speaker.</p> <h3>The Humility of ‘Illumination’</h3> <p>The book taught me that empathy is a practiced skill. It is not, as the cliché goes, about “putting yourself in their shoes,” because that would require lived experiences I simply do not possess. I am not a single mother, nor am I a refugee fleeing violence. How I perceive the world is vastly different from their mental model.</p> <p>Empathy is about recognizing this difference and actively trying to see the object or issue through <strong>their perspective</strong>.</p> <p>Brooks calls the moment this clicks <strong>“illumination,”</strong> and it is incredibly powerful and humbling. He references Jimmy Dorrell, a man who sees the inherent worth in every human being, believing that each person possesses a <strong>“transcendent spark”</strong> -a soul-which makes us all equal at the deepest level.</p> <p>This humility means admitting my assumptions are often wrong. It means knowing that I don’t know everything about a person before we even meet.</p> <h3>The Hope That Reaches Across Continents</h3> <p>This book has done the work of softening my heart, especially toward people I might otherwise view as an “enemy” simply because of a political or ideological disagreement. I am learning to silence my ego, which loves a good argument, and simply <strong>seek to understand.</strong></p> <p>What gives me profound hope is the idea that two people, with vastly different experiences, can truly empathize and understand one another. Brooks shares the powerful friendship between a writer and a man who survived the Rwandan genocide, a story that underlines both the <strong>danger</strong> of dehumanization and the <strong>power</strong> of connection.</p> <p>The Rwandan genocide was a horrific reminder of what happens when empathy is completely annihilated-over a million Tutsi people were murdered, often by their own neighbors, because of ethnic hatred woven with a complex history.</p> <p>Yet, from that tragedy, one man’s story offers redemption. Deo, a survivor, returned to his home country of Burundi to open a hospital that served both Hutu and Tutsi belligerent groups, treating everyone for medical care regardless of which side they were on.</p> <p>In the end, I’ve found a renewed desire to rediscover what it means to be human-with common struggles, common experiences, and common joys-not just what I do for a living or what content is curated for me.</p> <blockquote><em>“I am human, and nothing human is alien to me.” — Terence (p. 121)</em></blockquote> <p>Thank you, <a href="https://www.nytimes.com/by/david-brooks">David Brooks</a>, for intersecting various disciplines and compelling stories into a cohesive journey. This is a book I will be returning to often.</p> <p><strong>What is a book you’ve read that fundamentally changed how you interact with people?</strong></p> <p>Consider a read by <a href="https://www.amazon.com/How-Know-Person-Seeing-Others/dp/0593230078/ref=sr_1_1?crid=ZZ3KNO4R2ZOV&amp;dib=eyJ2IjoiMSJ9.8sge6aX_JyCRvGmi6NM5pZjeKokOT-pQQgmYPWR3DaoU3i6_pn24PiCbavqt_PdqZEtdWD7gT45B3ntw38DpghkzUkJ7eBE1KxP5QT5l03pmpN9jhAQwBp08fiFHRzk57e5kezvi7JAQqX90EbiWx854uLrPayfHZEXN_bKMma1CXLINKV8Fp2Tq0IXOPABokdy8r_6SY8OFRl78W4ZplhvRve-5xe8oIJDAKus6AA8.yLFLlJPTpo8IvnhUUyZ1I2-jvcTryTK-Ms1V0aYhShY&amp;dib_tag=se&amp;keywords=how+to+know+a+person+david+brooks&amp;qid=1764396608&amp;sprefix=how+to+know+a+person%2Caps%2C245&amp;sr=8-1">purchasing</a> or borrowing from your local library.</p> <p><strong>AI Transparency Note:</strong> This article was edited and polished using Generative AI. All opinions expressed are solely my own, and I remain entirely responsible for the final content and accuracy.</p> <p><em>Originally published at </em><a href="https://godot107.github.io/books/how_to_know_a_person_reflection_essay/"><em>https://godot107.github.io</em></a><em>.</em></p> <p><img src="https://medium.com/_/stat?event=post.clientViewed&amp;referrerSource=full_rss&amp;postId=5d845aacf052" width="1" height="1" alt=""/></p>]]></content><author><name></name></author><category term="external-posts"/><category term="medium"/></entry><entry><title type="html">Sentiment Analysis with TF-IDF and Logistic Regression</title><link href="https://willieman.com/blog/2025/sentiment-analysis-with-tf-idf-and-logistic-regression/" rel="alternate" type="text/html" title="Sentiment Analysis with TF-IDF and Logistic Regression"/><published>2025-02-15T22:39:22+00:00</published><updated>2025-02-15T22:39:22+00:00</updated><id>https://willieman.com/blog/2025/sentiment-analysis-with-tf-idf-and-logistic-regression</id><content type="html" xml:base="https://willieman.com/blog/2025/sentiment-analysis-with-tf-idf-and-logistic-regression/"><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*73Sf8fmt3kuFmdFa"/><figcaption>Photo by <a href="https://unsplash.com/@countchris?utm_source=medium&amp;utm_medium=referral">Count Chris</a> on <a href="https://unsplash.com?utm_source=medium&amp;utm_medium=referral">Unsplash</a></figcaption></figure> <p>This <a href="https://github.com/godot107/amazon_sentiment/blob/main/notebooks/logistic_regression_sentiment_baseline.ipynb">notebook </a>demonstrates a baseline approach to sentiment analysis using TF-IDF (Term Frequency-Inverse Document Frequency) vectorization and Logistic Regression. While simpler than modern deep learning approaches, this method provides a solid foundation for understanding text classification.</p> <h3>📚 Concepts</h3> <h3>TF-IDF Vectorization</h3> <p>TF-IDF is a statistical measure that evaluates how relevant a word is to a document in a collection of documents (corpus). It balances term frequency with word uniqueness, ensuring that frequently occurring but unimportant words do not dominate text representations.</p> <h4>Components of TF-IDF:</h4> <ol><li><strong>Term Frequency (TF)</strong>: Measures how often a term appears in a document.</li><li><strong>Inverse Document Frequency (IDF)</strong>: Measures how important or unique a term is across all documents.</li></ol> <figure><a href="https://www.geeksforgeeks.org/understanding-tf-idf-term-frequency-inverse-document-frequency/"><img alt="" src="https://cdn-images-1.medium.com/max/883/1*wnCHLAGFdy19iTpuFtUlnw.png"/></a><figcaption>Source: GeeksforGeeks</figcaption></figure> <h4>Why Use TF-IDF?</h4> <ul><li><strong>Downweights common words</strong> (e.g., “the”, “is”) while highlighting informative terms.</li><li><strong>Emphasizes distinctive words</strong>, making text classification more effective.</li><li><strong>Handles varying document lengths</strong> by normalizing term frequencies.</li></ul> <h3>Logistic Regression</h3> <p>A linear classification model that predicts probabilities using the sigmoid function.</p> <iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FyIYKR4sgzI8%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DyIYKR4sgzI8&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FyIYKR4sgzI8%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/f61920d0a1ae4c3c14b426822d4b3a15/href">https://medium.com/media/f61920d0a1ae4c3c14b426822d4b3a15/href</a></iframe> <h4>Key Advantages:</h4> <ul><li><strong>Handles high-dimensional, sparse data</strong> well (like TF-IDF features).</li><li><strong>Interpretable</strong>: Provides feature importance via learned weights.</li><li><strong>Efficient and scalable</strong>, making it a great baseline model.</li></ul> <h3>🎯 Implementation Details</h3> <h3>1. Data Preprocessing</h3> <ul><li>Handling missing values.</li><li>Converting ratings to sentiment labels (negative, neutral, positive).</li></ul> <figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fk4tQzn9WUBrvR5G_2qGiA.png"/></figure> <h3>2. Feature Engineering</h3> <ul><li><strong>TF-IDF vectorization</strong> to convert text into numerical features.</li><li><strong>Stop word removal</strong> to eliminate common words that add little value.</li><li><strong>Feature limitation</strong>: Restricting to the top 10,000 most frequent terms to reduce sparsity.</li></ul> <figure><img alt="" src="https://cdn-images-1.medium.com/max/618/1*b2JRiq3qig3TcD0xvRYapg.png"/></figure> <h3>3. Model Training</h3> <ul><li><strong>Train-test split</strong> with stratification to maintain class distribution.</li></ul> <figure><img alt="" src="https://cdn-images-1.medium.com/max/517/1*88kAuHDJl-bHNb8KrIwLyA.png"/></figure> <h3>1. Bag-of-Words Limitations</h3> <ul><li><strong>Loses word order information</strong> (e.g., “not happy” and “happy” are treated similarly).</li><li><strong>Cannot capture context</strong> beyond individual words.</li><li><strong>Struggles with negations</strong> (e.g., “not good” vs. “good”).</li></ul> <h3>2. Vocabulary Issues</h3> <ul><li><strong>Out-of-vocabulary (OOV) words</strong> in test data may not be well represented.</li><li><strong>Sparse feature matrix</strong> leads to memory inefficiency.</li><li><strong>Fixed vocabulary</strong>: Adding new words requires retraining.</li></ul> <h3>3. Class Imbalance</h3> <ul><li><strong>Biased predictions</strong> toward the majority class.</li><li><strong>Need for proper evaluation metrics</strong> beyond accuracy.</li><li><strong>Importance of stratified sampling</strong> to maintain label proportions.</li></ul> <figure><img alt="" src="https://cdn-images-1.medium.com/max/445/1*p3fOmhfOscw2tejqDaOwww.png"/></figure> <h3>🚀 Potential Improvements</h3> <h3>1. Text Preprocessing</h3> <ul><li>More sophisticated text cleaning (handling contractions, special characters, etc.).</li><li><strong>Lemmatization</strong> instead of stemming for better root-word representation.</li><li><strong>Explicit negation handling</strong> (e.g., replacing “not happy” with “not_happy”).</li><li><strong>Emoji and emoticon processing</strong> for sentiment-rich symbols.</li></ul> <h3>2. Feature Engineering</h3> <ul><li><strong>N-gram features</strong> (bigrams, trigrams) to capture phrase-level information.</li><li><strong>Custom stop words</strong> tailored to the domain.</li><li><strong>Part-of-Speech (POS) tagging</strong> to identify important words.</li><li><strong>Named Entity Recognition (NER)</strong> for identifying entities.</li></ul> <h3>3. Model Enhancements</h3> <ul><li><strong>Ensemble methods</strong> (e.g., Random Forest, XGBoost) for better performance.</li><li><strong>Feature selection</strong> to remove noisy terms.</li><li><strong>Hyperparameter tuning</strong> using GridSearchCV.</li><li><strong>More sophisticated cross-validation strategies</strong> (e.g., stratified k-fold).</li></ul> <h3>4. Advanced Techniques</h3> <ul><li><strong>Word embeddings</strong> (Word2Vec, GloVe) for dense representations.</li><li><strong>Deep learning models</strong> (BERT, RoBERTa) for context-aware sentiment analysis.</li><li><strong>Transfer learning</strong> to leverage pre-trained language models.</li></ul> <h3>📊 Evaluation Metrics</h3> <h3>Key Metrics:</h3> <ul><li><strong>Accuracy</strong>: Overall correctness of the model.</li><li><strong>F1-score</strong>: Balances precision and recall, especially for imbalanced datasets.</li><li><strong>Precision &amp; Recall</strong>: Measures of positive prediction quality and coverage.</li><li><strong>Confusion Matrix</strong>: Provides insights into misclassification.</li><li><strong>ROC-AUC Curve</strong>: Evaluates classification threshold performance.</li></ul> <h3>🔍 Use Cases</h3> <p>This approach is particularly useful for:</p> <ol><li><strong>Baseline model development</strong> before exploring deep learning.</li><li><strong>Quick prototyping</strong> with minimal computational cost.</li><li><strong>Small to medium-sized datasets</strong> where deep learning isn’t necessary.</li><li><strong>Interpretability-focused applications</strong>, such as legal or financial text classification.</li><li><strong>Limited computational resources</strong>, where traditional ML is preferable.</li></ol> <h3>📝 Next Steps</h3> <p>To further improve sentiment analysis:</p> <ol><li>Experiment with advanced feature engineering techniques.</li><li>Implement more robust text preprocessing strategies.</li><li>Optimize hyperparameters and cross-validation methods.</li><li>Compare performance against transformer-based models.</li><li>Analyze misclassified examples to refine the approach.</li></ol> <h3>📚 References</h3> <ol><li><a href="https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html">Scikit-learn TF-IDF documentation</a></li><li><em>Introduction to Information Retrieval</em> by Manning, Raghavan, and Schütze</li><li><em>Pattern Recognition and Machine Learning</em> by Christopher Bishop</li><li><a href="https://www.geeksforgeeks.org/understanding-tf-idf-term-frequency-inverse-document-frequency/">Understanding TF-IDF (Term Frequency-Inverse Document Frequency)</a></li></ol> <p><img src="https://medium.com/_/stat?event=post.clientViewed&amp;referrerSource=full_rss&amp;postId=f4cd86f359a1" width="1" height="1" alt=""/></p>]]></content><author><name></name></author><category term="logistic-regression"/><category term="sentiment-analysis"/><category term="nlp"/><category term="medium"/></entry><entry><title type="html">Don’t get a (rescue) dog</title><link href="https://willieman.com/blog/2025/dont-get-a-rescue-dog/" rel="alternate" type="text/html" title="Don’t get a (rescue) dog"/><published>2025-01-14T16:22:24+00:00</published><updated>2025-01-14T16:22:24+00:00</updated><id>https://willieman.com/blog/2025/dont-get-a-rescue-dog</id><content type="html" xml:base="https://willieman.com/blog/2025/dont-get-a-rescue-dog/"><![CDATA[<h4>unless you want to make an impact</h4> <figure><img alt="" src="https://cdn-images-1.medium.com/max/420/1*OeomiOs5LuiZV9rJHW62NA.png"/><figcaption>Lady Bird</figcaption></figure> <h3>Celebrating Four Years with Lady Bird</h3> <p>April 1st marks four years since we brought home our rescue dog, Lady Bird. No joke — April Fools’ Day became the start of a heartfelt journey. Adopting a rescue dog is a leap of faith; you’re taking on an unknown history, and Lady Bird came with her share of challenges, especially separation anxiety.</p> <h3>Understanding Separation Anxiety</h3> <p>Until you’ve experienced a dog with separation anxiety, it’s hard to grasp the weight it adds to daily life. Lady Bird had been through at least five homes before finding her forever home with us. Interestingly, we even ran into one of her previous foster owners in our apartment building — a small world indeed. To help Lady Bird adjust, we sought professional help from a separation anxiety consultant. The process involved structured exercises, gradually increasing the time she spent alone, starting with just a few minutes.</p> <figure><img alt="Separation Anxiety Training" src="https://cdn-images-1.medium.com/max/637/1*-3y05IQyEL3uYYpGItkjng.png"/></figure> <h3>Memorable Incidents and Challenges</h3> <p>Lady Bird’s separation anxiety has led to some memorable incidents:</p> <ul><li>She tore up blinds.</li></ul> <figure><img alt="" src="https://cdn-images-1.medium.com/max/673/1*zo5hj8iVYndoAPyjQUMOQA.png"/><figcaption>Lady Bird Separation Anxiety Episode</figcaption></figure> <ul><li>Opened the bottom freezer door by pulling it out. And taken the liberty to eat my chicken tenders!</li><li>Managed to turn on a stove burner.</li><li>Opened kitchen cabinets and pulled out items like an Instant Pot lid.</li><li>Bit into plants.</li><li>Climbed onto furniture to peer out the window.</li></ul> <figure><img alt="" src="https://cdn-images-1.medium.com/max/640/1*SX6fxaFsxeEZ29QSXBNZ8g.png"/><figcaption>Lady Bird peering out of window</figcaption></figure> <ul><li>Knocked down pillows.</li><li>Broke through her crate.</li><li>Tore into door molding.</li></ul> <p>She’s also scared of fireworks and thunderstorms, and she loves chasing small animals — very normal dog behaviors! Despite all this, she’s made progress. We’ve learned to control her environment by lifting blinds and removing temptations. To track her progress, we’ve diligently logged every time we left her home alone.</p> <h3>Lessons in Patience and Emotional Growth</h3> <p>Lady Bird has taught us patience and, in many ways, helped prepare us for the arrival of our newborn. She has a soft, gentle energy and would never hurt anyone. I’d like to think she has emotional intelligence — when a guest is sharing something difficult, she’ll lean her body against them as if to offer comfort.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/437/1*uLJca0p2HFCFTEILDH9VWQ.png"/><figcaption>Lady Bird Emotional Support</figcaption></figure> <h3>Lady Bird and Kids</h3> <p>Lady Bird is wonderful with kids. They treat her like a petting zoo animal, and she patiently endures until she needs a break. When she’s had enough, she’ll retreat under the table or to one of her three beds around the house.</p> <h3>A Community Effort</h3> <p>Raising a dog like Lady Bird takes a village, and we’re incredibly grateful for our extended family. They’re always willing to dogsit, even for long intervals, and their own rescue Border Collie reflects their big hearts and passion for animals.</p> <h3>Why Adopt a Dog?</h3> <p>Adopting a dog is not for everyone, but if you want to make a difference, celebrate breakthroughs, and enjoy the loyalty of a constant companion, it’s worth it. Lady Bird has been our fitness accountability partner, a source of laughter with her quirks, a zero-waste helper by eating leftovers, and a snuggle buddy on the couch.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/678/1*gJk49m4AsjbR0jHPC3Qe5g.png"/></figure> <h3>Special Thanks</h3> <p>Special thanks to Austin Pets Alive (<a href="https://www.instagram.com/austinpetsalive/?hl=en">@AustinPetsAlive</a>) for their mission as a no-kill shelter. Organizations like theirs make stories like Lady Bird’s possible.</p> <p><img src="https://medium.com/_/stat?event=post.clientViewed&amp;referrerSource=full_rss&amp;postId=4fb1968ec530" width="1" height="1" alt=""/></p>]]></content><author><name></name></author><category term="separation-anxiety"/><category term="animals"/><category term="dogs"/><category term="dog-training"/><category term="medium"/></entry><entry><title type="html">Reflections on 2024: Struggles, Wins, and Lessons</title><link href="https://willieman.com/blog/2025/reflections-on-2024-struggles-wins-and-lessons/" rel="alternate" type="text/html" title="Reflections on 2024: Struggles, Wins, and Lessons"/><published>2025-01-01T03:02:33+00:00</published><updated>2025-01-01T03:02:33+00:00</updated><id>https://willieman.com/blog/2025/reflections-on-2024-struggles-wins-and-lessons</id><content type="html" xml:base="https://willieman.com/blog/2025/reflections-on-2024-struggles-wins-and-lessons/"><![CDATA[<p>Reflections on 2024: Struggles, Wins, and Lessons</p> <p>As the year ends, I find myself reflecting on the challenges, growth, and victories that have defined 2024. It’s been a year of learning to let go, leaning into gratitude, and striving to become a better version of myself.</p> <p>Struggles</p> <p>1. Finding Contentment<br/>Navigating contentment in a world of comparisons is tough. I’ve wrestled with envy—good envy can motivate growth, as explored in Never Enough, but it’s a fine line that often drifts into imposter syndrome. Envy for opportunities sometimes makes it hard to focus on helping others, but I’ve learned from my uncle’s wisdom: generosity has a way of returning, though you shouldn’t expect it. It reminds me of the Swordsmith Village Arc from Demon Slayer—a lesson in humility and perseverance.</p> <p>Pride has also been a struggle. I’ve felt entitled to more because of the effort I put in, but life doesn’t work on a transaction system. The real reward is in the journey, not the accolades.</p> <p>2. Information Overload<br/>I’ve read books on fatherhood, faith, and self-improvement, but sometimes it feels like I’m chasing an unattainable ideal. Social media doesn’t help—it’s a highlight reel that amplifies feelings of deficiency. I limit my time on these platforms to avoid falling into the trap of seeking validation. Honestly, I thank God I’m not an influencer; I’d be homeless.</p> <p>3. Financial Pressures<br/>Inflation has made financial management a constant stressor. I’ve stretched every dollar—shopping at Costco, prepping food, maintaining my car, and even attempting DIY home repairs. Tight finances have taught me to appreciate labor and resourcefulness, values I hope to instill in future generations. They remind me of stories my in-laws shared about the Great Depression: scraping by, learning new skills, and making the most of what you have.</p> <p>4. Preparing for Fatherhood<br/>Fatherhood has been a mix of anticipation and anxiety. This year, my wife and I immersed ourselves in newborn classes and online content, navigating an industry full of advice and products. Despite the resources, I often wonder about the simplicity of trusting your gut and leaning on intuition.</p> <p>5. Career Stagnation<br/>While I graduated from grad school this year, the job market has been disheartening. Without new opportunities, it’s hard to feel accomplished, but I remind myself that growth isn’t always linear.</p> <p>---</p> <p>Wins</p> <p>1. Embracing Hospitality<br/>One of my greatest joys this year has been sharing baked goods and hosting guests. Whether serving drinks, baking for my church network, or connecting with neighbors, hospitality opens people’s hearts.</p> <p>2. Becoming a Father<br/>The biggest win of 2024: becoming a father. Earlier this year, I read a book emphasizing that failing as a father means failing at life. That resonated deeply. It’s about legacy—what you leave behind in your children’s lives. A colleague once told me, “If you don’t spend time with your kids, don’t expect them to spend time with you.” This dual responsibility of loving and leading them humbles me.</p> <p>Watching my wife transform from a mother-to-be into an incredible mother has been awe-inspiring. Parenthood has taught us to advocate for ourselves and each other, a necessity in today’s complex healthcare system.</p> <p>---</p> <p>Lessons</p> <p>1. Attention Shapes Identity<br/>In my early 30s, I’m starting to recognize the importance of where I direct my attention. John Mark Comer wrote that our habits define who we are, and habits are formed by micro-decisions. The consequences compound as we age. This principle is evident in the elderly around us—some are patient saints, while others are bitter. The difference lies in the habits and attitudes they’ve cultivated over decades.</p> <p>2. The Power of Mattering<br/>Mattering—feeling needed and valued—is more important than ever. The loneliness epidemic is real, as highlighted by the U.S. Surgeon General’s study. Community breakdowns and isolation are growing issues, especially in car-centric cities like Houston, where intentionality is required to create meaningful connections.</p> <p>Hosting a small Bible study with my wife has been one way to counter loneliness and build community. It’s a small step, but it reinforces that we matter—not because of our accomplishments but because of our inherent dignity and ability to contribute.</p> <p>3. Legacy and Gratitude<br/>As the son of refugees, I carry a story of survival and resilience. My family’s journey reminds me of the privilege of being here and the responsibility to make it count. Yet, being Cambodian-American often feels invisible; most people can’t find Cambodia on a map. Despite this, I strive to believe in my worth and see others as people of value.</p> <p>---</p> <p>Closing Thoughts</p> <p>Our pastor often says, “Life moves at the speed of relationships.” This year has reinforced that truth. Relationships—whether through hospitality, community, or fatherhood—are the heart of a meaningful life.</p> <p>As I look to 2025, my focus is on nurturing these connections, finding contentment, and leaving a legacy of love, kindness, and purpose.</p> <p>Here’s to growth, gratitude, and grace.</p> <p><img src="https://medium.com/_/stat?event=post.clientViewed&amp;referrerSource=full_rss&amp;postId=c3d65c128ae1" width="1" height="1" alt=""/></p>]]></content><author><name></name></author><category term="external-posts"/><category term="medium"/></entry><entry><title type="html">The Global Refugee Crisis</title><link href="https://willieman.com/blog/2024/the-global-refugee-crisis/" rel="alternate" type="text/html" title="The Global Refugee Crisis"/><published>2024-09-09T05:51:05+00:00</published><updated>2024-09-09T05:51:05+00:00</updated><id>https://willieman.com/blog/2024/the-global-refugee-crisis</id><content type="html" xml:base="https://willieman.com/blog/2024/the-global-refugee-crisis/"><![CDATA[<h4>A Survey</h4> <figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*pInuRgegfgBMMsMc"/><figcaption>Photo by <a href="https://unsplash.com/@curranrob?utm_source=medium&amp;utm_medium=referral">Rob Curran</a> on <a href="https://unsplash.com?utm_source=medium&amp;utm_medium=referral">Unsplash</a></figcaption></figure> <p>The global refugee crisis, driven by factors such as economic instability, sociopolitical unrest, war, violence, and natural disasters, has led to unprecedented levels of forced displacement. Many host countries, often low-income nations according to the World Development Indicators (WDI), face immense challenges as they strive to accommodate refugees.</p> <p>As The World Bank notes, <em>“Host communities need support, too. The forcibly displaced often live in poor areas in developing countries that are struggling to meet their own development goals. Accommodating the sudden arrival of masses of newcomers presents a challenge for host governments, putting further pressure on their ability to deliver basic services and infrastructure.”</em></p> <p>Understanding the complex dynamics of both displaced populations and the challenges faced by host nations is crucial for addressing this global crisis effectively.</p> <h3>Notebook</h3> <p>Source</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/536/1*W15eIwQP4YUPmxrwGeBUwA.png"/></figure> <p>Interestingly, when focusing on low-income countries or the ‘Europe &amp; Central Asia’ region, we observe a higher correlation between “GDP (current US$)” and the refugee population by country or territory of asylum. This aligns with the intuition that larger economies, as measured by GDP, have a greater capacity to host refugees. However, even among low-income countries, there is a strong correlation between GDP and refugee populations. This suggests that higher GDP, even within the lower income bracket, may play a role in hosting refugees. In the next iteration of this report, I plan to label-encode income classes and one-hot encode regions before applying Pearson correlation for deeper insights.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/875/1*Wd9WCiLUo5QqXMjwKhjVBA.png"/></figure> <p>To narrow the scope, I will focus on the top 10 countries where refugees seek asylum and the main countries from which they are fleeing. Based on the data, Jordan, Pakistan, and Lebanon rank as the top three countries hosting refugees from other nations.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fSeNFshwlRgK1OOGnm0_fg.png"/></figure> <p>Alternatively, I aimed to investigate the countries producing refugees to better understand the underlying factors, such as economic hardship or political instability. The findings align with expectations based on international headlines, such as Syria’s ongoing civil war and the political upheaval in Afghanistan following its government transition.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*53c6uKIyXgMEU-H48CPfqg.png"/></figure> <h3>Visualizations:</h3> <h4>Time series for Indicators by Income Group</h4> <p>Encoded by year as a position, value of indicator as position, and Income Group by Color.</p> <p>I wanted to explore Refugee and Asylum trends using Income Group defined by the WDI and as expected the low income countries produce the most refugees.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/885/1*PRNqV6Bh-L9VdVvw_Qfm9A.png"/></figure> <figure><img alt="" src="https://cdn-images-1.medium.com/max/882/1*KTUtTix2rovfO3-UvqgxMg.png"/></figure> <p>In the chart, refugee population increases among all income groups, but ‘Low Income’ countries bear the brunt</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*L5iJ4PUcAtE3V6LpEM8JGA.gif"/><figcaption>Interaction</figcaption></figure> <h4>Time Series Boxplot for Refugee asylum and origin (Top 10, respectively</h4> <figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1nY_T5xodI6WYaGUEJxDIg.png"/></figure> <p>The distributions of both “Refugee population by country or territory of asylum” and “Refugee population by country or territory of origin” exhibit a strong right skew with notable outliers, indicating that they are not normally distributed. Over time, the spread of “Refugee population by country or territory of asylum” tends to decrease, while the “Refugee population by country or territory of origin” shows an increasing trend. Although we considered using distribution plots and histograms, they were ultimately not chosen due to the temporal dimension of the data.</p> <h4>Refugee activity in top 10 countries of asylum and origin</h4> <p>Interestingly, Germany have been accepting a lot of refugees this past decade. Interactivity is enabled to hover points by year.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eNJZIVq85bjXcxnvY5zrEQ.gif"/></figure> <h4>Economy analysis of countries with refugee activity</h4> <p>As expected countries where refugees originate have a negative GDP and therefore, economy is shrinking. These countries also have GDP less than the average country GDP of that year.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/935/1*9JtqXD0lY5NjN6FNmTI0qQ.png"/><figcaption>Static</figcaption></figure> <figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AxelbUNLcxoT4I2wj_b57g.gif"/><figcaption>Interactive Visualization</figcaption></figure> <h3>Summary/Conclusion/Takeaways</h3> <ol><li><strong>Economic Disparity Among Host Countries</strong>: Apart from Germany, the remaining nine host countries are predominantly low-income nations with GDPs significantly below the average.</li><li><strong>Increasing Extremes in Refugee Population</strong>: From 2010 to 2021, the refugee population has been increasingly skewed, with more extreme variations each year.</li><li><strong>Economic Strain on Low-Income Countries</strong>: Low-income countries are shouldering the majority of the refugee burden, which poses a substantial risk to their own economic stability.</li></ol> <p><strong>Recommendation</strong>: There is a critical need for investment from higher-income countries into lower-income nations to support their capacity to host refugees and mitigate the economic strain.</p> <h4>Bonus Visualization:</h4> <p>An interactive map built using Vega and Altair where the parameters are the year and interested Indicator reported by the World Bank.</p> <figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DLGSZosPfcRpUprqRujBWg.gif"/></figure> <h3>References/Resources/Acknowledgements</h3> <ol><li>Samples<br/>- <a href="https://datascientyst.com/flatten-multiindex-in-pandas/">https://datascientyst.com/flatten-multiindex-in-pandas/</a><br/>- <a href="https://www.statology.org/pandas-pivot-table-to-dataframe/">https://www.statology.org/pandas-pivot-table-to-dataframe/</a><br/>- <a href="https://jakevdp.github.io/PythonDataScienceHandbook/01.07-timing-and-profiling.html">https://jakevdp.github.io/PythonDataScienceHandbook/01.07-timing-and-profiling.html</a></li><li>Documentations<br/>- <a href="https://github.com/altair-viz/altair/issues/2044">https://github.com/altair-viz/altair/issues/2044</a><br/>- <a href="https://www.sfu.ca/~mjbrydon/tutorials/BAinPy/08_correlation.html">https://www.sfu.ca/~mjbrydon/tutorials/BAinPy/08_correlation.html</a><br/>- <a href="https://stackoverflow.com/questions/56942670/matplotlib-seaborn-first-and-last-row-cut-in-half-of-heatmap-plot">https://stackoverflow.com/questions/56942670/matplotlib-seaborn-first-and-last-row-cut-in-half-of-heatmap-plot</a><br/>- <a href="https://github.com/altair-viz/altair/issues/2044">https://github.com/altair-viz/altair/issues/2044</a><br/>- <a href="https://thispointer.com/count-number-of-zeros-in-pandas-dataframe-column/">https://thispointer.com/count-number-of-zeros-in-pandas-dataframe-column/</a><br/>- <a href="https://altair-viz.github.io/gallery/multiline_tooltip.html">https://altair-viz.github.io/gallery/multiline_tooltip.html</a><br/>- <a href="https://altair-viz.github.io/altair-tutorial/notebooks/06-Selections.html">https://altair-viz.github.io/altair-tutorial/notebooks/06-Selections.html</a><br/>- <a href="https://stackoverflow.com/questions/14190045/how-do-i-convert-datetime-timedelta-to-minutes-hours-in-python">https://stackoverflow.com/questions/14190045/how-do-i-convert-datetime-timedelta-to-minutes-hours-in-python</a></li><li>Articles<br/>- <a href="https://www.worldbank.org/en/topic/forced-displacement">https://www.worldbank.org/en/topic/forced-displacement</a><br/>- <a href="https://www.unhcr.org/refugee-statistics/">https://www.unhcr.org/refugee-statistics/</a><br/>- <a href="https://data.worldbank.org/indicator?tab=featured">https://data.worldbank.org/indicator?tab=featured</a></li></ol> <p>4. Special thanks to <a href="https://www.gimp.org/">GIMP</a> and <a href="https://obsproject.com/">OBS</a> for enabling the creation of web assets for this publication. Both tools offer powerful video capture and photo editing capabilities, and they are free and open-source!</p> <p><img src="https://medium.com/_/stat?event=post.clientViewed&amp;referrerSource=full_rss&amp;postId=9ca91bc01943" width="1" height="1" alt=""/></p>]]></content><author><name></name></author><category term="python"/><category term="data-visualization"/><category term="data-analysis"/><category term="altair"/><category term="pandas"/><category term="medium"/></entry></feed>