Fundamentos de aprendizaje por refuerzo para desarrolladores
Introducción práctica al aprendizaje por refuerzo: agentes, entornos, recompensas y políticas, con ejemplos en TypeScript sobre problemas conocidos.

El aprendizaje por refuerzo (RL, por sus siglas en inglés) es la rama del aprendizaje automático en la que un agente aprende interactuando con un entorno. A diferencia del aprendizaje supervisado (donde se proporcionan ejemplos etiquetados) o del aprendizaje no supervisado (donde se buscan patrones en datos sin etiquetar), el RL aprende de las consecuencias de sus acciones. El agente realiza acciones, recibe recompensas o penalizaciones, y ajusta su estrategia para maximizar la recompensa acumulada a lo largo del tiempo.
Como desarrolladores, nos encontramos con problemas de tipo RL más a menudo de lo que pensamos — las pruebas A/B, las políticas de autoescalado, las estrategias de expulsión de caché y el balanceo de carga implican, todos ellos, tomar decisiones secuenciales bajo incertidumbre.
El bucle central: agente, entorno, estado, acción, recompensa
Todo problema de aprendizaje por refuerzo comparte la misma estructura fundamental: un agente observa el estado de un entorno, realiza una acción, recibe una recompensa y transiciona a un nuevo estado.
// The RL loop expressed as TypeScript interfaces
interface State {
features: number[]; // Numeric representation of current situation
}
interface Action {
id: number;
label: string;
}
interface Environment {
getState(): State;
getAvailableActions(): Action[];
step(action: Action): { nextState: State; reward: number; done: boolean };
reset(): State;
}
interface Agent {
selectAction(state: State, availableActions: Action[]): Action;
learn(state: State, action: Action, reward: number, nextState: State): void;
}
// The training loop
function trainAgent(agent: Agent, env: Environment, episodes: number): void {
for (let episode = 0; episode < episodes; episode++) {
let state = env.reset();
let totalReward = 0;
let done = false;
while (!done) {
const actions = env.getAvailableActions();
const action = agent.selectAction(state, actions);
const { nextState, reward, done: isDone } = env.step(action);
agent.learn(state, action, reward, nextState);
state = nextState;
totalReward += reward;
done = isDone;
}
if (episode % 100 === 0) {
console.log(`Episode ${episode}: total reward = ${totalReward}`);
}
}
}Exploración frente a explotación
El dilema central del RL es equilibrar la exploración (probar nuevas acciones para descubrir mejores estrategias) con la explotación (usar lo que ya sabemos que funciona). Es exactamente el mismo compromiso que existe en las pruebas A/B: ¿seguimos mostrando la variante con mejor rendimiento o probamos otras nuevas?
// Epsilon-greedy strategy: explore with probability epsilon, exploit otherwise
class EpsilonGreedyAgent implements Agent {
private qTable: Map<string, Map<number, number>> = new Map();
private epsilon: number;
private learningRate: number;
private discountFactor: number;
constructor(epsilon = 0.1, learningRate = 0.1, discountFactor = 0.95) {
this.epsilon = epsilon;
this.learningRate = learningRate;
this.discountFactor = discountFactor;
}
selectAction(state: State, availableActions: Action[]): Action {
const stateKey = JSON.stringify(state.features);
// Explore: pick a random action
if (Math.random() < this.epsilon) {
const idx = Math.floor(Math.random() * availableActions.length);
return availableActions[idx];
}
// Exploit: pick the action with the highest Q-value
const qValues = this.qTable.get(stateKey);
if (!qValues) {
// Never seen this state — explore by default
const idx = Math.floor(Math.random() * availableActions.length);
return availableActions[idx];
}
let bestAction = availableActions[0];
let bestValue = -Infinity;
for (const action of availableActions) {
const value = qValues.get(action.id) ?? 0;
if (value > bestValue) {
bestValue = value;
bestAction = action;
}
}
return bestAction;
}
learn(state: State, action: Action, reward: number, nextState: State): void {
const stateKey = JSON.stringify(state.features);
const nextStateKey = JSON.stringify(nextState.features);
if (!this.qTable.has(stateKey)) {
this.qTable.set(stateKey, new Map());
}
const currentQ = this.qTable.get(stateKey)!.get(action.id) ?? 0;
// Find the max Q-value for the next state
const nextQValues = this.qTable.get(nextStateKey);
let maxNextQ = 0;
if (nextQValues) {
maxNextQ = Math.max(...nextQValues.values(), 0);
}
// Q-learning update rule
const newQ = currentQ + this.learningRate * (
reward + this.discountFactor * maxNextQ - currentQ
);
this.qTable.get(stateKey)!.set(action.id, newQ);
}
}// ❌ Always exploiting — gets stuck on locally optimal strategies
class GreedyOnlyAgent implements Agent {
selectAction(state: State, actions: Action[]): Action {
// Always picks the best-known action
// Never discovers that action #3 is actually better long-term
return this.getBestKnownAction(state, actions);
}
}
// ✅ Decaying epsilon — explore a lot early, exploit more as you learn
class DecayingEpsilonAgent implements Agent {
private epsilon: number;
private readonly minEpsilon = 0.01;
private readonly decayRate = 0.995;
selectAction(state: State, actions: Action[]): Action {
const result = Math.random() < this.epsilon
? this.randomAction(actions)
: this.getBestKnownAction(state, actions);
// Gradually reduce exploration over time
this.epsilon = Math.max(this.minEpsilon, this.epsilon * this.decayRate);
return result;
}
}Un ejemplo concreto: expulsión de caché
Vamos a modelar algo con lo que los desarrolladores lidian a diario: la expulsión de entradas de caché. El agente decide qué entradas expulsar cuando la caché está llena. La recompensa se basa en la tasa de aciertos de caché.
interface CacheState {
features: number[]; // [cacheSize, hitRate, avgAge, avgFrequency]
}
class CacheEnvironment implements Environment {
private cache: Map<string, { value: string; age: number; hits: number }>;
private readonly capacity: number;
private requests: string[];
private step_count: number;
constructor(capacity: number, requests: string[]) {
this.cache = new Map();
this.capacity = capacity;
this.requests = requests;
this.step_count = 0;
}
getState(): CacheState {
const entries = [...this.cache.values()];
const avgAge = entries.length > 0
? entries.reduce((s, e) => s + e.age, 0) / entries.length : 0;
const avgFreq = entries.length > 0
? entries.reduce((s, e) => s + e.hits, 0) / entries.length : 0;
return {
features: [
this.cache.size / this.capacity, // Fullness ratio
avgAge,
avgFreq,
this.step_count,
],
};
}
getAvailableActions(): Action[] {
return [
{ id: 0, label: 'evict-lru' }, // Least recently used
{ id: 1, label: 'evict-lfu' }, // Least frequently used
{ id: 2, label: 'evict-random' }, // Random eviction
{ id: 3, label: 'evict-oldest' }, // Oldest entry
];
}
step(action: Action): { nextState: CacheState; reward: number; done: boolean } {
// Simulate processing the next request
const request = this.requests[this.step_count];
let reward = 0;
if (this.cache.has(request)) {
reward = 1; // Cache hit — positive reward
this.cache.get(request)!.hits++;
} else {
reward = -0.5; // Cache miss — negative reward
if (this.cache.size >= this.capacity) {
this.evict(action); // Agent chooses eviction strategy
}
this.cache.set(request, { value: request, age: 0, hits: 1 });
}
// Age all entries
for (const entry of this.cache.values()) {
entry.age++;
}
this.step_count++;
const done = this.step_count >= this.requests.length;
return { nextState: this.getState(), reward, done };
}
reset(): CacheState {
this.cache.clear();
this.step_count = 0;
return this.getState();
}
private evict(action: Action): void {
// Each action corresponds to a different eviction strategy
// The agent learns which strategy works best for current workload
const entries = [...this.cache.entries()];
let evictKey: string;
switch (action.id) {
case 0: evictKey = entries.sort((a, b) => b[1].age - a[1].age)[0][0]; break;
case 1: evictKey = entries.sort((a, b) => a[1].hits - b[1].hits)[0][0]; break;
case 2: evictKey = entries[Math.floor(Math.random() * entries.length)][0]; break;
case 3: evictKey = entries.sort((a, b) => b[1].age - a[1].age)[0][0]; break;
default: evictKey = entries[0][0];
}
this.cache.delete(evictKey);
}
}Cuándo tiene sentido usar RL (y cuándo no)
El RL es una herramienta poderosa para problemas en los que el comportamiento óptimo depende de secuencias de decisiones y la dinámica del entorno es compleja o desconocida. Pero resulta excesivo para decisiones simples basadas en reglas y puede volverse frágil cuando las señales de recompensa son escasas o están mal diseñadas.
Buenos casos de uso para RL: asignación dinámica de recursos, límites de frecuencia adaptativos, autoescalado, sistemas de recomendación, estrategias de pruebas automatizadas. Casos poco adecuados: decisiones puntuales, problemas con soluciones analíticas claras, situaciones en las que no se puede simular el entorno.
Puntos clave
- El RL aprende de las consecuencias — a diferencia del aprendizaje supervisado, el agente descubre estrategias óptimas mediante prueba y error dentro de un entorno
- La exploración frente a la explotación es el compromiso central — epsilon-greedy con decaimiento es una estrategia inicial simple y eficaz
- Q-learning construye una tabla de valores — asignar pares estado-acción a recompensas esperadas permite al agente tomar decisiones informadas
- La función de recompensa define lo que significa "bueno" — recompensas mal diseñadas provocan comportamientos no deseados; sé explícito sobre lo que quieres optimizar
- El RL se aplica a problemas reales de ingeniería — la expulsión de caché, el balanceo de carga y el autoescalado son todos problemas de decisión secuencial bajo incertidumbre
- Empieza con entornos simples — simula el problema en TypeScript antes de recurrir a frameworks de ML pesados


