转自:

平均感知机算法(Averaged Perceptron)

感知机算法是非常好的二分类算法,该算法求取一个分离超平面,超平面由w参数化并用来预测,对于一个样本x,感知机算法通过计算y = [w,x]预测样本的标签,最终的预测标签通过计算sign(y)来实现。算法仅在预测错误时修正权值w。 
平均感知机和感知机算法的训练方法一样,不同的是每次训练样本xi后,保留先前训练的权值,训练结束后平均所有权值。最终用平均权值作为最终判别准则的权值。参数平均化可以克服由于学习速率过大所引起的训练过程中出现的震荡现象。

词性标注

词性标注是一个监督学习。先读入训练预料,利用平均感知机算法训练得到tagging模型,并存储在硬盘上。当需要进行词性预测时,首先从硬盘上加载tagging模型,再读入测试语料,进行自动标注。

过程说明

1、为了存储权值weights,建立了一个双层的字典,保存了特征->词性类别->权重,结构如图: 

感知机 数据拟合回归 python_权值

2、从语料库中读取单词,当读取到”.”时,代表是一个句子的结尾,将前面的若干单词组成为一句话,形成一个sentence,存储在二元组中:例如([‘good’,’man’],[‘adj’,’n’])第一个列表是句子中的单词,第二个列表是单词对应的词性。语料库中的所有句子存储在列表training_data中(sentences),形如[ ([ ],[ ]), ([ ],[ ]), ([ ],[ ]) 。。。]

3、模型训练过程中,权值的更新是通过将(特征,正确词性)对应的特征权值+1,并且将(特征,错误词性)对应的特征权值-1。不仅增加正确词性对应的权值,还要惩罚错误词性对应的权值。

4、为了训练一个更通用的模型,在特征提取之前对数据进行预处理:

  • 所有英文词语都转小写
  • 四位数字并且在1800-2100之间的数字被转义为!YEAR
  • 其他数字被转义为!DIGITS
  • 当然还可以写一个专门识别日期、电话号码、邮箱等的模块,但目前先不拓展这部分

5、对第i个单词进行特征提取:

  • 单词的首字母
  • 单词的后缀
  • 第i-1个单词的词性
  • 第i-1个单词的后缀
  • 第i-2个单词的词性
  • 第i-2个单词的后缀
  • 第i+1个单词的词性,等等

实验

代码1:

AP_algorithm.py

# -*- coding:utf-8 -*-
# 平均感知机算法Averaged Perceptron:训练结束后平均所有权值,使用平均权值作为最终的权值

from collections import defaultdict
import pickle


class AveragedPerceptron(object):


    def __init__(self):
        # 每个'位置'拥有一个权值向量
        self.weights = {}
        self.classes = set()
        # 累加的权值,用于计算平均权值
        self._totals = defaultdict(int)  # 生成了一个默认为0的带key的数据字典
        self._tstamps = defaultdict(int) # 上次更新权值时的i
        self.i = 0   # 记录实例的数量

    def predict(self, features):  # 特征向量乘以权值向量,返回词性标签
        scores = defaultdict(float)
        for feat, value in features.items():
            if feat not in self.weights or value == 0:
                continue
            weights = self.weights[feat]
            for label, weight in weights.items():
                scores[label] += value * weight
        # 返回得分最高的词性
        return max(self.classes, key=lambda label: (scores[label], label)) # 返回得分最高的词性标签,如果得分相同取字母大的

    def update(self, truth, guess, features): # 更新权值

        def upd_feat(c, f, w, v): # c:正确的(或预测的)词性; f:feat某个特征; w:对应c的权值; v:1(或-1)
            param = (f, c)
            self._totals[param] += (self.i - self._tstamps[param]) * w  # 累加:(此时的i - 上次更新该权值时的i)*权值
            self._tstamps[param] = self.i # 记录更新此权值时的i
            self.weights[f][c] = w + v  # 更新权值

        self.i += 1
        if truth == guess:  # 如果预测正确,不做更新
            return None
        for f in features:
            weights = self.weights.setdefault(f, {})  # 返回字典weights中key为f的值,如果没有则返回{}
            upd_feat(truth, f, weights.get(truth, 0.0), 1.0)  # 将(特征,正确词性)对应的特征权重+1
            upd_feat(guess, f, weights.get(guess, 0.0), -1.0) # 将(特征,错误词性)对应的特征权重-1
        return None

    def average_weights(self):  # 计算平均权值
        for feat, weights in self.weights.items():
            new_feat_weights = {}
            for clas, weight in weights.items():
                param = (feat, clas)
                total = self._totals[param]
                total += (self.i - self._tstamps[param]) * weight
                averaged = round(total / float(self.i), 3)     # 每个权值:根据自己的i次迭代做平均
                if averaged:
                    new_feat_weights[clas] = averaged
            self.weights[feat] = new_feat_weights
        return None

    def save(self, path):    # 存储权值字典
        return pickle.dump(dict(self.weights), open(path, 'w'))

    def load(self, path):    # 读取权值字典
        self.weights = pickle.load(open(path))
        return None
  • 1

代码2:

AP_PosTagging.py

# -*- coding:utf-8 -*-
# 基于感知机的高性能词性标注器(为了节省时间训练集只取了少部分,所以准确率不是真实的水平)

import os
import random
from collections import defaultdict
import pickle
import logging

from AP_algorithm import AveragedPerceptron

PICKLE = "data/tagger-0.1.0.pickle"   # 模型存放的地址


class PerceptronTagger():

    START = ['-START-', '-START2-']
    END = ['-END-', '-END2-']
    AP_MODEL_LOC = os.path.join(os.path.dirname(__file__), PICKLE)  #模型存放的路径

    def __init__(self, load=True):
        self.model = AveragedPerceptron()
        self.tagdict = {}
        self.classes = set()
        if load:
            self.load(self.AP_MODEL_LOC)

    def tag(self, corpus):  # 输入一句话形如'a good man',输出对应词性形如[(word1, tag1),(word2, tag2)...]

        s_split = lambda t: t.split('\n') # 假设句子之间用\n相隔,词之间用' '相隔
        w_split = lambda s: s.split()

        def split_sents(corpus):
            for s in s_split(corpus):
                yield w_split(s)  # 当函数中含有yield时,代表它是一个生成器,生成器的用处是可以迭代,且yield有返回值

        prev, prev2 = self.START
        tokens = []  # 存放词性
        for words in split_sents(corpus):
            context = self.START + [self._normalize(w) for w in words] + self.END  # context是一个添加了首尾特殊字符的句子
            for i, word in enumerate(words):  # enumerate函数用于遍历序列中的元素以及它们的下标
                tag = self.tagdict.get(word)  # 从高频字典里获取词性,如果没有就用特征预测词性
                if not tag:
                    features = self._get_features(i, word, context, prev, prev2)
                    tag = self.model.predict(features)
                tokens.append((word, tag))
                prev2 = prev
                prev = tag
        return tokens  # 形如[(word1, tag1),(word2, tag2)...]

    def train(self, sentences, save_loc=None, nr_iter=5):  # 训练模型,sentences形如:[(['good','man'],['adj','n]), ([],[]), ([],[]),...]
        '''
        参数sentences: 一个包含(words, tags)的列表
        参数save_loc: 存放模型的地方
        参数nr_iter: 训练的迭代次数
        '''
        self._make_tagdict(sentences)
        self.model.classes = self.classes  # classes形如set('adj','n','vb'...)
        for iter_ in range(nr_iter):
            c = 0
            n = 0
            for words, tags in sentences:
                prev, prev2 = self.START
                context = self.START + [self._normalize(w) for w in words] \
                          + self.END
                for i, word in enumerate(words):
                    guess = self.tagdict.get(word)  # 从高频字典里获取词性,如果没有就用特征预测词性
                    if not guess:
                        feats = self._get_features(i, word, context, prev, prev2)  # 先进行特征提取
                        guess = self.model.predict(feats)  # 用提取的特征预测单词的词性
                        self.model.update(tags[i], guess, feats)  # 更新权值
                    prev2 = prev  # i-2单词
                    prev = guess  # i-1单词
                    c += guess == tags[i]  # 如果预测对了c+=1
                    n += 1
            random.shuffle(sentences)
            logging.info("Iter {0}: {1}/{2}={3}".format(iter_, c, n, _pc(c, n)))  # 日志报告时间,发生在正常运行时
        self.model.average_weights()  # 计算平均权值

        if save_loc is not None:
            pickle.dump((self.model.weights, self.tagdict, self.classes),
                        open(save_loc, 'wb'), -1)  # 参数-1:序列化使用最高的协议版本
        return None

    def load(self, loc):  # 加载模型

        try:
            w_td_c = pickle.load(open(loc, 'rb'))
        except IOError:
            msg = ("Missing trontagger.pickle file.")
            raise IOError(msg)
        self.model.weights, self.tagdict, self.classes = w_td_c
        self.model.classes = self.classes
        return None

    def _normalize(self, word):  # 对字符串预处理,字幕的话变成小写,数字的话单独考虑

        if '-' in word and word[0] != '-':
            return '!HYPHEN'
        elif word.isdigit() and len(word) == 4:
            return '!YEAR'
        elif word[0].isdigit():
            return '!DIGITS'
        else:
            return word.lower()

    def _get_features(self, i, word, context, prev, prev2):  # 提取单词的特征,返回features字典

        def add(name, *args):  # 连接字符串,用于给特征命名用
            features[' '.join((name,) + tuple(args))] += 1

        i += len(self.START)
        features = defaultdict(int)
        # 人工制定一些特征,如单词的前缀、后缀,前后单词的词性等
        add('bias')
        add('i suffix', word[-3:])  #单词后缀
        add('i pref1', word[0])     #单词首字母
        add('i-1 tag', prev)        #i-1的词性
        add('i-2 tag', prev2)       #i-2的词性
        add('i tag+i-2 tag', prev, prev2)
        add('i word', context[i])
        add('i-1 tag+i word', prev, context[i])
        add('i-1 word', context[i - 1])
        add('i-1 suffix', context[i - 1][-3:])
        add('i-2 word', context[i - 2])
        add('i+1 word', context[i + 1])
        add('i+1 suffix', context[i + 1][-3:])
        add('i+2 word', context[i + 2])
        return features

    def _make_tagdict(self, sentences):   # 制作一个高频的单词-词性字典,tagdict形如{'good':'adj','man':'n'...}
        counts = defaultdict(lambda: defaultdict(int))
        for words, tags in sentences:  # words形如['good','man'],tags形如['adj','n']
            for word, tag in zip(words, tags):  # word形如'good', tag形如'adj'
                counts[word][tag] += 1
                self.classes.add(tag)
        freq_thresh = 20
        ambiguity_thresh = 0.97
        for word, tag_freqs in counts.items():
            tag, mode = max(tag_freqs.items(), key=lambda item: item[1])
            n = sum(tag_freqs.values())
            # 设置个阈值, 只记录高频的词性
            if n >= freq_thresh and (float(mode) / n) >= ambiguity_thresh:
                self.tagdict[word] = tag


def _pc(n, d):   # 计算准确率
    return (float(n) / d) * 100


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO) # logging.basicConfig函数对日志的输出格式及方式做相关配置
    tagger = PerceptronTagger(False)
    try:
        tagger.load(PICKLE)
        print(tagger.tag('how are you ?'))
        logging.info('Start testing...')
        right = 0.0
        total = 0.0
        sentence = ([], [])
        for line in open('data/test.txt'):
            params = line.split()
            if len(params) != 2: continue
            sentence[0].append(params[0])  # 单词
            sentence[1].append(params[1])  # 词性
            if params[0] == '.':  # 代表一个句子的结尾,把单词组成一个用空格连接的句子
                text = ''
                words = sentence[0]
                tags = sentence[1]
                for i, word in enumerate(words):
                    text += word
                    if i < len(words): text += ' '
                outputs = tagger.tag(text)  # outputs形如[(word1, tag1),(word2, tag2)...]
                assert len(tags) == len(outputs)  # assert后面的语句非真的时候引发一个错误
                total += len(tags)
                for o, t in zip(outputs, tags):
                    if o[1].strip() == t: right += 1  # 预测正确时right+1
                sentence = ([], [])
        logging.info("Precision : %f", right / total)  # 正确率
    except IOError:
        logging.info('Reading corpus...')
        training_data = []
        sentence = ([], [])
        for line in open('data/train.txt'):
            params = line.split('\t')
            sentence[0].append(params[0])
            sentence[1].append(params[1])
            if params[0] == '.':   # 说明一个句子结束
                training_data.append(sentence)
                sentence = ([], [])
        logging.info('training corpus size : %d', len(training_data))  # train.txt中句子的个数
        logging.info('Start training...')
        tagger.train(training_data, save_loc=PICKLE)
  • 1

结果:

在不同的语料上进行测试,并将准确率与NLTK的词性标注器作比较。

TAGGER              WSJ     ABC     WEB
NLTK                94.0    91.5    88.4
AP_PosTagging       96.8    94.8    91.8
  • 1

附录:

词性标注的训练语料和测试语料可以自定义或者从此处下载。将data文件夹与代码放在一起。 
注意,此训练语料是为了演示词性标注的流程,选了一个非常小的训练集,所以该训练集训练出来的模型的准确率不是真实的水平,特此说明。