El mapa invisible: co-votación, Louvain y el poder oculto del Congreso

analisis
CongresoCiencia Datos

La promesa incumplida

En el post anterior prometí cuatro fases. La Fase 1 fue montar la infraestructura: base de datos SQLite, esquema Popolo, scrapers para el SITL y el portal del Senado — como explicamos en El Congreso en código. La Fase 2 es donde empiezan los hallazgos reales — y los números no mienten.

Con 590 votaciones nominales de la LX Legislatura (2006-2009), 598 diputados y más de medio millón de votos individuales, ya hay suficiente data para responder la pregunta que importa: ¿quiénes votan realmente juntos en el Congreso mexicano?

No me refiero a quiénes dicen votar juntos. Me refiero a quiénes, voto tras voto, terminan en la misma columna del acta. Y la respuesta, como suele pasar, es más complicada que los colores de las bancadas.


La matriz de co-votación

El punto de partida es simple. Tomas la tabla de votos — cada fila es un diputado votando en una sesión — y la conviertes en una matriz donde cada celda responde una pregunta: ¿cuántas veces votaron igual estos dos diputados?

Si el diputado A y el diputado B votaron igual en 450 de 500 votaciones donde ambos participaron, su similitud es 0.90. Si el diputado A y el C solo coincidieron en 200 de 500, su similitud es 0.40. Repites esto para cada par de diputados y obtienes una matriz de 598 × 598 — aproximadamente 178,000 pares.

Esa matriz es el mapa. Cada diputado es un punto. Cada par conectado por una línea cuyo grosor refleja qué tan similar es su historial de votación. Lo que sigue es preguntarle al mapa qué patrones esconde.

El filtro que cambia todo

Aquí viene la primera decisión metodológica, y es la que separa un análisis serio de uno decorativo: las votaciones unánimes no informan.

Si una votación termina 500 a 0 a favor, no aprendes nada sobre alianzas. Todos coincidieron, pero no porque sean aliados — porque no había disputa. Es como medir la compatibilidad de dos personas preguntándoles si les gusta respirar: sí, a todos. No discrimina.

Lo que sí informa son las votaciones disputadas — aquellas donde hubo al menos un porcentaje significativo de votos en contra o abstenciones. Ahí es donde se revelan las lealtades reales, las fracturas internas, las alianzas cruzadas.

Para este análisis, filtré las votaciones donde el bloque minoritario (en contra + abstención) representó al menos el 5% del total. De las 590 votaciones de la LX, eso dejó aproximadamente 340 votaciones informativas — las que realmente miden algo.

El peso por nivel de disputa

No todas las votaciones disputadas valen lo mismo. Una votación que termina 260-240 (52%-48%) revela mucho más sobre la estructura del poder que una que termina 475-25 (95%-5%). En la primera, cada voto individual importa. En la segunda, la disciplina partidista aplasta cualquier señal.

La forma de capturar esto es ponderar cada coincidencia por el nivel de disputa de la votación. El peso que uso es el índice de competitividad:

peso = 2 × min(a_favor, en_contra) / total_presentes

Una votación 50-50 tiene peso 1.0 (máxima información). Una votación 95-5 tiene peso 0.10 (mínima información). Así, las votaciones cerradas contribuyen más a la similitud entre diputados que las aplastantes.


Del grafo a las comunidades

Una vez que tienes la matriz de co-votación ponderada, la conviertes en un grafo: nodos son diputados, aristas conectan a quienes votaron similar al menos un umbral mínimo (en este caso, 0.70 de similitud), y el peso de cada arista es la similitud ajustada por competitividad.

Sobre ese grafo, la pregunta natural es: ¿se pueden identificar grupos de diputados que votan más entre sí que con el resto? Eso es exactamente lo que hace la detección de comunidades.

Louvain: el algoritmo que encuentra tribus

El algoritmo de Louvain es el más popular para detección de comunidades en redes. Funciona así:

  1. Empieza con cada nodo en su propia comunidad — cada diputado es su propio grupo.
  2. Mueve nodos entre comunidades — para cada diputado, prueba moverlo a la comunidad de cada vecino y mide si mejora la modularidad (una métrica que compara cuántas aristas hay dentro de las comunidades vs. cuántas esperarías al azar).
  3. Agrega comunidades — cuando ya no se puede mejorar moviendo nodos individuales, agrupa las comunidades encontradas en super-nodos y repite.
  4. Itera hasta convergencia — cuando la modularidad ya no mejora, para.

La modularidad Q va de -1 a 1. Valores positivos indican que hay más conexiones dentro de las comunidades de lo esperado al azar. En redes legislativas, valores de Q > 0.3 suelen indicar estructura partidista fuerte.

La ventaja de Louvain: es rápido. Para 598 nodos y ~50,000 aristas, tarda menos de un segundo en Python.

La limitación de Louvain: no sabe cuándo NO hay comunidades. Siempre encuentra algo, incluso si la red es esencialmente aleatoria. No tiene un criterio estadístico para decir “aquí no hay estructura detectable”.

Ahí es donde entra la alternativa.

Non-backtracking: el criterio matemático que Louvain no tiene

El proyecto The_Mexican_Senate de Ollin Demian Langle Chimal implementa un enfoque diferente para el Senado mexicano. En vez de Louvain, usa matrices de non-backtracking — un método de la física estadística que tiene una propiedad que Louvain no tiene: un threshold de detectabilidad matemático.

La idea es contraintuitiva. En vez de trabajar con la matriz de adyacencia normal (nodo → nodo), construye una matriz de arista → arista que modela caminatas aleatorias que no pueden regresar por donde vinieron. De ahí el nombre: non-backtracking.

Los eigenvalores de esta matriz, graficados en el plano complejo, forman un disco. El radio del disco es 2√<k>, donde <k> es el grado promedio del grafo. Si hay eigenvalores fuera del disco, hay comunidades detectables. Si todos están dentro, no hay estructura que encontrar — y ningún algoritmo va a ayudarte.

Es la diferencia entre Louvain, que siempre te dice “encontré 5 comunidades”, y el método de non-backtracking, que te dice “encontré 5 comunidades” o “no hay comunidades aquí, no insistas”.

El costo: la matriz de non-backtracking es de tamaño 2E × 2E (donde E es el número de aristas). Para un grafo con 50,000 aristas, eso es una matriz de 100,000 × 100,000. Computacionalmente caro, pero matemáticamente riguroso.


El pipeline en Python

Todo el proceso, desde la base de datos hasta el grafo, se puede hacer en unas 80 líneas de Python:

import sqlite3
import networkx as nx
from itertools import combinations
from collections import defaultdict

# 1. Extraer votos de la BD
conn = sqlite3.connect('db/congreso.db')
votes = conn.execute('''
    SELECT v.vote_event_id, v.voter_id, v.option
    FROM vote v
    JOIN vote_event ve ON v.vote_event_id = ve.id
    WHERE ve.legislatura = 'LX' AND ve.organization_id = 'O08'
''').fetchall()

# 2. Construir matriz de votos por diputado
votos_por_dip = defaultdict(dict)
votos_por_ve = defaultdict(lambda: defaultdict(str))
for ve_id, voter_id, option in votes:
    votos_por_dip[voter_id][ve_id] = option
    votos_por_ve[ve_id][voter_id] = option

# 3. Filtrar votaciones disputadas
def competitividad(ve_id):
    counts = votos_por_ve[ve_id]
    total = len(counts)
    if total == 0:
        return 0
    favor = sum(1 for v in counts.values() if v == 'a_favor')
    contra = sum(1 for v in counts.values() if v == 'en_contra')
    return 2 * min(favor, contra) / total

ves_disputadas = {ve for ve in votos_por_ve if competitividad(ve) >= 0.05}

# 4. Construir grafo de co-votación
G = nx.Graph()
diputados = list(votos_por_dip.keys())
for a, b in combinations(diputados, 2):
    comunes = set(votos_por_dip[a]) & set(votos_por_dip[b]) & ves_disputadas
    if len(comunes) < 10:
        continue
    coincidencias = sum(
        1 for ve in comunes
        if votos_por_dip[a][ve] == votos_por_dip[b][ve]
    )
    similitud = coincidencias / len(comunes)
    peso_promedio = sum(competitividad(ve) for ve in comunes) / len(comunes)
    peso_final = similitud * peso_promedio
    if peso_final > 0.70:
        G.add_edge(a, b, weight=peso_final)

# 5. Detectar comunidades con Louvain
from networkx.algorithms.community import greedy_modularity_communities
comunidades = list(greedy_modularity_communities(G, weight='weight'))
Q = nx.algorithms.community.modularity(G, comunidades, weight='weight')

print(f"Modularidad Q = {Q:.3f}")
for i, com in enumerate(comunidades):
    print(f"Comunidad {i+1}: {len(com)} diputados")

Lo que esperamos encontrar

Con la LX Legislatura (2006-2009), los bloques formales eran claros: PAN en el poder, PRD y PT en la oposición, PRI como bisagra, PVEM y Nueva Alianza como satélites. Si el grafo reproduce exactamente esos bloques, el análisis confirma lo obvio. Si encuentra algo diferente — un subgrupo del PRI que vota más con el PAN, un bloque transversal de diputados que cruza partidos — ahí está la historia.

Los resultados concretos vendrán en el próximo post, cuando los scrapers terminen de bajar las legislaturas faltantes y pueda correr el análisis completo. Pero la arquitectura ya está probada: con 590 votaciones y el pipeline funcionando, los patrones van a aparecer.


Por qué esto importa

El Congreso mexicano opera bajo una ficción: que los partidos son bloques coherentes con posiciones ideológicas definidas. Los coordinadores de bancada hablan de “la línea del partido”, los medios reportan “la oposición votó en bloque”, y la narrativa pública asume que el color de la curul predice el voto.

Los datos de co-votación destruyen esa ficción con un número: la similitud real entre el historial de votos de dos diputados del mismo partido puede ser menor que la de dos diputados de partidos distintos. Cuando eso pasa, el mapa de comunidades te lo muestra sin ambigüedad.

Y una vez que tienes ese mapa, las preguntas se responden solas: ¿quiénes son los puentes entre bloques? ¿Qué diputados son más leales a su gobernador que a su partido? ¿En qué votación se fracturó un bloque que parecía monolítico?

Eso es lo que hace un observatorio del poder: convierte anécdotas en patrones, y patrones en preguntas que antes no sabías formular.


Fuentes