Resumen General
El pipeline del Observatorio del Congreso ejecuta seis módulos de análisis que transforman datos brutos de votación nominal en información sobre estructura política y poder. La cadena de procesamiento va desde registros individuales de voto hasta posiciones ideológicas, redes de co-votación, particiones en comunidades, rankings de centralidad e índices de poder formal vs. empírico.
| Módulo | Entrada | Salida | Algoritmo Central |
|---|---|---|---|
| W-NOMINATE | Matriz de votos (legisladores x eventos) | Coordenadas de puntos ideales | SVD + Newton-Raphson |
| Co-Votación | Registros de votación | Matriz NxN de similitud + grafo | Conteo de acuerdos |
| Comunidades | Grafo de co-votación | Partición (nodo -> comunidad) | Louvain (nx.community) |
| Centralidad | Grafo de co-votación | Puntuaciones por nodo | Grado ponderado, intermediación |
| Índices de Poder | Escaños por partido | Valores Shapley-Shubik, Banzhaf | Programación dinámica O(n²W) |
| Poder Empírico | Registros de votación + escaños | Frecuencias de partidos críticos | Análisis de votaciones nominales |
Capa de Configuración
El pipeline de análisis centraliza los parámetros ajustables en analysis/config.py. Esto permite modificar umbrales y configuraciones de algoritmos sin alterar los módulos individuales.
| Parámetro | Tipo | Default | Propósito |
|---|---|---|---|
MIN_EVENTS_PER_WINDOW | int | 30 | Mínimo de eventos de votación por ventana temporal (ventanas por debajo se fusionan) |
LOUVAIN_SEED | int | 42 | Semilla aleatoria para reproducibilidad de Louvain |
LOUVAIN_RESOLUTION | float | 1.0 | Resolución de Louvain (> 1.0 = comunidades más pequeñas) |
CLOSE_VOTES_THRESHOLD | int | 10 | Margen máximo para clasificar una votación como “cerrada” |
REFORMA_JUDICIAL_VE_IDS | list[str] | [“VE04”, “VE05”] | IDs de eventos de votación para análisis de Reforma Judicial |
TOP_DISSENTERS_GLOBAL | int | 10 | Número de principales disidentes a retornar (global) |
TOP_DISSIDENTS_PER_WINDOW | int | 5 | Número de principales disidentes por ventana temporal |
DEALIGNMENT_THRESHOLD | float | -0.05 | Cambio mínimo de co-votación para detectar desalineación partidista |
Capa de Acceso a Datos
analysis/db.py centraliza el acceso a SQLite para todos los módulos de análisis. Provee una fábrica de conexiones con PRAGMAs configurados y cinco funciones de consulta parametrizadas que eliminan la duplicación de SQL entre módulos.
Fábrica de Conexiones
get_connection(db_path=None) retorna una conexión SQLite con:
journal_mode = WAL(lecturas concurrentes)busy_timeout = 5000ms(reintentar al bloquear)foreign_keys = ON(aplicar integridad referencial)row_factory = sqlite3.Row(acceso a columnas tipo diccionario)
Funciones de Consulta
| Función | Parámetros | Retorna |
|---|---|---|
get_vote_events() | legislatura, organization_id, result | Filas de vote_event filtradas |
get_votes() | vote_event_id, voter_id, join_vote_event | Filas de votos con legislatura/cámara opcional |
get_persons() | person_id | Filas de personas |
get_organizations() | clasificacion | Filas de organizaciones |
get_memberships() | person_id, org_id, rol | Filas de membresías |
Todas las funciones usan consultas parametrizadas (sin interpolación de strings) y manejo adecuado del ciclo de vida de la conexión (try/finally close).
Constantes Compartidas
analysis/constants.py centraliza colores de partidos, mapeos de nombres y ordenamiento canónico utilizados en todos los módulos de visualización y análisis.
| Constante | Tipo | Propósito |
|---|---|---|
PARTY_COLORS | dict[str, str] | Colores Matplotlib por partido (clave = nombre corto en mayúsculas) |
DEFAULT_COLOR | str | Color por defecto (#CCCCCC) |
ORG_TO_SHORT | dict[str, str] | Mapeo org_id → nombre corto (O01 → MORENA) |
PARTY_ORDER | list[str] | Ordenamiento canónico de partidos para visualizaciones |
COMMON_PARTIES | list[str] | Partidos presentes en ambas cámaras |
CAMARA_MAP | dict[str, str] | Nombre de cámara → código (diputados → D) |
COLORES_WEB | dict[str, str] | Colores compatibles con ECharts para exportación web |
PARTIDO_MAP | dict[str, str] | Nombre completo → abreviatura para exportación JSON |
W-NOMINATE: Estimación de Puntos Ideales
W-NOMINATE (Weighted Nominal Three-Step Estimation) es el algoritmo estándar para estimar posiciones ideológicas de legisladores a partir de votaciones nominales, desarrollado por Poole & Rosenthal (1985, 1997).
Referencias:
- 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.
Funcionamiento
El algoritmo recibe una matriz binaria de votos y recupera los puntos ideales de los legisladores en un espacio de políticas de baja dimensionalidad.
Paso 1: Binarización. Los strings de voto se mapean a valores binarios:
| Tipo de voto | Valor binario |
|---|---|
a_favor | 1 (Yea) |
en_contra | 0 (Nay) |
abstencion | NaN (excluido) |
ausente | NaN (excluido) |
Paso 2: Filtrado. Se eliminan votos con baja información y legisladores inactivos:
min_votes = 10 # mínimo de votos binarios por legislador
min_participants = 10 # mínimo de participantes binarios por evento de voto
lopsided_threshold = 0.975 # filtrar votos casi unánimes
Paso 3: Estimación. El algoritmo estima dos parámetros por legislador (coordenadas en espacio 2D) y dos por evento de voto:
- Puntos ideales (x_i, y_i): la posición de cada legislador en el espacio de políticas
- Pesos de relevancia (beta): qué tan abruptamente cae la utilidad de un legislador al alejarse del plano de corte
- Vectores normales (w_j): definen el plano de corte que separa Yea de Nay para cada voto
La inicialización usa descomposición SVD de la matriz binaria, seguida de optimización Newton-Raphson que maximiza la verosimilitud de clasificación.
Métricas de Calidad
| Métrica | Descripción | Interpretación |
|---|---|---|
| Tasa de clasificación | % de votos correctamente predichos por el modelo | Mayor = mejor ajuste; rango típico 85-95% |
| APRE | Aggregate Proportional Reduction in Error | Mejora sobre el baseline (predicción por mayoría); 0.0 = sin mejora, 1.0 = perfecto |
Variantes de Implementación
nominate_by_legislatura: ejecuta W-NOMINATE por separado para cada legislatura, produciendo espacios ideales independientes por periodonominate_cross_legislatura: combina todas las legislaturas en una sola ejecución, ubicando a todos los legisladores en un espacio compartido para comparación directa
Dependencias
scipy (svd, minimize, norm), numpy, pandas
Análisis de Co-Votación
La co-votación mide la frecuencia con la que cada par de legisladores vota de la misma manera. Es la base para todo el análisis basado en redes.
Pipeline de Construcción
- Carga de datos: votos, personas y organizaciones desde SQLite
- Normalización de partidos:
normalize_party()mapea valores mixtos devote.groupa IDs canónicos de organización - Asignación de partido primario:
get_primary_party()asigna cada legislador a su partido más frecuente - Construcción de matriz:
build_covotacion_matrix()produce una matriz numpy NxN donde la entrada (i,j) = conteo de acuerdos entre legisladores i y j, normalizado a 0-1 - Construcción de grafo:
build_graph()convierte la matriz en un grafo NetworkX con:- Nodos: legisladores, con atributos de partido y género
- Aristas: pares de co-votación, con
weight= similitud normalizada
# Cálculo simplificado del peso de co-votación
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)
Salida
El módulo retorna un diccionario que contiene:
| Clave | Tipo | Descripción |
|---|---|---|
matrix | arreglo numpy NxN | Similitud de co-votación por pares |
graph | networkx.Graph | Red de co-votación ponderada |
party_map | dict | mapeo person_id -> partido |
org_map | dict | mapeo org_id -> nombre del partido |
persons_df | DataFrame | Metadatos de legisladores |
Detección de Comunidades (Louvain)
El algoritmo de Louvain detecta comunidades de legisladores que votan de forma similar, yendo más allá de las etiquetas formales de partido para revelar bloques de votación reales.
Algoritmo
Louvain realiza optimización iterativa en dos fases:
- Movimiento local: cada nodo se mueve a la comunidad vecina que produce la mayor ganancia de modularidad
- Agregación: las comunidades se colapsan en super-nodos y el proceso se repite
El parámetro de resolución controla la granularidad de las comunidades:
| Resolución | Efecto |
|---|---|
| < 1.0 | Menos comunidades, más grandes (más grueso) |
| 1.0 (default) | Modularidad estándar |
| > 1.0 | Más comunidades, más pequeñas (más fino) |
Implementación
El módulo utiliza la implementación Louvain integrada de NetworkX (disponible desde NX 3.2), configurada mediante los parámetros compartidos de config.py:
communities = nx.community.louvain_communities(
graph,
weight="weight",
resolution=config.LOUVAIN_RESOLUTION,
seed=config.LOUVAIN_SEED, # 42 para reproducibilidad
)
:::note
El parámetro seed garantiza particiones de comunidad reproducibles entre ejecuciones. El valor de resolution se puede ajustar en config.py sin modificar el módulo de detección.
:::
Salida
detect_communities() retorna un diccionario de partición que mapea cada node_id a un community_id.
analyze_communities() produce un análisis detallado por comunidad:
- Composición partidista: conteo y porcentaje de cada partido dentro de la comunidad
- Métrica de pureza: porcentaje del partido dominante (100% = bloque puro de partido)
- Legisladores cruzados: individuos cuya comunidad difiere de su partido formal
- Sub-bloques: detección de facciones internas dentro de partidos grandes (específicamente sub-bloques de MORENA)
:::tip Una comunidad con pureza menor al 70% señala una coalición genuinamente multi-partidista, no solo una etiqueta de partido. Estas comunidades mixtas frecuentemente revelan alianzas legislativas reales. :::
Dependencias
networkx (nx.community.louvain_communities integrado desde NX 3.2)
Métricas de Centralidad
La centralidad identifica legisladores estructuralmente importantes en la red de co-votación. Se utilizan dos métricas complementarias.
Centralidad de Grado Ponderada
centrality[node] = weighted_degree(node) / max_weighted_degree
El grado ponderado de cada nodo es la suma de sus pesos de arista (intensidad total de co-votación con todos los demás legisladores), normalizado por el grado ponderado máximo en el grafo. Los valores van de 0.0 a 1.0.
Interpretación: grado ponderado alto = el legislador co-vota intensamente con muchos otros, indicando alineación con la coalición dominante.
Centralidad de Intermediación (Betweenness)
betweenness = nx.betweenness_centrality(graph, weight=None)
La intermediación se calcula sin ponderar (weight=None). Esta es una decisión deliberada:
:::tip
Los pesos de co-votación son medidas de similitud, no distancias geodésicas. Mayor peso = más similar = más cercano. Si se pasan como weight a NetworkX, el algoritmo los interpretaría como costos, tratando pares con alta co-votación como lejanos. Esto invierte la interpretación deseada. Usar weight=None cuenta cada arista como un salto, identificando correctamente a los legisladores que puentean distintas comunidades de votación.
:::
Interpretación: intermediación alta = el legislador se ubica en los caminos más cortos entre comunidades distintas, actuando como potencial intermediario o legislador bisagra.
| Métrica | Manejo de pesos | Captura |
|---|---|---|
| Grado Ponderado | Usa pesos | Intensidad general de co-votación |
| Intermediación | Ignora pesos (weight=None) | Posición estructural de puente |
Índices de Poder (Nominal)
Los índices de poder nominal calculan el poder de negociación de cada partido basándose únicamente en el conteo de escaños, asumiendo que todos los miembros votan con su partido.
Índice de Shapley-Shubik
Para un partido p, el índice de Shapley-Shubik computa el poder marginal mediante programación dinámica en lugar de enumeración de permutaciones por fuerza bruta:
def shapley_shubik(player_weights, quota):
n = len(player_weights)
results = {}
for i in range(n):
# dp[s][w] = número de subconjuntos de tamaño s
# con peso total w (excluyendo al jugador i)
dp = build_dp_table(weights_without_i, quota)
ss_i = 0.0
for s in range(n):
for w in range(quota):
if w + player_weights[i] >= quota:
ss_i += dp[s][w] * factorial(s) * factorial(n - 1 - s)
results[i] = ss_i / factorial(n)
return results
Un partido es crítico (un pivot) cuando se une a una coalición que está por debajo de la cuota, y su peso empuja el total a la cuota o por encima. La tabla DP cuenta cuántas configuraciones de coalición hacen crítico a cada partido.
Complejidad: O(n²W) donde n = número de partidos y W = cuota. Para 13 partidos con cuota ~251, esto resulta en aproximadamente 330K operaciones por jugador — comparado con 6.2 mil millones (13!) con enumeración de permutaciones por fuerza bruta.
Índice de Banzhaf
Para un partido p, se cuentan todas las coaliciones ganadoras donde p es crítico (su defección cambia el resultado):
for coalition in all_subsets(parties):
if coalition es ganadora AND coalition - {party} es perdedora:
party es crítico en esta coalición
banzhaf_index[party] = critical_count[party] / total_critical_count
Asignación de Escaños
La multi-pertenencia (legisladores que pertenecen a más de un partido a lo largo de su carrera) se resuelve mediante:
- Recopilar todas las membresías de partido para cada legislador
- Asignar al partido donde el legislador emitió más votos
- Empates se resuelven con la membresía con
start_datemás reciente
Análisis por Cámara
Ambos índices soportan análisis separado para Diputados (camara='D') y Senado (camara='S').
Análisis de Poder Empírico
El poder nominal asume disciplina partidista. El poder empírico mide lo que realmente ocurre en las votaciones nominales.
Partidos Críticos por Votación
Para cada evento de votación, el módulo identifica qué partidos fueron necesarios para alcanzar el umbral de mayoría:
winning_coalition = partidos que votaron con la mayoría
for party in winning_coalition:
seats_without = majority_seats - party_seats
if seats_without < majority_threshold:
party es "crítico" para esta votación
Índice de Poder Empírico
empirical_power[party] = times_critical[party] / total_vote_events
Esto produce una puntuación de 0.0 a 1.0 que refleja qué tan frecuentemente los votos de un partido fueron realmente decisivos.
Legisladores Bisagra y Votaciones Cerradas
El módulo identifica:
- Votaciones cerradas: eventos donde el margen fue estrecho (cerca del umbral de mayoría)
- Legisladores bisagra (swing voters): legisladores individuales cuyo voto podría haber cambiado el resultado
- Principales disidentes: legisladores que votaron en contra de la línea de su partido con mayor frecuencia, clasificados por conteo de disidencias
Comparación de Poder
La salida clave es una comparación de cuatro dimensiones:
| Índice | Base | Qué Mide |
|---|---|---|
| Nominal (escaños) | Conteo de escaños | Representación formal |
| Shapley-Shubik | Distribución de escaños | Poder de negociación (basado en PD) |
| Banzhaf | Distribución de escaños | Poder de negociación (basado en coaliciones) |
| Empírico | Votos reales | Relevancia en la práctica |
Las divergencias entre poder nominal y empírico revelan partidos formalmente pequeños pero estratégicamente críticos (o viceversa).
:::tip Un partido con 5% de los escaños pero un índice de poder empírico de 20% es un “hacedor de reyes”: sus votos son desproporcionadamente decisivos. Este patrón aparece cuando una coalición dominante necesita frecuentemente a un partido pequeño para cruzar el umbral de mayoría. :::
Infraestructura de Ejecución
Siete runners de análisis (run_*.py) proveen acceso por CLI a análisis individuales. Comparten infraestructura común de runner_utils.py:
| Runner | Módulo de Análisis | Qué ejecuta |
|---|---|---|
run_analysis.py | Todos | Pipeline de análisis completo |
run_nominate.py | nominate.py | Estimación de puntos ideales W-NOMINATE |
run_covotacion_dinamica.py | covotacion_dinamica.py | Co-votación por ventanas temporales |
run_evolucion_partidos.py | evolucion_partidos.py | Evolución partidista entre legislaturas |
run_efecto_genero.py | efecto_genero.py | Efecto de género en el comportamiento de votación |
run_efecto_curul_tipo.py | efecto_curul_tipo.py | Efecto del tipo de curul en la votación |
run_trayectorias.py | trayectorias.py | Trayectorias individuales de legisladores |
Todos los runners soportan los flags --camara (diputados/senado) y --output-dir vía runner_utils.build_simple_parser(). El helper run_for_cameras() permite ejecutar un análisis para una o ambas cámaras.
Logging
Todos los runners usan runner_utils.setup_logging() para formato de log consistente:
2026-04-15 10:30:00 - INFO - analysis.poder_empirico - Starting analysis...
Dinámica Temporal
Todos los métodos soportan análisis temporal a través de legislaturas.
Análisis por Legislatura
Cada legislatura recibe su propio análisis independiente:
- Espacios ideales W-NOMINATE separados
- Grafos de co-votación y detección de comunidades independientes
- Índices de poder específicos por cámara que reflejan cambios en escaños
Comparación entre Legislaturas
nominate_cross_legislaturaubica a todos los legisladores en un espacio ideal compartido, permitiendo comparación directa entre periodos- Seguimiento de evolución de comunidades: qué legisladores cambian de comunidad entre legislaturas, y qué implica eso sobre realineamiento de coaliciones
Tendencias de Modularidad
El seguimiento del puntaje de modularidad de Louvain a través de legislaturas revela cambios en la cohesión de los bloques de votación:
- Modularidad creciente: los partidos votan de forma más cohesiva, divisiones partidistas más marcadas
- Modularidad decreciente: aumenta la votación cruzada, los bloques se disuelven o realinean
- Caídas súbitas: pueden indicar un evento legislativo importante (votación de reforma, cambio de liderazgo) que interrumpió los patrones normales de votación