options(paged.print = FALSE,
tigris_use_cache = TRUE)
libraries <- list(
"sf", "collapse", "ggplot2", "magrittr", "dplyr",
"zeallot", "purrr", "patchwork"
)
invisible(lapply(libraries, library, character.only = TRUE))
microbenchmark <- microbenchmark::microbenchmark
glue <- glue::glue
annotation_map_tile <- ggspatial::annotation_map_tile
set_units <- units::set_units
st_remove_holes <- nngeo::st_remove_holes
get_acs <- tidycensus::get_acs
rownames_to_columns <- tibble::rownames_to_column
year <- 2024
crs <- 6528
caption <- glue("Source: census.gov, acs5, {year}")Demographic and Economic Variance in Albuquerque Part 1
Rapid wrangling with the collapse package
Introduction
This article follows on from an earlier article in which I used data from the US Census Bureau to see how age, race, and education vary between city council districts in Albuquerque, New Mexico. In some cases, the variations appeared striking. Now I would like to apply some formal testing to see if this impression of variance is backed up statistically. As in the prior article, I will use data from the US Census American Community Survey. In addition to the demographic variables of education and race, I will add a number of economic indicators such as income levels, housing values, and inequality measurements. Before being able to run the tests, and eventual models, a significant amount of data wrangling will be necessary in order to get the data in an appropriate form,with appropriate variables, from the raw data.
Having obtained the data, I will need clean out bad data, fill in missing values, engineer new variables of interest, and divide the spatial dataset by council districts. I will then explore correlation and spatial autocorrelation between the chosen variables. Next I will perform an analysis of variance, but since almost none of the variables will turn out to be normally distributed, some more work will be necessary to transform the data. I will also need to address issues of homogeneity of variance among the districts. Finally, I will do some regression modeling. Since my primary target variable is the county districts, this will require multinomial logistic regression models.
This would be far too much for one article, so I will split the excercise into four parts. The current article will be concerned with preparing the data. I have another purpose here, which is to highlight how data analysis and transformation can be significantly sped up by using the collapse package instead of the ubiquitous dplyr library. dplyr is without doubt wonderfully expressive and, as a key part of the popular tidyverse, sets a syntactical approach which has been widely adopted. While the syntactic standard is excellent, it is not, unfortunately, a particularly fast library. collapse, on the other hand, while embracing the syntactical conventions of dplyr (unlike data.table), focuses on speed. It is orders of magitude faster, and it is even much faster than data.table. Written in C/C++, it has “fast” versions of many commonly-used dplyr functions, simply with a prepended “f”, eg. fmutate rather than mutate. It provides many new fast statistical functions, transformations, and convenience functions. Throughout, I will show speed comparisons between dplyr and collapse using the microbenchmark package.
Loading libraries
I will begin by loading the required libraries and setting some constants. This project will require functions dozens of libraries, but in most cases only one or two function from any given library is needed. Rather than loading entire packages, I will load the primary libraries in full, and then the single functions from the other packages. This approach also has the virtue of declaring all package dependencies at the beginning, an excellent programming practice.
Council District Geometries
I’ll start by preparing the council district spatial dataset. I did the same in the last article, but this time I’ll use collapse functions.
council_dists <- st_read("data/BC_CityCouncil/ABQ_CityCouncils.shp") %>%
fselect(district = DISTRICTNU) %>%
ftransform(district = paste("Dist", district)) %>%
roworder(district) %>%
st_remove_holes %>%
st_cast("MULTIPOLYGON")
los_ranchos <- st_read("data/LosRanchos/LosRanchos.shp") %>%
fselect(district = Name)
council_dists <-
rowbind(council_dists, los_ranchos) %>%
st_transform(crs)fselect works just like select. collapse also has an fmutate function which is the fast version of mutate, and which I could have used here. When no grouping is required, however, ftransform is even faster than fmutate because it evaluates all arguments simultaneously. roworder replaces arrange, and rowbind replaces rbind. Finally, qF is used instead of factor. collapse offers a number of functions for fast conversion between data types, such as qTBL to convert to a tibble, qM to convert to a matrix, and qDF to convert to a data frame. In general, the more primitive the type, the faster the processing. When spatial information is unnecessary, I will use data frames for processing, converting back to spatial data frames for mapping.
Note that, after removing the “holes”, the geometries of council_dists will be a mix of POLYGONSs and MULTIPOLYGONs. This will generate errors in various future operations, so I will use st_cast to ensure consistency.
My study area is a rectangular region that is a bit smaller than the actual city limits. This will cut out some outlying areas. Also, as mentioned before, there are many patches of unincorporated county in the geographic area. Above I used st_remove_holes to include enclaves of unincorporated county in the respective districts. For the rest, I will explicitly label these areas as “Unincorporated”, obtaining the geometries for the region by taking the st_difference between the rectangular bounding box of the council_dists and the union of the council district geometries.
dist_box = c(xmin = 452000, xmax = 479833,
ymin = 443500, ymax = 467858)
council_dists %<>%
st_crop(dist_box) %>%
st_cast("MULTIPOLYGON")
difference_sfc <-
st_difference(
st_as_sfc(st_bbox(council_dists)),
st_union(council_dists$geometry)
)
difference_sf <-
st_sf(data.frame(district = "Unincorporated"),
geometry = difference_sfc
)
council_dists <- rowbind(difference_sf, council_dists)I have used %<>%, a lesser-used function from magrittr, which replaces the tedious yet ubiquitous idiom data <- data %>% with the simpler and shorter data %<>%. It is similar to modifiying by reference. I have also, again, needed to recast the geometries after the cropping operation. Now I will create a base plot for the rest of the study which shows the district boundaries.
dist_plot <- function(df = council_dists, labels = TRUE) {
p <- ggplot(df) +
annotation_map_tile(
type = "osm", zoomin = -1, cachedir = "~/.cache/maps/"
) +
geom_sf(aes(color = district), linewidth = 2) +
scale_color_brewer(palette = "Set3") +
labs(title = "Albuquerque, NM",caption = caption) +
theme_void()
if (labels) {
p <- p + geom_sf_text(aes(label = district)) + guides(color = "none")
}
p
}
council_dists %<>%
fsubset(district %!in% c("Los Ranchos", "Unincorporated"))
dist_plot(labels = FALSE)I will save the council district definitions for future use.
st_write(council_dists, "data/district_variance.gpkg",
layer = "council_dists", append = F)Census Data
Fetching
The get_acs() function from tidycensus obtains information from the American Community Survey tables published by the US Census Department. By supplying a named vector to the variables argument, the columns of the returned data table will be automatically renamed. Setting geometry = TRUE returns an sf object, and output = "wide" gives a column for each variable. After obtaining the data, I will remove the margin of error columns, remove the trailing “E” from the remaining column names, and transform the geometry to an appropriate coordinate reference system. get_vars is a more flexible version of fselect. As you can see, it can select by either index or name, and can also use regular expressions for column selection.
vars_census <- c(
House.Value.Med = "B25077_001",
Housing.Units = "B25001_001",
Renter.Occupied = "B25003_003",
Vacant = "B25002_003",
Income.Med = "DP03_0062",
Gini = "B19083_001",
Rent.Med = "B25031_001",
Foreign.Born.Pct = "DP02_0094P",
Hisp.Pct = "DP05_0090P",
In.Poverty = "B17001A_002",
Age.Med = "B01002_001",
College.Pct = "DP02_0068P",
Population = "B01003_001"
)
bern_data <- get_acs(
geography = "tract",
state = "NM",
county = "Bernalillo",
variables = vars_census,
output = "wide",
year = year,
geometry = TRUE,
cache_table = TRUE
) %>%
get_vars(c("E$", "GEOID"), regex = TRUE) %>%
get_vars(-2) %>%
frename(\(x) sub("E$", "", x)) %>%
st_transform(crs)Getting data from the 2020-2024 5-year ACS
Fetching data by table type ("B/C", "S", "DP") and combining the result.
Cleaning
Let’s take a look at the data before splitting it between districts. I’ll use qsu, collapse’s fast version of summary.
qsu(bern_data) N Mean SD Min Max
GEOID 176 - - - -
House.Value.Med 166 303775.904 126616.382 28700 869000
Housing.Units 176 1720.1477 685.5887 0 3814
Renter.Occupied 176 575.233 470.4958 0 2423
Vacant 176 98.017 87.0864 0 403
Gini 173 0.4183 0.0737 0.068 0.5929
Rent.Med 167 1306.5868 461.5261 425 3501
In.Poverty 176 235.892 207.3682 0 1161
Age.Med 174 40.9632 8.4063 19.8 66.3
Population 176 3829.1477 1636.8032 0 11669
Income.Med 172 75168.8779 34757.922 19780 250001
Foreign.Born.Pct 174 10.1443 7.5109 0 39.2
Hisp.Pct 174 47.4213 21.3292 0 93.2
College.Pct 174 37.681 18.8133 0 84.8
It is worth noting that the geometry column does not appear in this summary. For the most part, collapse ignores the fact that the data is an sf object.
There is some clean-up to do. There are some missing values to explore, and oddities like areas where the population total is 0. I’ll deal with the zero-population areas first. fsubset here is like dplyr’s filter. collapse also provides a fast versions of the logical operators ==, !=, and %in% (%==%, %!=%, %iin%).
bern_data %>%
fsubset(Population %==% 0)Simple feature collection with 2 features and 14 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 413766.6 ymin: 429075.1 xmax: 509166.4 ymax: 468330.2
Projected CRS: NAD83(2011) / New Mexico Central
# A tibble: 2 × 15
GEOID House.Value.Med Housing.Units Renter.Occupied Vacant Gini Rent.Med
<chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 350019408… NA 0 0 0 NA NA
2 350019803… NA 0 0 0 NA NA
# ℹ 8 more variables: In.Poverty <dbl>, Age.Med <dbl>, Population <dbl>,
# Income.Med <dbl>, Foreign.Born.Pct <dbl>, Hisp.Pct <dbl>,
# College.Pct <dbl>, geometry <MULTIPOLYGON [m]>
It is worth comparing this with the dyplr equivalent.
microbenchmark(
dp = bern_data %>% filter(Population == 0),
co = bern_data %>% fsubset(Population %==% 0)
)Unit: microseconds
expr min lq mean median uq max neval cld
dp 704.760 729.9025 751.36914 742.195 750.7650 1612.383 100 a
co 7.432 9.1285 13.60216 15.406 16.1865 44.255 100 b
The collapse version of this very common operation is about 50 times faster than dplyr’s! Now, let’s see where the tract is.
dist_plot() +
geom_sf(data = bern_data, color = "red") +
geom_sf(data = fsubset(bern_data, Population %==% 0),
fill = "navy") +
lims(x = dist_box[1:2], y = dist_box[3:4])Zoom: 11
This tract is the state fairgrounds. The other must be outside the study area. I’ll remove both.
bern_data %<>% fsubset(Population %!=% 0)The population of 16 is somewhat suspicious.
dist_plot() +
geom_sf(data = bern_data, color = "red") +
geom_sf(
data = fsubset(bern_data, Population < 20),
fill = "navy"
) +
lims(x = dist_box[1:2], y = dist_box[3:4])Zoom: 11
This is the Petroglyph National Monument, and the 16 people must be park rangers. I’ll remove this as well.
bern_data %<>% fsubset(Population > 20)This only leaves the missing values. Let’s take a closer look. keep is from the purrr library, allowing functional selection of columns.
na_cols <- st_drop_geometry(bern_data) %>%
keep(~anyNA(.x)) %>% colnames
bern_data %>%
fsubset(missing_cases(bern_data)) %>%
gv(na_cols) %>% st_drop_geometry# A tibble: 12 × 4
House.Value.Med Gini Rent.Med Income.Med
* <dbl> <dbl> <dbl> <dbl>
1 206500 0.592 NA 50329
2 NA 0.4 867 30810
3 NA 0.476 861 27168
4 NA 0.419 956 33875
5 NA 0.453 942 37088
6 869000 0.357 NA 250001
7 225100 0.403 NA 87645
8 NA 0.373 1095 48333
9 271500 0.394 NA 72926
10 98900 0.306 NA 46783
11 NA 0.333 1766 83774
12 NA NA NA NA
As you might guess missing_cases is from collapse, and is the fast, though opposite, of complete.cases. complete.cases, however, cannot be used on an sf object without dropping the geometry, while missing_cases can, and it is nearly 100 times faster than the dplyr idiom. (One important aspect of collapse is that it preserves attributes, and is generally agnositc as to the class of a table, although this is not entirely seamless for sf objects.) collapse also provides “short-hand” versions of some functions. In this case gv is short for get_vars.
microbenchmark(
dp = bern_data %>%
filter(if_any(everything(), is.na)),
co = bern_data %>%
fsubset(missing_cases(bern_data))
)Unit: microseconds
expr min lq mean median uq max neval cld
dp 1713.276 1794.0215 1971.39020 1892.5660 2115.654 3397.010 100 a
co 12.313 14.8835 24.37662 24.5325 31.324 48.541 100 b
In this case, collapse is nearly 100 times faster.
These observations do seem worth preserving. I will fill in the missing values based on the median values for each variable, mean in the case of Gini, but I would like these values to be based on the averages for the specific district, rather than an overall averages, so I will go ahead and split the data into districts at this point.
I want to remember the rows with missing values so I can check that they get filled in. I can’t just use row indices, since these will change after splitting into districts. Instead I’ll grab the GEOIDs. I am also using shorthand verbs provided by collapse: gv, short for get_vars, and ss, short for fsubset.
missing <- (bern_data %>%
st_drop_geometry() %>%
ss(missing_cases(bern_data)))[["GEOID"]]I will preserve the county data for future use.
st_write(bern_data, "data/district_variance.gpkg",
layer = "bern_data", append = F)The Albuquerque District Dataset
Splitting the data requires intersecting the county census data with the geometries from the district dataset. Any given tract’s boundaries may cross two or even three districts. Since some of the variables are extensive, representing counts, these values will need to be apportioned based on the percentage area represented by each subsection. I’ll calculate the initial area of each tract, then the areas of each newly-created sub-section, which will then allow me to divide each tract’s data by the percentage area lying in the newly created areas. I’ll also make district a factor. qF is a faster form of factor.
abq_data <- bern_data %>%
ftransform(area = st_area(.)) %>%
st_intersection(council_dists) %>%
ftransform(area_pct = as.numeric(st_area(.) / area)) %>%
fsubset(district %!iin% c("Los Ranchos", "Unincorporated")) %>%
ftransform(district = qF(district))Variable Engineering
I want to create some new variables from the raw data. First, I will turn the raw poverty numbers into a percentage of each tract’s population. I’ll also create housing and population density variables based on tract area, and vacancy and renter occupied variables that are a percentage of available housing units in each tract.
ftransformv takes a vector of column identifiers, in this case column names, and applies a function to them, in this case an anonymous function, using the FUN argument. ftransform is similar to fmutate, although it has some different rules, such as the variables being non-recursive. I’m also taking advantage of some quick column arithmetic. %c/% is shorthand for column division. collapse provides a range of such functions for row and column math, eg. %c*%. Note also that ftransform allows you to remove colums easily with the syntax variable = NULL.
Finally, I will use st_cast to ensure consistent geometries. After intersection operation, there are likely to be a mix of POLYGONs and MULTIPOLYGONs, which will cause trouble down the line for the spatial analyses.
ext_vars <- c("Population", "Housing.Units", "Renter.Occupied", "Vacant", "In.Poverty")
abq_data %<>%
ftransformv(
ext_vars,
FUN = function(x) round(x * abq_data$area_pct, 0)
) %>%
ftransform(
In.Poverty.Pct = In.Poverty %c/% Population * 100,
Population.Density = Population %c/% area %>%
set_units("1/km^2") %>% as.numeric,
Vacant.Pct = Vacant %c/% Housing.Units * 100,
Renter.Occupied.Pct = Renter.Occupied %c/% Housing.Units * 100,
Housing.Density = Housing.Units %c/% area %>%
set_units("1/km^2") %>% as.numeric,
In.Poverty = NULL, Vacant = NULL, Housing.Units = NULL,
Renter.Occupied = NULL, area = NULL
) %>%
colorder(district, pos = "front") %>%
st_cast("MULTIPOLYGON")More Cleaning
Now I want to fill in the missing values with grouped averages. I’ll start by creating a grouping object. I could simply provide a column, but it is somewhat faster to do it this way if you plan to re-use the same grouping multiple times. This is similar, to the ftransform above, but the syntax is a little more complicated since I need to supply the g (group) and TRA (transformation) arguments. There are ten different possible values for TRA, including “fill” for replacing all variables, “-” for subtracting (centering) and “/” for dividing (scaling). In this case I am simply replacing the NA values.
I am modifying the data by reference here by using settransform rather than transform. A number of collapse’s functions provide this alternative.
dist_grp <- GRP(abq_data$district)
settransform(abq_data,
fmean(list(
In.Poverty.Pct = In.Poverty.Pct,
Vacant.Pct = Vacant.Pct,
Renter.Occupied.Pct = Renter.Occupied.Pct),
g = dist_grp, TRA = "replace_NA"))
settransform(abq_data,
fmedian(list(
House.Value.Med = House.Value.Med,
Rent.Med = Rent.Med, Income.Med = Income.Med),
g = dist_grp, TRA = "replace_NA"))
abq_data %>%
fsubset(GEOID %iin% missing) %>%
gv(na_cols)Simple feature collection with 16 features and 4 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 452000 ymin: 443500 xmax: 479832.8 ymax: 467857.3
Projected CRS: NAD83(2011) / New Mexico Central
# A tibble: 16 × 5
House.Value.Med Gini Rent.Med Income.Med geometry
<dbl> <dbl> <dbl> <dbl> <MULTIPOLYGON [m]>
1 241200 0.419 956 33875 (((462930.7 453105.8, 462989.7 453…
2 225100 0.403 1094 87645 (((457618.8 446326, 457657.5 44668…
3 219100 0.373 1095 48333 (((453727.8 448553, 453809.7 44812…
4 357700 0.453 942 37088 (((467226.5 458350.1, 467482.7 458…
5 869000 0.357 1335 250001 (((474183.7 463267.7, 473833.1 463…
6 271500 0.394 1470. 72926 (((457080.4 467237.4, 457213.1 467…
7 98900 0.306 1470. 46783 (((454093.8 465851.9, 454092.9 465…
8 206500 0.592 920. 50329 (((474247.4 451766.8, 474247.4 451…
9 227650 0.4 867 30810 (((469320.7 450905.4, 469722.4 450…
10 227650 0.476 861 27168 (((465597.1 454623.1, 465620.1 454…
11 227650 0.333 1766 83774 (((466956.6 447739.8, 466998.7 447…
12 206500 0.592 1138 50329 (((474247.5 452850.5, 474251.4 452…
13 248600 0.476 861 27168 (((465743 454763.3, 465738.4 45475…
14 248600 0.453 942 37088 (((465992.2 457115, 466122.6 45737…
15 206500 0.592 1186 50329 (((474243.9 451602.1, 474247.2 451…
16 251700 0.333 1766 83774 (((474223.8 450039.5, 474223.8 449…
All missing values have been filled in. However, there is a new problem:
qsu(abq_data) N Mean SD Min Max
district 256 - - - -
GEOID 256 - - - -
House.Value.Med 256 295625.195 116039.083 28700 869000
Gini 256 0.4266 0.07 0.2313 0.5929
Rent.Med 256 1279.9629 434.2917 632 3501
Age.Med 256 40.3887 8.2295 19.8 66.3
Population 256 2068.5977 2095.4846 0 11152
Income.Med 256 71624.9805 32590.6795 19780 250001
Foreign.Born.Pct 256 10.4441 7.5116 0 39.2
Hisp.Pct 256 48.0937 20.604 10.6 93.2
College.Pct 256 37.8488 18.01 2.8 84.8
area_pct 256 0.5266 0.4622 0 1
In.Poverty.Pct 256 5.528 5.0507 0 33.3333
Population.Density 256 1009.1566 1115.3565 0 5955.4623
Vacant.Pct 256 4.7517 4.2573 0 20.2716
Renter.Occupied.Pct 256 34.1214 23.0123 0 100
Housing.Density 256 476.5967 535.6389 0 2575.2589
The split data now contains new areas with a population of 0. As a result of the splitting, there are numerous small fragments of tracts containing little if any information. To address this, I will remove all of the new rows which represent less than 1% of the original area. I’ll also remove unnecessary columns.
abq_data %<>%
fsubset(area_pct > .01) %>%
ftransform(GEOID = NULL, Population = NULL, area_pct = NULL)
qsu(abq_data) N Mean SD Min Max
district 173 - - - -
House.Value.Med 173 301837.283 118373.506 28700 869000
Gini 173 0.4222 0.0707 0.2313 0.5929
Rent.Med 173 1318.1994 449.6551 647 3501
Age.Med 173 40.1746 8.0354 19.8 66.3
Income.Med 173 74096.8266 34036.1653 19780 250001
Foreign.Born.Pct 173 10.4139 7.4977 0 39.2
Hisp.Pct 173 47.596 20.1354 10.6 93.2
College.Pct 173 38.8607 17.9201 4.7 84.8
In.Poverty.Pct 173 6.5 4.7025 0 22.4014
Population.Density 173 1492.4446 1058.1767 0.4385 5955.4623
Vacant.Pct 173 5.5672 4.3509 0 20.2716
Renter.Occupied.Pct 173 34.3276 21.1954 2.6316 92.3077
Housing.Density 173 704.8562 513.5391 0.2002 2575.2589
Quick plots
While data exploration will be the subject of the next article, I will make some quick plots of a few of the variables, using the qsu function.
plot_simple <- function(df, var) {
ggplot(data = df, aes(x = district, y = Mean)) +
geom_col(aes(fill = district)) +
scale_fill_viridis_d() +
geom_errorbar(aes(ymin = Mean - SD, ymax = Mean + SD),
color = "black", linewidth = 1
) +
labs(
title = glue("{var} by District in Albuquerque"),
caption = caption
)
}
map(
c("House.Value.Med", "Income.Med", "College.Pct", "Hisp.Pct"),
\(x) qsu(abq_data, formula(glue("{x} ~ district"))) %>%
qDF() %>% rownames_to_columns(var = "district") %>%
plot_simple(x))[[1]]
[[2]]
[[3]]
[[4]]
Finally, I will save the data for use in the next article.
st_write(abq_data, "data/district_variance.gpkg",
layer = "abq_data", append = F)