Co-sponsorship Network Analysis
Graphs, centrality, community detection, and cross-party collaboration
Overview
Co-sponsorship networks reveal the hidden structure of legislative coalitions. When legislators co-sign bills together, they signal policy alignment - and these signals aggregate into a network where clusters, bridges, and brokers become visible.
What you will build:
- Co-sponsorship edge list from the Open Assembly API
- NetworkX graph with party-colored nodes
- Centrality measures (degree, betweenness, closeness)
- Community detection (Louvain algorithm)
- Cross-party collaboration index
Step 1: Build edge list
import httpx
import pandas as pd
import time
from itertools import combinations
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
# Collect co-sponsors for bills on a topic
bills = []
for page in range(1, 20):
rows = api_get("nzmimeepazxkubdpn", AGE="22", BILL_NAME="환경", pIndex=page)
if not rows:
break
bills.extend(rows)
time.sleep(0.3)
print(f"Bills found: {len(bills)}")
# Get co-sponsors per bill
edges = []
names = {} # MONA_CD -> name, for labels
for i, bill in enumerate(bills):
bill_id = bill["BILL_ID"]
# pSize=1000: the default page of 100 rows would cut the bills
# that list more than 100 proposers and supporters
sponsors = api_get("BILLINFOPPSR", BILL_ID=bill_id, pSize=1000)
# 발의자 = lead and co-proposers, 찬성자 = supporters.
# NASS_CD is the MONA_CD, which tells same-name members apart.
proposers = [s for s in sponsors
if s.get("PPSR_ROLE") == "발의자" and s.get("NASS_CD")]
names.update({s["NASS_CD"]: s["PPSR_NM"] for s in proposers})
ids = {s["NASS_CD"] for s in proposers}
# Create pairwise edges
for a, b in combinations(sorted(ids), 2):
edges.append({"source": a, "target": b, "bill_id": bill_id})
if (i + 1) % 20 == 0:
print(f" {i + 1}/{len(bills)} bills processed")
time.sleep(0.3)
edge_df = pd.DataFrame(edges)
print(f"Raw edges: {len(edge_df)}")Faster with kna: The master database includes pre-built cosponsorship data, one row per bill and member for every member law bill of the 17th-22nd (1,379,763 rows, 255,415 of them in the 22nd). This builds the same edge_df and names without any API calls:
import pandas as pd
from itertools import combinations
from kna.data import BillDB
db = BillDB()
e = db.cosponsorship_edges(assembly=22) # instant
# role is 대표발의 (lead), 공동발의 (co-proposer) or 찬성 (supporter).
# Keep the proposers, as in the API example.
e = e[e["role"].isin(["대표발의", "공동발의"])]
# Same topic filter as the API example
titles = db.bills(assembly=22, columns=["bill_id", "bill_nm"])
topic_ids = titles.loc[titles["bill_nm"].str.contains("환경", na=False), "bill_id"]
e = e[e["bill_id"].isin(topic_ids)]
# Nodes are MONA_CDs (member_id), since names repeat
names = dict(zip(e["member_id"], e["member_name"]))
edge_df = pd.DataFrame(
[{"source": a, "target": b, "bill_id": bill_id}
for bill_id, ids in e.groupby("bill_id")["member_id"]
for a, b in combinations(sorted(set(ids)), 2)]
)Step 2: Build weighted graph
import networkx as nx
# Aggregate: weight = number of shared bills
weighted = (
edge_df
.groupby(["source", "target"])
.size()
.reset_index(name="weight")
)
G = nx.from_pandas_edgelist(weighted, "source", "target", edge_attr="weight")
print(f"Nodes: {G.number_of_nodes()}, Edges: {G.number_of_edges()}")
print(f"Density: {nx.density(G):.4f}")Step 3: Add party attributes
# Get member info for party labels, keyed on MONA_CD
members = api_get("nwvrqwxyaytdsfvhu", AGE="22", pSize=300)
party_map = {m["MONA_CD"]: m["POLY_NM"] for m in members if m.get("MONA_CD")}With kna: nwvrqwxyaytdsfvhu lists only the members serving today, so members who have left the Assembly come out as unknown below. kna’s member table covers all 321 members of the 22nd, with the party at election:
from kna.data import BillDB
db = BillDB()
# The satellite list parties can be merged into their parent parties,
# as kna's party_bloc does
SATELLITES = {"더불어민주연합": "더불어민주당", "국민의미래": "국민의힘"}
m22 = db.members(assembly=22)
party_map = m22.set_index("mona_cd")["party"].replace(SATELLITES).to_dict()for node in G.nodes():
G.nodes[node]["name"] = names.get(node, node)
G.nodes[node]["party"] = party_map.get(node, "unknown")
# Party distribution
from collections import Counter
party_counts = Counter(nx.get_node_attributes(G, "party").values())
print(pd.Series(party_counts).sort_values(ascending=False))Step 4: Centrality measures
# Degree centrality: how many co-sponsors
degree = nx.degree_centrality(G)
# Weighted degree (strength): total co-sponsorship ties
strength = {n: sum(d["weight"] for _, _, d in G.edges(n, data=True)) for n in G.nodes()}
# Betweenness: who bridges communities
betweenness = nx.betweenness_centrality(G, weight="weight")
# Compile into DataFrame
centrality = pd.DataFrame({
"id": list(G.nodes()),
"name": [G.nodes[n]["name"] for n in G.nodes()],
"party": [G.nodes[n].get("party", "") for n in G.nodes()],
"degree": [degree[n] for n in G.nodes()],
"strength": [strength[n] for n in G.nodes()],
"betweenness": [betweenness[n] for n in G.nodes()],
}).sort_values("betweenness", ascending=False)
print("Top 10 brokers (betweenness centrality):")
print(centrality.head(10)[["name", "party", "degree", "betweenness"]].to_string(index=False))Interpreting centrality:
| Measure | High value means |
|---|---|
| Degree | Co-sponsors with many different legislators |
| Strength | Frequently co-sponsors (many shared bills) |
| Betweenness | Bridges between otherwise disconnected groups |
Step 5: Community detection
The Louvain algorithm finds densely connected subgroups:
try:
from community import community_louvain
partition = community_louvain.best_partition(G, weight="weight")
except ImportError:
# Fallback
communities = nx.community.greedy_modularity_communities(G, weight="weight")
partition = {}
for i, comm in enumerate(communities):
for node in comm:
partition[node] = i
n_communities = len(set(partition.values()))
modularity = nx.community.modularity(G,
[{n for n, c in partition.items() if c == i} for i in set(partition.values())],
weight="weight")
print(f"Communities detected: {n_communities}")
print(f"Modularity (Q): {modularity:.3f}")Interpreting modularity: Q > 0.3 suggests meaningful community structure. In Korean legislative networks, Q typically ranges 0.3-0.6, reflecting strong party-line clustering with some cross-party bridges.
# Compare detected communities vs actual parties
for node in G.nodes():
G.nodes[node]["community"] = partition[node]
community_party = pd.DataFrame([
{"name": G.nodes[n]["name"], "party": G.nodes[n]["party"], "community": G.nodes[n]["community"]}
for n in G.nodes()
])
cross_tab = pd.crosstab(community_party["community"], community_party["party"])
print(cross_tab)Step 6: Cross-party collaboration
What fraction of a legislator’s co-sponsorships are with members of other parties?
def cross_party_rate(node, graph):
"""Fraction of co-sponsors from different parties."""
my_party = graph.nodes[node].get("party", "")
neighbors = list(graph.neighbors(node))
if not neighbors:
return 0.0
cross = sum(1 for n in neighbors if graph.nodes[n].get("party", "") != my_party)
return cross / len(neighbors)
centrality["cross_party"] = [cross_party_rate(n, G) for n in centrality["id"]]
# Average cross-party rate by party
print("\nCross-party collaboration by party:")
print(centrality.groupby("party")["cross_party"].mean().sort_values(ascending=False).round(3))Visualization
import plotly.graph_objects as go
pos = nx.spring_layout(G, k=0.3, seed=42, weight="weight")
PARTY_COLORS = {
"더불어민주당": "#004EA2", "국민의힘": "#E61E2B",
"조국혁신당": "#003764", "개혁신당": "#FF6B00",
"정의당": "#FFCC00", "무소속": "#999999",
}
node_x, node_y, node_color, node_text = [], [], [], []
for node in G.nodes():
x, y = pos[node]
node_x.append(x)
node_y.append(y)
party = G.nodes[node].get("party", "")
node_color.append(PARTY_COLORS.get(party, "#999999"))
node_text.append(f"{G.nodes[node]['name']} ({party})<br>Degree: {degree[node]:.3f}")
fig = go.Figure()
fig.add_trace(go.Scatter(
x=node_x, y=node_y, mode="markers",
marker=dict(size=8, color=node_color, opacity=0.8),
text=node_text, hoverinfo="text",
))
fig.update_layout(
title="Co-sponsorship Network",
showlegend=False, template="plotly_white",
xaxis=dict(showgrid=False, visible=False),
yaxis=dict(showgrid=False, visible=False),
)
fig.show()Export
# Nodes with centrality
centrality.to_csv("network_nodes.csv", index=False, encoding="utf-8-sig")
# Weighted edges
weighted.to_csv("network_edges.csv", index=False, encoding="utf-8-sig")References
- Fowler, J. H. (2006). Connecting the Congress: A study of cosponsorship networks. Political Analysis, 14(4), 456-487.
- Kirkland, J. H., & Gross, J. H. (2014). Measurement and theory in legislative networks. Social Networks, 36, 97-109.
- Blondel, V. D., et al. (2008). Fast unfolding of communities in large networks. Journal of Statistical Mechanics, P10008.