基于图卷积神经网络的微博疫情情感分析

一.前言

参考论文: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)TFIDFij10i,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向量。

三.模型实现

3.1 模型理论

对于图神经网络模型,本项目使用的是最经典的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~=D21AD21是正则化后的邻接矩阵,优化的损失函数为交叉熵。该解决框架可视化如所示:

基于图卷积神经网络的微博疫情情感分析_第1张图片

3.2 环境介绍

本实验的实验环境如下所示:

cuda 11.3
python 3.7.13
Pytorch 1.10.1
PyG 2.0.4
matplotlib 3.3.4

3.3 模型源码

本实验采用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

四.实验与分析

4.1 实验配置

前面提到过,本实验提取了疫情微博情感数据集中训练集的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来获取单位阵,但实际用稀疏矩阵的形式来表达更节省计算资源(官方源码也在这样干的),本实验这样做是为了图省事。

4.2 结果与分析

下面的某次训练过程中训练集和验证集上准确率随epoch的变化情况:

基于图卷积神经网络的微博疫情情感分析_第2张图片

分析:从训练集和验证集上准确率的变化曲线来看,最后模型有趋于过拟合的倾向。

限于时间原因,实验并没有进行细致的调参。最终在测试集上的准确率为0.7左右,似乎比之前利用循环神经网络的解决方案要好点。

4.3 讨论

Text GCN这套解决方案具有一定的局限性,在构图的过程中,需要获取整个数据集中所有的文档(文本),包括训练集、验证集和测试集。虽然在梯度更新的过程中,仅使用了训练集来计算损失函数。采用这种方式是无法直接对一个新文档进行预测的。(作者好像在Github也给出了Inductive版本的text_gcn,感兴趣的可以自行研究)

五.结语

从上述实验过程来看,采用图卷积神经网络进行文本分类也是一个不错的选择,当然该方案只是一个引子,留待后人继续探索。以上便是本文的全部内容,要是觉得不错就点个赞或关注一下博主吧。若是有啥问题,也敬请批评指正。

你可能感兴趣的:(深度学习实战,深度学习,NLP,GNN,文本分类)