REINFORCE menilai aksi dengan return penuh episode, DQN menaksir nilai tanpa kebijakan eksplisit. Actor-critic mengambil yang terbaik dari keduanya: satu jaringan actor memilih aksi, satu jaringan critic menaksir nilai, dan keduanya dilatih serentak dari aliran pengalaman yang sama. Ukuran penilaian aksinya bukan lagi return mentah, melainkan keunggulan (advantage) — seberapa jauh hasil aksi melampaui perkiraan critic. Hasilnya adalah gradien yang jauh lebih tenang daripada REINFORCE dan pembaruan setiap langkah tanpa menunggu episode selesai. Pada chapter ini kita bongkar arsitektur dua kepala ini, melatih implementasi mini dengan PyTorch, lalu memverifikasi critic lewat hitungan tangan.

Dua Kepala, Satu Tujuan #

Actor adalah kebijakan π_θ(a|s) berbentuk jaringan dengan softmax — persis konstruksi chapter Policy Gradient . Critic adalah penaksir nilai V_w(s) — satu angka per state, kerabat fungsi nilai chapter Value Function . Keduanya bertemu pada satu besaran, keunggulan temporal difference:

δ = r + γ·V(s') − V(s)

Cara membacanya: r + γ·V(s') adalah kenyataan yang barusan terjadi (reward sekarang plus nilai state tujuan), sedangkan V(s) adalah harapan critic. Bila δ > 0, aksi yang barusan diambil membawa hasil lebih baik daripada yang diantisipasi, maka log π(a|s) didorong naik sebesar δ; bila δ < 0, dorongannya berbalik turun. Karena besarnya return terserap di dalam V, galat pembelajaran actor hanya berupa selisih kecil — inilah penurunan varians yang dijanjikan dibanding bobot G_t yang berayun lebar pada REINFORCE. Critic sendiri dilatih mengecilkan δ², dan bonus entropi kecil ditambahkan agar kebijakan tidak terlalu cepat memastikan satu aksi sebelum bukti cukup.

AspekREINFORCEActor-critic
sinyal pembelajaran actorreturn episode G_tkeunggulan δ (varians kecil)
waktu pembaruanakhir episodesetiap langkah
komponen yang dilatihsatu jaringan kebijakandua jaringan serentak
penyangga stabilitasbaseline standardisasicritic menyerap besaran return

Demo: Actor-Critic Mini #

Dunianya dunia 1D 5 sel yang sama dengan chapter Policy Gradient, sehingga kemajuan kedua metode bisa dibandingkan langsung. Actor dan critic masing-masing MLP kecil 1 → 32 → keluaran; keduanya disetel oleh satu optimizer bersama. Berkas actor_critic_mini.py menjalankan 300 episode dengan pembaruan per langkah:

python
"""Actor-critic mini pada dunia 1D 5 sel (PyTorch, update per langkah).

Dunia sama dengan chapter Policy Gradient: mulai sel 0, target sel 4,
reward -0.2 per langkah dan +1 saat tiba. Actor (MLP 1 -> 32 -> 2 dengan
softmax) memilih aksi; critic (MLP 1 -> 32 -> 1) menaksir V(s). Setiap
langkah dihitung keunggulan TD delta = r + gamma V(s') - V(s), lalu
loss actor = -log pi(a|s) x delta (dilepas gradiennya) dan
loss critic = delta^2 diperbarui bersama, plus bonus entropi kecil.
"""

import numpy as np
import torch
import torch.nn as nn

torch.manual_seed(23)
torch.set_num_threads(1)          # model kecil: satu thread justru lebih cepat

JUMLAH_SEL, MAKS_LANGKAH = 5, 25
GAMMA, BETA_ENTROPI = 0.95, 0.01


def step(posisi, aksi):
    """Satu interaksi dunia 1D; kembalikan (posisi, reward, selesai)."""
    if aksi == 1:
        posisi = min(posisi + 1, JUMLAH_SEL - 1)
    else:
        posisi = max(posisi - 1, 0)
    selesai = posisi == JUMLAH_SEL - 1
    return posisi, (1.0 if selesai else -0.2), selesai


actor = nn.Sequential(nn.Linear(1, 32), nn.Tanh(), nn.Linear(32, 2))
critic = nn.Sequential(nn.Linear(1, 32), nn.Tanh(), nn.Linear(32, 1))
optimizer = torch.optim.Adam(
    list(actor.parameters()) + list(critic.parameters()), lr=0.01)


def prob_kanan(posisi):
    """Probabilitas aksi kanan versi actor (untuk laporan)."""
    with torch.no_grad():
        logits = actor(torch.tensor([[posisi / (JUMLAH_SEL - 1)]]))
    return torch.softmax(logits, dim=1)[0, 1].item()


print("pi(kanan | posisi) sebelum latih:")
print("  " + "  ".join(f"s={s}: {prob_kanan(s):.2f}" for s in range(4)))

riwayat, loss_aktor, loss_kritik = [], [], []
for episode in range(1, 301):
    posisi, total = 0, 0.0
    for _ in range(MAKS_LANGKAH):
        masukan = torch.tensor([[posisi / (JUMLAH_SEL - 1)]])
        logits = actor(masukan)
        distribusi = torch.distributions.Categorical(logits=logits)
        aksi = distribusi.sample()
        posisi_baru, reward, selesai = step(posisi, int(aksi.item()))

        with torch.no_grad():                       # target tanpa gradien
            v_berikut = torch.zeros(1) if selesai else critic(
                torch.tensor([[posisi_baru / (JUMLAH_SEL - 1)]]))
        v = critic(masukan)
        delta = (reward + GAMMA * v_berikut) - v     # keunggulan TD

        pg_aktor = -distribusi.log_prob(aksi) * delta.detach()
        loss_aktor.append(abs(pg_aktor.item()))
        loss = pg_aktor - BETA_ENTROPI * distribusi.entropy() + delta ** 2
        loss_kritik.append((delta ** 2).item())

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total += reward
        posisi = posisi_baru
        if selesai:
            break
    riwayat.append(total)

print("\nMean total reward per blok 50 episode:")
for i in range(0, 300, 50):
    print(f"  episode {i + 1:3d}-{i + 50:3d}: "
          f"{np.mean(riwayat[i:i + 50]):+.3f}")

n_awal = len(loss_aktor) // 6          # ~50 episode pertama
print(f"\nMean |loss aktor|  50 episode pertama : {np.mean(loss_aktor[:n_awal]):.4f}")
print(f"Mean |loss aktor|  50 episode terakhir : {np.mean(loss_aktor[-n_awal:]):.4f}")
print(f"Mean loss kritik  50 episode pertama : {np.mean(loss_kritik[:n_awal]):.4f}")
print(f"Mean loss kritik  50 episode terakhir: {np.mean(loss_kritik[-n_awal:]):.4f}")

print("\npi(kanan | posisi) setelah latih:")
print("  " + "  ".join(f"s={s}: {prob_kanan(s):.2f}" for s in range(4)))

with torch.no_grad():
    v0 = critic(torch.tensor([[0.0]])).item()
v0_teori = -0.2 + GAMMA * -0.2 + GAMMA**2 * -0.2 + GAMMA**3 * 1.0
print(f"\nV(0) critic      : {v0:.2f}")
print(f"V(0) hitungan    : {v0_teori:.2f}  "
      f"(-0.2 - 0.19 - 0.18 + 0.86, jalur optimal 4 langkah)")

Output yang Dihasilkan:

text
pi(kanan | posisi) sebelum latih:
  s=0: 0.37  s=1: 0.36  s=2: 0.37  s=3: 0.37

Mean total reward per blok 50 episode:
  episode   1- 50: -0.092
  episode  51-100: +0.400
  episode 101-150: +0.400
  episode 151-200: +0.396
  episode 201-250: +0.400
  episode 251-300: +0.400

Mean |loss aktor|  50 episode pertama : 0.0223
Mean |loss aktor|  50 episode terakhir : 0.0000
Mean loss kritik  50 episode pertama : 0.2379
Mean loss kritik  50 episode terakhir: 0.0006

pi(kanan | posisi) setelah latih:
  s=0: 1.00  s=1: 1.00  s=2: 1.00  s=3: 1.00

V(0) critic      : 0.29
V(0) hitungan    : 0.29  (-0.2 - 0.19 - 0.18 + 0.86, jalur optimal 4 langkah)

Tiga pengamatan. Pertama, kecepatan: mean reward blok kedua (episode 51-100) sudah +0.400 — nilai optimal dunia — sedangkan REINFORCE pada chapter sebelumnya baru mencapai +0.388 dan blok pertamanya +0.024; pembaruan per langkah dengan sinyal tenang memangkas waktu belajar. Kedua, kedua loss menurun: mean |loss aktor| dari 0.0223 ke 0.0000 dan loss critic dari 0.2379 ke 0.0006 — actor dan critic saling menenangkan, karena target masing-masing menstabil lawannya. Ketiga, verifikasi paling meyakinkan: critic menaksir V(0) = 0.29 dan hitungan manual jalur optimal (-0.2 - 0.19 - 0.18 + 0.86 dengan γ = 0.95) juga menghasilkan 0.29 — jaringan nilai menemukan angka yang sama tanpa pernah diberi rumusnya. Mari kita telaah kode di atas baris per baris:

  1. Baris 15-17: seed PyTorch dikunci dan set_num_threads(1) — demo CPU deterministik; NumPy dipakai untuk statistik pelaporan.
  2. Baris 22-29 (step): dunia 1D 5 sel — reward -0.2 per langkah, +1 di sel 4.
  3. Baris 32-35: dua kepala dibangun — actor 1 → 32 → 2 (dua logits) dan critic 1 → 32 → 1 (satu nilai); parameter keduanya digabung ke dalam satu optimizer Adam.
  4. Baris 52-55: actor menghasilkan distribusi Categorical lalu aksi diambil lewat sample(); log_prob-nya siap dipakai sebagai pegangan gradien.
  5. Baris 58-62: keunggulan δ = r + γ·V(s') − V(s); V(s') dihitung tanpa gradien dan dinolkan bila episode selesai — target tidak boleh ikut terlatih lewat jalur ini.
  6. Baris 64-66: gabungan tiga suku loss — -log π(a|s)·δ.detach() untuk actor (δ berperan bobot, bukan variabel), δ² untuk critic, dan -β·entropi (distributions PyTorch ) agar eksplorasi awet.
  7. Baris 69-73: satu optimizer.step() menyetel actor dan critic serentak dari loss gabungan itu.
  8. Baris 83-87: statistik loss blok pertama lawan blok terakhir — bukti kedua kepala konvergen.
  9. Baris 92-97: ujian pamungkas — V(0) versi critic dibandingkan hitungan manual return diskonto jalur optimal.

Keluarga Actor-Critic #

Kerangka dua kepala ini menjadi fondasi hampir seluruh RL modern, dengan varian yang berbeda pada cara menahan pembaruan kebijakan agar tidak berubah terlalu jauh sekaligus:

MetodeCiri khasKhasiat
A2C/A3Cbeberapa worker paralel, gradien diagabungdata lebih beragam, stabil
DDPGaksi kontinu, critic menilai Q(s,a)robotika, kontrol kontinu
PPOrasio pembaruan kebijakan dipotong (clipped)stabil, praktis, banyak dipakai
SACmaksimalkan return + entropieksplorasi terjaga di dunia sulit

PPO menjadi standar de facto — termasuk pada tahap fine-tuning model bahasa besar yang kita bahas pada section Large Language Model — justru karena kesederhanaannya: satu ide klip yang menjaga setiap pembaruan tetap kecil.

Algoritma, stabilisasi, dan kebijakan kini lengkap. Namun satu hal dari awal section masih menggantung tanpa keputusan: saat agent mengambil aksi, kapan waktunya mencoba hal baru dan kapan waktunya memakai yang sudah terbukti? Dilema exploration vs exploitation itulah chapter berikutnya.