情感分析学习笔记(6)——PolarityRank算法python代码实现

本文紧接上一篇理论文章《情感分析学习笔记(5)——PolarityRank算法原理讲解》,本文是代码实现。
参考的是两篇论文(Fernández-Gavilanes et al., 2016; Cruz et al., 2011)以及PageRank算法(Page et al., 1998)
由于找了一圈没有找到相关的代码,所以是我参考pagerank的实现过程以及论文自己手撸的代码,所以不一定保证效率与正确率,而且我测试也只用了positive的一条语句进行测试,没有测试过negative的和混合的,所以如果有问题,属实正常。
本文与SO-PMI算法一样,不会公开任何数据集,但是我会留下数据集的获取方式。

对了宝贝儿们,卑微小李的公众号【野指针小李】已开通,期待与你一起探讨学术哟~摸摸大!

代码我已上传至github,链接在这里:https://github.com/Balding-Lee/polarityrank/tree/master,如有需要,请自行提取

1 准备工作

1.1 stanford coreNLP

我用的POS和NER的工具都是stanford coreNLP,这里不多阐述具体的安装配置方法,如有需要参考这篇文章

这里帮大家排个坑,最新版的stanford coreNLP的中文版jar包下载下来名字是这样的:
原始jar包
如果就这样直接导入的话,会报错,具体报错的理由也很简单,就是你这个jar文件名字有问题。反正这是我编码5年多以来遇到的最离谱的一个错。要把jar包名字改为这样,就不会有问题了:
改名字后的jar包
中间的年月日不重要,只需要是yyyy-MM-dd格式就行。

至于这个东西加载速度有点慢,我这里查了查相关资料,如果有需要的,参考这篇文章。不过我没用,也不知道效果怎么样。

1.2 数据集获取

数据集我一共使用了两个数据集,分别如下:

  1. 搜狗实验室的新闻数据,我使用的是迷你版的,链接如下:https://www.sogou.com/labs/resource/t.php
  2. 情感词汇表,我采用的清华大学整理的情感词汇表(带有情感极性的)。

1.3 POS标签

POS标签我是直接参考的stanford句法分析的标签,链接如下:https://blog.csdn.net/u011847043/article/details/79595225

这个POS标签我用来主要是提取出名词、动词、形容词,具体理由下面解释。

2 代码讲解

本代码没有用语料数据集,只用了一条语句进行测试,语句如下:

text = '阿龙在成都吃的炸鸡不仅香,而且价格便宜'

2.1 预处理

预处理环节我们需要处理的有以下2部分:

  1. 加载stanford coreNLP,并进行POS和NER;
  2. 清洗数据。

2.1.1 stanford coreNLP

def get_init_attr(text):
    """
    获得初始属性, 包括:
        1. 使用stanford coreNLP进行句法树分析, 得到的结果为str
        2. 使用stanford coreNLP进行POS, 得到的结果为list
        3. 使用stanford coreNLP进行NER, 得到的结果为list
    :param text: 需要进行初始处理的文本
    :return: 数据清洗后的结果, i.e. 清洗了命名实体后的所有名词、动词、形容词
    """
    print("开始加载stanford coreNLP")
    nlp = StanfordCoreNLP(r'D:\pycharm\workspace\polarityrank\stanford-corenlp-4.2.0', lang='zh')
    # words_with_tags = nlp.parse(text)  # 句法树分析
    print("开始pos")
    pos = nlp.pos_tag(text)  # POS
    print("pos结束")
    print("开始ner")
    ner = nlp.ner(text)  # NER
    print("ner结束")
    # draw_tree(words_with_tags)
    return clean_data(pos, ner)

虽然我吐槽了很多次这个stanford coreNLP加载速度极慢,但是不得不说一行代码出一串结果的感觉贼爽。

但是这一个代码是有问题的,主要出现在加载stanford coreNLP这里,如果你有多个数据,那么每次运行都会加载一次stanford coreNLP,我估计运行不了两三个数据你的java虚拟机就会崩盘,所以如果各位要跑大量数据集,一定要把这行代码改为静态加载的:

nlp = StanfordCoreNLP(r'D:\pycharm\workspace\polarityrank\stanford-corenlp-4.2.0', lang='zh')

将上面获得的POS结果绘制出来,结果如下:
句法树
至于为何需要NER,我们来看下面一步,清洗数据。

2.1.2 清洗数据

def clean_data(pos, ner):
    """
    清洗数据, 步骤共2步:
        1. 先对句子进行NER, 清洗掉所有命名实体, 因为命名实体不带有任何情感。
           由于不清楚stanford coreNLP里面的标签, 所以只提取标签为'o'的词语
        2. 提取出除了命名实体之外的所有名词、动词、形容词

    :param pos: pos后的结果(list)
    :param ner: ner后的结果(list)
    :return nodes: (list)数据清洗后的结果, i.e. 清洗了命名实体后的所有名词、动词、形容词
    """
    n_v_adj = pos_e.n_v_adj

    nodes = []  # 存储句法图的词语
    not_ne = []  # 存储非命名实体的词语
    for n in ner:
        if n[1] == 'O':
            not_ne.append(n[0])

    for p in pos:
        if p[0] in not_ne and p[1] in n_v_adj:
            nodes.append(p[0])

    return nodes

首先根据 Fernández-Gavilanes et al. 老哥们的理论,一个句子中可能携带情感的词有:名词,动词,形容词。但是名词中有可能会有命名实体,这些就能带来更高的维度以及更低的准确率,比如“阿龙”的标签为Person,而我们也知道“阿龙”是不带有任何情感的。

但是由于我不知道NER有哪些标签,所以就直接清洗掉“O”以外的所有标签,同时设置一个列表,用于记录所有的名词、动词、形容词的POS标签

n_v_adj = ['NN', 'NR', 'NT', 'MD', 'VV', 'JJ', 'JJR', 'JJS', 'VA']

清洗出来的词语有:

['吃', '炸鸡', '香', '价格', '便宜']

2.2 生成句法图

当有了句法树,并且提取出所有的名词、动词、形容词节点后,就可以开始生成句法图了。

2.2.1 句法图创建

还是Fernández-Gavilanes et al. 老哥们提出,句法图的创建方式为:

边的生成是依据节点之间的依赖关系,每个节点会与其所有的后代节点相连,但是为了使整棵树的左右都有边相连(因为情感传播不是针对于某棵子树),所以每个节点还会与自己的兄弟节点相连。作者考虑到系动词不带有任何情感极性,所以作者剔除了系动词,同时将系动词的孩子节点向上提一级。

反正这个我是敲代码敲不出来的,尤其是我一个连英语语法都不会的人,所以我就强行解释(狡辩),根据SO-PMI理论,只要共现的词语,那么必然有关联性,所以我直接把提取出来的节点构成了一个强连接图。

def create_network(nodes):
    """
    创建强连接图(每条边都是双向连接)
    :param nodes: 节点
    :return dg: 有向图
    """
    dg = nx.DiGraph()

    for i in range(len(nodes)):
        dg.add_nodes_from([(nodes[i], {'pos': i})])  # 创建节点

    # 创建双向边
    for i in nodes:
        for j in nodes:
            if i != j:
                dg.add_edge(i, j)

    # draw_network(dg)
    get_words_sentiment(dg, nodes)
    get_edges_weight(dg)

    return dg

这里给每个节点赋值一个位置信息 i i i主要是方便后面处理,构成的图如下:
强连接图

2.2.2 词语初始PR值确定

有了图后,我们需要对每个节点赋予初始的情感值 P R + , P R − PR^+,PR^- PR+,PR,赋值语句如下:

def get_words_sentiment(dg, nodes):
    """
    从清华大学情感词语数据集中获取节点的初始情感极性
    :param dg: 有向图, 用于更新节点的PR值
    :param nodes: 节点
    """
    sentiment_words = []  # 存储情感词典数据的列表
    node_attribute = {}  # 存储节点属性的字典
    with open('./data/sentiment_words/chinese_sentiment_words_with_polarity.txt', 'r') as f:
        # print(f.read())
        for line in f.readlines():
            sentiment_words.append(line.strip('\n').split('\t'))

    # 如果词语在情感词典中, 将情感值赋值给该词语, 否则情感值为0
    for node in nodes:
        for sentiment_word in sentiment_words:
            if node == sentiment_word[0]:
                if float(sentiment_word[1]) > 0:
                    # 词典中情感值 > 0, 则pr+为情感值, pr-为0
                    node_attribute[node] = [float(sentiment_word[1]), 0]
                    break
                elif float(sentiment_word[1]) < 0:
                    # 词典中情感值 > 0, 则pr+为0, pr-为情感值
                    node_attribute[node] = [0, float(sentiment_word[1])]
                    break
            # 词语不在情感词典中, 则pr+与pr-都为0
            node_attribute[node] = [0, 0]

    # 更新有向图节点
    for node in node_attribute.keys():
        dg.add_nodes_from([node], pr_plus=node_attribute[node][0], pr_minus=node_attribute[node][1])

我这里对情感值的判定条件是,如果 s > 0 s>0 s>0 s s s是每个词语的情感值),那么 P R + = s , P R − = 0 PR^+=s,PR^-=0 PR+=s,PR=0,反之亦然,如果 s = 0 s=0 s=0,那么 P R + = 0 , P R − = 0 PR^+=0,PR^-=0 PR+=0,PR=0

所获得的的图的PR值为:

{'pos': 0, 'pr_plus': 0, 'pr_minus': 0}
炸鸡 {'pos': 1, 'pr_plus': 0, 'pr_minus': 0}{'pos': 2, 'pr_plus': 1.1513888888888888, 'pr_minus': 0}
价格 {'pos': 3, 'pr_plus': 0, 'pr_minus': 0}
便宜 {'pos': 4, 'pr_plus': 1.0333333333333332, 'pr_minus': 0}

2.2.3 词语之间边权判定

边权我并没有按照 Cruz et al. 等老哥的方法,分配 w i j + , w i j − w_{ij}^+,w_{ij}^- wij+,wij(因为我代码写的是weight,所以我这里用 w w w表达权重,与上篇文章的 p p p等价)(主要是我不知道这群老哥怎么算出来的这两个值),我采用的是Fernández-Gavilanes et al. 老哥们的方法,每条边只有一个权重,即双向边来回的权重相同。

Fernández-Gavilanes et al. 老哥们没有在论文里面提出怎么算权重的,但是提到了一句记录两个词语共现的频率,然后我就懂了。

首先我获取搜狗新闻数据,这里没有用微博是考虑到如下两个原因:

  1. 微博十分稀疏(一条微博评论可能只有两三个词),这样会导致分母特别大,会造成干扰;
  2. 微博是非正式文本包含了许多的错别字、流行语、表情符号等,也会导致分母特别大,造成干扰。
def get_sogou_news():
    """
    获取搜狗新闻语料

    :return news: 新闻列表
    """
    news = []

    # 该文件是用gbk编码的, 但是文件中一些特殊字符超出了gbk编码范围, 所以采用gb18030,
    # 为了避免无法编码字符, 添加errors='ignore'忽略
    # 解决方案来源: https://blog.csdn.net/lqzdreamer/article/details/76549256
    with open('./data/corpus/news_tensite_xml.smarty.dat', encoding='gb18030', errors='ignore') as f:
        for line in f:
            if re.match('<content>', line):
                line = line.strip().lstrip('<content>').rstrip('</content>')
                if line:
                    news.append(line)

    return news

获得了新闻数据后,将两个词放入,计算共现频率。

def get_edges_weight(dg):
    """
    获得每条边的权重
    权重计算方法: n(word1, word2) / N
        其中: n(word1, word2)为word1和word2在所有语料中共现的次数; N为语料的条数
    :param dg: 有向图
    """
    news = get_sogou_news()
    nodes = list(dg.nodes())

    N = len(news)
    weights = []
    temp_list = []  # 双向边只用计算一次权重

    for node_1 in nodes:
        temp_list.append(node_1)
        # weight = 0
        for node_2 in nodes:
            if node_1 != node_2:
                if node_2 not in temp_list:
                    # 如果节点不同, 且未曾出现, 则计算在语料中产生的频率
                    # 判断是否出现过, 主要是因为双向图, 权重是相同的,
                    # 且如果不处理, 后续会重复遍历语料, 减少运行速度
                    count = 0
                    for new in news:
                        # 判断是否共现
                        if re.search(node_1, new) and re.search(node_2, new):
                            count += 1
                    weight = count / N
                else:
                    continue
            else:
                weight = 0
            weights.append([node_1, node_2, weight])

    # 虽然双向边的权重只用计算一次, 但是在更新图中的权重的时候要更新两条边
    for weight in weights:
        dg.add_edges_from([(weight[0], weight[1], {'weight': weight[2]})])
        dg.add_edges_from([(weight[1], weight[0], {'weight': weight[2]})])

由于是双向图,且来回权重相同,所以我判断节点是否曾经出现过,这样主要是为了少遍历一半的新闻数据,减少时间浪费。

我也设定一个节点(词)不能与自己有边。

虽然权重只用计算一次,但是给边权赋值的时候要赋值两次哈。

这里的代码和之前的一样有问题,不可能每次都加载一遍新闻数据,所以如果要跑多条( ≥ 2 \geq2 2)数据,这行代码也要改成静态加载:

news = get_sogou_news()

得到的边权为:

吃 炸鸡 {'weight': 0.006024096385542169}
吃 香 {'weight': 0.0}
吃 价格 {'weight': 0.006024096385542169}
吃 便宜 {'weight': 0.0}
吃 吃 {'weight': 0}
炸鸡 吃 {'weight': 0.006024096385542169}
炸鸡 香 {'weight': 0.0}
炸鸡 价格 {'weight': 0.0}
炸鸡 便宜 {'weight': 0.0}
炸鸡 炸鸡 {'weight': 0}
香 吃 {'weight': 0.0}
香 炸鸡 {'weight': 0.0}
香 价格 {'weight': 0.006024096385542169}
香 便宜 {'weight': 0.0}
香 香 {'weight': 0}
价格 吃 {'weight': 0.006024096385542169}
价格 炸鸡 {'weight': 0.0}
价格 香 {'weight': 0.006024096385542169}
价格 便宜 {'weight': 0.006024096385542169}
价格 价格 {'weight': 0}
便宜 吃 {'weight': 0.0}
便宜 炸鸡 {'weight': 0.0}
便宜 香 {'weight': 0.0}
便宜 价格 {'weight': 0.006024096385542169}
便宜 便宜 {'weight': 0}

这里全部为0.006024096385542169,并非是出错了,而是在数据集中所有的内容只出现过一次,导致了值都相同,如下图所示:
权重说明

2.3 生成矩阵

矩阵生成就对应了上篇文章的所有公式推导过程。

2.3.1 生成PR向量

def create_pr_vec(dg):
    """
    创建PR向量
    :param dg: 有向图
    :return pr_vec: PR+与PR-组合在一起的向量, ndarray
    :return pr_plus: PR+的向量, 用于求0范数, ndarray
    :return pr_minus: PR-的向量, 用于求0范数, ndarray
    """
    pr_plus = []
    pr_minus = []

    nodes = dg.nodes()
    for node in nodes:
        pr_plus.append(dg.nodes[node]['pr_plus'])
        pr_minus.append(dg.nodes[node]['pr_minus'])

    temp_list = pr_plus + pr_minus  # 合并两个列表

    # 将list转为ndarray对象
    pr_plus = np.array(pr_plus)
    pr_minus = np.array(pr_minus)
    pr_vec = np.array(temp_list)

    return pr_vec, pr_plus, pr_minus

这里多传回了2个列表,pr_plus和pr_minus,即 P R + PR^+ PR+ P R − PR^- PR分别组成的列表,主要是方便后面计算 e e e

pr_vec结果如下:

array([0.        , 0.        , 1.15138889, 0.        , 1.03333333,
       0.        , 0.        , 0.        , 0.        , 0.        ])

2.3.2 生成e向量

这里考虑到向量 e ⃗ \vec e e 的计算方法是: ∑ i = 1 n e i + = ∑ i = 1 n e i − = n \sum_{i=1}^n e_i^+ = \sum_{i=1}^n e_i^- = n i=1nei+=i=1nei=n,所以我在计算这个的时候,采用了归一化的方法处理:

def calc_e(sum_pr, pr, n):
    """
    计算e的值

    计算方法:
        1. 获得PR+与PR-的向量
        2. 判断向量的第0范数, 如果0范数为0, 则这n个e为0; 如果0范数不为0, 则归一化不为0的数,
           再乘以n
           归一化方法: e_i = \frac{pr_i}{\sum_{j=1}^n pr_j}
        3. 将所获得的结果组成向量

    :param sum_pr: 传入的PR向量的总和
    :param pr: 传入的PR向量
    :param n: PR值的个数
    :return e: e向量
    """
    if np.linalg.norm(pr, ord=0):
        e = []
        for i in pr:
            e.append((i / sum_pr) * n)
        e = np.array(e)
    else:
        e = np.zeros(n)

    return e


def create_e_vec(pr_plus, pr_minus):
    """
    创建e_i^+和e_i^-构成的向量
    通过调用calc_e(sum_pr, pr)计算

    e的要求: e(e+和e-组合的向量)的第一范数为2n

    :param pr_plus: PR+构成的向量
    :param pr_minus: PR-构成的向量
    :return:
    """
    n = pr_plus.shape[0]  # 获取向量中元素的个数

    sum_pr_plus = sum(pr_plus)
    sum_pr_minus = sum(pr_minus)

    e_plus = calc_e(sum_pr_plus, pr_plus, n)
    e_minus = calc_e(sum_pr_minus, pr_minus, n)

    e_vec = np.concatenate([e_plus, e_minus])

    return e_vec

calc_e这个方法是我为了提高代码复用性加的。得到的结果如下:

array([0.        , 0.        , 2.63509218, 0.        , 2.36490782,
       0.        , 0.        , 0.        , 0.        , 0.        ])

2.3.3 生成 f u T fu^T fuT矩阵

这个没啥好说的,就是矩阵乘法:

def create_fu_mat(e_vec):
    """
    创建fu^T矩阵
    计算方法:
        f = e / m, m = 2n
        u: 单位向量
    :param e_vec: e+和e-构成的向量, 大小为2n×1
    :return fu^T: 将e_vec复制了2n列向量构成的矩阵, 每一列的向量一模一样, 大小为2n×2n
    """
    m = e_vec.shape[0]

    f = (e_vec / m).reshape((m, 1))
    u = np.ones((1, m))

    return np.dot(f, u)

结果如下:

array([[0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ],
       [0.26350922, 0.26350922, 0.26350922, 0.26350922, 0.26350922,
        0.26350922, 0.26350922, 0.26350922, 0.26350922, 0.26350922],
       [0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ],
       [0.23649078, 0.23649078, 0.23649078, 0.23649078, 0.23649078,
        0.23649078, 0.23649078, 0.23649078, 0.23649078, 0.23649078],
       [0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ]])

2.3.4 生成邻接矩阵

def get_sum_weights(dg):
    """
    获得邻接矩阵每一行的数据的和并乘2, i.e. 2 * \sum_{i=1}^n\sum_{j=1}^n w_{ij}

    因为原论文中的公式是q_j^+ 加 q_j^-, 和为2
    由于没有A+和A-之分, i.e. 没有q_j^+ 和q_j^- 之分
    但是矩阵是合并了的, 为了保证矩阵第一范数为1, 所以需要乘2
    :param dg: 有向图
    :return sum_weights: dict, {node: sum_weight}, 每一行的总权重,
                         i.e. /sum_{k \in out(v_j)}|p_{jk}|
    """
    sum_weights = {}
    for node_1 in dg.nodes():
        weight = 0
        for node_2 in dg.nodes():
            weight += dg.edges[node_1, node_2]['weight']
        sum_weights[node_1] = weight * 2

    return sum_weights


def create_adj_mat(dg):
    """
    生成邻接矩阵
        邻接矩阵权重计算方式: a_{ij} = w{ij} / w{j}
        w{j}是j列的权重和, 但是由于邻接矩阵是上下对称的, 所以也是第j行的权重和

    该过程实际上是一个归一化的过程
    :param dg: 有向图
    :return A_mat: 2n×2n大小的邻接矩阵, 由4个n×n的邻接矩阵拼接而成
    """
    n = dg.number_of_nodes()
    sum_weights = get_sum_weights(dg)  # 获得每一行的权重总和

    adj_list = []

    for node_1 in dg.nodes():
        sum_weight = sum_weights[node_1]
        for node_2 in dg.nodes():
            a = dg.edges[node_1, node_2]['weight'] / sum_weight
            adj_list.append(a)

    adj_mat = np.array(adj_list).reshape(n, n)

    A_mat = np.vstack((adj_mat, adj_mat))
    A_mat = np.hstack((A_mat, A_mat))

    return A_mat.T

这个邻接矩阵是我觉得最复杂的部分,因为我没有用 w + , w − w^+,w^- w+,w,我只有一个权重,所以我的矩阵 A \bold A A是如下构造:
A = [ A A A A ] \bold A=\left[ \begin{array}{ccc} \bold A & & \bold A \\ \bold A & & \bold A \end{array} \right] A=[AAAA]

但是这样带来了一个问题,就是转置后的第一范数不为1,是大于1的,这是因为上面的 ∥ A ∥ 1 = 1 \left\| \bold A \right\|_1=1 A1=1下面的也是等于1,再求和之后为2,那么就会导致最后的结果是发散的,而不是收敛的。那么问题我后来发现是出在这个公式上面:

a i j + = q i j + ∑ k = 1 n q k j + + ∑ k = 1 n q k j − a_{ij}^+=\frac{q_{ij}^+}{\sum_{k=1}^n q_{kj}^+ + \sum_{k=1}^n q_{kj}^-} aij+=k=1nqkj++k=1nqkjqij+

我没有正负之分,所以分母就小了一倍,所以我用这行代码,把权重翻倍,使得最后能够保证 ∥ A ∥ 1 = 1 \left\| \bold A \right\|_1=1 A1=1

sum_weights[node_1] = weight * 2

接着第二个问题就是记得返回值的时候要转置,要转置,要转置!因为毕竟公式里面是 Q = P T Q=P^T Q=PT,我最开始搞成了这个邻接矩阵是对称的,可惜我后来才想起来不是对称的。

运行结果如下:

array([[0.        , 0.5       , 0.        , 0.16666667, 0.        ,
        0.        , 0.5       , 0.        , 0.16666667, 0.        ],
       [0.25      , 0.        , 0.        , 0.        , 0.        ,
        0.25      , 0.        , 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.16666667, 0.        ,
        0.        , 0.        , 0.        , 0.16666667, 0.        ],
       [0.25      , 0.        , 0.5       , 0.        , 0.5       ,
        0.25      , 0.        , 0.5       , 0.        , 0.5       ],
       [0.        , 0.        , 0.        , 0.16666667, 0.        ,
        0.        , 0.        , 0.        , 0.16666667, 0.        ],
       [0.        , 0.5       , 0.        , 0.16666667, 0.        ,
        0.        , 0.5       , 0.        , 0.16666667, 0.        ],
       [0.25      , 0.        , 0.        , 0.        , 0.        ,
        0.25      , 0.        , 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.16666667, 0.        ,
        0.        , 0.        , 0.        , 0.16666667, 0.        ],
       [0.25      , 0.        , 0.5       , 0.        , 0.5       ,
        0.25      , 0.        , 0.5       , 0.        , 0.5       ],
       [0.        , 0.        , 0.        , 0.16666667, 0.        ,
        0.        , 0.        , 0.        , 0.16666667, 0.        ]])

2.4 PolarityRank

有了矩阵后,我们就可以开始计算了。

2.4.1 计算每个节点PR值

def calc_polarityrank(pr_vec, fu_mat, A_mat):
    """
    polarityrank算法
    计算方法:
        pr_vec := B * pr_vec
        B = ((1 - d)fu^T + dA)
        f = e / m, u: 单位向量

    使用论文中的计算方法, 可以减少一次矩阵与矩阵的乘法

    收敛判断:
        1. ||PR_{k+1} - PR_{k}|| <= 1e-10
        2. 迭代200次
    :param pr_vec: PR+和PR-构成的向量, 大小2n×1
    :param fu_mat: 将e_vec复制了2n列向量构成的矩阵, 每一列的向量一模一样, 大小为2n×2n
    :param A_mat: 由4个归一化权重构成的邻接矩阵拼接而成的大邻接矩阵, 用于矩阵运算, 大小为2n×2n
    :return pr_vec: 迭代结束后的每个节点的PR值
    """
    d = 0.85
    epsilon = 1e-5

    for i in range(200):
        pr_new = (1 - d) * np.dot(fu_mat, pr_vec) + d * np.dot(A_mat, pr_vec)

        print(i, np.linalg.norm(pr_new - pr_vec, ord=2))
        if np.linalg.norm(pr_new - pr_vec, ord=2) <= epsilon:
            pr_vec = pr_new
            break

        pr_vec = pr_new

    print("运行结束!")
    print("最终收敛的PR值:", pr_vec)
    return pr_vec

由于之前证明过,在 n → ∞ n \rightarrow \infty n时,最终会趋近于0,但是我们也没有那么多资源来跑到无穷对吧,所以我这里参考了Fernández-Gavilanes et al. 老哥们的方法,并自己设定了一个 ϵ = 1 0 − 5 \epsilon=10^{-5} ϵ=105,我的收敛条件给定的是:

  1. ∥ x ⃗ k + 1 − x ⃗ k ∥ ≤ ϵ \left\| \vec x_{k+1} - \vec x_k \right\| \le \epsilon x k+1x kϵ
  2. 迭代200次。

运行结果:

0 1.9422066348391442
1 1.372653777753172
2 0.999663462218954
...
110 1.0971141479105944e-05
111 1.0159208470470569e-05
112 9.387995084108366e-06
运行结束!
最终收敛的PR值: [3.32872517e-05 1.52967968e-05 3.31700068e-05 6.28000260e-05
 3.17410413e-05 3.32872517e-05 1.52967968e-05 1.92333899e-05
 6.28000260e-05 1.92333899e-05]

大家发现这个收敛的 P R PR PR值小的离谱对吧,但是当我们计算 S O SO SO之后大家会发现新大陆。

2.4.2 计算SO值

def calc_so(pr_vec, nodes):
    """
    计算每个节点的情感极性
    计算方法:
        SO(n) = (PR+(n) - PR-(n)) / (PR+(n) + PR-(n))
    :param pr_vec: 收敛后的PR值
    :param nodes: 节点
    """
    # so_dict = {}
    n = len(nodes)  # 记录节点数

    i = 0
    for node in nodes:
        pr_plus = pr_vec[i]
        pr_minus = pr_vec[i + n]

        # so_dict[node] = (pr_plus - pr_minus) / (pr_plus + pr_minus)
        print(node, " 的情感值为: ", (pr_plus - pr_minus) / (pr_plus + pr_minus))

        i += 1

运行结果如下:

吃  的情感值为:  0.0
炸鸡  的情感值为:  0.0
香  的情感值为:  0.26594873331317725
价格  的情感值为:  0.0
便宜  的情感值为:  0.2453710838914052

最后得出一个结论,该你有情感,你就得有情感。

3 总结

本文配合上文《情感分析学习笔记(5)——PolarityRank算法原理讲解》,讲解了如何通过python代码实现PolarityRank算法,其总体思维导图如下:
PolarityRank算法思维导图

4 参考

[1] Cruz F L , Vallejo C G , Enriquez F , et al. PolarityRank: Finding an equilibrium between followers and contraries in a network[J]. Information Processing & Management, 2012, 48(2):p.271-282.
[2] Fernandez-Gavilanes M , Alvarez-Lopez T , Juncal-Martinez J , et al. Unsupervised method for sentiment analysis in online texts[J]. Expert Systems with Applications, 2016, 58(Oct.):57-75.
[3]羽毛的笔.Stanford CoreNLP 入门指南[EB/OL].https://zhuanlan.zhihu.com/p/137226095,2020-5-5.
[4]闰土不用叉.利用nltk可视化stanford coreNLP构建的中文句法树[EB/OL].https://blog.csdn.net/xyz1584172808/article/details/81951846,2018-8-22.
[5]云水禅心_心一.Python中读取txt文本出现“ ‘gbk’ codec can’t decode byte 0xbf in position 2: illegal multibyte sequence”的解决办法[EB/OL].https://blog.csdn.net/lqzdreamer/article/details/76549256,2017-8-1.
[6]zy4321234zx.StanfordCoreNLP 运行缓慢(python)[EB/OL].https://blog.csdn.net/zy4321234zx/article/details/88913771,2019-3-30.
[7]张士超你到底把我家代码藏哪了.Stanford Parser 标签说明[EB/OL].https://blog.csdn.net/u011847043/article/details/79595225,2018-3-1.

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值