Skip to content

Analysis Methods

The Observatorio del Congreso pipeline runs six analysis modules that transform raw roll-call voting data into political structure and power insights. The processing chain goes from individual vote records to ideological positions, co-voting networks, community partitions, centrality rankings, and formal vs. empirical power indices.

ModuleInputOutputCore Algorithm
W-NOMINATEVote matrix (legislators x events)Ideal point coordinatesSVD + Newton-Raphson
Co-VotingVote recordsNxN similarity matrix + graphAgreement counting
CommunitiesCo-voting graphPartition dict (node -> community)Louvain modularity
CentralityCo-voting graphPer-node scoresWeighted degree, betweenness
Power IndicesSeat counts per partyShapley-Shubik, Banzhaf valuesCombinatorial enumeration
Empirical PowerVote records + seat countsCritical party frequenciesRoll-call analysis

W-NOMINATE (Weighted Nominal Three-Step Estimation) is the standard algorithm for estimating legislator ideological positions from roll call votes, developed by Poole & Rosenthal (1985, 1997).

References:

  • Poole & Rosenthal (1985). “A Spatial Model for Legislative Roll Call Analysis”. American Journal of Political Science, 29(2), 357-384.
  • Poole & Rosenthal (1997). Congress: A Political-Economic History of Roll Call Voting. Oxford University Press.
  • Poole (2005). Spatial Models of Parliamentary Voting. Cambridge University Press.

The algorithm takes a binary vote matrix and recovers legislator ideal points in a low-dimensional policy space.

Step 1: Binarization. Raw vote strings are mapped to binary values:

Vote typeBinary value
a_favor1 (Yea)
en_contra0 (Nay)
abstencionNaN (excluded)
ausenteNaN (excluded)

Step 2: Filtering. Low-information votes and inactive legislators are removed:

min_votes = 10 # minimum binary votes per legislator
min_participants = 10 # minimum binary participants per vote event
lopsided_threshold = 0.975 # filter near-unanimous votes

Step 3: Estimation. The algorithm estimates two parameters per legislator (coordinates in 2D space) and two per vote event:

  • Ideal points (x_i, y_i): each legislator’s position in the policy space
  • Salience weights (beta): how sharply a legislator’s utility drops with distance from the cutting plane
  • Normal vectors (w_j): define the cutting plane separating Yea from Nay for each vote

Initialization uses SVD decomposition of the binary matrix, then Newton-Raphson optimization maximizes the classification likelihood.

MetricDescriptionInterpretation
Classification rate% of votes correctly predicted by the modelHigher = better fit; typical range 85-95%
APREAggregate Proportional Reduction in ErrorImprovement over baseline (majority prediction); 0.0 = no improvement, 1.0 = perfect
  • nominate_by_legislatura: runs W-NOMINATE separately for each legislature, producing independent ideal point spaces per period
  • nominate_cross_legislatura: combines all legislatures into a single run, placing all legislators in a shared space for direct comparison

scipy (svd, minimize, norm), numpy, pandas

Co-voting measures how often each pair of legislators vote the same way. This is the foundation for all network-based analysis.

  1. Load data: votes, persons, and organizations from SQLite
  2. Party normalization: normalize_party() maps mixed vote.group values to canonical organization IDs
  3. Primary party assignment: get_primary_party() assigns each legislator to their most frequent party
  4. Build matrix: build_covotacion_matrix() produces an NxN numpy matrix where entry (i,j) = agreement count between legislators i and j, normalized to 0-1
  5. Build graph: build_graph() converts the matrix to a NetworkX graph with:
    • Nodes: legislators, with attributes for party, gender
    • Edges: co-voting pairs, with weight = normalized similarity
# Simplified co-voting weight calculation
for i, j in legislator_pairs:
shared_votes = votes_i.intersection(votes_j)
total_votes = votes_i.union(votes_j)
weight = len(shared_votes) / len(total_votes)

The module returns a dict containing:

KeyTypeDescription
matrixNxN numpy arrayPairwise co-voting similarity
graphnetworkx.GraphWeighted co-voting network
party_mapdictperson_id -> party mapping
org_mapdictorg_id -> party name mapping
persons_dfDataFrameLegislator metadata

The Louvain algorithm detects communities of legislators who vote similarly, going beyond formal party labels to reveal actual voting blocs.

Louvain performs two-phase iterative optimization:

  1. Local moving: each node moves to the neighbor community that yields the largest modularity gain
  2. Aggregation: communities are collapsed into super-nodes, and the process repeats

The resolution parameter controls community granularity:

ResolutionEffect
< 1.0Fewer, larger communities (coarser)
1.0 (default)Standard modularity
> 1.0More, smaller communities (finer)

detect_communities() returns a partition dict mapping each node_id to a community_id.

analyze_communities() produces detailed analysis per community:

  • Party composition: count and percentage of each party within the community
  • Purity metric: percentage of the dominant party (100% = pure party bloc)
  • Cross-party legislators: individuals whose community differs from their formal party
  • Sub-blocks: detection of internal factions within large parties (specifically MORENA sub-bloques)

python-louvain (community package), networkx

Centrality identifies structurally important legislators in the co-voting network. Two complementary metrics are used.

centrality[node] = weighted_degree(node) / max_weighted_degree

Each node’s weighted degree is the sum of its edge weights (total co-voting intensity with all other legislators), normalized by the maximum weighted degree in the graph. Values range from 0.0 to 1.0.

Interpretation: high weighted degree = legislator co-votes heavily with many others, indicating alignment with the dominant coalition.

betweenness = nx.betweenness_centrality(graph, weight=None)

Betweenness is computed unweighted (weight=None). This is a deliberate choice:

Interpretation: high betweenness = legislator sits on the shortest paths between different communities, acting as a potential broker or swing voter.

MetricWeight handlingCaptures
Weighted DegreeUses weightsOverall co-voting intensity
BetweennessIgnores weights (weight=None)Structural bridging position

Nominal power indices calculate how much bargaining power each party has based solely on seat counts, assuming all members vote with their party.

For a party p, the Shapley-Shubik index averages marginal power across all N! permutations of parties:

for permutation in all_permutations(parties):
coalition = set()
for party in permutation:
coalition.add(party)
if coalition reaches majority AND coalition - {party} does not:
party gets one "pivot" point
break
# repeat for all N! permutations
ss_index[party] = pivots[party] / factorial(N)

A party is critical (a pivot) at position k in a permutation if the coalition of the first k parties reaches majority, but removing that party drops it below majority.

For a party p, count all winning coalitions where p is critical (its defection flips the outcome):

for coalition in all_subsets(parties):
if coalition is winning AND coalition - {party} is losing:
party is critical in this coalition
banzhaf_index[party] = critical_count[party] / total_critical_count

Multi-membership (legislators belonging to more than one party across their career) is resolved by:

  1. Collect all party memberships for each legislator
  2. Assign to the party where the legislator cast the most votes
  3. Ties broken by most recent start_date membership

Both indices support separate analysis for Diputados (camara='D') and Senado (camara='S').

Nominal power assumes party discipline. Empirical power measures what actually happens in roll-call votes.

For each vote event, the module identifies which parties were necessary to reach the majority threshold:

winning_coalition = parties that voted with the majority
for party in winning_coalition:
seats_without = majority_seats - party_seats
if seats_without < majority_threshold:
party is "critical" for this vote
empirical_power[party] = times_critical[party] / total_vote_events

This produces a 0.0-1.0 score reflecting how often a party’s votes were actually decisive.

The module identifies:

  • Close votes: vote events where the margin was narrow (near the majority threshold)
  • Swing voters: individual legislators whose vote could have changed the outcome
  • Top dissenters: legislators who voted against their party line most frequently, ranked by dissent count

The key output is a four-way comparison:

IndexBasisWhat It Measures
Nominal (seats)Seat countFormal representation
Shapley-ShubikSeat distributionBargaining power (permutation-based)
BanzhafSeat distributionBargaining power (coalition-based)
EmpiricalActual votesReal-world relevance

Divergences between nominal and empirical power reveal parties that are formally small but strategically critical (or vice versa).

All methods support temporal analysis across legislatures.

Each legislature gets its own independent analysis:

  • Separate W-NOMINATE ideal point spaces
  • Independent co-voting graphs and community detection
  • Chamber-specific power indices reflecting seat changes
  • nominate_cross_legislatura places all legislators in a shared ideal point space, enabling direct comparison across time periods
  • Community evolution tracking: which legislators shift communities between legislatures, and what that implies about coalition realignment

Tracking the Louvain modularity score across legislatures reveals changes in voting bloc cohesion:

  • Rising modularity: parties are voting more cohesively, sharper partisan divisions
  • Declining modularity: cross-party voting increasing, blocs dissolving or realigning
  • Sudden drops: may indicate a major legislative event (reform vote, leadership change) that disrupted normal voting patterns