Carlos Isaac
Zainea Maya

Tunjos para la IA
Programa de cursos

Razonamiento, validación e inteligencia artificial en ciencias sociales y humanidades.

Curso 3 · Oficio · ocho sesiones · híbrido

Prácticas

Guías de las prácticas y cuaderno de apoyo.

Cuaderno de apoyo para hacer las prácticas por programa

Opcional. Todo el curso se puede hacer con una hoja de cálculo, el navegador y la interfaz web de un modelo. Este cuaderno es para quien quiera que la recolección, el hash, el catálogo y las descripciones queden en archivos y se puedan repetir. Está pensado para alguien sin experiencia previa en Python, copiando bloque a bloque. Cada bloque se explica antes de mostrarse.

Preparación

Instale Python 3.11 o superior y, en una terminal:

pip install requests pillow imagehash pandas python-dotenv openai

Cree una carpeta de trabajo con esta estructura:

mi-corpus/
  paginas.csv        lista de páginas fuente (la escribe usted antes de descargar)
  img/               imágenes descargadas
  descartadas/       lo que no es del tema
  catalogo.csv       el catálogo
  descripciones.jsonl
  .env               la clave del modelo; nunca va en el código ni en el repositorio

paginas.csv tiene cuatro columnas: fuente, escena, url, periodo. Una fila por página.

Comprobar robots

Antes de descargar nada, el programa pregunta al sitio si permite visitar la ruta. La biblioteca estándar trae un lector de robots.txt. Si el archivo no existe, la función devuelve permitido; anótelo igual, porque "no dijo que no" no es "dijo que sí".

from urllib import robotparser
from urllib.parse import urlparse

AGENTE = "corpus-visual-curso3 (contacto: su-correo@ejemplo.org)"

def permitido(url):
    """True si robots.txt del sitio permite que AGENTE visite la url."""
    base = "{0.scheme}://{0.netloc}".format(urlparse(url))
    rp = robotparser.RobotFileParser()
    rp.set_url(base + "/robots.txt")
    try:
        rp.read()
    except Exception:
        return True  # sin robots.txt: permitido, y se anota
    return rp.can_fetch(AGENTE, url)

Descargar una página

Explicación. El bloque pide la página, busca todas las etiquetas de imagen, resuelve la URL de cada una, la descarga con una pausa entre peticiones, lee las dimensiones con Pillow y escribe una fila por imagen con la URL de la imagen, la URL de la página, el texto alternativo y las medidas. Se identifica con el agente y espera dos segundos entre imágenes. No filtra: el filtrado es un paso aparte y registrado.

import csv, os, re, time
import requests
from io import BytesIO
from urllib.parse import urljoin
from PIL import Image

def descargar_pagina(url, fuente, escena, periodo, carpeta="img", catalogo="catalogo.csv", pausa=2.0):
    if not permitido(url):
        print("robots.txt no permite:", url)
        return
    os.makedirs(carpeta, exist_ok=True)
    html = requests.get(url, headers={"User-Agent": AGENTE}, timeout=30).text
    etiquetas = re.findall(r'<img[^>]+>', html, flags=re.I)
    nuevo = not os.path.exists(catalogo)
    with open(catalogo, "a", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        if nuevo:
            w.writerow(["id", "periodo", "fuente", "pagina_fuente", "url_imagen", "ancho", "alto",
                        "extension", "descripcion_heredada", "origen_descripcion", "derechos",
                        "escena", "ruta_local", "grupo_duplicados", "canonica", "sensible"])
        n = sum(1 for _ in open(catalogo, encoding="utf-8")) - 1
        for et in etiquetas:
            src = re.search(r'src="([^"]+)"', et)
            if not src:
                continue
            url_img = urljoin(url, src.group(1))
            alt = re.search(r'alt="([^"]*)"', et)
            alt = alt.group(1) if alt else ""
            try:
                r = requests.get(url_img, headers={"User-Agent": AGENTE}, timeout=30)
                im = Image.open(BytesIO(r.content))
            except Exception as e:
                print("no se pudo:", url_img, e)
                continue
            n += 1
            ext = (im.format or "jpg").lower()
            ruta = os.path.join(carpeta, f"{n:04d}.{ext}")
            with open(ruta, "wb") as g:
                g.write(r.content)
            w.writerow([n, periodo, fuente, url, url_img, im.width, im.height, "." + ext,
                        alt, "alt" if alt else "ninguna", "", escena, ruta, "", "", ""])
            time.sleep(pausa)

Para recorrer la lista:

import pandas as pd
paginas = pd.read_csv("paginas.csv")
for _, p in paginas.iterrows():
    descargar_pagina(p["url"], p["fuente"], p["escena"], p["periodo"])

Filtrar lo que no es del tema

Explicación. El filtrado se hace mirando, pero el programa ayuda a encontrar candidatos: dimensiones muy pequeñas, dimensiones repetidas muchas veces en la misma página, nombres de archivo con "logo", "icon" o "banner". El bloque solo lista candidatos; usted decide y mueve.

cat = pd.read_csv("catalogo.csv")
cat["dims"] = cat["ancho"].astype(str) + "x" + cat["alto"].astype(str)
repetidas = cat.groupby(["pagina_fuente", "dims"]).size()
candidatas = cat[(cat["ancho"] < 150) | (cat["alto"] < 150) |
                 cat["url_imagen"].str.contains("logo|icon|banner|sprite", case=False, na=False)]
print(repetidas[repetidas > 5])
print(candidatas[["id", "url_imagen", "dims"]])

Hash perceptual

Explicación. imagehash.dhash reduce cada imagen a 9 por 8 píxeles en gris y anota si cada píxel es más claro que el siguiente de su fila: 64 bits. La distancia entre dos hashes es el número de bits distintos. El bloque calcula el hash de cada imagen del catálogo, compara todos los pares y lista los que quedan bajo el umbral. Mire con cuidado los pares entre 8 y 14: ahí se decide.

import imagehash
from itertools import combinations

UMBRAL = 10
cat = pd.read_csv("catalogo.csv")
hashes = {}
for _, fila in cat.iterrows():
    try:
        hashes[fila["id"]] = imagehash.dhash(Image.open(fila["ruta_local"]))
    except Exception as e:
        print("sin hash:", fila["id"], e)

pares = []
for a, b in combinations(hashes, 2):
    d = hashes[a] - hashes[b]
    if d <= UMBRAL + 4:
        pares.append((a, b, d, "bajo umbral" if d <= UMBRAL else "en el límite"))
pares.sort(key=lambda t: t[2])
for p in pares:
    print(p)

Para asignar grupos, una pasada simple: cada par bajo el umbral une a los dos ids en el mismo grupo.

grupo = {}
siguiente = 1
for a, b, d, _ in pares:
    if d > UMBRAL:
        continue
    ga, gb = grupo.get(a), grupo.get(b)
    if ga and gb:
        for k, v in grupo.items():
            if v == gb:
                grupo[k] = ga
    elif ga:
        grupo[b] = ga
    elif gb:
        grupo[a] = gb
    else:
        grupo[a] = grupo[b] = f"G{siguiente}"
        siguiente += 1
cat["grupo_duplicados"] = cat["id"].map(grupo).fillna("")
cat.to_csv("catalogo.csv", index=False)

El ejemplar canónico lo elige usted en la columna canonica, con la razón en la plantilla.

Validar

Explicación. Dos comprobaciones: cada fila tiene archivo, cada archivo tiene fila. El bloque imprime las discrepancias y no corrige nada solo.

import glob
cat = pd.read_csv("catalogo.csv")
en_disco = set(glob.glob("img/*"))
en_catalogo = set(cat["ruta_local"].astype(str))
print("filas sin archivo:", sorted(en_catalogo - en_disco))
print("archivos sin fila:", sorted(en_disco - en_catalogo))
print("filas:", len(cat), "archivos:", len(en_disco))

Bloque opcional para describir con un modelo multimodal

Explicación. El bloque envía una imagen y el prompt por capas a un modelo compatible con la interfaz de openai, y guarda prompt, salida y metadatos en descripciones.jsonl. La clave está en .env y se lee con python-dotenv; nunca se escribe en el código ni se sube a un repositorio. Antes de correrlo sobre una imagen, compruebe en el catálogo que no está marcada como sensible. Varios proveedores ofrecen cuota gratuita; cambie BASE_URL y MODEL según el suyo.

Contenido de .env:

API_KEY=su_clave
BASE_URL=https://api.openai.com/v1
MODEL=nombre_del_modelo_con_vision
import os, json, base64, datetime
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
client = OpenAI(api_key=os.getenv("API_KEY"), base_url=os.getenv("BASE_URL"))
MODEL = os.getenv("MODEL")

PROMPT_CAPAS = open("prompt_capas.txt", encoding="utf-8").read()  # el de la práctica 2

def describir(id_imagen, ruta, prompt=PROMPT_CAPAS, registro="descripciones.jsonl"):
    """Envía la imagen y el prompt; guarda todo con fecha. Devuelve el texto."""
    with open(ruta, "rb") as f:
        b64 = base64.b64encode(f.read()).decode()
    ext = ruta.rsplit(".", 1)[-1].lower().replace("jpg", "jpeg")
    r = client.chat.completions.create(
        model=MODEL,
        temperature=0.2,
        messages=[{"role": "user", "content": [
            {"type": "text", "text": prompt},
            {"type": "image_url", "image_url": {"url": f"data:image/{ext};base64,{b64}"}},
        ]}],
    )
    texto = r.choices[0].message.content
    uso = getattr(r, "usage", None)
    with open(registro, "a", encoding="utf-8") as f:
        f.write(json.dumps({
            "id": id_imagen, "ruta": ruta, "fecha": datetime.datetime.now().isoformat(),
            "modelo": MODEL, "prompt": prompt, "respuesta": texto,
            "tokens": getattr(uso, "total_tokens", None),
        }, ensure_ascii=False) + "\n")
    return texto

Para la muestra de 20:

cat = pd.read_csv("catalogo.csv")
muestra = cat[(cat["sensible"] != "sí")].head(20)
for _, fila in muestra.iterrows():
    print(fila["id"]); print(describir(fila["id"], fila["ruta_local"])); print("=====")

Separar las capas de la salida

Explicación. Si el modelo respetó los títulos del prompt, el bloque corta la salida en cuatro columnas para la tabla de la semana 6.

import re
TITULOS = [("denotacion", "DENOTACIÓN"), ("indices", "ÍNDICES MATERIALES"),
           ("suposiciones", "SUPOSICIONES"), ("no_sabe", "LO QUE NO PUEDES SABER")]

def capas(texto):
    """Corta la salida en cuatro capas por sus títulos; vacío si falta un título."""
    out = {}
    for i, (clave, titulo) in enumerate(TITULOS):
        inicio = texto.find(titulo)
        if inicio < 0:
            out[clave] = ""
            continue
        fin = len(texto)
        for _, t2 in TITULOS[i + 1:]:
            j = texto.find(t2, inicio + len(titulo))
            if j >= 0:
                fin = min(fin, j)
        out[clave] = texto[inicio + len(titulo):fin].strip(" .:\n")
    return out

desc = pd.read_json("descripciones.jsonl", lines=True)
tabla = pd.DataFrame([{"id": d["id"], **capas(d["respuesta"])} for _, d in desc.iterrows()])
tabla.to_csv("descripciones_por_capas.csv", index=False)

Si el modelo no respetó los títulos, la separación falla y se anota: es un dato sobre el modelo.

Registro de frontera por programa

Mantenga un archivo con sus decisiones, una por afirmación, código o grupo de exhibición:

def decidir(tipo, id_imagen, propuesta, decision, criterio, responde, archivo="frontera.jsonl"):
    with open(archivo, "a", encoding="utf-8") as f:
        f.write(json.dumps({"tipo": tipo, "id": id_imagen, "propuso_el_modelo": propuesta,
                            "decidi": decision, "criterio": criterio, "responde": responde},
                           ensure_ascii=False) + "\n")

decidir("descripcion", 17, "cuatro personas de pie frente a un muro",
        "corregir: son tres; la cuarta es una sombra",
        "miré la imagen a tamaño completo", "yo")

Al final, frontera.jsonl se convierte en las tablas de la plantilla:

pd.read_json("frontera.jsonl", lines=True).to_csv("registro-de-frontera-visual.csv", index=False)

Nota sobre costos y privacidad

Use cuotas gratuitas y, hasta la semana 7, solo imágenes sin personas identificables. Si su corpus es sensible, considere un modelo local de visión: menor calidad, control total, y la decisión queda en el registro. Exterioridad y gobernanza de la IA en ciencias sociales trata estos problemas en detalle: derechos sobre el material, datos personales e imágenes sensibles, consentimiento en plataformas y la opción de describir en lugar de exhibir; concluye que esas decisiones se escriben en un protocolo de gobernanza que dice quién responde por cada una.


Práctica 1. Catálogo reproducible con deduplicación perceptual

Duración estimada: 4 horas, de las cuales 1 en la sesión sincrónica de la semana 2. Requisitos: una lista de páginas fuente del proyecto propio, una carpeta de trabajo y la plantilla plantillas/catalogo-visual.md. Para la vía por programa, Python 3.11 o superior con requests, Pillow, imagehash y pandas (ver cuaderno-apoyo.md). Para la vía manual, una hoja de cálculo y la mini app de deduplicación. No requiere programar: cada paso tiene una vía manual.

Objetivo

Construir el primer catálogo de 20 imágenes del corpus propio con las diez columnas de procedencia, detectar duplicados por hash perceptual con un umbral declarado, y validar la integridad entre catálogo y disco en ambas direcciones.

Antes de la sesión

  1. Escriba la pregunta con la que reúne el corpus. Una frase. Va en la cabecera de la plantilla y se escribe antes de descargar, porque después ya no es la misma.
  2. Haga la lista de páginas fuente: al menos cinco páginas de al menos dos escenas distintas (museo o archivo, prensa, plataforma). Una fila por página en la primera tabla de la plantilla.
  3. Para cada sitio, abra https://<sitio>/robots.txt en el navegador y anote si la ruta que va a visitar está permitida. Si el archivo no existe, anótelo. Si está prohibida, no la use y anote por qué.
  4. Si va por programa, instale las bibliotecas y pruebe que el bloque "comprobar robots" del cuaderno corre.

Cuatro pasos durante la sesión

Paso 1. Descargar con registro

Vía manual. Para cada imagen que quiera incorporar, guarde el archivo con un nombre numerado (0001.jpg, 0002.jpg) y anote en la hoja de cálculo la URL de la imagen (clic derecho, copiar dirección de la imagen), la URL de la página, la fecha y el texto alternativo o pie si lo hay. Las dimensiones se obtienen de las propiedades del archivo.

Vía por programa. Use el bloque "descargar una página" del cuaderno. Descarga las imágenes de una página respetando robots.txt, con pausa entre peticiones y un agente identificado, y escribe una fila por imagen con URL, página, dimensiones y texto alternativo.

En las dos vías, no filtre todavía: descargue todo lo que la página ofrezca como imagen. El filtrado es el paso siguiente y debe quedar registrado.

Paso 2. Filtrar lo que no es del tema

Mire las imágenes descargadas y marque las que no pertenecen al corpus: logotipos, banderas de navegación, íconos, miniaturas de otras secciones. En el caso demostrativo, una fracción visible de lo descargado era de ese tipo, con dimensiones repetidas (236 por 350 píxeles) que las delataban antes de abrirlas. Anote el criterio de descarte en la plantilla. No borre los archivos: muévalos a una carpeta descartadas/.

Paso 3. Deduplicar por hash perceptual

Vía por programa. El bloque "hash perceptual" del cuaderno calcula el dHash de cada imagen y la distancia de Hamming entre todos los pares. Fije el umbral en 10 para empezar y mire los pares con distancia entre 8 y 14: ahí está la decisión.

Vía manual. Con la mini app apps/dedup-perceptual.html vea cómo se calcula el hash y qué hace el umbral. Luego, en su carpeta, busque a ojo los pares sospechosos (misma escena, distinto recorte o brillo) y anótelos como grupo. Es menos preciso y se declara así.

Para cada grupo, elija el ejemplar canónico y diga por qué: mayor resolución, procedencia más clara, sin recorte. Los demás se conservan con el campo canonica = no.

Paso 4. Validar la integridad

Confronte el catálogo con la carpeta en ambas direcciones. Vía por programa: bloque "validar". Vía manual: cuente los archivos de la carpeta, cuente las filas, y compare nombre por nombre. Anote cada discrepancia y qué hizo con ella. Dé la cifra del corpus con su historia: cuántas bajó, cuántas descartó, cuántos grupos, cuántas quedan.

Después de la sesión

  1. Amplíe el catálogo a 40 imágenes como mínimo siguiendo los mismos cuatro pasos. Es la entrega de la semana 3.
  2. Consulte la página fuente de al menos la mitad de las imágenes para llenar la columna de derechos. "Desconocido" solo vale después de mirar.
  3. Marque en la columna sensible todas las imágenes con personas identificables, menores o muerte. Es la entrada del protocolo de la semana 7.
  4. Escriba la declaración: qué hizo un programa, qué hizo un asistente de código si lo usó, qué hizo usted, qué no pudo descargar.

Qué se evalúa

La trazabilidad, no el tamaño. Un catálogo de 40 imágenes en que cada fila tiene fuente, página, derechos consultados y grupo de duplicados vale más que uno de 400 con columnas vacías. La cifra del corpus debe poder defenderse con la validación.

Variante para grupos

Dos estudiantes descargan desde la misma lista de páginas con procedimientos distintos (uno manual, uno por programa) y comparan catálogos. Las filas que uno tiene y el otro no son el mejor material de discusión sobre reproducibilidad.


Práctica 2. Descripción por capas con un modelo multimodal y auditoría de una muestra

Duración estimada: 5 horas, repartidas entre las sesiones de las semanas 4 y 5 y el trabajo autónomo. Requisitos: 20 imágenes del catálogo que se puedan enviar a un modelo (públicas, de dominio público o sin personas identificables); acceso a un modelo multimodal de uso gratuito en su versión web, o el cuaderno de apoyo con la clave en .env; la plantilla plantillas/ficha-semiotica.md, sección "auditoría". No requiere programar.

Objetivo

Obtener descripciones de 20 imágenes con dos prompts, uno plano y uno por capas; guardar cada salida con sus metadatos; auditar una muestra de 15 descripciones por capas con la tabla de tres columnas; y escribir el resumen del sesgo del modelo sobre el corpus propio.

Antes de la sesión de la semana 4

  1. Elija las 20 imágenes. Compruebe en el catálogo que ninguna está marcada como sensible. Si su corpus entero es sensible, use para esta práctica una muestra de imágenes públicas del mismo tema o de un tema vecino, y dígalo.
  2. Para tres de las 20, escriba su lectura propia: diez afirmaciones etiquetadas como en el taller de la semana 3. Guárdela sin mostrarla al modelo.
  3. Abra una cuenta en un servicio con modelo multimodal gratuito, o configure el .env del cuaderno. Pruebe con una imagen.

Describir en la semana 4

Prompt plano

Describe esta imagen en detalle.

Prompt por capas

Describe esta imagen en cuatro partes separadas, con estos títulos exactos y en este orden.

1. DENOTACIÓN. Solo lo que se ve: personas (cuántas, dónde en el cuadro, postura, vestimenta), objetos, lugar, disposición, texto visible transcrito literal. Sin interpretar, sin adjetivos de valor, sin nombrar instituciones ni lugares que no estén escritos en la imagen.

2. ÍNDICES MATERIALES. Tipo de imagen (fotografía, grabado, captura de pantalla, ilustración), señales de época o de proceso (grano, color, desenfoque, compresión), marcas de agua, bordes, recortes, interfaz visible.

3. SUPOSICIONES. Lo que supones y no ves: época probable, lugar probable, género de la imagen, función, qué está pasando. Para cada suposición, di en qué te basas y tu grado de confianza (alta, media, baja).

4. LO QUE NO PUEDES SABER. Lista lo que un lector local o el contexto de la fuente sabrían y tú no puedes inferir de la imagen.

Responde en español.

Adapte el prompt a su corpus en dos frases como máximo (por ejemplo, pida transcribir texto en una lengua específica). No añada en el prompt información sobre la imagen: el modelo debe describir sin pistas.

Registro de cada salida

Por imagen y por prompt, guarde: id de la imagen, modelo y versión, fecha y hora, prompt literal, salida literal, y lo que pueda del costo (tokens, llamadas, tiempo). Vía manual: una fila por salida en una hoja de cálculo, con la salida pegada. Vía por programa: el bloque "describir" del cuaderno lo hace y escribe descripciones.jsonl.

Comparar los dos prompts

Para las tres imágenes con lectura propia, llene la tabla de la semana 4: afirmaciones en la capa de denotación, cuántas habría escrito usted igual, suposiciones declaradas, cuántas rechazaría, qué declaró el modelo no saber. Anote en la bitácora qué cambió entre prompts: no la cantidad de detalle sino su clasificación.

Presupuesto

Con el costo de las 40 salidas, extrapole al corpus completo: dinero, tiempo de ejecución, tiempo de lectura. Decida y escriba qué parte del corpus describirá con el presupuesto que tiene.

Auditar en la semana 5

Diseñar la muestra

Quince descripciones por capas, estratificadas por escena o periodo (cinco por estrato si hay tres estratos), incluyendo a propósito las imágenes que usted considera más locales. Escriba el criterio antes de auditar.

El orden obligatorio

Para cada imagen de la muestra: primero mire la imagen y escriba tres frases propias; después lea la descripción del modelo. Nunca al revés. La auditoría hecha leyendo primero es una aprobación.

La tabla de tres columnas

Una fila por afirmación del modelo. Las cuatro columnas de destino:

  • No está: el modelo lo afirma y la imagen no lo muestra.
  • No describe: la imagen lo muestra y el modelo no lo dice (se añade como fila con la afirmación que falta).
  • Contexto local: está en la imagen, el modelo lo describe en parte o mal, y solo se juzga sabiendo del lugar, el periodo o la comunidad. Diga qué contexto haría falta.
  • Aceptada: está y el modelo lo dice bien.
Imagen Afirmación del modelo Capa en que la puso Columna Qué sé yo que el modelo no Cómo lo sé

Cifras y resumen

Cuente las afirmaciones por columna sobre el total auditado. Declare que son cifras sobre la muestra, no tasas del corpus. Escriba el resumen del sesgo en un párrafo: qué tipo de cosas afirma el modelo sin base, qué tipo de cosas calla, qué tipo de cosas describe sin entender, con un ejemplo de cada una tomado de su corpus. Ese párrafo acompaña a todas las descripciones del corpus desde ahora.

Variante con dos modelos o dos lenguas

Si tiene acceso a dos modelos, o repite con el prompt en inglés, audite cinco imágenes con ambos y compare la tercera columna. Ananthram et al. (2025) encuentran que la lengua del prompt cambia el sesgo sin eliminarlo; compruebe si pasa en su corpus.

Después de la sesión de la semana 5

  1. Describa con el prompt por capas la parte del corpus que el presupuesto permitió, por programa o a mano, y consolide todo en una tabla con una fila por imagen y una columna por capa.
  2. Adjunte a la tabla el resumen del sesgo y las cifras de la muestra.
  3. Marque en el registro de frontera visual las afirmaciones que aceptó sin poder decir cómo lo sabía.

Qué se evalúa

La precisión de la auditoría, no la calidad de la descripción. Una descripción mediocre auditada afirmación por afirmación vale más que una brillante aprobada en bloque. La tercera columna se evalúa por si nombra el contexto que falta, no por su tamaño.