This appendix documents the construction of the 2026 Ohio Opportunity Index. The code in this document assumes the database of 37 constituent measures—each assigned to a domain—is already prepared. A separate document describes each variables in each domain using a conceptual English definition, univariate descriptive results, correlation analyses, and choropleth map visualization.
The steps are as follows:
The domain scores are a function of the constituent measures within each domain. We first standardized each individual constituent measure by transforming it to a z-score (centering it around zero and dividing by its standard deviation). We saved that value for plotting. We then transformed the z-score further to an exponential distribution that incorporates certain desirable cancellation properties, discussed in the next paragraph, into the final OOI.
The following is a simplified example characterizing the benefits of the transformation. If we used untransformed z-scores or domain rank values, then one unit of opportunity contribution by one domain could completely cancel-out one unit of deprivation contributed by another domain (i.e., zero-sum). The exponential transform adjusts these cancellation properties in such a way that such a cancellation would require more than one unit of opportunity to cancel out one unit of deprivation.
This choice is based on key principles stemming from research on the creation of deprivation indices in the UK (Noble, Wright, Smith & Dibbens, 2006).
Below is the R code used in the transformation. We plot univariate and bivariate information about the resulting set of untransformed and transformed domain scores.
# data containing constituent measures is already read in, and exists in the object "data".
# impute missing values ########################################################
# use the mean of neighboring tracts for the imputation value.
# add the tract id to the rownames
index_GEOID_crosswalk <- data %>% as.data.frame() %>% select(GEOID) %>% tibble::rownames_to_column()
## Find the neighboring tracts =================================================
# find direct neighbors of each tract
neighbors = sf::st_touches(data$geometry)
# find direct and once-removed neighbors of each tract
neighbors2 = as.matrix(neighbors) %*% as.matrix(neighbors)
# reformat neighbor info so you can look up by geoid
neighbors = lapply(seq(1:length(data$GEOID)), function(tract_id){
near <- neighbors[[tract_id]]
index_GEOID_crosswalk[near,]$GEOID
})
neighbors2 = lapply(seq(1:length(data$GEOID)), function(tract_id){
near <- which(neighbors2[,as.numeric(tract_id)] != 0)
index_GEOID_crosswalk[near,]$GEOID
})
names(neighbors) = data$GEOID
names(neighbors2) = data$GEOID
## Find value to impute for any tract/measure ==================================
# find mean of neighbors
near_data_means <- lapply(data$GEOID, function(x){
# try immediate neighbors first
one_neigh_means = neighbor_means(data = data,
neighbors = neighbors[[x]],
all_measures = all_measures)
# check if there are any NA means
still_miss = is.na(one_neigh_means)
any_missing = any(still_miss)
if(any_missing){
# if there are no neighboring tracts with known values, also use once-removed neighbors
two_neigh_means = neighbor_means(data = data,
neighbors = neighbors2[[x]],
all_measures = all_measures)
# grab the means constructed using second degree neighbors only for the necessary measures
values_for_imputation = c(two_neigh_means[still_miss], one_neigh_means[!still_miss])
}else{
# otherwise, we can grab the mean of the first degree neighbors for all measures
values_for_imputation = one_neigh_means
}
return(values_for_imputation)
})
names(near_data_means) <- data$GEOID
## Impute ======================================================================
# for metrics with missing information, impute the mean of the neighbors
for(metric in all_measures){
missing_vals <- data %>% filter(is.na(get(metric)))
for(tract in missing_vals$GEOID){
data[data$GEOID == tract, metric] <- near_data_means[[tract]][[metric]]
}
}
# Scale and Transform the data #################################################
# if there are no people living in a tract, assign all values for that tract "NA".
# This prevents tracts with no population from influencing the standardization and ranking of all tracts
data[which(data$Pop == 0), all_measures] <- NA
## Create z scores =============================================================
# subset to variables that make up OOI
measure_data <- data %>% select(all_of(all_measures))
# standardize variables (create z-scores)
z_measure_data <- measure_data %>% mutate_all(function(col){scale(col, scale = TRUE, center = TRUE)})
# join with tract names and geometry
data_scaled <- cbind(GEOID = data$GEOID, z_measure_data, data$geometry)
## Initialize dataframes for transformed data ==================================
# create a new data frame for the domain scores
data_rankexp <- data_scaled %>% select(all_of(c("GEOID", "geometry")))
# create an intermediate data frame for untransformed domains averages (for visualization)
data_sum <- data_scaled %>% select(all_of(c("GEOID", "geometry")))
# create a data frame for ranks of domain averages
data_rank <- data_scaled %>% select(all_of(c("GEOID", "geometry")))
# average the measures in their respective domains and transform
for(domain in domain_names) {
domain_specific_measures = Domains[[domain]]
# sum of z scores within the domain
data_sum[,domain] <- rowSums(z_measure_data[,domain_specific_measures])
# rank tracts within the domain (the lowest score has a rank of 0)
data_rank[,domain] <- rank(data_sum[,domain], na.last = "keep") -1
# scale to [0,1]
data_rank[,domain] <- data_rank[,domain]/max(data_rank[,domain], na.rm = TRUE)
# exponential transform
data_rankexp[,domain] <- -23 * log(1 - data_rank[,domain] * (1 - exp(-100/23)))
}
# find GEOIDs of tracts with 0 population
no_pop <- data_rankexp[which(is.na(data_rankexp[,domain_names]) %>% rowSums() > 0),]$GEOID
# find the domain means of neighboring tracts for tracts with 0 population
near_data_for0pop <- lapply(no_pop, function(x){
one_neigh = neighbors[[x]]
one_neigh_means = data_rankexp %>%
filter(GEOID %in% one_neigh) %>%
select(all_of(unname(domain_names))) %>%
colMeans(., na.rm = TRUE)
})
names(near_data_for0pop) <- no_pop
# for metrics with 0 population, input the mean of the neighbors
for(domain in domain_names){
missing_vals <- data_rankexp %>% filter(is.na(get(domain)))
for(tract in missing_vals$GEOID){
data_rankexp[data_rankexp$GEOID == tract, domain] <- near_data_for0pop[[tract]][[domain]]
}
}
# name the rows according to tract for easier merging during later mapping
rownames(data_rankexp) <- data_rankexp$GEOID
Take a look at histograms of the domain sum variables. Because the measures that make up the domains scores have all been standardized, they all share a median of 0. Some distributions are skewed, so the means, while close to 0 are often a little above or below 0. However, the distributions of each domain are different in shape and variance.
| Motorized Transportation | Education | Employment | Housing | Health | Environment | Crime | |
|---|---|---|---|---|---|---|---|
| Minimum | -9.161 | -6.630 | -21.802 | -6.723 | -5.790 | -5.098 | -3.298 |
| First Quartile | -1.031 | -1.744 | -1.297 | -2.135 | -1.701 | -1.196 | -2.339 |
| Median | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
| Mean | 0.108 | -0.205 | -0.388 | -0.537 | -0.358 | -0.104 | -1.324 |
| Third Quartile | 1.148 | 1.579 | 0.983 | 1.501 | 1.131 | 1.092 | 0.788 |
| Maximum | 6.361 | 12.975 | 16.964 | 24.551 | 50.238 | 21.136 | 163.131 |
| Standard Deviation | 1.796 | 2.579 | 2.158 | 3.181 | 2.650 | 2.047 | 4.762 |
Take a look at histograms of the domain averages that have been transformed. After transforming, the distributions for each of the domains are identical. The distributions are right-tailed meaning that there are more smaller (low opportunity) values, and a few large (high opportunity) values.
| Motorized Transportation | Education | Employment | Housing | Health | Environment | Crime | |
|---|---|---|---|---|---|---|---|
| Minimum | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 | 0.011 |
| First Quartile | 6.511 | 6.549 | 6.549 | 6.549 | 6.539 | 6.511 | 6.549 |
| Median | 21.673 | 21.701 | 21.709 | 21.731 | 21.674 | 21.662 | 21.718 |
| Mean | 15.632 | 15.682 | 15.697 | 15.725 | 15.654 | 15.611 | 15.682 |
| Third Quartile | 31.002 | 31.002 | 31.002 | 31.085 | 30.919 | 30.919 | 31.030 |
| Maximum | 100.000 | 100.000 | 100.000 | 100.000 | 100.000 | 100.000 | 100.000 |
| Standard Deviation | 19.910 | 19.904 | 19.902 | 19.917 | 19.899 | 19.909 | 19.918 |
We calculated the OOI for a tract as the mean of its transformed domain scores, and then we reverse the OOI such that higher values reflect more overall opportunity.
Below we see histograms of the DI and the OOI side-by-side. They are—as they should be—mirror images.
| DI | OOI | |
|---|---|---|
| Minimum | 2.300 | -67.613 |
| First Quartile | 12.997 | -28.949 |
| Median | 21.695 | -21.695 |
| Mean | 19.339 | -19.339 |
| Third Quartile | 28.949 | -12.997 |
| Maximum | 67.613 | -2.300 |
| Standard Deviation | 11.218 | 11.218 |
| Total Variance | Unique Variance | |
|---|---|---|
| ED | 0.556 | 0.041 |
| EM | 0.545 | 0.041 |
| EN | 0.374 | 0.052 |
| HL | 0.291 | 0.046 |
| CR | 0.280 | 0.039 |
| HS | 0.240 | 0.042 |
| TR | 0.084 | 0.046 |
Below are results of several regression models of several variables that are theoretically associated with the OOI. These results are discussed in the main text of the report.
| Outcome |
Correlation with OOI (p-value) |
R-squared |
|---|---|---|
| Social Vulnerability Index 2022 |
-0.59 (0.00) |
0.53 |
| The 2022 Social Capital Atlas - Economic Connectedness |
0.17 (0.00) |
0.18 |
| The 2022 Social Capital Atlas - Childhood Economic Connectedness |
0.18 (0.00) |
0.12 |
| Rate of Fast Food Establishments per 100,000 Population |
0.06 (0.00) |
0.29 |
| The National 2023 Child Opportunity Index, state normed |
0.73 (0.00) |
0.66 |
| CDC PLACES - Visited dentist or dental clinic in the past year among adults (crude prevalence) |
0.74 (0.00) |
0.69 |
| CDC PLACES - No leisure-time physical activity among adults (crude prevalence) |
-0.72 (0.00) |
0.64 |