参考论文:Graph Convolutional Networks for Text Classification
官方Github源码:text_gcn
关于微博疫情情感分析,博主之前有过给过一套基于循环神经网络的解决方案——疫情微博内容情感分析。今天我们换一个视角,利用图卷积神经网络(Graph Convolutional Network, GCN)来解决该问题。关于数据集的介绍和预处理部分,本实验基本沿用之前的设置,想要了解的可以去看看博主的那篇博客。唯一不同之处在从训练集中划分出20%作为验证集。话不多说,直接上干货!!!
要使用图神经网络,那么核心的问题当然是如何构图。在Text GCN中,节点包含文档(也可以是其它小段文本)和单词两种。单词指所有文档中所包含的不同词汇(若词汇过多则可以过滤低频词)。边也包含两种:单词-单词、单词-文档。
单词-单词间边的构建:采用PMI
作为依据,正的PMI
的值表示语料中单词间搞的语义相关性,而负值则表示低或没有语义相关性。
单词-文本间边的构建:采用TF-IDF
作为依据,TF-IDF
指词频逆文档频率,词频指单词在文档中出现的次数,逆文档频率是一个词语普遍重要性的度量,由语料库文档总数与包含某个词的文档数的比值再取对数计算而来。
基于此,下面给出其正式定义:
A i j = { PMI ( i , j ) i , j are words, PMI ( i , j ) > 0 T F − I D F i j i is document, j is word 1 i = j 0 otherwise A_{i j}= \begin{cases}\operatorname{PMI}(i, j) & i, j \text { are words, } \operatorname{PMI}(i, j)>0 \\ \mathrm{TF}_{-\mathrm{IDF}_{i j}} & i \text { is document, } j \text { is word } \\ 1 & i=j \\ 0 & \text { otherwise }\end{cases} Aij=⎩ ⎨ ⎧PMI(i,j)TF−IDFij10i,j are words, PMI(i,j)>0i is document, j is word i=j otherwise
其中单词对 ( i , j ) (i,j) (i,j)间的PMI
计算公式如下:
PMI ( i , j ) = log p ( i , j ) p ( i ) p ( j ) p ( i , j ) = # W ( i , j ) # W p ( i ) = # W ( i ) # W \begin{aligned} \operatorname{PMI}(i, j) &=\log \frac{p(i, j)}{p(i) p(j)} \\ p(i, j) &=\frac{\# W(i, j)}{\# W} \\ p(i) &=\frac{\# W(i)}{\# W} \end{aligned} PMI(i,j)p(i,j)p(i)=logp(i)p(j)p(i,j)=#W#W(i,j)=#W#W(i)
# W ( i ) \# W(i) #W(i)表示语料中包含单词 i i i的滑动窗口数, # W ( i , j ) \# W(i, j) #W(i,j)表示预料中同时包含单词 i i i和单词 j j j的滑动窗口数, # W \#W #W是语料滑动窗口的总数。
为了获取全局的单词共现信息,对所有文档(文本)进行了固定大小的滑窗。
对于构图部分,本实验直接复用了Github上某大佬的部分源码,下面直接贴出给出详细注释的核心源码(我对比了一下官方开源的源码,发现大佬的代码也是从官方源码来的,但是封装成了函数):
def build_edges(doc_list, word_id_map, vocab, word_doc_freq, window_size=20):
"""
doc_list: 文档列表,每个元素为一个文档(文本)
word_id_map: 单词到id的映射字典
vocab: 词表
word_doc_freq: 单词出现的频率字典
"""
# 对所有文档进行滑窗
windows = []
for words in doc_list:
doc_length = len(words)
if doc_length <= window_size:
windows.append(words)
else:
for i in range(doc_length - window_size + 1):
window = words[i:i + window_size]
windows.append(window)
# 获取#W(i)
word_window_freq = defaultdict(int)
for window in windows:
appeared = set()
for word in window:
if word not in appeared:
word_window_freq[word] += 1
appeared.add(word)
# 获取#W(i,j)
word_pair_count = defaultdict(int)
for window in tqdm(windows):
for i in range(1, len(window)):
for j in range(i):
word_i = window[i]
word_j = window[j]
word_i_id = word_id_map[word_i]
word_j_id = word_id_map[word_j]
if word_i_id == word_j_id:
continue
word_pair_count[(word_i_id, word_j_id)] += 1
word_pair_count[(word_j_id, word_i_id)] += 1
row = []
col = []
weight = []
# 计算PMI
num_docs = len(doc_list)
# 获取#W
num_window = len(windows)
for word_id_pair, count in tqdm(word_pair_count.items()):
i, j = word_id_pair[0], word_id_pair[1]
word_freq_i = word_window_freq[vocab[i]]
word_freq_j = word_window_freq[vocab[j]]
# log(p(i,j) / (p(i) * p(j)))
pmi = log(
(1.0 * count / num_window) / (1.0 * word_freq_i * word_freq_j /
(num_window * num_window)))
if pmi <= 0:
continue
row.append(num_docs + i)
col.append(num_docs + j)
weight.append(pmi)
# 获取词频
doc_word_freq = defaultdict(int)
for i, words in enumerate(doc_list):
for word in words:
word_id = word_id_map[word]
doc_word_str = (i, word_id)
doc_word_freq[doc_word_str] += 1
# 计算TF-IDF
for i, words in enumerate(doc_list):
doc_word_set = set()
for word in words:
if word in doc_word_set:
continue
word_id = word_id_map[word]
freq = doc_word_freq[(i, word_id)]
row.append(i)
col.append(num_docs + word_id)
idf = log(1.0 * num_docs / word_doc_freq[vocab[word_id]])
weight.append(freq * idf)
doc_word_set.add(word)
# 构建稀疏的邻接矩阵
number_nodes = num_docs + len(vocab)
adj_mat = sp.csr_matrix((weight, (row, col)),
shape=(number_nodes, number_nodes))
adj = adj_mat + adj_mat.T.multiply(adj_mat.T > adj_mat) - adj_mat.multiply(
adj_mat.T > adj_mat)
return adj
对于构建图,节点特征为单位阵,即每个节点的特征为一个独一无二的one-hot向量。
对于图神经网络模型,本项目使用的是最经典的GCN,其数学形式如下所示:
Z = softmax ( A ~ ReLU ( A ~ X W 0 ) W 1 ) Z=\operatorname{softmax}(\tilde{A} \operatorname{ReLU}(\tilde{A} X W_{0}) W_{1}) Z=softmax(A~ReLU(A~XW0)W1)
其中 A ~ = D − 1 2 A D − 1 2 \tilde{A}=D^{-\frac{1}{2}} A D^{-\frac{1}{2}} A~=D−21AD−21是正则化后的邻接矩阵,优化的损失函数为交叉熵。该解决框架可视化如所示:
本实验的实验环境如下所示:
cuda 11.3
python 3.7.13
Pytorch 1.10.1
PyG 2.0.4
matplotlib 3.3.4
本实验采用PyG来实现2层的GCN模型,由于GCN的传播规可以用矩阵乘法来表示,因此可以不表达为消息传递的形式,而是直接采用稀疏矩阵乘法(借助SparseTensor)来实现,具体源码如下:
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GCNConv
class GCN(nn.Module):
def __init__(self, in_feats, hidden_feats, out_feats, drop_prob):
super().__init__()
self.drop_prob = drop_prob
self.gcn1 = GCNConv(in_feats, hidden_feats)
self.gcn2 = GCNConv(hidden_feats, out_feats)
def forward(self, x, adj_t):
"""
x: 节点特征
adj_t: 图的稀疏矩阵,SparseTensor格式
"""
x = F.relu(self.gcn1(x, adj_t))
x = F.dropout(x, self.drop_prob, training=self.training)
x = self.gcn2(x, adj_t)
return F.log_softmax(x, dim=1)
if __name__ == "__main__":
pass
前面提到过,本实验提取了疫情微博情感数据集中训练集的20%作为验证集,此举主要是用来筛选模型的。模型通过训练集进行参数更新,然后通过验证集来筛选模型,最后在测试集上进行测评。
实验的超级参数配置如下表所示:
参数 | 值 |
---|---|
epoch | 100 |
lr | 0.01 |
drop_prob | 0.5 |
hidden_feats | 32 |
评估的指标为准确率。
训练源码如下所示:
import torch.nn as nn
import torch.optim as optim
from model.gcn import GCN
from sklearn.metrics import accuracy_score
import torch
from copy import deepcopy
import matplotlib.pyplot as plt
def train(model, graph, optimizer, loss_fn):
model.train()
graph = graph.to(device)
features = torch.eye(graph.num_nodes).to(device)
logits = model(features, graph.adj_t)
loss = loss_fn(logits[graph.mask == 1], graph.y[graph.mask == 1])
train_acc = accuracy_score(
y_pred=logits[graph.mask == 1].argmax(dim=1).cpu(),
y_true=graph.y[graph.mask == 1].cpu())
optimizer.zero_grad()
loss.backward()
optimizer.step()
return loss, train_acc
def evalute(model, graph, mask='val'):
model.eval()
graph = graph.to(device)
features = torch.eye(graph.num_nodes).to(device)
logits = model(features, graph.adj_t)
mask_val = 2 if mask == 'val' else 3
y_pred = logits[graph.mask == mask_val].argmax(dim=1).cpu()
y_true = graph.y[graph.mask == mask_val].cpu()
acc = accuracy_score(y_pred=y_pred, y_true=y_true)
return acc
if __name__ == "__main__":
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
epochs = 100
lr = 0.01
drop_prob = 0.6
data_path = "weibo/data.pt"
data = torch.load(data_path)
in_feats = data.num_nodes
hidden_feats = 32
out_feats = 6
model = GCN(in_feats, hidden_feats, out_feats, drop_prob)
model = model.to(device)
loss_fn = nn.NLLLoss()
optimizer = optim.Adam(params=model.parameters(), lr=lr)
best_acc, best_model = 0, None
for i in range(epochs):
train_loss, train_acc = train(model, data, optimizer, loss_fn)
val_acc = evalute(model, data)
print("Epoch {}: train loss {:.6f} train acc {:.4f} val acc {:.4f} ".
format(i + 1, train_loss, train_acc, val_acc))
if best_acc < val_acc:
best_acc = val_acc
best_model = deepcopy(model)
test_acc = evalute(best_model, data, 'test')
print("test acc: {:.4f}".format(test_acc))
从上述代码中可以看出,对于节点特征,实验直接采用的是
torch.eye
来获取单位阵,但实际用稀疏矩阵的形式来表达更节省计算资源(官方源码也在这样干的),本实验这样做是为了图省事。
下面的某次训练过程中训练集和验证集上准确率随epoch的变化情况:
分析:从训练集和验证集上准确率的变化曲线来看,最后模型有趋于过拟合的倾向。
限于时间原因,实验并没有进行细致的调参。最终在测试集上的准确率为0.7左右,似乎比之前利用循环神经网络的解决方案要好点。
Text GCN这套解决方案具有一定的局限性,在构图的过程中,需要获取整个数据集中所有的文档(文本),包括训练集、验证集和测试集。虽然在梯度更新的过程中,仅使用了训练集来计算损失函数。采用这种方式是无法直接对一个新文档进行预测的。(作者好像在Github也给出了Inductive版本的text_gcn,感兴趣的可以自行研究)
从上述实验过程来看,采用图卷积神经网络进行文本分类也是一个不错的选择,当然该方案只是一个引子,留待后人继续探索。以上便是本文的全部内容,要是觉得不错就点个赞或关注一下博主吧。若是有啥问题,也敬请批评指正。