攜程基于LSTM的廣告庫存預估算法
作者簡介
Paul,攜程高級研發經理,關注廣告投放技術架構、大數據、人工智能等領域;
Xunling,攜程資深后端開發工程師,關注廣告服務、性能優化,對AI技術有濃厚興趣。
一、背景
近年來,隨著互聯網的發展,在線廣告營銷成為一種非常重要的商業模式。出于廣告流量商業化售賣和日常業務投放精細化運營的目的,需要對廣告流量進行更精準的預估,從而更精細的進行廣告庫存管理。
因此,攜程廣告縱橫平臺實踐了LSTM(Long Short-Term Memory,長短時記憶網絡)模型結合Embedding的廣告庫存預估深度學習算法,在節省訓練資源的同時,構建更具泛化性的模型,支持根據不同地域分布、人口學屬性標簽等進行庫存變動預估,并能體現出節假日特征對庫存波動的影響,從而對廣告庫存進行更為精確的預估。
二、問題和挑戰
廣告庫存預估實現有諸多挑戰:
- 現實中對廣告庫存的影響因素非常多,比如節假日、周末、自然災害等等;
- 廣告庫存樣本周期以天為單位,訓練樣本很少;
- 需要支持不同維度交叉進行廣告預估,例如同時選擇地域、年齡、性別、會員等級等定向交叉,預估未來N天的庫存情況;
- 廣告庫存日新月異,隨時間推移,不斷有新的庫存樣本生成,模型更新頻率要求較高。
這些因素讓廣告庫存預估工作從資源和效率等角度都帶來壓力。
三、算法簡述
RNN(Recurrent Neural Network,循環神經網絡)是一種特殊的神經網絡,被廣泛應用于序列數據的建模和預測,如自然語言處理、語音識別、時間序列預測等領域。RNN對時間序列的數據有著強大的提取能力,也被稱作記憶能力。相對于傳統的前饋神經網絡,RNN具有循環連接,可以將前一時刻的輸出作為當前時刻的輸入,從而使得網絡可以處理任意長度的序列數據,捕捉序列數據中的長期依賴關系。
圖 3-1
LSTM(Long Short-Term Memory,長短時記憶網絡)是一種特殊的 RNN,它通過引入門控機制和記憶單元等結構來增強 RNN 的記憶能力和表達能力。
LSTM 的基本結構包括一個循環單元和三個門控單元:輸入門、遺忘門和輸出門。循環單元接受當前時刻的輸入和前一時刻的輸出作為輸入,并輸出當前時刻的輸出和傳遞到下一時刻的狀態。輸入門控制當前時刻的輸入對狀態的影響,遺忘門控制前一時刻的狀態對當前狀態的影響,輸出門控制當前狀態對輸出的影響。記憶單元則用于存儲和傳遞長期的信息。基于此,使得LSTM具有長期的記憶能力,并且具有防止梯度消失的特點,故我們選擇LSTM作為庫存預估模型的訓練基礎。
圖 3-2
Embedding是一種特征處理方式,它可以將我們的某個特征數據向量化,可以將離散的整數序列映射為連續的實向量,它通常用于自然語言處理中的詞嵌入(Word Embedding)任務,用于將每個單詞映射為一個實向量,從而使得單詞之間的語義關系可以在向量空間中進行計算。
在本文中,我們是使用它進行實體嵌入(Entity Embedding) 任務,也就是將我們的特征實體進行向量化,通過大量的數據訓練,得到實體向量之前的細微關系,從而幫助模型更清晰的識別不同實體對廣告庫存的影響,進而提高模型的泛化能力。
四、數據處理
4.1 特征定義
基礎特征為下圖所示:
圖 4-1
由于節假日的特殊性,我們又將其細分為以下維度,保證模型抓住節前和節后購票效應。
圖 4-2
4.2 歸一化
在深度學習中,對數據進行歸一化主要目的是將數據縮放到一個合適的范圍內,便于神經網絡的訓練和優化。對數據進行歸一化可以改善梯度下降、加速收斂、提高模型的泛化能力。
我們選擇Z-score標準化方法對數據進行處理,Z-score 標準化(也稱為標準差標準化)是一種常見的數據歸一化方法,其基本思想是將原始數據轉換為標準正態分布,即均值為 0,標準差為 1。公式如下圖所示,是一個實測值與平均數的差再除以標準差的過程。
4.3 數據聚類
我們在第二章節問題和挑戰中聊到過,廣告庫存預估需要對多個維度支持預估功能,不同維度交叉組合后,庫存量千差萬別,導致我們的樣本數據中標簽值min和max差距非常大。
如圖4-3所示,本圖Y軸表示某組合維度標簽值的庫存數據量級,庫存數據量較小的組合維度占有很大一部分,而庫存數據量較大的組合維度相對較少,僅憑數據歸一化手段,數據壓縮的效果非常不明顯,如果強行一起訓練會互相影響,導致模型難以擬合,訓練中的損失函數如圖4-4所示,訓練時模型無法正常擬合,損失值不能夠正常下降,訓練出的模型效果可想而知,無法支持正常的預估功能。
圖 4-3
圖 4-4
所以,我們以庫存樣本的標簽值為依據,使用K-means算法對不同維度的庫存進行數據聚類,將近似類別的維度放在一起進行訓練,提高模型的擬合速度和泛化能力。
我們選擇“肘部法則”來確認本數據集的最佳分類數,也就是K-means的簇數K的具體值。隨著K的增加,聚類效果不斷提高,但是當K到達某個值的時候,聚類效果的提高變得越來越慢,此時再增加K已經不能明顯提高聚類效果,因此這個點就是最佳的K值。具體過程如下:
- 對數據集進行K-Means聚類,分別嘗試不同的簇數K。
- 對于每個簇數K,計算該簇數下的SSE(Sum of Squared Errors),即每個數據點到其所屬簇中心的距離的平方和,保存SSE 到數組。
- 使用numpy庫中的diff函數計算相鄰兩點的差異并除以最大值,得到變化率。然后,我們使用numpy庫中的argmax函數找到最大斜率的位置,加上n即為肘部點的位置。
- n的取值要考慮自身數據集,一般來說,K的值至少要為2,不然沒有分類意義,而且argmax計算出來的是數組的索引位置,小于真實位置1個點位,綜合考慮,我們將n設置為2。
# 計算不同聚類個數下的聚類誤差
n = 2
sse = []
for k in range(1, 10):
kmeans = KMeans(n_clusters=k, random_state=0).fit(data)
sse.append(kmeans.inertia_)
# 自動尋找肘部點
diff = np.diff(sse)
diff_r = diff[1:] / diff[:-1]
nice_k = np.argmin(diff_r) + n
#繪制SSE隨簇數變化的曲線(如圖4-5),觀察曲線的形狀。
plt.plot(range(1, 10), sse, 'bx-')
plt.xlabel('Number of Clusters')
plt.ylabel('SSE')
plt.title('Elbow Method for Optimal k')
plt.show()
圖 4-5
經過K值選擇后,我們將樣本數據集進行聚類得到如下圖4-6所示結果,Y軸為某組合維度的標簽值,X軸為某組合維度出現在樣本數據集數組中的索引位置。其中最右側最高的點,明顯是沒有任何維度交叉的全部廣告庫存,其他位置都是經過維度交叉后的庫存,最終我們將其分成了3份進行訓練。
圖 4-6
經過聚類處理后,模型訓練時損失函數輸出的損失值如下圖所示,出現了正常的損失值下降的過程,并逐漸趨于穩定。
圖 4-7
五、網絡結構定義與訓練
5.1 網絡結構定義
1)網絡模型結構圖
圖 5-1
2)網絡模型定義
我們將各維度組裝特征數據和標簽數據的數據經過Embedding 實體嵌入,輸入到LSTM學習并提取歷史庫存的時間序列特性,最終經過激活函數和全連接層輸出與標簽值進行損失值計算,不斷擬合,直到損失值接近穩定或到達最大訓練批次。
import torch
from torch import nn
class LSTM(nn.Module):
def __init__(self, emb_dims, out_dim, hidden_dim, mid_layers):
super(LSTM, self).__init__()
self.emb_layers = nn.ModuleList([nn.Embedding(x, y) for x, y in emb_dims])
self.rnn = nn.LSTM([sum(x) for x in zip(*emb_dims)][1] + 1, hidden_dim, mid_layers, batch_first=False)
self.reg = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, out_dim),
)
def forward(self, cat_data):
var_x = []
for cat in cat_data:
x = self.emb_layer(cat)
var_x.append(x)
stack = torch.stack(var_x)
y = self.rnn(stack)
return y
5.2 訓練
1)組織數據
我們獲取任意維度歷史N天數據作為訓練集,如下圖所示,我們設定滑動窗口時間為七天,定義廣告請求數為標簽數據。使用DataLoader組織數據,shuffle設為True保證樣本是隨機獲取的,僅需保證滑動窗口內部有序即可,這樣訓練可以提高模型的泛化能力。
loader = Data.DataLoader(dataset=set, batch_size=size, shuffle=True, num_workers=numWorkers)
需要注意的是,LSTM要求的入參順序為(seq_length, batch_size, input_size)即:序列長度、批次量數、特征維度,然而DataLoade組織出的數據第一個維度是batch_size,因此設置batch_first = true,即可解決問題,繼續正常的使用LSTM模型訓練數據。但是這會拉低訓練效率,原因是NVIDIA cuDNN 的RNN API 設置的batch_size參數就是在第二個位置,這樣設置的原因如下:
舉個例子1,假設輸入序列的長度seq_length等于3,batch_size等于2,假設一個batch的數據是[[“A”, “B”, “C”], [“D”, “E”, “F”]],如圖5-2所示。由于RNN是序列模型,只有T1時刻計算完成,才能進入T2時刻,而"batch"就體現在每個時刻Ti的計算過程中,圖1中T1時刻將[“A”, “D”]作為當前時刻的batch數據,T2時刻將[“B”, “E”]作為當前時刻的batch數據,可想而知,“A"與"D"在內存中相鄰比"A"與"B"相鄰更合理,這樣取數據時才更高效。
而不論Tensor的維度是多少,在內存中都以一維數組的形式存儲,batch first意味著Tensor在內存中存儲時,先存儲第一個sequence,再存儲第二個… 而如果是seq_length first,模型的輸入在內存中,先存儲所有sequence的第一個元素,然后是第二個元素… 兩種區別如圖5-3所示,seq_length first意味著不同sequence中同一個時刻對應的輸入元素(比如"A”, “D” )在內存中是毗鄰的,這樣可以快速讀取數據。
圖 5-2
圖 5-3
因此,使用batch_first = true并不是好的選擇,可以選擇permute函數解決此問題:
batch_x = batch_x.permute((1, 0, 2))
使用此方法改變數組的shape,以適應原始LSTM模型的入參要求,在不影響訓練速度的原則下解決參數順序問題。
2)訓練步驟
輸入: 任意維度前七天組成的窗口數據,包括所有的特征數據 + 標簽數據
輸出: 第八天的標簽數據
第N步
圖 5-4
第N+1步:
圖 5-5
3)離線與在線增量訓練
第二章節問題和挑戰中提到,由于廣告樣本數據每天都在產生,而且影響其因素非常多,所以歷史模型僅憑歷史庫存學到的時間序列特性預估出的未來庫存,除了可以帶有假期周末等時間特征的正向影響外,很難緊跟近期的庫存數量級。
所以,這要求我們要高頻次的更新廣告庫存預估模型,但這也加大了維護成本,如果僅采用離線訓練的方式,每次都拉取全量歷史庫存樣本數據訓練出新的優質模型,無疑是不可取的,所以我們選擇一次離線訓練+N次在線增量訓練的方式解決此問題。
a. 離線訓練
首先使用Spark Sql組織廣告庫存Hive表中現存所有的歷史數據導入CSV文件,使用GPU訓練此樣本數據,由于是各維度交叉訓練,一般在這個階段數據量在億級別,如果資源有限,離線訓練可以拆分廣告位分別進行。經過多次訓練選擇優質的離線模型文件,作為基礎預估模型,可以用于短期的庫存預估工作。此時我們需要將優質模型以字節的形式存儲至Redis,便于后面的預估服務和在線訓練腳本使用。
此時需要注意的是,我們需要將模型和優化器打包為一個字典,以二進制的方式讀取模型文件并保存在Redis中。
#保存模型:
#net:模型
#optimizer:優化器
state = {'net': net.state_dict(), 'optimizer': optimizer.state_dict()}
torch.save(state, netPath)
#存儲至Redis:
def saveModel(modelName, netPath):
with open(modelPath, "rb") as f:
pth_model = f.read()
result = redis.set(modelName, pth_model)
b. 在線訓練:經過離線訓練后,我們得到了一個優質的預估模型,此時我們需要開發兩個腳本:
① PySpark 拉取hive表中昨天的庫存樣本存儲在GPU機器實時文件目錄。
② 讀取實時文件目錄,獲取近八個文件,前七個作為一個滑動窗口數據,第八個作為標簽值組成單次訓練樣本,在Redis中獲取并加載預存的模型和優化器,進行一次在線訓練,再將其覆蓋保存至redis中。
# 在Redis中加載模型
def readModel(modelName, path):
pthByte = redis.get(modelName)
with open(path, 'wb') as f:
f.write(pthByte)
return torch.load(path, map_locatinotallow=lambda storage, loc: storage)
# 加載模型和優化器狀態,用于訓練
# 讀取字典
model = readModel(modelName, path)
# 加載模型
net = LSTM(emb_dims, out_dim, mid_dim, mid_layers).to(device)
net.load_state_dict(model['net'])
# 加載優化器
optimizer = torch.optim.Adam(net.parameters(), lr=1e-2)
optimizer.load_state_dict(model['optimizer'])
最終,我們將以上兩個腳本配置在攜程大數據定時任務,設置每天0點后執行,即可實現不斷的在線訓練,保證模型在優質狀態的基礎上,使用最新的庫存數據對模型進行微調修正,使用Redis保存模型可以快速存取模型文件流,保證在線訓練結束后,預估服務可以立刻反應,獲取到新的模型用于預估工作,無需對預估服務做任何改動。根據以上方案可以不斷維持模型提供精準的預估能力。
4)早停機制
訓練過程中,為防止過擬合,也為提高效率,當loss 不再明顯變化時終止訓練,輸出模型,每次訓練不斷記錄并更新loss最小值,當loss 連續N次小于x時,視為可觸發早停機制。
class EarlyStopping:
def __call__(self, val_loss):
score = val_loss
if self.best_score is None:
self.best_score = score
elif score > self.best_score:
if score <= x:
self.counter += 1
if self.counter >= self.patience:
self.early_stop = True
else:
self.best_score = score
self.counter = 0
下圖為早停效果圖,設置的訓練批次為150批, 此模型訓練至115批截至,可以觀察到loss已經非常低, 已經沒有下降空間,符合早停預期。
圖 5-6
5)指標驗證
經過模型的構建和訓練后,我們需要對模型做出評估,以決定模型是否可以推到線上使用,因為基礎數據量較小,所以本次模型未設置驗證集,訓練集和測試集 9 :1。評估指標采用WMAPE(加權偏差率):
TorchMetrics 類庫對80 多個PyTorch 指標做了實現.
這里我們直接調用api : WeightedMeanAbsolutePercentageError
import torchmetrics
error = torchmetrics.WeightedMeanAbsolutePercentageError()
# 調用模型得到預估值
predict = net(valid_x)
# 計算偏差
error = error(predict, valid_y)
error_list.append(error.item())
指標驗證可以更科學的觀察到模型對所有維度的測試集的預估能力, 可以更科學的驗證模型是否符合我們的預期,指導我們進行調參,控制唯一變量并觀察指標變化。某廣告位置調參驗證示例如下, 比較不同批次訓練模型后指標比較, 明顯表現出200批次的模型指標更好。
Group Model 1: 150批次 測試集ERROR: 0.09729844331741333 200批次 測試集ERROR: 0.08846557699143887
Group Model 2: 150批次 測試集ERROR: 0.10297603532671928 200批次 測試集ERROR: 0.09801376238465309
Group Model 3: 150批次 測試集ERROR: 0.0748182088136673 200批次 測試集ERROR: 0.07573202252388
6)內存釋放
在大數據量的深度神經網絡訓練過程中,內存是我們的一大瓶頸,在組織數據過程中,python的List由于不能確定其存儲內容的數據類型,不支持快速切割結構,我們經常需要將List轉換為數組再做處理,此時需要copy一份數據,那么List就成了閑置內存,這種類似的閑置內存我們可以操作主動釋放,以快速釋放無用內存,讓我們的機器可以在有限的內存空間中支持更大數據量的訓練。
不同的數據類型的釋放方式如下(假設變量名稱為 X ):
# 1、List
del X[:]
# 2、數組
X = []
# 3、字典
X.clear()
# 4、Tensor(此方式需要調用gc.collect()觸發GC才可以真正清除內存)
del X
# 5、清理CUDA顯存
import torch
torch.cuda.empty_cache()
下圖5-7為訓練過程中主動釋放內存操作后,得到的內存釋放監控反饋。
圖 5-7
5.3 預估驗證
1)歷史預估
整體模型訓練完成,需要對模型做預估測試,我們選擇2022年的10月作為測試樣本,10月1日國慶節有特殊的節假日特性,可以考量模型預估能力。以攜程啟動頁廣告位為例,圖 5-8 分別展示了全量庫存、某特征定向和兩種會員等級定向不同維度的預估效果,都成功表達出10月1日的節假日上漲特性,與實際值的走勢相似,證明我們訓練出的模型具有預估未來廣告庫存的能力。
圖 5-8
2)未來預估
以下是對未來庫存的預估效果示例,從未來預估示例中,可以直觀看出在4月26-29左右有流量的高峰,經過5月1日后高峰下跌,回歸正常值,符五一的節假日效應。
圖5-9 定向交叉為:某年齡段、某地
圖 5-9
圖5-10 定向交叉為:某年齡段、某類會員
圖 5-10
圖5-11 定向交叉為:某地、某類會員
圖 5-11
六、模型部署
6.1 python 服務部署
使用python flask組件開發部署restful接口,支持預估服務。
from flask import Flask
from flask import request, jsonify
@app.route('/model/ad/forecast', methods=['post'])
def forecast():
# 在clickhouse中實時獲取前七天的標簽值,傳入接口
prepareData = request.json.get('prepareData')
return forecast(prepareData)
從Redis中讀取對應維度的模型文件流,加載到內存生成模型對象,將前7天的標簽值和對應其他特征組織好輸入模型得到預估結果。
# 在Redis中加載模型
def readModel(modelName, path):
pthByte = redis.get(modelName)
with open(path, 'wb') as f:
f.write(pthByte)
return torch.load(path, map_locatinotallow=lambda storage, loc: storage)
七、總結
通過使用LSTM算法結合Embedding特征處理方式訓練得到優質的廣告庫存預估模型,能夠捕捉庫存時間樣本數據中的長期依賴關系,表達節假日、周末等特殊時間點對庫存變化的影響,更具泛化能力,支持地域、年齡、會員等級、性別等定向交叉預估,對比早期使用全量庫存乘以定向比例的預估庫存方式,此方式更能體現出不同維度之間的相互影響關系,更貼近事實,預估準確度高。
使用離線與在線增量訓練相結合的訓練方式,使模型更具活力,每天在優選出的廣告庫存模型基礎上進行微調,可以不斷維持模型提供精準的預估能力。后續我們會繼續優化和探索更優秀的模型和特征處理方式,優化方向考慮在Embedding層 與 LSTM 層之間增加CNN 卷積層,提高模型的特征提取能力,進而提高廣告庫存模型的泛化能力、預估精準度。
八、參考文獻
- Pytorch lstm中batch_first 參數理解使用