参考论文: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}=
其中单词对
(
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
#
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,感兴趣的可以自行研究)
项目完整源码:text_gcn
从上述实验过程来看,采用图卷积神经网络进行文本分类也是一个不错的选择,当然该方案只是一个引子,留待后人继续探索。以上便是本文的全部内容,要是觉得不错就点个赞或关注一下博主吧。若是有啥问题,也敬请批评指正。