<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>census | Nikhil Kaza</title>
    <link>https://nkaza.github.io/category/census/</link>
      <atom:link href="https://nkaza.github.io/category/census/index.xml" rel="self" type="application/rss+xml" />
    <description>census</description>
    <generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>© 2018-2025 Nikhil Kaza</copyright><lastBuildDate>Tue, 20 Oct 2026 00:00:00 +0000</lastBuildDate>
    <image>
      <url>https://nkaza.github.io/media/icon_hu1ca6a6912ef6c300619228a995d3f134_46128_512x512_fill_lanczos_center_3.png</url>
      <title>census</title>
      <link>https://nkaza.github.io/category/census/</link>
    </image>
    
    <item>
      <title>Using Tidycensus and NHGIS in R (version 2)</title>
      <link>https://nkaza.github.io/post/tidycensus-nhgis/</link>
      <pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate>
      <guid>https://nkaza.github.io/post/tidycensus-nhgis/</guid>
      <description>&lt;script src=&#34;https://nkaza.github.io/post/tidycensus-nhgis/index.en_files/htmlwidgets/htmlwidgets.js&#34;&gt;&lt;/script&gt;
&lt;script src=&#34;https://nkaza.github.io/post/tidycensus-nhgis/index.en_files/pymjs/pym.v1.js&#34;&gt;&lt;/script&gt;
&lt;script src=&#34;https://nkaza.github.io/post/tidycensus-nhgis/index.en_files/widgetframe-binding/widgetframe.js&#34;&gt;&lt;/script&gt;
&lt;h2 id=&#34;introduction&#34;&gt;Introduction&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&#34;https://nkaza.github.io/post/using-tidycensus-package/&#34;&gt;earlier tidycensus tutorial&lt;/a&gt; showed how to query the Census API and map poverty and unemployment. This one goes a step further: it builds a small but realistic &lt;strong&gt;tract-level dataset&lt;/strong&gt; from several Census sources, and then uses it in a sequence of exercises.&lt;/p&gt;
&lt;h2 id=&#34;what-an-api-is&#34;&gt;What an API is&lt;/h2&gt;
&lt;p&gt;An &lt;strong&gt;Application Programming Interface (API)&lt;/strong&gt; is a go-between that lets two programs talk to each other. When a phone app shows you the weather, it sends a structured request to a server, and the server looks up the data, packages it, and sends it back for the app to display. The US Census Bureau runs APIs like this. Instead of downloading spreadsheets by hand, you can ask the Census servers for exactly the variables, years, and places you need, straight from R.&lt;/p&gt;
&lt;p&gt;Most requests need a free &lt;a href=&#34;https://api.census.gov/data/key_signup.html&#34; target=&#34;_blank&#34; rel=&#34;noopener&#34;&gt;Census API key&lt;/a&gt;. It identifies you to the server, so keep it private.&lt;/p&gt;
&lt;h2 id=&#34;querying-census-servers-directly&#34;&gt;Querying Census servers directly&lt;/h2&gt;
&lt;p&gt;Every Census API request is a web address. With your key in place of &lt;code&gt;YOUR_API_KEY&lt;/code&gt;, this one returns the median household income of every census tract in Orange County from the 2019–2023 American Community Survey:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://api.census.gov/data/2023/acs/acs5?get=NAME,B19013_001E&amp;amp;for=tract:*&amp;amp;in=state:37%20county:135&amp;amp;key=YOUR_API_KEY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read it piece by piece:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;https://api.census.gov/data/2023/acs/acs5?&lt;/code&gt; is the &lt;strong&gt;endpoint&lt;/strong&gt;: the five-year ACS ending in 2023, that is, 2019–2023.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;get=NAME,B19013_001E&lt;/code&gt; lists the &lt;strong&gt;variables&lt;/strong&gt;: the place name and table B19013, median household income. The &lt;code&gt;E&lt;/code&gt; means &lt;em&gt;estimate&lt;/em&gt;; the matching margin of error ends in &lt;code&gt;M&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;for=tract:*&lt;/code&gt; is the &lt;strong&gt;geography&lt;/strong&gt;: every tract.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;in=state:37%20county:135&lt;/code&gt; limits it to &lt;strong&gt;North Carolina&lt;/strong&gt; (FIPS code 37) and &lt;strong&gt;Orange County&lt;/strong&gt; (135). &lt;code&gt;%20&lt;/code&gt; is an encoded space.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key=...&lt;/code&gt; identifies you. The parts are joined with &lt;code&gt;&amp;amp;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When you paste this entire url in the browser, the server answers in JSON, a grid of text whose first row holds the column names. Every API has its own rules. They change over time, which is one reason code that worked last year can stop working.&lt;/p&gt;
&lt;p&gt;















&lt;figure  &gt;
  &lt;div class=&#34;d-flex justify-content-center&#34;&gt;
    &lt;div class=&#34;w-100&#34; &gt;&lt;img alt=&#34;The Census API’s answer in a browser. Tract 116.01 has an income of -666666666, the Census Bureau’s code for an estimate it could not compute.&#34; srcset=&#34;
               /post/tidycensus-nhgis/images/census-api-json_hu68858c80fb85f1b0e519c3e2c437124c_311758_aa1a66e052838db7e723868822440282.webp 400w,
               /post/tidycensus-nhgis/images/census-api-json_hu68858c80fb85f1b0e519c3e2c437124c_311758_8b8fa70e9ee99fdee62efcfd93ac0eaa.webp 760w,
               /post/tidycensus-nhgis/images/census-api-json_hu68858c80fb85f1b0e519c3e2c437124c_311758_1200x1200_fit_q75_h2_lanczos_3.webp 1200w&#34;
               src=&#34;https://nkaza.github.io/post/tidycensus-nhgis/images/census-api-json_hu68858c80fb85f1b0e519c3e2c437124c_311758_aa1a66e052838db7e723868822440282.webp&#34;
               width=&#34;760&#34;
               height=&#34;465&#34;
               loading=&#34;lazy&#34; data-zoomable /&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 id=&#34;tidycensus&#34;&gt;Tidycensus&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;tidycensus&lt;/strong&gt; package wraps the Census API in R functions: &lt;code&gt;get_decennial()&lt;/code&gt; for the census taken every ten years, and &lt;code&gt;get_acs()&lt;/code&gt; for the American Community Survey. They build the request, send it, and return a tidy table, optionally with geometry. Kyle Walker’s &lt;a href=&#34;https://walker-data.com/census-r/&#34; target=&#34;_blank&#34; rel=&#34;noopener&#34;&gt;&lt;em&gt;Analyzing US Census Data&lt;/em&gt;&lt;/a&gt; covers it in depth.&lt;/p&gt;
&lt;p&gt;Store your key once, and tidycensus adds it to every request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;library(tidycensus)
census_api_key(&amp;quot;YOUR_API_KEY&amp;quot;, install = TRUE)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;install = TRUE&lt;/code&gt; writes the key to your &lt;code&gt;.Renviron&lt;/code&gt; file, so it is available in every future R session.&lt;/p&gt;
&lt;p&gt;The hardest part is usually finding the right variable codes. &lt;code&gt;load_variables()&lt;/code&gt; lists every variable in a dataset so you can search it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;acs_vars &amp;lt;- load_variables(2023, &amp;quot;acs5&amp;quot;, cache = TRUE)
acs_vars |&amp;gt; filter(str_detect(label, &amp;quot;Median household income&amp;quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two kinds of data are important distinguish:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Decennial census counts&lt;/strong&gt; (2010, 2020 etc.) try to count everyone. Since 2020 they carry deliberate small errors (“differential privacy”) to protect respondents, so very small counts are fuzzy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;American Communty Survey (ACS) estimates&lt;/strong&gt; come from a sample of households pooled over five years. Every estimate has a &lt;strong&gt;margin of error&lt;/strong&gt;, and for small areas such as tracts it can be large. This post treats the estimates as if they were exact, when it really should not.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;building-the-dataset&#34;&gt;Building the dataset&lt;/h2&gt;
&lt;p&gt;For the sake of simplicity I focus on three counties in North Carolina.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;counties &amp;lt;- c(&amp;quot;Durham&amp;quot;, &amp;quot;Orange&amp;quot;, &amp;quot;Wake&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&#34;1-tract-boundaries&#34;&gt;1. Tract boundaries&lt;/h3&gt;
&lt;p&gt;Census tracts are small statistical areas, usually 2,500 to 8,000 people, drawn to stay stable over time. The &lt;strong&gt;tigris&lt;/strong&gt; package downloads their boundaries. &lt;code&gt;cb = TRUE&lt;/code&gt; asks for the generalized cartographic boundary files, which are lighter and clipped to the shoreline. The 2023 files use the tract boundaries drawn for the 2020 Census.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;library(tigris)
library(units)
library(tmap)
tr_shapes &amp;lt;- tracts(state = &amp;quot;NC&amp;quot;, county = counties, cb = TRUE, year = 2023, progress_bar = FALSE) |&amp;gt;
  st_transform(32119) |&amp;gt;
  transmute(GEOID, county = NAMELSADCO |&amp;gt; str_remove(&amp;quot; County&amp;quot;),
            area_sqmi = ALAND |&amp;gt;
              units::set_units(&amp;quot;m^2&amp;quot;) |&amp;gt;     # ALAND is land area in square meters
              units::set_units(&amp;quot;mi^2&amp;quot;))    # convert to square miles

tmap_mode(&#39;plot&#39;)  # static map: interactive maps don&#39;t render in .Rmarkdown posts

carto_positron &amp;lt;- paste0(
  &amp;quot;https://a.basemaps.cartocdn.com/rastertiles/light_all/{z}/{x}/{y}.png?key=&amp;quot;,
  Sys.getenv(&amp;quot;CARTO_API_KEY&amp;quot;)
)
# An explicit carto URL is needed because Carto moved its tiles from /light_all to
# /rastertiles/light_all, and tmap&#39;s built-in &amp;quot;CartoDB.Positron&amp;quot; still points to the old path.

tm_shape(tr_shapes)+
  tm_borders(col=&#39;blue&#39;, lwd=.2)+
  tm_basemap(carto_positron)
&lt;/code&gt;&lt;/pre&gt;
&lt;img src=&#34;https://nkaza.github.io/post/tidycensus-nhgis/index.en_files/figure-html/shapes-1.png&#34; alt=&#34;&#34; width=&#34;672&#34; /&gt;
&lt;div class=&#34;alert alert-note&#34;&gt;
  &lt;div&gt;
    The map uses CARTO’s Positron basemap, which now requires a free API key. Register for one at &lt;a href=&#34;https://carto.com/basemaps/apikey&#34; target=&#34;_blank&#34; rel=&#34;noopener&#34;&gt;carto.com/basemaps/apikey&lt;/a&gt;, then add a line to your &lt;code&gt;~/.Renviron&lt;/code&gt; file: &lt;code&gt;CARTO_API_KEY=your_key_here&lt;/code&gt;, and restart R so &lt;code&gt;Sys.getenv(&amp;quot;CARTO_API_KEY&amp;quot;)&lt;/code&gt; can find it. You can also use &lt;code&gt;usethis::edit_r_environ()&lt;/code&gt;. Keeping keys in &lt;code&gt;.Renviron&lt;/code&gt; keeps them out of your code.
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;code&gt;st_transform(32119)&lt;/code&gt; moves the boundaries into &lt;strong&gt;NC State Plane&lt;/strong&gt;, a projection in meters that keeps areas accurate in North Carolina. &lt;code&gt;ALAND&lt;/code&gt; is land area in square meters but the units are not defined. The &lt;strong&gt;units&lt;/strong&gt; package converts it to square miles: &lt;code&gt;set_units(&amp;quot;m^2&amp;quot;)&lt;/code&gt; labels the number with its unit, and &lt;code&gt;set_units(&amp;quot;mi^2&amp;quot;)&lt;/code&gt; converts it, so there is no conversion factor to look up or mistype. Whenever possible, use units to prevent errors, but they also create hiccups when comparing with numbers, so be mindful.&lt;/p&gt;
&lt;p&gt;For example, to find tracts smaller than one square mile, comparing &lt;code&gt;area_sqmi&lt;/code&gt; with a plain &lt;code&gt;1&lt;/code&gt; fails, because R cannot tell whether &lt;code&gt;1&lt;/code&gt; means square miles, acres, or square meters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;tr_shapes |&amp;gt; filter(area_sqmi &amp;lt; 1)
# Error in `stopifnot()`:
# i In argument: `area_sqmi &amp;lt; 1`.
# Caused by error in `Ops.units()`:
# ! both operands of the expression should be &amp;quot;units&amp;quot; objects
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Give the number a unit as well. Any area unit works, and units converts before comparing, so &lt;code&gt;640&lt;/code&gt; acres gives the same answer as &lt;code&gt;1&lt;/code&gt; square mile:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;tr_shapes |&amp;gt; filter(area_sqmi &amp;lt; set_units(1, &amp;quot;mi^2&amp;quot;)) |&amp;gt; nrow()
# [1] 64
tr_shapes |&amp;gt; filter(area_sqmi &amp;lt; set_units(640, &amp;quot;acre&amp;quot;)) |&amp;gt; nrow()
# [1] 64
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&#34;2-population-in-2020&#34;&gt;2. Population in 2020&lt;/h3&gt;
&lt;p&gt;The 2020 redistricting file (&lt;code&gt;sumfile = &amp;quot;pl&amp;quot;&lt;/code&gt;) has total population (&lt;code&gt;P1_001N&lt;/code&gt;) down to the &lt;strong&gt;block&lt;/strong&gt;, the smallest Census geography. Blocks are nested within tracts.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;blocks_pop_2020 &amp;lt;- get_decennial(geography = &amp;quot;block&amp;quot;, state = &amp;quot;NC&amp;quot;, county = counties,
                             variables = c(pop = &amp;quot;P1_001N&amp;quot;), year = 2020, sumfile = &amp;quot;pl&amp;quot;,
                             progress_bar = FALSE) |&amp;gt;
  select(GEOID, pop = value)

glimpse(blocks_pop_2020)
# Rows: 21,212
# Columns: 2
# $ GEOID &amp;lt;chr&amp;gt; &amp;quot;370630001011000&amp;quot;, &amp;quot;370630001011001&amp;quot;, &amp;quot;370630001011002&amp;quot;, &amp;quot;370630~
# $ pop   &amp;lt;dbl&amp;gt; 108, 35, 38, 43, 31, 55, 64, 23, 24, 37, 68, 39, 104, 50, 52, 48~
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A block GEOID is a chain of codes, each nested in the one before:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;characters&lt;/th&gt;
&lt;th&gt;code&lt;/th&gt;
&lt;th&gt;example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1–2&lt;/td&gt;
&lt;td&gt;state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;37&lt;/code&gt; (North Carolina)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3–5&lt;/td&gt;
&lt;td&gt;county&lt;/td&gt;
&lt;td&gt;&lt;code&gt;063&lt;/code&gt; (Durham)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6–11&lt;/td&gt;
&lt;td&gt;tract&lt;/td&gt;
&lt;td&gt;&lt;code&gt;000101&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12–15&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;So the first 11 characters of a block GEOID are the GEOID of the tract it sits in. Because 2020 blocks nest exactly inside 2020 tracts, cutting the GEOID to 11 characters and adding up gives the tract population:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;tr_pop_2020 &amp;lt;- blocks_pop_2020 |&amp;gt;
  mutate(GEOID = str_sub(GEOID, 1, 11)) |&amp;gt;
  summarise(pop_2020 = sum(pop, na.rm=T), .by = GEOID)
glimpse(tr_pop_2020)
# Rows: 340
# Columns: 2
# $ GEOID    &amp;lt;chr&amp;gt; &amp;quot;37063000101&amp;quot;, &amp;quot;37063000102&amp;quot;, &amp;quot;37063000200&amp;quot;, &amp;quot;37063000301&amp;quot;, &amp;quot;~
# $ pop_2020 &amp;lt;dbl&amp;gt; 3282, 4430, 2893, 2644, 3672, 2564, 2966, 4192, 5507, 3701, 1~
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same trick gives any geography that nests: &lt;code&gt;str_sub(GEOID, 1, 12)&lt;/code&gt; is the block group, and &lt;code&gt;str_sub(GEOID, 1, 5)&lt;/code&gt; the county. As a check, the result matches the tract counts the Census publishes directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;pop_2020_tr &amp;lt;- get_decennial(geography = &amp;quot;tract&amp;quot;, state = &amp;quot;NC&amp;quot;, county = counties,
                                variables = c(pop_2020 = &amp;quot;P1_001N&amp;quot;), year = 2020, sumfile = &amp;quot;pl&amp;quot;,
                                progress_bar = FALSE) |&amp;gt;
  select(GEOID, pop_2020 = value)

all.equal(arrange(tr_pop_2020, GEOID), arrange(pop_2020_tr, GEOID), check.attributes = FALSE)
# [1] TRUE
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&#34;3-population-in-2010-on-2020-tracts&#34;&gt;3. Population in 2010, on 2020 tracts&lt;/h3&gt;
&lt;p&gt;Measuring growth needs the same areas in both years, but &lt;strong&gt;tract boundaries change every decade&lt;/strong&gt;. Tracts that grew are split, so one 2010 tract can become two or three 2020 tracts with new GEOIDs; others are merged or redrawn. Joining 2010 and 2020 tables by GEOID would silently drop or mismatch exactly the fastest-growing places.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://www.nhgis.org&#34; target=&#34;_blank&#34; rel=&#34;noopener&#34;&gt;IPUMS NHGIS&lt;/a&gt; publishes &lt;strong&gt;crosswalks&lt;/strong&gt; for this. Census &lt;strong&gt;blocks&lt;/strong&gt; are the smallest areas, and the crosswalk gives, for each 2010 block, the share of its population in each 2020 tract. Multiply each 2010 block count by its weight and add up by 2020 tract, and you get the 2010 population on 2020 boundaries. Downloading the crosswalk needs an &lt;a href=&#34;https://account.ipums.org/api_keys&#34; target=&#34;_blank&#34; rel=&#34;noopener&#34;&gt;IPUMS API key&lt;/a&gt;, set with &lt;code&gt;ipumsr::set_ipums_api_key()&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;library(ipumsr)
xwalk_zip &amp;lt;- download_supplemental_data(
  &amp;quot;nhgis&amp;quot;, &amp;quot;crosswalks/nhgis_blk2010_tr2020_state/nhgis_blk2010_tr2020_37.zip&amp;quot;,
  download_dir = tempdir(), overwrite = TRUE
)
# blk2010ge: 2010 block GEOID; tr2020ge: 2020 tract GEOID;
# weight: share of the block&#39;s population that falls in the tract
xwalk &amp;lt;- read_csv(unz(xwalk_zip, &amp;quot;nhgis_blk2010_tr2020_37.csv&amp;quot;),
                  col_types = cols(blk2010ge = &amp;quot;c&amp;quot;, tr2020ge = &amp;quot;c&amp;quot;, blk2010gj = &amp;quot;c&amp;quot;,
                                   tr2020gj = &amp;quot;c&amp;quot;, .default = &amp;quot;d&amp;quot;)) |&amp;gt;
  filter(tr2020ge %in% tr_shapes$GEOID)

blocks_2010 &amp;lt;- get_decennial(geography = &amp;quot;block&amp;quot;, state = &amp;quot;NC&amp;quot;,
                             county = counties,
                             variables = c(pop = &amp;quot;P001001&amp;quot;), year = 2010, sumfile = &amp;quot;sf1&amp;quot;) |&amp;gt;
  select(blk2010ge = GEOID, pop = value)

pop_2010 &amp;lt;- xwalk |&amp;gt;
  inner_join(blocks_2010, by = &amp;quot;blk2010ge&amp;quot;) |&amp;gt;
  summarise(pop_2010 = round(sum(pop * weight)), .by = tr2020ge) |&amp;gt;
  rename(GEOID = tr2020ge)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few tracts had very few people in 2010:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;tr_pop_2020 |&amp;gt;
  left_join(pop_2010, by = &amp;quot;GEOID&amp;quot;) |&amp;gt;
  mutate(pop_2010 = replace_na(pop_2010, 0)) |&amp;gt;
  filter(pop_2010 &amp;lt; 1000) |&amp;gt;
  arrange(pop_2010)
# # A tibble: 5 x 3
#   GEOID       pop_2020 pop_2010
#   &amp;lt;chr&amp;gt;          &amp;lt;dbl&amp;gt;    &amp;lt;dbl&amp;gt;
# 1 37183053619      501        0
# 2 37183980100        2        2
# 3 37063980100        9        4
# 4 37183980200        0       30
# 5 37183053609     1653      744
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One Wake (37183) tract went from no residents to about 500. Percent growth from a base that small is enormous which matters in Exercise 1.&lt;/p&gt;
&lt;h3 id=&#34;4-households-renters-and-income-from-the-acs&#34;&gt;4. Households, renters, and income from the ACS&lt;/h3&gt;
&lt;p&gt;Two ACS tables describe household characteristics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;B25003, Tenure&lt;/strong&gt;: &lt;code&gt;B25003_001&lt;/code&gt; is all occupied housing units (households) and &lt;code&gt;B25003_003&lt;/code&gt; those occupied by renters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;B19013, Median household income&lt;/strong&gt; in the past 12 months.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;tr_acs_hh_inc &amp;lt;- get_acs(geography = &amp;quot;tract&amp;quot;, state = &amp;quot;NC&amp;quot;, county = counties, year = 2023,
               survey = &amp;quot;acs5&amp;quot;, output = &amp;quot;wide&amp;quot;,
               variables = c(households = &amp;quot;B25003_001&amp;quot;, renter_hh = &amp;quot;B25003_003&amp;quot;,
                             med_income = &amp;quot;B19013_001&amp;quot;)) |&amp;gt;
  select(GEOID, households = householdsE, renter_hh = renter_hhE, med_income = med_incomeE)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;output = &amp;quot;wide&amp;quot;&lt;/code&gt; returns one row per tract with an estimate (&lt;code&gt;E&lt;/code&gt;) and a margin of error (&lt;code&gt;M&lt;/code&gt;) column per variable; only the estimates are kept here. Two quirks show up. Some tracts have &lt;strong&gt;no median income&lt;/strong&gt; (&lt;code&gt;NA&lt;/code&gt;), and a few have &lt;strong&gt;no households&lt;/strong&gt; at all. To see why, add the 2020 count of people living in &lt;strong&gt;group quarters&lt;/strong&gt; (&lt;code&gt;P5_001N&lt;/code&gt;): dorms, nursing homes, prisons, and barracks, where residents are counted as people but not as households.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;gq_2020 &amp;lt;- get_decennial(geography = &amp;quot;tract&amp;quot;, state = &amp;quot;NC&amp;quot;, county = counties, year = 2020,
                         sumfile = &amp;quot;pl&amp;quot;, output = &amp;quot;wide&amp;quot;,
                         variables = c(pop_2020 = &amp;quot;P1_001N&amp;quot;, group_quarters = &amp;quot;P5_001N&amp;quot;)) |&amp;gt;
  select(GEOID, pop_2020, group_quarters)

tr_acs_hh_inc |&amp;gt;
  filter(households &amp;lt; 10 | is.na(med_income)) |&amp;gt;
  left_join(gq_2020, by = &amp;quot;GEOID&amp;quot;)
# # A tibble: 6 × 6
#   GEOID       households renter_hh med_income pop_2020 group_quarters
#   &amp;lt;chr&amp;gt;            &amp;lt;dbl&amp;gt;     &amp;lt;dbl&amp;gt;      &amp;lt;dbl&amp;gt;    &amp;lt;dbl&amp;gt;          &amp;lt;dbl&amp;gt;
# 1 37063001501          4         4         NA     3430           3398
# 2 37063001503          0         0         NA     1861           1825
# 3 37063980100          0         0         NA        9              0
# 4 37135011601          8         8         NA     2114           2070
# 5 37183980100          5         5         NA        2              0
# 6 37183980200          0         0         NA        0              0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every tract with a missing median income has fewer than ten households; the Census Bureau does not publish a median from so few survey responses. Three of these tracts still have thousands of residents, nearly all of them in group quarters: they are college dormitory tracts around Duke and UNC, which have people but almost no households. The rest are 98xx special land-use tracts (RDU, RTP etc.) with almost no residents at all. Click a tract to see its attributes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;quirk_tracts &amp;lt;- tr_shapes |&amp;gt;
  left_join(tr_acs_hh_inc, by = &amp;quot;GEOID&amp;quot;) |&amp;gt;
  left_join(gq_2020, by = &amp;quot;GEOID&amp;quot;) |&amp;gt;
  filter(households &amp;lt; 10 | is.na(med_income)) |&amp;gt;
  mutate(kind = if_else(group_quarters &amp;gt; 1000, &amp;quot;Group Quarters&amp;quot;, &amp;quot;Special land use (98xx)&amp;quot;))

tmap_mode(&#39;view&#39;)

m_quirks &amp;lt;- tm_shape(tr_shapes) +
  tm_borders(col = &amp;quot;gray50&amp;quot;, lwd = 0.3) +
  tm_shape(quirk_tracts) +
  tm_polygons(fill = &amp;quot;kind&amp;quot;,
              fill.scale = tm_scale_categorical(values = c(&amp;quot;firebrick&amp;quot;, &amp;quot;steelblue&amp;quot;)),
              fill.legend = tm_legend(title = &amp;quot;&amp;quot;),
              fill_alpha = 0.6,
              id = &amp;quot;GEOID&amp;quot;,
              popup.vars = c(&amp;quot;households&amp;quot;, &amp;quot;pop_2020&amp;quot;, &amp;quot;group_quarters&amp;quot;, &amp;quot;med_income&amp;quot;)) +
  tm_basemap(carto_positron)

library(widgetframe)
frameWidget(tmap_leaflet(m_quirks))
&lt;/code&gt;&lt;/pre&gt;
&lt;div id=&#34;htmlwidget-1&#34; style=&#34;width:100%;height:480px;&#34; class=&#34;widgetframe html-widget&#34;&gt;&lt;/div&gt;
&lt;script type=&#34;application/json&#34; data-for=&#34;htmlwidget-1&#34;&gt;{&#34;x&#34;:{&#34;url&#34;:&#34;index.en_files/figure-html//widgets/widget_acs-quirks-map.html&#34;,&#34;options&#34;:{&#34;xdomain&#34;:&#34;*&#34;,&#34;allowfullscreen&#34;:false,&#34;lazyload&#34;:false}},&#34;evals&#34;:[],&#34;jsHooks&#34;:[]}&lt;/script&gt;
&lt;h3 id=&#34;5-county-median-income&#34;&gt;5. County median income&lt;/h3&gt;
&lt;p&gt;Housing programs compare incomes with the &lt;strong&gt;area median income&lt;/strong&gt; of a region. As a stand-in, we use each county’s median household income (&lt;strong&gt;B19013_001&lt;/strong&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;county_income &amp;lt;- get_acs(geography = &amp;quot;county&amp;quot;, state = &amp;quot;NC&amp;quot;, county = counties, year = 2023,
                         survey = &amp;quot;acs5&amp;quot;, variables = c(county_med_income = &amp;quot;B19013_001&amp;quot;)) |&amp;gt;
  transmute(county = NAME |&amp;gt; str_remove(&amp;quot; County, North Carolina&amp;quot;), county_med_income = estimate)
glimpse(county_income)
# Rows: 3
# Columns: 2
# $ county            &amp;lt;chr&amp;gt; &amp;quot;Durham&amp;quot;, &amp;quot;Orange&amp;quot;, &amp;quot;Wake&amp;quot;
# $ county_med_income &amp;lt;dbl&amp;gt; 79501, 88553, 101763
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&#34;6-putting-it-together&#34;&gt;6. Putting it together&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;tracts_dat &amp;lt;- tr_shapes |&amp;gt;
  st_drop_geometry() |&amp;gt; # needed because of land area is calculated. No need to keep the geometry
  left_join(pop_2010, by = &amp;quot;GEOID&amp;quot;) |&amp;gt;
  left_join(tr_pop_2020, by = &amp;quot;GEOID&amp;quot;) |&amp;gt;
  left_join(tr_acs_hh_inc, by = &amp;quot;GEOID&amp;quot;) |&amp;gt;
  mutate(pop_2010 = replace_na(pop_2010, 0)) |&amp;gt;
  as_tibble()
tract_shapes &amp;lt;- tr_shapes |&amp;gt; select(GEOID)

glimpse(tracts_dat)
# Rows: 340
# Columns: 8
# $ GEOID      &amp;lt;chr&amp;gt; &amp;quot;37063001706&amp;quot;, &amp;quot;37183052002&amp;quot;, &amp;quot;37183053507&amp;quot;, &amp;quot;37183054006&amp;quot;,~
# $ county     &amp;lt;chr&amp;gt; &amp;quot;Durham&amp;quot;, &amp;quot;Wake&amp;quot;, &amp;quot;Wake&amp;quot;, &amp;quot;Wake&amp;quot;, &amp;quot;Wake&amp;quot;, &amp;quot;Wake&amp;quot;, &amp;quot;Wake&amp;quot;, &amp;quot;~
# $ area_sqmi  [mi^2] 2.2781641 [mi^2], 1.4934289 [mi^2], 1.1125317 [mi^2], 0.85~
# $ pop_2010   &amp;lt;dbl&amp;gt; 4179, 4907, 3603, 3467, 3588, 2611, 4201, 4071, 5624, 4418,~
# $ pop_2020   &amp;lt;dbl&amp;gt; 5252, 5085, 3923, 3288, 3799, 2938, 3954, 4336, 5449, 5134,~
# $ households &amp;lt;dbl&amp;gt; 2710, 2064, 1773, 1215, 1942, 1185, 1487, 1605, 1811, 2537,~
# $ renter_hh  &amp;lt;dbl&amp;gt; 1496, 980, 940, 578, 1612, 375, 677, 98, 681, 1227, 963, 20~
# $ med_income &amp;lt;dbl&amp;gt; 79459, 61447, 62292, 59312, 59208, 124261, 72331, 191696, 1~
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The boundaries are kept in &lt;code&gt;tract_shapes&lt;/code&gt;, separate from the numbers, so most of the work below uses a plain table and only the spatial maps need geometry.&lt;/p&gt;
&lt;div class=&#34;alert alert-note&#34;&gt;
  &lt;div&gt;
    Strictly speaking merging decennial and ACS data is problematic though it is commonly done both in research and in practise.
  &lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&#34;exercises&#34;&gt;Exercises&lt;/h2&gt;
&lt;h3 id=&#34;exercise-1-density-and-growth&#34;&gt;Exercise 1: Density and growth&lt;/h3&gt;
&lt;p&gt;Create &lt;code&gt;tract_growth&lt;/code&gt;, a table with &lt;code&gt;GEOID&lt;/code&gt;, &lt;code&gt;density&lt;/code&gt; (2020 population per square mile), and &lt;code&gt;pct_growth&lt;/code&gt; (percent change from 2010 to 2020, rounded to one decimal). Then find &lt;code&gt;fastest&lt;/code&gt;, the GEOID of the fastest-growing tract among tracts with &lt;strong&gt;at least 1,000 residents in 2010&lt;/strong&gt;, as a single string.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hint:&lt;/em&gt; &lt;code&gt;mutate()&lt;/code&gt; adds the columns; &lt;code&gt;filter()&lt;/code&gt;, &lt;code&gt;slice_max()&lt;/code&gt;, and &lt;code&gt;pull()&lt;/code&gt; find the tract. Watch the pipe: &lt;code&gt;x * 100 |&amp;gt; round(1)&lt;/code&gt; rounds only the 100.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;
Solution
&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;tract_growth &amp;lt;- tracts_dat |&amp;gt;
  mutate(density = pop_2020 / area_sqmi,
         pct_growth = round(100 * (pop_2020 - pop_2010) / pop_2010, 1)) |&amp;gt;
  select(GEOID, density, pct_growth, pop_2010)

fastest &amp;lt;- tract_growth |&amp;gt;
  filter(pop_2010 &amp;gt;= 1000) |&amp;gt;
  slice_max(pct_growth, n = 1) |&amp;gt;
  pull(GEOID)
fastest
# [1] &amp;quot;37183053205&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h3 id=&#34;exercise-2-renters-and-income&#34;&gt;Exercise 2: Renters and income&lt;/h3&gt;
&lt;p&gt;Say you want to create a targeted housing program for neighbourhoods where most households rent and incomes are low. “Low” has to be judged against the local cost of living: the median household income is about 80,000 usd in Durham County but over 100,000 usd in Wake. So each tract is compared with &lt;strong&gt;its own county’s&lt;/strong&gt; median.&lt;/p&gt;
&lt;p&gt;Create &lt;code&gt;eligible&lt;/code&gt; tibble: the tracts where more than half of households rent &lt;strong&gt;and&lt;/strong&gt; the tract’s median income is below its county’s median. Include &lt;code&gt;GEOID&lt;/code&gt;, &lt;code&gt;county&lt;/code&gt;, and &lt;code&gt;renter_share&lt;/code&gt;. Leave out tracts with unknown income.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hint:&lt;/em&gt; join &lt;code&gt;county_income&lt;/code&gt; by &lt;code&gt;county&lt;/code&gt; so each tract has its own county median beside it.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;
Solution
&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;eligible &amp;lt;- tracts_dat |&amp;gt;
  left_join(county_income, by = &amp;quot;county&amp;quot;) |&amp;gt;
  mutate(renter_share = renter_hh / households) |&amp;gt;
  filter(renter_share &amp;gt; 0.5, med_income &amp;lt; county_med_income) |&amp;gt;
  select(GEOID, county, renter_share)
count(eligible, county)
# # A tibble: 3 x 2
#   county     n
#   &amp;lt;chr&amp;gt;  &amp;lt;int&amp;gt;
# 1 Durham    23
# 2 Orange    10
# 3 Wake      56
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;filter()&lt;/code&gt; drops rows where its condition is &lt;code&gt;NA&lt;/code&gt;, so tracts with no income or no households fall out on their own.&lt;/p&gt;
&lt;/details&gt;
&lt;h3 id=&#34;exercise-3-a-county-summary-function&#34;&gt;Exercise 3: A county summary function&lt;/h3&gt;
&lt;p&gt;Write a function &lt;code&gt;county_stats(df, county_name)&lt;/code&gt; that takes a tibble &lt;code&gt;df&lt;/code&gt; as argument and summarises it by &lt;code&gt;county_name&lt;/code&gt;. It should return a one-row table with &lt;code&gt;county&lt;/code&gt;, &lt;code&gt;n_tracts&lt;/code&gt;, &lt;code&gt;pop_2020&lt;/code&gt;, &lt;code&gt;density&lt;/code&gt;, and &lt;code&gt;pct_growth&lt;/code&gt; (from county totals, rounded to one decimal), and stops with an error if &lt;code&gt;county_name&lt;/code&gt; is not in &lt;code&gt;df&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hint:&lt;/em&gt; check the input with &lt;code&gt;county_name %in% df$county&lt;/code&gt;, then &lt;code&gt;filter()&lt;/code&gt; and &lt;code&gt;summarise()&lt;/code&gt;. Use the &lt;code&gt;df&lt;/code&gt; argument inside the function, not the global &lt;code&gt;tracts_dat&lt;/code&gt;.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;
Solution
&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;county_stats &amp;lt;- function(df, county_name) {
  if (!county_name %in% df$county) stop(&amp;quot;Unknown county: &amp;quot;, county_name)
  stats &amp;lt;- df |&amp;gt;
    filter(county == county_name) |&amp;gt;
    summarise(county = county_name,
              n_tracts = n(),
              density = sum(pop_2020) / sum(area_sqmi),
              pct_growth = round(100 * (sum(pop_2020) - sum(pop_2010)) / sum(pop_2010), 1),
              pop_2020 = sum(pop_2020)) |&amp;gt;
    select(county, n_tracts, pop_2020, density, pct_growth)
  return(stats)
}
county_stats(tracts_dat, &amp;quot;Durham&amp;quot;)
# # A tibble: 1 x 5
#   county n_tracts pop_2020  density pct_growth
#   &amp;lt;chr&amp;gt;     &amp;lt;int&amp;gt;    &amp;lt;dbl&amp;gt; [1/mi^2]      &amp;lt;dbl&amp;gt;
# 1 Durham       68   324833    1130.       21.4
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h3 id=&#34;exercise-4-loop-versus-map&#34;&gt;Exercise 4: Loop versus map&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;for&lt;/code&gt; loop and &lt;code&gt;map()&lt;/code&gt; do the same job: repeat a step for each item. A loop spells out the bookkeeping (an empty container, an index, storing each result); &lt;code&gt;map()&lt;/code&gt; hides it and returns a list. Writing both and checking that they agree is a good way to see exactly what each one produces.&lt;/p&gt;
&lt;p&gt;Run &lt;code&gt;county_stats()&lt;/code&gt; for every county, once with a &lt;code&gt;for&lt;/code&gt; loop (&lt;code&gt;stats_loop&lt;/code&gt;) and once with &lt;code&gt;map()&lt;/code&gt; (&lt;code&gt;stats_map&lt;/code&gt;), taking the county names from the data. Then compare the two with &lt;code&gt;identical()&lt;/code&gt;. If they differ, find out why. (Don’t use &lt;code&gt;group_by()&lt;/code&gt;: the point is to repeat the function.)&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hint:&lt;/em&gt; &lt;code&gt;identical()&lt;/code&gt; compares names and table types too. Storing results with &lt;code&gt;stats_loop[[cty]]&lt;/code&gt; names them by county; &lt;code&gt;map()&lt;/code&gt; returns an unnamed list.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;
Solution
&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;counties &amp;lt;- unique(tracts_dat$county)

results &amp;lt;- list()
for (i in seq_along(counties)) {
  results[[i]] &amp;lt;- county_stats(tracts_dat, counties[i])
}
stats_loop &amp;lt;- bind_rows(results)

stats_map &amp;lt;- map(counties, \(x) county_stats(tracts_dat, x)) |&amp;gt; list_rbind()

identical(stats_loop, stats_map)
# [1] TRUE
stats_map
# # A tibble: 3 x 5
#   county n_tracts pop_2020  density pct_growth
#   &amp;lt;chr&amp;gt;     &amp;lt;int&amp;gt;    &amp;lt;dbl&amp;gt; [1/mi^2]      &amp;lt;dbl&amp;gt;
# 1 Durham       68   324833    1130.       21.4
# 2 Wake        230  1129410    1353.       25.4
# 3 Orange       42   148696     374.       11.2
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h3 id=&#34;exercise-5-fix-the-income-categories&#34;&gt;Exercise 5: Fix the income categories&lt;/h3&gt;
&lt;p&gt;The US Department of Housing and Urban Development (HUD) defines eligibility for housing assistance by comparing household income with the &lt;strong&gt;area median income&lt;/strong&gt; (AMI): households below 80% of AMI count as “low income,” and many affordable-housing programs serve households up to 120% of AMI. Comparing a tract’s median income with its county’s median is a rough version of this, useful for screening neighbourhoods.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tract income ÷ county median&lt;/th&gt;
&lt;th&gt;&lt;code&gt;income_class&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;below 0.8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;quot;low&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.8 to 1.2 (inclusive)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;quot;moderate&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;above 1.2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;quot;upper&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tract income missing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;quot;unknown&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The following function doesn’t work. Run it, read the error, and fix it. There is more than one bug.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;income_class &amp;lt;- function(df, county_income) {
  classified &amp;lt;- df |&amp;gt;
    left_join(county_income, by = &amp;quot;county&amp;quot;) |&amp;gt;
    mutate(
      ratio = county_med_income / med_income,
      income_class = if (ratio &amp;lt; 0.8) {
        &amp;quot;low&amp;quot;
      } else if (ratio &amp;lt;= 1.2) {
        &amp;quot;moderate&amp;quot;
      } else {
        &amp;quot;upper&amp;quot;
      }
    )
  return(classified)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Hint:&lt;/em&gt; &lt;code&gt;if ()&lt;/code&gt; checks one TRUE/FALSE, but a column has one per tract. Check which way the ratio is divided, and what happens to tracts with no income.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;
Solution
&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;income_class &amp;lt;- function(df, county_income) {
  classified &amp;lt;- df |&amp;gt;
    left_join(county_income, by = &amp;quot;county&amp;quot;) |&amp;gt;
    mutate(
      ratio = med_income / county_med_income,   # tract over county
      income_class = case_when(                 # works on whole columns
        is.na(ratio) ~ &amp;quot;unknown&amp;quot;,               # missing income first
        ratio &amp;lt; 0.8 ~ &amp;quot;low&amp;quot;,
        ratio &amp;lt;= 1.2 ~ &amp;quot;moderate&amp;quot;,
        TRUE ~ &amp;quot;upper&amp;quot;
      )
    )
  return(classified)
}

tract_classes &amp;lt;- income_class(tracts_dat, county_income)
count(tract_classes, county, income_class) |&amp;gt;
  pivot_wider(names_from = income_class, values_from = n)
# # A tibble: 3 x 5
#   county   low moderate unknown upper
#   &amp;lt;chr&amp;gt;  &amp;lt;int&amp;gt;    &amp;lt;int&amp;gt;   &amp;lt;int&amp;gt; &amp;lt;int&amp;gt;
# 1 Durham    18       33       3    14
# 2 Orange    12       16       1    13
# 3 Wake      64       87       2    77
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h3 id=&#34;exercise-6-refactor-the-summary-table&#34;&gt;Exercise 6: Refactor the summary table&lt;/h3&gt;
&lt;p&gt;Before analysing a new dataset, check each column: how many values are missing, and whether the smallest and largest values make sense. This code builds such a table by copying the same block five times. It runs, but copy-and-paste hides mistakes, and two of the copies are wrong. Can you spot them?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;pop_2010_stats &amp;lt;- tibble(variable = &amp;quot;pop_2010&amp;quot;, n_missing = sum(is.na(tracts_dat$pop_2010)),
                         min = min(tracts_dat$pop_2010, na.rm = TRUE),
                         median = median(tracts_dat$pop_2010, na.rm = TRUE),
                         max = max(tracts_dat$pop_2010, na.rm = TRUE))
pop_2020_stats &amp;lt;- tibble(variable = &amp;quot;pop_2020&amp;quot;, n_missing = sum(is.na(tracts_dat$pop_2020)),
                         min = min(tracts_dat$pop_2020, na.rm = TRUE),
                         median = median(tracts_dat$pop_2020, na.rm = TRUE),
                         max = max(tracts_dat$pop_2010, na.rm = TRUE))
households_stats &amp;lt;- tibble(variable = &amp;quot;households&amp;quot;, n_missing = sum(is.na(tracts_dat$households)),
                           min = min(tracts_dat$households, na.rm = TRUE),
                           median = median(tracts_dat$households, na.rm = TRUE),
                           max = max(tracts_dat$households, na.rm = TRUE))
renter_stats &amp;lt;- tibble(variable = &amp;quot;renter_hh&amp;quot;, n_missing = sum(is.na(tracts_dat$renter_hh)),
                       min = min(tracts_dat$renter_hh, na.rm = TRUE),
                       median = median(tracts_dat$renter_hh, na.rm = TRUE),
                       max = max(tracts_dat$renter_hh, na.rm = TRUE))
income_stats &amp;lt;- tibble(variable = &amp;quot;med_income&amp;quot;, n_missing = sum(is.na(tracts_dat$med_income)),
                       min = min(tracts_dat$med_income, na.rm = TRUE),
                       median = median(tracts_dat$med_income),
                       max = max(tracts_dat$med_income, na.rm = TRUE))
summary_table &amp;lt;- bind_rows(pop_2010_stats, pop_2020_stats, households_stats, renter_stats, income_stats)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rewrite it as a function &lt;code&gt;describe(df, var)&lt;/code&gt; that returns one row for the column named &lt;code&gt;var&lt;/code&gt; and stops with a clear error if &lt;code&gt;df&lt;/code&gt; has no such column, and build the table by calling it once per variable with &lt;code&gt;map()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hint:&lt;/em&gt; &lt;code&gt;var&lt;/code&gt; is a string, so get the column with &lt;code&gt;df[[var]]&lt;/code&gt;. &lt;code&gt;map(x, f)&lt;/code&gt; passes only one argument; pass the others with an anonymous function, &lt;code&gt;\(v) describe(tracts_dat, v)&lt;/code&gt;.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;
Solution
&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;describe &amp;lt;- function(df, var) {
  if (!var %in% names(df)) stop(&amp;quot;No column named &amp;quot;, var)
  x &amp;lt;- df[[var]]
  row &amp;lt;- tibble(variable = var,
                n_missing = sum(is.na(x)),
                min = min(x, na.rm = TRUE),
                median = median(x, na.rm = TRUE),
                max = max(x, na.rm = TRUE))
  return(row)
}

vars &amp;lt;- c(&amp;quot;pop_2010&amp;quot;, &amp;quot;pop_2020&amp;quot;, &amp;quot;households&amp;quot;, &amp;quot;renter_hh&amp;quot;, &amp;quot;med_income&amp;quot;)
map(vars, \(v) describe(tracts_dat, v)) |&amp;gt; list_rbind()
# # A tibble: 5 x 5
#   variable   n_missing   min median    max
#   &amp;lt;chr&amp;gt;          &amp;lt;int&amp;gt; &amp;lt;dbl&amp;gt;  &amp;lt;dbl&amp;gt;  &amp;lt;dbl&amp;gt;
# 1 pop_2010           0     0  3663    7887
# 2 pop_2020           0     0  4384   14610
# 3 households         0     0  1809    4672
# 4 renter_hh          0     0   608    2605
# 5 med_income         6 23198 90936. 242647
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two bugs: the &lt;code&gt;pop_2020&lt;/code&gt; block takes its maximum from &lt;code&gt;pop_2010&lt;/code&gt;, and the income block’s median has no &lt;code&gt;na.rm = TRUE&lt;/code&gt;, so it comes out &lt;code&gt;NA&lt;/code&gt;. With a function, each statistic is written once. The check matters because &lt;code&gt;df[[var]]&lt;/code&gt; quietly returns &lt;code&gt;NULL&lt;/code&gt; for a misspelled name, and without the check you get a row with &lt;code&gt;0&lt;/code&gt; missing, a minimum of &lt;code&gt;Inf&lt;/code&gt;, a maximum of &lt;code&gt;-Inf&lt;/code&gt;, and no median column at all, with only warnings to flag it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;describe(tracts_dat, &amp;quot;income&amp;quot;)
# Error in `describe()`:
# ! No column named income
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h3 id=&#34;exercise-7-maps-and-histograms&#34;&gt;Exercise 7: Maps and histograms&lt;/h3&gt;
&lt;p&gt;A map (statistical graphic not a function) shows &lt;strong&gt;where&lt;/strong&gt; values are; a histogram shows &lt;strong&gt;how they are distributed&lt;/strong&gt;: whether most tracts cluster in the middle, how long the tail of high incomes is, and how many tracts sit at the extremes. Together they answer questions neither answers alone.&lt;/p&gt;
&lt;p&gt;Join &lt;code&gt;tracts_dat&lt;/code&gt; onto &lt;code&gt;tract_shapes&lt;/code&gt;, write &lt;code&gt;county_map(df, county_name, var)&lt;/code&gt; with &lt;strong&gt;tmap&lt;/strong&gt; and &lt;code&gt;county_hist(df, county_name, var)&lt;/code&gt; with &lt;strong&gt;ggplot2&lt;/strong&gt;, make one map and one histogram of median income per county with &lt;code&gt;map()&lt;/code&gt;, and arrange them in a grid with &lt;strong&gt;cowplot&lt;/strong&gt;: maps on top, histograms below.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hint:&lt;/em&gt; tmap takes column names as strings, so &lt;code&gt;tm_polygons(fill = var)&lt;/code&gt; works as is; ggplot needs &lt;code&gt;aes(x = .data[[var]])&lt;/code&gt;. Start the join from &lt;code&gt;tract_shapes&lt;/code&gt; so the result stays an sf object. Convert each tmap to a grid graphic with &lt;code&gt;tmap_grob()&lt;/code&gt;; &lt;code&gt;cowplot::plot_grid()&lt;/code&gt; accepts grobs and ggplots alike.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;
Solution
&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;tract_sf &amp;lt;- tract_shapes |&amp;gt; left_join(tracts_dat, by = &amp;quot;GEOID&amp;quot;)

county_map &amp;lt;- function(df, county_name, var) {
  if (!county_name %in% df$county) stop(&amp;quot;Unknown county: &amp;quot;, county_name)
  d &amp;lt;- filter(df, county == county_name)
  m &amp;lt;- tm_shape(d) +
    tm_polygons(fill = var, col = NULL,
                fill.scale = tm_scale_continuous(
                  values = &amp;quot;viridis&amp;quot;,
                  label.format = list(fun = scales::label_dollar(scale_cut = scales::cut_short_scale()))),
                fill.legend = tm_legend(title = &amp;quot;&amp;quot;, frame = FALSE, orientation = &amp;quot;landscape&amp;quot;, width = 22,
                                        position = tm_pos_out(&amp;quot;center&amp;quot;, &amp;quot;bottom&amp;quot;))) +
    tm_title(county_name) +
    tm_layout(frame = FALSE)
  return(m)
}

county_hist &amp;lt;- function(df, county_name, var) {
  d &amp;lt;- filter(df, county == county_name)
  p &amp;lt;- ggplot(d, aes(x = .data[[var]])) +
    geom_histogram(bins = 15) +
    scale_x_continuous(labels = scales::label_dollar(scale_cut = scales::cut_short_scale())) +
    labs(title = county_name, x = NULL, y = &amp;quot;Tracts&amp;quot;) +
    theme_minimal()
  return(p)
}

county_maps &amp;lt;- map(counties, \(cn) county_map(tract_sf, cn, &amp;quot;med_income&amp;quot;) |&amp;gt; tmap_grob())
county_hists &amp;lt;- map(counties, \(cn) county_hist(tract_sf, cn, &amp;quot;med_income&amp;quot;))

plot_grid(plotlist = c(county_maps, county_hists), nrow = 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;img src=&#34;https://nkaza.github.io/post/tidycensus-nhgis/index.en_files/figure-html/ex7-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;
&lt;/details&gt;
&lt;p&gt;These solutions provided are one possible solution in R, may be not even the most efficient one. But it would be useful to see if you can best these solutions with your code from an efficiency or a readability perspective.&lt;/p&gt;
&lt;h2 id=&#34;conclusions&#34;&gt;Conclusions&lt;/h2&gt;
&lt;p&gt;The Census API, tidycensus, tigris, and the NHGIS crosswalks make the data easy to get. That ease does not replace careful thought about which data to trust, which denominators make sense, which comparisons are fair across time and place, and how to tell a good story.&lt;/p&gt;
</description>
    </item>
    
  </channel>
</rss>
