1 Introduction

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:

2 Domain scores

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.

2.1 Code

# 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

2.2 Visualize the result

2.2.1 Non-transformed domain values

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.

Histograms of each of the 7 domains. Distribution information is also represented in the table below.
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

2.2.2 Transformed domain values

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.

Histograms of each of the 7 domains after an exponential transformation. Distribution information is also represented in the table below.

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

2.3 Correlations among the domains

Heatmap of correlation between domains, positive correlations are shown in green, and negative correlations are shown in purple. Education and Employment have the strongest positive correlation of 0.48. Motorized Transportation and Housing as well as Motorized Transportation and Crime both have moderatley strong negative correlations of -0.30.

2.4 Construct the OOI as an unweighted mean of the domain scores

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.

2.5 Visualize the “learned” latent factor

Below we see histograms of the DI and the OOI side-by-side. They are—as they should be—mirror images.

Two histograms. The histogram on the left is the distribution of the Deprivation Index and is right skewed. The histogram on the right is the distribution of the Opportunity Index, and is a mirror image of the DI distribution (left skewed).

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

2.6 Variance of OOI attributable to domain scores

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

2.7 Evidence of Criterion Validity

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