ASVAB Test Demographics Analysis

The ASVAB test is a standardized test used by the U.S. military to assess the skills and abilities of potential recruits. It covers a range of subjects, including mathematics, verbal skills, and technical knowledge. The test is designed to help determine an individual’s suitability for various military occupations.
This analysis will focus on the race, age, and gender demographics of the ASVAB applicants. Additonally, this analyis will focus on the U.S. Air Force Eligility by race focused on the four composite scores: General, Mechanical, Administrative, and Electronics.
# load libraries  
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.4     ✔ readr     2.1.5
## ✔ forcats   1.0.0     ✔ stringr   1.5.1
## ✔ ggplot2   3.5.1     ✔ tibble    3.2.1
## ✔ lubridate 1.9.3     ✔ tidyr     1.3.1
## ✔ purrr     1.0.4     
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(readxl)
library(rio)
library(janitor)
## 
## Attaching package: 'janitor'
## 
## The following objects are masked from 'package:stats':
## 
##     chisq.test, fisher.test
library(ggplot2)
library(scales)
## 
## Attaching package: 'scales'
## 
## The following object is masked from 'package:purrr':
## 
##     discard
## 
## The following object is masked from 'package:readr':
## 
##     col_factor
# loading data 
asvab_data <- read_excel("asvab_data_2020-2024.xlsx")
# clean heading names 
asvab_data <- asvab_data|> 
  clean_names()
# clean data 
asvab_clean <- asvab_data %>%
  filter(if_any(everything(), ~ !is.na(.))) %>%
  mutate(across(where(is.character), str_trim)) %>%
  mutate(across(where(is.character), ~ na_if(., ""))) %>%
  mutate(across(where(is.character), ~ na_if(., "N/A"))) %>%
  mutate(across(where(is.character), ~ na_if(., "Unknown"))) 

Summarizing the data

Race distribution

race_counts <- asvab_clean |> 
  separate_rows(race, sep = ",\\s*") |>   # splits by comma and optional space
  count(race, sort = TRUE) 

print(race_counts)
## # A tibble: 8 × 2
##   race                                           n
##   <chr>                                      <int>
## 1 White                                     197129
## 2 <NA>                                      186121
## 3 Black or African American                  95875
## 4 Asian                                      25544
## 5 Native Hawaiian or Other Pacific Islander   8517
## 6 American Indian/Alaska Native               8097
## 7 Identification Pending                       999
## 8 Declined to Respond                           28

### Gender distribution

gender_count <- asvab_clean |> 
  count(sex, sort = TRUE) |> 
  print()
## # A tibble: 3 × 2
##   sex         n
##   <chr>   <int>
## 1 Male   332846
## 2 Female 156518
## 3 <NA>    11988

### Graphic of gender distribution

ggplot(gender_count, aes(x = sex, y = n, fill = sex)) +
  geom_col() +
  scale_fill_manual(values = c("Male" = "#3182bd", "Female" = "#e6550d")) +
  scale_y_continuous(labels = scales::label_comma()) +
  labs(title = "ASVAB Gender Distribution",
       x = "Gender",
       y = "Count") +
  theme_minimal()

  • There are 332,846 males and 156518 females, meaning that about 32% percent is represented by females and about 56% percent is represented by males.

    Age distribution

age_count <- asvab_clean |> 
  count(age, sort=TRUE) |> 
  print()
## # A tibble: 246 × 2
##      age      n
##    <dbl>  <int>
##  1    17 124298
##  2    18  88828
##  3    16  68047
##  4    19  45851
##  5    20  32091
##  6    21  24185
##  7    22  20958
##  8    23  17481
##  9    24  13744
## 10    25  11209
## # ℹ 236 more rows

### Producing age ranges

asvab_clean <- asvab_clean |> 
  mutate(age_range = cut(
    age,
    breaks = c(17, 24, 34, 44, 54, 64, Inf),  # customize as needed
    labels = c("16–24", "25–34", "35–44", "45–54", "55–64", "65+"),
    right = TRUE,   # include the upper bound (e.g., 24 is part of 18–24)
    include.lowest = TRUE
  ))
asvab_clean |> 
  count(age_range)
## # A tibble: 7 × 2
##   age_range      n
##   <fct>      <int>
## 1 16–24     367436
## 2 25–34      53281
## 3 35–44       9229
## 4 45–54         70
## 5 55–64         10
## 6 65+          232
## 7 <NA>       71094
asvab_clean$age_range <- factor(asvab_clean$age_range,
                                levels = c("16–24", "25–34", "35–44", "45–54", "55–64", "65+", "NA"))

# Plot
ggplot(asvab_clean, aes(x = age_range)) +
  geom_bar(fill = "lavender") +
  scale_y_continuous(labels = scales::label_comma()) +
  scale_x_discrete(drop = FALSE) +  
  labs(title = "Age Range Distribution",
       x = "Age Range",
       y = "Count") +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

  • So mostly 16-24 year olds take the ASVAB test followed by the age range 25-34.

Latest Schooling By Race

latest_schooling_by_race <- asvab_clean |> 
  separate_rows(race, sep = ",\\s*") |>   
  group_by(race, latest_school_type) |> 
  summarise(count = n(), .groups = "drop") |> 
  arrange(desc(count))

Graph of Latest Schooling By Race

ggplot(latest_schooling_by_race, aes(x = race, y = count, fill = latest_school_type)) +
  geom_col(position = "dodge") +
  labs(title = "Latest Schooling Type by Race",
       x = "Race",
       y = "Count",
       fill = "School Type") +
  scale_y_continuous(labels = scales::label_comma()) +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Latest Schooling By Gender

latest_schooling_by_gender <- asvab_clean |> 
    group_by(sex, latest_school_type) |> 
  summarise(count = n(), .groups = "drop")

print(latest_schooling_by_gender) 
## # A tibble: 17 × 3
##    sex    latest_school_type                                         count
##    <chr>  <chr>                                                      <int>
##  1 Female College, Post Graduate, Internship, Residency, FellowShip  13588
##  2 Female Military Education                                            12
##  3 Female Military Training                                              2
##  4 Female Other                                                        200
##  5 Female Secondary or High School                                   78239
##  6 Female Vocational/Technical College                                 361
##  7 Female <NA>                                                       64116
##  8 Male   College, Post Graduate, Internship, Residency, FellowShip  23755
##  9 Male   Military Education                                            33
## 10 Male   Military Training                                              2
## 11 Male   Other                                                        376
## 12 Male   Secondary or High School                                  192494
## 13 Male   Vocational/Technical College                                 963
## 14 Male   <NA>                                                      115223
## 15 <NA>   College, Post Graduate, Internship, Residency, FellowShip      5
## 16 <NA>   Secondary or High School                                      28
## 17 <NA>   <NA>                                                       11955

Graph of Latest Schooling By Gender

ggplot(latest_schooling_by_gender, aes(x = sex, y = count, fill = latest_school_type)) +
  geom_col(position = "dodge") +
  labs(title = "Latest Schooling Type by Gender",
       x = "Gender",
       y = "Count",
       fill = "School Type") +
  scale_y_continuous(labels = scales::label_comma()) +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Composite Scores Breakdown

  • The U.S. Air Force does not have a single, universal minimum composite score for any composite score. Instead, it has different minimum scores for various career fields. So, I will be using the min scores for different career fields that fall under each composite score section.

  • Breakdown of U.S. Air Force Composite Scores:

  • G General: Verbal Expression (WK + PC) + Arithmetic Reasoning (AR)

  • M Mechanical: Mechanical Comprehension (MC) + Auto & Shop (AS)

  • A Administrative: Verbal Expression (WK + PC)

  • E Electronics: EI + AR + MK + GS

  • G (General) - composite is used for general aptitude jobs that require verbal and math skills.

  • M (Mechanical) - focuses on mechanical and technical knowledge.

  • A (Administrative) - is for clerical and admin roles, based on verbal skills only.

  • E (Electronics) - combines several technical and science-based subtests for electronics and cyber-related roles.

Questions: ** Which gender and which race is more associated with each composite score section? What races and what genders qualify for what type of careers? **

General Scores

general_scores <- asvab_clean |> 
  select(asvab_ar, asvab_pc, asvab_wk, race, sex)
general_scores <- general_scores |>
    mutate(total_score = (asvab_ar + asvab_pc + asvab_wk))
print(general_scores)
## # A tibble: 501,352 × 6
##    asvab_ar asvab_pc asvab_wk race                      sex    total_score
##       <dbl>    <dbl>    <dbl> <chr>                     <chr>        <dbl>
##  1       37       50       55 Black or African American Male           142
##  2       40       40       34 Black or African American Male           114
##  3       49       53       56 White                     Male           158
##  4       43       54       50 Black or African American Female         147
##  5       49       56       72 Black or African American Male           177
##  6       55       55       61 White                     Male           171
##  7       57       53       64 Black or African American Female         174
##  8       55       56       54 Black or African American Female         165
##  9       50       56       58 White                     Male           164
## 10       57       60       61 White                     Male           178
## # ℹ 501,342 more rows

General Eligibility

  • Using 55 as the min score: qualify for roles such as Administrative (A) and Knowledge Operations (3D0X1))
# Filter for individuals with score >= 55
eligible_general <- general_scores |>
  filter(total_score >= 55)

general_grouped_eligible <- eligible_general |>
  group_by(race, sex) |>
  summarize(
    count_eligible = as.numeric(n())) |> 
  arrange(desc(count_eligible))
## `summarise()` has grouped output by 'race'. You can override using the
## `.groups` argument.
write.csv(general_grouped_eligible, "general_grouped_eligible.csv", row.names = FALSE)
# Filter for Black and White applicants
bw_general_data <- general_grouped_eligible |>
  filter(race %in% c("Black or African American", "White"))

# Plot for Black and White
ggplot(bw_general_data, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "General Eligibility Counts by Race (Black & White Applicants)",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex",
    caption = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1, size = 12, face = "bold")
  )

general_other_races <- general_grouped_eligible |>
  filter(!race %in% c("Black or African American", "White") & !is.na(race))

g_plot <- ggplot(general_other_races, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "General Eligibility Counts by Race (Other Applicants)",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex",
    caption = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +
  theme_minimal() +
  coord_flip()

# Save the plot
ggsave("general_eligibility_counts.png", plot = g_plot, width = 12, height = 7, dpi = 300, bg = "white")
# Plot: Eligible counts by race and sex with angled x-axis
g_plot <- ggplot(general_grouped_eligible, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "General Eligibility Counts (Score ≥ 55) by Race and Sex",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex", 
    capition = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +  
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1)
  )
ggsave("general_eligibility_counts.png", plot = g_plot, width = 12, height = 7, dpi = 300, bg = "white")

Mechanical: Mechanical Comprehension (MC) + Auto & Shop (AS)

  • Using 47 as the minimum Mechanical (M) composite score: qualifying for roles such as Aircraft Maintenance (2A5X1) and other general mechanical positions.
mechanical_scores <- asvab_clean |> 
  select(asvab_as, asvab_mc,asvab_ar, race, sex)
mechanical_scores <- mechanical_scores |>
  mutate(total_score = (asvab_as +  asvab_mc + asvab_ar))
print(mechanical_scores)
## # A tibble: 501,352 × 6
##    asvab_as asvab_mc asvab_ar race                      sex    total_score
##       <dbl>    <dbl>    <dbl> <chr>                     <chr>        <dbl>
##  1       48       39       37 Black or African American Male           124
##  2       36       24       40 Black or African American Male           100
##  3       65       56       49 White                     Male           170
##  4       43       35       43 Black or African American Female         121
##  5       43       39       49 Black or African American Male           131
##  6       70       68       55 White                     Male           193
##  7       45       47       57 Black or African American Female         149
##  8       46       50       55 Black or African American Female         151
##  9       50       60       50 White                     Male           160
## 10       55       60       57 White                     Male           172
## # ℹ 501,342 more rows
# Filter for individuals with score >= 47
eligible_mechanical <- mechanical_scores |>
  filter(total_score >= 47)

mechanical_grouped_eligible <- eligible_mechanical |>
  group_by(race, sex) |>
  summarize(
    count_eligible = as.numeric(n())) |> 
  arrange(desc(count_eligible))
## `summarise()` has grouped output by 'race'. You can override using the
## `.groups` argument.
write.csv(mechanical_grouped_eligible, "mechanical_grouped_eligible.csv", row.names = FALSE)
# Filter for Black and White applicants
bw_mechanical_data <- mechanical_grouped_eligible |>
  filter(race %in% c("Black or African American", "White"))

# Plot for Black and White
ggplot(bw_mechanical_data, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "Mechanical Eligibility Counts by Race (Black & White Applicants)",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex",
    caption = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1, size = 12, face = "bold")
  )

mechanical_other_races <- mechanical_grouped_eligible |>
  filter(!race %in% c("Black or African American", "White") & !is.na(race))

m_plot <- ggplot(mechanical_other_races, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "Mechanical Eligibility Counts by Race (Other Applicants)",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex",
    caption = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +
  theme_minimal() +
  coord_flip()

# Save the plot
ggsave("mechanical_eligibility_counts.png", plot = m_plot, width = 12, height = 7, dpi = 300, bg = "white")

A Administrative: Verbal Expression (WK + PC)

admin_scores <- asvab_clean |> 
  select(asvab_wk, asvab_pc, race, sex)
admin_scores <- admin_scores |>
  mutate(total_score = (asvab_wk + asvab_pc))
print(admin_scores)
## # A tibble: 501,352 × 5
##    asvab_wk asvab_pc race                      sex    total_score
##       <dbl>    <dbl> <chr>                     <chr>        <dbl>
##  1       55       50 Black or African American Male           105
##  2       34       40 Black or African American Male            74
##  3       56       53 White                     Male           109
##  4       50       54 Black or African American Female         104
##  5       72       56 Black or African American Male           128
##  6       61       55 White                     Male           116
##  7       64       53 Black or African American Female         117
##  8       54       56 Black or African American Female         110
##  9       58       56 White                     Male           114
## 10       61       60 White                     Male           121
## # ℹ 501,342 more rows
# Filter for individuals with score >= 55
eligible_admin <- admin_scores |>
  filter(total_score >= 55)

admin_grouped_eligible <- eligible_admin |>
  group_by(race, sex) |>
  summarize(
    count_eligible = as.numeric(n()),  
  ) |>
  arrange(desc(count_eligible))
## `summarise()` has grouped output by 'race'. You can override using the
## `.groups` argument.
write.csv(admin_grouped_eligible, "admin_grouped_eligible.csv", row.names = FALSE)
# Filter for Black and White applicants
bw_admin_data <- admin_grouped_eligible |>
  filter(race %in% c("Black or African American", "White"))

# Plot for Black and White
ggplot(bw_admin_data, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "Admin Eligibility Counts by Race (Black & White Applicants)",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex",
    caption = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1, size = 12, face = "bold")
  )

admin_other_races <- admin_grouped_eligible |>
  filter(!race %in% c("Black or African American", "White") & !is.na(race))

a_plot <- ggplot(admin_other_races, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "Admin Eligibility Counts by Race (Other Applicants)",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex",
    caption = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +
  theme_minimal() +
  coord_flip()

# Save the plot
ggsave("admin_eligibility_counts.png", plot = a_plot, width = 12, height = 7, dpi = 300, bg = "white")

E Electronics: EI + AR + MK + GS

electronics_scores <- asvab_clean |> 
  select(asvab_ei, asvab_ar, asvab_mk, asvab_gs, race, sex)
electronics_scores <- electronics_scores |>
  mutate(total_score = (asvab_ar + asvab_mk + asvab_ei + asvab_gs))
print(electronics_scores)
## # A tibble: 501,352 × 7
##    asvab_ei asvab_ar asvab_mk asvab_gs race                    sex   total_score
##       <dbl>    <dbl>    <dbl>    <dbl> <chr>                   <chr>       <dbl>
##  1       54       37       45       51 Black or African Ameri… Male          187
##  2       38       40       46       39 Black or African Ameri… Male          163
##  3       61       49       42       55 White                   Male          207
##  4       38       43       45       50 Black or African Ameri… Fema…         176
##  5       55       49       43       55 Black or African Ameri… Male          202
##  6       66       55       53       60 White                   Male          234
##  7       47       57       55       50 Black or African Ameri… Fema…         209
##  8       47       55       58       53 Black or African Ameri… Fema…         213
##  9       53       50       56       56 White                   Male          215
## 10       61       57       58       57 White                   Male          233
## # ℹ 501,342 more rows
# Filter for individuals with score >= 60
eligible_electronics <- electronics_scores |>
  filter(total_score >= 60)

electronics_grouped_eligible <- eligible_electronics |>
  group_by(race, sex) |>
  summarize(
    count_eligible = as.numeric(n()),
    .groups = "drop"
  ) |> 

    arrange(desc(count_eligible))


write.csv(electronics_grouped_eligible, "electronics_grouped_eligible.csv", row.names = FALSE)
bw_electronics_data <- electronics_grouped_eligible |>
  filter(race %in% c("Black or African American", "White"))

# Plot for Black and White
ggplot(bw_electronics_data, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "Electronics Eligibility Counts by Race (Black & White Applicants)",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex",
    caption = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1, size = 12, face = "bold")
)

electronics_other_races <- electronics_grouped_eligible |>
  filter(!race %in% c("Black or African American", "White") & !is.na(race))

e_plot <- ggplot(electronics_other_races, aes(x = race, y = count_eligible, fill = sex)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "Electronics Eligibility Counts by Race (Other Applicants)",
    x = "Race",
    y = "Number Eligible",
    fill = "Sex",
    caption = "Graphic by Tiasia Saunders"
  ) +
  scale_y_continuous(labels = scales::comma) +
  theme_minimal() +
  coord_flip()

ggsave("electronics_eligibility_counts.png", plot = e_plot, width = 12, height = 7, dpi = 300, bg = "white")

Results:

  • The four graphics for the most part show similar eligibility across all races with almost the same count of people per race that qualify for the positions. This makes sense considering the minimum eligibility score is similar ( 55 for General and Admin and 47 for Mechanical and 60 for Electronics)

  • The four graphics show similar eligibility patterns across racial groups, with relatively comparable numbers of people qualifying for each Air Force occupational area. This is likely because, despite differences in the specific ASVAB sub-tests used for each composite score, the cutoff thresholds (55 for General and Admin, 47 for Mechanical, and 60 for Electronics) are fairly similar and target a comparable range of high-scoring individuals.

In other words, while each occupational area uses a different combination of sub-test scores, the overall difficulty and percentile requirement are close enough that they select similarly sized pools of eligible applicants across race and gender.

  • Different ASVAB sub-test sums → but similar high scores + similar cutoff thresholds → similar eligibility counts across composites.

  • If you can think of a better way to highlight the disparities in each composite scoring (in the process of researching how to do that) please let me know. But, for the most part for each composite scoring White Americans have high eligibility count, followed by Black Americans, then Asian. `