Frailejones de los Páramos: mis observaciones en iNaturalist

Subtribu Espeletiinae (Asteraceae)

Python
Venezuela
Colombia
Asteraceae
Autor/a

José R. Ferrer-Paris

Fecha de publicación

11 de febrero de 2026

Fecha de última modificación

17 de febrero de 2026

Frailejones de los Páramos: mis observaciones en iNaturalist

Siento una conección especial con los frailejones (Espeletiinae), un grupo de plantas endémicas de los Páramos de Venezuela, Colombia y Ecuador. Durante mis estudios de biología en Venezuela tuve la oportunidad de trabajar en el páramo y observar de cerca las adaptaciones de estas plantas para sobrevivir en el clima frío y húmedo de la alta montaña tropical. En iNaturalist tengo varias observaciones de frailejones, y en este post quiero compartir un resumen de las especies observadas y los lugares en las que las he encontrado.

Introducción

A continuación, enumero los pasos que seguí para crear un resumen de mis observaciones de frailejones en iNaturalist, incluyendo la descarga de datos, la combinación de información taxonómica y geográfica, y la visualización de los resultados.

  1. Descargar información de observaciones e identificaciones de iNaturalist para una consulta específica.
  2. Descargar datos espaciales que representan regiones de interés y
  3. Intersectar las observaciones con estas fronteras.
  4. Agrupar y resumir la información basada en datos taxonómicos y geográficos.

Herramientas y Bibliotecas

Voy a utilizar un entorno de Python con una selección de mis bibliotecas favoritas, como se explica aquí.

from pyinaturalist import get_observations
import pandas as pd
import numpy as np
import geopandas as gpd
import folium
from datetime import datetime
from IPython.display import display, HTML

Declaramos una función útil para leer los datos temporales de la respuesta del API de iNat:

def as_date(x):
    if type(x) == str:
        y = datetime.strptime(x, "%Y-%m-%d").date()
    else:
        y = datetime.date(x)
    return(y)

Guía paso a paso

Paso 1: Descargar observaciones de iNaturalist

Empezamos por descargar las observaciones de iNaturalist utilizando la biblioteca pyinaturalist. Para obtener una selección de observaciones globales, seleccionamos el usuario neomapas y vemos dónde en el mundo han sido realizadas.

username = 'neomapas'
taxonid=794906
observations = get_observations(user_id=username, taxon_id=taxonid, per_page=0)
n_obs = observations['total_results']

Imprimimos un mensaje para mostrar el número total de observaciones que tenemos para este taxón:

print("User {} has {} observations of Espeletiinae (taxon id {}) in iNaturalist".format(username,n_obs,taxonid))
User neomapas has 18 observations of Espeletiinae (taxon id 794906) in iNaturalist

Me faltan incorporar mis observaciones de Colombia…

En este caso, el número total de observaciones es relativamente pequeño, pero coloco un ejemplo de código con paginación para mostrar cómo se puede descargar un número mayor de observaciones.

records=list()
idrecords=list()
msg="Requesting observations from user _{}_: page {}, total of {} observations downloaded"
j=1
while len(records) < n_obs:
    print(msg.format(username,j,min(j*200,n_obs)))
    observations = get_observations(
        user_id='neomapas',
        taxon_id=taxonid,
        per_page=1000,
        page=j)
    for obs in observations['results']:
        record = {
            'uuid': obs['uuid'],
            'quality': obs['quality_grade'],
            'description': obs['description'],
            'location': obs['place_guess'],
            'longitude': obs['location'][1],
            'latitude': obs['location'][0],
            'species guess': obs['species_guess'],
            'observed on': as_date(obs['observed_on']),
            'points': obs['faves_count'] * 10 + obs['comments_count'] + obs['identifications_count'] * 3,
        }
        for id in obs['identifications']:
            ca = id['category']
            fch = id['created_at']
            idrecord = {
                'uuid': obs['uuid'], 
                'quality_grade': obs['quality_grade'], 
                'id_category': ca, 
                'created': fch}
            for anc in id['taxon']['ancestors']:
                idrecord[anc['rank']] = anc['name']
            idrecord[id['taxon']['rank']] = id['taxon']['name']
            idrecords.append(idrecord)
        if len(obs['observation_photos'])>0:
            record['url'] = obs['observation_photos'][0]['photo']['url']
            record['attribution'] = obs['observation_photos'][0]['photo']['attribution']
        records.append(record)
    j=j+1
Requesting observations from user _neomapas_: page 1, total of 18 observations downloaded

Este ejemplo requiere extraer información adicional que está anidada dentro de la estructura json de la respuesta de la API. Explico algunos de los detalles en este post.

Transformamos estas listas de registros en dos marcos de datos con pandas:

inat_obs=pd.DataFrame(records)
inat_ids=pd.DataFrame(idrecords)

Paso 2: Combinar información de observaciones e identificaciones

Un paso complicado es transformar la información de las sugerencias de identificación en información taxonómica completa. Aquí estoy utilizando la información de identificación incluida en la respuesta de la función get_observation para reconstruir la información taxonómica. El problema es que hay múltiples sugerencias de identificación por observación, y tenemos que filtrar primero las identificaciones no validadas.

En este caso, la mitad de mis observaciones de Espeletiinae son de calidad de investigación:

inat_obs.groupby(['quality']).agg({"uuid": pd.Series.nunique})

uuid
quality
needs_id 9
research 9

En estos casos, las observaciones de calidad de investigación siempre tienen sugerencias de identificación de tipo improving o supporting:

inat_ids.groupby(['quality_grade','id_category']).agg({"uuid": pd.Series.nunique})

uuid
quality_grade id_category
needs_id improving 5
leading 7
supporting 2
research improving 9
maverick 1
supporting 9

Este es un truco que se puede usar para seleccionar la información taxonómica de la mejor identificación de cada observación, y luego combinar esta información con el marco de datos de las observaciones para consolidar los datos.

ss=inat_ids.id_category.isin(['improving','supporting'])
cols=['uuid','subfamily','tribe','subtribe','genus']
best_ids = inat_ids.loc[ss,cols].drop_duplicates().dropna()

inat_obs_ids = inat_obs.join(best_ids.set_index('uuid'), on='uuid')

En este punto tenemos un marco de datos con la información de las observaciones, incluyendo la información taxonómica de familia y subfamilia, que es lo que necesitamos para hacer los resúmenes por familia y país.

Me gusta tener la información de las observaciones con una figura que muestre la imagen de la observación, el nombre común y el nombre científico. Así que vamos a crear una nueva columna con esta información formateada como un string de html.

inat_obs_ids['figure'] = [
    "<figure class='mini'><a href='https://www.inaturalist.org/observations/%s' target=_blank><img src='%s' height=50><figcaption class='mini'>%s: <i>%s</i></figcaption></a></figure>" % (
        record['uuid'],
        record['url'],
        record['subtribe'],
        record['species guess'])
    for idx,record in inat_obs_ids.iterrows() 
]

Paso 3: Número de observaciones únicas y especies por familia

Ahora que tenemos toda la información combinada en un solo marco de datos, podemos usar las funciones de agrupamiento y agregación de pandas para obtener resúmenes por familia y subfamilia.

aggfuns = {
    "uuid": pd.Series.nunique,
    "species guess": pd.Series.nunique
    }
inat_obs_ids.groupby(['subtribe','genus']).agg(aggfuns)

uuid species guess
subtribe genus
Espeletiinae Espeletia 14 9
gs = gpd.points_from_xy(inat_obs_ids.longitude, inat_obs_ids.latitude, crs="EPSG:4326")
inat_obs_xy=gpd.GeoDataFrame(inat_obs_ids, geometry=gs)

Paso 4: Mapa de las observaciones

aggfuns = {
    'observed on': ["min", "max"],
    'species guess': ['count',pd.Series.nunique],
           }
inat_obs_ids.groupby('location').agg(aggfuns).sort_values(('observed on','min'))

observed on species guess
min max count nunique
location
Campo Elías, Mérida, Venezuela 2004-09-11 2004-09-12 2 2
Uribante, 5058, Táchira, Venezuela 2005-07-24 2005-07-24 4 4
Rangel, Mérida, VE 2006-07-13 2006-07-13 1 1
Sucre, Mérida, VE 2006-07-14 2006-07-14 3 2
Urdaneta, Trujillo, VE 2009-02-01 2009-02-01 1 1
Rangel, Mérida, Venezuela 2009-05-16 2009-05-16 1 1
Venezuela 2015-10-28 2015-10-28 2 2
Jauregui, Táchira, Venezuela 2015-12-19 2015-12-19 1 1
Jauregui, 5022, Táchira, Venezuela 2016-06-22 2016-06-22 1 1
Villapinzón, Cundinamarca, Colombia 2024-10-05 2024-10-05 1 1
map = folium.Map(tiles="Esri NatGeoWorldMap")
fg = folium.FeatureGroup(name="iNaturalist observations", control=True, attribution="observers @ iNaturalist").add_to(map)
popup_text = """<img src='{url}'>
<caption><i>{species}</i> observed on {observed_on} / {attribution}</caption> {desc}
   """
for obs in observations['results']:
    if obs['quality_grade'] == 'research':
        if obs['description'] is None:
            desc = ""
        else:
            desc = obs['description']
        pincolor = 'green'
    else:
        desc = "Observation is not research quality grade."
        pincolor = 'gray'
    fg.add_child(
        folium.Marker(
            location=obs['location'],
            popup=popup_text.format(
               species=obs['species_guess'],
                observed_on=obs['observed_on'],
                desc=desc,
               url = obs['observation_photos'][0]['photo']['url'],
               attribution = obs['observation_photos'][0]['photo']['attribution']),
            icon=folium.Icon(color=pincolor),
        )
      )
folium.LayerControl().add_to(map)

<folium.map.LayerControl object at 0x12cde0b60>
map.fit_bounds(map.get_bounds())
map

Make this Notebook Trusted to load map: File -> Trust Notebook

Paso 5: Mostrar una muestra de observaciones

Ahora vamos a mostrar las mejores fotos de cada especie identificada.

En estas líneas de código …

Uso las funciones display y HTML para leer las cadenas de texto formateadas como elementos html1 para organizar las figuras y leyendas en esta página web.

selection = (
    inat_obs_ids
    .sort_values('points')
    .groupby(['species guess'])
    .agg({'figure':'unique'})
)


sections = list()
for idx,row in selection.iterrows():
    sectionfigures="&nbsp;".join(row['figure'])
    sectionname="<figure class='mini'><p class='figsection'>%s </p></figure>" % idx
    sections.append(sectionname + sectionfigures)

allsections="<div class='container'>%s</div>" % ("".join(sections))

display(HTML(allsections))

Conclusión

En resumen, este proceso me permitió obtener un resumen de mis observaciones de frailejones en iNaturalist, incluyendo el número de observaciones por especie y su distribución geográfica. Además, pude mostrar una selección de las mejores fotos de cada especie identificada, lo que hace que esta información sea más atractiva y fácil de entender para los lectores.

Notas

  1. El formato de salida de los elementos html depende de las definiciones de estilo css del sitio. Mira este archivo si quieres reutilizar o adaptar mi estilo.↩︎