Ideal Point Estimation in the New Mexico House: A W-NOMINATE Analysis
Introduction
This analysis uses W-NOMINATE (Weighted
NOMINATE) – via the wnominate
R package – 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 spatial scaling method that positions
legislators on a map based on their roll call
votes. By examining these patterns, we can identify
the underlying dimensions of conflict – typically ideology and
party – that define the legislative session.
Data and Participation Baseline
We utilize voting data from OpenStates for the 2025 session. OpenStates provides the structured metadata – district, party affiliation, and individual vote choices – necessary to build a comprehensive vote matrix.
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).
pacman::p_load(wnominate, pscl, ggplot2, knitr, devtools, ggrepel, data.table, dplyr, tidyr, DT)
source("/home/jtimm/Dropbox/GitHub/blog/r/scale_voteview.R")# 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 vs. Participation
Before getting into the spatial mapping, it helps to look at the raw engagement levels in the House. We track two metrics to see how often a legislator’s preferences actually make it into the data:
Attendance %: This is \((yes + no) / (yes + no + absent)\). It shows how often a member voted when they were in the building and unexcused.
Participation %: This is \((yes + no) / (yes + no + excused + absent)\). This is the “full footprint” – it includes every missed vote, even the excused ones, to show the total volume of data we have for that person.
In a W-NOMINATE model, these percentages act as a “signal-to-noise” check. A legislator with a lower participation rate simply provides fewer data points for the algorithm to chew on. While we don’t exclude anyone based on these numbers, they provide useful context for the final “ideal points”: a member with 98% participation has a very precisely defined position, while a member with 60% might have a slightly “fuzzier” placement.
# 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(
caption = "Attendance and participation rates by legislator",
colnames = c("Legislator", "Party", "Votes Cast", "Excused", "Absent", "Attendance %", "Participation %"),
options = list(
pageLength = 10,
scrollX = TRUE,
order = list(list(6, 'asc'))
),
rownames = FALSE
)The W-NOMINATE Model
To perform the analysis, we convert the raw data into a
vote matrix where rows represent legislators
and columns represent specific roll calls. Following standard
convention, “Yes” votes are coded as 1 and “No” votes as 6. We
focus on House members (filtering for H_ in the
legislator label).
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(
caption = "Sample vote matrix: 10 legislators, 5 roll calls (1 = Yea, 6 = Nay, NA = absent/excused)",
colnames = c("Legislator", paste("Vote", 1:5)),
options = list(
pageLength = 10,
dom = 't',
ordering = FALSE
),
rownames = FALSE
)The pscl package’s rollcall()
function creates a structured rollcall 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
)We then estimate a two-dimensional W-NOMINATE model. The
polarity parameter anchors the scaling by
specifying which legislator should be at the positive end of
each dimension.
##
## 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.226 seconds to execute.
Model Fit and Dimensionality
The model’s strength is found in its classification accuracy – the percentage of actual votes correctly predicted by the spatial coordinates.
## correctclass1D correctclass2D
## 94.4962082 95.4901199
## apre1D apre2D
## 0.7707039 0.8121118
## gmp1D gmp2D
## 0.8609290 0.8856891
In the New Mexico House, the first dimension accounts for roughly 94-95% of voting behavior – party membership explains most of what happens on the floor. The second dimension adds a modest improvement in fit, enough to surface a handful of cross-cutting votes, but not enough to suggest a systematic second axis of conflict.
Spatial Results and Interpretation
One-Dimensional Polarization
When ordered along the primary dimension, the divide in the New Mexico House is almost entirely partisan: Democrats cluster at one pole and Republicans at the other, with virtually no overlap between the two parties.
d1 <- data.frame(
label = rownames(ideal_points_2D$legislators),
ideal_points_2D$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(plot.title = element_text(size = 16),
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 Relationships
The two-dimensional model allows us to see more complex spatial relationships:
The Horizontal Axis: Captures the primary partisan/ideological divide.
The Vertical Axis: Captures secondary variation – regional differences or cross-cutting issues that don’t always align with the party platform.
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)
)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',
linewidth = .25) +
coord_fixed(ratio = 1) +
xlab('First Dimension') +
ylab('Second Dimension') +
theme_minimal() +
theme(plot.title = element_text(size = 16),
legend.position = "none") +
ggtitle('Two-dimensional W-NOMINATE coordinates')Each legislator’s position is labeled with their surname. Even with this added nuance, the tight within-party clustering confirms that party membership remains the most powerful predictor of how a House member votes.
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_minimal() +
theme(plot.title = element_text(size = 16),
legend.position = "none") +
ggtitle("W-NOMINATE Coordinates: New Mexico House (2025)",
subtitle = "Two-dimensional ideal point estimates with legislator labels")Roll Call Geometry: Spread and Cutting Lines
Each roll call vote in a W-NOMINATE model has its own estimated geometry: a spread along each dimension and a midpoint (cutpoint) along the first dimension. These parameters describe how much a given vote “uses” each dimension to separate yeas from nays.
spread1D: How much the first (partisan) dimension drives the vote. A large value means the vote cleaves the chamber along the primary left-right axis – essentially a party-line roll call.spread2D: How much the second dimension contributes. A large value relative tospread1Dindicates a cross-cutting vote that divides legislators in a way that doesn’t track cleanly with party.midpoint1D: The location along the first dimension where the vote is closest to a coin-flip. It identifies the ideological pivot point of the vote.
actual_ids <- colnames(roll_obj$votes)
bill_analysis <- data.frame(
vote_event_id = actual_ids,
spread1 = ideal_points_2D$rollcalls[, "spread1D"],
spread2 = ideal_points_2D$rollcalls[, "spread2D"],
midpoint = ideal_points_2D$rollcalls[, "midpoint1D"],
stringsAsFactors = FALSE
) |>
left_join(
df_list$NM_2025_votes |> select(id, bill_id) |> rename(vote_event_id = id),
by = "vote_event_id"
) |>
left_join(df_list$NM_2025_bills, by = c("bill_id" = "id"))Dimension 1 Drivers
The votes with the largest spread1D values are
the floor’s most purely partisan roll calls – the ones where
nearly every Democrat voted one way and nearly every
Republican the other. These are the votes doing the most work
to define the primary axis of conflict.
bill_analysis |>
arrange(desc(abs(spread1))) |>
head(15) |>
select(identifier, title, spread1, spread2) |>
mutate(across(c(spread1, spread2), \(x) round(x, 3))) |>
DT::datatable(
caption = "Top 15 first-dimension drivers by absolute spread",
colnames = c("Bill", "Title", "Spread 1D", "Spread 2D"),
options = list(pageLength = 15, dom = "t", scrollX = TRUE, ordering = FALSE),
rownames = FALSE
)Dimension 2 Drivers
The second dimension captures votes where party alone is
insufficient to predict the outcome – where geography,
constituency, or issue-specific coalitions produce a different
split. To surface genuine cross-cutting signal (rather than
near-unanimous or otherwise uninformative votes), we filter to
votes with a spread2D above 0.5 and at least 3
“No” votes.
dim2_contested <- bill_analysis |>
filter(abs(spread2) > 0.5) |>
left_join(
df_list$NM_2025_vote_counts |>
filter(option == "no") |>
select(vote_event_id, value) |>
rename(no_count = value),
by = "vote_event_id"
) |>
filter(no_count > 3) |>
arrange(desc(abs(spread2)))dim2_contested |>
head(15) |>
select(identifier, title, spread1, spread2) |>
mutate(across(c(spread1, spread2), \(x) round(x, 3))) |>
DT::datatable(
caption = "Top second-dimension drivers -- contested votes only",
colnames = c("Bill", "Title", "Spread 1D", "Spread 2D"),
options = list(pageLength = 15, dom = "t", scrollX = TRUE, ordering = FALSE),
rownames = FALSE
)Cutting Lines
We can visualize this geometry directly. For any roll call, its cutting line is the boundary in the two-dimensional space that best separates predicted yeas from predicted nays. It runs perpendicular to the vote’s normal vector – a direction defined by the spread parameters – and passes through the midpoint. Legislators on one side of the line are predicted to vote Yea; those on the other, Nay.
The plot_rollcall() function below automates
this for any bill: it builds the legislator plot dataframe,
computes the cutting line slope and intercept (adjusting for
the -coord1D axis flip used throughout this
post), and renders the result using the VoteView palette with the
standard unit circle overlay.
plot_rollcall <- function(bill_id_str,
bill_analysis,
ideal_points_2D,
roll_obj,
legis) {
params <- bill_analysis |>
filter(identifier == bill_id_str) |>
slice(1)
if (nrow(params) == 0) stop("'", bill_id_str, "' not found in bill_analysis")
vid <- params$vote_event_id
# Cutting line geometry: slope and intercept on the -coord1D axis.
# Normal vector (spread1, spread2) perpendicular to the line;
# line passes through midpoint on dim1, 0 on dim2.
# Rearranging for x = -coord1D: slope = spread1/spread2, intercept = -midpoint/spread2
m_cut <- -(-params$spread1 / params$spread2)
b_cut <- -(params$midpoint / params$spread2)
# Clip cutting line to unit circle via intersection with x^2 + y^2 = 1.
# Substituting y = m*x + b: (1+m^2)*x^2 + 2mb*x + (b^2-1) = 0
cl_A <- 1 + m_cut^2
cl_disc <- 4 * (1 - b_cut^2 + m_cut^2)
cl_seg <- if (cl_disc >= 0) {
x1 <- (-2 * m_cut * b_cut - sqrt(cl_disc)) / (2 * cl_A)
x2 <- (-2 * m_cut * b_cut + sqrt(cl_disc)) / (2 * cl_A)
data.frame(x1 = x1, y1 = m_cut * x1 + b_cut,
x2 = x2, y2 = m_cut * x2 + b_cut)
} else NULL
leg_names <- rownames(ideal_points_2D$legislators)
plot_df <- as.data.frame(ideal_points_2D$legislators) |>
mutate(
legis_name = leg_names,
party_raw = legis[match(leg_names, legis$legis_label), ]$party,
vote_raw = roll_obj$votes[, vid],
party_prefix = case_when(party_raw == "D" ~ "Dem",
party_raw == "R" ~ "Rep",
TRUE ~ "Rep"),
vote_suffix = case_when(vote_raw == 1 ~ "Yea",
vote_raw == 6 ~ "Nay",
TRUE ~ "Not Voting"),
Party_Member_Vote = factor(
paste0(party_prefix, ": ", vote_suffix),
levels = levels(Party_Member_Vote)
)
)
ggplot(plot_df,
aes(x = -coord1D, y = coord2D,
color = Party_Member_Vote,
fill = Party_Member_Vote,
shape = Party_Member_Vote)) +
geom_point(size = 3, stroke = 0.5) +
annotate("path",
x = cos(seq(0, 2 * pi, length.out = 300)),
y = sin(seq(0, 2 * pi, length.out = 300)),
color = "gray", linewidth = 0.25) +
(if (!is.null(cl_seg))
geom_segment(data = cl_seg,
aes(x = x1, y = y1, xend = x2, yend = y2),
color = "gray40", linewidth = 0.45,
inherit.aes = FALSE)) +
scale_color_rollcall() +
scale_fill_rollcall() +
scale_shape_rollcall() +
coord_fixed(ratio = 1, xlim = c(-1.1, 1.1), ylim = c(-1.1, 1.1)) +
theme_minimal() +
labs(
title = paste0(params$identifier, ": ", params$title),
x = "First Dimension",
y = "Second Dimension",
color = NULL, fill = NULL, shape = NULL
) +
theme(
plot.title = element_text(size = 14),
legend.position = "bottom"
)
}A Dimension 1 Vote: SB 495
SB 495 (Higher Education Radio) looks like a party-line vote, but the W-NOMINATE model struggles with its internal resolution. The cutting line correctly puts most Democrats on one side and most Republicans on the other, but it is positioned too far left. The more conservative Republicans who voted “Nay” and the less conservative Republicans who joined the majority are not separated. The model sees the D/R divide but misses the conservative-versus-moderate split within the GOP caucus that actually defined the dissent.
A Dimension 2 Vote: HB 284
HB 284 (Free-Roaming Horses) is a different kind of problem. The “No” votes came entirely from legislators with the lowest Dimension 2 coordinates – a regional or industry-specific bloc. The model correctly reads this as a second-dimension event. But the cutting line ends up below the entire legislator cluster rather than through it. The model finds the right dimension but cannot find a valid intercept – so the line doesn’t physically separate the dissenters from anyone else.
Model Limits
Both cases reflect the same constraint: a 68-member chamber with a single session’s worth of roll calls is a small sample on both dimensions. A US House model has 435 members and thousands of votes to triangulate positions – enough information for the geometry to resolve intra-party variation and regional blocs with real precision. Here, a handful of idiosyncratic votes can push a cutting line off-center or off the map entirely, and the model doesn’t have enough data to correct it.
Despite these geometric hiccups, the overall results hold up. Legislators appear well-positioned in the space, and the estimated ideal points provide a reliable map of the House’s underlying political landscape – even if the cutting lines for low-information votes can’t always be taken at face value.
Summary
The 2025 W-NOMINATE analysis reveals a House defined by high partisan discipline. Because the first dimension correctly classifies the vast majority of votes, the “aisle” remains the primary feature of the legislative landscape, leaving only a small margin for the secondary, cross-cutting issues captured in the second dimension.
The OpenStates data makes this possible. It’s well-structured, covers all 50 states, and has everything needed to build a vote matrix from scratch – legislator metadata, roll call records, individual vote choices. A fairly simple workflow here, but the same pipeline scales directly to multi-session comparisons, cross-state polarization analysis, or tracking individual legislators over time.