Semana 5: Ensamblando el CLI, Ajustando la Temperatura, y Sintiendo el Muro de Velocidad
🎯 Objetivos de aprendizaje
Al final de esta semana podrás:
- Ensamblar cada pieza de las Semanas 1 a 4 en un generador de texto de línea de comandos completo, de principio a fin.
- Añadir un parámetro de temperatura que controle cuán aleatorio frente a predecible es el texto generado.
- Cronometrar tu pipeline de Python puro en un corpus más grande y explicar, a partir de la medición de primera mano, por qué esto motiva la Sección 2.
- Razonar informalmente sobre cómo escala el tiempo de ejecución de tu pipeline con el tamaño de la entrada.
Lección
Ensamblando el pipeline
Cada función de las Semanas 1 a 4 se compone en un solo pipeline — cargar, tokenizar, contar, convertir a probabilidades, generar:
import csv
import random
def load_corpus(path):
with open(path, newline="") as f:
return [row["sentence"] for row in csv.DictReader(f)]
def tokenize(sentence):
return sentence.lower().split()
def bigrams(tokens):
return [(tokens[i], tokens[i + 1]) for i in range(len(tokens) - 1)]
def build_bigram_probabilities(sentences):
counts_table = {}
for sentence in sentences:
for first, second in bigrams(tokenize(sentence)):
counts_table.setdefault(first, {})
counts_table[first][second] = counts_table[first].get(second, 0) + 1
probs_table = {}
for word, next_counts in counts_table.items():
total = sum(next_counts.values())
probs_table[word] = {w: c / total for w, c in next_counts.items()}
return probs_table
def generate_text(probs_table, start_word, max_words=10):
words, current = [start_word], start_word
for _ in range(max_words - 1):
if current not in probs_table:
break
next_words = list(probs_table[current].keys())
weights = list(probs_table[current].values())
current = random.choices(next_words, weights=weights, k=1)[0]
words.append(current)
return " ".join(words)
if __name__ == "__main__":
corpus = load_corpus("slm-corpus.csv")
probs_table = build_bigram_probabilities(corpus)
print(generate_text(probs_table, "the", max_words=8))
dict.setdefault(key, {}) en build_bigram_probabilities es un pequeño atajo para el patrón if key not in table: table[key] = {} de la Semana 3 — hace lo mismo en una línea. if __name__ == "__main__": es la forma estándar de marcar "ejecuta esto cuando el archivo se ejecuta directamente" — verás esta convención constantemente una vez que instales Python localmente en el Bono Capstone.
Temperatura: ajustando la aleatoriedad
La temperatura es un único mando que estira o aplana una distribución de probabilidad antes de muestrear de ella, sin cambiar qué resultados son posibles — solo qué tan cerca de uniforme (plana) o qué tan cerca de voraz (con picos) se comporta el muestreo:
- : sin cambios — muestreo normal de la distribución original.
- (p. ej. ): agudiza la distribución — las palabras de alta probabilidad se vuelven aún más probables, empujando la generación hacia el comportamiento voraz de la semana pasada.
- (p. ej. ): aplana la distribución — las palabras de baja probabilidad se vuelven relativamente más probables, produciendo texto más sorprendente, más caótico.
¿Por qué exponenciación, en lugar de simplemente escalar las probabilidades directamente? Porque elevar cada a una potencia afecta a las probabilidades grandes y pequeñas de forma desigual, exactamente en la dirección que quieres: para (así que ), una probabilidad como elevada a una potencia mayor que 1 se reduce, pero una probabilidad más pequeña como se reduce en una proporción mucho mayor — ampliando la brecha entre palabras probables e improbables. El paso de renormalización (dividir entre la nueva suma) es lo que convierte este conjunto reconfigurado de números de vuelta en una distribución de probabilidad válida que suma 1, la misma propiedad de "suma 1" de la Semana 2.
def apply_temperature(probs, temperature):
adjusted = {w: p ** (1 / temperature) for w, p in probs.items()}
total = sum(adjusted.values())
return {w: v / total for w, v in adjusted.items()}
def sample_next(word, probs_table, temperature=1.0):
if word not in probs_table:
return None
probs = probs_table[word]
if temperature != 1.0:
probs = apply_temperature(probs, temperature)
return random.choices(list(probs.keys()), weights=list(probs.values()), k=1)[0]
Este es exactamente el mismo concepto de "temperatura" que verás referenciado en las APIs reales de modelos de lenguaje grandes — estás implementando una versión simplificada de un mando de control real, ampliamente usado.
Cronometrándolo: sintiendo el muro de velocidad
Aquí está el ejercicio hacia el que ha estado apuntando todo este track. Cronometra tu pipeline en un corpus más grande — digamos, una versión del corpus repetida 200 veces para simular más datos (corpus * 200, usando multiplicación de listas) — usando el módulo time incorporado de Python:
import time
big_corpus = corpus * 200 # simula un conjunto de datos mucho más grande
start = time.time()
big_probs_table = build_bigram_probabilities(big_corpus)
elapsed = time.time() - start
print(f"Built bigram table for {len(big_corpus)} sentences in {elapsed:.3f} seconds")
Ejecuta esto en el playground y anota tu número real. Probablemente siga siendo rápido a este tamaño — pero observa qué pasa cuando multiplicas el tamaño del corpus de nuevo (* 1000, * 5000): el enfoque de bucles anidados y diccionario de diccionarios de la Semana 3 hace trabajo real por cada aparición de palabra, sin atajos. No hay forma de "vectorizar" un bucle for de Python puro de la forma en que pueden hacerlo las bibliotecas numéricas especializadas.
Lo que la cronometración realmente muestra
Cada oración adicional en el corpus añade una cantidad de trabajo aproximadamente constante: tokenizarla, formar sus bigramas, actualizar un puñado de entradas de diccionario. Duplicar el número de oraciones duplica aproximadamente el número de apariciones de palabras a procesar, lo que duplica aproximadamente el tiempo de ejecución — una relación lineal entre el tamaño de la entrada y el tiempo, informalmente donde es el conteo total de palabras. Eso en realidad es un buen comportamiento de escalado, no uno malo — el problema real no es que este algoritmo escale mal en teoría, es que cada paso individual (una búsqueda en un diccionario, una llamada a función, una iteración de bucle) cuesta más en Python puro e interpretado de lo que costaría el paso equivalente en el código compilado y vectorizado debajo de numpy/pandas. Números ilustrativos (los tuyos variarán según la máquina, pero la forma debería verse similar):
| Repeticiones del corpus | Oraciones aprox. | Tiempo aprox. |
|---|---|---|
| 1× | 20 | unos pocos milisegundos |
| 200× | 4,000 | todavía bien por debajo de un segundo |
| 5,000× | 100,000 | ahora claramente notable |
Esa brecha —entre lo que Python puro puede hacer cómodamente y lo que los problemas de texto/datos de tamaño real necesitan— es exactamente lo que la Sección 2 existe para cerrar. Pandas y numpy no solo hacen que el código sea más corto; hacen que operaciones como esta se ejecuten órdenes de magnitud más rápido, al empujar el bucle real hacia código optimizado y compilado en lugar del propio bucle del intérprete de Python.
⚠️ Errores comunes
- Cronometrar algo que incluye costos de configuración de una sola vez. Si tu código de cronometraje incluye accidentalmente el tiempo de lectura de archivo de
load_corpusjunto con el cómputo real debuild_bigram_probabilities, estás midiendo dos cosas distintas a la vez — cronometra solo el paso específico que te interesa. - Comparar cronometrajes entre cargas de máquina muy distintas. Ejecutar otros programas pesados al mismo tiempo puede sesgar una medición de tiempo; si un resultado se ve sorprendente, vuelve a ejecutarlo un par de veces.
- Asumir que el escalado lineal significa "sin problema a ningún tamaño". es bueno, pero un factor constante suficientemente grande por operación igual se acumula — todo el punto de esta semana es que el factor constante por operación es lo que Python puro pierde frente al código vectorizado, incluso con el mismo escalado a grandes rasgos.
🧩 Retos
Extiende el bloque if __name__ == "__main__": para que le pida al usuario (vía input()) una palabra inicial y un número máximo de palabras, en lugar de tenerlos fijos en el código.
Genera texto con temperature=0.5 y temperature=2.0 con la misma palabra inicial, varias veces cada una. Describe la diferencia cualitativa que observas.
Cronometra build_bigram_probabilities en tamaños de corpus crecientes (corpus * 1, * 50, * 200, * 1000) e imprime una pequeña tabla de tamaño frente a tiempo transcurrido. ¿El crecimiento se ve lineal, o peor que lineal?
¿Por qué sample_next trata como caso especial temperature != 1.0 en lugar de siempre llamar a apply_temperature? ¿Qué calcula realmente apply_temperature cuando temperature == 1.0?
Usando tus cronometrajes del Reto 3, calcula la razón entre el tiempo a 1000x y el tiempo a 50x. Compara esa razón con la razón de los tamaños de corpus (1000/50 = 20). ¿La razón de tiempos coincide aproximadamente, confirmando un escalado lineal?
Escribe una función generate_batch(probs_table, start_word, temperatures, max_words=10) que genere una oración por cada valor de temperatura en una lista dada (p. ej. [0.5, 1.0, 1.5, 2.0]) y devuelva todas juntas, para que puedas comparar el efecto de la temperatura lado a lado en una sola llamada.
🤔 Preguntas socráticas
- Acabas de medir personalmente el rendimiento de Python puro en una tarea de procesamiento de texto. ¿Crees que la lentitud que viste se debe principalmente al bucle
forde Python, a las operaciones de diccionario, o a otra cosa? ¿Qué necesitarías probar para aislar la causa? - La temperatura reconfigura una distribución de probabilidad pero nunca cambia cuáles resultados son posibles (una palabra con probabilidad 0 se queda en probabilidad 0 sin importar la temperatura). ¿Por qué es esa una propiedad importante para que tenga un "mando de aleatoriedad"?
- La Sección 2 comienza con una demostración que muestra el mismo tipo de tarea de conteo de palabras ejecutándose dramáticamente más rápido con numpy/pandas que la versión en Python puro que acabas de construir. Basándote en lo que ahora sabes sobre cómo funciona realmente tu
build_bigram_probabilities(bucles anidados, búsquedas en diccionarios), ¿qué predices que necesitaría hacer diferente una versión vectorizada para ser más rápida? - El escalado de esta semana fue lineal () en el conteo total de palabras — no el peor comportamiento de escalado posible. ¿Se te ocurre algún paso en todo este pipeline de 5 semanas que podría haber sido mucho peor (p. ej. cuadrático) si se hubiera escrito de una forma distinta, más ingenua?
✅ Cuestionario semanal
✅ Cuestionario semanal
🎁 Bono: empaquetando el modelo como una clase
Disponible al aprobar el cuestionario de esta semana.
🎉 Terminaste Python 101. Construiste un modelo de lenguaje funcional (aunque simple) usando nada más que la biblioteca estándar de Python — y mediste personalmente dónde empieza a tensarse. Esa es exactamente la puerta que la Sección 2, Pandas y Análisis de Datos, abre a continuación.