机器学习--结构化感知机

结构化学习

对于现实中的问题,总是多种多样的,我们不单想要预测一个连续的值,或者一个类别,有时我们可能还需要输出一个具有结构的结果,比如一个序列,一棵树,一个列表之类的,此时我们就需要用到结构化学习。

结构化学习可以解决结构化预测的问题,即我们的输出既有值还有对应的结构,结构化学习有一个通用的框架,具体如下

训练:
寻找一个合适的函数score(x, y)其中x为输入y为输出。
score可以评价输入和输出之间的分数,分数越高,说明y越准,我们也把score叫做打分函数。

预测:
输入一个x,其预测结果y如下
y = arg max ⁡ y ′ ∈ Y s c o r e ( x , y ′ ) y = \argmax_{y' \in Y}score(x, y') y=yYargmaxscore(x,y)
即我们从所有的可能的Y从找到一个y’,使y’和x之间的分数最大,然后把这个y‘当做最后的预测结果。

看完上面的流程你可能会一脸懵逼,没关系,我们来看看结构化感知机的整个流程,这样就能清楚地感受到什么是结构化学习了。

结构化感知机

结构化感知机的打分函数如下
s c o r e ( x , y ) = w ⋅ ϕ ( x , y ) score(x, y) = w\cdot \phi(x, y) score(x,y)=wϕ(x,y)
其中 ϕ ( x , y ) \phi(x,y) ϕ(x,y)表示x和y之中的特征,w表示每个特征对应的权重,我们在抽取特征时把对应的结构也当成一种特征来抽取
这样我们的预测就变成了
y = arg max ⁡ y ′ ∈ Y [ w ⋅ ϕ ( x , y ′ ) ] y = \argmax_{y' \in Y}[w\cdot \phi(x, y')] y=yYargmax[wϕ(x,y)]
然后我们来研究一下要怎么训练,结构化感知机的训练很简单,总共就两个步骤

  1. 对于样本x和对应的真实值y,我们首先得到 y ^ = arg max ⁡ y ′ ∈ Y [ w ⋅ ϕ ( x , y ′ ) ] \hat y = \argmax_{y' \in Y}[w\cdot \phi(x, y')] y^=yYargmax[wϕ(x,y)]
  2. 比较 y y y y ^ \hat y y^如果一样的话,就什么也不做,如果不一样就更新权重w,即 w = w + ϕ ( x , y ) − ϕ ( x , y ^ ) w = w + \phi(x, y) - \phi(x, \hat y) w=w+ϕ(x,y)ϕ(x,y^),即降低预测错的结果的特征的权重,增加正确结果对应的特征的权重。

如此这样重复下去就可以不断的更新权重,直到权重不再更新为止,停止训练

基于结构化感知机的序列标注

序列标注问题输入的是序列,输出的也是一个序列,所以我们需要从序列上抽取特征来进行输入,这里以分词为例子,使用 { B , E , S , M } \{B,E,S,M\} {B,E,S,M}标注

  1. 转移特征
    首先,对于原序列,一个重要的的特征就是转移特征,我们记标注序列为y,一共有N种转移特征,分别为 { s 1 , s 2 , . . . , s N } \{s_1, s_2, ..., s_N\} {s1,s2,...,sN},于是就有:
    ϕ k ( y t − 1 , y t ) = { 1 y t − 1 = s i & y t = s j 0 e l s e \phi_k(y_{t- 1}, y_{t})= \begin{cases} 1& y_{t -1} = s_i \&y_t = s_{j}\\ 0& else \end{cases} ϕk(yt1,yt)={10yt1=si&yt=sjelse
    就如同转移矩阵一样,我们抽取两两相邻序列中元素的转移特征,形成一个N x N的转移矩阵transition_matrix其中 t r a n s i t i o n _ m a t r i x [ i ] [ j ] transition\_matrix[i][j] transition_matrix[i][j]表示从上一个时刻标记为i的元素转移到下一个时刻状态为j的元素的权重。

  2. 转态特征
    状态特征我们从序列x和对应的y中抽取对应的特征,具体如何抽和特征模板有关
    ϕ l ( y t , x t ) = { 1 0 \phi_l(y_{t}, x_{t})= \begin{cases} 1\\ 0 \end{cases} ϕl(yt,xt)={10

比如我们使用分词可以使用特征模板,抽取如下特征
x i y i x i − 1 y i x i + 1 y i x i − 2 x i − 1 y i x i − 1 x i y i x i x i + 1 y i x i + 1 , x i + 2 y i x_iy_i\\ x_{i - 1}y_i\\ x_{i + 1}y_i\\ x_{i - 2}x_{i -1}y_i\\ x_{i - 1}x_iy_i\\ x_ix_{i + 1}y_i\\ x_{i + 1}, x_{i + 2}y_i xiyixi1yixi+1yixi2xi1yixi1xiyixixi+1yixi+1,xi+2yi
代码实现部分参考了https://github.com/zhangkaixu/minitools
先来看具体特征怎么抽取

import numpy as np
import re
def extract_features(self,x):
    for i in range(len(x)):
        left2=x[i-2] if i-2 >=0 else '#'
        left1=x[i-1] if i-1 >=0 else '#'
        mid=x[i]
        right1=x[i+1] if i + 1 < len(x) else '#'
        right2=x[i+2] if i + 2 < len(x) else '#'
        features=['1' + mid,'2' + left1,'3' + right1,
                '4'+left2+left1,'5'+left1+mid,'6'+mid+right1,'7'+right1+right2]
        yield features

这里依次返回的就是以上的那些特征,可以发现没有y的关系,因为每一个y都可以对应其对应的x上的特征,我们只需要组合就可以得到想要的特征,所以这里额extract_feature是提取x上的特征

然后我们写一个类来管理权重

class Weight:
    def __init__(self):
        self.values = dict() # 所有feature的存储,包括转移的状态,我们用values[key]的形式获取特征的权重,其中key就是特征,是一个字符串
    
    def update_weight(self, key, delta): # 更新权重,delta表示更新的权重的大小,key表示是哪个特征
        if key not in self.values:
            self.values[key] = 0

        self.values[key] += delta
    
    def get_value(self, key, default=0): # 获取对应特征的值,default为默认值
        if key not in self.values:
            return default
        else:
            return self.values[key]

我们把连两个特征和起来,即把 ϕ k ( y t − 1 , y t ) \phi_k(y_{t -1}, y_t) ϕk(yt1,yt) ϕ l ( x t , y t ) \phi_l(x_t, y_t) ϕl(xt,yt) 合并起来,写 ϕ k ( y t − 1 , y t , x t ) \phi_k(y_{t -1}, y_t, x_t) ϕk(yt1,yt,xt),然后我们就可以方便的计算score了
s c o r e ( x , y ) = ∑ i = 1 T w ⋅ ϕ k ( y t − 1 , y t , x t ) score(x, y) = \sum_{i = 1}^T w \cdot \phi_k(y_{t -1}, y_t, x_t) score(x,y)=i=1Twϕk(yt1,yt,xt)
我们顶一个结构化感知机的类,然后初始化weight,并且把特征抽取函数加上去

class StructuredPerceptron:
    def __init__(self):
        self.weight = Weight()
    
    def extract_features(self,x):
        for i in range(len(x)):
            left2=x[i-2] if i-2 >=0 else '#'
            left1=x[i-1] if i-1 >=0 else '#'
            mid=x[i]
            right1=x[i+1] if i + 1 < len(x) else '#'
            right2=x[i+2] if i + 2 < len(x) else '#'
            features=['1' + mid,'2' + left1,'3' + right1,
                    '4'+left2+left1,'5'+left1+mid,'6'+mid+right1,'7'+right1+right2]

然后写上维特比解码,使用维特比解码,我们需要转移矩阵和发射矩阵,所以我们要先计算他们

def veterbi_decode(self, x):
     transition_matrix = np.array([[self.weight.get_value(str(i) + ':' + str(j)) for j in range(4)] 
                                                                                 for i in range(4)])

转移矩阵,我们可以直接从weight中获取权重,然后使用列表递推式算出。

发射矩阵可能比较复杂

emisstion_matrix = np.array([[sum(self.weight.get_value(str(tag) + feature)
                                           for feature in features) for tag in range(4)]
                                           for features in self.extract_features(x)])

发射矩阵输出的是x的每个时刻对应的每个标记的分数,由于每个时刻的x有多个转移特征,所以我们需要对其进行求和,即我们对每个时刻的标记上的转移特征进行求和,然后得到发射矩阵。

从这里可以看出隐马尔科夫模型的失败之处,结构化感知机可以抽取多种多样的特征来构建发射矩阵,而隐马尔科夫只能抽取单一的特征。

然后就是维特比解码了,和HMM没啥区别,贴上完整代码

def veterbi_decode(self, x):
    transition_matrix = np.array([[self.weight.get_value(str(i) + ':' + str(j)) for j in range(4)] 
                                                                                for i in range(4)])
    emisstion_matrix = np.array([[sum(self.weight.get_value(str(tag) + feature)
                                            for feature in features) for tag in range(4)]
                                            for features in self.extract_features(x)])

    path = []
    alpha = emisstion_matrix[0]

    for i in range(len(x) - 1):
        alpha = alpha.reshape(-1, 1) + transition_matrix + emisstion_matrix[i + 1]
        path.append(list(np.argmax(alpha, axis=0)))
        alpha = np.max(alpha, axis=0)
    
    res = [np.argmax(alpha)]
    idx = res[0]

    for p in reversed(path):
        idx = p[idx]
        res.append(idx)

    return list(reversed(res))

然后就是训练部分,我使用的是msr数据集

def train(self, corpus: str, iter=200, encoding='utf-8'):
     corpus = list(open(corpus, encoding=encoding))
     report_num = len(corpus) // 50

     for epoch in range(iter):
         for j, sentence in enumerate(corpus):
             if len(sentence.strip()) == 0:
                 continue
             x, y = self._tagging(sentence)
             y_hat = self.veterbi_decode(x)
             if y_hat != y:
                 self.update(x, y, 1) # 奖励正确特征
                 self.update(x, y_hat, -1) # 惩罚错误特征

             if j % report_num == 0:
                 print('ep:{} ---- {}%'.format(epoch, round(j / len(corpus) * 100, 2)))

         print(self.score('msr_test.utf8', 'msr_test_gold.utf8'))

然后我们把更新的函数也写一下

def update(self, x, y, delta): 
     for i, features in zip(range(len(y)), self.extract_features(x)): # 更新发射特征
         for feature in features:
             self.weight.update_weight(str(y[i]) + feature, delta)
     for i in range(1, len(x)): # 更新转移特征
         self.weight.update_weight(str(y[i - 1]) + ':' + str(y[i]), delta)

接下来就是一些琐碎的数据处理和评估的函数,就是分词里的那老一套就不细说了

def _tagging(self, sentence: str):
    """
    (B, M, E, S)
    """
    sentence = sentence.strip().split()
    tagseq = []
    for word in sentence:
        if len(word) == 1:
            tagseq.append(3)
        else:
            tagseq.extend([0] + [1] * (len(word) - 2)+ [2])
    return ''.join(sentence), tagseq
def segment(self, x):
    y = self.veterbi_decode(x)
    res, tmp = [], ''
    
    for i in range(len(x)):
        if tmp and (y[i] == 0 or y[i] == 3):
            res.append(tmp)
            tmp = ''
        tmp += x[i]
    if tmp: res.append(tmp)
    return res

def toRegion(self, x: str):
    res = []
    st = 1
    for word in x.strip().split():
        res.append((st, st + len(word) - 1))
        st = len(word) + st
    return res

def score(self, test, target):
    A, B, ANB = 0, 0, 0
    with open(test, encoding='utf-8', mode='r') as ts, open(target, encoding='utf-8', mode='r') as tg:
        for a, b in zip(ts, tg):
            tmpstr = re.sub(r'[ \n  ]', '', a).strip()
            pre = self.segment(tmpstr)
            tmpa, tmpb = set(self.toRegion(' '.join(pre))), set(self.toRegion(b.strip()))
            A += len(tmpa)
            B += len(tmpb)
            ANB += len(tmpa & tmpb)
    p, r = ANB / B, ANB / A
    return p, r, 2 * p * r / (p + r)

然后是完整代码

import numpy as np
import re

class Weight:
    def __init__(self):
        self.values = dict() # 所有feature的存储,包括转移的状态
    
    def update_weight(self, key, delta):
        if key not in self.values:
            self.values[key] = 0

        self.values[key] += delta
    
    def get_value(self, key, default=0):
        if key not in self.values:
            return default
        else:
            return self.values[key]
    

class StructuredPerceptron:
    def __init__(self):
        self.weight = Weight()
    
    def extract_features(self,x):
        for i in range(len(x)):
            left2=x[i-2] if i-2 >=0 else '#'
            left1=x[i-1] if i-1 >=0 else '#'
            mid=x[i]
            right1=x[i+1] if i + 1 < len(x) else '#'
            right2=x[i+2] if i + 2 < len(x) else '#'
            features=['1' + mid,'2' + left1,'3' + right1,
                    '4'+left2+left1,'5'+left1+mid,'6'+mid+right1,'7'+right1+right2]
            yield features
    
    def veterbi_decode(self, x):
        transition_matrix = np.array([[self.weight.get_value(str(i) + ':' + str(j)) for j in range(4)] 
                                                                                    for i in range(4)])
        emisstion_matrix = np.array([[sum(self.weight.get_value(str(tag) + feature)
                                                for feature in features) for tag in range(4)]
                                                for features in self.extract_features(x)])

        path = []
        alpha = emisstion_matrix[0]

        for i in range(len(x) - 1):
            alpha = alpha.reshape(-1, 1) + transition_matrix + emisstion_matrix[i + 1]
            path.append(list(np.argmax(alpha, axis=0)))
            alpha = np.max(alpha, axis=0)
        
        res = [np.argmax(alpha)]
        idx = res[0]

        for p in reversed(path):
            idx = p[idx]
            res.append(idx)

        return list(reversed(res))       
    
    def _tagging(self, sentence: str):
        """
        (B, M, E, S)
        """
        sentence = sentence.strip().split()
        tagseq = []
        for word in sentence:
            if len(word) == 1:
                tagseq.append(3)
            else:
                tagseq.extend([0] + [1] * (len(word) - 2)+ [2])
        return ''.join(sentence), tagseq

    def train(self, corpus: str, iter=200, encoding='utf-8'):
        corpus = list(open(corpus, encoding=encoding))
        report_num = len(corpus) // 50

        for epoch in range(iter):
            for j, sentence in enumerate(corpus):
                if len(sentence.strip()) == 0:
                    continue
                x, y = self._tagging(sentence)
                y_hat = self.veterbi_decode(x)
                if y_hat != y:
                    self.update(x, y, 1)
                    self.update(x, y_hat, -1)
 
                if j % report_num == 0:
                    print('ep:{} ---- {}%'.format(epoch, round(j / len(corpus) * 100, 2)))

            print(self.score('msr_test.utf8', 'msr_test_gold.utf8'))
    
    def update(self, x, y, delta):
        for i, features in zip(range(len(y)), self.extract_features(x)):
            for feature in features:
                self.weight.update_weight(str(y[i]) + feature, delta)
        for i in range(1, len(x)):
            self.weight.update_weight(str(y[i - 1]) + ':' + str(y[i]), delta)
    
    def segment(self, x):
        y = self.veterbi_decode(x)
        res, tmp = [], ''
        
        for i in range(len(x)):
            if tmp and (y[i] == 0 or y[i] == 3):
                res.append(tmp)
                tmp = ''
            tmp += x[i]
        if tmp: res.append(tmp)
        return res
    
    def toRegion(self, x: str):
        res = []
        st = 1
        for word in x.strip().split():
            res.append((st, st + len(word) - 1))
            st = len(word) + st
        return res
    
    def score(self, test, target):
        A, B, ANB = 0, 0, 0
        with open(test, encoding='utf-8', mode='r') as ts, open(target, encoding='utf-8', mode='r') as tg:
            for a, b in zip(ts, tg):
                tmpstr = re.sub(r'[ \n  ]', '', a).strip()
                pre = self.segment(tmpstr)
                tmpa, tmpb = set(self.toRegion(' '.join(pre))), set(self.toRegion(b.strip()))
                A += len(tmpa)
                B += len(tmpb)
                ANB += len(tmpa & tmpb)
        p, r = ANB / B, ANB / A
        return p, r, 2 * p * r / (p + r)

其最终的performance,F1可以达到 95.8 %之前[Zhang and Clark]的97.2 % 要低一些,其原因可能是没有使用平均和正则技巧来进行泛化,所以导致模型过拟合。
具体的数据我放在github上了
https://github.com/zipper112/StructedPerceptron/tree/master

  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值