Voting Behavior & Party Discipline
Rice index, defection analysis, and roll-call panel data
Overview
This chapter builds a member-level roll-call panel from the Open Assembly API and computes standard measures of party cohesion. The output is a tidy (bill x member) dataset ready for logistic regression or difference-in-differences designs.
What you will build:
- Bill-level vote tallies (yes/no/abstain per bill)
- Member-level roll-call records (how each legislator voted)
- Rice index - standard measure of party cohesion
- Defection flags - which members broke party line
- Cross-party coalition detection
Step 1: Collect voted bills
import httpx
import pandas as pd
import time
API_KEY = "YOUR_KEY"
BASE = "https://open.assembly.go.kr/portal/openapi"
def api_get(endpoint, **params):
"""Single API call with pagination support."""
params = {"KEY": API_KEY, "Type": "json", "pSize": 100, **params}
r = httpx.get(f"{BASE}/{endpoint}", params=params,
headers={"User-Agent": "research-client"})
body = r.json()
rows = body.get(endpoint, [{}])
if isinstance(rows, dict):
return []
head = rows[0] if rows else {}
if "head" in head:
code = head["head"][1]["RESULT"]["CODE"]
if code != "INFO-000":
return []
return rows[1].get("row", [])
return []
# Get bills with vote records (22nd Assembly)
voted_bills = []
page = 1
while True:
rows = api_get("ncocpgfiaoituanbr", AGE="22", pIndex=page)
if not rows:
break
voted_bills.extend(rows)
page += 1
time.sleep(0.3)
df_votes = pd.DataFrame(voted_bills)
print(f"Bills with vote tallies: {len(df_votes)}")Step 2: Collect member-level roll calls
For each voted bill, fetch individual member votes:
def get_member_votes(bill_id, age="22"):
"""Get per-member vote for one bill."""
rows = api_get("nojepdqqaweusdfbi", AGE=age, BILL_ID=bill_id, pSize=300)
return rows
# Collect roll calls (rate-limited)
all_rolls = []
for i, row in df_votes.iterrows():
bill_id = row["BILL_ID"]
members = get_member_votes(bill_id)
for m in members:
m["BILL_ID"] = bill_id
m["BILL_NAME"] = row.get("BILL_NAME", "")
all_rolls.extend(members)
if (i + 1) % 10 == 0:
print(f" {i + 1}/{len(df_votes)} bills collected")
time.sleep(0.5) # respect rate limits
panel = pd.DataFrame(all_rolls)
print(f"Roll-call panel: {len(panel)} member-vote records")Faster alternative: If you have the kna CLI installed (see Chapter 6), you can skip API collection entirely:
from kna.data import BillDB
db = BillDB()
panel = db.roll_calls(assembly=22) # 383K records, instantStep 3: Rice index
The Rice index measures party cohesion on a 0-100 scale:
\[\text{Rice}_p = \frac{|Y_p - N_p|}{Y_p + N_p} \times 100\]
where \(Y_p\) and \(N_p\) are the number of yes and no votes from party \(p\) on a given bill. A score of 100 means perfect unity; 0 means an even split.
def rice_index(group):
"""Compute Rice index for a party-bill group."""
yes = (group["RESULT_VOTE_MOD"] == "찬성").sum()
no = (group["RESULT_VOTE_MOD"] == "반대").sum()
total = yes + no
if total == 0:
return None
return abs(yes - no) / total * 100
# Compute per party-bill
rice = (
panel
.groupby(["BILL_ID", "HG_NM_PARTY"]) # party field name may vary
.apply(rice_index)
.reset_index(name="rice")
)
# Average Rice index by party
rice_by_party = (
rice
.groupby("HG_NM_PARTY")["rice"]
.agg(["mean", "median", "count"])
.sort_values("mean", ascending=False)
)
print(rice_by_party.round(1))Step 4: Identify defectors
A defection occurs when a member votes against their party’s majority position:
def party_majority(group):
"""Determine party majority position (yes or no)."""
yes = (group["RESULT_VOTE_MOD"] == "찬성").sum()
no = (group["RESULT_VOTE_MOD"] == "반대").sum()
return "찬성" if yes >= no else "반대"
# Get majority position per party per bill
majorities = (
panel
.groupby(["BILL_ID", "HG_NM_PARTY"])
.apply(party_majority)
.reset_index(name="party_line")
)
# Join back to individual votes
panel_with_line = panel.merge(majorities, on=["BILL_ID", "HG_NM_PARTY"])
# Flag defections (voted, but against party line)
panel_with_line["defected"] = (
(panel_with_line["RESULT_VOTE_MOD"].isin(["찬성", "반대"])) &
(panel_with_line["RESULT_VOTE_MOD"] != panel_with_line["party_line"])
).astype(int)
# Top defectors
defectors = (
panel_with_line
.groupby("HG_NM")["defected"]
.agg(["sum", "count"])
.assign(rate=lambda d: d["sum"] / d["count"] * 100)
.sort_values("sum", ascending=False)
.head(20)
)
print(defectors.round(1))Step 5: Contested votes
Identify bills where the two major parties voted differently:
# Pivot: party majority per bill
pivot = (
majorities
.pivot_table(index="BILL_ID", columns="HG_NM_PARTY", values="party_line", aggfunc="first")
)
# Contested = major parties split
if "더불어민주당" in pivot.columns and "국민의힘" in pivot.columns:
contested = pivot[pivot["더불어민주당"] != pivot["국민의힘"]]
print(f"Contested bills: {len(contested)} / {len(pivot)} ({len(contested)/len(pivot)*100:.1f}%)")Export
# Tidy panel for regression
panel_with_line.to_csv("vote_panel.csv", index=False, encoding="utf-8-sig")
# Rice index summary
rice.to_csv("rice_index.csv", index=False)The vote panel is ready for models like:
# R: Party discipline as DV
library(fixest)
feols(defected ~ seniority + margin + contested | party + session, data = panel)References
- Rice, S. A. (1928). Quantitative Methods in Politics. New York: Knopf.
- Hix, S., Noury, A., & Roland, G. (2005). Power to the parties: Cohesion and competition in the European Parliament, 1979-2001. British Journal of Political Science, 35(2), 209-234.