说明
实体识别的数据标注方式和传统的机器学习0/1标注的方法差别比较大,一般采用标注工具,如LabelStudio来对原始数据进行打标,然后转换为标准的可训练模式;模型训练后,对数据进行预测,结果仍然需要重新转为LabelStudio的格式,方便检查。
LS在过程中起到的作用非常大,有许多高级功能值得探索和学习。以下是我快速看了一遍之后的总结
理解label studio
-
1 general
- 确定数据类型
-
2 label interface
- 1 参考模板-可以发现更多的机器学习模型
- 2 就实体模型而言,只有三个抽象列:
- 1 数据列:这个是要打标的主要对象
- 2 实体列:这里可以选择在数据上可标注的实体类型
- 3 勾选列:可以根据对一些选项进行勾选
-
3 machine learning
- 1 了解与model的对接方式
- 2 允许标注与模型联动
-
4 cloud storage
-
5 webhooks
- 1 可以在特定情况下发出告警
回到本篇的内容:解决pydantic与实体识别模型之间的关联。
内容
我采用BIO的标注方式。
原始的BIO标注方法是可以在同一个句子中标注多种不同的实体的,例如 ORG-B, ORG-I , PER-B, PER-I。但如果考虑这种标注方式,在标准化的时候会有一点麻烦。所以我做了一些简化,即每个实体识别模型仅标注一种实体,所以就只有B、I和O。
由于实体识别是一种贯序的分类任务,例如一个实体"张三", 是由“BI”两个分类标签才能表示的,所以可以先有一个ent_tuple的概念。即由实体、开始位置和结束位置共同构成一个实体的表达。
1 将原始标注数据转为BIO数据
以下是样例数据
其中content是原文主体,而highlight部分则是标注的结果,也就是ent_tuple_list
,例子中列表的长度仅为1,实际长度可以为多个。
[{'text': '鞍山银行股份有限公司', 'labelType': 'ORG', 'start': 2, 'end': 12}]
1.1 提取ent_tuple
然后按照之前说的ent_tuple概念,我们可以建立数据模型
# 1 将标签转为ent_tuple_list
from typing import List, Optional
from pydantic import BaseModel,FieldValidationInfo, field_validator
class LabelStudioTag(BaseModel):
text: str
labelType: str
start: int
end: int
@property
def ls_tuple(self):
return (self.text, self.start, self.end)
def get_tuple(self):
return self.ls_tuple
lst = LabelStudioTag(**{'text': '鞍山银行股份有限公司', 'labelType': 'ORG', 'start': 2, 'end': 12})
lst.get_tuple()
('鞍山银行股份有限公司', 2, 12)
这样就得到了一个ent_tuple。
针对实际给到的结果是列表形态,可以有
class LabelStudioTagList(BaseModel):
tag_list : List[LabelStudioTag]
def convert_final_res(some_list):
tem_lstl = LabelStudioTagList(tag_list = some_list)
ent_tuple_list = [x.get_tuple() for x in tem_lstl.tag_list]
return ent_tuple_list
对整个df执行变换
# 【转换】
tem_df['ent_list'] = tem_df['final_res'].apply(convert_final_res)
tem_df['ent_tuple_list'] = tem_df['highlight'].apply(convert_final_res)
1.2 创建BIO标签
已知原文,标注实体(及其位置), 就可以创建BIO标签(与原文等长)
def make_BIO_by_len(some_len):
default_str = 'I' * some_len
str_list = list(default_str)
str_list[0] ='B'
return str_list
def gen_BIO_list2(some_dict):
the_content = some_dict['clean_data']
ent_list = some_dict['ent_tuple_list']
content_list = list(the_content)
tag_list = list('O'* len(content_list))
for ent_info in ent_list:
start = ent_info[1]
end = ent_info[2]
label_len = end-start
tem_bio_list = make_BIO_by_len(label_len)
tag_list[start:end] = tem_bio_list
res_dict = {}
res_dict['x'] = ''.join(content_list)
res_dict['y'] = ''.join(tag_list)
return res_dict
转换时,要求给到clean_data 和 ent_tuple_list。
_s = cols2s(title_df, cols=['content', 'ent_tuple_list'], cols_key_mapping=['clean_data', 'ent_tuple_list'])
_s1 = _s.apply(gen_BIO_list2)
_tem_df = pd.DataFrame(_s1.to_list())
然后数据就转为了BIO,也就是模型的y
BIO的"反函数"
写到这里,既然我们将标注数据标准化为了ent_tuple_list(与标注关联,人可识别), 进而将数据转为了BIO格式(模型可使用),那么就一定会有一个反过来的过程。即模型给出了BIO,然后再转为人可识别的ent_tuple_list。
import re
# 提取实体的位置列表[(start,end)]
def extract_bio_positions(bio_string):
pattern = re.compile(r'B(I+)(O|$)')
matches = pattern.finditer(bio_string)
results = []
for match in matches:
start, end = match.span()
results.append((start, end - 1)) # end-1 to include the last 'I'
return results
extract_bio_positions('OOOOOOOOOOOOOOBIIIIIIIIIIIIOOOOOOOOO')
[(14, 27)]
def bio2ent_tuple_list(some_dict):
text = some_dict['text']
bio = some_dict['bio']
res_list = []
bio_pos_list = extract_bio_positions(bio)
for bio_pos_tuple in bio_pos_list:
bio_start, bio_end = bio_pos_tuple
the_ent = text[bio_start:bio_end]
res_list.append( (the_ent,bio_start,bio_end) )
return res_list
# 反函数
_s00 = cols2s(_tem_df, cols = ['x','y'] , cols_key_mapping= ['text', 'bio'])
_s01 = _s00.apply(bio2ent_tuple_list)
_s01.head()
假设输入x,模型给到了y,那么就可以这样重新变换为 ent_tuple_list。
1.2 长句切短句
原始的文本可能是一篇文章,长度很长。这样的数据,模型是没法训练的。
考虑模型核心运算是矩阵计算,所以输入数据要想办法塑造为豆腐块的形状。
很显然,我们正常的表达不会有超长的句子,在需要分割和停顿的地方我们会有分隔符,例如句号和逗号。很显然,我们要识别的主体里不会包含这些分隔符,所以可以将一个大任务(一篇文章)分解为若干的小任务(由分隔符分开的短句)。
# 段落分割句子
import re
strong_punctuation = r'([。?!?!\n])'
weak_punctuation = r'([,,。!??、.;;ˎ̥”\ue000《》=><{}::\n\r\t\s])'
# 这个是针对x的
def split_sentences_with_punctuation_01(text, punctuation = r'([。?!?!\n])'):
# 定义句子分隔符
# 根据句子分隔符进行分割,并保留分隔符
parts = re.split(punctuation, text)
# 将分隔符与句子重新组合
sentences = []
for i in range(0, len(parts), 2):
sentence = parts[i].strip()
if i + 1 < len(parts):
sentence += parts[i + 1]
sentences.append(sentence)
return [x for x in sentences if len(x)]
split_sentences_with_punctuation_01('蔚蓝的天空下稻浪翻滚,微风拂过,空气中弥漫着沁人心脾的稻香。', weak_punctuation )
['蔚蓝的天空下稻浪翻滚,', '微风拂过,', '空气中弥漫着沁人心脾的稻香。']
因此一篇文章可以按此重塑为
def make_sentences(some_dict = None):
doc_id = some_dict['doc_id']
sentences = some_dict['sentences']
res_list = []
for i, v in enumerate(sentences):
tem_dict = {}
tem_dict['doc_id'] = doc_id
tem_dict['s_ord'] = i+1
tem_dict['sentence'] = v
res_list.append(tem_dict)
return res_list
some_dict = {'doc_id':'some_id',
'sentences':['蔚蓝的天空下稻浪翻滚,', '微风拂过,', '空气中弥漫着沁人心脾的稻香。']}
make_sentences(some_dict)
[{'doc_id': 'some_id', 's_ord': 1, 'sentence': '蔚蓝的天空下稻浪翻滚,'},
{'doc_id': 'some_id', 's_ord': 2, 'sentence': '微风拂过,'},
{'doc_id': 'some_id', 's_ord': 3, 'sentence': '空气中弥漫着沁人心脾的稻香。'}]
清洗数据
虽然在这里清洗稍微晚了点,但有时候也没办法,因为前序标注以及更前序的清洗可能不在我们控制范围内。我们需要清洗以保证模型之后拿到的输入都是干净的半角字符。
import re
def extract_utf8_chars(input_string = None):
# 定义一个正则表达式,用于匹配所有的UTF-8字符
utf8_pattern = re.compile(r'[\u0000-\U0010FFFF]')
# 使用findall方法找到所有匹配的字符
utf8_chars = utf8_pattern.findall(input_string)
return ''.join(utf8_chars)
def toDBC(some_char):
tem_str_ord = ord(some_char)
res = None
if tem_str_ord >65280 and tem_str_ord < 65375:
res =tem_str_ord - 65248
# 12288全角空格,160  空格
if tem_str_ord in [12288,160]:
res = 32
res_var_ord = res or tem_str_ord
return chr(res_var_ord)
def tranform_half_widh(some_str = None):
res_list = []
return ''.join([toDBC(x) for x in some_str])
上面已经将训练数据转为了x,y这样的形态,以下进行半角转换,并检查转换后长度是否一致。
_tem_df['x1'] = _tem_df['x'].apply(tranform_half_widh)
_is_ok = _tem_df['x1'].apply(len) == _tem_df['x'].apply(len)
_is_ok.sum()
然后将长度一致的数据提取出来,进行下一步
clean_bio_df = _tem_df[_is_ok]
下面就将“干净”的数据x和y进行对应的短句拆分
def make_sentences_with_bio(some_dict = None, puncs = None):
doc_id = some_dict['doc_id']
text = some_dict['text']
bio = some_dict['bio']
sentences = split_sentences_with_punctuation_01(text,puncs)
res_list = []
start_len = 0
for i, v in enumerate(sentences):
tem_dict = {}
tem_dict['doc_id'] = doc_id
tem_dict['s_ord'] = i+1
tem_dict['sentence'] = v
sentence_len = len(v)
tem_dict['bio'] = bio[start_len:start_len + sentence_len]
start_len += sentence_len
res_list.append(tem_dict)
return res_list
将数据进行转换
# 将doc_id挂回来
# clean_bio_df['doc_id'] = list(title_df['doc_id'])
clean_bio_df['doc_id'] = list(range(len(clean_bio_df)))
_s20 = cols2s(clean_bio_df, cols=['doc_id' ,'x1', 'y'], cols_key_mapping= ['doc_id', 'text', 'bio'])
all_lod = _s20.apply(lambda x: make_sentences_with_bio(x, puncs = weak_punctuation))
all_lod[0]
[{'doc_id': 0,
's_ord': 1,
'sentence': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'(Mock掉了),
'bio': 'OOBIIIIIIIIIIIOOOOOOOOOOOOOOOO'}]
将数据进行扁平化,组成df,然后再进行必要的筛选
all_lod1 = flatten_list(all_lod)
output_df = pd.DataFrame(all_lod1)
# 训练时选择包含实体的短句
is_ent_sel = output_df['bio'].apply(lambda x: True if 'B' in x else False)
# 长度小于最大限定长度
is_len_198 = output_df['sentence'].apply(lambda x: True if len(x) <198 else False)
# 最终
is_sel = is_ent_sel & is_len_198
train_df = output_df[is_sel]
最后,可以保存为一个excel,这一步就算结束了。
后面还有两部分,为了避免内容太长混在一起,就单独再开两篇
- 1 怎么基于拿到的数据进行模型训练
- 2 怎么利用模型训练,然后再返回对应的结果。