Ideal Point Estimation in the New Mexico House: A W-NOMINATE Analysis
Introduction
This post uses W-NOMINATE (Weighted NOMINATE) to estimate
ideal points for members of the New Mexico House of
Representatives based on their voting patterns during the 57th
Legislature, Year 1 (2025). W-NOMINATE is a scaling method
that positions legislators in a spatial model based on their
roll call votes, where legislators with similar voting
patterns are placed closer together. The method estimates
“ideal points”—positions in a policy space that best explain
each legislator’s voting behavior. The analysis uses the wnominate
R package.
Data
The analysis uses voting data from OpenStates for the 57th Legislature, Year 1 (2025). OpenStates provides structured access to state legislative data, including legislator metadata (chamber, district, party affiliation) and roll call vote records. To access OpenStates data, you need to create an account—it’s free. By examining how legislators vote across many roll calls, we can identify underlying dimensions of conflict and cooperation in the legislature. The first dimension typically captures the primary ideological divide (often party-based), while additional dimensions may reveal other policy cleavages.
The OpenStates data download is structured as follows:
NM_2025_csv_3LAtJwe7Zs1ldDHVVcwMRa/
├── README
└── NM/
└── 2025/
├── NM_2025_bill_actions.csv
├── NM_2025_bill_document_links.csv
├── NM_2025_bill_documents.csv
├── NM_2025_bill_sources.csv
├── NM_2025_bill_sponsorships.csv
├── NM_2025_bill_version_links.csv
├── NM_2025_bill_versions.csv
├── NM_2025_bills.csv
├── NM_2025_organizations.csv
├── NM_2025_vote_counts.csv
├── NM_2025_vote_people.csv
├── NM_2025_vote_sources.csv
└── NM_2025_votes.csv
The analysis primarily uses NM_2025_votes.csv
(roll call vote metadata) and
NM_2025_vote_people.csv (individual legislator
vote choices).
if (!requireNamespace("pacman", quietly = TRUE)) {
install.packages("pacman")
}
suppressPackageStartupMessages(
pacman::p_load(
wnominate,
pscl,
ggplot2,
knitr,
devtools,
ggrepel,
data.table,
dplyr,
tidyr,
DT
)
)# Load legislator metadata
legis <- read.csv("/home/jtimm/Dropbox/GitHub/git-projects/newmexico-political-data/data-raw/open.pluralpolicy.com/nm-57th-congress-yr1/nm.csv") |>
rename(voter_id = id) |>
mutate(
chamber = case_when(
current_chamber == "lower" ~ "H",
current_chamber == "upper" ~ "S",
TRUE ~ NA_character_
),
party = case_when(
current_party %in% c("Democratic") ~ "D",
current_party %in% c("Republican") ~ "R",
current_party %in% c("Independent") ~ "I",
TRUE ~ "I"
),
legis_label = paste(chamber, current_district, party, family_name, sep = "_")
)
# Load OpenStates vote data files
open_dir <- "/home/jtimm/Dropbox/GitHub/git-projects/newmexico-political-data/data-raw/open.pluralpolicy.com/nm-57th-congress-yr1/NM_2025_csv_3LAtJwe7Zs1ldDHVVcwMRa/NM/2025/"
files <- list.files(open_dir, full.names = TRUE)
names(files) <- sub("\\.csv$", "", basename(files))
df_list <- lapply(files, read.csv, stringsAsFactors = FALSE)Attendance and Participation
The table below shows two distinct metrics for each legislator:
Attendance %: The percentage of votes where a legislator cast a yea or nay vote, excluding excused absences from the denominator. Excused absences are legitimate/approved, so legislators aren’t penalized for those. Formula: (yes + no) / (yes + no + absent).
Participation %: The percentage of votes where a legislator cast a yea or nay vote, including all absences (excused and unexcused) in the denominator. Participation means actually voting, regardless of the reason for not voting. Formula: (yes + no) / (yes + no + excused + absent).
# Calculate participation rates from vote data
# First get all House votes (from votes.csv, not vote_people)
house_votes <- df_list$NM_2025_votes |>
filter(motion_text == 'house passage') |>
pull(id)
total_votes_session <- length(house_votes)
# Get vote data for House members - join correctly on voter_id
vote_data_raw <- df_list$NM_2025_vote_people |>
filter(voter_id != '') |>
left_join(legis) |>
filter(grepl('H_', legis_label)) |>
filter(vote_event_id %in% house_votes) |>
select(legis_label, vote_event_id, option)
vd2 <- vote_data_raw |>
count(legis_label, option) |>
tidyr::pivot_wider(id_cols = legis_label,
names_from = option,
values_from = n)
# Calculate attendance and participation metrics
participation <- vd2 |>
mutate(
# Add party column
party = case_when(
grepl("_D_", legis_label) ~ "D",
grepl("_R_", legis_label) ~ "R",
TRUE ~ "I"
),
# Replace NA with 0 for vote counts
yes = ifelse(is.na(yes), 0, yes),
no = ifelse(is.na(no), 0, no),
excused = ifelse(is.na(excused), 0, excused),
absent = ifelse(is.na(absent), 0, absent),
# Calculate votes cast (yes + no)
votes_cast = yes + no,
# Attendance: (yes + no) / (yes + no + absent)
# Excludes excused absences from denominator
attendance_pct = ifelse((votes_cast + absent) > 0,
(votes_cast / (votes_cast + absent)) * 100,
0),
attendance_pct_rounded = round(attendance_pct, 1),
# Participation: (yes + no) / (yes + no + excused + absent)
# Includes everything in denominator
participation_pct = ifelse((votes_cast + excused + absent) > 0,
(votes_cast / (votes_cast + excused + absent)) * 100,
0),
participation_pct_rounded = round(participation_pct, 1)
) |>
arrange(participation_pct)participation |>
select(legis_label, party, votes_cast, excused, absent, attendance_pct_rounded, participation_pct_rounded) |>
DT::datatable(
colnames = c("Legislator", "Party", "Votes Cast", "Excused", "Absent", "Attendance %", "Participation %"),
options = list(
pageLength = 10,
order = list(list(6, 'asc'))
),
rownames = FALSE
)Creating the Vote Matrix
To perform W-NOMINATE analysis, we need to convert the vote data into a matrix format where rows represent legislators and columns represent votes. Each cell contains the legislator’s vote choice (yea, nay, or missing) for that particular roll call.
We focus on House members (filtering for H_ in
the legislator label) and convert vote options to numeric
codes: 1 for “yes”/“aye” and 6 for “no”/“nay”, following the
standard W-NOMINATE convention.
x <- df_list$NM_2025_vote_people |>
filter(voter_id != '') |>
left_join(legis) |>
select(legis_label, vote_event_id, option) |>
filter(grepl('H_', legis_label))
wide <- x |>
mutate(option = case_when(
option %in% c("yes", "aye") ~ 1L,
option %in% c("no", "nay") ~ 6L,
TRUE ~ NA_integer_
)) |>
pivot_wider(
names_from = vote_event_id,
values_from = option
)
mat <- wide |> select(-legis_label) |> as.matrix()
vnames <- colnames(mat)The vote matrix has 68 legislators (rows) and 321 roll call votes (columns). Below is a sample of the matrix showing 10 legislators and 5 votes:
wide |>
select(1:6) |>
head(10) |>
DT::datatable(
colnames = c("Legislator", paste("Vote", 1:5)),
options = list(
pageLength = 10,
dom = 't',
ordering = FALSE
),
rownames = FALSE
)Creating the Rollcall Object
The pscl package’s rollcall()
function creates a structured object that W-NOMINATE can
process. This object contains the vote matrix along with
metadata about legislators and votes.
roll_obj <- pscl::rollcall(
data = mat,
yea = 1,
nay = 6,
missing = NA,
legis.names = wide$legis_label,
vote.names = vnames
)Estimating Ideal Points
We estimate ideal points in both one-dimensional and two-dimensional spaces. The one-dimensional model captures the primary dimension of conflict (typically the main partisan/ideological divide), while the two-dimensional model may reveal additional policy cleavages.
The polarity parameter anchors the scaling by
specifying which legislator should be at the positive end of
each dimension. This ensures consistent orientation across
runs.
##
## Preparing to run W-NOMINATE...
##
## Checking data...
##
## ... 2 of 68 total members dropped.
##
## Votes dropped:
## ... 186 of 321 total votes dropped.
##
## Running W-NOMINATE...
##
## Getting bill parameters...
## Getting legislator coordinates...
## Starting estimation of Beta...
## Getting bill parameters...
## Getting legislator coordinates...
## Starting estimation of Beta...
## Getting bill parameters...
## Getting legislator coordinates...
##
##
## W-NOMINATE estimation completed successfully.
## W-NOMINATE took 2.539 seconds to execute.
##
## Preparing to run W-NOMINATE...
##
## Checking data...
##
## ... 2 of 68 total members dropped.
##
## Votes dropped:
## ... 186 of 321 total votes dropped.
##
## Running W-NOMINATE...
##
## Getting bill parameters...
## Getting legislator coordinates...
## Starting estimation of Beta...
## Getting bill parameters...
## Getting legislator coordinates...
## Starting estimation of Beta...
## Getting bill parameters...
## Getting legislator coordinates...
## Getting bill parameters...
## Getting legislator coordinates...
## Estimating weights...
## Getting bill parameters...
## Getting legislator coordinates...
## Estimating weights...
## Getting bill parameters...
## Getting legislator coordinates...
##
##
## W-NOMINATE estimation completed successfully.
## W-NOMINATE took 1.389 seconds to execute.
Model Fit
The fit statistics show how well the spatial model explains the voting patterns. Higher values indicate better fit.
## $`1D`
## correctclass1D apre1D
## 94.4962082 0.7707039
## gmp1D
## 0.8658075
##
## $`2D`
## correctclass1D correctclass2D
## 94.4962082 95.4901199
## apre1D apre2D
## 0.7707039 0.8121118
## gmp1D gmp2D
## 0.8609290 0.8856891
Key takeaway: The 1D model already does quite well (typically around 94-95% correct classification), and adding a second dimension only provides a modest improvement (to around 95-96%). This suggests that voting in the New Mexico House is predominantly structured along a single dimension—likely the standard left-right/liberal-conservative divide that correlates strongly with party.
The relatively small gain from adding the second dimension indicates there isn’t a major secondary cleavage (like regional issues, rural/urban splits, or cross-cutting coalitions) that strongly structures voting behavior beyond the main ideological dimension. This is typical for state legislatures with strong party discipline.
One-Dimensional Results
The one-dimensional plot shows legislators ordered along the primary dimension of conflict. Legislators on the left (negative values) tend to vote together, as do those on the right (positive values). The distance between legislators reflects how often they vote together. The plot reveals the primary dimension of voting behavior in the New Mexico House. Legislators are spread along a continuum, with those at opposite ends having the most divergent voting patterns. Typically, this first dimension captures the main partisan or ideological divide.
d1 <- data.frame(
label = rownames(ideal_points_1D$legislators),
ideal_points_1D$legislators,
stringsAsFactors = FALSE
) |>
arrange(-coord1D) |>
mutate(
party = case_when(
grepl("_D_", label) ~ "D",
grepl("_R_", label) ~ "R",
TRUE ~ "I"
),
color = case_when(
party == "D" ~ "#1f77b4",
party == "R" ~ "#d62728",
TRUE ~ "gray50"
)
)ggplot() +
geom_text(data = d1,
aes(x = reorder(label, -coord1D),
y = -coord1D,
label = label,
color = color),
size = 3) +
scale_color_identity() +
theme_minimal() +
labs(title = "1D W-NOMINATE Ideal Points: New Mexico House (2025)",
subtitle = "Legislators ordered by first dimension coordinate") +
theme(axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
legend.position = "none") +
xlab('') +
ylab('First Dimension') +
ylim(-1.1, 1.1) +
coord_flip()Two-Dimensional Results
The two-dimensional model allows for more complex spatial relationships. While the first dimension often captures the primary partisan divide, the second dimension may reveal other policy cleavages—such as urban/rural splits, regional differences, or cross-cutting issues.
legislators_2d <- data.frame(
label = rownames(ideal_points_2D$legislators),
ideal_points_2D$legislators,
stringsAsFactors = FALSE
) |>
mutate(
party = case_when(
grepl("_D_", label) ~ "D",
grepl("_R_", label) ~ "R",
TRUE ~ "I"
),
color = case_when(
party == "D" ~ "#1f77b4",
party == "R" ~ "#d62728",
TRUE ~ "gray50"
),
surname = sub(".*_([^_]+)$", "\\1", label)
)Base Plot
The base plot shows legislators positioned in the two-dimensional policy space. The gray circle represents the theoretical boundary of the space. Most legislators cluster within this boundary, with their positions reflecting their voting patterns across all roll calls.
base_2D <- ggplot(data = legislators_2d,
aes(x = -coord1D,
y = coord2D,
color = color)) +
geom_point(size = 1.5) +
scale_color_identity() +
annotate("path",
x = cos(seq(0, 2*pi, length.out = 300)),
y = sin(seq(0, 2*pi, length.out = 300)),
color = 'gray',
size = .25) +
coord_fixed(ratio = 1) +
xlab('First Dimension') +
ylab('Second Dimension') +
theme(legend.position = "none") +
ggtitle('Two-dimensional W-NOMINATE coordinates')Labeled Plot
The labeled plot shows each legislator’s position in the two-dimensional policy space with their labels. Legislators positioned close together tend to vote similarly, while those far apart have more divergent voting patterns. The horizontal axis (first dimension) typically captures the primary partisan divide, while the vertical axis (second dimension) may reveal additional policy cleavages or regional patterns. Overlapping labels are automatically hidden to maintain readability.
base_2D +
geom_text(
data = legislators_2d,
aes(x = -coord1D,
y = coord2D,
label = surname,
color = color),
hjust = 0.5,
vjust = -0.5,
size = 2.5,
check_overlap = TRUE) +
scale_color_identity() +
theme_classic() +
theme(legend.position = "none") +
ggtitle("W-NOMINATE Coordinates: New Mexico House (2025)",
subtitle = "Two-dimensional ideal point estimates with legislator labels")Summary
This W-NOMINATE analysis estimates ideal points for New Mexico House members during the 2025 session using roll call voting data from OpenStates. The one-dimensional model correctly classifies 94.5% of votes, with only modest improvement when adding a second dimension (95.5%), indicating that voting behavior is predominantly structured along a single liberal-conservative axis that closely follows party lines.