← Back to list

Identifying High-Potential U.S. Metro Areas: A Data-Driven Approach to Market Selection

Overview

Dberle · 2025-05-08 23:25 · 0 claps · 10.8 min read
#geography #census-data
Open on Medium ↗
Wiki topics: ECO · Economy · General 🏛️ · Politics

Identifying High-Potential U.S. Metro Areas: A Data-Driven Approach to Market Selection

Overview

Identifying Metro Areas that are worth investing in is hard in this ever changing economic environment. Taking a structured, data-driven approach can give us tools to identify areas worth investing in.

In this project, we built a framework to rank markets by investment potential, identify areas that are potentially overheated, and identify clusters of similar markets. We used a mix of Population, Economic, Affordability, and Housing KPIs to build this framework.

To simplify, these are our goals:

  • To create a standardized investment index comparing CBSAs nationwide.
  • To detect overheated or undervalued markets based on income-adjusted pricing gaps.
  • To segment markets into data-driven clusters with shared characteristics.
  • To provide regional insights and strategic recommendations for investors.

We retrieved data from a selection of government sources, including the ACS, HUD, BLS, and BEA. We used R to ingest, clean, and prepare our data. The overall ETL process won’t be described here (but we can link to a GitHub repo of the work).

In this article, we will provide our analysis and samples of our R code.

Setting up our environment

First, we load up a series of packages that we will use throughout our analysis.

library(tidyverse)
library(lubridate)
library(magrittr)
library(plotly)
library(tidytext)
library(scales)
library(tidycensus)
library(tigris)
library(sf)
library(readxl)
library(httr)
library(jsonlite)
library(DT)
library(ggbeeswarm)
library(knitr)
library(factoextra)
library(corrplot)

Then we load and create our initial data frames.

# Ingest our Gold tables
kpi_afford <- read_csv("gold/kpi_afford.csv") 
kpi_eco <- read_csv("gold/kpi_eco.csv") %>%
  select(-year)
kpi_housing <- read_csv("gold/kpi_housing.csv")
kpi_pop <- read_csv("gold/kpi_pop.csv") %>%
  select(-year)
cbsa_master <- read_csv("gold/cbsa_metadata.csv")

# Join KPIs with Metadata to create a master table
kpi_master <- cbsa_master %>%
  left_join(kpi_pop, by = c("GEOID")) %>%
  left_join(kpi_eco, by = c("GEOID")) %>%
  left_join(kpi_afford, by = c("GEOID")) %>%
  left_join(kpi_housing, by = c("GEOID")) %>%
  filter(year == 2023)

# Reduce the Master table to Core KPIs - This will be the base for our analyses
kpi_core <- kpi_master %>%
  select(GEOID, name = NAME, states, primary_state, cbsa_type, region, division, counties, pop_total = pop_totalE, pop_growth_5yr, pop_education_rate = ed_higher_pop_percent, eco_unemployment_rate = unemployment_rate, eco_unemployment_stability = unemployment_cv_5yr, eco_med_income = income_medE.x, eco_wage_per_capita = wage_per_person, eco_wage_growth_5yr = wage_per_person_5yr, eco_industry_entropy = scaled_industry_entropy, afford_home_value = home_value_medE, afford_rent_growth_5yr = rent_growth_5yr, afford_gross_rent = gross_rent_medE.x, afford_rental_index = rental_affordability_index, afford_hud_rent_price_ratio = fmr_acs_rent_ratio, afford_rpp = rpp_all_items, housing_vacancy_rate = vacancy_rate, housing_hpi_growth_5yr = hpi_growth_5yr, housing_permits_per_1000 = permits_per_1000_pop, housing_units_per_capita) %>%
  filter(primary_state != "PR") %>% # Remove PR from the analysis
  mutate(GEOID = as.character(GEOID)) # Transform GEOID in case it's read in as a numeric

We then scale our data and use an approach of inputting median values for any NULLs.

# Scale all of our KPIs using Z-Scores
# Define the dimension columns you want to keep unscaled
dimension_cols <- c("GEOID", "name", "states", "primary_state", "cbsa_type", "region", "division", "counties")

# Create kpi_scaled: Metro Areas only, with z-score versions of all other numeric columns
kpi_scaled <- kpi_core %>%
  filter(cbsa_type == "Metro Area") %>%
  select(all_of(dimension_cols), where(is.numeric)) %>%
  mutate(across(
    .cols = setdiff(names(.)[sapply(., is.numeric)], dimension_cols),
    .fns = ~ scale(.)[, 1],
    .names = "z_{.col}"
  ))

# Input the median value
kpi_scaled_input <- kpi_scaled %>%
  mutate(across(starts_with("z_"), ~ ifelse(is.na(.), median(., na.rm = TRUE), .)))

Here’s a quick explanation of our variables:

Population:

  • Total Population
  • Population Growth, 5 year
  • Higher Education Rate — % of Pop with Bachelors degree or higher

Economics:

  • Unemployment Rate
  • Unemployment Stability — Variance of the unemployment rate over the last 5 years, meant to show the stability of the job market. We use the Coefficient of Variance to scale our data. This is the Standard Dev / Mean. Lower is better
  • Wage Per Capita
  • Wage Per Capita Growth, 5 year
  • Industry Entropy — Shows the diversity of industries on the local economy, higher values show more diversity

Affordability:

  • Home Value
  • Gross Rent
  • Gross Rent Growth, 5 year
  • Rental Affordability Index — Shows how affordable median rents are compared to income. Rent should be about 30% of Income, the index is (Income * 0.3) / Gross Rent
  • HUD Rental Price Ratio — Compares HUD Fair Market Values to Rents to identify the relative value of markets. FMR 2 Bed Average / Gross Rent from the ACS
  • RPP — Regional Price Indexes

Housing:

  • Vacancy Rates — Vacant Homes / Total Homes from ACS
  • HPI Growth, 5 year
  • Housing Permits per 1000 — New Permit Units Estimate from BPS / Total Population from ACS
  • Housing Units per Capita — Total Homes / Total Population from ACS

Analytics Approaches

Investment Index

Now that our environment is set up, we’ll begin our analysis by creating a standardized Investment Index. We select a subset of our KPIs anduse z-score scaled KPIs to ensure consistency of our inputs.

We then constructed a composite Investment Index, calculated as a weighted average of select metrics believed to indicate long-term opportunity. The index was centered at zero, with higher values indicating greater investment potential. This allows us to rank all Metro Areas on the same scale and spot potential outliers.

# Select KPIs and filter to Metro Areas
index_scaled <- kpi_scaled_input %>%
  filter(cbsa_type == "Metro Area") %>%
  select(GEOID:counties, z_pop_growth_5yr, z_pop_education_rate, z_eco_unemployment_rate, z_eco_unemployment_stability, z_eco_wage_per_capita, z_eco_wage_growth_5yr, z_eco_industry_entropy, z_afford_rental_index, z_afford_hud_rent_price_ratio, z_afford_rpp, z_housing_vacancy_rate, z_housing_hpi_growth_5yr, z_housing_permits_per_1000, z_housing_units_per_capita) 

# Create our weights
weights <- c(
  z_pop_growth_5yr = 0.10,
  z_pop_education_rate = 0.05,
  z_eco_unemployment_rate = -0.05,
  z_eco_unemployment_stability = -0.05,
  z_eco_wage_per_capita = 0.10,
  z_eco_wage_growth_5yr = 0.10,
  z_eco_industry_entropy = 0.05,
  z_afford_rental_index = 0.10,
  z_afford_hud_rent_price_ratio = 0.05,
  z_afford_rpp = 0.05,
  z_housing_vacancy_rate = -0.05,
  z_housing_hpi_growth_5yr = 0.15,
  z_housing_permits_per_1000 = 0.05,
  z_housing_units_per_capita = 0.05
)

# Using the input index
index_input <- index_scaled %>%
  rowwise() %>%
  mutate(investment_index = sum(c_across(names(weights)) * weights)) %>%
  ungroup()

# Scale the index from 0-100 for better readability
# Create Index Tiers as simple buckets
index_input <- index_input %>%
  mutate(investment_index_scaled = scales::rescale(investment_index, to = c(0, 100)),
         investment_tier = ntile(investment_index, 5))

# Create an outlier column for data outside of one SD
index_mean <- mean(index_input$investment_index, na.rm = TRUE)
index_sd <- sd(index_input$investment_index, na.rm = TRUE)

index_input <- index_input %>%
  mutate(index_outlier = case_when(
    investment_index >= index_mean + index_sd ~ "High Performer",
    investment_index <= index_mean - index_sd ~ "Under Performer",
    TRUE ~ "Middle Tier"
  ),
  GEOID = as.character(GEOID))

Some examples of top and bottom performing markets by approach are:

Top 5:

  • Naples-Marco Island, FL
  • Sebastian-Vero Beach-West Vero Corridor, FL
  • Wildwood-The Villages, FL
  • Wilmington, NC
  • Missoula, MT

Bottom 5:

  • New Orleans-Metairie, LA
  • Salisbury, MD
  • Shreveport-Bossier City,
  • Lafayette, LA
  • Lake Charles, LA

And, we can see how the index performs by Census Division

Overheated Gap Score

Next, we introduced a concept of market overheating by comparing current affordability (home values and rents) against local income and regional price parity. We constructed an Overheated Score to quantify this gap. Higher scores indicate markets where prices may be rising faster than fundamentals support.

This allows us to:

  • Highlight metros at risk of pricing corrections
  • Identify undervalued gems with room for appreciation
  • Balance high-investment metros against their risk profile

Overheated Market: High growth in prices or rents, but without supportive fundamentals (e.g. population, wage, or permit growth).

Undervalued Market: Lagging prices/rents relative to strong fundamentals might suggest investment upside.

We start by defining our indicators and creating our scaled data.

Price Related KPIs: Symptoms of Overheating

  • home_value
  • gross_rent
  • rent_growth_5yr
  • hpi_growth_5yr

Fundamentals: Support for Growth

  • pop_growth_5yr — more people = more demand
  • eco_wage_growth_5yr — higher wages support prices
  • housing_permits_per_1000 — permits suggest supply can keep up
  • housing_units_per_capita — more units = less pressure
  • eco_unemployment_stability (low = good) — stable unemployment is good
  • afford_rental_index (low = overpriced) — higher index = more affordable
  • afford_hud_rent_price_ratio (low = overpriced) — higher ratio = rent closer to fair
overheat_scale <- kpi_scaled_input %>%
  filter(cbsa_type == "Metro Area") %>%
  select(GEOID:counties, z_afford_home_value, z_afford_gross_rent, z_afford_rent_growth_5yr, z_housing_hpi_growth_5yr, z_pop_growth_5yr, z_eco_wage_growth_5yr, z_housing_permits_per_1000, z_housing_units_per_capita, z_eco_unemployment_stability, z_afford_rental_index, z_afford_hud_rent_price_ratio) 

We then calculate our score and define a Market Type variable to tell us if a market is Overvalued, Undervalued, or Neutral.

overheat_score <- overheat_scale %>%
  mutate(
    overheating_score = z_afford_home_value + z_afford_gross_rent + z_afford_rent_growth_5yr + z_housing_hpi_growth_5yr,
    support_score = z_pop_growth_5yr + z_eco_wage_growth_5yr + z_housing_permits_per_1000 + z_housing_units_per_capita - z_eco_unemployment_stability + z_afford_rental_index + z_afford_hud_rent_price_ratio,
    overheat_gap = overheating_score - support_score,
    market_type = case_when(
      overheat_gap >= quantile(overheat_gap, 0.9, na.rm = TRUE) ~ "Overheated",
      overheat_gap <= quantile(overheat_gap, 0.1, na.rm = TRUE) ~ "Undervalued",
      TRUE ~ "Neutral"
    )
  )

We’re looking for Markets that overindex on support without having too high of an overheating score. This scatter plot helps to visualize what we are looking for:

And below, we can see how this scores by Census Division.

The Pacific and New England are overall the most overheated regions, while the West North Central is the least overheated.

Clustering

Next, we used an unsupervised machine learning (k-means clustering) model to segment CBSAs. We selected a subset of relevant KPIs and used their scaled values to create 5 distinct clusters.

# Select vars
cluster_vars <- kpi_scaled_input %>%
  select(z_eco_wage_per_capita, z_eco_wage_growth_5yr, z_eco_unemployment_rate, z_eco_unemployment_stability, z_eco_industry_entropy, z_housing_hpi_growth_5yr, z_housing_vacancy_rate, z_housing_units_per_capita, z_housing_permits_per_1000, z_pop_growth_5yr, z_pop_education_rate, z_afford_rental_index, z_afford_hud_rent_price_ratio, z_afford_rpp)

# Create elbow plot
fviz_nbclust(cluster_vars, kmeans, method = "wss")

# Set seed for reproducability
set.seed(123)

kmeans_result <- kmeans(
  cluster_vars,
  centers = 5,  # Replace with optimal k
  nstart = 25
)

# Add cluster labels back to your dataframe
kpi_clustered <- kpi_scaled_input %>%
  mutate(cluster = factor(kmeans_result$cluster))

# Create summary dataframe 
cluster_summary_real <- kpi_clustered %>%
  group_by(cluster) %>%
  summarise(across(
    .cols = matches("^(housing_|eco_|pop_|afford_)"),
    .fns = list(mean = ~ mean(.x, na.rm = TRUE)),
    .names = "{.col}_mean"
  )) %>%
  ungroup()

# Add labels to and descriptions to our cluster df
kpi_clustered <- kpi_clustered %>%
  mutate(
    cluster_label = case_when(
      cluster == 1 ~ "Growth-Oriented Mid-Sized Markets",
      cluster == 2 ~ "Economically Struggling, High Growth Potential",
      cluster == 3 ~ "Small but Booming Markets",
      cluster == 4 ~ "Large, Expensive, Established Metros",
      cluster == 5 ~ "Declining or Stagnant Value Markets"
    ),
    cluster_description = case_when(
      cluster == 1 ~ "Fast-growing, relatively affordable metros with solid economic momentum and active housing development.",
      cluster == 2 ~ "Economically volatile smaller metros with potential upside from job recovery and undersupplied housing.",
      cluster == 3 ~ "Dynamic, high-income small metros likely driven by tech or innovation sectors with fast growth.",
      cluster == 4 ~ "Stable, wealthy major metros with high cost of entry and limited new supply, suited for long-term plays.",
      cluster == 5 ~ "Stagnant or declining metros with low costs but limited economic and population growth prospects."
    )
  )

Linked here are summary stats about each cluster.

Cluster 1: Growth-Oriented Mid-Sized Markets

Characteristics:

  • Population: Large mid-sized metros (~817K), with strong population growth (+8.7%).
  • Economics: Moderate median income ($75K) and strong wage growth.
  • Affordability: Relatively affordable housing ($288K average home value), low rent ($1,192), and reasonable price parity.
  • Housing: High new construction activity (23.7 permits/1,000 people), healthy HPI growth (60%).

Interpretation: These are fast-growing, relatively affordable markets with good economic momentum and strong housing development, promising for investment.

Examples:

  • Augusta-Richmond County, GA-SC
  • Austin-Round Rock-San Marcos, TX

Cluster 2: Economically Struggling, High Growth Potential

Characteristics:

  • Population: Smaller metros (~393K), slower population growth (3.7%), and low education attainment.
  • Economics: Highest unemployment (8.8%), low stability, but high wage growth (+35%) suggests recent economic change.
  • Affordability: Moderate home prices ($345K) but strong rental inflation and tight supply.
  • Housing: Low building activity and low units per capita.

Interpretation: These metros are economically volatile, but may offer value opportunities where job markets are recovering and housing remains undersupplied.

Examples:

  • Bakersfield-Delano, CA
  • Yakima, WA

Cluster 3: Small but Booming Markets

Characteristics:

  • Population: Smaller metros (~288K), strong growth (+8%), and highest education rate (25%+).
  • Economics: Strong wage levels ($167K/labor) and highest wage growth (46%).
  • Affordability: Moderate home prices, strong rent growth.
  • Housing: Highest permits and HPI growth, suggesting active housing markets.

Interpretation: These may be innovation hubs or up-and-coming cities with dynamic economies, high incomes, and fast growth. High investment potential but could overheat.

Examples:

  • Asheville, NC
  • Deltona-Daytona Beach-Ormond Beach, FL
  • Flagstaff, AZ

Cluster 4: Large, Expensive, Established Metros

Characteristics:

  • Population: Largest metros (2.5M), very slow population growth (+0.7%), highest education (28%).
  • Economics: Highest income levels ($94K median), stable economies.
  • Affordability: Very expensive housing ($530K), high rents ($1,691), and highest price parity.
  • Housing: Limited new construction and low vacancy.

Interpretation: These are major metros like NYC, SF, or LA — stable and wealthy, but high cost of entry. Likely good for long-term stability, less so for value or growth investing.

Examples:

  • Ann Arbor, MI
  • Boston-Cambridge-Newton, MA-NH

Cluster 5: Declining or Stagnant Value Markets

  • Population: Smallest metros (~234K), shrinking population, and lower education levels.
  • Economics: Lowest income and wage levels, moderate wage growth.
  • Affordability: Cheapest home prices ($198K) and rents (~$973).
  • Housing: Weak rental growth, moderate housing supply.

Interpretation:

These are potentially undervalued but stagnant markets with limited population and economic momentum. Investment might require caution and a value-add approach.

Examples:

  • Abilene, TX
  • Champaign-Urbana, IL

Putting It All together: Understanding the Investment Signals

This section ties together the three analytic tools, Investment Index, Overheated Score, and Clustering, to surface actionable insights. We highlight “sweet spot” markets, flag risky ones, and explain how these tools complement each other.

Identifying Sweet Spot Markets

Purpose:

Find CBSAs that balance high opportunity with low risk that are ideal for prioritization.

Guiding Questions:

  • Which CBSAs rank high on the Investment Index and remain undervalued?
  • Which clusters house the most balanced opportunities?

Analysis:

Defining Sweet Spot:

  • Investment Index > 70
  • Overheated Gap Score < 30
  • Not in Cluster 4

Sweet Spot Comparison:

We start by comparing some of the main KPIs for our Sweet Spot Metro Areas. We can see that our Sweet Spot areas generally outperform other Metro Areas across our different themes.

We can see that our Sweet Spot markets are mostly in the Upper Mountain region, Florida, North Carolina, and New England. With two other markets in Texas and Missouri.

Balancing Risk & Reward

Purpose:

To visualize how risk and reward are balanced across CBSAs.

Guiding Questions:

  • Are there CBSAs that appear high-potential but overheated?
  • Which markets are “value traps” or truly undervalued gems?

Analysis:

Here we can see examples of how our CBSAs are mapped into different zones. The bottom right quadrant is the best area for potential investment.

Many of our Overheated Markets are in more classic investment areas like the Southwest, California, Florida, and the Northeast. While many Undervalued locations are in the Upper Plains or are close to major Metro Areas, but are not part of a main one.

Below is a similar version for Investment Index results.

By overlaying these three lenses, we can uncover markets that are not only performing well but are also primed for continued growth. And we can avoid markets that appear strong on paper but show signs of overheating. Our analysis highlights several Sweet Spot markets across the South and Midwest while cautioning against high-index metros in overheated coastal areas.

Conclusions & Recommendations

Based on our analysis, several key insights emerged:

  • High-investment metros were found not just in traditional tech hubs, but also in emerging Southeast and Midwest metros. These areas combine rising incomes, affordable housing, and strong population growth.
  • Overheated markets, often concentrated in major West Coast cities, show pricing that significantly exceeds fundamentals — investors may want to tread carefully.
  • Undervalued metros, including many in the Great Lakes and Texas, represent opportunities to enter before broader recognition.
  • Our clustering shows that not all growth looks the same — some metros are expanding rapidly but remain unaffordable, while others are slower-growing but undervalued.

메타데이터
post_id
bb805c474240
slug
identifying-high-potential-u-s-metro-areas-a-data-driven-approach-to-market-selection-bb805c474240
url
https://medium.com/@urbandatanotes/identifying-high-potential-u-s-metro-areas-a-data-driven-approach-to-market-selection-bb805c474240
canonical_url
https://medium.com/@urbandatanotes/identifying-high-potential-u-s-metro-areas-a-data-driven-approach-to-market-selection-bb805c474240
author_url
https://medium.com/@urbandatanotes
status
ok
fetched_at
2026-08-23 19:11:25