Zum Inhalt springen

Grundlagen des Reinforcement Learning für Softwareentwickler

Praxisnahe Einführung ins Reinforcement Learning: Agenten, Umgebungen, Belohnungen und Policies, mit TypeScript-Beispielen aus dem Alltag.

5 Min. Lesezeit
Diagramm, das einen Agenten zeigt, der über Aktionen mit einer Umgebung interagiert und dabei Belohnungen erhält

Reinforcement Learning (RL) ist der Teilbereich des maschinellen Lernens, in dem ein Agent durch die Interaktion mit einer Umgebung lernt. Im Gegensatz zum überwachten Lernen (bei dem beschriftete Beispiele vorgegeben werden) oder zum unüberwachten Lernen (bei dem Muster in unbeschrifteten Daten gesucht werden) lernt RL aus den Konsequenzen von Handlungen. Der Agent führt Aktionen aus, erhält Belohnungen oder Strafen und passt seine Strategie an, um die kumulierte Belohnung über die Zeit zu maximieren.

Als Entwickler begegnen uns RL-ähnliche Probleme häufiger, als wir denken — A/B-Tests, Autoscaling-Strategien, Cache-Eviction-Strategien und Lastverteilung erfordern allesamt sequenzielle Entscheidungen unter Unsicherheit.

Die zentrale Schleife: Agent, Umgebung, Zustand, Aktion, Belohnung

Jedes Reinforcement-Learning-Problem folgt derselben grundlegenden Struktur: Ein Agent beobachtet den Zustand einer Umgebung, führt eine Aktion aus, erhält eine Belohnung und wechselt in einen neuen Zustand.

tstypescript
// 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}`);
    }
  }
}

Exploration versus Exploitation

Das zentrale Dilemma im RL besteht darin, Exploration (neue Aktionen ausprobieren, um bessere Strategien zu entdecken) mit Exploitation (das nutzen, was bereits nachweislich funktioniert) in Einklang zu bringen. Genau derselbe Kompromiss zeigt sich beim A/B-Testing: Zeigt man weiterhin die bisher beste Variante, oder testet man neue?

tstypescript
// 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);
  }
}
tstypescript
// ❌ 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;
  }
}

Ein konkretes Beispiel: Cache-Eviction

Modellieren wir etwas, mit dem Entwickler täglich zu tun haben — Cache-Eviction. Der Agent entscheidet, welche Cache-Einträge verdrängt werden, wenn der Cache voll ist. Die Belohnung basiert auf der Trefferrate des Caches.

tstypescript
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);
  }
}

Wann RL sinnvoll ist (und wann nicht)

RL ist leistungsstark für Probleme, bei denen optimales Verhalten von Entscheidungsfolgen abhängt und die Dynamik der Umgebung komplex oder unbekannt ist. Für einfache, regelbasierte Entscheidungen ist es jedoch überdimensioniert, und es kann instabil werden, wenn die Belohnungssignale spärlich oder schlecht gestaltet sind.

Gut geeignet für RL: dynamische Ressourcenzuteilung, adaptive Rate-Limiting-Strategien, Autoscaling, Empfehlungssysteme, automatisierte Teststrategien. Weniger geeignet: einmalige Entscheidungen, Probleme mit klaren analytischen Lösungen sowie Situationen, in denen sich die Umgebung nicht simulieren lässt.

Die wichtigsten Erkenntnisse

  1. RL lernt aus Konsequenzen — im Gegensatz zum überwachten Lernen entdeckt der Agent optimale Strategien durch Versuch und Irrtum in einer Umgebung
  2. Exploration versus Exploitation ist der zentrale Kompromiss — Epsilon-Greedy mit Decay ist eine einfache und wirkungsvolle Ausgangsstrategie
  3. Q-Learning baut eine Wertetabelle auf — die Zuordnung von Zustand-Aktion-Paaren zu erwarteten Belohnungen ermöglicht dem Agenten fundierte Entscheidungen
  4. Die Belohnungsfunktion definiert, was „gut" bedeutet — schlecht gestaltete Belohnungen führen zu unerwünschtem Verhalten; sei explizit darüber, was optimiert werden soll
  5. RL lässt sich auf reale technische Probleme übertragen — Cache-Eviction, Lastverteilung und Autoscaling sind allesamt sequenzielle Entscheidungsprobleme unter Unsicherheit
  6. Beginne mit einfachen Umgebungen — simuliere das Problem in TypeScript, bevor du zu umfangreichen ML-Frameworks greifst
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX