序列标注任务
目标:为文本中的每一个 token 分配一个标签,因此 Transformers 库也将其称为 token 分类任务。常见的序列标注任务有命名实体识别 NER (Named Entity Recognition) 和词性标注 POS (Part-Of-Speech tagging)。
1. 命名实体识别(NER - Named Entity Recognition)
- 定义:命名实体识别是识别文本中具有特定意义的命名实体,例如人名、地名、组织名、日期、货币等。它的目标是为文本中的词汇标记实体类别,例如将“北京”标记为地名,将“特斯拉”标记为公司名称等。
- 目的:NER 可以帮助从文本中抽取有价值的信息,如提取商业报告中的公司名称、识别社交媒体上的人物提及等。
- 标签举例:在文本中,“张三”可以被标记为 PERSON(人名),而“北京”可以被标记为 LOCATION(地点)。
2. 词性标注(POS - Part-of-Speech Tagging)
- 定义:词性标注是为文本中的每个词分配其词性标签,例如名词、动词、形容词、副词等。词性标注帮助我们了解每个词在句子中的功能和语法角色。
- 目的:词性标注对于句法分析和构建语言的句法树非常重要,它帮助理解句子的结构和语法关系。
- 标签举例:在句子中,“猫”可以被标记为 N(名词),而“跑”可以被标记为 V(动词)。
BERT 是一种基于 Transformer 架构的模型,广泛应用于各类自然语言处理任务。下面我们以 NER 为例,运用 Transformers 库手工构建一个基于 BERT 的模型来完成任务。
准备数据
我们选择 1998 年人民日报语料库作为数据集,该语料库标注了大量的语言学信息,可以同时用于分词、NER 等任务。这里我们直接使用处理好的 NER 语料 china-people-daily-ner-corpus.tar.gz。
该语料已经划分好了三类文件example.train(训练集)、example.dev(验证集)和 example.test(测试集),包含 20864 / 2318 / 4636 个句子。语料采用 IOB2 格式进行标注,一行对应一个字:
- IOB2 格式:
- B-XXX:表示某一类实体的开始。
- I-XXX:表示某一类实体的中间。
- O:表示非实体(即该词不属于任何实体类别)。
- 具体实体类别包括:
- PER:人物
- LOC:地点
- ORG:组织
- 例如,“B-LOC”和“I-LOC”分别表示地点实体的开始和中间。
因此共有 7 种标签:
- “O”:非实体;
- “B-PER/I-PER”:人物实体的起始/中间;
- “B-LOC/I-LOC”:地点实体的起始/中间;
- “B-ORG/I-ORG”:组织实体的起始/中间。
构建数据集
首先编写继承自 Dataset
类的自定义数据集用于组织样本和标签。数据集中句子之间采用空行分隔,因此我们首先通过 '\n\n'
切分出句子,然后按行读取句子中每一个字和对应的标签,如果标签以 B
或者 I
开头,就表示出现了实体。
这段代码展示了如何编写一个自定义的 PyTorch Dataset 类,用于将1998 年《人民日报》语料库中的数据加载进来,并为命名实体识别(NER)任务准备数据样本和标签。这段代码主要负责读取原始数据文件,进行预处理,以便后续的模型训练。下面我来逐步解释这段代码的具体作用。
类别集合初始化:
categories = set()
创建了一个集合 categories
,用于存储数据集中所有的命名实体类别。例如,如果数据中有 "LOC"(地点)、"PER"(人名)等标签,这些类别将会被存储在 categories
中。
定义自定义数据集类 PeopleDaily
class PeopleDaily(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
- 类定义:定义了一个继承自
Dataset
的自定义数据集类PeopleDaily
。 __init__
方法:初始化函数,用于加载数据文件,并调用load_data
方法对数据进行处理。
load_data
方法:加载和处理数据
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f.read().split('\n\n')):
if not line:
break
sentence, labels = '', []
for i, item in enumerate(line.split('\n')):
char, tag = item.split(' ')
sentence += char
if tag.startswith('B'):
labels.append([i, i, char, tag[2:]]) # Remove the B- or I-
categories.add(tag[2:])
elif tag.startswith('I'):
labels[-1][1] = i
labels[-1][2] += char
Data[idx] = {
'sentence': sentence,
'labels': labels
}
return Data
- 文件读取:
with open(data_file, 'rt', encoding='utf-8') as f
打开数据文件,并逐行读取内容。-
open()
函数:open()
是 Python 中用于打开文件的函数。- 语法格式为:
open(file, mode, encoding)
,它返回一个文件对象,用于对文件进行读、写或其他操作。
-
data_file
:- 这是一个变量,表示要打开的文件路径,可以是相对路径或者绝对路径。例如,
data_file = "example.train"
代表需要打开名为example.train
的文件。
- 这是一个变量,表示要打开的文件路径,可以是相对路径或者绝对路径。例如,
-
'rt'
参数:r
:表示以**只读模式(read)**打开文件,这意味着文件只能读取,不能写入。t
:表示以**文本模式(text)打开文件,默认情况下,文件会以字符串形式读取。如果换成'rb'
,则是以二进制模式(binary)**打开文件。- 组合在一起,
'rt'
表示以文本模式打开文件进行只读。
-
encoding='utf-8'
:encoding
指定了文件的字符编码格式。'utf-8'
是一种常用的字符编码,确保程序能够正确处理包含非 ASCII 字符的文本(例如中文、特殊符号等)。它确保打开文件时不会因为编码问题而产生错误。
-
with
语句:with open(...) as f:
是 Python 的一种上下文管理方式。- 使用
with
语句可以确保文件在使用完后自动关闭,避免因忘记关闭文件而导致的内存泄漏或文件锁问题。 f
是一个文件对象,通过f
可以对文件内容进行读取、写入等操作。with
语句结束后,文件会被自动关闭。
-
- 分隔句子:
f.read().split('\n\n')
通过两个换行符将文本划分为句子,即每个句子之间由两个换行符分隔。- f.read(size=-1)
f.read()
是 Python 中用于从文件对象(例如f
)中读取内容的方法。f
是通过open()
打开的文件对象,这个对象允许你读取、写入文件的内容。size
表示要读取的字符数。如果size
没有指定(或者为-1
),则f.read()
会读取文件的全部内容。
- str.split(separator, maxsplit)
.split()
是 Python 中用于分割字符串的方法。str
是要分割的字符串,在这里是通过f.read()
得到的文件内容。-
参数:
separator
(分隔符,默认值为None
):- 用于指定字符串的分隔符。如果指定了分隔符,例如
\n\n
,则.split()
会在每次遇到该分隔符时将字符串切分为不同部分。 - 在
f.read().split('\n\n')
中,\n\n
作为分隔符表示文件中的双换行符。这意味着,文件的内容会按段落来分割,每个段落之间有两个换行符。
- 用于指定字符串的分隔符。如果指定了分隔符,例如
maxsplit
(可选):maxsplit
用于控制最大分割次数,默认值为-1
,表示没有限制。- 例如,
s.split(separator, 2)
只会在前两个分隔符处进行分割。
f.read().split('\n\n')
的组合用法f.read()
:读取文件的全部内容,返回一个 字符串,例如整个文件中的所有文字。.split('\n\n')
:对文件内容进行分割,使用双换行符\n\n
作为分隔符。这将文件内容按段落进行分割,将每个段落作为一个列表中的元素。
- f.read(size=-1)
- 遍历句子:
for idx, line in enumerate(f.read().split('\n\n'))
用于对段落进行划分,将文件的内容按双换行符进行分割,得到一个段落的列表,然后逐个段落进行处理。for后面的idx
是句子的索引,line
是当前句子内容。enumerate()
是 Python 中的一个内置函数,用于在遍历可迭代对象时同时获取每个元素的索引和元素本身- enumerate(iterable, start=0)
iterable
:任何可迭代对象(如列表、字符串等)。start
:可选参数,表示索引开始的数字,默认为0
。
- enumerate(f.read().split('\n\n'))
f.read().split('\n\n')
返回一个列表,每个元素是一个段落。enumerate()
将这个列表的每个元素配对上一个索引(从0
开始)。
-
f.read().split('\n\n')
:- 读取文件的所有内容,并按双换行符分割为段落。
- 返回一个列表,列表的每个元素是一个段落(即用两个换行符分隔的部分)。
-
enumerate(f.read().split('\n\n'))
:enumerate()
为分割后的每个段落加上一个索引,形成索引与段落内容的配对。- 返回一个 迭代器,可以在遍历中使用,格式为
(index, element)
,其中index
是段落的索引,element
是具体的段落内容。
- 处理句子内容:for i, item in enumerate(line.split('\n')) 用于对句子或行进行划分,将当前段落(
line
)按单换行符进行分割,得到一个句子的列表,然后逐个句子进行处理。- 初始化
sentence
和labels
,用于存储句子文本和标签。 - 遍历句子中的每一行(每一行代表一个字符和对应的标注),
line.split('\n')
将句子内容分为多个字符。 char, tag = item.split(' ')
依照空格' '
将字符和标注进行分割,将分割结果分别赋值给 char 和 tag 两个变量。-
举个例子,假设
item
是一个字符串"今 B-LOC"
:item.split(' ')
的结果是一个列表:['今', 'B-LOC']
。- 列表的第一个元素是字符
'今'
,第二个元素是标注信息'B-LOC',也就会得到
char = '今',tag = 'B-LOC'。
-
- 构建句子文本:
sentence += char
将当前字符追加到句子中。 - 处理标签:
- 如果标签以
B
开头(表示命名实体的开始),则将[i, i, char, tag[2:]]
加入labels
列表,这表示标注实体的起始位置和类别名称。 - 如果标签以
I
开头(表示命名实体的中间部分),则更新上一个标签的结束位置labels[-1][1] = i
,并将字符追加到实体中。
- 如果标签以
- 将
sentence
和labels
存储在Data
字典中,以idx
作为键。
- 初始化
-
根据标签进行解析、存储
- if tag.startswith('B'):
labels.append([i, i, char, tag[2:]]) # Remove the B- or I-
categories.add(tag[2:])
elif tag.startswith('I'):
labels[-1][1] = i
labels[-1][2] += chartag
字符串,代表当前字符的标注信息(比如B-LOC
表示地名实体的开始,I-LOC
表示地名实体的中间部分)。char
是当前的字符- 而
i
是字符在句子中的索引。
- 代码通过处理这些信息来构建标注的列表
labels
。 -
if tag.startswith('B'):
tag.startswith('B')
是一个 字符串方法,用于检查字符串tag
是否以'B'
开头。- 功能:
- 在命名实体标注(NER)中,
B-
通常表示一个实体的开始(如B-LOC
表示地名的开始)。 - 这一行的意思是,如果当前的标注是以
'B'
开头的,则说明这是一个新实体的开始,需要对这个实体进行记录。
- 在命名实体标注(NER)中,
-
labels.append([i, i, char, tag[2:]])
-
labels.append([...])
:labels
是一个列表,存储所有的实体标注信息。append()
方法用于在列表labels
的末尾添加一个新的元素。
-
[i, i, char, tag[2:]]
:- 这里是添加的列表元素,它包含四个部分:
i
:当前字符的索引,表示实体的起始位置。i
:这里的第二个i
也是当前字符的索引,用于表示实体的结束位置。在实体的开始时,开始位置和结束位置是相同的,后续字符会逐步更新结束位置。char
:当前的字符,表示实体的初始字符。tag[2:]
:截取标注字符串tag
的从第 2 个字符到末尾的部分。tag[2:]
的作用是移除'B-'
或'I-'
,只保留类别本身。例如,如果tag
是'B-LOC'
,那么tag[2:]
就是'LOC'
,表示这个实体的类别为地名。
- 这里是添加的列表元素,它包含四个部分:
-
功能:
- 将一个新实体的起始信息添加到
labels
列表中,这包括实体的起始和结束索引、字符本身以及实体类别。
- 将一个新实体的起始信息添加到
-
-
categories.add(tag[2:])
categories
:categories
是一个 集合(set),用于存储所有的实体类别。
categories.add(tag[2:])
:add()
方法用于向集合categories
中添加新的元素。tag[2:]
:这里的作用与上面一样,是从tag
中移除'B-'
或'I-'
,只保留类别部分。例如'LOC'
、'PER'
等。
- 功能:
- 将当前实体类别添加到
categories
集合中。这确保每个类别只会出现一次,因为集合中的元素是唯一的。
- 将当前实体类别添加到
-
elif tag.startswith('I'):
elif
是else if
的缩写,用于表示在前一个条件不满足时,检查当前条件是否为真。tag.startswith('I')
:- 检查
tag
是否以'I'
开头。 - 在命名实体标注中,
I-
通常表示一个实体的中间部分,说明这个字符属于前面某个已经开始的实体的一部分。
- 检查
- 功能:
- 如果当前标注是以
'I'
开头,说明这是之前标记的实体的延续,需要更新这个实体的结束位置。
- 如果当前标注是以
-
labels[-1][1] = i
labels[-1]
:-1
表示列表的最后一个元素。labels[-1]
获取列表中的最后一个标注。
labels[-1][1] = i
:labels[-1][1]
:获取最后一个标注中的第二个值,表示当前实体的结束位置。= i
:将结束位置更新为当前字符的索引i
。
- 功能:
- 更新当前实体的结束位置,将结束索引从初始位置更新为当前字符的位置。这样可以标识出整个实体的范围。
-
labels[-1][2] += char
labels[-1][2]
:- 获取列表
labels
中最后一个标注的第三个值,表示实体当前已累积的字符。
- 获取列表
labels[-1][2] += char
:+= char
表示将当前字符追加到之前的实体字符后面。例如,如果之前字符是'北'
,当前字符是'京'
,那么结果会变为'北京'
。
- 功能:
- 将当前字符追加到现有的实体字符串中,以构建出完整的实体内容。
- 例子
- 假设我们处理的标注信息是:
- 今 B-LOC
天 I-LOC
天 O
气 O
好 O
- 今 B-LOC
-
流程:
-
对于第一个字符
'今'
,标注为'B-LOC'
:if tag.startswith('B')
为真,表示这是一个新的实体。labels.append([i, i, char, tag[2:]])
添加一个新实体:[0, 0, '今', 'LOC']
。categories.add(tag[2:])
将'LOC'
加入类别集合。
-
对于第二个字符
'天'
,标注为'I-LOC'
:elif tag.startswith('I')
为真,表示这是之前实体的延续。labels[-1][1] = i
更新结束位置为1
,变为[0, 1, '今', 'LOC']
。labels[-1][2] += char
将字符'天'
追加到已有的字符,变为[0, 1, '今天', 'LOC']
。
-
对于后面的字符
'天'
、'气'
、'好'
,它们标注为'O'
,表示非实体,代码不会处理这些字符的标注。
-
- 假设我们处理的标注信息是:
-
Data[idx] = { 'sentence': sentence, 'labels': labels }
-
这里的
Data[idx]
表示为字典Data
添加一个新的键值对,其中: idx
是段落的索引(通常从 0 开始),作为字典的 键。每个段落有唯一的索引值idx
。{'sentence': sentence, 'labels': labels}
是字典的 值,包含两个部分:'sentence': sentence
:sentence
是段落的完整内容,表示该段落的句子字符串。'labels': labels
:labels
是与sentence
对应的标注信息,包含关于每个实体的起始位置、结束位置、字符、类别等。
- 例子
- 假设文件内容包含以下段落:
-
段落1:
今 B-LOC
天 I-LOC
天 O
气 O
好 O段落2:
上 B-LOC
海 I-LOC
真 O
美 O
丽 O -
在代码执行过程中:
-
第一个段落被处理后,生成的
sentence
和labels
分别是:sentence = "今天天气好"
labels = [[0, 1, '今天', 'LOC']]
:表示从索引0
到1
的字符'今天'
是一个地名(LOC
)。
-
第二个段落被处理后,生成的
sentence
和labels
分别是:sentence = "上海真美丽"
labels = [[0, 1, '上海', 'LOC']]
:表示从索引0
到1
的字符'上海'
是一个地名(LOC
)。
-
最后,
Data
字典可能会变成以下结构: -
Data = {
0: {
'sentence': '今天天气好',
'labels': [[0, 1, '今天', 'LOC']]
},
1: {
'sentence': '上海真美丽',
'labels': [[0, 1, '上海', 'LOC']]
}
}
-
- if tag.startswith('B'):
__len__
方法:返回数据集长度
def __len__(self):
return len(self.data)
返回数据集的样本数量,用于在训练过程中获取数据集的长度。
__getitem__
方法:获取指定索引的数据样本
def __getitem__(self, idx):
return self.data[idx]
根据给定的索引 idx
,返回相应的数据样本,通常包括一个句子及其对应的标签。
构建数据集部分的总结
对于这一段代码,我的理解就是,它其实是首先根据两次空行'\n\n'来划分段落,然后再根据一次空行'\n'来划分句子,接着再根据空格' '来划分字符和标签,最后通过判断标签来进行提取,将得到的结果输出为需要的字和标签的集合。我这样理解正确吗?
代码处理的步骤大致如下:
- 按段落划分:首先按两个换行符
\n\n
将文本划分成不同的段落。 - 按句子划分:然后按换行符
\n
将每个段落划分成多个句子。 - 按空格划分字符和标签:接着,按空格将每一行分割为字符和标签。
- 提取字和标签:根据标签(如
B-LOC
,O
等)来处理每个字符,构建句子和标注。 - 存储数据:最后,保存每个段落的句子和标注信息到
Data
字典中。
最终输出的 Data
字典包含了所有段落的句子和对应的标注,通常用于命名实体识别(NER)等任务。
数据预处理
id2label = {0:'O'}
for c in list(sorted(categories)):
id2label[len(id2label)] = f"B-{c}"
id2label[len(id2label)] = f"I-{c}"
label2id = {v: k for k, v in id2label.items()}
print(id2label)
print(label2id)
建立标签映射字典
为了能够让模型理解和处理数据,需要将每个实体标签转换为数值形式,这样模型可以接收并进行训练。这里我们使用两个字典:
id2label
:将ID映射到标签。label2id
:将标签映射到ID。
代码分析
id2label = {0: 'O'}
id2label
是一个字典,用于将ID映射到标签。- 初始化
id2label
,将ID0
对应的标签设置为'O'
,表示非实体。'O'
通常用于表示这个词或字符不是任何命名实体的一部分。
for c in list(sorted(categories)):
id2label[len(id2label)] = f"B-{c}"
id2label[len(id2label)] = f"I-{c}"
-
for c in list(sorted(categories)):
- 遍历所有在
categories
集合中的标签类别(例如LOC
,ORG
,PER
等)。 categories
是之前通过遍历数据集构建的集合,包含所有的命名实体类别。- 使用
sorted(categories)
是为了确保标签按照字母顺序排列,这样生成的标签ID是一致的,不会因为集合的无序性导致标签ID不一致。
- 遍历所有在
-
id2label[len(id2label)] = f"B-{c}"
:len(id2label)
:获取当前id2label
的长度,作为新标签的ID。f"B-{c}"
:这是f-string格式化字符串,用于生成'B-LOC'
,'B-ORG'
,'B-PER'
等标签。B-{c}
:其中'B'
表示这个实体是命名实体的开始部分,{c}
是实体的类别名称(如LOC
,ORG
,PER
)。- 然后将生成的标签加入
id2label
,使得新的ID映射到相应的标签。 -
使用类似的方式添加
'I-LOC'
,'I-ORG'
,'I-PER'
等标签。I
表示这个实体是命名实体的中间部分或连续部分。- 在
f"B-{c}"
中,{c}
被大括号框起来的原因是因为它是 f-string(格式化字符串)的语法,用来插入变量或表达式的值。B-
是一个固定的字符串部分,表示命名实体的开始标签(例如,表示地点的实体LOC
,组织的实体ORG
等)。{c}
是一个变量插入部分,c
是循环中的当前类别(比如'LOC'
,'ORG'
,'PER'
等)。
最终 id2label
的结果
在遍历完所有的实体类别后,id2label
字典最终可能看起来像这样:
{0: 'O', 1: 'B-LOC', 2: 'I-LOC', 3: 'B-ORG', 4: 'I-ORG', 5: 'B-PER', 6: 'I-PER'}
- ID
0
对应'O'
,表示非实体。 - ID
1
对应'B-LOC'
,表示地名的开始部分。 - ID
2
对应'I-LOC'
,表示地名的中间部分。 - 依次类推...
建立反向映射字典 label2id
label2id = {v: k for k, v in id2label.items()}
label2id
是一个反向映射字典,用于将标签映射回ID。{v: k for k, v in id2label.items()}
是一个字典推导式,用于反转id2label
的键值对。id2label.items()
返回id2label
中的所有键值对。v: k for k, v in id2label.items()
将每个键值对中的键(ID)和值(标签)进行交换。
- 生成的
label2id
字典会看起来像这样:
{'O': 0, 'B-LOC': 1, 'I-LOC': 2, 'B-ORG': 3, 'I-ORG': 4, 'B-PER': 5, 'I-PER': 6}
这样就可以通过标签找到对应的ID。例如,'B-LOC'
对应的ID是 1
。
print
打印结果
print(id2label)
print(label2id)
这两行代码是用于查看生成的 id2label
和 label2id
字典的内容,以确认标签和ID的映射关系是否正确。
输出结果为:
{0: 'O', 1: 'B-LOC', 2: 'I-LOC', 3: 'B-ORG', 4: 'I-ORG', 5: 'B-PER', 6: 'I-PER'}
{'O': 0, 'B-LOC': 1, 'I-LOC': 2, 'B-ORG': 3, 'I-ORG': 4, 'B-PER': 5, 'I-PER': 6}
这些输出展示了标签和ID之间的双向映射关系,方便在模型训练和推理时进行转换。
作用和用途
- 标签转换为数值形式:神经网络模型只能接受数值输入,因此需要将标签(例如
'B-LOC'
,'I-PER'
)转换为数值ID。这就是label2id
的用途。 - 预测结果转换回标签形式:在模型进行推理之后,预测的输出是数值ID,需要将这些ID转换回可理解的标签。这就是
id2label
的用途。