Ideal Points & Ideological Positioning

W-NOMINATE and DW-NOMINATE ideal points, polarization trends, and spatial voting models for the Korean National Assembly

Overview

Roll-call scaling methods such as W-NOMINATE and DW-NOMINATE (Dynamic Weighted Nominal Three-Step Estimation) place legislators on an ideological scale using their roll-call voting records. Unlike party labels - which shift with frequent mergers and rebrandings in Korean politics - ideal points provide a continuous, member-level measure.

The kna master database (release 0.8.1) includes 955 ideal-point estimates for the 20th-22nd Assembly (2016-present), one per legislator-term, in three series. The default series is per-assembly W-NOMINATE, aligned across terms using the legislators who served in consecutive assemblies. A pooled DW-NOMINATE series is shipped alongside it.

What you will learn:

  1. The three ideal-point series in kna and which one to use
  2. How to load and visualize ideal points
  3. Polarization trends across the 20th-22nd Assembly
  4. Using ideal points as independent/dependent variables in regression
Tip

Interactive explorer: See all 955 legislator-terms on an interactive scatterplot at Uijeong Jido 의정지도.

Why roll-call ideal points for Korea?

Korean parties undergo frequent mergers, splits, and name changes. What was the Grand National Party (한나라당) became the Saenuri Party (새누리당), then the Liberty Korea Party (자유한국당), then the United Future Party (미래통합당), and finally the People Power Party (국민의힘) - all within a decade. The progressive bloc has undergone similar rebranding cycles.

Simple party-label-based ideology measures cannot capture:

  • Within-party heterogeneity (moderates vs. hardliners)
  • Cross-party bridges (legislators who vote across party lines)
  • Temporal shifts (has the same party moved left or right?)

Roll-call-based ideal points solve all three. A W-NOMINATE fit of one assembly places its legislators on one scale. Comparing assemblies needs a common scale, which kna builds from bridging legislators - members who serve in more than one term. The bridged series maps each assembly onto the previous one, and pooled DW-NOMINATE estimates the three assemblies together.

Data foundation

Metric Value
Legislator-terms 955 (317 in the 20th, 318 in the 21st, 320 in the 22nd)
Assemblies 20th (2016-20), 21st (2020-24), 22nd (2024-)
Roll call votes 2,562,024 member-level records on 8,610 recorded votes
Contested votes scaled 269 / 331 / 226 (at least 2.5% of those voting yea or nay in the minority)
Bridging legislators 221 in two or more terms (126 in both the 20th and 21st, 156 in both the 21st and 22nd, 70 in all three)
Estimation method W-NOMINATE per assembly via R wnominate, chained bridging, and pooled DW-NOMINATE via R dwnominate
Sign Positive = conservative (보수), negative = liberal (진보), anchored on 추경호 (G152611B)
Vintage v20260917, votes through 2026-09-17

A legislator is scaled with at least 20 contested yea or nay votes. 찬성 counts as yea and 반대 as nay, while 기권 and 불참 are treated as missing. The 22nd Assembly is in session, so its values are provisional. The 22nd votes of the 16 members that the vote API omits come from LIKMS. One of them, 한동훈, has too few contested votes and no 22nd ideal point.

Three series

series= File Column Comparable within an assembly Comparable across assemblies Movement of a legislator
"bridged" (default) ideal_points_bridged.csv bridged_1d Yes Yes Partly
"wnominate" ideal_points_wnominate.csv wnom_1d Yes No No
"dwnominate" ideal_points_dwnominate.csv dwnom_1d Yes Yes No, constant

The bridged series equals per-assembly W-NOMINATE in the 20th. It maps the 21st onto the 20th and the 22nd onto the aligned 21st with a least-squares line fitted on the legislators who served in both, which assumes that these legislators do not move on average. With only three terms, pooled DW-NOMINATE admits one position per legislator for all terms, so changes in its party means across assemblies come entirely from turnover. Section 10 of the kna codebook describes the estimation.

Loading ideal points

Python (via kna)

from kna.data import BillDB

db = BillDB()
ip = db.ideal_points()  # bridged series (default)

print(f"Legislator-terms: {len(ip)}")
print(f"Columns: {list(ip.columns)}")
print(ip.head())

# The other two series
wnom = db.ideal_points(series="wnominate")
dwnom = db.ideal_points(series="dwnominate")
Legislator-terms: 955
Columns: ['member_id', 'member_name', 'party', 'party_bloc', 'term', 'wnom_1d', 'ideal_point', 'vintage']

Key columns:

Column Description
member_id MONA_CD (unique legislator identifier)
member_name Legislator name. Names repeat, so join on member_id
term Assembly number (20, 21, 22)
party Party at election in that assembly, including satellite list parties
party_bloc Broad bloc (conservative, liberal, progressive, rebuilding, centrist, liberal_minor, independent)
ideal_point First dimension of the series. db.ideal_points() renames bridged_1d, wnom_1d or dwnom_1d to this name
wnom_1d Bridged series only. The per-assembly W-NOMINATE value before alignment
vintage v followed by the date of the last vote used

The ideal_point column of the default series is what you should use for analysis. It is:

  • Cross-assembly comparable through bridging, under the assumption that the bridging legislators do not move on average
  • Signed to match political convention, with negative = liberal (진보) and positive = conservative (보수). No sign flip is needed.

R

library(readr)
library(dplyr)

# Path relative to the root of the kna checkout
ip <- read_csv("data/processed/ideal_points_bridged.csv",
               col_types = cols(member_id = col_character())) %>%
  rename(ideal_point = bridged_1d)  # positive = conservative, no flip needed

ip %>%
  group_by(term, party_bloc) %>%
  summarise(n = n(), mean_score = mean(ideal_point), .groups = "drop") %>%
  arrange(term, mean_score)

Via kna CLI

# Quick lookup
kna legislator 이재명 --assembly 22
#   ideal point          -0.359 (22nd, bridged W-NOMINATE, v20260917)
#   rank                 173 / 320 in the 22nd (← 진보 ··· 보수 →)

kna export writes bills, not ideal points. To save the ideal points for Stata or Excel, write them from Python:

ip.to_csv("idealpoints.csv", index=False, encoding="utf-8-sig")

Visualizing the ideological space

Scatter plot by assembly

import plotly.express as px

fig = px.strip(
    ip, x="ideal_point", y="term", color="party_bloc",
    hover_data=["member_name", "party"],
    color_discrete_map={
        "liberal": "#004EA2", "conservative": "#E61E2B",
        "progressive": "#FFCC00", "rebuilding": "#003764",
        "centrist": "#FF6B00", "liberal_minor": "#56B4E9",
        "independent": "#999999",
    },
    labels={"ideal_point": "Bridged W-NOMINATE (← 진보  ···  보수 →)", "term": "Assembly"},
    title="Ideological Distribution by Assembly",
)
fig.update_layout(template="plotly_white", height=400)
fig.show()

Density by bloc

import plotly.figure_factory as ff

ip22 = ip[ip["term"] == 22]
lib = ip22[ip22["party_bloc"] == "liberal"]["ideal_point"]
con = ip22[ip22["party_bloc"] == "conservative"]["ideal_point"]

fig = ff.create_distplot(
    [lib, con],
    ["Liberal bloc", "Conservative bloc"],
    colors=["#004EA2", "#E61E2B"],
    bin_size=0.02, show_rug=False,
)
fig.update_layout(
    xaxis_title="Bridged W-NOMINATE, 22nd Assembly (← 진보  ···  보수 →)",
    template="plotly_white", height=350,
)
fig.show()

The blocs follow party_bloc, so the liberal bloc of the 22nd includes the members elected on the 더불어민주연합 list and the conservative bloc those elected on the 국민의미래 list.

Polarization analysis

Polarization is measured as the distance between the liberal and conservative bloc means:

\[\text{Polarization}_t = |\bar{x}_{liberal,t} - \bar{x}_{conservative,t}|\]

# party_bloc already groups the parties, including 새누리당 and the
# satellite list parties. A hand-written party list can silently drop
# members, because party is the party at election.
blocs = ip[ip["party_bloc"].isin(["liberal", "conservative"])]

polar = (
    blocs
    .groupby(["term", "party_bloc"])["ideal_point"]
    .mean()
    .unstack()
)
polar["gap"] = abs(polar["liberal"] - polar["conservative"])
print(polar.round(3))

Expected output:

party_bloc  conservative  liberal    gap
term
20                 0.453   -0.364  0.818
21                 0.456   -0.380  0.836
22                 0.433   -0.385  0.818

In the bridged series the gap is 0.818 in the 20th, 0.836 in the 21st and 0.818 in the 22nd, 0.1% larger in the 22nd than in the 20th. The three series do not give the same trend, so it is worth computing the gap in each:

for series in ["bridged", "wnominate", "dwnominate"]:
    d = db.ideal_points(series=series)
    d = d[d["party_bloc"].isin(["liberal", "conservative"])]
    means = d.groupby(["term", "party_bloc"])["ideal_point"].mean().unstack()
    gap = (means["liberal"] - means["conservative"]).abs()
    print(f"{series:<11}", gap.round(3).tolist(), f"{gap[22] / gap[20] - 1:+.1%}")
bridged     [0.818, 0.836, 0.818] +0.1%
wnominate   [0.818, 0.931, 1.068] +30.6%
dwnominate  [0.762, 0.809, 0.834] +9.5%

The per-assembly W-NOMINATE gaps are not comparable across assemblies, because each fit fixes its own unit from the configuration it recovers. What a per-assembly fit identifies is the ratio of the distance between the blocs to the dispersion within them. Bridging rescales distance and dispersion by the same factor and leaves that ratio unchanged. The kna codebook therefore recommends reporting within-party dispersion together with the distance, and treating the growth in the per-assembly series as an upper bound (CODEBOOK.md, section 10.4).

Within-party sorting

Polarization can increase through two mechanisms, blocs moving apart (divergence) or members within blocs becoming more homogeneous (sorting). Standard deviations reveal which:

sorting = (
    blocs
    .groupby(["term", "party_bloc"])["ideal_point"]
    .agg(["mean", "std", "count"])
    .round(3)
)
print(sorting)
                    mean    std  count
term party_bloc
20   conservative  0.453  0.204    124
     liberal      -0.364  0.099    136
21   conservative  0.456  0.153    115
     liberal      -0.380  0.091    183
22   conservative  0.433  0.110    113
     liberal      -0.385  0.059    188

Declining standard deviations indicate sorting - fewer moderates within each bloc. The ratio of the gap to the pooled within-bloc standard deviation is 5.18, 7.04 and 10.01 in the 20th, 21st and 22nd in the bridged series, and 7.00, 7.11 and 6.80 in the pooled DW-NOMINATE series.

Using ideal points in regression

As independent variable

Ideal points predict legislative behavior beyond party labels:

library(arrow)
library(fixest)

# Does ideology predict bill passage (with committee fixed effects)?
bills <- read_parquet("data/processed/master_bills_22.parquet") %>%
  filter(bill_kind == "법률안", ppsr_kind == "의원",
         !grepl(",", rst_mona_cd))  # joint leads are comma-joined MONA_CDs

# Join ideal points to bills via the lead proposer's MONA_CD
bills_ip <- bills %>%
  inner_join(ip %>% filter(term == 22), by = c("rst_mona_cd" = "member_id"))

feols(enacted ~ ideal_point + I(ideal_point^2) | committee_nm, data = bills_ip)

As dependent variable

What predicts a legislator’s ideological position?

# Merge legislator metadata, one row per member of the 22nd
members <- read_parquet("data/processed/members_22.parquet") %>%
  transmute(member_id = mona_cd,
            pr_elected = election_type == "비례대표",
            female = sex == "여",
            seniority = term_number)  # seniority at the 22nd, not reelection

ip_full <- ip %>%
  filter(term == 22) %>%
  left_join(members, by = "member_id")

# Does electoral method matter? party_bloc, not party, as the fixed effect,
# because the proportional members of the two main blocs were elected on
# satellite lists (더불어민주연합, 국민의미래)
feols(ideal_point ~ pr_elected + female + seniority | party_bloc, data = ip_full)

Spatial voting models

For advanced applications, the raw roll-call data enables custom ideal point estimation:

library(tidyr)
library(pscl)
library(wnominate)

# Load roll calls (17th-22nd) and keep the 22nd, one row per bill_id and member_id
votes <- read_parquet("data/processed/roll_calls_all.parquet") %>%
  filter(term == 22)

# Pivot to vote matrix (legislators x votes), keyed on MONA_CD because
# names repeat. 찬성 = yea, 반대 = nay, 기권 and 불참 = missing, and no
# row = not seated at the time of the vote.
vote_matrix <- votes %>%
  mutate(vote_num = case_when(
    vote == "찬성" ~ 1, vote == "반대" ~ 6, TRUE ~ 9
  )) %>%
  select(member_id, bill_id, vote_num) %>%
  pivot_wider(names_from = bill_id, values_from = vote_num, values_fill = 0)

# Estimate with wnominate, with the kna thresholds (2.5% minority, 20
# votes) and the kna sign anchor 추경호 (G152611B) on both dimensions
rc <- rollcall(as.matrix(vote_matrix[, -1]),
               yea = 1, nay = 6, missing = 9, notInLegis = 0,
               legis.names = vote_matrix$member_id)
result <- wnominate(rc, dims = 2, minvotes = 20, lop = 0.025,
                    polarity = c("G152611B", "G152611B"))

Key references

  • Poole, K. T., & Rosenthal, H. (1985). A spatial model for legislative roll call analysis. American Journal of Political Science, 29(2), 357-384.
  • Poole, K. T., & Rosenthal, H. (2007). Ideology and Congress. Transaction Publishers.
  • Hix, S., & Jun, H. W. (2009). Party behaviour in the parliamentary arena: The case of the Korean National Assembly. Party Politics, 15(6), 667-694.
  • Carroll, R., et al. (2009). Measuring bias and uncertainty in DW-NOMINATE ideal point estimates via the parametric bootstrap. Political Analysis, 17(3), 261-275.

Interactive explorer

Explore all 955 legislator-terms interactively: