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:

  1. Bill-level vote tallies (yes/no/abstain per bill)
  2. Member-level roll-call records (how each legislator voted)
  3. Rice index - standard measure of party cohesion
  4. Defection flags - which members broke party line
  5. 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):
    """One page of results. Raises on API errors instead of returning []."""
    params = {"KEY": API_KEY, "Type": "json", "pSize": 100, **params}
    r = httpx.get(f"{BASE}/{endpoint}", params=params,
                  headers={"User-Agent": "research-client"})
    r.raise_for_status()
    body = r.json()
    if endpoint not in body:
        # Errors come back as HTTP 200 with a top-level RESULT object.
        # INFO-200 means no data, e.g. a page past the last one.
        result = body.get("RESULT", {})
        if result.get("CODE") == "INFO-200":
            return []
        raise RuntimeError(f"{endpoint}: {result.get('CODE')} {result.get('MESSAGE')}")
    head = body[endpoint][0]["head"]
    code = head[1]["RESULT"]["CODE"]
    if code != "INFO-000":
        raise RuntimeError(f"{endpoint}: {code} {head[1]['RESULT'].get('MESSAGE')}")
    rows = body[endpoint][1].get("row", []) if len(body[endpoint]) > 1 else []
    return [rows] if isinstance(rows, dict) else rows

# 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")
Tip

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).rename(columns={
    "bill_id": "BILL_ID", "member_id": "MONA_CD", "member_name": "HG_NM",
    "vote": "RESULT_VOTE_MOD", "party_api": "POLY_NM",
})  # 549,513 records, instant, with the API's column names used below

The API’s POLY_NM is each member’s party at the time of the query, written onto every past vote. kna keeps it as party_api, and its party column holds the party at election, the party whose list or ticket the member was elected on. To group by the party at election instead, rename party rather than party_api to POLY_NM. It keeps the satellite list parties 더불어민주연합 and 국민의미래 apart from 더불어민주당 and 국민의힘.

Step 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", "POLY_NM"])  # POLY_NM = party label
    .apply(rice_index)
    .reset_index(name="rice")
)

# Average Rice index by party
rice_by_party = (
    rice
    .groupby("POLY_NM")["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", "POLY_NM"])
    .apply(party_majority)
    .reset_index(name="party_line")
)

# Join back to individual votes
panel_with_line = panel.merge(majorities, on=["BILL_ID", "POLY_NM"])

# 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(["MONA_CD", "HG_NM"])["defected"]  # MONA_CD: names repeat
    .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="POLY_NM", 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(arrow)
library(dplyr)
library(fixest)

panel <- read.csv("vote_panel.csv")

# Member covariates from kna (path relative to the kna checkout).
# term_number is the seniority at the 22nd Assembly (1 = 초선).
# reelection is the lifetime count at collection, not seniority then.
members <- read_parquet("data/processed/members_22.parquet") %>%
  transmute(MONA_CD = mona_cd, term_number,
            pr_elected = election_type == "비례대표")

panel <- panel %>% left_join(members, by = "MONA_CD")

feols(defected ~ term_number + pr_elected | POLY_NM + BILL_ID, 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.