<?xml version="1.0" encoding="UTF-8" standalone="no"?><rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" version="2.0">

<channel>
	<title>DataScience+</title>
	<atom:link href="https://datascienceplus.com/feed/" rel="self" type="application/rss+xml"/>
	<link>https://datascienceplus.com</link>
	<description>Learn R programming for data science</description>
	<lastBuildDate>Wed, 19 Aug 2026 15:45:14 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.4</generator>

<image>
	<url>https://datascienceplus.com/wp-content/uploads/2026/07/cropped-favicon-w-32x32.png</url>
	<title>DataScience+</title>
	<link>https://datascienceplus.com</link>
	<width>32</width>
	<height>32</height>
</image> 
	<xhtml:meta content="noindex" name="robots" xmlns:xhtml="http://www.w3.org/1999/xhtml"/><item>
		<title>In R, a missing value is a join key</title>
		<link>https://datascienceplus.com/in-r-a-missing-value-is-a-join-key/</link>
					<comments>https://datascienceplus.com/in-r-a-missing-value-is-a-join-key/#respond</comments>
		
		<dc:creator><![CDATA[Loess]]></dc:creator>
		<pubDate>Wed, 19 Aug 2026 15:45:14 +0000</pubDate>
				<category><![CDATA[Data Management]]></category>
		<category><![CDATA[Data Manipulation]]></category>
		<category><![CDATA[dplyr]]></category>
		<category><![CDATA[tidyverse]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32975</guid>

					<description><![CDATA[I joined two country tables on their ISO code, which is meant to be the safe key, and got back more rows than I…]]></description>
										<content:encoded><![CDATA[<p>I joined two country tables on their ISO code, which is meant to be the safe key, and got back more rows than I put in. That kind of surprise is at least loud. What stayed with me was the second version of the same mistake: the row count came back exactly right, nothing warned, and fifteen rows were quietly holding somebody else&#8217;s number.</p>
<p>Both come from one rule that I had never seen written down anywhere I was likely to read it. In R, a missing value is a join key. <code>NA</code> matches <code>NA</code>, so every row whose key is blank matches every other row whose key is blank. SQL does the opposite, since <code>NULL = NULL</code> is never true there, and I suspect that is where most people&#8217;s expectation comes from.</p>
<p>Below I take a real pair of tables, watch both failure modes happen, measure what they cost, and end with the guards that turn a join into something that fails loudly instead.</p>
<h2>Two tables and an obvious key</h2>
<p>I am using two indicators from <a href="https://ourworldindata.org/">Our World in Data</a>: <a href="https://ourworldindata.org/grapher/life-expectancy">life expectancy at birth</a> and <a href="https://ourworldindata.org/grapher/median-age">median age</a>. Both come down as plain CSV with no key or registration, and both are shaped the same way: one row per entity per year, with an <code>entity</code> name and a <code>code</code>.</p>
<pre><code class="language-r">library(readr)
library(dplyr)
library(ggplot2)

owid &lt;- function(slug) {
  read_csv(paste0(&quot;https://ourworldindata.org/grapher/&quot;, slug,
                  &quot;.csv?v=1&amp;csvType=full&amp;useColumnShortNames=true&quot;),
           show_col_types = FALSE, progress = FALSE)
}

life &lt;- owid(&quot;life-expectancy&quot;) |&gt;
  rename(life_exp = life_expectancy_0) |&gt;
  filter(year == 2023) |&gt;
  select(entity, code, life_exp)

age &lt;- owid(&quot;median-age&quot;) |&gt;
  rename(median_age = median_age__sex_all__age_all__variant_estimates) |&gt;
  filter(year == 2023, !is.na(median_age)) |&gt;
  select(entity, code, median_age)

c(life = nrow(life), age = nrow(age))
</code><em>## life  age 
##  261  253
</em></pre>
<p>The <code>code</code> column is ISO 3166 alpha-3 for countries, plus a handful of <code>OWID_</code> codes for things the standard does not cover. It is the column you are supposed to join on, precisely because country <em>names</em> are a mess across providers. Here is the part that matters:</p>
<pre><code class="language-r">life |&gt; filter(is.na(code)) |&gt; pull(entity) |&gt; head(6)
</code><em>## [1] &quot;Americas&quot;                               
## [2] &quot;High-and-upper-middle-income countries&quot; 
## [3] &quot;Land-locked Developing Countries (LLDC)&quot;
## [4] &quot;Latin America and the Caribbean&quot;        
## [5] &quot;Least developed countries&quot;              
## [6] &quot;Less developed regions&quot;
</em></pre>
<pre><code class="language-r">c(blank_in_life = sum(is.na(life$code)), blank_in_age = sum(is.na(age$code)))
</code><em>## blank_in_life  blank_in_age 
##            15             5
</em></pre>
<p>Neither table is broken. Both simply carry aggregate rows next to the country rows, and an aggregate like &quot;Least developed countries&quot; has no ISO code to put in the column, so the field is empty. Almost every statistical source I have pulled does this somewhere: a total row, a regional subtotal, a category that predates the code list.</p>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/converting-data-from-long-to-wide-and-from-wide-to-long-simplified-tidyverse-package/">Converting data from long to wide simplified: tidyverse package</a></li><li><a href="https://datascienceplus.com/data-manipulation-with-dplyr/">Data Manipulation with dplyr</a></li><li><a href="https://datascienceplus.com/proteomics-data-analysis-2-3-data-filtering-and-missing-value-imputation/">Proteomics Data Analysis (2/3): Data Filtering and Missing Value Imputation</a></li></ul><h2>The join grows</h2>
<pre><code class="language-r">say_warnings &lt;- function(expr) {
  withCallingHandlers(expr, warning = function(w) {
    cat(&quot;Warning:&quot;, conditionMessage(w), &quot;\n&quot;)
    invokeRestart(&quot;muffleWarning&quot;)
  })
}

joined &lt;- say_warnings(
  left_join(life, age, by = &quot;code&quot;, suffix = c(&quot;_life&quot;, &quot;_age&quot;))
)
</code><em>## Warning: Detected an unexpected many-to-many relationship between `x` and `y`.
## &#x2139; Row 6 of `x` matches multiple rows in `y`.
## &#x2139; Row 122 of `y` matches multiple rows in `x`.
## &#x2139; If a many-to-many relationship is expected, set `relationship =
##   &quot;many-to-many&quot;` to silence this warning.
</em></pre>
<pre><code class="language-r">c(rows_in = nrow(life), rows_out = nrow(joined))
</code><em>##  rows_in rows_out 
##      261      321
</em></pre>
<p>A left join is supposed to return one row per row of <code>life</code>. It returned 321 from 261. Since dplyr 1.1.0 there is at least a warning, and it is a good one, but it arrives phrased as a relationship problem rather than as a missing-value problem, so it is easy to read it as &quot;these two tables just overlap in a complicated way&quot; and move on.</p>
<p>The extra rows are not complicated at all:</p>
<pre><code class="language-r">joined |&gt;
  filter(is.na(code)) |&gt;
  select(entity_life, entity_age, life_exp, median_age) |&gt;
  head(5)
</code><em>## # A tibble: 5 × 4
##   entity_life entity_age                                     life_exp median_age
##   &lt;chr&gt;       &lt;chr&gt;                                             &lt;dbl&gt;      &lt;dbl&gt;
## 1 Americas    Least developed countries                          77.3       19.2
## 2 Americas    Less developed regions                             77.3       28.4
## 3 Americas    Less developed regions, excluding China            77.3       25.6
## 4 Americas    Less developed regions, excluding least devel…     77.3       30.6
## 5 Americas    More developed regions                             77.3       41.6
</em></pre>
<p>15 blank-code rows on the left met 5 blank-code rows on the right and produced every combination, 75 rows in which the life expectancy of one aggregate sits beside the median age of a different one. &quot;Americas&quot; paired with &quot;Least developed countries&quot; is not a data quality issue in either source. It is a row that R invented during the join.</p>
<h2>Why NA matches NA</h2>
<p>The behavior is a documented default rather than an accident. Every dplyr join takes an <code>na_matches</code> argument, and it is set to <code>&quot;na&quot;</code>:</p>
<pre><code class="language-r">x &lt;- tibble(code = c(&quot;AFG&quot;, NA, NA), v = 1:3)
y &lt;- tibble(code = c(&quot;AFG&quot;, NA),     w = c(10, 20))

left_join(x, y, by = &quot;code&quot;)                        # default: na_matches = &quot;na&quot;
</code><em>## # A tibble: 3 × 3
##   code      v     w
##   &lt;chr&gt; &lt;int&gt; &lt;dbl&gt;
## 1 AFG       1    10
## 2 &lt;NA&gt;      2    20
## 3 &lt;NA&gt;      3    20
</em></pre>
<pre><code class="language-r">left_join(x, y, by = &quot;code&quot;, na_matches = &quot;never&quot;)
</code><em>## # A tibble: 3 × 3
##   code      v     w
##   &lt;chr&gt; &lt;int&gt; &lt;dbl&gt;
## 1 AFG       1    10
## 2 &lt;NA&gt;      2    NA
## 3 &lt;NA&gt;      3    NA
</em></pre>
<p>This is not a tidyverse quirk either. Base R agrees, and has its own spelling of the fix:</p>
<pre><code class="language-r">merge(x, y, by = &quot;code&quot;, all.x = TRUE)
</code><em>##   code v  w
## 1  AFG 1 10
## 2 &lt;NA&gt; 2 20
## 3 &lt;NA&gt; 3 20
</em></pre>
<pre><code class="language-r">merge(x, y, by = &quot;code&quot;, all.x = TRUE, incomparables = NA)
</code><em>##   code v  w
## 1  AFG 1 10
## 2 &lt;NA&gt; 2 NA
## 3 &lt;NA&gt; 3 NA
</em></pre>
<p>Which default is <em>right</em> depends on what the <code>NA</code> means to you. If it is a real category, &quot;the unclassified group&quot;, then matching it to itself is sensible. If it means &quot;we do not know what this is&quot;, then two unknowns are not evidence of a match, and R&#8217;s default is doing something you almost never want. In my experience with data pulled from public sources, blank keys are the second kind essentially every time.</p>
<h2>The version that leaves no trace</h2>
<p>The warning above only fires because both sides had several blank-code rows. Watch what happens when the lookup table has exactly one, which is what you get from any source carrying a single &quot;World&quot; or &quot;Total&quot; row.</p>
<pre><code class="language-r">lookup &lt;- age |&gt; filter(!is.na(code) | entity == &quot;More developed regions&quot;)
sum(is.na(lookup$code))
</code><em>## [1] 1
</em></pre>
<pre><code class="language-r">quiet &lt;- say_warnings(
  left_join(life, lookup, by = &quot;code&quot;, suffix = c(&quot;_life&quot;, &quot;_age&quot;))
)

c(rows_in = nrow(life), rows_out = nrow(quiet))
</code><em>##  rows_in rows_out 
##      261      261
</em></pre>
<p>No warning. No change in row count. The join is now many-to-one, which is exactly the relationship a left join is meant to have, so there is nothing for dplyr to object to. And yet:</p>
<pre><code class="language-r">quiet |&gt;
  filter(is.na(code)) |&gt;
  select(entity_life, entity_age, median_age) |&gt;
  head(5)
</code><em>## # A tibble: 5 × 3
##   entity_life                             entity_age             median_age
##   &lt;chr&gt;                                   &lt;chr&gt;                       &lt;dbl&gt;
## 1 Americas                                More developed regions       41.6
## 2 High-and-upper-middle-income countries  More developed regions       41.6
## 3 Land-locked Developing Countries (LLDC) More developed regions       41.6
## 4 Latin America and the Caribbean         More developed regions       41.6
## 5 Least developed countries               More developed regions       41.6
</em></pre>
<p>Every one of the 15 blank-code rows on the left has been handed a median age of 41.6 years, the value belonging to &quot;More developed regions&quot;. &quot;Least developed countries&quot; now carries the median age of the developed world. Nothing in the object tells you this happened. The row count, which is the check most of us actually run, agrees with the input and always will.</p>
<p>Here is what those rows look like once they are in a plot, alongside the real matches:</p>
<pre class="has-plot"><code class="language-r">dsp_colors &lt;- c(&quot;#0066CC&quot;, &quot;#E8862D&quot;, &quot;#159A6C&quot;, &quot;#7D5BD6&quot;,
                &quot;#D64580&quot;, &quot;#2AA9B8&quot;, &quot;#C9A227&quot;)
dsp_theme &lt;- theme_minimal(base_size = 13) +
  theme(plot.background    = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
        panel.background   = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
        panel.grid.minor   = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.major.y = element_line(color = &quot;grey78&quot;),
        axis.ticks         = element_blank(),
        plot.title         = element_text(face = &quot;bold&quot;),
        strip.text         = element_text(face = &quot;bold&quot;))

quiet |&gt;
  filter(!is.na(median_age)) |&gt;
  mutate(kind = if_else(is.na(code), &quot;Given a value that is not theirs&quot;, &quot;Real matches&quot;)) |&gt;
  ggplot(aes(median_age, life_exp, color = kind)) +
  geom_point(size = 1.9, alpha = 0.85) +
  scale_color_manual(values = c(&quot;Real matches&quot; = dsp_colors[1],
                                &quot;Given a value that is not theirs&quot; = dsp_colors[2])) +
  labs(title = &quot;No warning, no extra rows, fifteen wrong values&quot;,
       subtitle = &quot;One blank-code row in the lookup table is enough&quot;,
       x = &quot;Median age (years)&quot;, y = &quot;Life expectancy at birth (years)&quot;,
       color = NULL) +
  dsp_theme + theme(legend.position = &quot;top&quot;)
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-missing-values-are-join-keys-fig-silent-1.png" alt="plot of chunk fig-silent" /></figure>
<p>The wrong rows land in the middle of the cloud, at a median age that is perfectly plausible for a rich country. Without the color there is nothing to see. This is the case I now actively check for, because the loud version at least stops you.</p>
<h2>What the extra rows cost</h2>
<p>Back to the first join, the one that grew. If I go on to ask how life expectancy tracks median age across the world in 2023, the invented rows come along.</p>
<pre><code class="language-r">is_country &lt;- function(code) !is.na(code) &amp; (nchar(code) == 3 | code == &quot;OWID_KOS&quot;)

as_returned &lt;- joined |&gt; filter(!is.na(median_age))
countries   &lt;- as_returned |&gt; filter(is_country(code))

fit_summary &lt;- function(d, label) {
  m &lt;- lm(life_exp ~ median_age, data = d)
  tibble(table = label,
         rows  = nrow(d),
         slope = round(coef(m)[[&quot;median_age&quot;]], 3),
         r     = round(cor(d$life_exp, d$median_age), 3),
         r2    = round(summary(m)$r.squared, 3))
}

bind_rows(fit_summary(as_returned, &quot;join as returned&quot;),
          fit_summary(countries,   &quot;countries only&quot;))
</code><em>## # A tibble: 2 × 5
##   table             rows slope     r    r2
##   &lt;chr&gt;            &lt;int&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt;
## 1 join as returned   317 0.504 0.727 0.528
## 2 countries only     237 0.592 0.826 0.682
</em></pre>
<p>80 of the 317 rows I would have modeled, 25 percent of the sample, are aggregates or invented pairs. They pull the correlation from 0.826 down to 0.727 and flatten the slope from 0.592 to 0.504 years of life expectancy per year of median age. Nothing about those numbers looks wrong on its own. A correlation of 0.727 is the sort of figure you would write into a paragraph without a second thought.</p>
<pre class="has-plot"><code class="language-r">as_returned |&gt;
  mutate(kind = if_else(is.na(code), &quot;Invented pairs (blank code)&quot;, &quot;Real matches&quot;)) |&gt;
  ggplot(aes(median_age, life_exp)) +
  geom_point(aes(color = kind), size = 1.9, alpha = 0.85) +
  geom_smooth(data = countries, method = &quot;lm&quot;, formula = y ~ x, se = FALSE,
              color = dsp_colors[1], linewidth = 0.9) +
  geom_smooth(method = &quot;lm&quot;, formula = y ~ x, se = FALSE,
              color = dsp_colors[2], linewidth = 0.9, linetype = &quot;22&quot;) +
  scale_color_manual(values = c(&quot;Real matches&quot; = dsp_colors[1],
                                &quot;Invented pairs (blank code)&quot; = dsp_colors[2])) +
  labs(title = &quot;Seventy-five rows that match nothing real&quot;,
       subtitle = &quot;Dashed line: the fit on the joined table exactly as it came back&quot;,
       x = &quot;Median age (years)&quot;, y = &quot;Life expectancy at birth (years)&quot;,
       color = NULL) +
  dsp_theme + theme(legend.position = &quot;top&quot;)
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-missing-values-are-join-keys-fig-cost-1.png" alt="plot of chunk fig-cost" /></figure>
<p>The orange points form vertical stripes, one per distinct blank-code value on the right-hand side, each stripe stacking every blank-code value from the left. That signature is worth memorizing. Whenever a scatter plot of joined data shows a few suspiciously straight vertical or horizontal lines of points, a key matched more rows than it should have.</p>
<h2>Writing the join as a contract</h2>
<p>The fix is not to remember any of this. It is to say out loud what the join is supposed to do, and let R refuse when it cannot. <code>na_matches</code> has been there all along, and dplyr 1.1.0 added the rest.</p>
<pre><code class="language-r">try_join &lt;- function(expr) {
  out &lt;- tryCatch(expr, error = function(e) conditionMessage(e))
  if (is.character(out)) cat(&quot;Error:&quot;, out, &quot;\n&quot;) else cat(&quot;Returned&quot;, nrow(out), &quot;rows\n&quot;)
}

# What I actually mean: each country on the left gets at most one match,
# blanks match nothing, and I want to hear about it if that is not true.
try_join(
  left_join(life, age, by = &quot;code&quot;,
            na_matches   = &quot;never&quot;,
            relationship = &quot;many-to-one&quot;)
)
</code><em>## Returned 261 rows
</em></pre>
<pre><code class="language-r"># The same join without the na_matches guard still cannot hold.
try_join(
  left_join(life, age, by = &quot;code&quot;, relationship = &quot;many-to-one&quot;)
)
</code><em>## Error: Each row in `x` must match at most 1 row in `y`.
## &#x2139; Row 6 of `x` matches multiple rows in `y`.
</em></pre>
<p>Three arguments I now write by default:</p>
<ul>
<li><code>na_matches = &quot;never&quot;</code> makes blank keys match nothing, which is the SQL behavior and, I would argue, the useful one for keys pulled from files.</li>
<li><code>relationship = </code> states the cardinality you believe in (<code>&quot;one-to-one&quot;</code>, <code>&quot;many-to-one&quot;</code>, <code>&quot;one-to-many&quot;</code>) and errors instead of silently multiplying rows. This is the argument that catches the quiet case above, because &quot;many-to-one&quot; is not violated by a bad NA match, but &quot;one-to-one&quot; is.</li>
<li><code>unmatched = &quot;error&quot;</code> turns unmatched keys into a failure rather than a column of <code>NA</code>. In a left join it checks <code>y</code>, since rows of <code>x</code> are kept by definition.</li>
</ul>
<p>The last guard is not an argument at all. Before trusting a lookup table, look at what will <em>not</em> match:</p>
<pre><code class="language-r">unmatched &lt;- anti_join(life, age, by = &quot;code&quot;, na_matches = &quot;never&quot;)
unmatched |&gt; select(entity, code) |&gt; print(n = Inf)
</code><em>## # A tibble: 19 × 2
##    entity                                                      code    
##    &lt;chr&gt;                                                       &lt;chr&gt;   
##  1 Africa                                                      OWID_AFR
##  2 Americas                                                    &lt;NA&gt;    
##  3 Asia                                                        OWID_ASI
##  4 Europe                                                      OWID_EUR
##  5 High-and-upper-middle-income countries                      &lt;NA&gt;    
##  6 Land-locked Developing Countries (LLDC)                     &lt;NA&gt;    
##  7 Latin America and the Caribbean                             &lt;NA&gt;    
##  8 Least developed countries                                   &lt;NA&gt;    
##  9 Less developed regions                                      &lt;NA&gt;    
## 10 Less developed regions, excluding China                     &lt;NA&gt;    
## 11 Less developed regions, excluding least developed countries &lt;NA&gt;    
## 12 Low-and-Lower-middle-income countries                       &lt;NA&gt;    
## 13 Low-and-middle-income countries                             &lt;NA&gt;    
## 14 Middle-income countries                                     &lt;NA&gt;    
## 15 More developed regions                                      &lt;NA&gt;    
## 16 No income group available                                   &lt;NA&gt;    
## 17 Northern America                                            &lt;NA&gt;    
## 18 Oceania                                                     OWID_OCE
## 19 Small Island Developing States (SIDS)                       &lt;NA&gt;
</em></pre>
<p>That list is the join&#8217;s own account of itself, and it is the step I would keep if I had to drop the other three. Every row on it is an aggregate, which is the answer I want: the blanks that caused all the trouble, plus four continents that one file codes and the other does not carry. No actual country is being dropped, and that is a claim I can check rather than assume.</p>
<pre><code class="language-r">unmatched |&gt; filter(is_country(code)) |&gt; nrow()
</code><em>## [1] 0
</em></pre>
<p>When that number is not zero, the useful question is never &quot;how many rows did I lose&quot; but &quot;what do the lost rows have in common&quot;. Dropped rows are hardly ever a random sample. They are the small territories, the renamed states, the entities one provider counts and the other does not.</p>
<p>With the guards in place the analysis is the boring one I meant to run in the first place:</p>
<pre><code class="language-r">panel &lt;- inner_join(
  life |&gt; filter(is_country(code)),
  age  |&gt; filter(is_country(code)),
  by = &quot;code&quot;, na_matches = &quot;never&quot;, relationship = &quot;one-to-one&quot;,
  suffix = c(&quot;&quot;, &quot;_age&quot;)
)

nrow(panel)
</code><em>## [1] 237
</em></pre>
<pre><code class="language-r">round(cor(panel$life_exp, panel$median_age), 3)
</code><em>## [1] 0.826
</em></pre>
<p>237 countries, correlation 0.826, and the join itself is now a statement that would have failed if either file had changed shape underneath me.</p>
<h2>What I take from this</h2>
<p>The row count is the check everyone runs, and it is the check that the dangerous version of this bug passes. A join that returns exactly as many rows as it started with can still have filled a column with values that belong to someone else, and it will do so without a warning, because many-to-one is a perfectly legitimate relationship for a left join to have.</p>
<p>So the habit I would rather build is the one where every join carries its assumptions in the call. <code>na_matches = &quot;never&quot;</code> unless a blank key genuinely names a category. <code>relationship = </code> on every join, because writing it forces you to decide what you believe before you find out. <code>unmatched = &quot;error&quot;</code> whenever the lookup is supposed to be complete. An <code>anti_join()</code> first, to read the list of rows that will not match. That is four extra lines that turn a silent wrong answer into a stack trace, which is the trade I will take every time.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/in-r-a-missing-value-is-a-join-key/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/in-r-a-missing-value-is-a-join-key/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>What your heteroscedasticity test is actually detecting</title>
		<link>https://datascienceplus.com/what-your-heteroscedasticity-test-is-actually-detecting/</link>
					<comments>https://datascienceplus.com/what-your-heteroscedasticity-test-is-actually-detecting/#respond</comments>
		
		<dc:creator><![CDATA[Loess]]></dc:creator>
		<pubDate>Mon, 17 Aug 2026 20:13:56 +0000</pubDate>
				<category><![CDATA[Regression Models]]></category>
		<category><![CDATA[Linear Regression]]></category>
		<category><![CDATA[Multiple Regression]]></category>
		<category><![CDATA[tidyverse]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32970</guid>

					<description><![CDATA[There is a routine that shows up almost everywhere linear regression is taught: fit the model, plot residuals against fitted values, run a Breusch-Pagan…]]></description>
										<content:encoded><![CDATA[<p>There is a routine that shows up almost everywhere linear regression is taught: fit the model, plot residuals against fitted values, run a Breusch-Pagan test, and if the p-value is small, transform the outcome and refit. I wanted to look at the two steps in the middle, because both of them hide something.</p>
<p>The first is the test itself. R gives you two implementations of the Breusch-Pagan test, they get used interchangeably, and only one of them is really a test for non-constant variance. The other also reacts to the <em>shape</em> of the error distribution: on data with heavy tails and perfectly constant variance, it rejects about half the time.</p>
<p>The second is what happens after. A significant test tells you the model has a problem somewhere, but it says nothing about which coefficient is damaged or by how much. On the data below, one coefficient&#8217;s standard error is off by 7% and another by 100%, from the same single p-value.</p>
<p>So this post is about closing that gap: what the test measures, what the fix actually changes, and which robust variance estimator to reach for. Everything runs on <a href="https://cran.r-project.org/package=lmtest">lmtest</a>, <a href="https://cran.r-project.org/package=sandwich">sandwich</a> and the tidyverse.</p>
<h2>A model with a very obvious problem</h2>
<p>I used the <a href="https://cran.r-project.org/package=modeldata">Ames housing data</a> that ships with the <code>modeldata</code> package: every residential sale in Ames, Iowa between 2006 and 2010, with the lot and building characteristics recorded for each one. I kept the ordinary arm&#8217;s-length sales and regressed the sale price on four measurements a buyer can see.</p>
<pre><code class="language-r">library(tidyverse)
library(modeldata)
library(lmtest)
library(sandwich)
library(broom)

data(ames)

homes &lt;- ames |&gt;
  filter(Sale_Condition == &quot;Normal&quot;) |&gt;
  transmute(price      = Sale_Price / 1000,   # thousands of dollars
            area       = Gr_Liv_Area,         # above-grade living area, sq ft
            year_built = Year_Built,
            lot        = Lot_Area / 1000,     # thousands of sq ft
            basement   = Total_Bsmt_SF)

fit &lt;- lm(price ~ area + year_built + lot + basement, data = homes)
nrow(homes)
</code><em>## [1] 2413
</em></pre>
<p>That leaves 2,413 sales. The residual plot is the textbook picture, so much so that it barely needs a test:</p>
<pre class="has-plot"><code class="language-r">dsp_colors &lt;- c(&quot;#0066CC&quot;, &quot;#E8862D&quot;, &quot;#159A6C&quot;, &quot;#7D5BD6&quot;,
                &quot;#D64580&quot;, &quot;#2AA9B8&quot;, &quot;#C9A227&quot;)
dsp_theme &lt;- theme_minimal(base_size = 13) +
  theme(plot.background    = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
        panel.background   = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
        panel.grid.minor   = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.major.y = element_line(color = &quot;grey78&quot;),
        axis.ticks         = element_blank(),
        plot.title         = element_text(face = &quot;bold&quot;),
        strip.text         = element_text(face = &quot;bold&quot;))

augment(fit) |&gt;
  ggplot(aes(.fitted, .resid)) +
  geom_hline(yintercept = 0, color = &quot;grey40&quot;) +
  geom_point(alpha = 0.25, size = 1.1, color = dsp_colors[1]) +
  labs(title = &quot;Residual spread grows with the predicted price&quot;,
       x = &quot;Fitted sale price (thousands of dollars)&quot;, y = &quot;Residual&quot;) +
  dsp_theme
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-heteroscedasticity-robust-standard-errors-resid-plot-1.png" alt="plot of chunk resid-plot" /></figure>
<p>(That theme snippet is reusable, so copy it once and drop it on every figure in a post.)</p>
<p>Cheap houses are predicted to within a few thousand dollars; expensive ones miss by a hundred thousand in either direction. The test agrees, emphatically:</p>
<pre><code class="language-r">bptest(fit)
</code><em>## 
## 	studentized Breusch-Pagan test
## 
## data:  fit
## BP = 440.22, df = 4, p-value &lt; 2.2e-16
</em></pre>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/fitting-polynomial-regression-r/">Fitting Polynomial Regression in R</a></li><li><a href="https://datascienceplus.com/multicollinearity-in-r/">Multicollinearity in R</a></li><li><a href="https://datascienceplus.com/how-to-detect-heteroscedasticity-and-rectify-it/">How to detect heteroscedasticity and rectify it?</a></li></ul><h2>What the test is reacting to</h2>
<p>Here is the part that is easy to miss. There are two Breusch-Pagan tests in common use in R, and they are not the same test.</p>
<p>The original 1979 statistic regresses the squared residuals on the predictors and scales the result by a variance figure that only holds if the errors are normal. <code>bptest(fit, studentize = FALSE)</code> computes exactly that, and <code>car::ncvTest()</code> computes the same normality-dependent score test (scored against the fitted values rather than the predictors, by default). The <em>studentized</em> version, from Koenker (1981), replaces the assumed scale with one estimated from the residuals, which makes it valid whatever the error distribution looks like. <code>lmtest::bptest()</code> uses the studentized version by default, which is why its printed heading says &quot;studentized Breusch-Pagan test&quot;.</p>
<p>The difference matters more than the naming suggests. To see how much, I generated data where the variance really is constant, varied only the shape of the error distribution, and counted how often each version rejects at the 5% level. Every rejection in that setting is a false alarm. I then repeated it with variance that genuinely grows with <code>x</code>, where rejections are the desired outcome.</p>
<pre><code class="language-r">n &lt;- 200
x &lt;- rnorm(n)

errors &lt;- function(shape) {
  e &lt;- switch(shape,
              &quot;normal&quot;     = rnorm(n),
              &quot;t(5)&quot;       = rt(n, df = 5),
              &quot;log-normal&quot; = exp(rnorm(n)),
              &quot;uniform&quot;    = runif(n))
  (e - mean(e)) / sd(e)          # same variance, different shape
}

one_run &lt;- function(shape, variance) {
  spread &lt;- if (variance == &quot;constant variance&quot;) 1 else exp(0.15 * x)
  y &lt;- 1 + x + errors(shape) * spread
  m &lt;- lm(y ~ x)
  tibble(shape = shape, variance = variance,
         studentized = bptest(m)$p.value,
         original    = bptest(m, studentize = FALSE)$p.value)
}

grid &lt;- expand_grid(shape    = c(&quot;normal&quot;, &quot;t(5)&quot;, &quot;log-normal&quot;, &quot;uniform&quot;),
                    variance = c(&quot;constant variance&quot;, &quot;variance grows with x&quot;),
                    rep      = 1:2000)

rates &lt;- pmap_dfr(list(grid$shape, grid$variance), one_run) |&gt;
  pivot_longer(c(studentized, original), names_to = &quot;test&quot;, values_to = &quot;p&quot;) |&gt;
  group_by(variance, shape, test) |&gt;
  summarise(reject = mean(p &lt; 0.05), .groups = &quot;drop&quot;)

rates |&gt; pivot_wider(names_from = test, values_from = reject)
</code><em>## # A tibble: 8 × 4
##   variance              shape      original studentized
##   &lt;chr&gt;                 &lt;chr&gt;         &lt;dbl&gt;       &lt;dbl&gt;
## 1 constant variance     log-normal   0.525       0.05  
## 2 constant variance     normal       0.046       0.0495
## 3 constant variance     t(5)         0.187       0.046 
## 4 constant variance     uniform      0.0025      0.0495
## 5 variance grows with x log-normal   0.630       0.124 
## 6 variance grows with x normal       0.800       0.795 
## 7 variance grows with x t(5)         0.709       0.482 
## 8 variance grows with x uniform      0.91        0.990
</em></pre>
<pre class="has-plot"><code class="language-r">rates |&gt;
  mutate(shape = factor(shape, c(&quot;uniform&quot;, &quot;normal&quot;, &quot;t(5)&quot;, &quot;log-normal&quot;))) |&gt;
  ggplot(aes(shape, reject, fill = test)) +
  geom_col(position = position_dodge(width = 0.7), width = 0.6) +
  geom_hline(yintercept = 0.05, linetype = &quot;dashed&quot;, color = &quot;grey30&quot;) +
  facet_wrap(~ variance) +
  scale_fill_manual(values = dsp_colors[c(2, 1)], name = NULL,
                    labels = c(&quot;original (assumes normality)&quot;,
                               &quot;studentized (bptest default)&quot;)) +
  scale_y_continuous(labels = scales::percent) +
  labs(title = &quot;In the left panel, every rejection is a false alarm&quot;,
       subtitle = &quot;Rejection rate at the 5% level, 2,000 simulations per bar&quot;,
       x = &quot;Error distribution&quot;, y = &quot;Rejections at the 5% level&quot;) +
  dsp_theme +
  theme(legend.position = &quot;top&quot;)
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-heteroscedasticity-robust-standard-errors-sim-plot-1.png" alt="plot of chunk sim-plot" /></figure>
<p>Read the left panel first. With normally distributed errors both versions behave, sitting on the dashed 5% line. Swap in log-normal errors and the original test rejects 52% of the time, even though the variance never changes. Student&#8217;s t with 5 degrees of freedom, a mild amount of extra tail weight, gets it to 19%. The studentized version stays near 5% throughout.</p>
<p>The failure runs in the other direction too. With light-tailed uniform errors the original test rejects almost never under the null, 0.2%, which is not caution but a broken calibration that costs it detections elsewhere.</p>
<p>The reason is that the original statistic divides by a variance figure that is only correct when the errors are normal. Heavy tails inflate the squared residuals it is testing without inflating that denominator, so kurtosis reads as heteroscedasticity. Studentizing estimates the denominator from the residuals instead, and the sensitivity to shape disappears.</p>
<p>Now the right panel, where the variance really does grow with <code>x</code>. With normal errors the two versions have essentially identical power (80% against 80%), so the protection in the left panel is free. But look at the log-normal bars: the studentized test finds real heteroscedasticity only 12% of the time. Its higher-looking neighbor is not better, because a test that fires 52% of the time under the null cannot have its rejections interpreted.</p>
<p>That is the practical takeaway from this section, and it has two halves. Use the studentized version, which is what you get by default from <code>bptest()</code>. And do not read a non-significant result as a clean bill of health, because with skewed or heavy-tailed errors the honest test has very little power.</p>
<h2>What robust standard errors actually change</h2>
<p>Back to the houses, where the test result was not in doubt anyway. The usual next move is to transform the outcome, but that changes the quantity being estimated: a coefficient in a log-price model is a percentage effect, not a dollar effect, and if the dollar effect is what you wanted, you have answered a different question to fix a standard error.</p>
<p>The alternative keeps the model and fixes only the standard errors. <code>sandwich::vcovHC()</code> builds a heteroscedasticity-consistent covariance matrix, and <code>coeftest()</code> re-runs the coefficient table with it.</p>
<pre><code class="language-r">comparison &lt;- bind_rows(
  classical = tidy(fit),
  HC3       = tidy(coeftest(fit, vcov. = vcovHC(fit))),
  .id = &quot;vcov&quot;) |&gt;
  filter(term != &quot;(Intercept)&quot;) |&gt;
  select(vcov, term, estimate, std.error, statistic) |&gt;
  pivot_wider(names_from = vcov, values_from = c(std.error, statistic)) |&gt;
  mutate(inflation = std.error_HC3 / std.error_classical)

print(comparison, width = Inf)
</code><em>## # A tibble: 4 × 7
##   term       estimate std.error_classical std.error_HC3 statistic_classical
##   &lt;chr&gt;         &lt;dbl&gt;               &lt;dbl&gt;         &lt;dbl&gt;               &lt;dbl&gt;
## 1 area         0.0786             0.00159       0.00254               49.4 
## 2 year_built   0.731              0.0257        0.0275                28.5 
## 3 lot          0.685              0.0881        0.176                  7.78
## 4 basement     0.0521             0.00198       0.00303               26.4 
##   statistic_HC3 inflation
##           &lt;dbl&gt;     &lt;dbl&gt;
## 1         30.9       1.60
## 2         26.5       1.07
## 3          3.90      2.00
## 4         17.2       1.53
</em></pre>
<p>The point estimates never move, because ordinary least squares is still unbiased under heteroscedasticity. Only the uncertainty around them changes, and this is where a single global p-value stops being useful. The inflation factor runs from 1.07 on <code>year_built</code> to 2.00 on <code>lot</code>. The year the house was built has a standard error that was essentially fine all along. The lot size coefficient had a standard error half the size it should be, and its t statistic falls from 7.8 to 3.9.</p>
<p>Why that coefficient? Because heteroscedasticity only distorts a standard error where the large errors coincide with the extreme values of that predictor, and lot size is the variable with the extremes:</p>
<pre><code class="language-r">c(median_lot = median(homes$lot), largest_lot = max(homes$lot))
</code><em>##  median_lot largest_lot 
##       9.360     215.245
</em></pre>
<pre><code class="language-r"># how much of the lot-size variation comes from ten properties
big &lt;- slice_max(homes, lot, n = 10)
sum((big$lot - mean(homes$lot))^2) / sum((homes$lot - mean(homes$lot))^2)
</code><em>## [1] 0.6923105
</em></pre>
<pre><code class="language-r">c(mean_leverage = mean(hatvalues(fit)), max_leverage = max(hatvalues(fit)))
</code><em>## mean_leverage  max_leverage 
##   0.002072109   0.270229368
</em></pre>
<p>A handful of acreages, one of them 23 times the median lot, carry most of the information about that slope, and those same properties have the largest residuals. That is the worst possible combination, and it is invisible in the omnibus test.</p>
<p>One more thing worth knowing, because &quot;robust&quot; is often heard as &quot;conservative&quot;: the correction is not guaranteed to make standard errors bigger. When the largest errors sit in the <em>middle</em> of a predictor&#8217;s range rather than at its extremes, it goes the other way.</p>
<pre><code class="language-r">ratio &lt;- replicate(1000, {
  x &lt;- rnorm(300)
  y &lt;- 1 + x + rnorm(300, sd = exp(-0.6 * abs(x)))  # noisiest near the center
  m &lt;- lm(y ~ x)
  sqrt(diag(vcovHC(m)))[[&quot;x&quot;]] / sqrt(diag(vcov(m)))[[&quot;x&quot;]]
})
c(mean_ratio = mean(ratio), share_below_one = mean(ratio &lt; 1))
</code><em>##      mean_ratio share_below_one 
##       0.6535694       1.0000000
</em></pre>
<p>Every simulated dataset gets a <em>smaller</em> robust standard error, averaging 0.65 times the classical one. The sandwich estimator is not a safety margin bolted on top of the classical one. It is a different estimate, and it can point either way.</p>
<h2>Which sandwich</h2>
<p><code>vcovHC()</code> offers several corrections, and the choice matters far more than it appears to. HC0 is the original White estimator. HC1 applies a degrees-of-freedom factor and is what Stata&#8217;s <code>robust</code> option reports. HC3 divides each squared residual by <code>(1 - h)²</code>, where <code>h</code> is that observation&#8217;s leverage, which approximates what you would get by leaving the point out and refitting. To see what that buys, I simulated data with real heteroscedasticity and measured how often a nominal 95% interval for the slope actually contains the true value.</p>
<pre><code class="language-r">covers &lt;- function(m, V, truth = 1) {
  ci &lt;- coefci(m, vcov. = V)[&quot;x&quot;, ]
  ci[1] &lt;= truth &amp; truth &lt;= ci[2]
}

one_fit &lt;- function(n) {
  x &lt;- rnorm(n)
  y &lt;- 1 + x + rnorm(n, sd = exp(0.6 * x))
  m &lt;- lm(y ~ x)
  tibble(n = n,
         classical = covers(m, vcov(m)),
         HC0 = covers(m, vcovHC(m, type = &quot;HC0&quot;)),
         HC1 = covers(m, vcovHC(m, type = &quot;HC1&quot;)),
         HC3 = covers(m, vcovHC(m, type = &quot;HC3&quot;)))
}

sizes &lt;- c(30, 60, 120, 500)

cover &lt;- map_dfr(rep(sizes, each = 2000), one_fit) |&gt;
  pivot_longer(-n, names_to = &quot;vcov&quot;, values_to = &quot;covered&quot;) |&gt;
  group_by(n, vcov) |&gt;
  summarise(coverage = mean(covered), .groups = &quot;drop&quot;)

cover |&gt; pivot_wider(names_from = vcov, values_from = coverage)
</code><em>## # A tibble: 4 × 5
##       n   HC0   HC1   HC3 classical
##   &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt; &lt;dbl&gt;     &lt;dbl&gt;
## 1    30 0.898 0.912 0.953     0.828
## 2    60 0.918 0.922 0.940     0.820
## 3   120 0.934 0.936 0.942     0.815
## 4   500 0.946 0.946 0.951     0.79
</em></pre>
<pre class="has-plot"><code class="language-r">cover |&gt;
  ggplot(aes(factor(n), coverage, color = vcov, group = vcov)) +
  geom_hline(yintercept = 0.95, linetype = &quot;dashed&quot;, color = &quot;grey30&quot;) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.4) +
  scale_y_continuous(labels = scales::percent, limits = c(0.75, 1)) +
  scale_color_manual(values = dsp_colors[c(5, 2, 7, 3)], name = NULL) +
  labs(title = &quot;Coverage of a nominal 95% interval for the slope&quot;,
       subtitle = &quot;2,000 simulations per point, variance growing with x&quot;,
       x = &quot;Sample size&quot;, y = &quot;Coverage&quot;) +
  dsp_theme +
  theme(legend.position = &quot;top&quot;)
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-heteroscedasticity-robust-standard-errors-cover-plot-1.png" alt="plot of chunk cover-plot" /></figure>
<p>The classical interval is the flat line at the bottom, covering around 81% instead of 95%, and it gets <em>worse</em> as the sample grows. That is the thing to internalize about heteroscedasticity: it is not a small-sample problem that more data will wash out. The interval is converging, just to the wrong width.</p>
<p>Among the robust options, at n = 500 they are indistinguishable, which is the textbook claim and the reason the choice often gets waved through. At n = 30 they are not: HC0 covers 89.8% and HC1 91.2%, while HC3 reaches 95.3%. A test you believe runs at 5% is running closer to 10%. HC3 is the default in <code>vcovHC()</code> for exactly this reason, so plain <code>vcovHC(fit)</code> is already the right call, and the way to get this wrong is to type a <code>type = </code> argument copied from somewhere else.</p>
<h2>The routine I would use instead</h2>
<p>Plot the residuals, always. The picture tells you the shape of the problem, which no p-value does.</p>
<p>Run <code>bptest()</code> and leave <code>studentize</code> alone, and treat a non-significant result as weak evidence rather than as clearance, especially when the residuals are skewed.</p>
<p>Skip the test as a gate on whether to use robust standard errors. Deciding between two covariance estimators by looking at a p-value from the same data makes the reported standard error a random choice between them, and the heteroscedasticity-consistent one is valid either way, costing only a little efficiency when the variance really is constant. Report <code>coeftest(fit, vcov. = vcovHC(fit))</code> and be done.</p>
<p>Transform the outcome when the transformed scale is the one you want to interpret, not as a repair for a standard error.</p>
<p>And when a coefficient&#8217;s standard error moves a lot under the correction, go look at that predictor. On the Ames data the correction was not just a technical adjustment: it pointed at ten properties that were quietly running the lot-size coefficient.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/what-your-heteroscedasticity-test-is-actually-detecting/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/what-your-heteroscedasticity-test-is-actually-detecting/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Building a Human-in-the-Loop Validation Workflow for AI Systems in Python</title>
		<link>https://datascienceplus.com/building-a-human-in-the-loop-validation-workflow-for-ai-systems-in-python/</link>
					<comments>https://datascienceplus.com/building-a-human-in-the-loop-validation-workflow-for-ai-systems-in-python/#respond</comments>
		
		<dc:creator><![CDATA[Harris Bashir]]></dc:creator>
		<pubDate>Mon, 17 Aug 2026 00:30:26 +0000</pubDate>
				<category><![CDATA[Programming]]></category>
		<category><![CDATA[ai governance]]></category>
		<category><![CDATA[Machine Learning]]></category>
		<category><![CDATA[Python]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32961</guid>

					<description><![CDATA[AI systems often fail in production not because they cannot generate an output, but because nobody has built a reliable process for checking, correcting,…]]></description>
										<content:encoded><![CDATA[<p>AI systems often fail in production not because they cannot generate an output, but because nobody has built a reliable process for checking, correcting, and learning from that output.</p>
<p>A prototype usually focuses on one question:</p>
<p>Can the model produce something useful?</p>
<p>A production system needs to answer a harder question:</p>
<p>Can we trust this output enough to use it in a real workflow?</p>
<p>This is where human-in-the-loop validation becomes important.</p>
<p>Human-in-the-loop does not mean the AI system is weak. In many real-world systems, it is the difference between a risky automation and a controlled AI workflow. Human review helps teams validate uncertain outputs, correct mistakes, capture feedback, improve data quality, and create a reliable audit trail.</p>
<p>In this article, we will build a simple human-in-the-loop validation workflow in Python.</p>
<p>The goal is to create a small but practical process that:</p>
<ul>
<li>Loads model-generated predictions</li>
<li>Assigns items for review</li>
<li>Applies rule-based validation checks</li>
<li>Captures human approval or correction</li>
<li>Stores review decisions</li>
<li>Produces a clean final dataset</li>
<li>Creates a basic review summary</li>
</ul>
<p>This pattern can be extended into a web app, Streamlit dashboard, internal review tool, or production data pipeline.</p>
<h2>Why human-in-the-loop validation matters</h2>
<p>Many AI systems produce outputs that look reasonable most of the time.</p>
<p>For example, an AI system might:</p>
<ul>
<li>Classify support tickets</li>
<li>Label mobile apps by category</li>
<li>Detect risky documents</li>
<li>Score leads</li>
<li>Summarise customer conversations</li>
<li>Extract fields from invoices</li>
<li>Recommend product tags</li>
<li>Identify policy violations</li>
</ul>
<p>The challenge is that &quot;mostly correct&quot; is not always good enough.</p>
<p>A wrong classification may affect reporting.<br />
A wrong risk score may affect compliance.<br />
A wrong label may affect user experience.<br />
A wrong extracted field may affect business operations.</p>
<p>In production, we need to know when an AI output should be accepted automatically, when it should be reviewed, and how corrections should be stored.</p>
<p>A good human-in-the-loop system should answer:</p>
<ul>
<li>Which predictions need review?</li>
<li>Who reviewed them?</li>
<li>What decision did they make?</li>
<li>What was corrected?</li>
<li>Why was it corrected?</li>
<li>Was the final value written back to the dataset?</li>
<li>Can we analyse review patterns later?</li>
</ul>
<p>If these answers are stored properly, human review becomes more than manual checking. It becomes a feedback loop.</p>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/machine-learning-for-diabetes-with-python/">Machine Learning for Diabetes with Python</a></li><li><a href="https://datascienceplus.com/topic-modeling-in-python-with-nltk-and-gensim/">Topic Modeling in Python with NLTK and Gensim</a></li><li><a href="https://datascienceplus.com/top-python-libraries-for-machine-learning/">Top Python Libraries for Machine Learning</a></li></ul><h2>Sample dataset</h2>
<p>For this tutorial, we will use a small CSV file called <code>ai_predictions.csv</code>.</p>
<p>Each row contains an item that has been classified by an AI model.</p>
<pre><code class="language-csv">item_id,item_name,predicted_category,predicted_audience,confidence,source
1,Math Games for Kids,Education,Children,0.91,android
2,Fast Racing Challenge,Racing,Teens,0.74,android
3,Invoice Scanner Pro,Productivity,Adults,0.86,ios
4,Monster Battle Arena,Action,Children,0.58,android
5,ABC Learning App,Education,Children,0.96,ios
6,Crypto Trading Signals,Finance,Teens,0.62,android
7,Daily Meditation Guide,Health,Adults,0.82,ios
8,Princess Coloring Book,Education,Children,0.88,android
9,Real Car Parking 3D,Racing,Children,0.67,android
10,Legal Contract Reader,Productivity,Adults,0.79,ios
</code></pre>
<p>In a real system, this data could come from:</p>
<p>* A machine learning model<br />
* An LLM classification workflow<br />
* An internal AI service<br />
* A batch prediction pipeline<br />
* A database table<br />
* An API response<br />
* A spreadsheet export</p>
<p>For this example, we will keep the dataset simple.</p>
<p>## Step 1: Load the predictions</p>
<p>First, load the CSV file with pandas.</p>
<pre>
import pandas as pd

df = pd.read_csv(&quot;ai_predictions.csv&quot;)

print(df.head())</pre>
<p>Now check the structure of the data.</p>
<pre><code class="language-python">print(df.info())
print(df[&quot;predicted_category&quot;].value_counts())</code></pre>
<p>At this stage, we have raw model predictions. These are not yet approved. They are simply outputs that need either automatic acceptance or human review.</p>
<h2>Step 2: Define review rules</h2>
<p>Not every prediction needs human review.</p>
<p>A simple production workflow might auto-approve high-confidence predictions and send lower-confidence or higher-risk predictions to a review queue.</p>
<p>For this tutorial, we will review an item if:</p>
<ul>
<li>confidence is below 0.80</li>
<li>the predicted audience is Children and confidence is below 0.90</li>
<li>the category is Finance</li>
<li>the category is Action and audience is Children</li>
</ul>
<p>These rules are only examples. In a real system, review rules should be based on business risk, compliance needs, and model performance.</p>
<pre><code class="language-python">def needs_review(row):
    if row[&quot;confidence&quot;] &lt; 0.80:
        return True

    if row[&quot;predicted_audience&quot;] == &quot;Children&quot; and row[&quot;confidence&quot;] &lt; 0.90:
        return True

    if row[&quot;predicted_category&quot;] == &quot;Finance&quot;:
        return True

    if row[&quot;predicted_category&quot;] == &quot;Action&quot; and row[&quot;predicted_audience&quot;] == &quot;Children&quot;:
        return True

    return False

df[&quot;needs_review&quot;] = df.apply(needs_review, axis=1)

print(df[[&quot;item_id&quot;, &quot;item_name&quot;, &quot;predicted_category&quot;, &quot;predicted_audience&quot;, &quot;confidence&quot;, &quot;needs_review&quot;]])</code></pre>
<p>This creates a clear separation between items that can be auto-approved and items that require review.</p>
<h2>Step 3: Create a review queue</h2>
<p>Now we can create a review queue containing only items that require human validation.</p>
<pre><code class="language-python">review_queue = df[df[&quot;needs_review&quot;] == True].copy()

review_queue = review_queue.sort_values(
    by=[&quot;confidence&quot;],
    ascending=True
)

print(review_queue[[
    &quot;item_id&quot;,
    &quot;item_name&quot;,
    &quot;predicted_category&quot;,
    &quot;predicted_audience&quot;,
    &quot;confidence&quot;
]])</code></pre>
<p>Sorting by confidence helps reviewers focus on the most uncertain items first.</p>
<p>In a larger system, the review queue could also be prioritised by:</p>
<ul>
<li>business value</li>
<li>customer impact</li>
<li>compliance risk</li>
<li>model confidence</li>
<li>number of previous errors</li>
<li>source system</li>
<li>deadline or SLA</li>
</ul>
<h2>Step 4: Simulate reviewer decisions</h2>
<p>In a real application, reviewer decisions would be captured through a form, dashboard, or web application.</p>
<p>For this tutorial, we will simulate reviewer input using a small dictionary.</p>
<p>Each reviewer decision includes:</p>
<ul>
<li>item_id</li>
<li>review_status</li>
<li>final_category</li>
<li>final_audience</li>
<li>reviewer_notes</li>
</ul>
<pre><code class="language-python">review_decisions = [
    {
        &quot;item_id&quot;: 2,
        &quot;review_status&quot;: &quot;approved&quot;,
        &quot;final_category&quot;: &quot;Racing&quot;,
        &quot;final_audience&quot;: &quot;Teens&quot;,
        &quot;reviewer_notes&quot;: &quot;Prediction looks correct.&quot;
    },
    {
        &quot;item_id&quot;: 4,
        &quot;review_status&quot;: &quot;corrected&quot;,
        &quot;final_category&quot;: &quot;Action&quot;,
        &quot;final_audience&quot;: &quot;Teens&quot;,
        &quot;reviewer_notes&quot;: &quot;Game is not suitable for children based on content.&quot;
    },
    {
        &quot;item_id&quot;: 6,
        &quot;review_status&quot;: &quot;corrected&quot;,
        &quot;final_category&quot;: &quot;Finance&quot;,
        &quot;final_audience&quot;: &quot;Adults&quot;,
        &quot;reviewer_notes&quot;: &quot;Finance-related app should not be labelled for teens.&quot;
    },
    {
        &quot;item_id&quot;: 9,
        &quot;review_status&quot;: &quot;approved&quot;,
        &quot;final_category&quot;: &quot;Racing&quot;,
        &quot;final_audience&quot;: &quot;Children&quot;,
        &quot;reviewer_notes&quot;: &quot;Child-friendly racing game.&quot;
    },
    {
        &quot;item_id&quot;: 10,
        &quot;review_status&quot;: &quot;approved&quot;,
        &quot;final_category&quot;: &quot;Productivity&quot;,
        &quot;final_audience&quot;: &quot;Adults&quot;,
        &quot;reviewer_notes&quot;: &quot;Prediction looks correct.&quot;
    }
]

reviews = pd.DataFrame(review_decisions)

print(reviews)</code></pre>
<p>This review table is important because it becomes part of the audit trail.</p>
<p>It tells us not only what the final answer was, but also what the human reviewer changed.</p>
<h2>Step 5: Merge predictions with review decisions</h2>
<p>Now merge the original predictions with the review decisions.</p>
<pre><code class="language-python">validated = df.merge(
    reviews,
    on=&quot;item_id&quot;,
    how=&quot;left&quot;
)

print(validated.head())</code></pre>
<p>For items that were not reviewed, we can use the original model prediction as the final approved value.</p>
<pre><code class="language-python">validated[&quot;final_category&quot;] = validated[&quot;final_category&quot;].fillna(
    validated[&quot;predicted_category&quot;]
)

validated[&quot;final_audience&quot;] = validated[&quot;final_audience&quot;].fillna(
    validated[&quot;predicted_audience&quot;]
)

validated[&quot;review_status&quot;] = validated[&quot;review_status&quot;].fillna(
    &quot;auto_approved&quot;
)

validated[&quot;reviewer_notes&quot;] = validated[&quot;reviewer_notes&quot;].fillna(
    &quot;Auto-approved based on validation rules.&quot;
)

print(validated[[
    &quot;item_id&quot;,
    &quot;item_name&quot;,
    &quot;predicted_category&quot;,
    &quot;final_category&quot;,
    &quot;predicted_audience&quot;,
    &quot;final_audience&quot;,
    &quot;review_status&quot;
]])</code></pre>
<p>Now every row has a final value.</p>
<p>Some were auto-approved.<br />
Some were approved by a human.<br />
Some were corrected by a human.</p>
<p>This is much better than keeping raw model outputs with no validation context.</p>
<h2>Step 6: Identify changed predictions</h2>
<p>It is useful to know where the human reviewer disagreed with the model.</p>
<pre><code class="language-python">validated[&quot;category_changed&quot;] = (
    validated[&quot;predicted_category&quot;] != validated[&quot;final_category&quot;]
)

validated[&quot;audience_changed&quot;] = (
    validated[&quot;predicted_audience&quot;] != validated[&quot;final_audience&quot;]
)

validated[&quot;any_change&quot;] = (
    validated[&quot;category_changed&quot;] | validated[&quot;audience_changed&quot;]
)

print(validated[[
    &quot;item_id&quot;,
    &quot;item_name&quot;,
    &quot;predicted_category&quot;,
    &quot;final_category&quot;,
    &quot;predicted_audience&quot;,
    &quot;final_audience&quot;,
    &quot;any_change&quot;
]])</code></pre>
<p>This gives us a simple correction dataset.</p>
<p>In a real system, this can help answer:</p>
<ul>
<li>Which categories are most often corrected?</li>
<li>Which audience labels are most unreliable?</li>
<li>Which sources produce more review failures?</li>
<li>Which rules are sending too many or too few items for review?</li>
<li>Where should the model or prompt be improved?</li>
</ul>
<h2>Step 7: Create a review summary</h2>
<p>Now we can summarise the review process.</p>
<pre><code class="language-python">summary = {
    &quot;total_items&quot;: len(validated),
    &quot;auto_approved&quot;: (validated[&quot;review_status&quot;] == &quot;auto_approved&quot;).sum(),
    &quot;human_reviewed&quot;: (validated[&quot;review_status&quot;] != &quot;auto_approved&quot;).sum(),
    &quot;approved_by_reviewer&quot;: (validated[&quot;review_status&quot;] == &quot;approved&quot;).sum(),
    &quot;corrected_by_reviewer&quot;: (validated[&quot;review_status&quot;] == &quot;corrected&quot;).sum(),
    &quot;changed_predictions&quot;: validated[&quot;any_change&quot;].sum()
}

for key, value in summary.items():
    print(f&quot;{key}: {value}&quot;)</code></pre>
<p>We can also calculate review rates.</p>
<pre><code class="language-python">total_items = len(validated)
human_reviewed = summary[&quot;human_reviewed&quot;]
changed_predictions = summary[&quot;changed_predictions&quot;]

review_rate = human_reviewed / total_items
correction_rate = changed_predictions / total_items

print(f&quot;Review rate: {review_rate:.1%}&quot;)
print(f&quot;Correction rate: {correction_rate:.1%}&quot;)</code></pre>
<p>These metrics are useful because they show how much human effort is required and how often the model is corrected.</p>
<p>A high correction rate may indicate:</p>
<ul>
<li>the model is weak for certain categories</li>
<li>the prompt needs improvement</li>
<li>the training data is outdated</li>
<li>the review rules are catching genuinely risky cases</li>
<li>the category definitions are unclear</li>
</ul>
<h2>Step 8: Analyse corrections by category</h2>
<p>Now let us see which predicted categories had the most corrections.</p>
<pre><code class="language-python">corrections_by_category = (
    validated[validated[&quot;any_change&quot;] == True]
    .groupby(&quot;predicted_category&quot;)
    .agg(
        corrections=(&quot;item_id&quot;, &quot;count&quot;)
    )
    .reset_index()
    .sort_values(&quot;corrections&quot;, ascending=False)
)

print(corrections_by_category)</code></pre>
<p>If a category is corrected often, it may need closer attention.</p>
<p>We can also look at corrections by source.</p>
<pre><code class="language-python">corrections_by_source = (
    validated.groupby(&quot;source&quot;)
    .agg(
        total_items=(&quot;item_id&quot;, &quot;count&quot;),
        corrections=(&quot;any_change&quot;, &quot;sum&quot;)
    )
    .reset_index()
)

corrections_by_source[&quot;correction_rate&quot;] = (
    corrections_by_source[&quot;corrections&quot;] / corrections_by_source[&quot;total_items&quot;]
)

print(corrections_by_source)</code></pre>
<p>This can help identify whether errors are linked to a specific source system, platform, or data pipeline.</p>
<h2>Step 9: Export the final dataset</h2>
<p>Once review is complete, export the validated dataset.</p>
<pre><code class="language-python">final_output = validated[[
    &quot;item_id&quot;,
    &quot;item_name&quot;,
    &quot;final_category&quot;,
    &quot;final_audience&quot;,
    &quot;review_status&quot;,
    &quot;reviewer_notes&quot;
]]

final_output.to_csv(&quot;validated_ai_outputs.csv&quot;, index=False)

print(final_output)</code></pre>
<p>This final output is the dataset that downstream systems should use.</p>
<p>The raw model output is still preserved, but the business process uses the validated output.</p>
<h2>Step 10: Save the review audit trail</h2>
<p>It is also important to save the review history.</p>
<pre><code class="language-python">audit_trail = validated[[
    &quot;item_id&quot;,
    &quot;item_name&quot;,
    &quot;predicted_category&quot;,
    &quot;predicted_audience&quot;,
    &quot;confidence&quot;,
    &quot;needs_review&quot;,
    &quot;review_status&quot;,
    &quot;final_category&quot;,
    &quot;final_audience&quot;,
    &quot;reviewer_notes&quot;,
    &quot;category_changed&quot;,
    &quot;audience_changed&quot;,
    &quot;any_change&quot;
]]

audit_trail.to_csv(&quot;ai_review_audit_trail.csv&quot;, index=False)

print(audit_trail)</code></pre>
<p>The audit trail is useful for:</p>
<ul>
<li>debugging</li>
<li>compliance</li>
<li>reviewer training</li>
<li>model evaluation</li>
<li>future retraining</li>
<li>stakeholder trust</li>
</ul>
<p>Without an audit trail, human review becomes a manual activity that disappears after the correction is made.</p>
<p>With an audit trail, review becomes structured feedback data.</p>
<h2>Step 11: Visualise review outcomes</h2>
<p>A simple chart can show how many items were auto-approved, approved, or corrected.</p>
<pre><code class="language-python">import matplotlib.pyplot as plt

status_counts = validated[&quot;review_status&quot;].value_counts()

plt.figure(figsize=(8, 5))
plt.bar(status_counts.index, status_counts.values)
plt.xlabel(&quot;Review status&quot;)
plt.ylabel(&quot;Number of items&quot;)
plt.title(&quot;AI validation outcomes&quot;)
plt.tight_layout()
plt.show()</code></pre>
<p>We can also visualise correction rate by source.</p>
<pre><code class="language-python">plt.figure(figsize=(8, 5))
plt.bar(
    corrections_by_source[&quot;source&quot;],
    corrections_by_source[&quot;correction_rate&quot;]
)
plt.xlabel(&quot;Source&quot;)
plt.ylabel(&quot;Correction rate&quot;)
plt.title(&quot;Correction rate by source&quot;)
plt.tight_layout()
plt.show()</code></pre>
<p>These simple visuals can help teams understand how the validation process is performing.</p>
<h2>What this workflow gives us</h2>
<p>This small workflow creates a basic but useful human-in-the-loop validation layer.</p>
<p>It helps answer:</p>
<ul>
<li>Which AI outputs were auto-approved?</li>
<li>Which outputs required human review?</li>
<li>Which predictions were corrected?</li>
<li>What final values should downstream systems use?</li>
<li>Which categories or sources produce more corrections?</li>
<li>What evidence exists for audit and improvement?</li>
</ul>
<p>This is the foundation of a more reliable AI system.</p>
<h2>How to extend this into production</h2>
<p>This tutorial uses CSV files and simulated reviewer decisions, but the same logic can be extended into a real production workflow.</p>
<p>Possible improvements include:</p>
<h3>Use a database</h3>
<p>Store predictions, reviews, and final outputs in PostgreSQL, MySQL, BigQuery, Snowflake, or another database.</p>
<h3>Add reviewer identities</h3>
<p>Track who reviewed each item and when.</p>
<h3>Add timestamps</h3>
<p>Store prediction time, review time, approval time, and update time.</p>
<h3>Add role-based access</h3>
<p>Different users may need different permissions:</p>
<ul>
<li>reviewer</li>
<li>validator</li>
<li>admin</li>
<li>analyst</li>
<li>auditor</li>
</ul>
<h3>Add review queues</h3>
<p>A real system should assign work based on priority, risk, category, source, or reviewer capacity.</p>
<h3>Add model feedback</h3>
<p>Corrected outputs can be reused for future evaluation, prompt tuning, or model retraining.</p>
<h3>Add alerts</h3>
<p>If correction rates increase, the system should alert the team.</p>
<h3>Add data quality monitoring</h3>
<p>Track whether errors are coming from model weakness, source data problems, or unclear business definitions.</p>
<h2>Common mistakes to avoid</h2>
<h3>Treating human review as temporary</h3>
<p>Some teams assume human review is only needed until the model gets better. In reality, many production systems need review permanently for high-risk or uncertain cases.</p>
<h3>Not storing corrections</h3>
<p>If corrections are made but not stored, the system cannot learn from them.</p>
<h3>Reviewing too much</h3>
<p>If every item needs review, the AI system may not be reducing work. Review rules should focus attention where it matters most.</p>
<h3>Reviewing too little</h3>
<p>If the system auto-approves risky or low-confidence outputs, mistakes can quietly enter production.</p>
<h3>Not defining final ownership</h3>
<p>Someone must own the final approved value. Is it the model, the reviewer, the validator, or the business team?</p>
<h2>Final thoughts</h2>
<p>Human-in-the-loop validation is not a sign that an AI system has failed.</p>
<p>It is often the process that makes the AI system safe enough to use.</p>
<p>The important difference is structure.</p>
<p>Unstructured review creates bottlenecks, confusion, and lost feedback. Structured review creates validated outputs, audit trails, correction datasets, and continuous improvement.</p>
<p>For production AI systems, the goal is not to remove humans from every decision. The goal is to use human judgment where it adds the most value, and then capture that judgment as data.</p>
<p>That is how human review becomes part of the AI system, not a workaround for it.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/building-a-human-in-the-loop-validation-workflow-for-ai-systems-in-python/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/building-a-human-in-the-loop-validation-workflow-for-ai-systems-in-python/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Building a Simple LLM Cost and Risk Visibility Tool in Python</title>
		<link>https://datascienceplus.com/building-a-simple-llm-cost-and-risk-visibility-tool-in-python/</link>
					<comments>https://datascienceplus.com/building-a-simple-llm-cost-and-risk-visibility-tool-in-python/#respond</comments>
		
		<dc:creator><![CDATA[Harris Bashir]]></dc:creator>
		<pubDate>Sun, 16 Aug 2026 18:52:29 +0000</pubDate>
				<category><![CDATA[Programming]]></category>
		<category><![CDATA[ai governance]]></category>
		<category><![CDATA[data engineering]]></category>
		<category><![CDATA[llm]]></category>
		<category><![CDATA[Machine Learning]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[risk monitoring]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32957</guid>

					<description><![CDATA[Most teams experimenting with large language models start with the same question: Can we make this work? A developer builds a prototype. A product…]]></description>
										<content:encoded><![CDATA[<p>Most teams experimenting with large language models start with the same question:</p>
<p>Can we make this work?</p>
<p>A developer builds a prototype. A product manager tests a few prompts. The results look useful. Soon the team wants to connect the model to documents, internal tools, customer data, support tickets, reports, or dashboards.</p>
<p>That is usually when the harder questions appear:</p>
<ul>
<li>How much will this cost when more users start using it?</li>
<li>Which workflows are generating the most token usage?</li>
<li>Are we sending sensitive data into the model?</li>
<li>Which prompts are risky?</li>
<li>Are we tracking failures, retries, and expensive requests?</li>
<li>Can we explain where the data went?</li>
</ul>
<p>Many AI prototypes fail not because the model is weak, but because the team has no visibility around cost, risk, and data movement.</p>
<p>In this article, we will build a simple Python-based LLM cost and risk visibility tool. The goal is not to create a full governance platform. The goal is to show how a small team can start monitoring AI usage in a practical way.</p>
<p>We will create a lightweight workflow that:</p>
<ul>
<li>loads sample LLM request logs;</li>
<li>estimates token-based cost;</li>
<li>flags risky prompts;</li>
<li>groups usage by user and workflow;</li>
<li>identifies expensive or sensitive requests;</li>
<li>produces a simple summary report.</li>
</ul>
<p>This pattern can be extended later into a dashboard, database table, Streamlit app, or internal monitoring tool.</p>
<h2>Why cost and risk visibility matters</h2>
<p>LLM usage can grow quietly.</p>
<p>One user testing a chatbot may not cost much. But once an AI feature is used across a team, costs can increase through:</p>
<ul>
<li>long prompts;</li>
<li>large uploaded documents;</li>
<li>repeated retries;</li>
<li>agent loops;</li>
<li>unnecessary model calls;</li>
<li>verbose responses;</li>
<li>background automations;</li>
<li>multiple tools calling the model without tracking.</li>
</ul>
<p>Risk grows in a similar way.</p>
<p>At the prototype stage, users may paste test data. In production, they may paste customer details, financial information, confidential documents, employee data, or business-sensitive material.</p>
<p>If the application does not log what is happening, the team cannot manage it.</p>
<p>A simple monitoring layer can help answer:</p>
<ul>
<li>Which workflows use the most tokens?</li>
<li>Which users or teams generate the highest cost?</li>
<li>Which prompts contain sensitive terms?</li>
<li>Which model calls failed or retried?</li>
<li>Which requests need review?</li>
</ul>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/machine-learning-for-diabetes-with-python/">Machine Learning for Diabetes with Python</a></li><li><a href="https://datascienceplus.com/topic-modeling-in-python-with-nltk-and-gensim/">Topic Modeling in Python with NLTK and Gensim</a></li><li><a href="https://datascienceplus.com/top-python-libraries-for-machine-learning/">Top Python Libraries for Machine Learning</a></li></ul><h2>Sample dataset</h2>
<p>For this example, we will use a small CSV file called <code>llm_requests.csv</code>.</p>
<p>Each row represents one LLM request.</p>
<pre><code class="language-csv">request_id,user_id,workflow,model,prompt,response,status
1,u001,customer_support,gpt-4,&quot;Summarise this customer complaint: My card number is 4111 1111 1111 1111 and I was charged twice.&quot;,&quot;The customer reports a duplicate charge.&quot;,success
2,u002,sales_email,gpt-4,&quot;Write a follow-up email for a SaaS lead interested in pricing.&quot;,&quot;Here is a professional follow-up email...&quot;,success
3,u003,hr_policy,gpt-4,&quot;Can you review this employee performance note and suggest improvements?&quot;,&quot;The note can be rewritten as follows...&quot;,success
4,u001,customer_support,gpt-3.5,&quot;Classify this ticket as billing, technical, or account access.&quot;,&quot;Billing&quot;,success
5,u004,legal_review,gpt-4,&quot;Review this contract clause and identify risks related to liability.&quot;,&quot;The clause may create liability exposure...&quot;,success
6,u005,analytics,gpt-3.5,&quot;Generate SQL to calculate monthly active users from the events table.&quot;,&quot;SELECT DATE_TRUNC(&#039;month&#039;, event_time)...&quot;,success
7,u006,finance,gpt-4,&quot;Analyse this invoice and bank account number 12345678 for payment validation.&quot;,&quot;The invoice appears valid...&quot;,success</code></pre>
<p>In a real system, these logs could come from:</p>
<ul>
<li>application logs;</li>
<li>API gateway logs;</li>
<li>database tables;</li>
<li>prompt logging middleware;</li>
<li>SaaS AI tool exports;</li>
<li>internal workflow automation systems.</li>
</ul>
<p>For this tutorial, the CSV keeps things simple.</p>
<h2>Step 1: Load the data</h2>
<pre><code class="language-python">import pandas as pd

df = pd.read_csv(&quot;llm_requests.csv&quot;)

print(df.head())</code></pre>
<p>We should also check the basic structure.</p>
<pre><code class="language-python">print(df.info())
print(df[&quot;workflow&quot;].value_counts())</code></pre>
<p>This gives us a starting point: who is using the system, which workflows are active, and what kind of model calls are being made.</p>
<h2>Step 2: Estimate token usage</h2>
<p>In production, the best option is to use actual token counts returned by the model provider.</p>
<p>For this example, we will use a rough approximation:</p>
<ul>
<li>1 token is around 4 characters in English text.</li>
<li>Total tokens = prompt tokens + response tokens.</li>
</ul>
<p>This is not exact, but it is good enough for a basic monitoring prototype.</p>
<pre><code class="language-python">def estimate_tokens(text):
    if pd.isna(text):
        return 0
    return max(1, round(len(str(text)) / 4))

df[&quot;prompt_tokens&quot;] = df[&quot;prompt&quot;].apply(estimate_tokens)
df[&quot;response_tokens&quot;] = df[&quot;response&quot;].apply(estimate_tokens)
df[&quot;total_tokens&quot;] = df[&quot;prompt_tokens&quot;] + df[&quot;response_tokens&quot;]

print(df[[&quot;request_id&quot;, &quot;workflow&quot;, &quot;model&quot;, &quot;total_tokens&quot;]])</code></pre>
<p>In a real application, replace this approximation with the token usage returned by the API.</p>
<h2>Step 3: Add model pricing</h2>
<p>Different models have different costs. We can create a simple pricing table.</p>
<p>The numbers below are only example prices. You should replace them with current pricing from your model provider.</p>
<pre><code class="language-python">pricing = {
    &quot;gpt-4&quot;: {
        &quot;input_per_1k&quot;: 0.03,
        &quot;output_per_1k&quot;: 0.06
    },
    &quot;gpt-3.5&quot;: {
        &quot;input_per_1k&quot;: 0.0015,
        &quot;output_per_1k&quot;: 0.002
    }
}</code></pre>
<p>Now we can estimate cost per request.</p>
<pre><code class="language-python">def estimate_cost(row):
    model = row[&quot;model&quot;]

    if model not in pricing:
        return 0

    input_cost = (row[&quot;prompt_tokens&quot;] / 1000) * pricing[model][&quot;input_per_1k&quot;]
    output_cost = (row[&quot;response_tokens&quot;] / 1000) * pricing[model][&quot;output_per_1k&quot;]

    return input_cost + output_cost

df[&quot;estimated_cost_usd&quot;] = df.apply(estimate_cost, axis=1)

print(df[[&quot;request_id&quot;, &quot;workflow&quot;, &quot;model&quot;, &quot;total_tokens&quot;, &quot;estimated_cost_usd&quot;]])</code></pre>
<p>This gives us an estimated cost for each LLM request.</p>
<h2>Step 4: Summarise cost by workflow</h2>
<p>Cost is more useful when grouped by business workflow.</p>
<pre><code class="language-python">workflow_cost = (
    df.groupby(&quot;workflow&quot;)
    .agg(
        requests=(&quot;request_id&quot;, &quot;count&quot;),
        total_tokens=(&quot;total_tokens&quot;, &quot;sum&quot;),
        estimated_cost_usd=(&quot;estimated_cost_usd&quot;, &quot;sum&quot;)
    )
    .reset_index()
    .sort_values(&quot;estimated_cost_usd&quot;, ascending=False)
)

print(workflow_cost)</code></pre>
<p>This helps answer:</p>
<ul>
<li>Which workflow is most expensive?</li>
<li>Which workflows may need prompt optimisation?</li>
<li>Which use cases are growing fastest?</li>
</ul>
<p>A workflow with high cost is not automatically bad. It may be valuable. But without this visibility, the team cannot make informed decisions.</p>
<h2>Step 5: Summarise cost by user</h2>
<p>User-level monitoring can help detect unusual behaviour.</p>
<pre><code class="language-python">user_cost = (
    df.groupby(&quot;user_id&quot;)
    .agg(
        requests=(&quot;request_id&quot;, &quot;count&quot;),
        total_tokens=(&quot;total_tokens&quot;, &quot;sum&quot;),
        estimated_cost_usd=(&quot;estimated_cost_usd&quot;, &quot;sum&quot;)
    )
    .reset_index()
    .sort_values(&quot;estimated_cost_usd&quot;, ascending=False)
)

print(user_cost)</code></pre>
<p>This can be useful for internal AI tools where different teams use the same system.</p>
<p>For example, if one user or team generates unusually high usage, it may indicate:</p>
<ul>
<li>a genuine high-value use case;</li>
<li>a prompt that is too long;</li>
<li>repeated retries;</li>
<li>misuse;</li>
<li>an automation loop;</li>
<li>lack of user training.</li>
</ul>
<h2>Step 6: Flag risky prompts</h2>
<p>Now we can add a simple risk scanner.</p>
<p>This is not a full data-loss-prevention system. It is a basic first layer that flags prompts containing sensitive terms or patterns.</p>
<pre><code class="language-python">import re

risk_patterns = {
    &quot;payment_card&quot;: r&quot;b(?:d[ -]*?){13,16}b&quot;,
    &quot;bank_account&quot;: r&quot;bd{8}b&quot;,
    &quot;confidential_terms&quot;: r&quot;b(confidential|private|secret|internal only)b&quot;,
    &quot;employee_data&quot;: r&quot;b(employee|performance note|salary|disciplinary)b&quot;,
    &quot;legal_content&quot;: r&quot;b(contract|liability|clause|legal)b&quot;
}</code></pre>
<p>Now apply the patterns to each prompt.</p>
<pre><code class="language-python">def detect_risks(text):
    if pd.isna(text):
        return []

    detected = []
    text = str(text).lower()

    for risk_name, pattern in risk_patterns.items():
        if re.search(pattern, text, flags=re.IGNORECASE):
            detected.append(risk_name)

    return detected

df[&quot;risk_flags&quot;] = df[&quot;prompt&quot;].apply(detect_risks)
df[&quot;risk_count&quot;] = df[&quot;risk_flags&quot;].apply(len)
df[&quot;has_risk&quot;] = df[&quot;risk_count&quot;] &gt; 0

print(df[[&quot;request_id&quot;, &quot;workflow&quot;, &quot;prompt&quot;, &quot;risk_flags&quot;]])</code></pre>
<p>This gives us a basic view of which prompts may need review.</p>
<h2>Step 7: Create a risk summary</h2>
<p>We can now summarise risk by workflow.</p>
<pre><code class="language-python">risk_summary = (
    df.groupby(&quot;workflow&quot;)
    .agg(
        total_requests=(&quot;request_id&quot;, &quot;count&quot;),
        risky_requests=(&quot;has_risk&quot;, &quot;sum&quot;),
        estimated_cost_usd=(&quot;estimated_cost_usd&quot;, &quot;sum&quot;)
    )
    .reset_index()
)

risk_summary[&quot;risk_rate&quot;] = (
    risk_summary[&quot;risky_requests&quot;] / risk_summary[&quot;total_requests&quot;]
)

risk_summary = risk_summary.sort_values(&quot;risk_rate&quot;, ascending=False)

print(risk_summary)</code></pre>
<p>This helps identify which workflows are most likely to involve sensitive or high-risk content.</p>
<p>For example:</p>
<ul>
<li>HR workflows may contain employee data.</li>
<li>Finance workflows may contain bank details.</li>
<li>Legal workflows may contain contracts.</li>
<li>Customer support workflows may contain personal information.</li>
</ul>
<p>The goal is not to block every request. The goal is to understand which workflows need stronger controls.</p>
<h2>Step 8: Add a simple priority score</h2>
<p>A useful monitoring tool should help teams decide what to review first.</p>
<p>We can create a simple priority score based on:</p>
<ul>
<li>risk count;</li>
<li>estimated cost;</li>
<li>model used;</li>
<li>workflow type.</li>
</ul>
<pre><code class="language-python">high_risk_workflows = [&quot;finance&quot;, &quot;legal_review&quot;, &quot;hr_policy&quot;]

def calculate_priority(row):
    score = 0

    # Risk flags
    score += row[&quot;risk_count&quot;] * 3

    # Expensive model
    if row[&quot;model&quot;] == &quot;gpt-4&quot;:
        score += 2

    # High-risk workflow
    if row[&quot;workflow&quot;] in high_risk_workflows:
        score += 3

    # Higher token usage
    if row[&quot;total_tokens&quot;] &gt; 100:
        score += 1

    return score

df[&quot;priority_score&quot;] = df.apply(calculate_priority, axis=1)

review_queue = df.sort_values(&quot;priority_score&quot;, ascending=False)

print(review_queue[[
    &quot;request_id&quot;,
    &quot;workflow&quot;,
    &quot;model&quot;,
    &quot;total_tokens&quot;,
    &quot;estimated_cost_usd&quot;,
    &quot;risk_flags&quot;,
    &quot;priority_score&quot;
]])</code></pre>
<p>This creates a lightweight review queue.</p>
<p>Requests with higher scores may need:</p>
<ul>
<li>manual review;</li>
<li>prompt rewriting;</li>
<li>workflow restrictions;</li>
<li>user training;</li>
<li>model downgrade;</li>
<li>stronger data controls.</li>
</ul>
<h2>Step 9: Generate a simple report</h2>
<p>Now we can generate a short summary.</p>
<pre><code class="language-python">total_requests = len(df)
total_cost = df[&quot;estimated_cost_usd&quot;].sum()
risky_requests = df[&quot;has_risk&quot;].sum()
risk_rate = risky_requests / total_requests

print(&quot;LLM Usage Summary&quot;)
print(&quot;-----------------&quot;)
print(f&quot;Total requests: {total_requests}&quot;)
print(f&quot;Estimated cost: ${total_cost:.4f}&quot;)
print(f&quot;Risky requests: {risky_requests}&quot;)
print(f&quot;Risk rate: {risk_rate:.1%}&quot;)

print(&quot;nTop workflows by cost:&quot;)
print(workflow_cost.head())

print(&quot;nHighest priority requests:&quot;)
print(review_queue[[
    &quot;request_id&quot;,
    &quot;workflow&quot;,
    &quot;model&quot;,
    &quot;risk_flags&quot;,
    &quot;priority_score&quot;
]].head())</code></pre>
<p>This kind of report can be run daily or weekly.</p>
<p>It can also be exported to CSV.</p>
<pre><code class="language-python">workflow_cost.to_csv(&quot;workflow_cost_summary.csv&quot;, index=False)
risk_summary.to_csv(&quot;workflow_risk_summary.csv&quot;, index=False)
review_queue.to_csv(&quot;llm_review_queue.csv&quot;, index=False)</code></pre>
<h2>Step 10: Visualise cost by workflow</h2>
<p>A simple bar chart can make the result easier to understand.</p>
<pre><code class="language-python">import matplotlib.pyplot as plt

plt.figure(figsize=(10, 6))
plt.bar(workflow_cost[&quot;workflow&quot;], workflow_cost[&quot;estimated_cost_usd&quot;])
plt.xlabel(&quot;Workflow&quot;)
plt.ylabel(&quot;Estimated cost in USD&quot;)
plt.title(&quot;Estimated LLM cost by workflow&quot;)
plt.xticks(rotation=45, ha=&quot;right&quot;)
plt.tight_layout()
plt.show()</code></pre>
<p>You can also visualise risky requests.</p>
<pre><code class="language-python">plt.figure(figsize=(10, 6))
plt.bar(risk_summary[&quot;workflow&quot;], risk_summary[&quot;risky_requests&quot;])
plt.xlabel(&quot;Workflow&quot;)
plt.ylabel(&quot;Risky requests&quot;)
plt.title(&quot;Risky LLM requests by workflow&quot;)
plt.xticks(rotation=45, ha=&quot;right&quot;)
plt.tight_layout()
plt.show()</code></pre>
<p>These charts are simple, but they are enough to start a conversation with product, data, security, or finance teams.</p>
<h2>What this prototype shows</h2>
<p>This small project gives us a basic visibility layer around LLM usage.</p>
<p>It helps answer:</p>
<ul>
<li>Which workflows use the most tokens?</li>
<li>Which workflows cost the most?</li>
<li>Which users generate the most usage?</li>
<li>Which prompts contain risky content?</li>
<li>Which requests should be reviewed first?</li>
</ul>
<p>This is not a complete AI governance system, but it is a practical starting point.</p>
<p>Many organisations do not need a complex platform on day one. They need a simple way to see what is happening.</p>
<h2>How to improve this further</h2>
<p>This prototype can be extended in many ways.</p>
<h3>Use real token counts</h3>
<p>Instead of estimating tokens by character length, collect actual token usage from the model provider.</p>
<h3>Add user and team metadata</h3>
<p>Join request logs with team, department, or cost-centre data.</p>
<h3>Track success and failure rates</h3>
<p>Add fields such as:</p>
<ul>
<li>error type;</li>
<li>retry count;</li>
<li>latency;</li>
<li>timeout;</li>
<li>fallback model;</li>
<li>user feedback.</li>
</ul>
<h3>Add data classification</h3>
<p>Use a more advanced scanner to detect:</p>
<ul>
<li>personally identifiable information;</li>
<li>financial data;</li>
<li>health data;</li>
<li>legal data;</li>
<li>customer records;</li>
<li>source code;</li>
<li>credentials.</li>
</ul>
<h3>Build a Streamlit dashboard</h3>
<p>A simple Streamlit interface could show:</p>
<ul>
<li>total cost;</li>
<li>cost by workflow;</li>
<li>cost by user;</li>
<li>risky prompts;</li>
<li>review queue;</li>
<li>model usage;</li>
<li>trend over time.</li>
</ul>
<h3>Store logs in a database</h3>
<p>Instead of using CSV files, store logs in PostgreSQL, BigQuery, Snowflake, or another analytics database.</p>
<h3>Add governance actions</h3>
<p>For high-risk requests, the system could:</p>
<ul>
<li>flag for review;</li>
<li>block the request;</li>
<li>redact sensitive values;</li>
<li>require approval;</li>
<li>route to a safer model;</li>
<li>warn the user before submission.</li>
</ul>
<h2>Final thoughts</h2>
<p>AI systems do not become production-ready just because the model works.</p>
<p>They need visibility.</p>
<p>Teams need to understand how data moves, which workflows create risk, where costs are coming from, and which requests require human review.</p>
<p>A simple Python monitoring layer can provide that first level of visibility. It does not need to be perfect. It just needs to make invisible problems visible.</p>
<p>For teams adopting LLMs, that is often the difference between an exciting prototype and a system that can actually be trusted in production.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/building-a-simple-llm-cost-and-risk-visibility-tool-in-python/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/building-a-simple-llm-cost-and-risk-visibility-tool-in-python/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Mapping a Hospital Desert in R</title>
		<link>https://datascienceplus.com/mapping-a-hospital-desert-in-r/</link>
					<comments>https://datascienceplus.com/mapping-a-hospital-desert-in-r/#respond</comments>
		
		<dc:creator><![CDATA[Loess]]></dc:creator>
		<pubDate>Sat, 15 Aug 2026 15:02:18 +0000</pubDate>
				<category><![CDATA[Visualizing Data]]></category>
		<category><![CDATA[Data Visualisation]]></category>
		<category><![CDATA[ggplot2]]></category>
		<category><![CDATA[Maps]]></category>
		<category><![CDATA[spatial]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32954</guid>

					<description><![CDATA[A &#34;hospital desert&#34; map measures, for each neighborhood, the distance to the nearest hospital. You have probably seen one: they are a staple of…]]></description>
										<content:encoded><![CDATA[<p>A &quot;hospital desert&quot; map measures, for each neighborhood, the distance to the nearest hospital. You have probably seen one: they are a staple of health-access reporting, and Nevada is the usual poster child. The shading always looks the same: a huge dark interior with two small pale dots on it.</p>
<p>I kept wondering how much of that dark interior anyone actually lives in, so I built the map myself, for Nevada, and then measured the thing the shading hides. Nevada really does look like one of the worst hospital deserts in the country, even though almost everyone in it lives in Las Vegas or Reno, a few minutes from an emergency room. The fix is to weight the same result by population instead of by area, and the difference turns out to be the whole story. And because a gap measured in one state could be waved off as just how the measurement behaves, I run the identical pipeline on Massachusetts, a state with the opposite geography, and let the two results face each other.</p>
<p>Along the way you get a reusable toolkit: pulling a facility list from an API, geocoding each facility from its street address, measuring distance to the nearest one with <a href="https://r-spatial.github.io/sf/"><code>sf</code></a>, and weighting a spatial result by population instead of by area. Everything is open data and every package is on CRAN, and the whole pipeline needs no API key at all.</p>
<pre><code class="language-r">library(httr2)
library(dplyr)
library(tidyr)
library(zipcodeR)
library(tidygeocoder)
library(sf)
library(tigris)
library(ggplot2)
</code></pre>
<h2>Nevada hospitals data</h2>
<p>The Centers for Medicare &amp; Medicaid Services publishes a <a href="https://data.cms.gov/provider-data/dataset/xubh-q36u">Hospital General Information</a> file listing every hospital in the country. It is queryable as JSON, and its datastore API takes a <code>conditions</code> filter, so I ask for Nevada only and get the whole state&#8217;s list in one page, no paging loop.</p>
<pre><code class="language-r">url &lt;- &quot;https://data.cms.gov/provider-data/api/1/datastore/query/xubh-q36u/0&quot;

hosp_raw &lt;- request(url) |&gt;
  req_url_query(
    limit = 500,
    `conditions[0][property]` = &quot;state&quot;,
    `conditions[0][operator]` = &quot;=&quot;,
    `conditions[0][value]` = &quot;NV&quot;
  ) |&gt;
  req_perform() |&gt;
  resp_body_json() |&gt;
  (\(x) bind_rows(lapply(x$results, as_tibble)))()

nrow(hosp_raw)
</code><em>## [1] 46
</em></pre>
<p>That is every hospital in Nevada. But &quot;hospital&quot; is a broad label, and I want <em>emergency</em> care. The file carries an <code>emergency_services</code> flag and a <code>hospital_type</code>, so I keep the emergency-capable set: general acute-care, critical-access, and rural-emergency hospitals that report an ER.</p>
<pre><code class="language-r">er_types &lt;- c(
  &quot;Acute Care Hospitals&quot;,
  &quot;Critical Access Hospitals&quot;,
  &quot;Rural Emergency Hospital&quot;
)

er_hosp &lt;- hosp_raw |&gt;
  filter(hospital_type %in% er_types, emergency_services == &quot;Yes&quot;) |&gt;
  mutate(zip5 = substr(zip_code, 1, 5))

nrow(er_hosp)
</code><em>## [1] 34
</em></pre>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/how-happy-is-your-country-visualized/">How Happy is Your Country? — Happy Planet Index Visualized</a></li><li><a href="https://datascienceplus.com/blue-bikes-sharing-in-boston/">Analysis and Visualization of Blue Bikes Sharing in Boston</a></li><li><a href="https://datascienceplus.com/machine-learning-results-one-plot-to-rule-them-all/">Machine Learning Results in R: one plot to rule them all! (Part 1 &#8211; Classification Models)</a></li></ul><h2>Geocoding hospital addresses</h2>
<p>The file gives street addresses but no coordinates, so I have to geocode them. The US Census runs a free, keyless <a href="https://geocoding.geo.census.gov/">batch geocoder</a>, and <a href="https://jessecambon.github.io/tidygeocoder/"><code>tidygeocoder</code></a> talks to it straight from R. With only a few dozen Nevada hospitals this is a single quick call, no batching.</p>
<pre><code class="language-r">geo &lt;- er_hosp |&gt;
  geocode(
    street = address,
    city = citytown,
    state = state,
    postalcode = zip5,
    method = &quot;census&quot;,
    quiet = TRUE
  )
</code></pre>
<p>The Census matcher resolves most of the addresses; the few it misses usually have a PO-box or otherwise unmatchable street line. For those I fall back to the ZIP centroid, which is fine as a minority backstop, with a <code>coalesce()</code>.</p>
<pre><code class="language-r">zip_ll &lt;- zip_code_db |&gt; transmute(zip5 = zipcode, zlat = lat, zlng = lng)

er_geo &lt;- geo |&gt;
  left_join(zip_ll, by = &quot;zip5&quot;) |&gt;
  mutate(
    long = coalesce(long, zlng), # Census miss -&gt; ZIP centroid
    lat = coalesce(lat, zlat)
  ) |&gt;
  filter(!is.na(lat))

nrow(er_geo)
</code><em>## [1] 34
</em></pre>
<p>Now I turn the coordinates into a spatial object and project it. Modern <code>sf</code>, through its <code>s2</code> backend, measures true distances on the sphere even from raw longitude and latitude, so the old rule that you must never compute distance on lon/lat no longer bites. I still project to <a href="https://epsg.io/5070">EPSG:5070</a>, an equal-area continental projection in meters, because the area and centroid calculations later behave in a flat equal-area space and the map draws in the right shape.</p>
<pre><code class="language-r">er &lt;- er_geo |&gt;
  st_as_sf(coords = c(&quot;long&quot;, &quot;lat&quot;), crs = 4326) |&gt;
  st_transform(5070)
</code></pre>
<h2>The desert map</h2>
<p>I pull Nevada&#8217;s census tracts with <a href="https://github.com/walkerke/tigris"><code>tigris</code></a> (geometry only, no survey data). The tract geometry carries <code>ALAND</code>, its land area, which I will need later.</p>
<pre><code class="language-r">tr &lt;- tracts(&quot;Nevada&quot;, cb = TRUE, year = 2022, progress_bar = FALSE) |&gt;
  st_transform(5070)
</code></pre>
<p>The measurement is two <code>sf</code> calls. For each tract centroid, <code>st_nearest_feature()</code> finds the closest emergency-capable hospital, and <code>st_distance(..., by_element = TRUE)</code> returns that one distance. I divide by 1609.34 for miles.</p>
<pre><code class="language-r">ctr &lt;- st_centroid(tr)
i &lt;- st_nearest_feature(ctr, er)
tr$er_mi &lt;- as.numeric(st_distance(ctr, er[i, ], by_element = TRUE)) / 1609.34
</code></pre>
<p>Now the desert map. I shade each tract by its distance on a square-root color scale, so a few very remote tracts do not stretch it and flatten everything else, and I drop a dot on every emergency-capable hospital so you can see where the care actually sits. I use one editorial theme, tinted light gray with the axes stripped away; copy it into your own maps.</p>
<pre class="has-plot"><code class="language-r">rng &lt;- range(tr$er_mi, na.rm = TRUE)

ggplot(tr) +
  geom_sf(aes(fill = er_mi), color = NA) +
  # a dot on every ER hospital: they cluster in the pale near-ER areas
  # (Las Vegas, Reno) and vanish from the dark empty center
  geom_sf(
    data = er,
    inherit.aes = FALSE,
    shape = 21,
    size = 1.1,
    stroke = 0.25,
    fill = &quot;#111111&quot;,
    color = &quot;white&quot;
  ) +
  scale_fill_viridis_c(
    option = &quot;magma&quot;,
    direction = -1,
    transform = &quot;sqrt&quot;,
    limits = rng,
    breaks = c(5, 10, 20, 40),
    name = &quot;Miles to nearest ER  &quot;
  ) +
  guides(
    fill = guide_colorbar(
      barwidth = 14,
      barheight = 0.5,
      title.position = &quot;top&quot;,
      title.hjust = 0.5
    )
  ) +
  labs(
    title = &quot;Nevada: how far is the nearest ER?&quot;,
    subtitle = &quot;Distance from each census tract to the nearest hospital with a general ER&quot;,
    caption = &quot;Data: CMS Hospital General Information&quot;
  ) +
  theme_void(base_size = 12) +
  theme(
    plot.background = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
    legend.position = &quot;bottom&quot;,
    plot.title = element_text(face = &quot;bold&quot;, size = 17),
    plot.subtitle = element_text(color = &quot;grey30&quot;)
  )
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/mapping-hospital-deserts-in-r-map-1.png" alt="plot of chunk map" /></figure>
<p>The pattern looks damning: a vast dark center hundreds of miles from an ER, with the hospital dots huddled into a couple of small clusters. Read as &quot;where hospitals are out of reach,&quot; the map is misleading. The next section shows why.</p>
<h2>The problem: the map colors land, not people</h2>
<p>Look at the dark tracts. They are real, they genuinely are far from an ER, but they are enormous and nearly empty. The people live in the small pale specks: Las Vegas and Reno. A choropleth gives every tract visual weight in proportion to its area, so a thousand square miles of empty desert dominates the image while a dense city takes up a few pixels. The map answers &quot;how much <em>land</em> is far from an ER,&quot; when the question we care about is &quot;how many <em>people</em> are.&quot;</p>
<p>I can answer both with the data already in hand. Census tracts are drawn to hold roughly equal population, so counting tracts is a good stand-in for counting people, and <code>ALAND</code> gives me area. I compute the share of Nevada within 20 miles of an ER weighted two ways: by people (each tract equal) and by land (each tract weighted by area).</p>
<pre><code class="language-r">tr_tab &lt;- st_drop_geometry(tr)

by_people &lt;- mean(tr_tab$er_mi &lt;= 20) * 100
by_land &lt;- sum(tr_tab$ALAND[tr_tab$er_mi &lt;= 20]) / sum(tr_tab$ALAND) * 100

c(by_people = round(by_people), by_land = round(by_land))
</code><em>## by_people   by_land 
##        94        17
</em></pre>
<p>There is the whole story in two numbers. <strong>94% of Nevadans live within 20 miles of an ER, but only 17% of the state&#8217;s land does</strong>, a gap of 78 points. The desert is real as geography and almost empty as a matter of people.</p>
<h2>A control state with the opposite geography</h2>
<p>Two numbers from one state are suggestive, not conclusive. Maybe a gap like that is just what this measurement produces everywhere, and every state&#8217;s map overstates its desert by about the same amount. The way to rule that out is a control with the opposite geography, so I picked Massachusetts: small, dense, and with emergency care spread across the whole state. Every step above is the same handful of calls with a different state code, so I fold the pipeline into one function and run it once more.</p>
<pre><code class="language-r">desert_tracts &lt;- function(abbr, name) {
  er &lt;- request(url) |&gt;
    req_url_query(
      limit = 500,
      `conditions[0][property]` = &quot;state&quot;,
      `conditions[0][operator]` = &quot;=&quot;,
      `conditions[0][value]` = abbr
    ) |&gt;
    req_perform() |&gt;
    resp_body_json() |&gt;
    (\(x) bind_rows(lapply(x$results, as_tibble)))() |&gt;
    filter(hospital_type %in% er_types, emergency_services == &quot;Yes&quot;) |&gt;
    mutate(zip5 = substr(zip_code, 1, 5)) |&gt;
    geocode(
      street = address,
      city = citytown,
      state = state,
      postalcode = zip5,
      method = &quot;census&quot;,
      quiet = TRUE
    ) |&gt;
    left_join(zip_ll, by = &quot;zip5&quot;) |&gt;
    mutate(long = coalesce(long, zlng), lat = coalesce(lat, zlat)) |&gt;
    filter(!is.na(lat)) |&gt;
    st_as_sf(coords = c(&quot;long&quot;, &quot;lat&quot;), crs = 4326) |&gt;
    st_transform(5070)

  tr &lt;- tracts(name, cb = TRUE, year = 2022, progress_bar = FALSE) |&gt;
    st_transform(5070)
  ctr &lt;- st_centroid(tr)
  i &lt;- st_nearest_feature(ctr, er)
  tr$er_mi &lt;- as.numeric(st_distance(ctr, er[i, ], by_element = TRUE)) / 1609.34
  tr$state &lt;- name

  list(er = er, tracts = tr)
}

ma &lt;- desert_tracts(&quot;MA&quot;, &quot;Massachusetts&quot;)
</code></pre>
<p>First, the same desert map for Massachusetts, drawn on the same square-root color scale with Nevada&#8217;s limits, so a shade of color means the same distance in both maps.</p>
<pre class="has-plot"><code class="language-r">ggplot(ma$tracts) +
  geom_sf(aes(fill = er_mi), color = NA) +
  geom_sf(
    data = ma$er,
    inherit.aes = FALSE,
    shape = 21,
    size = 1.1,
    stroke = 0.25,
    fill = &quot;#111111&quot;,
    color = &quot;white&quot;
  ) +
  scale_fill_viridis_c(
    option = &quot;magma&quot;,
    direction = -1,
    transform = &quot;sqrt&quot;,
    limits = rng,
    breaks = c(5, 10, 20, 40),
    name = &quot;Miles to nearest ER  &quot;
  ) +
  guides(
    fill = guide_colorbar(
      barwidth = 14,
      barheight = 0.5,
      title.position = &quot;top&quot;,
      title.hjust = 0.5
    )
  ) +
  labs(
    title = &quot;Massachusetts: how far is the nearest ER?&quot;,
    subtitle = &quot;Same measurement and color scale as the Nevada map&quot;,
    caption = &quot;Data: CMS Hospital General Information&quot;
  ) +
  theme_void(base_size = 12) +
  theme(
    plot.background = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
    legend.position = &quot;bottom&quot;,
    plot.title = element_text(face = &quot;bold&quot;, size = 17),
    plot.subtitle = element_text(color = &quot;grey30&quot;)
  )
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/mapping-hospital-deserts-in-r-map-ma-1.png" alt="plot of chunk map-ma" /></figure>
<p>On Nevada&#8217;s scale Massachusetts never gets near the dark end: its deepest shade is the outer tip of Cape Cod, about 27 miles from an ER, while Nevada&#8217;s remotest tract sits 3.9 times farther out. The 57 emergency-capable hospitals dot the state end to end instead of huddling in two corners. Nothing here tempts a &quot;desert&quot; headline, which is what a control should look like.</p>
<p>Now the same 20-mile shares, for both states side by side.</p>
<pre><code class="language-r">both &lt;- bind_rows(
  tibble(state = &quot;Nevada&quot;, er_mi = tr_tab$er_mi, ALAND = tr_tab$ALAND),
  ma$tracts |&gt; st_drop_geometry() |&gt; select(state, er_mi, ALAND)
)

shares &lt;- both |&gt;
  group_by(state) |&gt;
  summarise(
    by_people = mean(er_mi &lt;= 20) * 100,
    by_land = sum(ALAND[er_mi &lt;= 20]) / sum(ALAND) * 100
  ) |&gt;
  mutate(gap = by_people - by_land)

shares
</code><em>## # A tibble: 2 × 4
##   state         by_people by_land    gap
##   &lt;chr&gt;             &lt;dbl&gt;   &lt;dbl&gt;  &lt;dbl&gt;
## 1 Massachusetts      99.8    99.3  0.486
## 2 Nevada             94.4    16.5 77.8
</em></pre>
<p>Massachusetts barely registers a gap: 99.8% of its people and 99.3% of its land sit within 20 miles of an ER, 0.5 points apart, against Nevada&#8217;s 78. So the gap is not something the measurement manufactures on its own. It is a property of Nevada&#8217;s geography, and it took the control to earn that sentence.</p>
<p>The cumulative curves put the contrast in one picture. For each state I plot the share within a given distance weighted by people next to the same share weighted by land.</p>
<pre class="has-plot"><code class="language-r">curve &lt;- both |&gt;
  group_by(state) |&gt;
  reframe(
    mi = seq(0, 60, 1),
    People = sapply(mi, \(m) mean(er_mi &lt;= m) * 100),
    Land = sapply(mi, \(m) sum(ALAND[er_mi &lt;= m]) / sum(ALAND) * 100)
  ) |&gt;
  pivot_longer(c(People, Land), names_to = &quot;weight&quot;, values_to = &quot;pct&quot;)

ggplot(curve, aes(mi, pct, color = weight)) +
  geom_line(linewidth = 1.1) +
  facet_wrap(~ factor(state, c(&quot;Nevada&quot;, &quot;Massachusetts&quot;))) +
  scale_color_manual(values = c(People = &quot;#0066CC&quot;, Land = &quot;#E8862D&quot;)) +
  labs(
    title = &quot;Same measurement, two states, counted two ways&quot;,
    subtitle = &quot;Share of each state within a distance of an ER, weighted by population vs by land area&quot;,
    x = &quot;Miles to nearest ER&quot;,
    y = &quot;% within&quot;,
    color = NULL,
    caption = &quot;Data: CMS Hospital General Information; tract land area from US Census TIGER&quot;
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.background = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
    panel.background = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
    panel.grid.minor = element_blank(),
    panel.grid.major.x = element_blank(),
    panel.grid.major.y = element_line(color = &quot;grey78&quot;),
    axis.ticks = element_blank(),
    strip.text = element_text(face = &quot;bold&quot;),
    plot.title = element_text(face = &quot;bold&quot;),
    plot.subtitle = element_text(color = &quot;grey30&quot;),
    legend.position = &quot;bottom&quot;
  )
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/mapping-hospital-deserts-in-r-curve-1.png" alt="plot of chunk curve" /></figure>
<p>In the Nevada panel the blue people line snaps up to near 100% within a few miles while the orange land line crawls: the signature of a place that looks empty on a map but lives in a couple of cities. In Massachusetts the two lines climb together and are finished by about 20 miles, because when people and hospitals spread over the land together there is nothing for the two weightings to disagree about. The divergence, not the dark shading, is what a desert of land rather than a desert of people looks like, and it is exactly what the choropleth hides.</p>
<p>(Two caveats worth naming. First, equal-population tracts are an approximation, and rural tracts run a little smaller, so if anything they understate how concentrated the population really is: weighting by an actual population count only widens the gap. Second, each state sees only its own hospitals, so a few border towns that actually rely on an out-of-state ER, Mesquite near St. George, Utah, or Laughlin near Bullhead City, Arizona, read as farther from care than they are. That, too, only overstates the desert.)</p>
<h2>Make it your own</h2>
<p>The pipeline is general: <code>desert_tracts()</code> already takes any state, or point the same steps at any table of facilities, pharmacies, clinics, grocery stores, and <code>st_nearest_feature()</code> measures access to whatever you give it. The last figure is the piece worth carrying past this dataset. Any time you shade a map by a rate or a distance over regions that vary wildly in population, from rural tracts to whole countries, the map is weighting by area, and your eye reads it as importance. Before you call something a desert, weight it by the people who actually live there, and check a place where you expect no desert at all, so you know what agreement looks like. Sometimes the desert stays. In Nevada, I found, almost nobody lives in it.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/mapping-a-hospital-desert-in-r/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/mapping-a-hospital-desert-in-r/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Your clusters are stable. That doesn’t mean they are real</title>
		<link>https://datascienceplus.com/your-clusters-are-stable-that-doesnt-mean-they-are-real/</link>
					<comments>https://datascienceplus.com/your-clusters-are-stable-that-doesnt-mean-they-are-real/#respond</comments>
		
		<dc:creator><![CDATA[Loess]]></dc:creator>
		<pubDate>Sat, 15 Aug 2026 03:17:01 +0000</pubDate>
				<category><![CDATA[Advanced Modeling]]></category>
		<category><![CDATA[Bootstrap]]></category>
		<category><![CDATA[K Means]]></category>
		<category><![CDATA[Machine Learning]]></category>
		<category><![CDATA[Unsupervised Learning]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32942</guid>

					<description><![CDATA[I kept running into the same pattern in clustering tutorials: fit k-means, draw an elbow plot, pick a k, color the scatter, describe the…]]></description>
										<content:encoded><![CDATA[<p>I kept running into the same pattern in clustering tutorials: fit k-means, draw an elbow plot, pick a k, color the scatter, describe the segments. The validation step, when it appears at all, is a silhouette width or a bootstrap stability score, and it always seems to pass.</p>
<p>That bothered me, because k-means will happily partition anything. Hand it a featureless cloud of noise and it returns tidy, well-separated groups. So I wanted to answer a narrow question: <strong>can the standard validation toolkit tell the difference between data that really contains groups and data that does not?</strong> I ran the experiment, and the answer surprised me enough to write it up.</p>
<p>Short version: bootstrap stability, the check most often held up as the rigorous one, could not tell them apart at all.</p>
<h2>The data</h2>
<p>I used the <a href="https://github.com/rfordatascience/tidytuesday/tree/master/data/2020/2020-07-07">Coffee Quality Institute ratings</a> distributed through TidyTuesday: professional cuppers scoring arabica lots on aroma, flavor, aftertaste, acidity, body and balance. It is a good test case because &quot;are there distinct coffee flavor profiles?&quot; is a real question someone would reach for k-means to answer.</p>
<pre><code class="language-r">library(tidyverse)
library(cluster)
library(fpc)
library(diptest)

url &lt;- paste0(&quot;https://raw.githubusercontent.com/rfordatascience/tidytuesday/&quot;,
              &quot;master/data/2020/2020-07-07/coffee_ratings.csv&quot;)

coffee &lt;- read_csv(url, show_col_types = FALSE) |&gt;
  filter(species == &quot;Arabica&quot;) |&gt;
  select(aroma, flavor, aftertaste, acidity, body, balance) |&gt;
  drop_na() |&gt;
  filter(if_all(everything(), ~ .x &gt; 0))   # a few lots scored all zeros

X &lt;- scale(coffee)
dim(X)
</code><em>## [1] 1310    6
</em></pre>
<p>That leaves 1310 coffees on 6 sensory scores. One thing to note before clustering, because it matters later:</p>
<pre><code class="language-r">round(cor(coffee), 2)
</code><em>##            aroma flavor aftertaste acidity body balance
## aroma       1.00   0.74       0.69    0.61 0.55    0.61
## flavor      0.74   1.00       0.86    0.74 0.66    0.73
## aftertaste  0.69   0.86       1.00    0.71 0.67    0.76
## acidity     0.61   0.74       0.71    1.00 0.61    0.64
## body        0.55   0.66       0.67    0.61 1.00    0.67
## balance     0.61   0.73       0.76    0.64 0.67    1.00
</em></pre>
<pre><code class="language-r">pca &lt;- prcomp(X)
round(summary(pca)$importance[2, 1:3], 3)
</code><em>##   PC1   PC2   PC3 
## 0.739 0.079 0.063
</em></pre>
<p>Every score correlates with every other score between 0.55 and 0.86, and the first principal component absorbs 73.9% of the variance with near-identical loadings on all six variables. Coffees that score well on aroma score well on everything. This looks like one general quality dimension rather than distinct profiles, but let us proceed exactly as a tutorial would and see what the diagnostics say.</p>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/k-means-clustering-in-r/">K Means Clustering in R</a></li><li><a href="https://datascienceplus.com/predict-customer-churn-logistic-regression-decision-tree-and-random-forest/">Predict Customer Churn &#8211; Logistic Regression, Decision Tree and Random Forest</a></li><li><a href="https://datascienceplus.com/a-gentle-introduction-on-market-basket-analysis%e2%80%8a-%e2%80%8aassociation-rules/">A Gentle Introduction on Market Basket Analysis — Association Rules</a></li></ul><h2>The standard pipeline</h2>
<pre class="has-plot"><code class="language-r">dsp_colors &lt;- c(&quot;#0066CC&quot;, &quot;#E8862D&quot;, &quot;#159A6C&quot;, &quot;#7D5BD6&quot;,
                &quot;#D64580&quot;, &quot;#2AA9B8&quot;, &quot;#C9A227&quot;)
dsp_theme &lt;- theme_minimal(base_size = 13) +
  theme(plot.background    = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
        panel.background   = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
        panel.grid.minor   = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.major.y = element_line(color = &quot;grey78&quot;),
        axis.ticks         = element_blank(),
        plot.title         = element_text(face = &quot;bold&quot;),
        strip.text         = element_text(face = &quot;bold&quot;))

elbow &lt;- map_dfr(1:10, ~ tibble(
  k   = .x,
  wss = kmeans(X, .x, nstart = 25, iter.max = 50)$tot.withinss
))

ggplot(elbow, aes(k, wss)) +
  geom_line(color = dsp_colors[1], linewidth = 0.9) +
  geom_point(color = dsp_colors[1], size = 2.4) +
  scale_x_continuous(breaks = 1:10) +
  labs(title = &quot;The elbow says three&quot;,
       x = &quot;Number of clusters (k)&quot;,
       y = &quot;Total within-cluster sum of squares&quot;) +
  dsp_theme
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-are-your-clusters-real-elbow-1-1.png" alt="plot of chunk elbow" /></figure>
<p>A textbook elbow at k = 3. The drop in within-cluster sum of squares is 3,354 going from one cluster to two, 1,107 going to three, then only 490 going to four. So k = 3 it is.</p>
<pre><code class="language-r">km3 &lt;- kmeans(X, 3, nstart = 25, iter.max = 50)

coffee |&gt;
  mutate(cluster = km3$cluster) |&gt;
  group_by(cluster) |&gt;
  summarise(n = n(), across(everything(), ~ round(mean(.x), 2))) |&gt;
  arrange(flavor)
</code><em>## # A tibble: 3 × 8
##   cluster     n aroma flavor aftertaste acidity  body balance
##     &lt;int&gt; &lt;dbl&gt; &lt;dbl&gt;  &lt;dbl&gt;      &lt;dbl&gt;   &lt;dbl&gt; &lt;dbl&gt;   &lt;dbl&gt;
## 1       2   298  7.23   7.09       6.97    7.19  7.22    7.1 
## 2       1   706  7.58   7.54       7.42    7.53  7.52    7.54
## 3       3   306  7.88   7.89       7.79    7.9   7.83    7.89
</em></pre>
<p>Three clean groups. But look at the summary rows: the clusters are ordered on every single variable at once. Cluster means rise together across aroma, flavor, aftertaste, acidity, body and balance. These are not flavor profiles, they are quality tiers, which is what you get when you cut a one-dimensional gradient into three pieces. The cluster means on PC1 confirm it:</p>
<pre><code class="language-r">round(tapply(pca$x[, 1], km3$cluster, mean), 2)
</code><em>##     1     2     3 
## -0.06  2.82 -2.61
</em></pre>
<h2>The three checks, and how they do</h2>
<p>Now the validation. I ran the three diagnostics that show up most often.</p>
<p><strong>Silhouette width</strong> measures how much closer each point sits to its own cluster than to the next nearest one, averaged over all points. Kaufman and Rousseeuw&#8217;s rule of thumb is that below 0.25 there is no substantial structure, and 0.25 to 0.50 is weak.</p>
<pre><code class="language-r">sil_k &lt;- function(M, k) {
  km &lt;- kmeans(M, k, nstart = 25, iter.max = 50)
  mean(silhouette(km$cluster, dist(M))[, 3])
}
coffee_sil &lt;- sil_k(X, 3)
round(coffee_sil, 3)
</code><em>## [1] 0.303
</em></pre>
<p><strong>The gap statistic</strong> compares the within-cluster dispersion to what you would get on a structureless reference distribution, and <code>cluster::clusGap</code> picks a k for you.</p>
<pre><code class="language-r">gap_coffee &lt;- clusGap(X, FUN = kmeans, nstart = 25, K.max = 8, B = 50)
maxSE(gap_coffee$Tab[, &quot;gap&quot;], gap_coffee$Tab[, &quot;SE.sim&quot;], method = &quot;firstSEmax&quot;)
</code><em>## [1] 3
</em></pre>
<pre><code class="language-r">round(gap_coffee$Tab[, &quot;gap&quot;], 3)
</code><em>## [1] 1.138 1.200 1.248 1.250 1.251 1.245 1.235 1.223
</em></pre>
<p><strong>Bootstrap stability</strong> is the one usually presented as the serious check. <code>fpc::clusterboot</code> resamples the data, re-clusters, matches the new clusters to the original ones and reports a mean Jaccard similarity per cluster. Above 0.85 is conventionally called highly stable.</p>
<pre><code class="language-r">jac_k &lt;- function(M, k, seed) {
  cb &lt;- clusterboot(M, B = 100, clustermethod = kmeansCBI, krange = k,
                    runs = 25, count = FALSE, seed = seed)
  mean(cb$bootmean)
}
coffee_jac &lt;- jac_k(X, 3, seed = 7)
round(coffee_jac, 3)
</code><em>## [1] 0.963
</em></pre>
<p>So: silhouette 0.303 (weak, but tutorials publish worse), gap statistic endorsing k = 3, and a bootstrap Jaccard of 0.963, comfortably in &quot;highly stable&quot; territory. Two of the three checks pass, and the one that is lukewarm would not stop most people.</p>
<h2>The control</h2>
<p>Here is the part I actually wanted to run. I generated a dataset with the same number of rows, the same number of variables and the same covariance matrix as the coffee scores, drawn from a single multivariate normal. By construction it contains <strong>zero</strong> clusters. Then I put it through the identical pipeline.</p>
<pre><code class="language-r">null_like &lt;- function(M) {
  N &lt;- MASS::mvrnorm(nrow(M), mu = rep(0, ncol(M)), Sigma = cov(M))
  colnames(N) &lt;- colnames(M)
  N
}
X_null &lt;- null_like(X)
</code></pre>
<pre class="has-plot"><code class="language-r">project &lt;- function(M, label) {
  pc &lt;- prcomp(M)$x
  tibble(PC1 = pc[, 1], PC2 = pc[, 2], data = label,
         cluster = factor(kmeans(M, 3, nstart = 25, iter.max = 50)$cluster))
}

bind_rows(project(X, &quot;Coffee scores (real data)&quot;),
          project(X_null, &quot;Random data, no clusters at all&quot;)) |&gt;
  ggplot(aes(PC1, PC2, color = cluster)) +
  geom_point(alpha = 0.55, size = 1.2) +
  facet_wrap(~ data) +
  scale_color_manual(values = dsp_colors) +
  labs(title = &quot;Same pipeline, same picture&quot;, x = &quot;PC1&quot;, y = &quot;PC2&quot;) +
  dsp_theme +
  theme(legend.position = &quot;none&quot;)
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-are-your-clusters-real-nullplot-1-1.png" alt="plot of chunk nullplot" /></figure>
<p>Both panels show three crisp, well-separated, roughly equal bands. The right panel is pure noise. Nothing in that picture distinguishes the real data from the fake, because in both cases k-means is doing the same thing: slicing a single elongated blob into three along its longest axis.</p>
<p>The diagnostics on the noise:</p>
<pre><code class="language-r">c(silhouette = round(sil_k(X_null, 3), 3),
  jaccard    = round(jac_k(X_null, 3, seed = 7), 3))
</code><em>## silhouette    jaccard 
##      0.273      0.925
</em></pre>
<p>A structureless cloud scores about as well as the coffee data on silhouette and just as well on bootstrap stability.</p>
<h2>Calibrating against the null</h2>
<p>One draw could be luck, so I repeated it. Twenty null datasets, each matched to the real data&#8217;s size and covariance, each pushed through the same two checks. I did the same for a positive control: the <a href="https://allisonhorst.github.io/palmerpenguins/">palmerpenguins</a> body measurements, where three real species are known to be present.</p>
<pre><code class="language-r">penguins_X &lt;- palmerpenguins::penguins |&gt;
  drop_na(bill_length_mm, bill_depth_mm, flipper_length_mm, body_mass_g) |&gt;
  select(bill_length_mm, bill_depth_mm, flipper_length_mm, body_mass_g) |&gt;
  scale()

calibrate &lt;- function(M, k, R = 20) {
  map_dfr(1:R, function(i) {
    N &lt;- null_like(M)
    tibble(rep = i, silhouette = sil_k(N, k), jaccard = jac_k(N, k, seed = i))
  })
}

null_coffee   &lt;- calibrate(X, 3)
null_penguins &lt;- calibrate(penguins_X, 3)

observed &lt;- tibble(
  data       = c(&quot;Coffee&quot;, &quot;Penguins&quot;),
  silhouette = c(coffee_sil, sil_k(penguins_X, 3)),
  jaccard    = c(coffee_jac, jac_k(penguins_X, 3, seed = 7))
)
observed |&gt; mutate(across(where(is.numeric), ~ round(.x, 3)))
</code><em>## # A tibble: 2 × 3
##   data     silhouette jaccard
##   &lt;chr&gt;         &lt;dbl&gt;   &lt;dbl&gt;
## 1 Coffee        0.303   0.963
## 2 Penguins      0.447   0.965
</em></pre>
<pre class="has-plot"><code class="language-r">nulls &lt;- bind_rows(mutate(null_coffee, data = &quot;Coffee&quot;),
                   mutate(null_penguins, data = &quot;Penguins&quot;)) |&gt;
  pivot_longer(c(silhouette, jaccard), names_to = &quot;index&quot;, values_to = &quot;null&quot;)

obs_long &lt;- observed |&gt;
  pivot_longer(c(silhouette, jaccard), names_to = &quot;index&quot;, values_to = &quot;obs&quot;)

labels &lt;- c(silhouette = &quot;Silhouette width&quot;, jaccard = &quot;Bootstrap Jaccard&quot;)

ggplot(nulls, aes(y = data)) +
  geom_point(aes(x = null), color = &quot;grey55&quot;, alpha = 0.6, size = 1.8) +
  geom_point(data = obs_long, aes(x = obs), color = dsp_colors[2], size = 4) +
  facet_wrap(~ index, scales = &quot;free_x&quot;, labeller = labeller(index = labels)) +
  labs(title = &quot;Observed value (orange) against 20 null datasets (grey)&quot;,
       x = NULL, y = NULL) +
  dsp_theme +
  theme(panel.grid.major.y = element_blank(),
        panel.grid.major.x = element_line(color = &quot;grey78&quot;))
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-are-your-clusters-real-calibplot-1-1.png" alt="plot of chunk calibplot" /></figure>
<p>This is the result worth taking away. On <strong>bootstrap Jaccard</strong>, the orange dots are buried in the grey. The coffee continuum scores 0.963, squarely inside its own null range of 0.911 to 0.977. The penguins, which genuinely contain three species, score 0.965 against a null range of 0.82 to 0.956, clearing the noise by 0.009. Real groups, an artificial cut through a gradient, and pure noise all land in the same narrow band above 0.9, and the ordering between them is meaningless.</p>
<p>That is not a bug in <code>clusterboot</code>. It is what the statistic measures. Jaccard stability asks whether the algorithm reproduces the same partition on resampled data, and a smooth elongated cloud has extremely reproducible cut points, precisely because there is nothing there to make the boundary wobble. High stability is evidence that your k-means run is deterministic, not that your clusters exist.</p>
<p><strong>Silhouette</strong> does better, but only once it is calibrated. Raw, the coffee value of 0.303 and the penguin value of 0.447 are both &quot;weak&quot; by the usual thresholds. Against their own nulls the picture separates: coffee clears its null range (0.263 to 0.295) by only 0.008, while the penguins beat their null range (0.254 to 0.286) by 0.161, a margin roughly 19 times larger. The absolute number was uninformative. The comparison was informative.</p>
<p>A note on the gap statistic, which I left out of that plot because it behaves differently:</p>
<pre><code class="language-r">gap_k &lt;- function(M) {
  g &lt;- clusGap(M, FUN = kmeans, nstart = 25, K.max = 8, B = 50)
  maxSE(g$Tab[, &quot;gap&quot;], g$Tab[, &quot;SE.sim&quot;], method = &quot;firstSEmax&quot;)
}
c(noise = gap_k(X_null), coffee = gap_k(X), penguins = gap_k(penguins_X))
</code><em>##    noise   coffee penguins 
##        1        3        5
</em></pre>
<p>Run on the pure noise it correctly returns k = 1, so it is not useless. But it endorsed k = 3 on the coffee continuum, and on the penguins it misses the three species. Its reference distribution is a uniform box, so it responds to any departure from uniformity, skewness included, and not to clustering specifically.</p>
<h2>What did work</h2>
<p>If the question is &quot;are there groups&quot;, then a better thing to test is whether the data is multimodal along the direction that carries the structure. Hartigan and Hartigan&#8217;s dip test does exactly that, and <code>diptest::dip.test</code> is one line.</p>
<pre><code class="language-r">dip_coffee   &lt;- dip.test(prcomp(X)$x[, 1])
dip_penguins &lt;- dip.test(prcomp(penguins_X)$x[, 1])
c(coffee = dip_coffee$p.value, penguins = dip_penguins$p.value)
</code><em>##       coffee     penguins 
## 8.544241e-01 2.465773e-05
</em></pre>
<pre class="has-plot"><code class="language-r">bind_rows(
  tibble(pc1 = scale(prcomp(X)$x[, 1])[, 1],
         data = &quot;Coffee: one bump&quot;),
  tibble(pc1 = scale(prcomp(penguins_X)$x[, 1])[, 1],
         data = &quot;Penguins: two bumps&quot;)
) |&gt;
  ggplot(aes(pc1)) +
  geom_density(fill = dsp_colors[1], color = NA, alpha = 0.75) +
  facet_wrap(~ data, scales = &quot;free_y&quot;) +
  labs(title = &quot;The check that separated them&quot;,
       x = &quot;First principal component (scaled)&quot;, y = &quot;Density&quot;) +
  dsp_theme
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/08/loess-are-your-clusters-real-dipplot-1-1.png" alt="plot of chunk dipplot" /></figure>
<p>The coffee scores give p = 0.85, entirely consistent with a single bump. The penguins give p = 2.5e-05. That is the separation the other two indices failed to make, from a test that took a fraction of a second.</p>
<p>The honest caveat: the dip test here looks only at PC1. Groups could separate along some other direction and this would miss them, so on a real problem run it on the first few components, or on the pairwise distance distribution.</p>
<h2>What I would do instead</h2>
<p>Three things came out of this that I will apply from now on.</p>
<p><strong>Always run a matched null.</strong> <code>null_like()</code> above is four lines. Generate data with your data&#8217;s covariance and no clusters, run your entire pipeline on it, and report your index next to that reference. Any index without a null is a number without a scale, and as this showed, the thresholds people quote from memory can be met by noise.</p>
<p><strong>Do not treat stability as evidence of existence.</strong> Bootstrap Jaccard is a genuinely useful statistic for a different question: given that groups exist, which of my clusters are solid and which are an artifact of a few points. Reported as proof that the groups are real, it will agree with you no matter what.</p>
<p><strong>Set <code>runs</code> when you call <code>clusterboot</code>.</strong> <code>kmeansCBI</code> defaults to <code>runs = 1</code>, a single random start per bootstrap replicate. On the penguins that default made the Jaccard swing between roughly 0.63 and 0.86 depending only on the seed, because k-means kept landing in local optima. With <code>runs = 25</code> it is stable across seeds. If you have ever seen a stability score change when you changed the seed, this is likely why.</p>
<p>And when the data really is a continuum, as the coffee scores are, cutting it into three is still a perfectly reasonable thing to do. Quality tiers are useful. Just describe them as what they are, thresholds on a gradient that you chose, rather than as types you discovered. The difference matters to whoever reads the result next and assumes the groups were out there waiting.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/your-clusters-are-stable-that-doesnt-mean-they-are-real/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/your-clusters-are-stable-that-doesnt-mean-they-are-real/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>How to import NHANES data and run a linear and logistic regression, in Python and R</title>
		<link>https://datascienceplus.com/how-to-import-nhanes-data-and-run-a-linear-and-logistic-regression-in-python-and-r/</link>
					<comments>https://datascienceplus.com/how-to-import-nhanes-data-and-run-a-linear-and-logistic-regression-in-python-and-r/#respond</comments>
		
		<dc:creator><![CDATA[Klodian Dhana]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 02:39:08 +0000</pubDate>
				<category><![CDATA[Regression Models]]></category>
		<category><![CDATA[Import Data]]></category>
		<category><![CDATA[Linear Regression]]></category>
		<category><![CDATA[Logistic Regression]]></category>
		<category><![CDATA[NHANES]]></category>
		<category><![CDATA[Python]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32934</guid>

					<description><![CDATA[This post shows, side by side, the Python and R code to import data from the US National Health and Nutrition Examination Survey (NHANES)…]]></description>
										<content:encoded><![CDATA[<p>This post shows, side by side, the Python and R code to import data from the US National Health and Nutrition Examination Survey (NHANES) and run a linear and a logistic regression. NHANES is a complex survey with sampling weights, so an estimate meant for the US population has to account for its design. I first fit the models the naive way, treating every row equally, then add the survey design so the standard errors and coefficients are correct for the population.</p>
<p>The R code needs three packages:</p>
<pre><code class="language-r">library(haven) # read SAS .xpt files
library(dplyr) # data wrangling
library(survey) # complex-survey models
</code></pre>
<h2>Import the data</h2>
<p>Each NHANES component is a SAS transport file (<code>.xpt</code>), which both languages read directly from the CDC for this post. I pull four files from the 2017 to 2018 cycle: demographics (which also holds the survey-design columns), body measures, blood pressure, and glycohemoglobin (HbA1c).</p>
<p><strong>Python</strong> uses <code>pandas.read_sas</code>:</p>
<pre><code class="language-python">import pandas as pd
import numpy as np
import urllib.request
import io

base = &quot;https://wwwn.cdc.gov/Nchs/Data/Nhanes/Public/2017/DataFiles/&quot;

def nhanes(f):
    req = urllib.request.Request(base + f + &quot;.xpt&quot;,
                                 headers={&quot;User-Agent&quot;: &quot;Mozilla/5.0&quot;})
    raw = urllib.request.urlopen(req).read()
    return pd.read_sas(io.BytesIO(raw), format=&quot;xport&quot;)

demo = nhanes(&quot;DEMO_J&quot;)
bmx  = nhanes(&quot;BMX_J&quot;)
bpx  = nhanes(&quot;BPX_J&quot;)
ghb  = nhanes(&quot;GHB_J&quot;)
demo.shape
</code><em>## (9254, 46)
</em></pre>
<p><strong>R</strong> uses <code>haven::read_xpt</code>:</p>
<pre><code class="language-r">base &lt;- &quot;https://wwwn.cdc.gov/Nchs/Data/Nhanes/Public/2017/DataFiles/&quot;
nhanes &lt;- function(f) read_xpt(paste0(base, f, &quot;.xpt&quot;))

demo &lt;- nhanes(&quot;DEMO_J&quot;)
bmx &lt;- nhanes(&quot;BMX_J&quot;)
bpx &lt;- nhanes(&quot;BPX_J&quot;)
ghb &lt;- nhanes(&quot;GHB_J&quot;)
dim(demo)
</code><em>## [1] 9254   46
</em></pre>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/how-to-incorporate-ml-net-with-algorithmic-trading/">How to Incorporate ML.Net With Algorithmic Trading</a></li><li><a href="https://datascienceplus.com/building-a-logistic-regression-in-python-step-by-step/">Building A Logistic Regression in Python, Step by Step</a></li><li><a href="https://datascienceplus.com/logistic-regression-with-python-using-titanic-data/">Logistic Regression with Python using Titanic data</a></li></ul><h2>Merge and prepare the variables</h2>
<p>Every file shares an id <code>SEQN</code>. I average the four blood-pressure readings, left-join the components onto demographics, rename the predictors, code sex as a label, and derive a diabetes outcome (HbA1c of 6.5% or higher). A missing HbA1c stays missing rather than counting as &quot;no diabetes&quot;.</p>
<p><strong>Python:</strong></p>
<pre><code class="language-python">bpx = bpx.assign(SBP=bpx[[&quot;BPXSY1&quot;, &quot;BPXSY2&quot;, &quot;BPXSY3&quot;, &quot;BPXSY4&quot;]].mean(axis=1))

df = (demo[[&quot;SEQN&quot;, &quot;RIAGENDR&quot;, &quot;RIDAGEYR&quot;,
            &quot;WTMEC2YR&quot;, &quot;SDMVPSU&quot;, &quot;SDMVSTRA&quot;]]
      .merge(bmx[[&quot;SEQN&quot;, &quot;BMXBMI&quot;]], on=&quot;SEQN&quot;, how=&quot;left&quot;)
      .merge(bpx[[&quot;SEQN&quot;, &quot;SBP&quot;]],    on=&quot;SEQN&quot;, how=&quot;left&quot;)
      .merge(ghb[[&quot;SEQN&quot;, &quot;LBXGH&quot;]],  on=&quot;SEQN&quot;, how=&quot;left&quot;))

df = df.rename(columns={&quot;RIDAGEYR&quot;: &quot;age&quot;, &quot;BMXBMI&quot;: &quot;bmi&quot;, &quot;SBP&quot;: &quot;sbp&quot;})
df = df.assign(
    sex      = np.where(df[&quot;RIAGENDR&quot;] == 1, &quot;Male&quot;, &quot;Female&quot;),
    diabetes = np.where(df[&quot;LBXGH&quot;] &gt;= 6.5, 1.0,
               np.where(df[&quot;LBXGH&quot;].notna(), 0.0, np.nan)),
)
adult = df[df[&quot;age&quot;] &gt;= 20]
adult[[&quot;age&quot;, &quot;sex&quot;, &quot;bmi&quot;, &quot;sbp&quot;, &quot;diabetes&quot;]].head()
</code><em>##      age     sex   bmi         sbp  diabetes
## 2   66.0  Female  31.7  200.000000       0.0
## 5   66.0  Female  23.7  142.000000       0.0
## 6   75.0  Female  38.9  118.666667       0.0
## 8   56.0    Male  21.3  101.333333       0.0
## 10  67.0    Male  23.5  104.666667       0.0
</em></pre>
<p><strong>R:</strong></p>
<pre><code class="language-r">bpx &lt;- bpx |&gt;
  mutate(
    SBP = rowMeans(across(c(BPXSY1, BPXSY2, BPXSY3, BPXSY4)), na.rm = TRUE)
  )

df &lt;- demo |&gt;
  select(SEQN, RIAGENDR, RIDAGEYR, WTMEC2YR, SDMVPSU, SDMVSTRA) |&gt;
  left_join(select(bmx, SEQN, BMXBMI), by = &quot;SEQN&quot;) |&gt;
  left_join(select(bpx, SEQN, SBP), by = &quot;SEQN&quot;) |&gt;
  left_join(select(ghb, SEQN, LBXGH), by = &quot;SEQN&quot;) |&gt;
  rename(age = RIDAGEYR, bmi = BMXBMI, sbp = SBP) |&gt;
  mutate(
    sex = if_else(RIAGENDR == 1, &quot;Male&quot;, &quot;Female&quot;),
    diabetes = case_when(LBXGH &gt;= 6.5 ~ 1, !is.na(LBXGH) ~ 0, TRUE ~ NA_real_)
  )
adult &lt;- filter(df, age &gt;= 20)
head(adult[c(&quot;age&quot;, &quot;sex&quot;, &quot;bmi&quot;, &quot;sbp&quot;, &quot;diabetes&quot;)])
</code><em>## # A tibble: 6 × 5
##     age sex      bmi   sbp diabetes
##   &lt;dbl&gt; &lt;chr&gt;  &lt;dbl&gt; &lt;dbl&gt;    &lt;dbl&gt;
## 1    66 Female  31.7  200         0
## 2    66 Female  23.7  142         0
## 3    75 Female  38.9  119.        0
## 4    56 Male    21.3  101.        0
## 5    67 Male    23.5  105.        0
## 6    54 Female  39.9  162         1
</em></pre>
<h2>Linear regression</h2>
<p>I model systolic blood pressure on age, BMI, and sex. Both languages use a formula with a categorical <code>sex</code> (Female is the reference).</p>
<p><strong>Python</strong> (<code>statsmodels</code>, <code>missing=&quot;drop&quot;</code> drops rows with missing data):</p>
<pre><code class="language-python">import statsmodels.formula.api as smf

lin = smf.ols(&quot;sbp ~ age + bmi + C(sex)&quot;, data=adult, missing=&quot;drop&quot;).fit()
lin.params.round(3)
</code><em>## Intercept         87.734
## C(sex)[T.Male]     1.691
## age                0.528
## bmi                0.378
## dtype: float64
</em></pre>
<p><strong>R</strong> (<code>lm</code> drops incomplete rows by default):</p>
<pre><code class="language-r">lin &lt;- lm(sbp ~ age + bmi + sex, data = adult)
round(coef(lin), 3)
</code><em>## (Intercept)         age         bmi     sexMale 
##      87.734       0.528       0.378       1.691
</em></pre>
<p><code>lin.summary()</code> in Python and <code>summary(lin)</code> in R print the full table with standard errors and p-values.</p>
<p>Both languages report about 0.53 mmHg per year of age, 0.38 per BMI point, and 1.7 higher for men.</p>
<h2>Logistic regression</h2>
<p>Same formula for the binary diabetes outcome. The coefficients are on the log-odds scale, so I exponentiate them to read odds ratios.</p>
<p><strong>Python</strong> (<code>smf.logit</code>):</p>
<pre><code class="language-python">logit = smf.logit(&quot;diabetes ~ age + bmi + C(sex)&quot;,
                  data=adult, missing=&quot;drop&quot;).fit(disp=0)
np.exp(logit.params).round(3)
</code><em>## Intercept         0.001
## C(sex)[T.Male]    1.283
## age               1.052
## bmi               1.068
## dtype: float64
</em></pre>
<p><strong>R</strong> (<code>glm</code> with <code>family = binomial</code>):</p>
<pre><code class="language-r">logit &lt;- glm(diabetes ~ age + bmi + sex, data = adult, family = binomial)
round(exp(coef(logit)), 3)
</code><em>## (Intercept)         age         bmi     sexMale 
##       0.001       1.052       1.068       1.283
</em></pre>
<p>Both languages report an odds ratio of about 1.05 per year of age, 1.07 per BMI point, and 1.28 for men versus women.</p>
<h2>Add the survey weights</h2>
<p>The fits above treat every row equally. NHANES is a complex survey: it oversamples some groups and samples in clusters, so any estimate meant for the US population needs the sampling weight (<code>WTMEC2YR</code>), the stratum (<code>SDMVSTRA</code>), and the cluster (<code>SDMVPSU</code>). In both languages I describe the design once, then reuse it, and I build it on the full data and restrict to adults afterward so the design stays intact for correct standard errors.</p>
<p><strong>Python</strong> uses the <a href="https://svylab.com/docs">svy</a> package (built on polars). <code>where=</code> restricts to the adult domain, and <code>family</code> is <code>&quot;gaussian&quot;</code> for the linear model and <code>&quot;binomial&quot;</code> for the logistic one:</p>
<pre><code class="language-python">import polars as pl
import svy

design = svy.Design(stratum=&quot;SDMVSTRA&quot;, psu=&quot;SDMVPSU&quot;, wgt=&quot;WTMEC2YR&quot;)
sample = svy.Sample(pl.from_pandas(df), design)

lin_w = sample.glm.fit(y=&quot;sbp&quot;, x=[&quot;age&quot;, &quot;bmi&quot;, svy.Cat(&quot;sex&quot;, ref=&quot;Female&quot;)],
                       family=&quot;gaussian&quot;, where=svy.col(&quot;age&quot;) &gt;= 20)
{c.term: round(float(c.est), 3) for c in lin_w.coefs}
</code><em>## {'_intercept_': 87.602, 'age': 0.458, 'bmi': 0.418, 'sex_Male': 3.1}
</em></pre>
<pre><code class="language-python">log_w = sample.glm.fit(y=&quot;diabetes&quot;, x=[&quot;age&quot;, &quot;bmi&quot;, svy.Cat(&quot;sex&quot;, ref=&quot;Female&quot;)],
                       family=&quot;binomial&quot;, where=svy.col(&quot;age&quot;) &gt;= 20)
{c.term: round(float(np.exp(c.est)), 3) for c in log_w.coefs}
</code><em>## {'_intercept_': 0.0, 'age': 1.057, 'bmi': 1.08, 'sex_Male': 1.552}
</em></pre>
<p><strong>R</strong> uses the <code>survey</code> package. <code>svydesign</code> sets up the design, <code>subset</code> restricts to adults, and <code>svyglm</code> fits the models (<code>quasibinomial</code> for logistic to avoid a harmless weights warning):</p>
<pre><code class="language-r">options(survey.lonely.psu = &quot;adjust&quot;)
des &lt;- svydesign(
  ids = ~SDMVPSU,
  strata = ~SDMVSTRA,
  weights = ~WTMEC2YR,
  nest = TRUE,
  data = df
)
des_ad &lt;- subset(des, age &gt;= 20)

lin_w &lt;- svyglm(sbp ~ age + bmi + sex, design = des_ad)
round(coef(lin_w), 3)
</code><em>## (Intercept)         age         bmi     sexMale 
##      87.602       0.458       0.418       3.100
</em></pre>
<pre><code class="language-r">log_w &lt;- svyglm(
  diabetes ~ age + bmi + sex,
  design = des_ad,
  family = quasibinomial()
)
round(exp(coef(log_w)), 3)
</code><em>## (Intercept)         age         bmi     sexMale 
##       0.000       1.057       1.080       1.552
</em></pre>
<p>The weighted estimates are population estimates with design-based standard errors, and the two toolkits agree: the male-versus-female blood-pressure gap is about 3.1 mmHg and the diabetes odds ratio for men is about 1.55, in both Python and R.</p>
<p>That&#8217;s it. I hope you find this tutorial useful, and if you have questions, leave a comment below.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/how-to-import-nhanes-data-and-run-a-linear-and-logistic-regression-in-python-and-r/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/how-to-import-nhanes-data-and-run-a-linear-and-logistic-regression-in-python-and-r/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Base R glm() vs tidymodels for Logistic Regression: What You Actually Gain</title>
		<link>https://datascienceplus.com/base-r-glm-vs-tidymodels-for-logistic-regression-what-you-actually-gain/</link>
					<comments>https://datascienceplus.com/base-r-glm-vs-tidymodels-for-logistic-regression-what-you-actually-gain/#respond</comments>
		
		<dc:creator><![CDATA[Klodian Dhana]]></dc:creator>
		<pubDate>Mon, 27 Jul 2026 10:58:36 +0000</pubDate>
				<category><![CDATA[Regression Models]]></category>
		<category><![CDATA[Logistic Regression]]></category>
		<category><![CDATA[NHANES]]></category>
		<category><![CDATA[tidymodels]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32921</guid>

					<description><![CDATA[When you need to run a logistic regression in R, your modeling routine is probably three base functions: glm() to fit, predict() to score,…]]></description>
										<content:encoded><![CDATA[<p>When you need to run a logistic regression in R, your modeling routine is probably three base functions: <code>glm()</code> to fit, <code>predict()</code> to score, and <em>broom&#8217;s</em> <code>tidy()</code> to turn the fit into a table of odds ratios. Meanwhile <em>tidymodels</em> keeps showing up, and it can replace every one of those functions. The fair questions are: what actually changes when you swap them, what does the rest of the framework add that base R does not, and does any of it earn a place in epidemiological research?</p>
<p>Let&#8217;s answer all three on real data. First I replace <code>glm()</code>, <code>predict()</code>, and <code>tidy()</code> with their tidymodels equivalents one for one and see what is different (mostly the output, not the math). Then I show what tidymodels adds <em>around</em> the model, the part base R has no direct function for. Finally I weigh whether it is worth using for everyday epidemiological research.</p>
<h2>The data: predicting diabetes in NHANES</h2>
<p>I use <a href="https://cran.r-project.org/package=NHANES" target="_blank" rel="noopener">NHANES <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2197.png" alt="↗" class="wp-smiley" style="height: 1em; max-height: 1em;" /></a>, the US National Health and Nutrition Examination Survey, which ships as an R package. NHANES is a <em>complex survey</em> with sampling weights, and any valid population estimate has to carry those weights (using the <a href="https://cran.r-project.org/package=survey" target="_blank" rel="noopener">survey <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2197.png" alt="↗" class="wp-smiley" style="height: 1em; max-height: 1em;" /></a> package and <code>survey::svyglm()</code> in place of <code>glm()</code>). For this tutorial, I keep the example simple and unweighted.</p>
<pre><code class="language-r">library(tidymodels)
library(NHANES)
tidymodels_prefer() # solves naming conflicts in R by making tidymodels functions take priority over other packages
</code></pre>
<pre><code class="language-r">data(NHANES)
nh &lt;- NHANES |&gt;
  filter(Age &gt;= 18) |&gt;
  select(
    Diabetes,
    Age,
    BMI,
    Gender,
    PhysActive,
    BPSysAve,
    TotChol,
    DirectChol,
    Poverty,
    Smoke100
  ) |&gt;
  filter(!is.na(Diabetes)) |&gt;
  mutate(Diabetes = factor(Diabetes, levels = c(&quot;No&quot;, &quot;Yes&quot;))) |&gt;
  distinct() # NHANES resampled some individuals; drop exact duplicate rows

head(nh)
</code><em>## # A tibble: 6 × 10
##   Diabetes   Age   BMI Gender PhysActive BPSysAve TotChol DirectChol Poverty Smoke100
##   &lt;fct&gt;    &lt;int&gt; &lt;dbl&gt; &lt;fct&gt;  &lt;fct&gt;         &lt;int&gt;   &lt;dbl&gt;      &lt;dbl&gt;   &lt;dbl&gt; &lt;fct&gt;   
## 1 No          34  32.2 male   No              113    3.49       1.29    1.36 Yes     
## 2 No          49  30.6 female No              112    6.7        1.16    1.91 Yes     
## 3 No          45  27.2 female Yes             118    5.82       2.12    5    No      
## 4 No          66  23.7 male   Yes             111    4.99       0.67    2.2  Yes     
## 5 No          58  23.7 male   Yes             104    4.24       0.96    5    No      
## 6 No          54  26.0 male   Yes             134    6.41       1.16    2.2  No
</em></pre>
<p>That leaves 4,834 distinct adults, 11.3% with diagnosed diabetes. The <code>distinct()</code> matters more than it looks: the NHANES <em>package</em> is a teaching sample that resampled some individuals, so the raw extract carries many exact duplicate rows. Leave them in and copies of one person would land on both sides of the train/test split I make later, quietly flattering any out-of-sample estimate, so I keep one row per person.</p>
<p>Note the <code>factor(..., levels = c(&quot;No&quot;, &quot;Yes&quot;))</code>: it is not cosmetic. <code>glm()</code> models the probability of the <strong>last</strong> level, so with <code>No</code> first I am modeling P(diabetes), which is what I want. Reverse it and every odds ratio silently inverts.</p>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/vitamin-d-deficiency-in-the-united-states-nhanes-2001-2010/">Exploring Vitamin D deficiency in the United States: NHANES 2001-2010</a></li><li><a href="https://datascienceplus.com/weight-loss-in-the-u-s-an-analysis-of-nhanes-data-with-tidyverse/">Weight loss in the U.S. &#8211; An analysis of NHANES data with tidyverse</a></li><li><a href="https://datascienceplus.com/perform-logistic-regression-in-r/">How to Perform a Logistic Regression in R</a></li></ul><h2>Replacing the base functions, one for one</h2>
<p><strong>glm() and broom, and their parsnip equivalent</strong></p>
<p>Here is the base-R fit, tidied into odds ratios.</p>
<pre><code class="language-r">fit_glm &lt;- glm(
  Diabetes ~ Age +
    BMI +
    Gender +
    PhysActive +
    BPSysAve +
    TotChol +
    DirectChol +
    Poverty +
    Smoke100,
  data = nh,
  family = binomial
)

tidy(fit_glm, exponentiate = TRUE, conf.int = TRUE) |&gt;
  select(term, OR = estimate, conf.low, conf.high, p.value)
</code><em>## # A tibble: 10 × 5
##    term               OR conf.low conf.high  p.value
##    &lt;chr&gt;           &lt;dbl&gt;    &lt;dbl&gt;     &lt;dbl&gt;    &lt;dbl&gt;
##  1 (Intercept)   0.00218 0.000670   0.00693 7.77e-25
##  2 Age           1.06    1.05       1.06    1.85e-46
##  3 BMI           1.08    1.06       1.09    7.43e-22
##  4 Gendermale    1.14    0.901      1.45    2.68e- 1
##  5 PhysActiveYes 0.965   0.766      1.22    7.61e- 1
##  6 BPSysAve      1.01    1.00       1.02    2.52e- 3
##  7 TotChol       0.780   0.700      0.868   6.34e- 6
##  8 DirectChol    0.531   0.376      0.743   2.70e- 4
##  9 Poverty       0.880   0.820      0.945   4.26e- 4
## 10 Smoke100Yes   1.21    0.975      1.51    8.32e- 2
</em></pre>
<p>Now the same model through parsnip, the tidymodels interface: pick a model type, set an engine, <code>fit()</code>.</p>
<pre><code class="language-r">fit_psn &lt;- logistic_reg() |&gt;
  set_engine(&quot;glm&quot;) |&gt;
  fit(
    Diabetes ~ Age +
      BMI +
      Gender +
      PhysActive +
      BPSysAve +
      TotChol +
      DirectChol +
      Poverty +
      Smoke100,
    data = nh
  )

tidy(fit_psn, exponentiate = TRUE, conf.int = TRUE) |&gt;
  select(term, OR = estimate, conf.low, conf.high, p.value)
</code><em>## # A tibble: 10 × 5
##    term               OR conf.low conf.high  p.value
##    &lt;chr&gt;           &lt;dbl&gt;    &lt;dbl&gt;     &lt;dbl&gt;    &lt;dbl&gt;
##  1 (Intercept)   0.00218 0.000670   0.00693 7.77e-25
##  2 Age           1.06    1.05       1.06    1.85e-46
##  3 BMI           1.08    1.06       1.09    7.43e-22
##  4 Gendermale    1.14    0.901      1.45    2.68e- 1
##  5 PhysActiveYes 0.965   0.766      1.22    7.61e- 1
##  6 BPSysAve      1.01    1.00       1.02    2.52e- 3
##  7 TotChol       0.780   0.700      0.868   6.34e- 6
##  8 DirectChol    0.531   0.376      0.743   2.70e- 4
##  9 Poverty       0.880   0.820      0.945   4.26e- 4
## 10 Smoke100Yes   1.21    0.975      1.51    8.32e- 2
</em></pre>
<p>Identical coefficients, because parsnip called <code>glm()</code> for you and <code>tidy()</code> is the same <code>broom</code>. So far tidymodels has changed nothing.</p>
<p><strong>predict(): the one replacement that actually behaves differently</strong></p>
<p>Prediction is where the swap is not cosmetic. Let&#8217;s score three people with each.</p>
<pre><code class="language-r">newpeople &lt;- nh[c(10, 200, 900), ]
newpeople
</code><em>## # A tibble: 3 × 10
##   Diabetes   Age   BMI Gender PhysActive BPSysAve TotChol DirectChol Poverty Smoke100
##   &lt;fct&gt;    &lt;int&gt; &lt;dbl&gt; &lt;fct&gt;  &lt;fct&gt;         &lt;int&gt;   &lt;dbl&gt;      &lt;dbl&gt;   &lt;dbl&gt; &lt;fct&gt;   
## 1 No          60  25.8 male   No              152    6.39       1.34    1.03 Yes     
## 2 No          22  52.1 male   No              128    3.67       0.88    0.98 No      
## 3 No          45  22.0 female No              114    4.78       1.55    2.03 No
</em></pre>
<pre><code class="language-r"># you must remember type = &quot;response&quot; to get a probability
predict(fit_glm, newpeople, type = &quot;response&quot;)
</code><em>##          1          2          3 
## 0.14843669 0.21032214 0.03234337
</em></pre>
<p>With <code>type = &quot;response&quot;</code>, <code>predict()</code> returns the three people as probabilities between 0 and 1, the modeled diabetes risk for each. Omit the argument and you get log-odds instead, which is the default trap the comment warns about.</p>
<pre><code class="language-r"># TIDYMODELS: always a tibble, one row per input row, standardized names
predict(fit_psn, newpeople, type = &quot;prob&quot;) # .pred_No / .pred_Yes
</code><em>## # A tibble: 3 × 2
##   .pred_No .pred_Yes
##      &lt;dbl&gt;     &lt;dbl&gt;
## 1    0.852    0.148 
## 2    0.790    0.210 
## 3    0.968    0.0323
</em></pre>
<p>tidymodels returns the same risks, but as a tibble with one column per outcome level: <code>.pred_No</code> and <code>.pred_Yes</code>, which sum to 1 in each row. There is no scale to remember, <code>type = &quot;prob&quot;</code> always means probabilities, and the column names say which level each one belongs to. <code>.pred_Yes</code> is the diabetes risk, the same number the <code>type = &quot;response&quot;</code> vector gave above.</p>
<p><code>augment()</code> goes one step further and binds the predictions onto the data:</p>
<pre><code class="language-r">augment(fit_psn, newpeople) |&gt;
  select(Diabetes, Age, BMI, .pred_class, .pred_Yes)
</code><em>## # A tibble: 3 × 5
##   Diabetes   Age   BMI .pred_class .pred_Yes
##   &lt;fct&gt;    &lt;int&gt; &lt;dbl&gt; &lt;fct&gt;           &lt;dbl&gt;
## 1 No          60  25.8 No             0.148 
## 2 No          22  52.1 No             0.210 
## 3 No          45  22.0 No             0.0323
</em></pre>
<p><strong>So the whole base-R workflow has a direct translation:</strong></p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>What you want</th>
<th>Base R</th>
<th>tidymodels</th>
</tr>
</thead>
<tbody>
<tr>
<td>Fit a logistic model</td>
<td><code>glm(y ~ ., family = binomial)</code></td>
<td><code>logistic_reg() |&gt; set_engine("glm") |&gt; fit(y ~ ., data)</code></td>
</tr>
<tr>
<td>Coefficients as odds ratios</td>
<td><code>broom::tidy(fit, exponentiate = TRUE)</code></td>
<td><code>tidy(fit, exponentiate = TRUE)</code> (same broom)</td>
</tr>
<tr>
<td>Predicted probabilities</td>
<td><code>predict(fit, new, type = "response")</code> (vector)</td>
<td><code>predict(fit, new, type = "prob")</code> (tibble)</td>
</tr>
<tr>
<td>Data plus predictions</td>
<td><code>cbind(new, p)</code></td>
<td><code>augment(fit, new)</code></td>
</tr>
</tbody>
</table>
</div>
<p><strong>Conclusion:</strong> for a single fit, tidymodels computes exactly what <code>glm()</code>, <code>predict()</code>, and <code>broom</code> already give you. The output is tidier and the prediction defaults are safer, but the math is the same, so the swap on its own is not a reason to switch.</p>
<h2>What tidymodels adds</h2>
<p>The framework is not really about replacing <code>glm()</code>; it is about the workflow around the model.</p>
<p><strong>Preprocessing that travels with predict()</strong></p>
<p>In base R, if you scale or impute your predictors, you have to redo that transformation, identically, on every new dataset you predict on, using the <em>training</em> statistics. Forget to, or use the new data&#8217;s own mean instead, and you get wrong or leaky predictions. A <strong>recipe</strong> attaches the preprocessing to the model so <code>predict()</code> applies it automatically.</p>
<pre><code class="language-r">set.seed(2026)
split &lt;- initial_split(nh, prop = 0.75, strata = Diabetes)
train &lt;- training(split)
test &lt;- testing(split)

rec &lt;- recipe(Diabetes ~ ., data = train) |&gt;
  step_impute_median(all_numeric_predictors()) |&gt; # learn medians on train
  step_impute_mode(all_nominal_predictors()) |&gt;
  step_dummy(all_nominal_predictors()) |&gt;
  step_normalize(all_numeric_predictors()) # center/scale on train stats

wf_fit &lt;- workflow() |&gt;
  add_recipe(rec) |&gt;
  add_model(logistic_reg() |&gt; set_engine(&quot;glm&quot;)) |&gt;
  fit(train)
</code></pre>
<p>The payoff shows up when a new row is incomplete. Here is a test patient whose BMI is missing:</p>
<pre><code class="language-r">patient &lt;- test[1, ]
patient$BMI &lt;- NA_real_

# base glm cannot score a row with a gap
predict(fit_glm, patient, type = &quot;response&quot;)
</code><em>##  1 
## NA
</em></pre>
<pre><code class="language-r"># the workflow imputes BMI (with the training median) before predicting
predict(wf_fit, patient, type = &quot;prob&quot;)
</code><em>## # A tibble: 1 × 2
##   .pred_No .pred_Yes
##      &lt;dbl&gt;     &lt;dbl&gt;
## 1    0.945    0.0551
</em></pre>
<p>The base model returns <code>NA</code>; the workflow silently imputes the missing BMI with the median it learned from the training data and returns a real risk.</p>
<p><strong>Model performance</strong></p>
<p>The question a risk model has to answer is how it does on data it has never seen, measured the way risk models are judged: <strong>discrimination</strong> (ROC AUC) and <strong>calibration</strong>. Fitting was the easy part; the work is estimating those two honestly, on data the fit did not touch. That is the piece base R leaves you to assemble, and where tidymodels earns its keep.</p>
<p><code>vfold_cv()</code> splits the training data into 10 folds; the model is fit on 9 of them and scored on the tenth, ten times over, so every performance number comes from data the fit never saw. Doing this by hand means writing the fold loop <em>and</em> re-learning the recipe inside each fold, so the imputation and scaling use only that fold&#8217;s training portion. Get that last part wrong (impute once on the whole set) and the held-out fold has quietly seen the training data through a shared median.</p>
<pre><code class="language-r">set.seed(2026)
folds &lt;- vfold_cv(train, v = 10, strata = Diabetes)

log_spec &lt;- logistic_reg() |&gt; set_engine(&quot;glm&quot;)
mset &lt;- metric_set(roc_auc, brier_class) # discrimination + calibration

res_log &lt;- fit_resamples(
  workflow() |&gt; add_recipe(rec) |&gt; add_model(log_spec),
  folds,
  metrics = mset
)
</code></pre>
<p><code>fit_resamples()</code> fits the workflow on every fold, re-learns the recipe inside each one, and collects the two metrics without ever touching the test set.</p>
<pre><code class="language-r">collect_metrics(res_log) |&gt;
  select(.metric, cv_estimate = mean, std_err)
</code><em>## # A tibble: 2 × 3
##   .metric     cv_estimate std_err
##   &lt;chr&gt;             &lt;dbl&gt;   &lt;dbl&gt;
## 1 brier_class      0.0860 0.00172
## 2 roc_auc          0.816  0.0105
</em></pre>
<p>Cross-validated, the logistic model reaches an AUC of 0.816, and the number is trustworthy precisely because no fold was scored on data it trained on. You could get the same value in base R, but you would own the whole fold loop and the per-fold preprocessing described above. tidymodels does that bookkeeping for you: the recipe travels into every resample automatically, so the shortcut that leaks is not even on the table.</p>
<p><strong>Discrimination and calibration plots</strong></p>
<pre><code class="language-r">lf_log &lt;- last_fit(
  workflow() |&gt; add_recipe(rec) |&gt; add_model(log_spec),
  split,
  metrics = mset
)
preds &lt;- collect_predictions(lf_log)
</code></pre>
<pre><code class="language-r">dsp_colors &lt;- c(
  &quot;#0066CC&quot;,
  &quot;#E8862D&quot;,
  &quot;#159A6C&quot;,
  &quot;#7D5BD6&quot;,
  &quot;#D64580&quot;,
  &quot;#2AA9B8&quot;,
  &quot;#C9A227&quot;
)
dsp_theme &lt;- theme_minimal(base_size = 13) +
  theme(
    plot.background = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
    panel.background = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
    panel.grid.minor = element_blank(),
    panel.grid.major = element_line(color = &quot;grey78&quot;),
    axis.ticks = element_blank(),
    plot.title = element_text(face = &quot;bold&quot;),
    strip.text = element_text(face = &quot;bold&quot;)
  )
</code></pre>
<pre class="has-plot"><code class="language-r">preds |&gt;
  roc_curve(truth = Diabetes, .pred_Yes, event_level = &quot;second&quot;) |&gt;
  ggplot(aes(1 - specificity, sensitivity)) +
  geom_abline(linetype = &quot;dashed&quot;, color = &quot;grey60&quot;) +
  geom_path(linewidth = 1.1, color = dsp_colors[1]) +
  coord_equal() +
  labs(
    title = &quot;Discrimination: who is at risk of diabetes?&quot;,
    subtitle = &quot;ROC curve on held-out test data&quot;,
    x = &quot;False positive rate&quot;,
    y = &quot;True positive rate&quot;
  ) +
  dsp_theme
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/07/diabetes-risk-glm-broom-vs-tidymodels-roc-1-1.png" alt="plot of chunk roc" /></figure>
<p>On the held-out test set the model&#8217;s AUC is 0.816, close to the cross-validated estimate, which is the reassurance you want: it did not fall apart on data it had never seen. But discrimination is only half the story. A risk model also has to be <strong>calibrated</strong>: when it predicts a 30% risk, close to 30% of the people carrying that prediction should actually have diabetes. The next plot checks that by binning people into risk deciles and comparing predicted against observed rates.</p>
<pre class="has-plot"><code class="language-r">preds |&gt;
  mutate(bin = ntile(.pred_Yes, 10)) |&gt;
  group_by(bin) |&gt;
  summarise(
    pred = mean(.pred_Yes),
    obs = mean(Diabetes == &quot;Yes&quot;),
    .groups = &quot;drop&quot;
  ) |&gt;
  ggplot(aes(pred, obs)) +
  geom_abline(linetype = &quot;dashed&quot;, color = &quot;grey60&quot;) +
  geom_line(linewidth = 0.9, color = dsp_colors[1]) +
  geom_point(size = 2.4, color = dsp_colors[1]) +
  labs(
    title = &quot;Calibration: are the predicted risks honest?&quot;,
    subtitle = &quot;Predicted vs observed rate by risk decile (dashed line = perfect)&quot;,
    x = &quot;Mean predicted risk&quot;,
    y = &quot;Observed diabetes rate&quot;
  ) +
  dsp_theme
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/07/diabetes-risk-glm-broom-vs-tidymodels-cal-1-1.png" alt="plot of chunk cal" /></figure>
<p>The points track the diagonal closely through the low and middle deciles and wobble only at the high-risk end, where the bins are smallest and noisiest and predicted risk climbs toward 40%. The Brier score, 0.087 (lower is better), is a single number that folds discrimination and calibration together.</p>
<h2>Is any of this useful in epidemiological and clinical research?</h2>
<p>It depends on the aim:</p>
<p><strong>Association and etiologic papers</strong> report adjusted odds or hazard ratios with confidence intervals, usually from a survey-weighted or matched design. For that, base <code>glm()</code> or <code>survey::svyglm()</code> with <code>broom::tidy()</code> is exactly right and tidymodels adds almost nothing.</p>
<p><strong>Prediction and prognostic papers</strong> are the other case: developing or validating a clinical risk score, the kind of work that reporting guidelines like TRIPOD govern. There the deliverable <em>is</em> discrimination and calibration on unseen data, with internal validation by resampling. That is what tidymodels streamlines.</p>
<p>So it is not <code>glm()</code> versus tidymodels, and <code>broom</code> lives inside both. Replace your base functions with tidymodels when you want the workflow it enables, not for the sake of the swap: use <code>glm()</code> or <code>svyglm()</code> and <code>broom</code> to explain associations, and reach for the full framework when your focus is a risk model you have to validate.</p>
<p>That&#8217;s it. I hope you find this tutorial useful, and if you have questions, leave a comment below.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/base-r-glm-vs-tidymodels-for-logistic-regression-what-you-actually-gain/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/base-r-glm-vs-tidymodels-for-logistic-regression-what-you-actually-gain/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Mapping the American Commute in R with Census Data</title>
		<link>https://datascienceplus.com/mapping-the-american-commute-in-r-with-census-data/</link>
					<comments>https://datascienceplus.com/mapping-the-american-commute-in-r-with-census-data/#respond</comments>
		
		<dc:creator><![CDATA[Klodian Dhana]]></dc:creator>
		<pubDate>Sat, 25 Jul 2026 16:25:09 +0000</pubDate>
				<category><![CDATA[Visualizing Data]]></category>
		<category><![CDATA[ACS]]></category>
		<category><![CDATA[ggplot2]]></category>
		<category><![CDATA[Maps]]></category>
		<category><![CDATA[sf]]></category>
		<category><![CDATA[tidycensus]]></category>
		<guid isPermaLink="false">https://datascienceplus.com/?p=32899</guid>

					<description><![CDATA[How long is your drive to work, and how does it compare to the rest of the country? The US Census Bureau asks that…]]></description>
										<content:encoded><![CDATA[<p>How long is your drive to work, and how does it compare to the rest of the country? The US Census Bureau asks that exact question of millions of households every year, and it hands you the answer for free through the American Community Survey (ACS). In this tutorial I use the <a href="https://walker-data.com/tidycensus/"><code>tidycensus</code></a> package to pull mean travel-time-to-work down to the county and census-tract level, draw a national commute map, and then break the number apart three ways: by metro, by how people travel, and by where they live inside a single city. Along the way one result stands out that I did not expect.</p>
<p>I use a handful of packages, all on CRAN. <code>tidycensus</code> fetches ACS estimates (and, handily, their map geometry in one call); the rest are for wrangling and plotting.</p>
<pre><code class="language-r">library(tidycensus)
library(dplyr)
library(stringr)
library(forcats)
library(ggplot2)
library(sf)
library(ggrepel)
library(patchwork)
</code></pre>
<p><code>tidycensus</code> needs a free Census API key. Request one at <a href="https://api.census.gov/data/key_signup.html">api.census.gov/data/key_signup.html</a>, then run <code>census_api_key(&quot;YOUR_KEY&quot;, install = TRUE)</code> once and restart R.</p>
<h2>Finding the right variable</h2>
<p>The ACS reports commute times across dozens of tables. There is no single &quot;mean commute&quot; variable, but there is an easy way to build one. Table <code>B08013</code> gives the <em>aggregate</em> travel time to work (every commuter&#8217;s minutes added together) for an area, and <code>B08012</code> gives the number of workers who commute. Divide one by the other and you get the mean. Every ACS table has an <code>_001</code> row for the total, which is all I need here.</p>
<p>I pull both for every county in the country at once. Leaving out the <code>state</code> argument returns all ~3,200 counties. I use the <code>wide</code> output so each variable becomes its own column.</p>
<pre><code class="language-r">cty &lt;- get_acs(
  geography = &quot;county&quot;,
  variables = c(agg = &quot;B08013_001&quot;, workers = &quot;B08012_001&quot;),
  year = 2022, survey = &quot;acs5&quot;, output = &quot;wide&quot;
) |&gt;
  mutate(mean_commute = aggE / workersE)

nat_mean &lt;- sum(cty$aggE, na.rm = TRUE) / sum(cty$workersE, na.rm = TRUE)
round(nat_mean, 1)
</code><em>## [1] 26.7
</em></pre>
<p>Averaged across every commuter in the country, the mean trip is 26.7 minutes each way. Before mapping anything, it is worth looking at the extremes to check the numbers make sense.</p>
<pre><code class="language-r">cty |&gt;
  filter(workersE &gt; 50000) |&gt;
  arrange(desc(mean_commute)) |&gt;
  transmute(NAME, mean_commute = round(mean_commute, 1)) |&gt;
  head(6)
</code><em>## # A tibble: 6 × 2
##   NAME                        mean_commute
##   &lt;chr&gt;                              &lt;dbl&gt;
## 1 Bronx County, New York              44.6
## 2 Charles County, Maryland            44.2
## 3 Richmond County, New York           43.8
## 4 Queens County, New York             43.6
## 5 Kings County, New York              42.7
## 6 Monroe County, Pennsylvania         39.8
</em></pre>
<p>The longest commutes among populous counties are the outer boroughs of New York and the exurban counties feeding Washington and New York. That is exactly what you would expect, so the pipeline is working.</p>
<p><strong>Related posts on DataScience+:</strong></p><ul><li><a href="https://datascienceplus.com/building-heatmaps-in-r/">Building Heatmaps in R with ggplot2 package</a></li><li><a href="https://datascienceplus.com/visualising-thefts-using-heatmaps-in-ggplot2/">Visualising Thefts using Heatmaps in ggplot2</a></li><li><a href="https://datascienceplus.com/how-happy-is-your-country-visualized/">How Happy is Your Country? — Happy Planet Index Visualized</a></li></ul><h2>The national map</h2>
<p>To map this I ask <code>tidycensus</code> for the same data <em>with</em> geometry by adding <code>geometry = TRUE</code>. That returns an <code>sf</code> object I can hand straight to <code>ggplot2</code>. The one extra touch is <code>tigris::shift_geometry()</code>, which relocates Alaska, Hawaii, and Puerto Rico under the lower 48 so the whole country fits one frame.</p>
<p>I reuse a small editorial theme across every figure in this post. It tints the entire canvas a light gray and strips the chart down to the data. For a map I also drop the axes.</p>
<pre><code class="language-r">dsp_theme &lt;- theme_minimal(base_size = 13) +
  theme(plot.background    = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
        panel.background   = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
        panel.grid.minor   = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.major.y = element_line(color = &quot;grey78&quot;),
        axis.ticks         = element_blank(),
        plot.title         = element_text(face = &quot;bold&quot;),
        plot.subtitle      = element_text(color = &quot;grey30&quot;))

map_theme &lt;- dsp_theme +
  theme(panel.grid.major = element_blank(),
        axis.text = element_blank(), axis.title = element_blank(),
        legend.position = &quot;bottom&quot;)
</code></pre>
<p>Now the map itself. The data comes down with geometry, I shift it, and <code>geom_sf()</code> fills each county by its mean commute. County outlines would only clutter 3,200 shapes, so I draw them borderless and overlay a single thin white state layer to give the eye something to orient by. I pull state boundaries from <code>tigris</code> and shift them the same way, so they line up with the counties. I also drop labels on a few large metros so the geography is easy to read, using <code>ggrepel</code> to nudge the text clear of the coastline.</p>
<pre class="has-plot"><code class="language-r">cty_geo &lt;- get_acs(
  geography = &quot;county&quot;,
  variables = c(agg = &quot;B08013_001&quot;, workers = &quot;B08012_001&quot;),
  year = 2022, survey = &quot;acs5&quot;, output = &quot;wide&quot;, geometry = TRUE
) |&gt;
  mutate(mean_commute = aggE / workersE) |&gt;
  tigris::shift_geometry()

states &lt;- tigris::states(cb = TRUE, year = 2022) |&gt;
  filter(!STUSPS %in% c(&quot;VI&quot;, &quot;GU&quot;, &quot;MP&quot;, &quot;AS&quot;)) |&gt;
  tigris::shift_geometry()

# a few metros to anchor the eye; transformed into the shifted map's CRS
cities &lt;- tibble::tribble(
  ~lab,            ~lon,     ~lat,
  &quot;New York&quot;,      -74.00,   40.71,
  &quot;San Francisco&quot;, -122.42,  37.77,
  &quot;Washington&quot;,    -77.04,   38.90,
  &quot;Boston&quot;,        -71.06,   42.36,
  &quot;Chicago&quot;,       -87.63,   41.88
) |&gt;
  st_as_sf(coords = c(&quot;lon&quot;, &quot;lat&quot;), crs = 4326) |&gt;
  st_transform(st_crs(cty_geo))

city_labels &lt;- geom_text_repel(
  data = cities, aes(label = lab, geometry = geometry),
  stat = &quot;sf_coordinates&quot;, size = 3.2, fontface = &quot;bold&quot;,
  color = &quot;grey10&quot;, bg.color = &quot;#ECECEF&quot;, bg.r = 0.15,
  min.segment.length = 0, segment.color = &quot;grey30&quot;,
  segment.size = 0.25, box.padding = 0.5, seed = 1
)

ggplot(cty_geo) +
  geom_sf(aes(fill = mean_commute), color = NA) +
  geom_sf(data = states, fill = NA, color = &quot;white&quot;, linewidth = 0.2) +
  city_labels +
  scale_fill_viridis_c(option = &quot;magma&quot;, direction = -1,
                       name = &quot;Mean commute (min)  &quot;,
                       breaks = c(15, 25, 35, 45)) +
  labs(title = &quot;How long is the drive to work?&quot;,
       subtitle = &quot;Mean travel time to work by county, ACS 2018-2022&quot;,
       caption = &quot;Data: US Census Bureau, ACS 5-year&quot;) +
  guides(fill = guide_colorbar(barwidth = 12, barheight = 0.5,
                               title.position = &quot;top&quot;, title.hjust = 0.5)) +
  map_theme
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/07/commute-time-maps-in-r-national-map-1-3.png" alt="" /></figure>
<p>The geography tells a clear story. Commutes are longest in the dark bands of the East, the Appalachians and the Deep South, and around every major metro. They are shortest across the light expanse of the Great Plains and rural Alaska, where towns are small and the drive across one is a matter of minutes. Long commutes are a feature of density and distance-to-a-big-city, not of open country.</p>
<h2>Which metros have it worst</h2>
<p>The county map hints at metros but does not rank them. For that I switch the geography to <code>&quot;cbsa&quot;</code>, the Census term for metropolitan and micropolitan areas, and pull population too so I can keep only the large metros where the number is stable.</p>
<pre><code class="language-r">metro &lt;- get_acs(
  geography = &quot;cbsa&quot;,
  variables = c(agg = &quot;B08013_001&quot;, workers = &quot;B08012_001&quot;, pop = &quot;B01003_001&quot;),
  year = 2022, survey = &quot;acs5&quot;, output = &quot;wide&quot;
) |&gt;
  filter(str_detect(NAME, &quot;Metro Area&quot;), popE &gt; 500000) |&gt;
  mutate(mean_commute = aggE / workersE,
         label = paste0(str_extract(NAME, &quot;^[^-,]+&quot;), &quot;, &quot;,
                        str_remove(str_extract(NAME, &quot;, [A-Z]{2}&quot;), &quot;, &quot;)))
</code></pre>
<p>A horizontal bar chart is the natural way to compare the top of the list. I sort with <code>fct_reorder()</code> so the bars line up longest-first and add the value at the end of each.</p>
<pre class="has-plot"><code class="language-r">metro |&gt;
  slice_max(mean_commute, n = 15) |&gt;
  ggplot(aes(mean_commute, fct_reorder(label, mean_commute))) +
  geom_col(fill = &quot;#0066CC&quot;, width = 0.72) +
  geom_text(aes(label = sprintf(&quot;%.1f&quot;, mean_commute)), hjust = -0.15, size = 3.6) +
  scale_x_continuous(expand = expansion(mult = c(0, 0.10))) +
  labs(title = &quot;The metros with the longest commutes&quot;,
       subtitle = &quot;Mean travel time to work, metros over 500k people, ACS 2018-2022&quot;,
       x = NULL, y = NULL, caption = &quot;Data: US Census Bureau, ACS 5-year&quot;) +
  dsp_theme +
  theme(axis.text.x = element_blank(), panel.grid.major.x = element_blank())
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/07/commute-time-maps-in-r-metro-bar-1-3.png" alt="" /></figure>
<p>New York tops the list, no surprise. What is more interesting is the company it keeps: Stockton, Riverside, and Modesto are not big job centers themselves, they are the affordable edges of the Bay Area and Los Angeles, where people move for cheaper housing and pay for it in driving time. The longest commutes are half about big cities and half about the sprawl around them.</p>
<h2>The result I did not expect: by mode</h2>
<p>Here is where the data surprised me. I assumed transit riders, gliding past traffic, would have the shortest commutes. The opposite is true. Table <code>B08136</code> breaks aggregate travel time down by how people get to work, and <code>B08301</code> gives the matching worker counts, so the same divide-and-average trick gives a mean commute per mode. The mode categories are split across several rows, so I pull the whole tables and pick the pieces I need.</p>
<pre><code class="language-r">agg &lt;- get_acs(&quot;us&quot;, table = &quot;B08136&quot;, year = 2022, survey = &quot;acs5&quot;)
cnt &lt;- get_acs(&quot;us&quot;, table = &quot;B08301&quot;, year = 2022, survey = &quot;acs5&quot;)

A &lt;- function(code) agg$estimate[agg$variable == code]
N &lt;- function(...) sum(sapply(c(...), function(x) cnt$estimate[cnt$variable == x]))

modes &lt;- data.frame(
  mode = c(&quot;Drove alone&quot;, &quot;Carpooled&quot;, &quot;Bus&quot;, &quot;Subway / rail&quot;,
           &quot;Commuter rail / ferry&quot;, &quot;Walked&quot;),
  minutes = c(A(&quot;B08136_003&quot;) / N(&quot;B08301_003&quot;),
              A(&quot;B08136_004&quot;) / N(&quot;B08301_004&quot;),
              A(&quot;B08136_008&quot;) / N(&quot;B08301_011&quot;),
              A(&quot;B08136_009&quot;) / N(&quot;B08301_012&quot;, &quot;B08301_014&quot;),
              A(&quot;B08136_010&quot;) / N(&quot;B08301_013&quot;, &quot;B08301_015&quot;),
              A(&quot;B08136_011&quot;) / N(&quot;B08301_019&quot;))
)
</code></pre>
<pre class="has-plot"><code class="language-r">ggplot(modes, aes(minutes, fct_reorder(mode, minutes))) +
  geom_col(fill = &quot;#0066CC&quot;, width = 0.68) +
  geom_text(aes(label = sprintf(&quot;%.0f min&quot;, minutes)), hjust = -0.15, size = 4) +
  scale_x_continuous(expand = expansion(mult = c(0, 0.13))) +
  labs(title = &quot;Transit takes far longer than driving&quot;,
       subtitle = &quot;Mean travel time to work by commute mode, US, ACS 2018-2022&quot;,
       x = NULL, y = NULL, caption = &quot;Data: US Census Bureau, ACS 5-year&quot;) +
  dsp_theme +
  theme(axis.text.x = element_blank(), panel.grid.major.x = element_blank())
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/07/commute-time-maps-in-r-mode-bar-1-3.png" alt="" /></figure>
<p>Someone who drives alone averages about 26 minutes. Someone on commuter rail or a ferry averages 71, nearly three times as long. This is not because trains are slow. It is a selection effect: transit is worth taking mostly for long trips into dense downtowns, so the people who use it are the ones with the longest journeys to begin with, and their door-to-door time includes walking, waiting, and transfers. The mode does not cause the long commute; the long commute is why they chose the mode. It is a good reminder that a difference in a group average is rarely a clean cause-and-effect.</p>
<p>A map makes the selection effect obvious. If I map the <em>share</em> of workers who commute by public transportation, table <code>B08301</code> again, I can see where those long transit trips actually happen. Transit share is tiny in almost every county, so I put the color scale on a square-root transform to keep the busy metros from washing everything else out.</p>
<pre class="has-plot"><code class="language-r">ts &lt;- get_acs(
  geography = &quot;county&quot;,
  variables = c(transit = &quot;B08301_010&quot;, total = &quot;B08301_001&quot;),
  year = 2022, survey = &quot;acs5&quot;, output = &quot;wide&quot;, geometry = TRUE
) |&gt;
  mutate(transit_share = 100 * transitE / totalE) |&gt;
  tigris::shift_geometry()

ggplot(ts) +
  geom_sf(aes(fill = transit_share), color = NA) +
  geom_sf(data = states, fill = NA, color = &quot;grey35&quot;, linewidth = 0.15) +
  city_labels +
  scale_fill_viridis_c(option = &quot;mako&quot;, direction = -1, transform = &quot;sqrt&quot;,
                       breaks = c(1, 5, 15, 30),
                       name = &quot;% commuting by transit  &quot;) +
  labs(title = &quot;Transit is a big-metro phenomenon&quot;,
       subtitle = &quot;Share of workers commuting by public transportation, ACS 2018-2022&quot;,
       caption = &quot;Data: US Census Bureau, ACS 5-year&quot;) +
  guides(fill = guide_colorbar(barwidth = 12, barheight = 0.5,
                               title.position = &quot;top&quot;, title.hjust = 0.5)) +
  map_theme
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/07/commute-time-maps-in-r-transit-map-1-3.png" alt="" /></figure>
<p>The country is almost empty. Transit collapses to a handful of dark islands: New York above all, then the Bay Area, Washington, Boston, and Chicago. These are the same metros that topped the commute-time chart, and that is the whole point. The long transit average is not a national fact about trains; it is a few very large, very dense metros where a long ride beats an even longer drive, showing up in the one national number.</p>
<h2>Inside a single metro</h2>
<p>A metro-wide average hides as much as it shows. To see the texture I drop to the census-tract level, the finest geography the ACS maps well, for two metros with very different shapes. <code>tidycensus</code> takes a vector of counties, and I keep only tracts with enough commuters to be reliable.</p>
<pre><code class="language-r">get_metro &lt;- function(state, counties, name) {
  get_acs(&quot;tract&quot;, state = state, county = counties,
          variables = c(agg = &quot;B08013_001&quot;, workers = &quot;B08012_001&quot;),
          year = 2022, survey = &quot;acs5&quot;, output = &quot;wide&quot;, geometry = TRUE) |&gt;
    mutate(mean_commute = aggE / workersE, metro = name) |&gt;
    filter(workersE &gt; 200)
}

nyc &lt;- get_metro(&quot;NY&quot;, c(&quot;New York&quot;, &quot;Kings&quot;, &quot;Queens&quot;, &quot;Bronx&quot;, &quot;Richmond&quot;),
                 &quot;New York City&quot;)
atl &lt;- get_metro(&quot;GA&quot;, c(&quot;Fulton&quot;, &quot;DeKalb&quot;, &quot;Cobb&quot;, &quot;Gwinnett&quot;, &quot;Clayton&quot;),
                 &quot;Atlanta&quot;)
</code></pre>
<p>I draw both on a shared color scale with <code>patchwork</code> so the two panels are directly comparable, then collect the single legend.</p>
<pre class="has-plot"><code class="language-r">rng &lt;- range(c(nyc$mean_commute, atl$mean_commute), na.rm = TRUE)

one_map &lt;- function(d, ttl) {
  ggplot(d) +
    geom_sf(aes(fill = mean_commute), color = NA) +
    scale_fill_viridis_c(option = &quot;magma&quot;, direction = -1, limits = rng, name = &quot;min&quot;) +
    labs(title = ttl) +
    theme_void(base_size = 12) +
    theme(plot.background = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
          plot.title = element_text(face = &quot;bold&quot;, hjust = 0.02))
}

(one_map(nyc, &quot;New York City&quot;) | one_map(atl, &quot;Atlanta&quot;)) +
  plot_layout(guides = &quot;collect&quot;) +
  plot_annotation(
    title = &quot;Same average, two different shapes&quot;,
    subtitle = &quot;Mean travel time to work by census tract, ACS 2018-2022&quot;,
    caption = &quot;Data: US Census Bureau, ACS 5-year&quot;,
    theme = theme(plot.background = element_rect(fill = &quot;#ECECEF&quot;, color = NA),
                  plot.title = element_text(face = &quot;bold&quot;, size = 16),
                  plot.subtitle = element_text(color = &quot;grey30&quot;)))
</code></pre>
<figure class="code-plot"><img decoding="async" src="https://datascienceplus.com/wp-content/uploads/2026/07/commute-time-maps-in-r-tract-map-1-3.png" alt="" /></figure>
<p>The two cities could not be more different. New York is dark almost everywhere, a median tract commute of 42.7 minutes, and the longest trips are in the outer boroughs, not the core: people commute <em>into</em> Manhattan, not out of it. Atlanta shows the classic ring: a bright, short-commute core in Midtown and Buckhead fading to dark exurban edges, with a tract median of just 30 minutes. Same country, same survey, two completely different geographies of getting to work.</p>
<h2>One more number worth knowing</h2>
<p>The averages are one thing, but the tail is where the human cost sits. Table <code>B08303</code> bins commuters by how long they travel, so I can count the &quot;super-commuters&quot; who spend 60 minutes or more each way.</p>
<pre><code class="language-r">b &lt;- get_acs(&quot;us&quot;, table = &quot;B08303&quot;, year = 2022, survey = &quot;acs5&quot;)
total &lt;- b$estimate[b$variable == &quot;B08303_001&quot;]
over60 &lt;- sum(b$estimate[b$variable %in% c(&quot;B08303_012&quot;, &quot;B08303_013&quot;)])
round(100 * over60 / total, 1)
</code><em>## [1] 8.9
</em></pre>
<p>About 8.9 percent of American commuters, roughly 12.3 million people, spend at least an hour getting to work, one way. That is two-plus hours of every working day spent in transit.</p>
<h2>Make it your own</h2>
<p>Everything here comes from three or four ACS variables and the divide-an-aggregate-by-a-count trick. Swap the table codes and you can map median rent, income, insurance coverage, or the share of people who work from home, at any geography from the whole nation down to a single tract. Change the <code>state</code> and <code>county</code> arguments and the tract maps redraw for your own city. The ACS has thousands of variables; <code>load_variables(2022, &quot;acs5&quot;)</code> lets you search them all.</p>
<p>That&#8217;s it. I hope you find this tutorial useful, and if you have questions, leave a comment below.</p>
<hr><p><em>This article was first published on <a href="https://datascienceplus.com/mapping-the-american-commute-in-r-with-census-data/">DataScience+</a>, a community of R and Python tutorial authors. Have a data-science technique worth sharing? <a href="https://datascienceplus.com/write-for-us/">Write for us</a> — no pitch required.</em></p>]]></content:encoded>
					
					<wfw:commentRss>https://datascienceplus.com/mapping-the-american-commute-in-r-with-census-data/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>