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óduloEntradaSalidaAlgoritmo Central
W-NOMINATEMatriz de votos (legisladores x eventos)Coordenadas de puntos idealesSVD + Newton-Raphson
Co-VotaciónRegistros de votaciónMatriz NxN de similitud + grafoConteo de acuerdos
ComunidadesGrafo de co-votaciónPartición (nodo -> comunidad)Louvain (nx.community)
CentralidadGrafo de co-votaciónPuntuaciones por nodoGrado ponderado, intermediación
Índices de PoderEscaños por partidoValores Shapley-Shubik, BanzhafProgramación dinámica O(n²W)
Poder EmpíricoRegistros de votación + escañosFrecuencias de partidos críticosAná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ámetroTipoDefaultPropósito
MIN_EVENTS_PER_WINDOWint30Mínimo de eventos de votación por ventana temporal (ventanas por debajo se fusionan)
LOUVAIN_SEEDint42Semilla aleatoria para reproducibilidad de Louvain
LOUVAIN_RESOLUTIONfloat1.0Resolución de Louvain (> 1.0 = comunidades más pequeñas)
CLOSE_VOTES_THRESHOLDint10Margen máximo para clasificar una votación como “cerrada”
REFORMA_JUDICIAL_VE_IDSlist[str][“VE04”, “VE05”]IDs de eventos de votación para análisis de Reforma Judicial
TOP_DISSENTERS_GLOBALint10Número de principales disidentes a retornar (global)
TOP_DISSIDENTS_PER_WINDOWint5Número de principales disidentes por ventana temporal
DEALIGNMENT_THRESHOLDfloat-0.05Cambio 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ónParámetrosRetorna
get_vote_events()legislatura, organization_id, resultFilas de vote_event filtradas
get_votes()vote_event_id, voter_id, join_vote_eventFilas de votos con legislatura/cámara opcional
get_persons()person_idFilas de personas
get_organizations()clasificacionFilas de organizaciones
get_memberships()person_id, org_id, rolFilas 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.

ConstanteTipoPropósito
PARTY_COLORSdict[str, str]Colores Matplotlib por partido (clave = nombre corto en mayúsculas)
DEFAULT_COLORstrColor por defecto (#CCCCCC)
ORG_TO_SHORTdict[str, str]Mapeo org_id → nombre corto (O01 → MORENA)
PARTY_ORDERlist[str]Ordenamiento canónico de partidos para visualizaciones
COMMON_PARTIESlist[str]Partidos presentes en ambas cámaras
CAMARA_MAPdict[str, str]Nombre de cámara → código (diputados → D)
COLORES_WEBdict[str, str]Colores compatibles con ECharts para exportación web
PARTIDO_MAPdict[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 votoValor binario
a_favor1 (Yea)
en_contra0 (Nay)
abstencionNaN (excluido)
ausenteNaN (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étricaDescripciónInterpretación
Tasa de clasificación% de votos correctamente predichos por el modeloMayor = mejor ajuste; rango típico 85-95%
APREAggregate Proportional Reduction in ErrorMejora 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 periodo
  • nominate_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

  1. Carga de datos: votos, personas y organizaciones desde SQLite
  2. Normalización de partidos: normalize_party() mapea valores mixtos de vote.group a IDs canónicos de organización
  3. Asignación de partido primario: get_primary_party() asigna cada legislador a su partido más frecuente
  4. 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
  5. 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:

ClaveTipoDescripción
matrixarreglo numpy NxNSimilitud de co-votación por pares
graphnetworkx.GraphRed de co-votación ponderada
party_mapdictmapeo person_id -> partido
org_mapdictmapeo org_id -> nombre del partido
persons_dfDataFrameMetadatos 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:

  1. Movimiento local: cada nodo se mueve a la comunidad vecina que produce la mayor ganancia de modularidad
  2. 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ónEfecto
< 1.0Menos comunidades, más grandes (más grueso)
1.0 (default)Modularidad estándar
> 1.0Má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étricaManejo de pesosCaptura
Grado PonderadoUsa pesosIntensidad general de co-votación
IntermediaciónIgnora 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:

  1. Recopilar todas las membresías de partido para cada legislador
  2. Asignar al partido donde el legislador emitió más votos
  3. Empates se resuelven con la membresía con start_date má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:

ÍndiceBaseQué Mide
Nominal (escaños)Conteo de escañosRepresentación formal
Shapley-ShubikDistribución de escañosPoder de negociación (basado en PD)
BanzhafDistribución de escañosPoder de negociación (basado en coaliciones)
EmpíricoVotos realesRelevancia 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:

RunnerMódulo de AnálisisQué ejecuta
run_analysis.pyTodosPipeline de análisis completo
run_nominate.pynominate.pyEstimación de puntos ideales W-NOMINATE
run_covotacion_dinamica.pycovotacion_dinamica.pyCo-votación por ventanas temporales
run_evolucion_partidos.pyevolucion_partidos.pyEvolución partidista entre legislaturas
run_efecto_genero.pyefecto_genero.pyEfecto de género en el comportamiento de votación
run_efecto_curul_tipo.pyefecto_curul_tipo.pyEfecto del tipo de curul en la votación
run_trayectorias.pytrayectorias.pyTrayectorias 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_legislatura ubica 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