结构化学习
对于现实中的问题,总是多种多样的,我们不单想要预测一个连续的值,或者一个类别,有时我们可能还需要输出一个具有结构的结果,比如一个序列,一棵树,一个列表之类的,此时我们就需要用到结构化学习。
结构化学习可以解决结构化预测的问题,即我们的输出既有值还有对应的结构,结构化学习有一个通用的框架,具体如下
训练:
寻找一个合适的函数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=y′∈Yargmaxscore(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=y′∈Yargmax[w⋅ϕ(x,y′)]
然后我们来研究一下要怎么训练,结构化感知机的训练很简单,总共就两个步骤
- 对于样本x和对应的真实值y,我们首先得到 y ^ = arg max y ′ ∈ Y [ w ⋅ ϕ ( x , y ′ ) ] \hat y = \argmax_{y' \in Y}[w\cdot \phi(x, y')] y^=y′∈Yargmax[w⋅ϕ(x,y′)]
- 比较 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}标注
-
转移特征
首先,对于原序列,一个重要的的特征就是转移特征,我们记标注序列为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(yt−1,yt)={10yt−1=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的元素的权重。 -
转态特征
状态特征我们从序列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
xiyixi−1yixi+1yixi−2xi−1yixi−1xiyixixi+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(yt−1,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(yt−1,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=1∑Tw⋅ϕk(yt−1,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