436 millones de años. Solo conocemos el 30% de sus rasgos. ¿Y si eso bastara para reescribir la historia de los peces?

436 millones de años. Solo conocemos el 30% de sus rasgos. ¿Y si eso bastara para reescribir la historia de los peces?#

Paper: Zhu et al. (2025) — Eosteus, the oldest osteichthyan. Nature. DOI: 10.1038/s41586-026-10125-2

Abrir en Colab

Video: Ver en YouTube

El contexto#

Los osteíctios (peces óseos) dominan la biodiversidad vertebrada actual — incluyen desde el salmón hasta nosotros. Pero su registro fósil antes del Devónico era escaso y fragmentario.

Un equipo encontró un pez diminuto, casi completo, en la Lagerstätte de Chongqing (China): Eosteus, de ~436 millones de años. Es el osteíctio articulado más antiguo jamás encontrado.

Para ubicarlo en el árbol de la vida, construyeron una matriz filogenética: una tabla de 163 especies × 709 caracteres morfológicos, donde cada rasgo se codifica como presente (0/1/2) o desconocido (?). Esa matriz es pública — y es lo que vamos a explorar.

# ══════════════════════════════════════════════════════════════
# Configuración — modifica estos valores para explorar
# ══════════════════════════════════════════════════════════════
EOSTEUS_COMPLETITUD = 30.5    # % de caracteres codificados
TOTAL_TAXA = 163
TOTAL_CHARS = 709
FUENTE = 'Fuente: Zhu et al. (2025), Nature | Datos: Figshare (SI1)'
COLOR_EOSTEUS = '#DC2626'     # Rojo — destaca al protagonista
COLOR_DATOS = '#2563EB'       # Azul CaM
COLOR_SECUNDARIO = '#059669'  # Emerald
COLOR_REFERENCIA = '#D97706'  # Amber
COLOR_VIOLETA = '#7C3AED'
COLOR_GRIS = '#BBBBBB'

# ── Setup ──
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os, urllib.request

style_file = '../../cam.mplstyle'
if not os.path.exists(style_file):
    style_file = '/tmp/cam.mplstyle'
    if not os.path.exists(style_file):
        urllib.request.urlretrieve('https://raw.githubusercontent.com/Ciencia-a-Mordiscos/lab/main/cam.mplstyle', style_file)
plt.style.use(style_file)

# ── Cargar datos ──
df_comp = pd.read_csv('datos/completitud_taxa.csv')
df_sim = pd.read_csv('datos/similitud_eosteus.csv')

# Colores por grupo
COLORES_GRUPO = {
    'Actinopterigio': '#2563EB',
    'Sarcopterigio': '#059669',
    'Stem Osteictio': '#DC2626',
    'Condrictio': '#7C3AED',
    'Acantodio': '#D97706',
    'Placodermo': '#BBBBBB',
    'Agnato': '#F59E0B',
    'Otro': '#D1D5DB',
}

print(f"Matriz filogenética: {len(df_comp)} taxa × {TOTAL_CHARS} caracteres")
eosteus = df_comp[df_comp['taxon'] == 'Eosteus'].iloc[0]
print(f"Eosteus: {eosteus['n_coded']}/{eosteus['n_total']} caracteres codificados ({eosteus['pct_complete']}%)")
print(f"Rank: {(df_comp['pct_complete'] >= eosteus['pct_complete']).sum()}/{len(df_comp)}")
Matriz filogenética: 163 taxa × 709 caracteres
Eosteus: 216/709 caracteres codificados (30.5%)
Rank: 112/163

¿Cuánto sabemos de cada especie?#

Veamos.

fig, ax = plt.subplots(figsize=(13, 5.5))

# Ordenar por completitud
df_sorted = df_comp.sort_values('pct_complete', ascending=True).reset_index(drop=True)

# Colores: Eosteus en rojo, resto por grupo
colors = []
for _, row in df_sorted.iterrows():
    if row['taxon'] == 'Eosteus':
        colors.append(COLOR_EOSTEUS)
    else:
        colors.append(COLORES_GRUPO.get(row['grupo'], '#D1D5DB'))

ax.barh(range(len(df_sorted)), df_sorted['pct_complete'], color=colors, alpha=0.8, height=0.9)

# Marcar Eosteus
eosteus_idx = df_sorted[df_sorted['taxon'] == 'Eosteus'].index[0]
ax.annotate('Eosteus\n30,5%', xy=(df_sorted.iloc[eosteus_idx]['pct_complete'], eosteus_idx),
            xytext=(55, eosteus_idx + 8), fontsize=11, fontweight='bold', color=COLOR_EOSTEUS,
            arrowprops=dict(arrowstyle='->', color=COLOR_EOSTEUS, lw=1.5))

# Marcar el más completo
top_idx = df_sorted['pct_complete'].idxmax()
ax.annotate('Mimipiscis\n94,5%', xy=(df_sorted.iloc[top_idx]['pct_complete'], top_idx),
            xytext=(75, top_idx - 12), fontsize=9, color='#2563EB',
            arrowprops=dict(arrowstyle='->', color='#2563EB', lw=1.2))

# Línea de media
ax.axvline(x=40.0, color='#666666', linewidth=1, linestyle='--', alpha=0.6)
ax.text(41, len(df_sorted) * 0.95, 'Media: 40,0%', fontsize=9, color='#666666')

ax.set_xlabel('Caracteres codificados (%)', fontsize=11)
ax.set_title('¿Cuánto conocemos de 163 vertebrados fósiles?',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.5, 1.02, 'Cada barra = una especie. Rojo = Eosteus, el osteíctio más antiguo',
        transform=ax.transAxes, fontsize=10, color='#666666', ha='center')
ax.set_yticks([])
ax.set_xlim(0, 100)

fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/completitud_hero.png', dpi=200, bbox_inches='tight')
plt.show()
../../_images/ff4c52906214841cd5d65aa340d7dca586467c5ebc4c500774d12002d227f66d.png

Eosteus apenas supera el 30% de caracteres codificados — normal para un fósil del Silúrico, donde la preservación es excepcional solo en contadas localidades. La media de completitud de las 163 especies es 40% (mediana 39,5%, IQR: 26–52%).

Lo llamativo: incluso con ese 30%, el análisis bayesiano logra ubicar a Eosteus en el árbol evolutivo — aunque el consenso estricto lo deja sin resolver. ¿Cómo? Porque no todos los caracteres pesan igual en un análisis filogenético — y los que sí se preservaron incluyen rasgos clave para definir parentescos.

¿A quién se parece Eosteus?#

De los 216 caracteres que conocemos de Eosteus, podemos comparar cuántos coinciden con cada otra especie.

# Similitud media por grupo (excluyendo taxa con <20 caracteres comparables)
df_sim_filt = df_sim[df_sim['caracteres_comparables'] >= 20].copy()
group_sim = df_sim_filt.groupby('grupo').agg(
    similitud_media=('similitud_pct', 'mean'),
    n_taxa=('similitud_pct', 'count')
).sort_values('similitud_media', ascending=True).reset_index()

fig, ax = plt.subplots(figsize=(10, 5))

colors_bar = [COLORES_GRUPO.get(g, '#D1D5DB') for g in group_sim['grupo']]
bars = ax.barh(range(len(group_sim)), group_sim['similitud_media'],
               color=colors_bar, alpha=0.85, height=0.7, edgecolor='white', linewidth=0.5)

# Inline labels
for i, (_, row) in enumerate(group_sim.iterrows()):
    ax.text(row['similitud_media'] + 0.8, i,
            f"{row['similitud_media']:.1f}% (n={int(row['n_taxa'])})",
            va='center', fontsize=9, fontweight='bold',
            color=COLORES_GRUPO.get(row['grupo'], '#666666'))

ax.set_yticks(range(len(group_sim)))
ax.set_yticklabels(group_sim['grupo'], fontsize=10)
ax.set_xlabel('Coincidencia con Eosteus (%)', fontsize=11)
ax.set_title('¿A qué grupo se parece más Eosteus?',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.5, 1.02, 'Porcentaje de caracteres compartidos que coinciden (filtro: ≥20 caracteres comparables)',
        transform=ax.transAxes, fontsize=10, color='#666666', ha='center')
ax.set_xlim(50, 100)

fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/similitud_grupo.png', dpi=200, bbox_inches='tight')
plt.show()
../../_images/76de44dad79f4b1309ba28ccb4f0b92ab4545e739afef44cf95e232cef8c3233.png

Los actinopterigios (peces con aletas de radios — desde el pez payaso hasta el atún) son el grupo con mayor coincidencia: 90,6%. Los osteíctios basales (stem osteichthyans), el grupo donde el paper ubica a Eosteus, le siguen con 84,0%.

Esto encaja con lo que describe el paper: Eosteus tiene rasgos como una sola aleta dorsal y fulcros caudales, que hoy son típicos de actinopterigios. Pero también conserva espinas de aleta que antes solo se conocían en condrictios basales (tiburones primitivos) y placodermos.

Veamos las especies individuales más parecidas.

# Top 25 más similares con ≥20 caracteres comparables
top25 = df_sim_filt.nlargest(25, 'similitud_pct')

fig, ax = plt.subplots(figsize=(13, 6))

colors_scatter = [COLORES_GRUPO.get(g, '#D1D5DB') for g in top25['grupo']]
scatter = ax.scatter(top25['caracteres_comparables'], top25['similitud_pct'],
                     c=colors_scatter, s=top25['caracteres_comparables'] * 1.5,
                     alpha=0.8, edgecolors='white', linewidths=0.8, zorder=5)

# Etiquetar los top 8
for _, row in top25.head(8).iterrows():
    nombre = row['taxon'].replace('_', ' ')
    if len(nombre) > 25:
        nombre = nombre[:22] + '...'
    ax.annotate(nombre, xy=(row['caracteres_comparables'], row['similitud_pct']),
                xytext=(5, 5), textcoords='offset points',
                fontsize=7.5, color=COLORES_GRUPO.get(row['grupo'], '#666666'),
                fontweight='bold')

ax.set_xlabel('Caracteres comparables con Eosteus', fontsize=11)
ax.set_ylabel('Coincidencia (%)', fontsize=11)
ax.set_title('Las 25 especies más parecidas a Eosteus',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.5, 1.02, 'Tamaño del punto = número de caracteres comparables',
        transform=ax.transAxes, fontsize=10, color='#666666', ha='center')

# Leyenda manual
from matplotlib.lines import Line2D
legend_elements = [Line2D([0], [0], marker='o', color='w', markerfacecolor=c,
                          label=g, markersize=8)
                   for g, c in COLORES_GRUPO.items() if g in top25['grupo'].values]
ax.legend(handles=legend_elements, fontsize=9, loc='lower right', framealpha=0.9)

fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/similitud_top25.png', dpi=200, bbox_inches='tight')
plt.show()
../../_images/1e6e670023b3f55c24c66a09a74533603c9ccb909f63400d9d1dc10faeaa8f2e.png

¿Qué tan incompleto es realmente?#

Eosteus tiene el 30,5% de sus caracteres codificados. Suena poco. Pero ¿cómo se compara con el resto de la matriz?

fig, ax = plt.subplots(figsize=(10, 5))

n_hist, bins, patches = ax.hist(df_comp['pct_complete'], bins=20,
                                 color=COLOR_DATOS, alpha=0.4,
                                 edgecolor=COLOR_DATOS, linewidth=0.8)

y_max = n_hist.max() * 1.15
ax.set_ylim(0, y_max)

# Media
media = df_comp['pct_complete'].mean()
ax.axvline(x=media, color=COLOR_DATOS, linewidth=1.5, alpha=0.8)
ax.text(media + 1, y_max * 0.92, f'Media\n{media:.1f}%', fontsize=9,
        color=COLOR_DATOS, fontweight='bold')

# Eosteus
ax.axvline(x=EOSTEUS_COMPLETITUD, color=COLOR_EOSTEUS, linewidth=2.5)
ax.text(EOSTEUS_COMPLETITUD - 1, y_max * 0.75, f'Eosteus\n{EOSTEUS_COMPLETITUD}%',
        fontsize=11, fontweight='bold', color=COLOR_EOSTEUS, ha='right')

# Flecha diferencia
ax.annotate('', xy=(media, y_max * 0.6), xytext=(EOSTEUS_COMPLETITUD, y_max * 0.6),
            arrowprops=dict(arrowstyle='<->', color='#666666', lw=1.5))
ax.text((media + EOSTEUS_COMPLETITUD) / 2, y_max * 0.63,
        f'{media - EOSTEUS_COMPLETITUD:.1f} pp\nmenos', fontsize=9,
        color='#666666', ha='center')

# Percentil
n_below = (df_comp['pct_complete'] < EOSTEUS_COMPLETITUD).sum()
n_total_comp = len(df_comp)
pct_below = n_below / n_total_comp * 100
ax.text(0.98, 0.95, f'{n_below} de {n_total_comp} especies tienen\nmenos datos que Eosteus ({pct_below:.0f}%)',
        transform=ax.transAxes, fontsize=9, color='#666666', ha='right', va='top')

ax.set_xlabel('Caracteres codificados (%)', fontsize=11)
ax.set_ylabel('Número de especies', fontsize=11)
ax.set_title('Distribución de completitud en la matriz filogenética',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.5, 1.02, f'{n_total_comp} especies de vertebrados fósiles',
        transform=ax.transAxes, fontsize=10, color='#666666', ha='center')

fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/histograma_completitud.png', dpi=200, bbox_inches='tight')
plt.show()
../../_images/71d8e274649bef0e79f2552c6a91ab77082d588588de8853b47d4aebc224de33.png

Lo que los datos soportan#

Afirmación

¿Soportada?

Detalle

Eosteus tiene rasgos típicos de actinopterigios

Coincidencia media del 90,6% con actinopterigios, la más alta de todos los grupos (n=8 actinopterigios con ≥20 caracteres comparables)

La posición filogenética de Eosteus está resuelta

⚠️

El análisis bayesiano lo ubica en el stem osteichthyan, pero el consenso estricto lo deja sin resolver. Los datos de similitud son coherentes con ambas posibilidades

Los datos sugieren una radiación más extensa de peces óseos en el Silúrico

⚠️

El paper lo enmarca como sugerencia (implies…than suggested). La matriz muestra que Eosteus es distinto a otros osteíctios basales, lo que es consistente con diversidad temprana, pero no lo demuestra por sí solo

Limitaciones: (1) La coincidencia de caracteres NO es lo mismo que una filogenia — un análisis filogenético formal usa modelos de evolución de caracteres, no similitud bruta. (2) Los taxa con pocos caracteres comparables (<20) generan coincidencias artificialmente altas o bajas. (3) La matriz es una herramienta construida por los autores — la selección de caracteres y la codificación contienen decisiones subjetivas.


Ahora tú#

  1. ¿Qué pasa si subes el filtro de caracteres comparables? Prueba cambiando >= 20 por >= 50 en la celda de similitud por grupo. ¿Cambia el ranking?

  2. ¿Cuál es la especie más parecida a Eosteus con >100 caracteres en común? Busca en df_sim_filt[df_sim_filt['caracteres_comparables'] > 100].nlargest(5, 'similitud_pct').

  3. ¿Los placodermos son realmente un grupo homogéneo? Con 60 especies, son el grupo más grande. ¿Cuánta variación hay en su similitud con Eosteus?

# --- EXPERIMENTA AQUÍ ---
# ¿Cuánta variación hay DENTRO de cada grupo?
# Esto revela si un grupo es homogéneo o tiene especies muy diversas

fig, ax = plt.subplots(figsize=(12, 5))

groups_ordered = df_sim_filt.groupby('grupo')['similitud_pct'].median().sort_values().index
np.random.seed(42)

positions = list(range(len(groups_ordered)))
for i, grupo in enumerate(groups_ordered):
    vals = df_sim_filt[df_sim_filt['grupo'] == grupo]['similitud_pct'].values
    n = len(vals)
    color = COLORES_GRUPO.get(grupo, '#D1D5DB')
    
    x_strip = np.linspace(positions[i] - 0.2, positions[i] + 0.2, n)
    np.random.shuffle(x_strip)
    ax.scatter(x_strip, vals, color=color, s=30, alpha=0.6,
               edgecolors='white', linewidths=0.5, zorder=5)
    
    # Media ± SEM
    mean = vals.mean()
    sem = vals.std(ddof=1) / np.sqrt(n) if n > 1 else 0
    ax.errorbar(positions[i], mean, yerr=sem, fmt='_', color=color,
                markersize=20, markeredgewidth=3,
                capsize=6, capthick=1.5, zorder=6)

ax.set_xticks(positions)
ax.set_xticklabels(groups_ordered, fontsize=9, fontweight='bold', rotation=15, ha='right')
for tick, grupo in zip(ax.get_xticklabels(), groups_ordered):
    tick.set_color(COLORES_GRUPO.get(grupo, '#666666'))

ax.set_ylabel('Coincidencia con Eosteus (%)', fontsize=11)
ax.set_title('Variación dentro de cada grupo',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.5, 1.02, 'Cada punto = una especie (filtro: ≥20 caracteres comparables)',
        transform=ax.transAxes, fontsize=10, color='#666666', ha='center')
ax.text(0.98, 0.02, '━ media ± SEM', transform=ax.transAxes,
        fontsize=8, color='#999999', ha='right', va='bottom', style='italic')

fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/variacion_grupos.png', dpi=200, bbox_inches='tight')
plt.show()
../../_images/80df902fbb4e5abdce631b92ccc3ed38f515d8bc0626aa17217538b614c6c48a.png

Créditos#