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


    一.前言

    参考论文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}=

    {PMI(i,j)i,j are words, PMI(i,j)>0TFIDFiji is document, j is word 1i=j0 otherwise " role="presentation">{PMI(i,j)i,j are words, PMI(i,j)>0TFIDFiji is document, j is word 1i=j0 otherwise 
    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
    PMI(i,j)=logp(i,j)p(i)p(j)p(i,j)=#W(i,j)#Wp(i)=#W(i)#W" role="presentation">PMI(i,j)=logp(i,j)p(i)p(j)p(i,j)=#W(i,j)#Wp(i)=#W(i)#W
    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是正则化后的邻接矩阵,优化的损失函数为交叉熵。该解决框架可视化如所示:

    text_gcn

    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%作为验证集,此举主要是用来筛选模型的。模型通过训练集进行参数更新,然后通过验证集来筛选模型,最后在测试集上进行测评。

    实验的超级参数配置如下表所示:

    参数
    epoch100
    lr0.01
    drop_prob0.5
    hidden_feats32

    评估的指标为准确率

    训练源码如下所示:

    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的变化情况:

    结果

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

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

    4.3 讨论

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

    五.结语

    项目完整源码text_gcn

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

  • 相关阅读:
    施耐德NOE77101后门漏洞分析
    怎样去提高效率,五步优化法
    c#语法详解
    课件演示用什么软件?万兴录演:多种录屏方式任你选
    系统运维工程师
    mongodb.使用自带命令工具导出导入数据
    喜提JDK的BUG一枚!多线程的情况下请谨慎使用这个类的stream遍历。
    路径规划 | 图解Theta*算法(附ROS C++/Python/Matlab仿真)
    2.9 PE结构:重建导入表结构
    VSCode使用简介
  • 原文地址:https://blog.csdn.net/qq_42103091/article/details/126962168