前言
重讀《Deep Reinforcemnet Learning Hands-on》, 常讀常新, 極其深入淺出的一本深度強化學習教程。 本文的唯一貢獻是對其進行了翻譯和提煉, 加一點自己的理解組織成一篇中文筆記。
第四章 交叉熵方法
在本章中, 我們將完成本書的第一部分, 並介紹一種強化學習方法——交叉熵。 儘管沒有一些其他許多強化學習方法知名:例如 deep Q-learning (DQN),或者 Advantage Actor-Critic。 但它仍具有自己的強項:
- 簡潔: 這個算法非常簡潔, 在pytorch中的實現不超過100行代碼。
- 很好的收斂性:在不需要 學習開發 複雜的多步策略 且 回合較短獎勵頻繁 的 簡單環境中, 交叉熵法行之有效。 儘管這種場景不一定常見, 但交叉熵法真的非常適合。
接下來,我們會先從實際使用方面介紹交叉熵, 闡釋他如何應用於兩種Gym環境(一種是熟悉的CartPole, 一種是FrozenLake)。最後,我們介紹其理論原理。 理論部分是可選的, 且需要較好的數理統計知識。 但如果你想理解交叉熵方法, 就值得深入一看。
強化學習方法的分類
強化學習的方法,可以從多個角度進行分類:
- Model-free 或 Model-base
Model-free 方法指的是, 該強化學習方法並不需要對環境或獎勵建立一個專門的模型進行分析。 它只是直接地把動作和環境聯繫在一起。 簡單來說: Agent只是將當前的觀測通過一些計算, 得出應該採取的動作。 相反的, model-based 方法, 嘗試去預測 下一個觀測 和(或)獎勵, 並根據這一預測, 來選擇可能最佳的動作。 兩種方法都有其優點或缺點, 但一般model-base的方法只能用於確定性的環境中,比如有嚴格規則的棋類遊戲。 而model-free方法則更容易被訓練, 因爲複雜的觀測很難被簡單建模。 本書中討論的幾乎全都是model-free方法, 這也是過去幾十年最活躍的領域。 - Value-based 或 policy-base
Policy-based 方法指的是agent提出策略——每一步應該採取怎樣的動作。 一般而言, policy用一個表示概率分佈的向量代表——每個值代表對應的action被採用的概率。 相反的, value-based 方法,指的是agent會計算出每個action的對應value, 並選擇value最高的action。這兩種方法都非常普遍,並在書的後面章節詳細介紹。 - On-policy 或 off-policy
這個概念會在以後詳細介紹, 現在我們只需要知道:off-policy是指從舊的歷史數據中學習到的方法 (歷史數據指的是比如 由之前的agent得到的數據, 或者人工演示的記錄或者是同一個agent之前幾個回合 的數據)。
按這個分類,交叉熵方法屬於 model-free, policy-based, on-policy 的 強化學習方法:
- 並不對環境進行任何建模。 只是簡單地告訴agent每步該採取的策略。
- 輸出的是一個策略(動作選取的概率分佈)
- 需要環境的最新數據
實用的交叉熵
對交叉熵的講述分爲兩部分: 理論 和 實踐。 實踐部分是更直觀的, 而理論部分解釋了其爲何得以運作。
你也許還記得, 強化學習中 最重要的東西就是 agent, agent的目的是爭取在與環境的交互中(action)爲了獲得儘可能多的獎勵積累。在實踐中, 我們用通過的機器學習方法來實現agent——接收觀測值, 轉化成輸出。 具體的輸出則視具體使用的方法而定 (比如policy-based方法, 或者value-based方法)。而我們當前要介紹的policy-based方法 是 policy-based, 也就是說,我們會用一個非線性函數(神經網絡)來產生 策略 (policy),示意圖可以參考如下:
即神經網絡接受觀測s, 輸出策略pi, 這裏神經網絡就是扮演了Agent的角色。
在實踐中, 策略 往往 表示爲 每個動作的概率分佈, 這就與 經典的分類問題非常相像——輸出樣例屬於每個類別的概率分佈。 那麼我們的Agent的工作就非常簡單理解了: 接收觀測環境值給神經網絡, 獲得動作的概率分佈(策略),再根據概率採用,選取動作。
接下來,再介紹強化學習中很重要的一個概念 : 經驗 (experience)。每一場進行的遊戲(一個回合,episode)就是一條經驗, 可以用於神經網絡的學習(優化)。 每個回合,包括了一系列步, 每一步中包括了:環境的觀測, 採用的動作及當前步的reward。對於每個episode,我們都可以計算他的總reward——根據前幾章的介紹,這裏我們可以定義一個決定agent短視與否的gamma折扣係數。 在本例中,我們假定gamma=1,即agent關心的是所有步的reward之和。 那麼,我們的經驗池可以描述爲下圖:
o1,a1,r1分別代表了第一步的觀測,動作和獎勵。 我們agent的目的是使得總reward R 最大化。 顯然,由於我們一開始只是隨機的策略, 每個episode的獎勵值大小不同。 而我們的策略就是, 用表現更好的episode作爲“經驗”,對 網絡進行訓練。 因此, 交叉熵法的核心步驟可以概括如下:
- 在選定的環境中,進行N個獨立的回合,並記錄。
- 計算總的reward值。
- 丟棄總reward值後70%的回合經驗。 (70%可以自己修改爲任意比例)
- 使用剩下的30%的“精英經驗”, 以觀測值爲輸入, 訓練網絡的輸出與該觀測值對應的動作值接近。 (很容易理解, 我們認爲當前episode是值得學習的經驗, 比如這條episode中,對於觀測o1, agent當時採用了a1,那麼我們就認爲a1是好的動作——因爲總reward值高。 那麼我們就把網絡往這個方向訓練——當網絡輸入o1的時候,輸出儘可能逼近a1。)
- 重複步驟1, 直到結果滿意爲止(總的reward連創新高)
我們把 丟棄的70%的回合的reward最大值, 稱爲界。 每次都根據這個界,篩選出30%超過該界的回合作爲學習經驗 (總reward大於這個界的回合留下, 小於的刪去)。 隨着循環, 界的值必然逐漸上升(因爲每次固定篩除70%,而隨着agent的訓練, 總體的reward值肯定一直提升), 也就對應着reward逐漸上升了。 雖然這個方法很簡單, 但他可以有效處理許多環境, 且容易實現。 接下來就是將該方法實現,用於解決CartPole問題了(第二章中曾一度介紹過,但沒有給出解決方法)。
交叉熵法實踐:玩CartPole小遊戲
代碼詳見 Chapter04/01_cartpole.py
文件, 可以從上面的github庫中clone。 接下來的實踐中,我們只使用了一個單層的簡單神經網絡,由128個神經元和Relu激活函數組成。 大部分的超參數都是默認或隨機給出的。本例中可以看出, 交叉熵法很強的魯棒性(基本不需要超參數調參)和收斂性。
CartPole小遊戲在第二章中介紹過, 是Gym自帶的一個環境, 其機制是玩家控制下方的木塊左右移動, 防止木塊上的木棍傾倒。
可以看到, 代碼只有100行左右, 除去空白及import等語句,實際語句其實只需要50行不到,就能實現這個方法。
HIDDEN_SIZE = 128
BATCH_SIZE = 16
PERCENTILE = 70
一些參數的設置, 128是神經元的個數, 16是Batch的大小(每次迭代中所用到的訓練樣本的個數), 70就是70%的篩選比例。
class Net(nn.Module):
def __init__(self, obs_size, hidden_size, n_actions):
super(Net, self).__init__()
self.net = nn.Sequential(
nn.Linear(obs_size, hidden_size),
nn.ReLU(),
nn.Linear(hidden_size, n_actions)
)
def forward(self, x):
return self.net(x)
就下來就是用第三章中介紹的Pytorch框架中的Module類,自定義了自己的網絡層。 不熟悉的讀者可以參考第三章, 這是最基本的Sequential搭建網絡的用法。 重寫forward()方法後, 我們已搭建了一個一層線性神經網絡+Relu激活函數的網絡。
這裏需要重點注意的一點是: 我們居然沒有用softmax來激活輸出。 如之前提到的,我們希望網絡的輸出是對各個action的概率分佈——即總和1的一個浮點數張量。 而在深度學習中,softmax是用來將輸出激活爲滿足這一類型的函數——而我們現在的輸出直接是線性層的輸出結果, 並不能滿足這一條件。 這是因爲: 我們接下來要使用的nn.CrossEntropyLoss
這個損失函數,會自動地對輸入做softmax, 再進行交叉熵計算。 (和nn.BCEloss不同)因此,就不需要你自己再專門做一個softmax。這樣做的話方便了很多,缺點就在於當你在測試的時候,要記得對神經網絡的輸出結果做一個softmax操作。
from collections import namedtuple
Episode = namedtuple('Episode', field_names=['reward', 'steps'])
EpisodeStep = namedtuple('EpisodeStep', field_names=['observation', 'action'])
這一步是使用了python自帶庫collections中的namedtuple類型。 我們都知道tuple(元組)是python的基本數據類型, 但其缺點是,每一個元素無法單獨命名, 而namedtuple則可以對每個元素及元組進行命名。這個的好處後面會體現, namedtuple的用法可以參考namedtuple的用法,簡單來說就是這樣:
那麼這段代碼就是命名了兩個元組:
- EpisodeStep: 用於記錄回合中每一單步的結果——保存了包括觀測,agent採取的動作。這也會作爲訓練數據。
- Episode:代表一個完整的的episode。其字段分別是總獎勵值和回合中記錄的EpisodeStep值。
結合後續代碼更容易理解。
def iterate_batches(env, net, batch_size):
batch = []
episode_reward = 0.0
episode_steps = []
obs = env.reset()
sm = nn.Softmax(dim=1)
while True:
obs_v = torch.FloatTensor([obs])
act_probs_v = sm(net(obs_v))
act_probs = act_probs_v.data.numpy()[0]
action = np.random.choice(len(act_probs), p=act_probs)
next_obs, reward, is_done, _ = env.step(action)
episode_reward += reward
episode_steps.append(EpisodeStep(observation=obs, action=action))
if is_done:
batch.append(Episode(reward=episode_reward, steps=episode_steps))
episode_reward = 0.0
episode_steps = []
next_obs = env.reset()
if len(batch) == batch_size:
yield batch
batch = []
obs = next_obs
這一段代碼, 負責產生訓練樣本集(batch)。 首先, 將batch, episode_reward, episode_steps, obs
等初始化。
- 這裏注意: 我們剛剛提到,我們的神經網絡裏省略了softmax這一步, 因此,這裏我們先使用
sm = nn.Softmax(dim=1)
, 然後act_probs_v = sm(net(obs_v))
即表示, 對網絡的輸出進行softmax操作——這樣,sm(net(obs_v))
就是一個和爲1的張量, 代表了策略——選取每個動作的概率。 action = np.random.choice(len(act_probs), p=act_probs)
,這一步就是numpy中隨機類的採樣方法。 這裏需要注意的是:我們可以使用env.action_space
查看動作空間, 在CartPole例子中,動作空間就是0和1, 分別代表向左或向右——因此,我們的動作結果, 就是在[0,1]中, 按概率(由神經網絡計算得到)選取。np.random.choice(len(act_probs), p=act_probs)
指的就是在range(len(act_probs)
中, 按概率分佈爲act_probs
進行選取。- 由於後面需要torch庫進行自動的梯度計算,因此我們在把樣本輸入網絡前, 必須先轉換成torch.Tensor類。
episode_steps.append(EpisodeStep(observation=obs, action=action))
,每進行一步, 將該步的觀測和動作記錄到命名元組中保存。if is_done: batch.append(Episode(reward=episode_reward, steps=episode_steps)
當回合結束時,將記錄了本回合每一步數據的列表episode_steps
及總的reward值,保存爲Episode元組, 並加入到代表最終虛訓練數據的batch列表中。- 同樣,也是使用了
while True
循環 + yield關鍵詞的方式, 讓每次調用本函數時,都返回一組訓練樣本。
def filter_batch(batch, percentile):
rewards = list(map(lambda s: s.reward, batch))
reward_bound = np.percentile(rewards, percentile)
reward_mean = float(np.mean(rewards))
train_obs = []
train_act = []
for example in batch:
if example.reward < reward_bound:
continue
train_obs.extend(map(lambda step: step.observation, example.steps))
train_act.extend(map(lambda step: step.action, example.steps))
train_obs_v = torch.FloatTensor(train_obs)
train_act_v = torch.LongTensor(train_act)
return train_obs_v, train_act_v, reward_bound, reward_mean
上面這段代碼,則是負責篩去70%的樣本, 留下最好的30%的樣本回合。
rewards = list(map(lambda s: s.reward, batch))
,使用了map函數 —— 可以理解爲對batch的每個元素都進行lambda funciton操作,等價於rewards = [func(x) for x in batch]
, 其中, func=lambda s: s.reward
。接下來, 使用numpy庫的API np.percentile(rewards, percentile)
, 可以返回第一個參數中, 第70%(第二個參數)百分位的數 (從小到大排列)。也就是說,大於 reward_bound
的佔30%。因此,後面對每一個batch進行循環, reward值小於該閾值reward_bound
的,不加入到樣本中, 也就是說樣本集最後留下的是獎勵總額爲前30%的回合經驗。 train_obs , train_act
分別保存每個合格樣本(前30%)的觀測與動作,這裏使用了list的extend方法,這個方法類似於 列表的“+”操作。 即
train_obs.extend(map(lambda step: step.observation, example.steps))
等價於train_obs + map(lambda step: step.observation, example.steps)
。總之,通過extend方法, 樣本數據被記錄到了兩個列表中。
再將兩個列表轉換爲torch的Tensor類——obs是浮點,而action是整數值。 注意,轉換後的張量, 第一維是樣本的數量, 其餘維則是每個樣本的維度。 最後,返回需要的值。
最後是運行的主函數代碼
if __name__ == "__main__":
env = gym.make("CartPole-v0")
# env = gym.wrappers.Monitor(env, directory="mon", force=True)
obs_size = env.observation_space.shape[0]
n_actions = env.action_space.n
net = Net(obs_size, HIDDEN_SIZE, n_actions)
objective = nn.CrossEntropyLoss()
optimizer = optim.Adam(params=net.parameters(), lr=0.01)
writer = SummaryWriter(comment="-cartpole")
for iter_no, batch in enumerate(iterate_batches(env, net, BATCH_SIZE)):
obs_v, acts_v, reward_b, reward_m = filter_batch(batch, PERCENTILE)
optimizer.zero_grad()
action_scores_v = net(obs_v)
loss_v = objective(action_scores_v, acts_v)
loss_v.backward()
optimizer.step()
print("%d: loss=%.3f, reward_mean=%.1f, reward_bound=%.1f" % (
iter_no, loss_v.item(), reward_m, reward_b))
writer.add_scalar("loss", loss_v.item(), iter_no)
writer.add_scalar("reward_bound", reward_b, iter_no)
writer.add_scalar("reward_mean", reward_m, iter_no)
if reward_m > 199:
print("Solved!")
break
writer.close()
框架與上一章最後的Demo幾乎一致:
- 初始化 網絡, 優化器, 損失函數, Writer(用於Tensorboard監控)
- 進行循環, 每次循環調用樣本生成函數, 得到一批訓練樣本
- 清零梯度, 計算損失函數, 然後調用損失值的backward()方法計算梯度, 調用優化器的step()方法優化網絡。
- 打印中間值, 保存Writer
運行結果如下:
在短短30次不到的迭代中, 交叉熵方法便成功訓練出了能達到200 reward的Agent——要知道我們的隨機Agent的reward不到20。可見,交叉熵方法在這一問題中,是非常簡便實用的。 下圖是tensorboard展示的訓練過程——損失值逐次下降,伴隨着reward逐漸提升。
接下來,讓我們暫停一會,並思考交叉熵方法,爲什麼可以有效工作——我們的神經網絡在沒有得到任何環境描述的情況, 學會了如何去更好地做出動作 —— 我們的方法並不依賴於環境的細節。這就是強化學習的迷人之處。
交叉熵的理論背景
交叉熵具體是怎麼工作的? 這一點,我覺得原書中講的不好, 我仔細查閱了引用文獻及相關資料後, 以自己的理解寫一下。
這一節是介紹交叉熵的數學機理——當然,讀者也可以選擇讀交叉熵的原論文。
對於初學者, 推薦這篇知乎的文章, 簡單地瞭解下 交叉熵的基本概念:
傳送門
我就從經典的KL散度講起, 對於兩個概率分佈和 , KL散度是經典的刻畫兩者差距的度量標準。 首先說下什麼是概率分佈?
- 比如分類問題中, 總共有三種類別。 對於一個樣本, 如果我們的標籤知道這屬於第一類別, 那麼它的真實概率分佈就可以表示爲:[1, 0, 0]。即屬於第一類的概率1,其餘爲0 。而我們預測的概率分佈值, 就可能是 [0.7, 0.2, 0.1], 代表(第一類)的概率是0.7,是0.2, 是0.1。
- 就我們當前這個例子而言, 我們的強化學習 策略——就是一種概率分佈, 即, 對應於選取動作1到動作n的概率。
簡而言之, 所謂概率分佈,就是指對一隨機變量的分佈情況描述(比如上面列舉的離散情況, 概率分佈就是指隨機變量屬於各個類的概率)。這個定義也很容易拓展到連續的情況。
那麼,什麼是KL散度呢?
上式就是KL散度的公式。 他的特點在於——當且僅當時, 達到最小值, 即兩個概率分佈的距離爲0, 對應兩個分佈完全一致。 而兩者差異越大, 則KL散度越大。
上式可以進一步改寫:
注意, 右邊等式的第一項,就是的熵, 是一個常數(我們算法一般是對進行優化, p(x)一般認爲是標籤,是常量),所以,我們其實只需最小化第二項:
而這個, 就是被定義的 交叉熵。 由這個推導可以看出, 交叉熵是刻畫兩個量 和 之間的差距的, 交叉熵越小, 代表差距越小 —— 這一點和著名的MSE函數是一致的:。
知乎上有篇答案, 很好地從數學角度解釋了最小化交叉熵本質上就是最大似然估計法,寫的很好,大家可以參考 傳送門。
知道了交叉熵的基本概念和物理含義, 就不難理解爲什麼將其作爲神經網絡的損失函數了。 比如圖像分類問題(假設有四類), 對於一個樣本屬於第一類, 真實的標籤就是[1, 0, 0, 0], 按上式計算交叉熵並優化網絡儘可能減小它, 那麼會使得神經網絡最後的輸出結果也儘可能地接近標籤——比如[0.9, 0.1, 0.05, 0.05]這樣, 就代表屬於第一類的概率極高。 這個其實很像用MSE損失函數訓練, 但是在深度學習中,交叉熵在許多場景下的表現更好。
那麼就很容易理解, 交叉熵在強化學習這個例子中的原理了——
- 首先, 我們通過運行環境, 積累了許多經驗, 並挑出前30%的經驗, 以這30%經驗中所採用的策略(本質是動作選擇的概率分佈)作爲標籤。
- 我們以交叉熵作爲損失函數, 就能使我們網絡輸出的策略結果,往更趨近於這30%較優策略的方向優化——因爲網絡訓練會降低損失值, 而和標籤策略的交叉熵越小,代表和標籤策略越接近, 這就可以理解爲 從過往成功的經驗中學習。
- 隨着不斷的迭代訓練,網絡一次次地向頂尖30%的策略學習, 這也會使得下一次產生樣本集合時, 所有100%的經驗的平均水平都提升了(每條經驗都是基於優化的Agent產生的),也就是說, 挑出的30%的經驗也在逐步越來越好(因爲平均水平和上限都提升了)。經過足夠的訓練, 網絡就逐漸往最好的性能收斂了。
總結
這一章,講述了交叉熵法在強化學習中的應用。 在我看來, 其實就是以優秀的經驗策略爲標籤, 讓網絡逐步優化。 接下來的章節中, 我們會介紹更多,更復雜也更強大的強化學習方法。