Probabilidad de ganar la Lotería Nacional con Python (Monte Carlo)
Calcula la probabilidad real de ganar la Lotería Nacional con Python: fórmula acumulada, Monte Carlo y por qué el histórico no predice.

Probabilidad de ganar la Lotería Nacional: análisis con Python, histórico y Monte Carlo
En mi familia durante los últimos años ha aparecido la extraña costumbre de ir al bingo una vez al año, concretamente el Día de Reyes. A mí, personalmente, los juegos de azar no me llaman nada la atención, así que esta tradición ha provocado conversaciones interesantes estas navidades. Y de ahí salió la pregunta inevitable:
- ¿Cómo de posible es realmente que te toque la lotería?
- Si todos sabemos que las probabilidades son ínfimas, ¿se puede optimizar algo el proceso?
Antes de seguir, una advertencia importante: juega siempre con cabeza. La estadística no está de nuestro lado. Aun así, este caso es perfecto para aprender (bien) estadística aplicada y desmontar varias creencias populares.
En este artículo voy a analizar prácticas típicas cuando alguien juega a la Lotería Nacional (número de 5 cifras):
- ¿Tiene sentido mirar el histórico para elegir números?
- Si juego durante años, ¿qué probabilidad real tengo de acertar el premio grande?
- Si no puedo aumentar la probabilidad de acertar, ¿puedo evitar compartir premio?
Nota de alcance: aquí hablo de Lotería Nacional (00000–99999). Esto no es Primitiva/Bonoloto/Euromillones, que tienen reglas y probabilidades distintas.
Qué significa “que te toque”
Antes de tocar código, hay que definir el objetivo con precisión. Según lo que entiendas por “ganar”, el análisis cambia:
- Acertar el número exacto (premio mayor asociado a ese número)
- Ganar algo (cualquier premio)
- Ganar más de lo invertido (rentabilidad)
En este post me centro en el caso más común cuando se habla de “el premio gordo”: acertar el número exacto.
Obtención de datos (y por qué trabajo en local)
Lo primero es lo primero: ¿de dónde sacamos la información?
En la web oficial hay resultados, pero no encontré una exportación masiva cómoda para análisis (al menos a simple vista). Por eso usé como fuente un dataset accesible vía web de la administración de loterías Eduardo Losilla, que expone un histórico en JSON.
- Fuente:
https://api.eduardolosilla.es/botes/actuales?uts
Importante para reproducibilidad: descargo el JSON como snapshot y trabajo en local (por ejemplo en data/loteria.json). Así el análisis no depende de que la API esté disponible cuando lo ejecute otra persona.
Con eso obtengo un JSON con los sorteos y varios campos ya calculados. Un ejemplo:
{
"numero": 6703,
"fecha_sorteo": 1767654000,
"nombre": "Extraordinario del Niño",
"enlace_pdf_premios": null,
"temporada": 2026,
"numero_texto": "06703",
"suma": 16,
"pares": 3,
"impares": 2,
"bajos": 3,
"altos": 2,
"diferentes": 4,
"reduccion1d": 7
}Aquí aparece el primer “detalle tonto” que rompe análisis: los números pueden empezar por 0. No es lo mismo 06703 que 6703 si tratas el valor como entero. Lo solucionamos normalizando a 5 dígitos.
Además, el JSON incluye una leyenda útil para entender los campos calculados (suma, pares/impares, bajos/altos, etc.) y tipos_sorteo si quieres filtrar por tipo.
Carga en Pandas y normalización del número
import json
import pandas as pd
path = "data/loteria.json" # ajusta el nombre/ruta a tu snapshot
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
df = pd.json_normalize(raw["sorteos"])
# Fecha: epoch seconds -> datetime con zona horaria
df["fecha"] = (
pd.to_datetime(df["fecha_sorteo"], unit="s", utc=True)
.dt.tz_convert("Europe/Madrid")
)
# Número ganador a 5 dígitos (ceros a la izquierda)
df["numero_5"] = df["numero_texto"].astype(str).str.zfill(5)
# Orden cronológico: útil para rachas y análisis temporal
df = df.sort_values("fecha").reset_index(drop=True)
df.head()Validaciones mínimas (poco vistosas, muy necesarias)
Antes de sacar conclusiones, compruebo lo básico: nulos, duplicados, longitudes y rangos.
print("Filas, columnas:", df.shape)
null_rate = df.isna().mean().sort_values(ascending=False)
print(null_rate.head(12))
print(
"Duplicados:",
df.duplicated(subset=["fecha_sorteo", "numero_5", "nombre"]).sum()
)
print(df["numero_5"].str.len().value_counts())
print(df[["suma","pares","impares","bajos","altos","diferentes"]].describe())Alternativa habitual: “elimino filas con nulos y ya está”.
- ¿Por qué no lo hago por defecto? Porque a veces lo que falta es una columna auxiliar, y borrar filas puede introducir sesgo. Prefiero entender primero qué falta y por qué.
Probabilidad teórica: el punto de partida (y lo que el histórico no cambia)
Si el juego es un número de 5 cifras entre 00000 y 99999, hay 100.000 combinaciones posibles.
Si hablamos de acertar un número exacto (premio mayor asociado a ese número), la probabilidad por sorteo es:
- p = 1 / 100000
Aquí viene la pregunta útil:
“Si juego muchas veces, ¿cuánto sube mi probabilidad?”
Jugar muchas veces: probabilidad acumulada sin autoengaños
La probabilidad de acertar al menos una vez tras n sorteos independientes no se obtiene sumando. Se calcula como:
- P(al menos una) = 1 − (1 − p)ⁿ
p = 1/100_000
def prob_al_menos_una(n, p=p):
return 1 - (1 - p)**n
for n in [10, 100, 1_000, 10_000]:
print(n, prob_al_menos_una(n))Interpretación honesta:
- Jugar más siempre aumenta tu probabilidad acumulada.
- Pero cuando p es 1/100.000, aumenta desesperadamente lento.
Lo que el histórico sí aporta: auditar el “azar” con pruebas sencillas
Aquí cambiamos el objetivo. En vez de intentar adivinar, usamos el histórico para comprobar si los resultados son compatibles con un proceso aleatorio razonable.
Último dígito: una auditoría rápida
En un histórico largo, el último dígito debería aparecer más o menos uniforme entre 0 y 9.
import numpy as np
df["last_digit"] = df["numero_5"].str[-1].astype(int)
counts = df["last_digit"].value_counts().sort_index()
freq = counts / counts.sum()
print(freq.round(4))Chi-cuadrado de uniformidad (sin jerga)
Qué mide: si la distribución observada se desvía “demasiado” de una uniforme.
from scipy.stats import chisquare
observed = counts.values
expected = np.full_like(observed, observed.sum()/10, dtype=float)
chi2, pvalue = chisquare(observed, f_exp=expected)
print("chi2=", chi2, "pvalue=", pvalue)Cómo leerlo de forma honesta:
- Un p-value alto no demuestra que sea aleatorio; solo indica que no ves evidencia fuerte de desviación.
- Un p-value bajo puede ser un sesgo real… o un artefacto del dataset (faltan sorteos, mezcla de tipos, cambios históricos).
Consejo: repite el test por
nombre(tipo de sorteo) y por periodos para reducir falsos positivos.
Cuando “parece no aleatorio”: patrones que engañan a la intuición
Tu dataset trae features perfectas para enseñar una idea clave: muchos resultados aleatorios no “parecen” aleatorios.
Suma de dígitos
import matplotlib.pyplot as plt
df["suma"].plot(kind="hist", bins=range(0, 46), title="Distribución de la suma de dígitos")
plt.xlabel("Suma")
plt.show()- Mucha gente evita
12345porque “se ve demasiado obvio”. - Pero si el evento es “acertar este número exacto”,
12345y80417son igual de probables.
Pares e impares
par_freq = df["pares"].value_counts().sort_index() / len(df)
par_freq.plot(kind="bar", title="Cantidad de dígitos pares en el ganador")
plt.show()Dígitos repetidos (cuántos dígitos únicos)
uniq_freq = df["diferentes"].value_counts().sort_index() / len(df)
uniq_freq.plot(kind="bar", title="Dígitos únicos en el número ganador")
plt.show()11111 es raro como forma, pero no es “menos aleatorio”. Solo choca con nuestra expectativa de variedad.
“Números calientes” y rachas: por qué aparecen sin magia
Dos sesgos típicos:
- “Hace mucho que no sale X, ya toca” (falacia del jugador).
- “Sale mucho, seguirá saliendo” (mano caliente).
Podemos mirar rachas del último dígito para ver por qué el cerebro se engancha a estas historias.
last = df["last_digit"].to_numpy()
runs = []
current_digit = last[0]
length = 1
for x in last[1:]:
if x == current_digit:
length += 1
else:
runs.append((current_digit, length))
current_digit = x
length = 1
runs.append((current_digit, length))
run_lengths = pd.Series([l for _, l in runs])
print(run_lengths.value_counts().sort_index().head(12))En secuencias largas, ver rachas llamativas de vez en cuando es normal. La rareza subjetiva no implica sesgo.
Simulación Monte Carlo: otra forma de entender el “tiempo hasta ganar”
La simulación no predice el próximo número. Su valor es pedagógico: ayuda a visualizar variabilidad y tiempos típicos.
import numpy as np
p = 1/100_000
N = 200_000
# Sorteos hasta el primer acierto
times = np.random.geometric(p, size=N)
print("Mediana sorteos:", np.median(times))
print("Media sorteos:", np.mean(times))
print("Percentil 90:", np.percentile(times, 90))Decisión técnica: uso geometric directamente.
- Alternativa: simular sorteo a sorteo (Bernoulli repetido).
- Por qué la descarto aquí: es más lenta y no aporta intuición extra si el objetivo es “cuánto suele tardar”.
La única “optimización” real: reducir la probabilidad de compartir premio
Aquí está el matiz que casi siempre se olvida.
- No puedes aumentar la probabilidad de acertar el número exacto.
- Pero sí puedes elegir números menos típicos para reducir la probabilidad de compartir premio si algún día aciertas.
Ejemplos de números que mucha gente compra (y por tanto tienden a compartirse más):
- Fechas (
01024por 1/1/24, etc.) - Patrones (
12345,11111,00000) - Combinaciones “bonitas” o simétricas
Esto no mejora tu probabilidad de ganar, pero sí puede mejorar tu resultado condicionado a ganar.
Valor esperado (EV): la herramienta que conecta análisis con una decisión real
Si lo que quieres es decidir si “me compensa jugar”, el concepto útil es el valor esperado.
- EV = Σ(probabilidad de cada premio × importe) − coste
Limitación importante: con solo el histórico de números ganadores, no puedes calcular EV completo sin incorporar la tabla oficial de premios y sus probabilidades.
Plantilla en Python para cuando tengas la tabla:
prizes = [
{"name": "Premio mayor", "p": 1/100_000, "amount": 300_000},
{"name": "Reintegro", "p": 0.3, "amount": 20},
]
coste = 20
ev = sum(x["p"] * x["amount"] for x in prizes) - coste
print("EV por décimo:", ev)Qué puedes aprender y qué no puedes “optimizar”
- Si tu objetivo es acertar el premio mayor, la probabilidad base manda. El histórico no la cambia.
- Si tu objetivo es entender cómo crece la probabilidad con el tiempo, la probabilidad acumulada y Monte Carlo lo explican sin trampas.
- Si tu objetivo es “elegir mejor”, la conclusión honesta es:
- no puedes subir la probabilidad de acertar el número,
- pero sí puedes evitar elecciones típicas para reducir la probabilidad de compartir premio si algún día ganas.
Repositorio
Código y dataset de ejemplo en GitHub:
FAQ
¿Sirve mirar el histórico para elegir números?
No para aumentar la probabilidad de acertar el número exacto. Sí sirve para entender distribuciones y desmontar sesgos.
Si juego durante años, ¿tengo una probabilidad “real” significativa?
Sube, pero muy lentamente. La fórmula correcta es: 1 − (1 − p)^n.
¿Existen números “calientes”?
Las rachas aparecen de forma natural en procesos aleatorios; verlas no implica que el sistema esté sesgado.
¿Hay alguna optimización útil?
No para acertar más. La única optimización práctica es reducir el riesgo de compartir premio evitando números típicos (fechas/patrones).