<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dostonbek</title>
    <description>The latest articles on DEV Community by Dostonbek (@dostonbek_ur).</description>
    <link>https://dev.to/dostonbek_ur</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4052619%2F4ff01420-649b-4aff-837a-066e152d3e80.jpg</url>
      <title>DEV Community: Dostonbek</title>
      <link>https://dev.to/dostonbek_ur</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dostonbek_ur"/>
    <language>en</language>
    <item>
      <title>My Recommendation System Never Actually Measured Whether Its Recommendations Were Good - So I Checked</title>
      <dc:creator>Dostonbek</dc:creator>
      <pubDate>Mon, 17 Aug 2026 13:13:36 +0000</pubDate>
      <link>https://dev.to/dostonbek_ur/my-recommendation-system-never-actually-measured-whether-its-recommendations-were-good-so-i-4maa</link>
      <guid>https://dev.to/dostonbek_ur/my-recommendation-system-never-actually-measured-whether-its-recommendations-were-good-so-i-4maa</guid>
      <description>&lt;p&gt;An earlier project of mine built a content-based movie recommender: vectorize each movie's genre and plot summary, rank by cosine similarity, return the top 5 closest matches. It worked in the sense that it ran and printed five plausible-looking titles. What it never did was measure whether those five titles were actually &lt;em&gt;good&lt;/em&gt; recommendations, by any definition I just eyeballed the output for two example movies and moved on. Going back to it, I built an actual evaluation, and along the way found a modeling choice that quietly made results worse, not better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on the data:&lt;/strong&gt; the original notebook used a Kaggle-hosted dataset I couldn't re-access while rebuilding this. I used the well-known public TMDB 5000 Movies dataset instead same shape (title, genres, plot overview), different specific source. The method and findings below are new work built on that substitute dataset, not a re-run of the original numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;4,800 movies with genre tags and plot overviews. Each movie's genres and overview text get combined into one "tags" string, vectorized, and compared by cosine similarity against every other movie. Ask for recommendations on a title, get back the 5 most similar by that measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The evaluation I should have had the first time&lt;br&gt;
**&lt;br&gt;
"Similar" isn't inherently meaningful without checking it against something. I used **genre overlap&lt;/strong&gt; as a concrete, checkable proxy for recommendation quality: for a sample of 30 movies, pull each one's top-5 recommendations, and measure what fraction of the query movie's genres show up in each recommended movie's genres.&lt;/p&gt;

&lt;p&gt;Then I compared that against two baselines, since a number on its own doesn't tell you much:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Random baseline&lt;/strong&gt;: 5 randomly chosen movies instead of the model's picks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Popularity baseline&lt;/strong&gt;: always recommend the 5 most popular movies in the dataset, regardless of the query.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Mean genre overlap (top-5)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Random baseline&lt;/td&gt;
&lt;td&gt;30.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Popularity baseline&lt;/td&gt;
&lt;td&gt;26.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Content-based (genre + overview)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;76.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The content-based approach clearly beats both baselines by a wide margin — this is the actual evidence the original project never produced, and it's a real, positive result: the recommender is doing meaningfully more than chance or "just recommend whatever's popular."&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;The part that surprised me&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
I also compared two design choices I hadn't questioned in the original version:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does including genre in the text tags actually help, or would the plot overview alone do just as well?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tags used&lt;/th&gt;
&lt;th&gt;Mean genre overlap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Overview text only&lt;/td&gt;
&lt;td&gt;42.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Genre + overview&lt;/td&gt;
&lt;td&gt;76.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Including genre explicitly nearly doubles the genre-overlap score which sounds almost circular (of course genre-matching improves when you feed in genre), but it's still worth confirming rather than assuming, since it means the plot text alone is carrying real but limited signal on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does switching from plain word counts (CountVectorizer) to TF-IDF usually treated as the "better" default in NLP actually improve things?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vectorizer (genre + overview)&lt;/th&gt;
&lt;th&gt;Mean genre overlap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CountVectorizer&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;76.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TF-IDF&lt;/td&gt;
&lt;td&gt;49.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TF-IDF did noticeably &lt;em&gt;worse&lt;/em&gt; here. My read: TF-IDF's whole mechanism is downweighting terms that appear frequently across the corpus, but genre words like "Action" or "Drama" are exactly the short, high-value, repeated terms this system needs to weight heavily to make genre-based matches, and TF-IDF suppresses them relative to rarer overview vocabulary. The "fancier" method actively worked against the thing I was trying to optimize for. I wouldn't have caught this without measuring both instead of assuming TF-IDF was the safer default.&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;Where this still falls short&lt;br&gt;
*&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Genre overlap is a proxy, not ground truth.&lt;/strong&gt; Two movies can share zero listed genres and still be a great recommendation (tone, theme, era), or share every genre and be a poor match. This measures one specific, checkable thing not "good taste."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30 sampled query movies&lt;/strong&gt; is a reasonable spot-check, not exhaustive; a full evaluation across all 4,800 movies would give a more stable estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No user feedback loop&lt;/strong&gt; this is a purely content-based, offline evaluation. Real recommendation quality ultimately needs actual user response data, which this dataset doesn't have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;*&lt;em&gt;Why this mattered to actually check&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
It's easy to ship a recommender that "looks right" on a couple of manually-inspected examples and call it done, I did exactly that the first time. The gap between "looks right on 2 examples" and "measurably beats a random baseline across 30" is the difference between a demo and something you could actually trust a design decision on, and the TF-IDF result specifically shows why skipping that check can leave a worse default in place without anyone noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Full evaluation code: &lt;a href="https://github.com/DostonUr/Movie_Recommendation_Projec" rel="noopener noreferrer"&gt;github.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7olts3puusy7x2w4aoqx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7olts3puusy7x2w4aoqx.jpg" alt=" " width="799" height="436"&gt;&lt;/a&gt;]&lt;/p&gt;

&lt;p&gt;If you've evaluated a content-based recommender differently, or think genre overlap is the wrong proxy here, I'd like to hear your take — reply here or find me on &lt;a href="https://www.linkedin.com/in/doston-urinov/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>nlp</category>
      <category>recommendersystems</category>
      <category>python</category>
    </item>
    <item>
      <title>Revisiting My Capstone Project: The Metric I Trusted Was Hiding a Model That Barely Worked</title>
      <dc:creator>Dostonbek</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:33:41 +0000</pubDate>
      <link>https://dev.to/dostonbek_ur/revisiting-my-capstone-project-the-metric-i-trusted-was-hiding-a-model-that-barely-worked-4e9k</link>
      <guid>https://dev.to/dostonbek_ur/revisiting-my-capstone-project-the-metric-i-trusted-was-hiding-a-model-that-barely-worked-4e9k</guid>
      <description>&lt;p&gt;My Master's capstone was a churn prediction model for retail banking customers 10,000 records, the goal being to flag customers likely to leave before they actually do. At the time I reported it as a success: 86.5% accuracy from a Random Forest, best of the three models I tried. Going back to it recently, I realized that number was quietly hiding a model that was catching less than half the customers it was actually supposed to catch. This is what I found re-examining it, and what changed once I fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The original&amp;nbsp;setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The dataset: 10,000 bank customers, with features like credit score, geography, age, account balance, number of products, and whether they're an active member, and a binary target did they leave (Exited) or not.&lt;br&gt;
My original capstone code (still in the repo) trained three models Logistic Regression, Decision Tree, and Random Forest with standard scaling and label encoding, and compared them on accuracy and ROC AUC. Random Forest won on both: 86.5% accuracy, 0.847 AUC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I missed the first&amp;nbsp;time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The dataset is imbalanced about 80% of customers stayed, 20% left. I didn't account for that in the original analysis, and accuracy on an imbalanced dataset is a genuinely misleading number: a model that just predicted "stays" for everyone would already score close to 80%.&lt;/p&gt;

&lt;p&gt;Going back and breaking the original Random Forest's performance down by class tells the real story: it only caught 45.9% of customers who actually churned (recall), while correctly flagging most of the stayers. Precision on the customers it did flag as leaving was decent (78.6%) but that's cold comfort for a churn-prevention model whose entire point is catching people before they leave. Missing more than half of them defeats the purpose. The original Logistic Regression was far worse on this front: 14.3% recall it was essentially only ever predicting "stays."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixing it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I re-ran the same three models with class_weight='balanced', which reweights the loss function so the minority class (churners) isn't drowned out during training, and added XGBoost with scale_pos_weight set to the actual class ratio for the same reason.&lt;/p&gt;

&lt;p&gt;Model Accuracy Precision Recall F1 AUC Logistic Regression (original) 80.5% 58.6% 14.3% 0.229 0.771 Random Forest (original) 86.5% 78.6% 45.9% 0.580 0.847 Logistic Regression (balanced) 70.8% 38.3% 71.7% 0.500 0.774 Random Forest (balanced) 85.8% 77.3% 42.8% 0.551 0.848 XGBoost (default) 85.4% 70.6% 48.4% 0.574 0.838 XGBoost (weighted) 82.1% 55.2% 62.2% 0.585 0.831&lt;/p&gt;

&lt;p&gt;A few things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accuracy went down for almost every "improved" version. That's expected and, in this context, the right trade a model that's slightly less accurate overall but catches noticeably more actual churners is doing its actual job better, even though the headline number looks worse.&lt;/li&gt;
&lt;li&gt;Balanced Logistic Regression's recall jumped from 14.3% to 71.7%, a huge swing from essentially useless to genuinely useful at catching churners, at the real cost of many more false alarms (precision dropped to 38.3%).&lt;/li&gt;
&lt;li&gt;Weighted XGBoost gave the best overall balance (highest F1, 0.585), catching 62.2% of churners while keeping precision at a still-usable 55.2%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which of these is "best" isn't a purely statistical question it depends on the actual cost of a false alarm (an unnecessary retention offer to someone who wasn't leaving) versus a missed churner (losing a customer entirely). I don't have real cost figures for this bank, so I'm not going to invent a false-precision "optimal" answer but the tradeoff itself, and having several real options along that curve, is the actually useful output of this analysis, more useful than my original single accuracy number ever was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually drives churn in this&amp;nbsp;data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Feature importance from the balanced Random Forest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Age (25.3%) by far the strongest single predictor.&lt;/li&gt;
&lt;li&gt;Balance (14.3%), Estimated Salary (14.0%), Number of Products (13.6%), and Credit Score (13.4%) form a fairly even second tier.&lt;/li&gt;
&lt;li&gt;Tenure (7.9%), Geography (3.9%), Active Member status (3.7%), Gender (2.0%), and having a credit card (1.9%) mattered comparatively little.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Age dominating this strongly, with tenure barely registering, wasn't what I expected going in I'd assumed how long someone had been a customer would matter more than it did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where this still falls&amp;nbsp;short&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I didn't have real business cost figures (cost of a retention incentive vs. value of a retained customer), so the "which model to deploy" decision here is illustrative, not a final recommendation.&lt;/li&gt;
&lt;li&gt;The dataset is a single static snapshot rather than a time series, so this predicts churn risk at one point rather than tracking how risk changes over a customer's lifetime.&lt;/li&gt;
&lt;li&gt;I used simple class-weighting rather than more involved resampling techniques (like SMOTE) worth comparing directly in a future pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why I'm publishing the "I got it wrong the first time"&amp;nbsp;version&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It would have been easier to just write up the original 86.5%-accuracy result and stop there. I think the more honest and more useful piece is this one going back, checking the number I trusted at the time, and showing exactly where it broke down and what fixing it actually looked like. That's closer to how real model evaluation works than any single clean result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Original capstone notebook and the updated comparison: github.com/DostonUr/bank_churn&lt;/p&gt;

&lt;p&gt;If you've handled class imbalance differently, or have a view on how the precision/recall trade-off should be set for a churn use case like this, I'd like to hear it reply here or find me on &lt;a href="https://www.linkedin.com/in/doston-urinov/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9p5bw54g79v6z1ufzti0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9p5bw54g79v6z1ufzti0.png" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I Kept Doing the Same Sanity Check on Every Small Dataset, So I Turned It Into a Package</title>
      <dc:creator>Dostonbek</dc:creator>
      <pubDate>Sun, 02 Aug 2026 13:42:18 +0000</pubDate>
      <link>https://dev.to/dostonbek_ur/i-kept-doing-the-same-sanity-check-on-every-small-dataset-so-i-turned-it-into-a-package-io1</link>
      <guid>https://dev.to/dostonbek_ur/i-kept-doing-the-same-sanity-check-on-every-small-dataset-so-i-turned-it-into-a-package-io1</guid>
      <description>&lt;p&gt;After writing up two small projects - one predicting data science salaries, one checking whether Punxsutawney Phil's shadow call means anything - I noticed I'd done the same manual step both times: train a couple of models, check the test-set R², then immediately re-run everything with cross-validation before trusting that number. On a dataset with a few hundred rows, a single train/test split can make a weaker model look like the winner purely by chance, and I didn't want to keep catching that by hand.&lt;/p&gt;

&lt;p&gt;So I turned the check into a small package: (&lt;a href="https://github.com/DostonUr/modelcheck" rel="noopener noreferrer"&gt;https://github.com/DostonUr/modelcheck&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You give it your data and a dictionary of models, and it does the comparison I was doing by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.linear_model&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LinearRegression&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.ensemble&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RandomForestRegressor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;modelcheck&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;compare_regressors&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compare_regressors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Linear Regression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LinearRegression&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Random Forest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RandomForestRegressor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_estimators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;cv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reports the held-out test score for each model, the 5-fold cross-validation spread (mean, std, min, max) for each, and — the part I actually built this for - it automatically flags two specific situations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A model's cross-validation score bounces around a lot fold to fold, meaning the single test-set number shouldn't be trusted as precise.&lt;/li&gt;
&lt;li&gt;Two models' CV score ranges overlap heavily, meaning whichever one scored higher on the held-out split isn't necessarily the real winner.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Proving it actually works, not just on a toy example&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I re-ran both of my earlier projects through it instead of hand-writing the checks again.&lt;/p&gt;

&lt;p&gt;On the salary data, it reproduced the exact numbers from the original article - Linear Regression R² 0.273 vs. Random Forest R² 0.24 on the test split - and it correctly generated the overlap warning without me writing that logic manually this time:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Linear Regression" and "Random Forest" have heavily overlapping CV score ranges. The higher mean R2 alone is not strong evidence that "Linear Regression" is genuinely better on this data.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I tried something I hadn't done in the original groundhog article: instead of a hand-computed t-test, I set up a regression comparing a model that uses Phil's shadow call against a &lt;code&gt;DummyRegressor&lt;/code&gt; that just always predicts the historical average, and ran both through &lt;code&gt;modelcheck&lt;/code&gt;. If the shadow call carried real information, the model using it should have clearly beaten the dummy. Instead, the dummy baseline scored &lt;em&gt;better&lt;/em&gt; (R² of ‑0.046 vs. ‑0.061), both were negative, and the tool flagged the same overlap warning again. That's the same "no real signal" conclusion from the original article, reached through a completely different comparison method — which is a more convincing confirmation than either check on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I'm putting this out instead of keeping it as a personal script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A one-off script only proves I can write code. A package other people can install and actually use is a different kind of evidence of the work — it has to have real tests, has to handle inputs I didn't originally write it for, and has to be documented well enough that a stranger can pick it up. It's a small tool, but it's built to be used, not just read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;git+https://github.com/DostonUr/modelcheck.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code, tests, and both worked examples: &lt;a href="https://github.com/DostonUr/modelcheck" rel="noopener noreferrer"&gt;github.com/DostonUr/modelcheck&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've hit the same problem - a small dataset making one model look better than it really is - I'd like to hear how you've handled it, or what this package is missing. Reply here or find me on &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb8jo4jbli463o9c29lzp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb8jo4jbli463o9c29lzp.png" alt=" " width="799" height="436"&gt;&lt;/a&gt;(&lt;a href="https://www.linkedin.com/in/doston-urinov/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/doston-urinov/&lt;/a&gt;).&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>machinelearning</category>
      <category>ai</category>
    </item>
    <item>
      <title>Is Punxsutawney Phil Actually Any Good at Predicting Spring? I Checked 115 Years of Data</title>
      <dc:creator>Dostonbek</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:07:28 +0000</pubDate>
      <link>https://dev.to/dostonbek_ur/is-punxsutawney-phil-actually-any-good-at-predicting-spring-i-checked-115-years-of-data-13np</link>
      <guid>https://dev.to/dostonbek_ur/is-punxsutawney-phil-actually-any-good-at-predicting-spring-i-checked-115-years-of-data-13np</guid>
      <description>&lt;p&gt;Every February 2nd, a groundhog in Pennsylvania either sees his shadow or doesn’t, and half the US media treats it as a real forecast. I wanted to know if there’s anything to it, so I pulled the actual prediction record going back to the 1880s and checked it against real March temperatures. Short answer: no, and the “no” is more interesting than I expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The dataset tracks Punxsutawney Phil’s yearly prediction (full shadow = “six more weeks of winter,” no shadow = “early spring”) alongside recorded February and March average temperatures for Pennsylvania, the Northeast, and the Midwest, going back to 1886.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After cleaning:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Dropped a summary row at the bottom of the file that wasn’t an actual year (labeled “1901–2000,” clearly an aggregate row someone left in).&lt;br&gt;
Of 131 real yearly rows, 118 had an actual Full Shadow / No Shadow call - the rest were marked “No Record” (12 years) or, in exactly one case, “Partial Shadow.” I excluded those 13, since there’s no clean way to score a non-prediction.&lt;/p&gt;

&lt;p&gt;115 of the remaining 118 had usable March temperature data to check the prediction against.&lt;/p&gt;

&lt;p&gt;The class split is heavily lopsided: 100 Full Shadow predictions vs. only 15 No Shadow predictions - Phil calls for six more weeks of winter about 86% of the time, regardless of what actually happens. That imbalance turns out to matter a lot for how to read the accuracy number.&lt;br&gt;
How I scored “correct”&lt;/p&gt;

&lt;p&gt;Phil’s prediction is really a directional claim: Full Shadow implies a colder-than-normal March, No Shadow implies a warmer one. So for each year, I compared his call against whether that year’s actual Pennsylvania March average temperature came in above or below the long-run mean across the whole dataset (36.0°F).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Accuracy: 52.2% (60 correct out of 115 years).&lt;/p&gt;

&lt;p&gt;That sounds barely-better-than-a-coin-flip, and it is, but the more telling comparison isn’t 50%, it’s against a naive strategy that ignores Phil entirely and just always guesses “colder than average” every single year (which would nearly match his own bias toward predicting Full Shadow). That naive always-guess-colder strategy scores 49.6% statistically indistinguishable from Phil’s actual 52.2%.&lt;/p&gt;

&lt;p&gt;I also ran a direct comparison of average March temperature in Full Shadow years vs. No Shadow years:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full Shadow years: 35.95°F average&lt;/li&gt;
&lt;li&gt;No Shadow years: 36.32°F average&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s in the “correct” direction (Full Shadow years were very slightly colder), but the gap is tiny and a Welch’s t-test puts the p-value at 0.72 nowhere close to statistically significant. There’s no real signal here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checking it wasn’t a fluke of one region or one era&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before concluding “no skill,” I checked whether the result held up under different slices, since a null result on one narrow cut can hide something real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Northeast temperatures: same story, 52.2% accuracy, p=0.50.&lt;/li&gt;
&lt;li&gt;Midwest temperatures: 51.3% accuracy, p=0.73.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pre-1960 vs. 1960-onward: 51.7% vs. 50.9%, no meaningful shift even across a period that includes real, measurable climate warming.&lt;/p&gt;

&lt;p&gt;Consistent near-50% across three regions and two eras is a stronger “no effect” than a single test would have shown, and it’s the main reason I trust the null result here rather than writing it off as noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I think this happens&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The lopsided class split explains most of it: because Phil predicts Full Shadow around 86% of the time regardless of the actual weather, his “track record” mostly just reflects how often a below-average March happens by chance which, across 115 years, is close to half the time anyway. There’s no evidence the tradition is tracking anything about the actual weather; it’s closer to a fixed, mostly-one-sided coin that happens to land near a 50–50 real-world split.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where this could be wrong&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Small sample for the “No Shadow” class (only 15 cases) means that side of the comparison has wide uncertainty a handful of different outcomes in those 15 years could shift the picture more than the same shift would in the Full Shadow group.&lt;/p&gt;

&lt;p&gt;I used the Pennsylvania column as primary since that’s where Phil actually is, but “correct” is inherently a simplification real early-spring vs. late-winter isn’t fully captured by one month’s average temperature relative to a century-long mean.&lt;/p&gt;

&lt;p&gt;I excluded the “No Record” and “Partial Shadow” years rather than trying to impute a guess for them, which is the more honest choice but does shrink the sample further.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code and data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Full cleaning and analysis: github.com/DostonUr/GrounHog-prediction-Project-DS&lt;/p&gt;

&lt;p&gt;If you’ve seen a different cut of this data or think the scoring method should be set up differently, I’d like to hear it reply here or find me on LinkedIn.&lt;/p&gt;

</description>
      <category>analysis</category>
      <category>analytics</category>
      <category>data</category>
      <category>datascience</category>
    </item>
    <item>
      <title>What I Learned Predicting Data Scientist Salaries From a Messy Public Dataset</title>
      <dc:creator>Dostonbek</dc:creator>
      <pubDate>Wed, 29 Jul 2026 09:16:26 +0000</pubDate>
      <link>https://dev.to/dostonbek_ur/what-i-learned-predicting-data-scientist-salaries-from-a-messy-public-dataset-18p0</link>
      <guid>https://dev.to/dostonbek_ur/what-i-learned-predicting-data-scientist-salaries-from-a-messy-public-dataset-18p0</guid>
      <description>&lt;p&gt;I've been asked some version of "how much should I expect to get paid as a data scientist" enough times that I decided to stop guessing and actually build something to answer it, using real data instead of gut feeling or whatever number shows up first on Google. This is a walkthrough of that project - what worked, what didn't, and the mistakes that taught me more than the parts that went smoothly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used a public salary dataset covering data science and analytics roles, self-reported between 2019 and 2021 - 258 rows in total, mostly from the US, with fields for job title, years of tenure, location, education, industry, and salary.&lt;/p&gt;

&lt;p&gt;It was not clean, and cleaning it took longer than the modeling did:&lt;/p&gt;

&lt;p&gt;115 unique job titles for what were really about 8 actual role levels. "Senior Data Scientist," "Senior Data Scientist Lead," "Senior Predictive Analyst," and "Senor Data Analyst" (yes, a typo) were all technically different strings. I grouped them by hand into eight buckets - junior IC, analyst, mid-level data scientist, ML engineer, senior IC, manager/lead, leadership, and other - based on keyword matching against the raw title.&lt;br&gt;
Salaries in inconsistent formats - some as $190k, some as "93,000" with commas and quote marks, some as bare numbers. These needed normalizing before anything downstream would work.&lt;/p&gt;

&lt;p&gt;Missing data throughout - tenure was missing for 26 rows, education for 3, and 4 rows had no usable salary at all and had to be dropped, leaving 254 usable rows.&lt;/p&gt;

&lt;p&gt;A handful of real outliers, not typos. Three rows - all "Data Scientist" or "Senior Data Scientist" titles on the West Coast - reported salaries between $375,000 and $475,000. I checked these individually rather than deleting them; they're plausible for senior IC/staff-level roles at large tech companies, so I kept them in, which is itself a modeling decision worth being upfront about.&lt;/p&gt;

&lt;p&gt;After cleaning: median salary across the dataset was $114,500, with a fairly wide spread (25th percentile $85,000, 75th percentile $150,000).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Features going into the model: grouped title, years of tenure, US region, education level, and industry group.&lt;/p&gt;

&lt;p&gt;I tried two models:&lt;/p&gt;

&lt;p&gt;Linear Regression (baseline): R² of 0.27, mean absolute error of about $41,600.&lt;br&gt;
Random Forest Regressor (300 trees, max depth 6): R² of 0.24, MAE of about $43,300 on the same held-out test split. Cross-validation across 5 folds showed the score bouncing between 0.10 and 0.34 depending on the split - a direct consequence of having only 254 rows to learn from.&lt;/p&gt;

&lt;p&gt;The Random Forest didn't beat the simple linear baseline. I want to be honest about why, rather than quietly picking whichever number looked better: with a dataset this small, a more flexible model doesn't have enough data to find real non-linear patterns - it mostly just adds variance. The linear model's more constrained assumptions actually generalized slightly better here. That's a useful lesson on its own: more complex isn't automatically better, especially under 300 rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually mattered&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Looking at feature importance from the Random Forest (grouped by category, since one-hot encoding splits each into several columns):&lt;/p&gt;

&lt;p&gt;Job title / seniority group mattered most overall (about 30% of total importance) - unsurprising, but useful confirmation.&lt;/p&gt;

&lt;p&gt;Region was close behind (about 24%) - West Coast roles had a median salary of $145,000 versus $86,000 for Southeast roles and $86,000 for non-US roles in this sample, the widest gap in the whole dataset.&lt;/p&gt;

&lt;p&gt;Years of tenure alone (as a single numeric feature, not grouped) was actually the single most important individual feature - more predictive than any one title or region category.&lt;/p&gt;

&lt;p&gt;Education showed a real gap too - PhD holders had a median of $155,000, versus $110,000 for Master's and $95,500 for Bachelor's - though I'd treat this cautiously since PhD and leadership-title rows likely overlap.&lt;br&gt;
By seniority group, medians ranged from $185,000 (leadership) down to $49,000 (junior IC), with a genuinely odd result in between: the "ML engineer" group's median came out lower ($80,000) than mid-level data scientists ($110,000), which contradicts what I expected going in. With only 9 ML-engineer rows in the whole dataset, this is almost certainly a small-sample artifact rather than a real market signal, and I'm flagging it rather than smoothing it over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where this falls short&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;254 rows is small for 5 categorical features with many levels between them - several title/region/education combinations have only a handful of examples, so predictions for those specific combinations are shakier than the headline R² suggests.&lt;/p&gt;

&lt;p&gt;The data is from 2019–2021. Tech and data science compensation has moved a lot since then; treat this as a lesson in method, not as current market pricing.&lt;/p&gt;

&lt;p&gt;Self-reported data carries the usual bias risks - people who respond to salary surveys aren't a random sample of the whole field.&lt;br&gt;
An R² of ~0.27 means the model explains roughly a quarter of the variance in salary - useful for spotting directional patterns (seniority, region, education all matter, in that rough order), not precise enough to quote a confident number to a specific person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd do differently&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next time I'd spend less effort tuning the Random Forest and more effort either finding a larger dataset or engineering better features from what little text data exists (like extracting seniority signals more carefully from free-text titles instead of keyword-matching by hand). Given how much of the "title" signal was really just noisy text, a cleaner NLP-based title-normalization step would likely have helped more than switching models did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Full cleaning and modeling code: github.com/DostonUr/DataScience_Salary&lt;/p&gt;

&lt;p&gt;If you've worked with a similar dataset or see a flaw in how I handled the outliers or the title grouping, I'd genuinely like to hear about it - reply here or find me on LinkedIn.&lt;/p&gt;

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