IADev - Senior por Suscripción

Inicio

K-Means en Go: agrupar datos sin preguntarle a un LLM

En el primer post usamos una recta para estimar cuánto iba a consumir nuestro server, después vimos cómo ajustarla con descenso de gradiente y en el anterior, pasamos de predecir una cantidad a estimar la probabilidad de que se quedara sin memoria.

Continuaremos con el mismo ejemplo, hasta ahora había algo en común, le dábamos al entrenamiento las entradas y también las respuestas correctas, los MB que se consumieron, o si el proceso terminó fallando, ahora vamos a quitar esa segunda parte y trabajaremos con un dato más, CPU.

Añadimos ese parámetro adicional, para ahora hacernos la pregunta, qué observaciones se parecen entre sí?

Vamos a agruparlas con K-Means, desde cero y en Go, esta vez ni siquiera necesitamos derivar una sigmoide, con restar, multiplicar y calcular medias es más que suficiente.

asignar-promediar-repetir

Ahora no tenemos una y que copiar

Lo anterior era aprendizaje supervisado, cada entrada tenía un resultado conocido con el que comparar la predicción, ahora solo vamos a pasar las características de cada observación.

Esto es aprendizaje no supervisado, y el problema concreto que vamos a resolver se llama clustering o agrupamiento.

No estamos entrenando al modelo para reconocer unas clases que ya definimos, estamos buscando una agrupación según una medida de cercanía que también vamos a elegir nosotros.

Los datos: el server, pero ahora con dos números

Cada fila será una medición del mismo proceso en un momento distinto, añadimos CPU al consumo de RAM, así podemos representar cada observación como un punto en un plano.

Medición CPU RAM
A 10 % 15 %
B 15 % 20 %
C 20 % 25 %
D 70 % 20 %
E 75 % 25 %
F 80 % 30 %
G 70 % 75 %
H 75 % 80 %
I 80 % 85 %

Las letras solo sirven para identificar las filas, no son clases y no se las pasaremos al algoritmo, tampoco le pasaremos el instante de cada medición porque vamos a comparar estados, no a aprender cómo evoluciona el proceso en el tiempo.

Si dibujamos los datos, queda así:

Se ven tres zonas bastante claras porque hemos inventado los datos para que se vean, esto nos sirve para entender y comprobar el algoritmo, la gracia es que el programa no va a recibir esas tres zonas dibujadas, solo recibirá los nueve pares de números.

Qué significa K-Means?

K-Means es un algoritmo de aprendizaje no supervisado que sirve para agrupar datos que se parecen entre sí, sin necesidad de decirle previamente a qué grupo pertenece cada uno.

La K es la cantidad de grupos que queremos intentar formar, esa cantidad la elegimos antes de entrenar al modelo, en nuestro caso vamos a usar k = 3.

Means viene de medias, cada grupo tiene un punto central llamado centroide, que representa la media de los valores de todos los elementos que pertenecen a ese grupo, por ejemplo, si manualmente agrupamos los servers A y B, podemos calcular su centroide así:

CPU: (10 + 15) / 2 = 12,5
Memoria: (15 + 20) / 2 = 17,5

Por tanto, el centroide de ese grupo estará en las coordenadas (12,5, 17,5), fácil no? pues cada vez que mencione un centroide, ya sabemos qué es exactamente.

Entonces, sabiendo esto, usaremos el algoritmo básico que consiste en alternar dos operaciones, asignar cada punto al centroide más cercano y recalcular los centroides con los puntos que recibieron.

Elegir k posiciones iniciales
            |
            v
Asignar cada punto al centroide más cercano
            |
            v
Recalcular cada centroide haciendo la media
            |
            v
Reasignar los puntos y repetir si cambian los grupos

Esta es la versión clásica conocida como algoritmo de Lloyd, nosotros pararemos cuando las asignaciones ya no cambien, con un límite de iteraciones por si no llegamos antes.

Primero: qué significa que dos puntos estén cerca?

Usaremos la distancia euclídea al cuadrado, la que usabamos en la univ cuando trabajabamos con el plano cartesiano, para dos puntos a y b:

distancia⁡2(a,b)=(ax−bx)2+(ay−by)2\operatorname{distancia}^2(a, b) = (a_x - b_x)^2 + (a_y - b_y)^2

La distancia euclídea llevaría una raíz cuadrada, pero no necesitamos calcularla para decidir cuál es menor: comparar 4 con 9 da el mismo orden que comparar 2 con 3.

Vamos a empezar con estas posiciones, tomadas de A, B y H (ojo no significa que esto sea el resultado de los centroides, estamos directamente tomando esas posiciones como los centroides, para tener un punto de partida):

Centroide 0 = (0.10,0.15)
Centroide 1 = (0.15,0.20)
Centroide 2 = (0.75,0.80)

Fijate que los dos primeros están bastante juntos, esto lo hacemos a propósito para ver cómo se recolocan, no para regalarle al algoritmo la solución terminada.

Para la medición E, que está en (0.75, 0.25), las cuentas son, lo tomaremos como nuestro punto a en la ecuación de distancia y los centroides inventados como b:

Al centroide 0:(0.75−0.10)2+(0.25−0.15)2=0.4325Al centroide 1:(0.75−0.15)2+(0.25−0.20)2=0.3625Al centroide 2:(0.75−0.75)2+(0.25−0.80)2=0.3025\begin{aligned} \text{Al centroide 0:} \quad &(0.75 - 0.10)^2 + (0.25 - 0.15)^2 = 0.4325 \\ \text{Al centroide 1:} \quad &(0.75 - 0.15)^2 + (0.25 - 0.20)^2 = 0.3625 \\ \text{Al centroide 2:} \quad &(0.75 - 0.75)^2 + (0.25 - 0.80)^2 = 0.3025 \end{aligned}

La menor es la última, así que E empieza en el grupo 2, todavía no hemos terminado, cuando se muevan los centroides, puede cambiar de grupo.

Después: mover el centro al centro

Con esas posiciones iniciales, el grupo 1 recibe B, C y D y su nuevo centroide se calcula así:

CPU media=0.15+0.20+0.703=0.35RAM media=0.20+0.25+0.203≈0.216667Nuevo centroide 1≈(0.35,0.216667)\begin{aligned} \text{CPU media} &= \frac{0.15 + 0.20 + 0.70}{3} = 0.35 \\\\ \text{RAM media} &= \frac{0.20 + 0.25 + 0.20}{3} \approx 0.216667 \\\\ \text{Nuevo centroide } 1 &\approx (0.35, 0.216667) \end{aligned}

Hacemos lo mismo con los otros grupos y volvemos a calcular a qué centroide queda más cerca cada punto.

Y por qué la media? Porque, manteniendo fijos los puntos de un grupo, esa es la posición que minimiza la suma de sus distancias al cuadrado, en una sola coordenada podemos verlo sin mucho misterio:

Error⁡(c)=∑i(xi−c)2derivada=2(mc−∑ixi)\begin{aligned} \operatorname{Error}(c) &= \sum_i (x_i - c)^2 \\ \text{derivada} &= 2 \left(mc - \sum_i x_i\right) \end{aligned}

Si igualamos a cero la derivada y despejamos:

c=∑iximc = \frac{\sum_i x_i}{m}

m es el número de puntos de ese grupo, repetimos la cuenta para la otra coordenada y tenemos el centroide.

Una vez fijados los grupos, sabemos calcular directamente su mejor centro, lo que repetimos es la asignación y la media, no pequeños pasos de gradiente.

Y qué estamos intentando reducir?

En los posts anteriores comparábamos una predicción con una respuesta conocida, aquí no tenemos esa respuesta, pero sí podemos medir cuánto se alejan los puntos del centro de su grupo.

La función que queremos minimizar es:

J=∑idistancia⁡2(puntoi,centroide asignadoi)J = \sum_i \operatorname{distancia}^2(\text{punto}_i, \text{centroide asignado}_i)

A esta suma se la suele llamar inercia o suma de cuadrados dentro de los grupos, esta es una medida de dispersión, no un porcentaje de aciertos.

Con los centroides fijos, mandar cada punto al más cercano no aumenta esa suma, con las asignaciones fijas, mover cada centro a la media tampoco la aumenta, es por eso que estos dos pasos encajan, mejoramos una parte mientras mantenemos la otra quieta.

En aritmética exacta, la inercia no aumenta entre iteraciones, en estos tests vamos a dejar una pequeña tolerancia para el redondeo de los float64.

Implementación en Go

Esta vez creamos el paquete clustering, para separarnos del paquete regression.

La implementación será de dos dimensiones, para que podamos seguirla en el dibujo, el mismo procedimiento se puede extender a más dimensiones, sumando las diferencias al cuadrado de cada una, pero no lo veo necesario para explicarlo.

Creamos clustering/kmeans.go:

package clustering

import (
	"fmt"
	"math"
	"slices"
)

// Point representa el punto en el plano cartesiano
type Point struct {
	X, Y float64
}

type KMeans struct {
	Centroids []Point
	Converged bool
}

func finite(value float64) bool {
	return !math.IsNaN(value) && !math.IsInf(value, 0)
}

func squaredDistance(a, b Point) float64 {
	dx, dy := a.X-b.X, a.Y-b.Y

	return dx*dx + dy*dy
}

func nearest(point Point, centroids []Point) (int, float64, error) {
	if len(centroids) == 0 {
		return 0, 0, fmt.Errorf("el modelo no tiene centroides")
	}

	best, bestDistance := 0, math.Inf(1)
	for i, centroid := range centroids {
		distance := squaredDistance(point, centroid)
		if !finite(distance) {
			return 0, 0, fmt.Errorf("distancia infinita: revisa los datos y su escala")
		}
		// En un empate gana el centroide con menor índice.
		if distance < bestDistance {
			best, bestDistance = i, distance
		}
	}
	return best, bestDistance, nil
}

// Predict devuelve el índice del centroide más cercano, no una probabilidad.
func (model *KMeans) Predict(point Point) (int, error) {
	label, _, err := nearest(point, model.Centroids)
	return label, err
}

func assign(points, centroids []Point) ([]int, float64, error) {
	labels := make([]int, len(points))
	var inertia float64
	for i, point := range points {
		label, distance, err := nearest(point, centroids)
		if err != nil {
			return nil, 0, fmt.Errorf("dato %d: %w", i, err)
		}
		labels[i] = label
		inertia += distance
	}
	if !finite(inertia) {
		return nil, 0, fmt.Errorf("inercia infinita: revisa la escala")
	}
	return labels, inertia, nil
}

// Fit aplica K-Means en 2D. k es len(initial).
// history[0] es la inercia inicial, el resto, después de cada actualización
// Conserva el centroide anterior cuando un grupo queda vacío
func Fit(points, initial []Point, maxIterations int) (*KMeans, []int, []float64, error) {
	if len(points) == 0 || len(initial) == 0 || len(initial) > len(points) {
		return nil, nil, nil, fmt.Errorf("se requiere 1 <= k <= número de datos")
	}

	if maxIterations <= 0 {
		return nil, nil, nil, fmt.Errorf("maxIterations debe ser positivo")
	}

	model := &KMeans{Centroids: slices.Clone(initial)}
	labels, inertia, err := assign(points, model.Centroids)
	if err != nil {
		return nil, nil, nil, err
	}
	history := []float64{inertia}

	k := len(initial)
	sums := make([]Point, k)
	counts := make([]int, k)

	for iteration := 0; iteration < maxIterations; iteration++ {
		for i := 0; i < k; i++ {
			sums[i] = Point{0, 0}
			counts[i] = 0
		}

		for i, point := range points {
			group := labels[i]
			sums[group].X += point.X
			sums[group].Y += point.Y
			counts[group]++
		}

		for group, count := range counts {
			if count == 0 {
				continue
			}
			model.Centroids[group] = Point{
				X: sums[group].X / float64(count),
				Y: sums[group].Y / float64(count),
			}
		}

		next, inertia, err := assign(points, model.Centroids)
		if err != nil {
			return nil, nil, nil, err
		}
		history = append(history, inertia)

		model.Converged = slices.Equal(labels, next)
		labels = next

		if model.Converged {
			break
		}
	}

	return model, labels, history, nil
}

Fit recibe los datos, las posiciones iniciales y el máximo de iteraciones, k es la longitud de initial, así evitamos recibir un número por un lado y una cantidad distinta de centroides por otro.

También retorna el modelo, el grupo asignado a cada observación y el historial de inercia, como en los posts anteriores, history[0] describe el estado inicial y history[1], el resultado de la primera actualización.

Las posiciones iniciales son solo un punto de partida, no etiquetas de entrenamiento, las pasamos explícitamente para poder repetir las cuentas y probar distintos arranques sin mezclar todavía el algoritmo con una estrategia aleatoria.

Dos detalles importantes

Primero, un grupo puede quedarse sin puntos, la media de cero observaciones no existe, así que no vamos a dividir entre cero.

En esta versión conservamos su centroide anterior. esto es una decisión sencilla, no una garantía de que terminemos con k grupos ocupados. Si necesitas que todos tengan datos, tendrás que tratar esa situación de otra manera, por ejemplo recolocando el centroide vacío y continuando. Hay implementaciones con distintas políticas, no una línea mágica que todos deban copiar.

Segundo, después de mover los centroides reasignamos los puntos hasta en la última vuelta, ssí, las etiquetas retornadas coinciden con lo que devuelve Predict sobre los mismos datos.

Si llegamos al límite mientras siguen cambiando las asignaciones, Converged será false, esto significará que los centroides todavía podrían no ser las medias de esos últimos grupos, parar porque se acabó el bucle no es lo mismo que converger.

Cómo lo usamos?

Nuestro main.go contiene los mismos nueve datos de la tabla:

package main

import (
	"fmt"
	"log"

	"example.com/kmeans/clustering"
)

func main() {
	// X = proporción de CPU; Y = proporción de RAM.
	points := []clustering.Point{
		{X: 0.10, Y: 0.15}, // A
		{X: 0.15, Y: 0.20}, // B
		{X: 0.20, Y: 0.25}, // C
		{X: 0.70, Y: 0.20}, // D
		{X: 0.75, Y: 0.25}, // E
		{X: 0.80, Y: 0.30}, // F
		{X: 0.70, Y: 0.75}, // G
		{X: 0.75, Y: 0.80}, // H
		{X: 0.80, Y: 0.85}, // I
	}

	// Tres posiciones de partida: A, B y H.
	initial := []clustering.Point{points[0], points[1], points[7]}
	model, labels, history, err := clustering.Fit(points, initial, 100)
	if err != nil {
		log.Fatal(err)
	}

	for iteration, inertia := range history {
		fmt.Printf("Iteración %d | Inercia: %.6f\n", iteration, inertia)
	}
	fmt.Printf("Convergió: %t\n", model.Converged)

	for group, centroid := range model.Centroids {
		fmt.Printf("\nGrupo %d | CPU: %.2f %% | RAM: %.2f %% | Datos:",
			group, centroid.X*100, centroid.Y*100)
		for i, label := range labels {
			if label == group {
				fmt.Printf(" %c", 'A'+i)
			}
		}
	}
	fmt.Println()

	newPoint := clustering.Point{X: 0.78, Y: 0.28}
	group, err := model.Predict(newPoint)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("\nCPU: 78 %% | RAM: 28 %% | Grupo: %d\n", group)
}

Esto nos devuelve:

Iteración 0 | Inercia: 0.872500
Iteración 1 | Inercia: 0.491778
Iteración 2 | Inercia: 0.177600
Iteración 3 | Inercia: 0.030000
Convergió: true

Grupo 0 | CPU: 15.00 % | RAM: 20.00 % | Datos: A B C
Grupo 1 | CPU: 75.00 % | RAM: 25.00 % | Datos: D E F
Grupo 2 | CPU: 75.00 % | RAM: 80.00 % | Datos: G H I

CPU: 78 % | RAM: 28 % | Grupo: 1

La inercia baja desde 0.8725 hasta 0.03, y las asignaciones evolucionan así:

Iteración A B C D E F G H I
Inicio 0 1 1 1 2 2 2 2 2
Iteración 1 0 0 0 1 2 2 2 2 2
Iteración 2 0 0 0 1 1 1 2 2 2
Iteración 3 0 0 0 1 1 1 2 2 2

En el tercer ciclo del bucle, recalculamos los centros con los grupos de la segunda, volvemos a asignar y nadie cambia de sitio, ahí marcamos la convergencia. E, que empezó en el grupo 2, ha acabado en el 1 sin que nadie le escriba un prompt para reconsiderar su pertenencia.

El centroide del grupo 1 termina en (0.75, 0.25), y nuestra nueva observación , (0.78, 0.28), cae en ese grupo porque es el centroide más cercano que tiene:

Distancia2=(0.78−0.75)2+(0.28−0.25)2=0.0018\text{Distancia}^2 = (0.78 - 0.75)^2 + (0.28 - 0.25)^2 = 0.0018

PD: esta vez lo aprendido ya no son el intercepto y la pendiente, sino también las coordenadas de los tres centroides, seis float64, el Predict compara con esos centros, no necesita recorrer los nueve ejemplos originales ni repetir el entrenamiento cada vez que llega una medición.

El grupo 2 no significa los que explotan

Podemos mirar el resultado y describir el grupo 0 como bajo consumo de ambos recursos, el 1, como CPU alta y RAM relativamente baja y el 2, como consumo alto de ambos recursos.

Esos nombres los ponemos nosotros después, el algoritmo no conoce el significado de CPU, no sabe si esa carga es esperable y no ha aprendido si alguno de esos puntos terminó provocando un fallo.

En el artículo lo simplificamos como evento futuro, aquí no, por eso sustituir la regresión logística por K-Means y llamar “fallo” al grupo con más RAM sería incorrecto.

Los índices tampoco tienen un orden de gravedad, con otro arranque, los mismos grupos podrían recibir los números 2, 0 y 1, cambiarles el número no cambia qué observaciones están juntas.

Un posible uso de este ejemplo sería resumir las métricas en perfiles y estudiar qué está ejecutándose en cada uno y cpn eso diagnosticar la causa.

Y si empezamos en otro sitio?

Aquí hay una trampa bastante importante, converger no significa haber encontrado el mejor agrupamiento posible, el resultado puede depender de las posiciones iniciales, esto lo podemos leer en la documentación sobre las limitaciones de K-Means.

Podemos comprobarlo sin cambiar los datos, simplemente en el main.go, sustituye la elección inicial por A, B y C:

initial := []clustering.Point{points[0], points[1], points[2]}

Ahora los tres centros empiezan en la misma zona, con nuestro código, la inercia final queda en 0.476250, aunque Converged también sea true.

Posiciones iniciales Inercia final Convergió
A, B y H 0.030000 Sí
A, B y C 0.476250 Sí

En el segundo caso se queda separando la zona de poco consumo en dos grupos y juntando las otras dos zonas en uno solo, esol significa que está estable, pero tenemos otro resultado que reduce mucho más la misma función, podrías subir el límite de 100 a 1M de iteraciones y no arreglarías ese arranque, si las asignaciones ya no cambian, repetir las mismas medias no va a encontrar otra solución.

converger-no-es-encontrar-lo-mejor

Una estrategia habitual es probar varias inicializaciones y quedarse con la de menor inercia, comparando el mismo k, los mismos datos y la misma escala.

Otra es K-Means++, una forma de elegir los centros iniciales favoreciendo puntos alejados de los ya elegidos, esto mejora el punto de partida pero no garantiza el óptimo global.

Aquí dejamos esa responsabilidad fuera de Fit para que el bucle principal siga siendo fácil de leer y comprobar.

La K tampoco la ha descubierto el algoritmo

Hemos elegido tres porque preparamos un ejemplo con tres zonas, con datos reales no siempre vas a tener esa ayuda, siempre podemos probar distintos valores y observar cómo baja la inercia, por ejemplo, el método del codo busca un punto a partir del cual añadir más grupos aporta una reducción bastante menor, es como una orientación, no siempre aparece un codo claro.

Y otra referencia es el coeficiente de silueta, que básicamente compara la cercanía de cada punto a su grupo con la cercanía al grupo vecino, esto ayuda a evaluar separación y compacidad, no a demostrar que los grupos tengan el significado que buscamos.

Ojo con elegir k mirando solo cuál da la menor inercia, con nueve centros, uno en cada una de nuestras nueve observaciones, podemos obtener cero.

El mínimo posible de esa función no empeora al permitir más centros, una ejecución concreta sí puede salir peor por un mal arranque, por eso es mejor considerar las inicializaciones al comparar.

La escala vuelve a importar

Ya nos pasó con el descenso de gradiente, pero aquí la escala afecta directamente a qué puntos consideramos parecidos.

Imagina que usamos CPU como proporción y RAM en MB, una diferencia de 0.10 en CPU aporta 0.01 a la distancia al cuadrado, mientras que una diferencia de 1000 MB aporta 1000000, con esto, la segunda característica puede dominar la cuenta simplemente por cómo hemos escrito sus unidades.

Por eso hay que pensar en la preparación y el escalado de las características, no pasar cualquier tabla y asumir que la distancia ya representa lo que nos interesa.

Nosotros usamos proporciones para ambos recursos y damos el mismo peso a un cambio de diez puntos porcentuales en CPU que al mismo cambio en RAM y eso también es una elección, no una propiedad universal del problema.

En fin, la aproximación que elijas debe mantenerse al predecir, por ejemplo, entrenar con 0.78 y luego pasar 78 no es un pequeño detalle de formato, es importante.


Hasta ahora habíamos aprendido a predecir cantidades, ajustar parámetros y estimar probabilidades para clasificar, ahora hemos añadido algo distinto, agrupar observaciones sin tener sus respuestas de antemano.

CPU y RAM → distancias → grupos → medias → repetir

Hemos elegido las características, la escala, la cantidad de grupos, el punto de partida y la forma de medir el resultado, el script ha hecho los cálculos y nos ha devuelto una agrupación que podemos inspeccionar.

K-Means se puede utilizar en muchos ámbitos para descubrir grupos de datos que comparten características similares, sin tener que clasificarlos manualmente, casos de uso que veo para esto serían:

  • Identificar patrones de tráfico de red y detectar comportamientos poco habituales
  • Agrupar clientes según sus hábitos de compra y preferencias
  • Identificar productos que tengan patrones de venta similares
  • Podemos agrupar transferencias según importes, frecuencias y características
  • Identificar usuarios con intereses o comportamientos parecidos
  • Reducir la cantidad de colores d euna imagen agrupando colores parecidos

Y muchas más, imagina que tienes un SaaS, sin indicarle qué tipo de usuario es cada uno de los que tienes, con K-Means podrías encontrar tres grupos

  • 0: usuarios que entran ocasionalmente y utilizan pocas funcionalidades
  • 1: usuarios que entran frecuentemente y utilizan varias funcionalidades
  • 2: usuarios muy activos que utilizan prácticamente toda la plataforma

El algoritmo no sabe qué significa ser un usuario ocasional o avanzado, simplemente encuentra similitudes matemáticas entre sus comportamientos, ya nosotros en una interfaz bonita pintamos la interpretación de esos grupos, y no necesitamos IA para que sea capaz de “contextualizar” el comportamineto de los usuarios y nos lo diga.

VIBECODER, aquí!

¿Leer tú? Eso no escala. Copia este prompt y que tu IA de confianza haga el esfuerzo.

Lee el artículo completo en https://iadev.blog/posts/k-means-go-agrupar-datos-sin-preguntarle-a-un-llm/ y resúmelo.

Explica la idea principal, los argumentos clave y la conclusión. Distingue la sátira de las afirmaciones literales por mi y no añadas datos que no aparezcan en el texto. Si no puedes acceder a la URL, dilo y pídeme que pegue el artículo.

Compártelo

El algoritmo ya ha leído suficiente.
Pásaselo a una persona.

Redes sociales

Mensajería

¿Quieres escribir aquí? Pues escribe.

Este blog también es tuyo. Ven a despotricar a gusto: aquí no hay censura ni necesitas un cargo inventado en LinkedIn, solo un post en Markdown y una PR.

Crea otra categoría si te da la gana, firma con tu cuenta de Github, crea una nueva feature si la necesitas para el post, total, hoy día todos podemos hacer apps millonarias con un prompt, ¿qué es hacer una feature en un blog estático frente a eso?

Escribir mi post Guía para publicar

~ Antes de escribir necesitas ser colaborador del repositorio, puedes solicitar acceso abriendo este issue.
~ El proceso está automatizado, recibirás una invitación que debes aceptar.

Puedes crear el post como anónimo, pero tu cuenta y la PR mergeada seguirán siendo públicas en GitHub ya que entrarás en los commits.

Historial

  1. feat: new post: k-means en go, decoracion de ecuaciones en .md (#9)Brayan Narváez8c8e4d0
  2. feat: antibloqueos de laliga (#8)Brayan Narváez7fdbb62
  3. chore: de-prompt-a-ia-engineer-la-evolucion-del-humo (#7)Brayan Narváezaa1e987
  4. chore: regresion-logistica-go-cuando-una-recta-tiene-que-tomar-decisiones (#6)Brayan Narváez46cc92e
  5. chore: post descenso-de-gradiente-go-machine-learning (#5)Brayan Narváez29b5dfb
  6. chore: post regresion-lineal-go-o-harness-de-agentes (#4)Brayan Narváezb736065
  7. feat: contribuidores aceptados mediante actions por abrir issues (#3)Brayan Narváez8c6bcf1
  8. chore: post la-ia-arreglo-un-bug-y-me-regalo-200k-lineas-de-deuda-tecnica (#2)Brayan Narváez3faad2f
  9. chore: post la-ia-arreglo-un-bug-y-me-regalo-200000-lineas-de-deuda-tecnicaBrayan Narváez2fafa99
  10. chore: post la-ia-bajo-el-capo (#1)Brayan Narváez4b54226
  11. chore: post la-ia-bajo-el-capoBrayan Narváez25bc714
  12. release: primera versión del blog, desde este punto partimosBrayan Narváez0468f2a
  13. feat: plan de contingenciaBrayan Narváez8113fd7
  14. feat: pagina 404 y componente botónBrayan Narváez5ea7de2
  15. feat: crear nuevo post y fallback del fallback de avatarBrayan Narváez1468a37
  16. chore: consideracion para desplegar en cloudflare, token de authBrayan Narváez4f63a72
  17. ia: generacion de imagen para seo (pero revisado y corregido, conBrayan Narváezfc0046c
  18. feat: compartir articulosBrayan Narváezb7c8b33
  19. feat: articulos aptos para "vibras"Brayan Narváez8dc7d26
  20. fix: tooltip sobre el boton back to topBrayan Narváez2a47b01
  21. feat: back to top botonBrayan Narváez8131e93
  22. feat: múltiples autores, cualquiera podrá escribir en este blogBrayan Narváez2d30665
  23. fix: borde reservado para el scroll eliminadoBrayan Narváez8210e02
  24. feat: historial de commits del repo en barra lateralBrayan Narváez3273be9
  25. feat: footer como barra baja con iconosBrayan Narváezb92bac1
  26. chore: actualizamos el sloganBrayan Narváez3b81fc7
  27. feat: retraso de 200ms al tooltipBrayan Narváez55badff
  28. feat: cursor blinkBrayan Narváezd8dad1f
  29. ia: ajustes visuales responsiveBrayan Narváez173d5e9
  30. feat: calculo de gasto de agua con inflacion xdBrayan Narváez4671788
  31. feat: calculo de tokens en buildtime, basado en conteo, solo tokens deBrayan Narváezcbb05c6
  32. feat: tiempo de lectura, base para conteo de tokensBrayan Narváez06db851
  33. feat: animacion de thumbnail con view-transitionBrayan Narváez359846d
  34. feat: categorias para los posts, y algunas refactorizaciones pequeñasBrayan Narváez2c40065
  35. feat: thumbnail, prompt para generar mas, faviconbrayan-bemobile4e1baaf
  36. ia: variables de font size en cssBrayan Narváezeda8702
  37. chore: check formatea los ficheros con biome correctamenteBrayan Narváez85b4d76
  38. feat: paginador en la homeBrayan Narváez78de189
  39. chore: centrando un poquito, aquí falta diseñoBrayan Narváez621889c
  40. fix: mi 404, no el de astroBrayan Narváezba35d49
  41. feat: diseño base y arquitectura aplicada, le pregunté a claudioBrayan Narváez50fdadd
  42. feat: husky como ci local, por si la IA enloqueceBrayan Narváez82f5114
  43. feat: astro base setup y arquitectura definidaBrayan Narváez167f91d
SaaS is dead