金融文本信息抽取

概要

仓库地址

任务要求

在投资研究过程中,上市公司公告是投资者的重要参考材料,挖掘公告重要信息是研究员每日的必要功课,但海量公告却令人脑难以负荷。如果机器能够根据需求,自动抽取结构化数据,就能帮助研究员快速获取投资线索。

相关名词解释

  • 【信息披露(公告)】

    主要是指公众公司以招股说明书、上市公告书以及定期报告和临时报告等形式,把公司及与公司相关的信息,向投资者和社会公众公开披露的行为。目前,上市公司所发布的公告,是投资者及社会公众了解企业情况,进行投资决策的基本依据。

  • 【股东增减持】

    由于上市公司股东、高管相较社会公众更加了解公司的发展状况,因此投资者会格外关注重要股东的买卖行为,并以此作为投资参考。比如:

    “股东增持行为”通常表示公司股东对公司营收及发展前景有信心,投资者会跟随追捧,有利于提升公司股价;

    “股东减持行为”除股东个人原因外,也可能表示股东对公司发展信心不足,这会给投资者带来一定负面影响,导致投资者抛售股票,公司股价下跌。

  • 【定向增发】

    上市公司定向增发的主要目是通过融资扩张公司业务和规模,例如发起新项目,研发新技术,收购其他公司等。投资者可以通过定增目的了解公司的融资意图,从而判断公司前景以及投资价值。

  • 【重大合同】

    上市公司签署重大合同,有利于增加公司营业收入,投资者通过了解合同项目金额,可进一步预测公司未来的经营和业绩情况,从而发掘投资机会。

项目思路

此项目需要我们通过实体识别(NER)来进行html文件的信息提取。

首先我们需要对原始数据进行基于html格式的结构提取,这一步通过调用beautiful soup实现。一方面是对html中的paragraph和content进行识别并提取,另一方面是提取另一个重要的信息——表格,并以二维数组的形式保存。

针对文本内容,我们先使用pyltp自带的模型进行初步的实体标注。然后依据不同种类公告的需要,用相应的正则表达式进行第二轮实体标注。之后,我们在已经标注好的文本上,进行目标信息的匹配。目标信息的匹配同样取决于不同种类的披露信息需求,因此我们分了三个信息抽取器进行工作。

针对表格内容的处理则更加直观。经过对三类文件表格的观察,我们预先设置了三个正则配置文件,分别对应我们所需要的实体信息。然后我们处理表格的时候只需遍历表格,并与配置文件进行匹配即可。

我们在建立了NER处理系统之后,衡量了时间和难度,最终采用了人工制作的语法系统进行记录的匹配。虽然这样召回率不高,但能够保证较高的精度,使得最终的F1也不会特别差。

docparser — html 文件解析

这个模块进行的工作是将html格式的原始数据解析成能被后续程序所处理的类和数组。

文本解析

在文本解析中,我们不仅需要尽可能多的解析到文本内容,同时最好能保留部分的文件结构。最后我们仅实现了一级段落粒度上的结构提取。我们解析html数据中的段落(paragraph)文本,并最终返回一个由段落组成的数组。

段落解析

1
2
3
4
5
6
7
8
9
soup = BeautifulSoup(fp.read(), "lxml")
paragraphs = []
for div in soup.find_all('div'):
div_type = div.get('type')
if div_type is not None and div_type == 'paragraph':
paragraphs.append(div)

for paragraph_div in paragraphs:
self.subparagraph_parse(paragraph_div)

打开文件后,对beautifulsoup进行初始化。一级段落处理很简单,遍历所有的div元素并判断是否为paragraph,是则加入段落数组。值得注意的是,我们这里的find_all()方法是递归的,即所有级别的段落都已加入段落数组。

遍历完文件之后,我们需要遍历段落数组,进行内容的解析。

内容解析

1
2
3
4
5
rs.append([])
for content_div in paragraph_div.find_all('div', recursive=False):
div_type = content_div.get('type')
if div_type is not None and div_type == 'content':
rs[-1].append(TextUtils.clean_text(content_div.text))

对于每个一级段落,仅将该段落下的一级content加入数组,这样我们就能没有重复地得到html文件中所有的content。

表格解析

因为表格的格式非常繁多,尤其是表头可能有多级结构,很难处理。我们经过观察后,最后实现了一级和二级表头的表格处理。

单个表格去结构化

首先是将表格去结构化,对于跨越多行或者多列的格,将其拆分为多个内容冗余的单元格。对于原表格的结构,仅保留是否含二级表头的标识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
for tr in table.find_all('tr'):
col_index, cur_col_index = 0, 0
for td in tr.find_all('td'):
rowspan = td.get('rowspan')
rowspan = int(rowspan) if (rowspan is not None and int(rowspan) > 1) else 1
colspan = td.get('colspan')
colspan = int(colspan) if (colspan is not None and int(colspan) > 1) else 1
if is_head:
if row_index > 0:
is_head = False
elif rowspan > 1 or colspan > 1:
is_head_two_rowspan = True
is_head = False
content = TextUtils.remove_blank_chars(td.text)
for r in range(rowspan):
if (row_index + r) not in rs_dict:
rs_dict[row_index + r] = {}
cur_col_index = col_index
for c in range(colspan):
while cur_col_index in rs_dict[row_index + r]:
cur_col_index += 1
rs_dict[row_index + r][cur_col_index] = content
cur_col_index += 1
col_index = cur_col_index
row_index += 1
return rs_dict, is_head_two_rowspan

恢复表头二级结构

对可能的二级表头进行恢复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if is_head_two_rowspan and row_length > 2:
try:
new_table_dict = {}
head_row = {}
col_length = len(table_dict[0])
for col_idx in range(col_length):
head_row[col_idx] = table_dict[0][col_idx] + table_dict[1][col_idx]
new_table_dict[0] = head_row
for row_idx in range(2, row_length):
new_table_dict[row_idx - 1] = table_dict[row_idx]
rs_list.append(new_table_dict)
except KeyError:
rs_list.append(table_dict)
else:
rs_list.append(table_dict)

NER — 命名实体识别

标签文本类

  此类是用于存储原始文本和相应标注好的标签列表,并在其中定义了有效标签集合和实体的释义词典。有效标签集合定义如下:

1
2
self.valid_tag_set = {'Nh', 'Ni', 'nt', 'v', 'm', 'q'}
self.tag_entity_dict = {'Nh': 'person', 'Ni': 'org', 'nt': 'date', 'm': 'num', 'mp': 'percent'}

对于有效标签集合,就是pyltp模型中主动识别的标签的一部分,解释如下:

  • Nh — person name 人名 实体
  • Ni — organization name 组织名 实体
  • nt — temporal noun 时间名词
  • v — verb 动词
  • m — number 数量
  • q — quantity 量词

对于实体词典,其中Nh\Ni\nt\v\m与有效集合元素对应,只新增了一个实体标签mp用于表示百分比实体。

  另外,为了获取我们需要的标签文本(即只标注实体词典的部分),使用的方法如下

1
2
3
4
5
6
tagged_str = ""
for word, tag in self.tagged_seg_list:
if tag in self.tag_entity_dict:
tagged_str += "<%s>%s</%s>" % (self.tag_entity_dict[tag], word, self.tag_entity_dict[tag])
else:
tagged_str += word

这部分很好理解,在实体标注列表中选取包含在自定义的实体列表中的标签即可,其余文本不变。


NER模型标注类

  这部分为实体识别标注的关键类,包含了模型的初始化即调用,最关键的就是根据外部利用规则得到的实体名词在初始标签后的修正方法。

初始化

pyltp模型是直接从网上下载的,此处只需要告诉模型路径即可

1
2
3
4
self.model_dir_path = model_dir_path
self.cws_model_path = os.path.join(self.model_dir_path, 'cws.model')
self.pos_model_path = os.path.join(self.model_dir_path, 'pos.model')
self.ner_model_path = os.path.join(self.model_dir_path, 'ner.model')

接下来直接初始化pyltp中的三个类

1
2
3
self.segmentor = pyltp.Segmentor()
self.segmentor.load(self.cws_model_path)
...

此处仅展示分词模型的初始化,其他两个类似

最后还有初始化公司黑名单,这个名单里表示这些名称会被模型识别为公司,但本身并不代表公司,我们会在后续修正中除掉这些名称。

1
2
3
4
5
self.com_blacklist = set()
with open(blacklist_path, 'r', encoding='utf-8') as f_com_blacklist:
for line in f_com_blacklist:
if len(line.strip()) > 0:
self.com_blacklist.add(line.strip())

NER模型标注及修正

  主体函数很简短,如下

1
2
3
4
5
6
words = self.segmentor.segment(text)  # 分词
post_tags = self.postagger.postag(words) # 词性标注
ner_tags = self.recognizer.recognize(words, post_tags) # 命名实体识别
entity_list = self.construct_entity_list(words, post_tags, ner_tags)
entity_list = self.ner_tag_by_dict(entity_dict, entity_list)
return NERTaggedText(text, entity_list)

前三行为该模型使用的标准方法,首先分词,再对每个分词做词性标注,最后根据词性找出其中的命名实体,这种方法得到的实体列表对于文本中所有的字符均有定义,包括标点符号等。

第四行为实体列表的创建,其目的主要有两个:排除公司黑名单中的实体标注;合并时间、排除错误数字识别、识别百分数实体。

第五行主要为根据外部正则表达提取的实体名词进行修正,主要包含两种行为:一个实体中包含了自定义实体名词,需要对该实体拆分重组;多个实体合并后能够匹配自定义实体名词,需要对多个实体合并。

最后返回标签文本类。

创建实体列表

主体为对于之前模型得到的三个返回列表的循环

1
2
3
4
entity_list = []
for word, post_tag, ner_tag in zip(words, post_tags, ner_tags):
...
return entity_list

首先,获取实体标注的位置标签和实体类型标签

1
2
tag = ner_tag[0]  # 位置标签
entity_type = ner_tag[2:] # 实体类型标签

此处要对该模型使用的标签体系进行说明:

命名实体识别 (ner_tags) 格式:
位置标签(B,I,E,S,O)-实体类型标签(Nh,Ns,Ni)
O后不跟-符号
位置标签含义:B-实体开始词;I-实体中间词;E-实体结束词;S-单独实体;O-不构成实体
实体类型标签:Nh-人名;Ns-地名;Ni-机构名

如果是单独实体,比如这样的词,我们直接加入实体列表

1
2
if tag == 'S':
entity_list.append((word, entity_type))

如果是多部分组成实体,我们则需要将他们合并,并且排除公司黑名单

1
2
3
4
5
6
7
8
9
elif tag in 'BIE':
entity += word
if tag == 'E':
# 判断公司名黑名单
if entity in self.com_blacklist:
entity_list.append((entity, "n"))
else:
entity_list.append((entity, entity_type))
entity = ""

正如上面阐释的标签体系,识别为E时代表实体结束,可以进行判断。此处自定义标签n不存在于有效标签集合即可,可以自行定义别的标签,只要不在有效集合中。

非实体识别及修正

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
elif tag == 'O':
# 循环直至识别一个完整的时间名词
if post_tag == 'nt':
entity += word
else:
if entity != "":
entity_list.append((entity, 'nt'))
entity = ""
# 排除错误数字识别,例如“大宗”
if post_tag == 'm' and not re.match("[0-9]+.*", word):
post_tag = 'n'
# 识别数字中的百分数
if post_tag == 'm' and re.match("[0-9.]+%", word):
post_tag = 'mp'
entity_list.append((word, post_tag))

时间名词可能不只一个,比如2018年3月15日就是三个连续的时间,我们需要合并这样的时间实体。

剩下识别错误数字和百分数很好理解,在数量标签中正则匹配即可。

外部词典修正——拆分实体

初始化基本数据及主要循环

1
2
3
4
5
6
legal_tag = entity_dict.values()
j = 0
limit = len(entity_list)
while j < limit:
...
j += 1

我们只对外部词典中含有的标签类型进行修正,这里的limit会在循环中变化,主要是当实体分裂后,实体列表会扩张,于是上限就必须改变。

简单情况判断,如果实体恰好在外部词典中得到匹配,则没必要进行拆分识别,提升效率

1
2
3
4
long_entity = entity_list[j][0]
if long_entity in entity_dict:
j += 1
continue

如果不在,我们就需要进一步判断是否需要拆分该实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
else:
k = 0
new_entity = []
limit2 = len(long_entity) - 1
while k < limit2:
has_entity = False
for entity_len in range(len(long_entity) - k, 0, -1):
segment = long_entity[k:k + entity_len]
if segment in entity_dict:
has_entity = True
new_entity.append((long_entity[0:k], 'raw'))
new_entity.append((segment, entity_dict[segment]))
long_entity = long_entity[k + entity_len:]
k = 0
limit2 = len(long_entity) - 1
break
if not has_entity:
k += 1

此处主循环判断条件为k的取值大小。循环顺序为:

  • 1.从起始字符开始,从该字符到结尾字符开始截断并进行匹配,长度逐渐减小,直到只包含起始字符后一个字符。而k的值此时就代表起始字符的位置,当一轮匹配没有结果时,我们需要将k加一,将下一个字符作为起始字符

  • 2.如果在某次截断匹配上了,则我们需要将该长实体拆分,即[0:k]、截断、[k+len:]三部分,而第一部分已经是我们扫描过的,不存在实体,所以后续可以直接分词重新标注,而截断已经匹配好,直接插入即可,将后半部分作为新的长实体从头开始步骤1,所以需要将k值归零,重置上界limit2,进入新的while循环

最后是将非实体词典中的部分重新完成实体识别和建表过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if len(new_entity) == 0:
j += 1
continue
new_entity.append((long_entity, 'raw'))
del entity_list[j]
for cut in new_entity:
if cut[1] in legal_tag:
entity_list.insert(j, cut)
j += 1
else:
words = self.segmentor.segment(cut[0])
post_tags = self.postagger.postag(words)
ner_tags = self.recognizer.recognize(words, post_tags)
for entity in self.construct_entity_list(words, post_tags, ner_tags):
entity_list.insert(j, entity)
j += 1
limit = len(entity_list)
continue

插入过程j始终随着最新插入数据自加,这样最后结束时,j依然指在实体列表中下一个需要检测实体

外部词典修正——合并实体

此部分逻辑较简单且主体与上面类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
i = 0
while i < len(entity_list) - 1:
has_entity = False
for entity_len in range(4, 1, -1):
segment = "".join([x[0] for x in entity_list[i: i + entity_len]])
# 将 2 到 4 个相邻的分词合并
segment_uni = segment
if segment_uni in entity_dict:
has_entity = True
entity_list[i] = (segment, entity_dict[segment_uni])
del entity_list[i + 1: i + entity_len]
i = i + entity_len
break
if not has_entity:
i += 1

此处认为最多为4个实体能够合并为一个词典实体。


extract — 信息抽取

增减持

由于主体代码框架最初都是来自于github的项目代码,这里避免叙述冗杂,很多地方将会略过。

主调用函数

1
2
3
4
5
6
7
8
9
10
11
12
rs = []
paragraphs = self.html_parser.parse_content(html_file_path)
rs_paragraphs = self.extract_from_paragraphs(paragraphs, html_id)
for table_dict in self.html_parser.parse_table(html_file_path):
rs_table = self.extract_from_table_dict(table_dict)
if len(rs_table) > 0:
# 第二个有效表格一定是增减持之后的数量和占比
if len(rs) > 0:
self.merge_record(rs, rs_table)
break
else:
rs.extend(rs_table)

此部分首先对文本进行了分析,接下来就是处理表格部分数据。

self.extract_from_paragraphs为提取文本类数据,后续会提及。

self.html_parser.parse_tableparser部分的函数,用于解析表格数据。

self.extract_from_table_dict为提取表格数据函数,在后续会提到。

self.merge_record为合并记录函数,功能是将第二个参数与第一个参数的股东名匹配,第二个参数存储的增减持之后的持股数量和占比,通过匹配,能够接在第一个参数中表格记录的最后一个有效记录之后。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if len(rs) <= 0:
return rs_paragraphs
else:
for record in rs:
full_company_name, abbr_company_name = self.get_shareholder(record.shareholderFullName)
record.shareholderFullName = full_company_name
record.shareholderShortName = abbr_company_name
price_pattern = re.compile(r'[\d\\.]+')
if record.sharePrice is not None:
m_price = price_pattern.findall(record.sharePrice)
if m_price is not None:
record.sharePrice = '-'.join(m_price)[:-1]
self.trans(record, html_id)
return rs

若没有表格数据,则直接返回文本数据。否则对所有表格数据作一些处理并返回。

self.get_shareholder功能为根据文本分析部分构建的股东全简称词典,将记录股东名称补全。

price_pattern = re.compile(r'[\d\\.]+')此处对价格进行修正,主要是发现有的价格记录是区间,由于NER模型的问题,有的出现了中文,有的范围之间也有其他部分。因此搜索所有的价格数字,并用-连接。

self.trans主要是用于检测时间是否只包含年和月,若只包含年月,则需要在public_time文件中去寻找公告发布时间。


表格数据提取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
head_row = table_dict[0]
col_length = len(head_row)
# 遍历表格第一行 (表头) 的元素
for i in range(col_length):
text = head_row[i]
# 尝试匹配 table_dict_field_pattern 中的各个模式
for (field_name, table_dict_field_pattern) in self.table_dict_field_pattern_dict.items():
# 匹配成功
if table_dict_field_pattern.is_match_pattern(text) and \
not table_dict_field_pattern.is_match_col_skip_pattern(text):
if field_name not in field_col_dict:
field_col_dict[field_name] = (i, "")
if '%' in text or field_name == 'sharePcntAfterChg':
field_col_dict[field_name] = (i, '%')
if '万' in text:
field_col_dict[field_name] = (i, '万')
# 逐行扫描这个字段的取值,如果满足 row_skip_pattern 则丢弃整行 row
for j in range(1, row_length):
try:
text = table_dict[j][i]
if table_dict_field_pattern.is_match_row_skip_pattern(text):
skip_row_set.add(j)
except KeyError:
pass

首先是遍历表头,根据config/ZengJianChiConfig.json预先设定好的简单规则,匹配表头模式,匹配成功则在field_col_dict词典中增加模式名,这里为了修正部分数据问题,在列数之后增加了后缀,用于后续添加到数据后。从该列第二行开始搜索,记录每行是否为需要跳过的行,比如合计等字眼的行需要跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
exit_flag = False
for row_index in range(1, row_length):
if row_index in skip_row_set:
continue
record = ZengJianChiRecord(None, None, None, None, None, None, None)
for (field_name, col_index) in field_col_dict.items():
try:
text = table_dict[row_index][col_index[0]]
text += "" if col_index[1] in text else col_index[1]
if field_name == 'shareholderFullName':
record.shareholderFullName = self.table_dict_field_pattern_dict.get(field_name).convert(text)
...
elif field_name == 'sharePcntAfterChg':
record.sharePcntAfterChg = self.table_dict_field_pattern_dict.get(field_name).convert(text)
exit_flag = True
break
else:
pass
except KeyError:
pass
rs.append(record)
if exit_flag:
break

判断该行是否需要跳过。不需要跳过,则需要根据之前存储的词典进行记录的转换。其中为了跳过第二个表格中不必要部分,只选择第一行的合计数据进行记录,因此最后比例那项一旦完成记录则不再继续进行之后的判断。


文本数据提取

细节函数此处不再详细描述,只考虑提取的几个主要函数。

主调用函数

1
2
3
4
5
6
7
8
9
self.clear_com_abbr_dict()
change_records = []
change_after_records = []
record_list = []
# 对各个段落进行抽取
for para in paragraphs:
change_records_para, change_after_records_para = self.extract_from_paragraph(para)
change_records += change_records_para
change_after_records += change_after_records_para

self.extract_from_paragraph为提取增减持记录和最后持股数据的函数。

1
2
3
4
5
6
7
8
9
10
11
12
# 保持各条记录中的公司全称一致
self.sort_and_modify(change_records, change_after_records)
# 对截止日期相同的记录进行去重
change_records = sorted(change_records, key=lambda r: r.finishDate)
limit = len(change_records) - 1
i = 0
while i < limit:
if change_records[i].finishDate == change_records[i + 1].finishDate:
del change_records[i]
limit -= 1
else:
i += 1

self.sort_and_modify用于对记录中不同股东名称进行统一处理。这主要是由于记录匹配有的时候不太合理,导致多条记录且股东名称不一样。该函数主要是记录同一个股东名称出现的记录数,选择记录最多的股东名称替换掉其余的所有记录,保证了记录股东名称的一致性。

之后是记录去重,由于有的时候会出现重复数据,因此同一个截止日期的记录需要去掉。且所有记录会排好序,这是为了在最新的记录后显示增减持后持股数据。

1
2
3
4
self.merge_record(change_records, change_after_records)
for record in change_records:
record_list.append(self.trans(record, html_id))
return record_list

此处合并数据和寻找日期和之前表格处理相同,不再赘述。


NER处理部分

1
2
3
4
5
6
7
8
9
10
11
tag_res = self.ner_tagger.ner(paragraph, self.com_abbr_ner_dict)
tagged_str = tag_res.get_tagged_str()
# 抽取公司简称以及简称
new_size = self.extract_company_name(tagged_str)
if new_size > 0:
tag_res = self.ner_tagger.ner(paragraph, self.com_abbr_ner_dict)
tagged_str = tag_res.get_tagged_str()
# 抽取变动记录,变动后记录
change_records = self.extract_change(tagged_str)
change_after_records = self.extract_change_after(tagged_str)
return change_records, change_after_records

这个为中间过程函数。首先对文本进行第一次实体识别,然后手动提取公司名称,如果有新的公司名称,则需要配合自定义实体词典去完成第二次实体标注,之后便是基本的提取记录和增减持后持股数据。


公司名提取

1
r'(股东|<org>){1,2}(?P<com>.{1,28}?)(</org>)?[((].{0,5}?简称:?("|“|<org>)?(?P<com_abbr>.{2,20}?)("|”|</org>)?[))]'

此处没有太多需要解释的,基本都是在实践过程中总结出来的规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for target in targets:
# 股东简称
com_abbr = target.group("com_abbr")
# 股东名称
com_name = target.group("com")
if '<' in com_abbr or '>' in com_abbr:
com_abbr = self.delete_and_modify(com_abbr)
if '<' in com_name or '>' in com_name:
com_name = self.delete_and_modify(com_name)
if com_abbr is not None and com_name is not None:
self.com_abbr_dict[com_abbr] = com_name
self.com_full_dict[com_name] = com_abbr
self.com_abbr_ner_dict[com_abbr] = "Ni"
self.com_abbr_ner_dict[com_name] = "Ni"

self.delete_and_modify函数用于删除股东名称或简称中<>之间的内容,这是在提取的时候发现的细节问题,有的名称里出现了其他标签。之后将新的公司全称和简称加入自定义实体词典,用于新的一轮实体标注。


增减持记录提取

1
r'(出售|减持|增持|买入)了?[^,。.,::;!??()()“”"<>]*?(股票|股份|(<org>([^.。,,<>]*?)</org>))[^.。,,《》]{0,30}?<num>(?P<share_num>.{1,20}?)</num>股?'

此部分规则挺复杂,也是反复处理得到的,所以不用深度学习实在是很麻烦。

1
2
3
4
5
6
7
8
9
10
11
# 查找公司
pat_com = re.compile(r'<org>(.*?)</org>')
m_com = pat_com.findall(paragraph, 0, start_pos)
shareholder = ""
if m_com is not None and len(m_com) > 0:
shareholder = m_com[-1]
else:
pat_person = re.compile(r'<person>(.*?)</person>')
m_person = pat_person.findall(paragraph, 0, start_pos)
if m_person is not None and len(m_person) > 0:
shareholder = m_person[-1]

此部分为查找股东名称(公司或者人名),从文本开头到匹配,出现的最后一个公司名或人名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
      # 查找日期
period_find = re.compile(r'。|(后续)|(不[低高]于)')
last_date = None
change_date = ""
for pat_date in pat_dates:
# 循环至变动数量之前的最后一个时间段名词
if pat_date.end() < start_pos:
last_date = pat_date
else:
break
if last_date is not None:
tmp_end = last_date.end()
# 日期与变动数量之间
# 存在句号:说明日期与变动数量很可能没有联系
if len(period_find.findall(paragraph, tmp_end, end_pos)) <= 0:
change_date = last_date.group().split('>')[1].split('<')[0]

查找日期,首先找到离记录最近的日期记录,查看该日期与记录间是否存在period_find模式里的片段,存在则说明没有关联或者不是我们需要的日期记录。

1
2
3
4
5
6
7
# 查找变动价格
pat_price = re.compile(r'(均价|(平均)?(增持|减持|成交)?(价格|股价))([::为])?<num>(?P<share_price>.*?)</num>')
m_price = pat_price.search(paragraph, start_pos)
period_find = re.compile(r'。')
share_price = ""
if m_price is not None and len(period_find.findall(paragraph, start_pos, m_price.end())) <= 0:
share_price = m_price.group("share_price")

类似匹配,在记录之后寻找变动价格,且之间不能包含句号,否则说明并不存在关联。


增减持后持股数据提取

1
r'(增持(计划实施)?后|减持(计划实施)?后|变动后)[^。;;]*?持有.{0,30}?<num>(?P<share_num_after>.*?)</num>(股|万股|百万股|亿股)?'

股东名称同上面相同,不再赘述。

1
2
3
4
5
6
7
# 查找变动后持股比例
pat_percent_after = re.compile(r'<percent>(?P<share_percent>.*?)</percent>')
m_percent_after = pat_percent_after.search(paragraph, start_pos)
period_find = re.compile(r'。')
share_percent_after = ""
if m_percent_after is not None and len(period_find.findall(paragraph, start_pos, m_percent_after.end())) <= 0:
share_percent_after = m_percent_after.group("share_percent")

查找持股比例,且不能有句号分隔。


重大合同

在重大合同的信息抽取中,我们需要从文本中提取以下信息:
hetong1.png

我们仍然使用 ner 打标签 + 正则匹配的方式提取信息。由于绝大部分关于重大合同的公告中并没有表格,所以这些数据都需要制定特定的匹配规则,下面分别进行叙述。

乙方

乙方名称的识别比较容易。一般来说,重大合同的公告都是由乙方发出的,所以公告的标题行很多都是 “xx公司关于xx重大合同的公告”,如下所示:

yifang1.png
yifang2.png

观察经由 nerTagger 添加标签后的 html 文件发现,标题行中的公司名称一般都能正确地被识别为组织名,并且被 <org><\org> 分离开来。基于这个观察,我们决定将文本中第一个识别出来的组织名作为乙方名称。这个规则对所有的 html 文件都有输出 (也就是说所有 html 都能识别出组织名,尽管可能不是在标题行中识别到的)。但是这样识别出来的名称有一个问题:由于 html 解析段落时没能很好分离公告信息行以及标题行,公告信息最后的公告标号会跟标题中的公司名称黏在一起,被整个识别为一个组织。因此,需要一个额外的子程序 remove_number_in_name() 来去除这些可能出现的编号。

1
2
3
4
5
6
7
partyB_pattern = re.compile(r'(<org>)(?P<partyB>.{1,28}?)(</org>)')
for text in tagged_paragraphs:
search_obj = partyB_pattern.search(text)
if search_obj:
partyB_name = search_obj.group('partyB')
return self.remove_number_in_name(partyB_name)
return ''

甲方

甲方的提取就比乙方难了不少,一个原因是甲方在文中出现的位置并不像乙方那样有固定的模式,并且甲方的上下文环境类型较多,需要浏览大量 html 来确定出现频率较高的句法模式;另一个原因是有很多文件的甲方为 “xx市xx局” 这种不是以公司为后缀的命名实体,而 ner 对这类实体的识别效果并不理想,因此也不能像乙方那样直接用 <org><\org> 进行匹配。在经过对训练数据的观察之后,我们制定了如下规则来抽取可能的甲方名称:

  • “与|和 … 签署|签订”:这里省略号的位置通常就是甲方,如下所示。

    jiafang1.png

  • “接到|收到 … 发来|发出”:一般这种句式用来说明乙方收到了甲方发来的中标通知。

    jiafang2.png

甲方的匹配由 extract_partyA() 完成,其主要内容就是在各个段落中寻找上述模式。不过这样找到的 “甲方” 可能会有多个,所以需要进行选择,这部分工作由 select_partyA() 完成。选择的策略是,对这些名称根据出现次数进行排序,出现频率较高的选为结果返回。如果有两个名称出现次数相等,就检测其之间是否存在包含关系。一般来说,由于实体识别以及规则制定的缺陷,可能会有部分匹配到的名称带有多余的单词,这样作为子串的名称才是正确的名称。如果上述策略依旧无法确定出结果,那就任意返回一个结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def extract_partyA(self, tagged_paragraphs):
partyA_candidates = []
partyA_pattern = re.compile(r'(与|和)(.*)(<org>)?(?P<partyA>.{1,50}?)(</org>)?(.*)(签订|签署)')
for text in tagged_paragraphs:
match_objs = partyA_pattern.finditer(text)
for match_obj in match_objs:
partyA_name = match_obj.group('partyA')
partyA_candidates.append(partyA_name)

partyA_pattern = re.compile(r'(收到|接到)(<org>)?(?P<partyA>.{1,28}?)(</org>)?(发出|发来)')
# ... 同上

if len(partyA_candidates) > 0:
return self.select_partyA(partyA_candidates)
return ''

项目名称

抽取的思路与抽取甲方基本相同,只不过规则换为:

  • 用书名号 《》 括起来并且以 “项目”、”标”、”标段” 或者 “工程” 等字眼结尾的 (一种较宽松的规则是只要包含这些字眼,并且这些字眼与 “》” 相隔不超过 10 个字的就选入);

    projname3.png

  • 用双引号 “” 括起来并且以 “项目”、”标”、”标段” 或者 “工程” 等字眼结尾的;

    projname2.png

  • 明显的表明 “项目名称” 的;

    projname1.png

  • 说明 “中标 xx 标段” 的;

    projname4.png

  • “为 xx 标|标段|项目” 的。

    projname5.png

1
2
3
4
5
re.compile(r'《(?P<proj_name>.{1,100}?(标|标段|项目|工程))[^,。)》]{1,10}》')
re.compile(r'“(?P<proj_name>.{1,100}?(标|标段|项目|工程))[^,。)》]{1,10}”')
re.compile(r'(中标项目|项目名称)([:“])(?P<proj_name>.{1,100}?)([。”)(])')
re.compile(r'(中标)(?P<proj_name>.{1,100}?标|标段[)]?)')
re.compile(r'([为])(?P<proj_name>[^,。)》]{1,60}?(标|标段|项目))')

其中最后一条规则过于宽松,所以要在其他规则没有找到结果时再进行匹配。此外,匹配到的字符串中需要排除 “。”、 “,”、”)” 或者 “》”,这样可以避免出现比较奇怪的、明显不正确的结果。


合同名称

与项目名称的抽取相同。规则为:

  • 用书名号 《》 括起来并且以 “合同” 结尾的;

    hetong3.png

  • 明显的表明 “合同名称” 的;

    hetong2.png


合同金额

虽然要求提取的是合同金额的上下限,但观察训练集数据发现,大部分公告中的合同金额上下限是相等的,也就是基本上只需要提取合同金额,然后上下限都用这个金额填入。这个抽取策略的确欠缺考虑,但是项目到后面因为时间的原因也没能考虑更为妥当的策略。因此,我们直接对公告中明显出现的 “合同金额”、”金额” 等字眼进行搜索,然后匹配跟在这些单词后方,被 ner 识别为 <num></num> 的数字。如果有多个数字匹配成功,则以数字与这些字眼的距离 (间隔字数) 作为准则进行选择。如果上述规则无法识别出合同金额,就简单匹配 <num></num> 和 “元” 的组合。


联合体成员

时间原因并没有处理这部分数据。


定向增发

定向增发需要匹配的项目最少,且和增减持的思路大体相近。因为这两类文件中都有大量的文本和表格,且都可能会涵盖到需要的信息,所以我们分别用文本抽取和表格抽取两种方法来进行定向增发的处理。由于表格数据提取已经在增减持的部分做过介绍,这里仅介绍文本提取中不同的思路和规则。

锁定期与认购方式

这是在定向增发中最为不同的处理。经过对原始pdf的观察,我们发现在同一公告中,各增发对象的锁定期和认购方式这两项信息其实大多是一样的。因而,这两项往往不会和最主要的三项内容分别列表或叙述陈列,而是在文本的某一部分统一说明,锁定期和认购方式为何。

因而,除了在文本和表格中依旧要判断之外,我还添加了一个函数进行全局搜索。如果单条记录中不能匹配到独立的锁定期和认购方式,则进行全局匹配然后将结果分发给每一条记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def extract_pm(self, paragraphs):
add_method = ""
add_period = ""
for paragraph in paragraphs:
# 查找锁定期
if add_period == "":
pat_period = re.compile(r'自本次发行结束之日起(\s*)<num>(?P<period>.*?)</num>(\s*)个月内不得转让')
m_period = pat_period.search(paragraph)
if m_period is not None:
add_period = m_period.group("period")
else:
m_period = pat_period.findall(paragraph)
if m_period is not None and len(m_period) > 0:
add_period = m_period[-1]
# 查找方法
if add_method == "":
pat_method = re.compile(r'以(?P<method>.{0,10})认购')
m_method = pat_method.search(paragraph)
if m_method is not None:
add_method = m_method.group("method")
else:
m_method = pat_method.findall(paragraph)
if m_method is not None and len(m_method) > 0:
add_method = m_method[-1]
return add_period, add_method

目标记录提取

其实定向增发所需要的信息非常依赖文本结构,不过由于底层html的parser是共用的,时间问题也没有做这方面的提升。最后只能提取存在于单个段落内的增发对象及对应的数目或金额,不过这样的准确度是非常高的。

思路是匹配文中的谓宾短语(这样前后能有哨兵包住具体的数目或金额),然后再往前匹配对象。

  • 增发数量的目标文本匹配
1
targets = re.finditer(r'(申购|认购|发行)(不超过|不少于|.{0,5})?<num>(?P<num>.*?)</num>股?', paragraph)
1
add_num = target.group("num")

增发数量即为匹配文本中的数字。

  • 增发金额的目标文本匹配
1
targets = re.finditer(r'(认缴|申购|认购|发行|资)?.{0,10}?(金额|资本|额)([::为])?<num>(?P<price>.*?)</num>元?', paragraph)
1
add_num = target.group("num")

增发金额即为匹配文本中的数字。

  • 增发对象

从段落开始找到匹配文本开头为止,找到匹配文本的主语。

1
2
3
4
5
6
7
8
9
10
11
12
13
pat_obj = re.compile(r'<org>(.*?)</org>')
m_obj = pat_obj.findall(paragraph, 0, start_pos)
add_obj = ""
if m_obj is not None and len(m_obj) > 0:
add_obj = m_obj[-1]
else:
pat_person = re.compile(r'<person>(.*?)</person>')
m_person = pat_person.findall(paragraph, 0, start_pos)
if m_person is not None and len(m_person) > 0:
add_obj = m_person[-1]
# 没有查找到对象名称
if add_obj is None or len(add_obj) == 0:
continue
  • 锁定期与认购方式

锁定期与认购方式同全局的函数匹配相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 查找锁定期
pat_period = re.compile(r'自本次发行结束之日起(\s*)<num>(?P<period>.*?)</num>(\s*)个月内不得转让')
m_period = pat_period.search(paragraph, start_pos)
add_period = ""
if m_period is not None:
add_period = m_period.group("period")
else:
m_period = pat_period.findall(paragraph)
if m_period is not None and len(m_period) > 0:
add_period = m_period[-1]
# 查找方法
pat_method = re.compile(r'以(?P<method>.{0,10})认购')
m_method = pat_method.search(paragraph, start_pos)
add_method = ""
if m_method is not None:
add_method = m_method.group("method")
else:
m_method = pat_method.findall(paragraph)
if m_method is not None and len(m_method) > 0:
add_method = m_method[-1]

总结

这次项目的难度确实挺大的,也是我们第一次比较深入地接触到nlp的问题,学到了相当多的知识和工程经验。但由于能力和时间有限,有不少可以提升的点,很遗憾我们没有做到。比如对重大合同的信息提取,其实难度相当大。因为另外两类文本不少重要信息还是依赖于表格,而重大合同中的表格则非常少,主要依赖于文本分析。而文本分析仅依靠规则匹配其实召回率是非常低的。更好的方法也许是结合神经网络进行训练。再像定向增发,像上文提到的其实非常依赖公告的文本结构。遗憾的是我们只保留了一级的段落结构,而没有在更深入地保留更详细的sub-section和title的信息了,不然通过这个信息就能很快地定位到包含有效信息的局部文本。毕竟最后测试的时候,定向增发其实花费时间是最长的。

If it helps, you may buy me a cup of coffee plz.
0%