2021DCIC智能医疗决策初赛第9名方案分享+体验(非深度学习方案)

然而并没有进入复赛,前八名进入复赛,害
2021DCIC智能医疗决策初赛第9名方案分享+体验(非深度学习方案)
随手就当写个故事记录最近一个月的经历,其实我并不会自然语言处理NLP(正在试图复现Transformer中),因此能有这个结果已经让我感到很意外了
作为退役OIer,感觉还挺奇妙的,自从学了python,入坑机器学习
就好久没自己写过算法了,天天都在调包和调参,这一写感觉自己又回到了高中哈哈哈,当然Debug也挺烦的,早期甚至还会有内存泄漏的问题
作为一名退役OIer,我更乐意与大家分享一下我的算法设计思路和代码实现的问题,至于怎么设计模式串和规则,略讲一点把,随便看看就好
代码将稍后进行开源(很不情愿,毕竟代码写得丑,也没怎么优化)
比赛链接点我

赛题背景

2021DCIC智能医疗决策初赛第9名方案分享+体验(非深度学习方案)

赛题介绍

2021DCIC智能医疗决策初赛第9名方案分享+体验(非深度学习方案)

数据与评测

数据说明

大赛提供三类数据集:
1、训练集:未标注,1000份病例,需选手自行标注,选手应结合提供的10类实体定义、“标注参考数据集”样例和相应的医学临床知识完成数据标注再开展模型训练。
2、标注参考数据集:已标注,100例,由5人医生团队标注而成,低年资医生分别标注,标注结果一致则通过,不一致由高年资医生协同判断,再进行脱敏、脱密形成的真实标注数据集。
3、测试集:未标注,1050例,选手用于预测提交,线上评分。测试数据提供txt文本,选手预测相应实体提交tag文件。禁止对测试数据手工标注。
共定义了10类实体,具体类别定义如下:【枚举实体类型定义】
1、肿瘤位置(B-Tloc):指肿瘤所在的部位
2、肿瘤组织学类型(B-This): 指肝细胞癌的组织排列方式。
3、分化程度(B-Tdiff):肿瘤的分化是指肿瘤组织在形态和功能上与某种正常组织的相似之处,相似的程度称为肿瘤的分化程度。
4、肿瘤数量(B-Tnum):指肿瘤的数目。
5、肿瘤大小(B-Tsize):指肿瘤的大小。
6、微血管癌栓(B-MVI):指在显微镜下于内皮细胞衬覆的脉管腔内见到癌细胞巢团,以门静脉分支为主(含包膜内血管)。根据MVI的数量和分布情况进行风险分级。
7、卫星子灶(B-Sate):指主瘤周边近癌旁肝组织内出现的肉眼或显微镜下小癌灶。
8、肝硬化程度(B-LC):各种病因引起的肝脏疾病的终末期病变,病变以慢性进行性、弥漫性的肝细胞变性坏死、肝内纤维组织增生和肝细胞结节状再生为基本病理特征,广泛增生的纤维组织分割原来的肝小叶并包绕成大小不等的假小叶,引起肝小叶结构及血管的破坏和改建。
9、病理分期(B-TNM):是美国癌症联合委员会和国际抗癌联盟建立的恶性肿瘤分期系统。T是指原发肿瘤、N为淋巴结、M为远处转移。
10、包膜(B-Caps):指包绕在肿瘤组织外层的纤维组织。
提供的标注文件示例:
标注文件tag每行包括起始位置、结束位置、实体类别以及实体内容。其中“起始位置”、“结束位置”、“实体类别”和“实体内容”间以“#”分隔。实体边界位置,左开右闭。
例:
样本:【1.(右肝肿瘤)①肝细胞癌伴坏死】
标注:【3#5#Tloc#右肝】

格式及样本说明:

1、提供的原始数据文件都为txt格式
2、提供的标注参考文件为tag格式,选手需提交的结果文件为tag格式
3、每单个文件包含50例病例

可视化:

解题思路

骗分过样例,暴力出奇迹o( ̄▽ ̄)ブ

最初的想法

刚开始参加比赛的时候看到数据我的感觉数据非常的具有规则性,看起来正则表达式就可以直接出结果,甚至当时我觉得都用不上正则表达式,直接KMP或者AC自动机就行了,而且用AC自动机时间复杂度上碾压正则表达式,当然肯定也比NER快了。
太久没写算法了,我一时半会儿想不起来AC自动机怎么写,于是我选择写个KMP算法直接暴力匹配一把。当然不使用正则表达式的另一个原因是因为我觉得正则表达式好像没办法得到匹配结果出现的位置(这题需要标注位置信息),当然也可能是我孤陋寡闻,事实上我几乎没有使用正则表达式的经验。
于是我基于KMP逐步改进和完善了一套类似正则表达式的匹配算法,至少对于这道题而言基本上足够了
当时本来没打算正儿八经参赛,毕竟KMP怎么能胜任NLP任务呢hhh
结果一通骚操作上了0.80,感觉还不错,那就这样吧。。
其实我有让我队友写个NER,结合我的规则匹配应该很有效,但是我的队友忙着考研,我忙着导师的项目,所以就咕咕咕了

代码分析

环境参数

系统:Windows10(抱歉了,Linux无法通过编译)
编译器:g++ (tdm64-1) 4.9.2
编译指令:-Wall -O3 -std=c++14(啊为什么c++17才有string_view)
编译命令:

>g++ source/main.cpp -std=c++14 -O3 -Wall -o main.exe
>g++ source/run.cpp -std=c++14 -O3 -Wall -o run.exe
>g++ source/visualization.cpp -std=c++14 -O3 -Wall -o visualization.exe

文件结构

project
    builder.cmd
    main.exe
    visualization.exe
    run.exe
	source
    	console_attribution.h 
        file_reader.h
        kmp.h
        offset_policy.h
        parser.h
        pattern_ops
        property.h
        run.cpp
        main.cpp
        visualization.cpp 

功能描述

console_attribution.h 提供一些控制台的操作
file_reader.h 读取中文文件,并进行编码转换以正确显示和处理中文字符
kmp.h 标注所使用的核心算法,基于KMP的字符串模糊匹配算法(类似正则表达式的用法)
offset_policy.h 匹配算法中对于offset的处理策略,定义但未使用到
parser.h 解析控制台向main函数传递的参数
pattern_ops 定义了模式串的一些操作,包括读取,清空,解析等
property.h 定义用于保存匹配算法返回结果的数据结构
run.cpp 用于批量对多个文件进行匹配程序源码
main.cpp 用于对单个文件进行匹配程序源码
visualization.cpp 可视化匹配结果程序源码
visualization.exe 可视化程序
命令格式:
visualization [-args...] --txt [data path] --tag [label path]
示例:
visualization -i --txt 1.txt --tag 1.tag
参数:
[--tag]:标注文件路径
[--txt]:待标注文件的路径.
[-d1]:官方样例1(–tag sample_example/21.tag --txt sample_example/21.txt)
[-d2]:官方示例2(–tag sample_example/21.tag --txt sample_example/21.txt)
[-i]:打印被排除的部分样本
[-l]:显示每一个被标注出来的实体对应的标签

main.exe 标注一个文件
命令格式:
main --from[data path] --to[label path]
示例:
main --from 1.txt --to 1.tag
参数:
--from 待标注文件的路径
--to 结果保存的路径

run.exe
批量标注,遍历一个文件夹所有txt文档,进行标注并保存结果
run --from[data folder] --to[label folder]
示例:
main --from test/txt --to results/results
参数:
--from 待标注文件夹的路径
--to 结果保存的文件夹路径

比赛方案

所有代码里面最重要的就是kmp.h
实现了一个字符串模糊匹配算法,基于kmp改进而来
这个头文件用到了property.hutils.h的内容
保存匹配结果的数据结构(property.h)

struct Property
{
	int lpos; //起始点
	int rpos; //结束点
	int raw_length;//实际匹配长度
	std::wstring value;//记录匹配的结果(经过处理的结果)
	std::wstring key;//记录被哪个模式串匹配上
	Property();
	Property(const int&lpos,const int&rpos,const std::wstring&key,const std::wstring&value);
	friend bool operator<(const Property&a,const Property&b)//用于排序和去重
	{
		if(a.lpos<b.lpos)
			return true;
		if(a.lpos==b.lpos&&a.rpos>b.rpos)
			return true;
		if(a.lpos==b.lpos&&a.rpos==b.rpos&&a.raw_length<b.raw_length)
			return true;
		return false;
	} 
};

编码转换,std::string和std::wstring的转换,以及字符串分割(split)
很坑爹的是C++并不能很好的处理中文的问题

std::string to_utf8(const wchar_t* buffer,int len); 
std::string to_utf8(const std::wstring& str);
template<typename str_type>
std::vector<str_type>split(const str_type&s,const str_type&delimiters);
std::wstring string2wstring(std::string str); 
std::string wstring2string(std::wstring str);//string和wstring互相转换
template<typename T>
void parse(std::string&str,T&ch);
template<typename T,typename...Args>
void parse(std::string&str,T&&data,Args ...args);
template<typename T,T... cs>
const auto operator ""_wstring();//"test"_wstring 将得到std::wstring类型的字符串

然后就是最核心的模式匹配算法了,熟悉的KMP算法即将出现

struct Property; //保存一条结果
using Offset=std::map<std::wstring,std::pair<int,int>>;//匹配到的不一定直接就是结果,可能还需要取子串,后文会讲
using Patterns=std::list<std::wstring>;//模式串
using Properties=std::list<Property>;//保存若干条结果
using Limit=std::map<std::wstring,int>;//规定一个通配符能匹配的最长长度
constexpr const int default_max_fuzzy_match_length=252645135;
namespace predicate//谓词,各种字符集判断,每一个谓词对应一个通配符 
{
	bool is_roman_digit(const int&x); 
	inline bool isdigit(const int&c); //使用std::isdight会有问题,这里自己实现一个 
	inline bool isalpha(const int&c); //功能同std::isdight和std::isalpha 
	inline bool is_digit(const int&x);//是否是数字,小数点,减号(-),波浪号(~),乘号(×),顿号(、) 
	inline bool is_alpha(const int&x);//是否是字母,小数点,减号(-),波浪号(~),乘号(×),顿号(、)  
	inline bool is_alpha_or_dight(const int&x)
	inline bool is_wildcard(const int&x)//是否为通配符,# $ *这三个为通配符,分别对应不同的字符集 
	std::map<const int,std::function<bool(const int&x)>>match_functions;//保存上述所有函数,每个通配符对应一个谓词函数 
	inline bool fuzzy_match(const int&wildcard,const int&x);//字符x是否能和通配符wildcard进行匹配 
	void add_custom_match(const int&wildcard,std::wstring&custom_char_set);//运行时动态构建新的通配符 
};
class KMP
{
public:
	KMP(const std::wstring&text)noexcept;
	Properties unique_match(const Patterns&patterns,const Offset&offset={},const Limit&limit={},const int&num_threads=1)noexcept;
	//对结果去重,以及去掉子串,保留最长的匹配结果,如果同时匹配多个,M2,M2级,选择最长的作为最终结果
	Properties match(const Patterns&patterns,const Offset&offset,const Limit&limit={},const int&num_threads=1)noexcept;//KMP匹配,对多个模式串进行匹配 合并结果并排序  
	Properties threads_match(const Patterns&patterns,const Offset&offset,const Limit&limit={},const int&num_threads=1)noexcept; //match的多线程版本 
private:
	void __match(const std::wstring&pattern,Properties&properties,const Offset&_offset,const Limit&_limit)noexcept;//KMP算法,模式串含有多个通配符
	void build()noexcept //KMP算法预处理next数组 
public:
	constexpr static int default_limit=25;
private:
	std::wstring text;//待匹配文本 
	std::vector<int>next;//next数组,kmp算法核心 
};

关于Offset,因为比如模式串为肝纤维化*期(G*S*)
就会匹配到肝纤维化3期(G3S3),但根据题意,实际结果只需要保留G3S3,但是如果两个单独出现,则两个都需要标注(不一定正确,但是我所观测到的规则是这样)
因此需要对匹配出的肝纤维化3期(G3S3)取子串,每一个模式串都对应一个offset,这个在后文会讲
而Limit限制了每一个通配符的最长匹配长度
类似查见*癌栓,查见可能是上一个病理的,癌栓可能是下一个病例的,这样就会匹配出一个长度为几百个字符的结果出来,这样显然是不合理的,因此需要规定一个最长的长度
我多线程部分我的想法是每一个线程匹配若干个模式串,结果储存为链表,然后主线程链表O(1)合并,这样就不存在临界资源的问题,也不用加锁,加锁也会带来时间损耗
不过我怀疑我写假了因为多线程和单线程跑出来时间花销好像没什么显著差异,还不如开个O3优化来的快。。。
当然其实无关紧要,这比赛不卡常数和时间复杂度(逃)

匹配算法的匹配规则(整个算法核心)

匹配规则其实在kmp.h里面有写到
这里直接抄过来了

作者:Shijunfeng
时间:(最后一次更新):2021/3/22/0:49 
字符串模糊匹配,基于KMP算法改进
1.使用KMP精确匹配特定模式串,并给出所有模式串出现的位置信息
2.模糊匹配,通配符 * $ # × 
	2.1使用*匹配任意字符,在默认情况下规定*最长能匹配15个字符(其实可以任意指定长度) 
	2.2使用$匹配任意数字字符以及{'-','~','×','、'},在默认情况下规定*最长能匹配15个字符(默认参数,可为每一个模式串指定一个最长长度) 
	2.3其他两个通配符参考代码predicate::is_romam_digit,predicate::is_alpha_or_dight
	2.4可以支持自定义任意字符集,但未使用到该功能,因此未实现,可能的语法:[a-z] [a b c] 
3.{}合并多个字符串相同的部分,或者说匹配其中任意一个,a{1 2}b 等价于 a1b a2b两个模式串(可以借此实现2.4但不推荐) 
4.Offset:{left_offset,right_offset},代表对匹配的结果取某个子串作为最终结果
	4.1当left_offset和right都为正数时候,left_offset是左端点向右移动字符数量,right是右端点向左移动数量 
	4.2当left_offset为正数,right_offset为负数,left_offset含义同上,但是right_offset代表子串长度
	4.3当left_offset为负数,right_offset为正数,同上
	4.4当left_offset和right_offset都是负数,会得到一个异常o(* ̄▽ ̄*)ブ
4.如果将KMP换成AC自动机,性能将大幅度提升,不过这里并未实现 
5.为什么不用正则表达式...因为我当时觉得正则表达式没办法得到位置信息,查出字符串还要到原字符串在找一遍位置
  另外刚开始比赛的时候,我也就想到看起来像是个简单的匹配问题,就用KMP了,然后后续在这个基础上修改
  就是这份代码了,个人觉得相比于正则表达式更灵活,因为可以很方便的修改算法,比较的* 

其实蛮简单的哈,相比于正则表达式而言o( ̄▽ ̄)ブ
好了现在就可以说一下KMP::__match部分了,其他的都是对这个方法返回的结果进行处理

void __match(const std::wstring&pattern,Properties&properties,const Offset&_offset,const Limit&_limit)noexcept
/*
	pattern 模式窜
	properties 保存结果的链表
	_offset 偏移量(后文会讲到使用方法)
	_limit 每个模式串的通配符最长匹配长度
*/

算法实现还是比较复杂,但是仔细想还是容易搞懂

	void __match(const std::wstring&pattern,Properties&properties,const Offset&_offset,const Limit&_limit)//KMP算法,模式串含有多个通配符,
	noexcept 
	{
#ifdef __KMP__DEBUG__
		cout<<"in KMP::__match"<<endl;
#endif 
		// (int)× is 215 ,9.2×7.5×6.5cm	
		Offset&offset=*const_cast<Offset*>(&_offset);//注意不要对offset和limit进行修改 
		Limit&limit=*const_cast<Limit*>(&_limit);
		bool has_offset=(offset.find(pattern)!=offset.end());
		bool has_limit=(limit.find(pattern)!=limit.end());
		int t1=0,t2=0,back=0,tmp=0,max_fuzzy_match_length;//back记录当前通配符匹配了几个"任意字符" 
		if(has_limit)
			max_fuzzy_match_length=limit[pattern];
		else
			max_fuzzy_match_length=default_max_fuzzy_match_length;//default value
		std::vector<int>offset_history(text.size()+1);
		std::list<int>fuzzy_points;//记录每个通配符的位置(倒序)
		for(int i=pattern.size()-1;i>=0;--i)
		{
			if(predicate::is_wildcard(pattern[i]))
			{
				fuzzy_points.push_back(tmp);
				tmp=-1;
			}
			tmp++;
			//其实这里是用数组实现了一个链表
			//tmp是两个通配符的间隔大小
		}
		while(t1<(int)text.size()&&t2-back<(int)pattern.size())
		{
			bool fuzzy_match=t2-back>=0&&predicate::fuzzy_match(pattern[t2-back],text[t1]);//是否当前正在被通配符匹配 
#ifdef __KMP__DEBUG__
				cout<<wstring2string(pattern)<<endl;
				printf("t2:%d | pattern:%s (%d) | seq:%s (%d) | fuzzy_match:%d | match:%d | back:%d \n",
					t2,
					t2-back>=0?wstring2string(pattern.substr(t2-back,1)).c_str():"Null",
					pattern[t2-back],
					wstring2string(text.substr(t1,1)).c_str(),
					(char)text[t1],
					fuzzy_match,
					text[t1]==pattern[t2-back]||fuzzy_match,
					back
				);
#endif	
			if(text[t1]==pattern[t2-back]||t2==-1||fuzzy_match) //如果通配符匹配过长,也结束匹配过程 
			{		
				//*已经至少与一个符号进行了匹配,尝试匹配下一个符号,如果可以匹配就结束当前的通配符 
				if(fuzzy_match&&back&&(text[t1]==pattern[t2-back+1]||predicate::fuzzy_match(pattern[t2-back+1],text[t1]))) 
				{
					//成功,因此结束通配符 
					t2=t2-back+1;
					back=0;
					continue;
				}
				if(back>max_fuzzy_match_length) //通配符匹配长度过长,强制结束匹配,匹配失败 
				{
					t2=next[t2-back];
					back=0;
					continue;
				} 
				t1++;
				t2++;
				if(fuzzy_match)
				{
					back++; //模式串为*,可以与任意字符匹配,因此模式串从*cm变为**cm,第一个*被正确匹配,现在去匹配第二个* 
				}           //但是在实际实现过程中并不是正真的插入一个*在后面,用back来模拟复制,实际相当于t2指针不移动 
			}
			else
			{
				if(back==0)
				{
					t2=next[t2-back]; //匹配失败,该位置不是*,因此回跳 
				}
				else
				{
					t2=t2-back+1; //匹配失败,但是该位置为*,之前已经有*被匹配过,说明*已经与任意字符串匹配结束,继续判断下一个字符 
				}
				back=0;//要么跳到*前面,要么结束'任意匹配'状态,无论怎样back都应该清0 
			}
			if(t2-back==(int)pattern.size()) //匹配到一个合法子串,记录位置信息 
			{
				Property property;
				int fuzzy_match_length=0,tmp_t1=t1;
				for(auto&it:fuzzy_points)
				{
					fuzzy_match_length+=std::max(offset_history[tmp_t1-it]-1,0); //模糊匹配匹配上的字符数量 
					tmp_t1=tmp_t1-it-offset_history[tmp_t1-it];
				}
				property.lpos=t1-(int)pattern.size()-fuzzy_match_length;  //加上模式串本身长度,求得匹配出的字符串起始位置 
				property.rpos=t1;
				property.raw_length=property.rpos-property.lpos;
				if(has_offset) //处理偏移量
				{ 
					auto&o=offset[pattern];
					if(o.first>=0&&o.second>=0)
					{
						property.lpos+=o.first;
						property.rpos-=o.second;
					}
					else if(o.second<0)
					{
					    property.lpos+=o.first;
						property.rpos=property.lpos-o.second;
					}
					else if(o.first<0)
					{
						property.rpos-=o.second;
						property.lpos=property.rpos+o.first;
					}
				}
				
				if(text[property.lpos]==12289)     //修个BUG,开头和结尾出现'、'的情况,这是应该避免的(仅对于本次比赛) 
					property.lpos++;
				if(text[property.rpos-1]==12289)
					property.rpos--;
					
				property.key=pattern;
				property.value=std::move(text.substr(property.lpos,property.rpos-property.lpos));
#ifdef __KMP__DEBUG__
				printf("pattern:%s matched_length:%d lpos:%d rpos:%d\n",
					   wstring2string(pattern).c_str(),
					   (int)property.value.length(),
					   property.lpos,
					   property.rpos
				);
#endif			
				properties.push_back(property);
				t2=next[t2];
			}
			offset_history[t1]=std::max(offset_history[t1],back);
		}
#ifdef __KMP__DEBUG__
		for(unsigned i=0;i<offset_history.size();i++)
			std::cout<<"pos:"<<i<<" "<<"offset:"<<offset_history[i]<<" "<<wstring2string(text.substr(i,1))<<std::endl;
#endif	
	}

接着是多线程版本:

	Properties threads_match(const Patterns&patterns,const Offset&offset,const Limit&limit={},const int&num_threads=1) //match的多线程版本 
	noexcept
	{                              
#ifdef __KMP__DEBUG__
		cout<<"in KMP::threads_match"<<endl;
#endif                     
		std::vector<Properties>vec_properties(num_threads);
		std::vector<std::thread>threads(num_threads);
		Properties properties;
		std::vector<std::wstring>vec_patterns;
		int num_patterns=patterns.size();
		int patterns_per_threads=(int)(num_patterns/(double)(num_threads));
		int extra_patterns=num_patterns%num_threads;
		for(auto&it:patterns)
			vec_patterns.push_back(it);
		auto match_slice=[&](int thread_id,int start_index,int end_index)->void
		{
			//std::cout<<"thread:"<<thread_id<<"begin"<<std::endl;
			for(int i=start_index;i<=end_index;i++)
				this->__match(vec_patterns[i],vec_properties[thread_id],offset,limit);
			//std::cout<<"thread:"<<thread_id<<"begin"<<std::endl;
		};	
		for(int i=0,index=0;i<num_threads;i++) //将数组均等化分为num_threads份,分别给num_threads个线程,没有临界资源问题,不用加锁(加锁增大时间开销) 
		{
			int start_index,end_index;
			start_index=i*patterns_per_threads+index;
			if(i<extra_patterns)
				index++;
			end_index=(i+1)*patterns_per_threads+index-1;
			
			threads[i]=std::move(std::thread(match_slice,i,start_index,end_index));
		}
		for(auto&t:threads)
			t.join();
		for(auto&v:vec_properties)
			properties.splice(properties.begin(),v);
		properties.sort();
		return properties;
	}

然后必然会有重复的问题,多个模式串匹配到同一个结果上
然后还有包含关系,M2和M2级,这种就保留最长的
然后未见微血管癌栓和卫星病灶,这个未见到底是微血管癌栓还是卫星病灶?不幸的是我的代码会匹配到两个未见,一个.标签是微血管癌栓,一个标签是卫星病灶,我个人就搞了个就近原则,我认为他是微血管癌栓
啊其实我印象中我做过最大区间并的题,是可以O(NlogN),作为退役OIer的我真想不起来怎么做了,这里比较特殊的就是字符串区间要么和其他区间无交集,要么就一定是子区间,所以我用了个看起来似乎是正确的做法,至少时间复杂度是对的
1.先排序,如果左端点靠前,就排在前面,否则如果右端点靠前,就排在右边,如果区间完全重合,就看谁的raw_length,也就是未经过offset的原始匹配长度谁短谁在前面,两个区间相等当且仅当两个property对象属性完全相同
2.去重,保留第一个结果(链表倒序遍历,相同就删掉prev)
3.左端点相同,保留第一个结果(方法同上)
4.右端点相同,保留第一个结果

	Properties unique_match(const Patterns&patterns,const Offset&offset={},const Limit&limit={},const int&num_threads=1)//对结果去重,如果同时匹配多个,M2,M2级,选择最长的作为最终结果
	noexcept
	{ 
#ifdef __KMP__DEBUG__
		cout<<"in KMP::unique_match"<<endl;
#endif 
		Properties properties(std::move(this->match(patterns,offset,limit,num_threads)));
		auto map_fn=[&](const auto&func)->void //遍历
		{
			auto end=--properties.end();
			auto begin=--properties.begin();
			auto prev=--properties.end();
			for(auto it=--end;it!=begin;--it)
			{
				if(func(it,prev))
					properties.erase(prev);
				prev=it;
			}
		};
		map_fn([](const auto&it,const auto&prev)->bool{return (*it).lpos==(*prev).lpos&&(*it).rpos==(*prev).rpos;});//第一遍去重
		map_fn([](const auto&it,const auto&prev)->bool{return (*it).lpos==(*prev).lpos&&(*it).rpos>(*prev).rpos;});//第二遍在左端点相同的情况下删掉子区间 
		map_fn([](const auto&it,const auto&prev)->bool{return (*it).rpos==(*prev).rpos&&(*it).lpos<(*prev).lpos;});//第三次在右端点相同的情况下删除子区间 
		map_fn([](const auto&it,const auto&prev)->bool{return (*it).lpos<(*prev).lpos&&(*it).rpos>(*prev).rpos;});//第四次删除被其他区间包括的子区间(端点不相等) 
		return properties;
	}

模式串的预处理:
模式串来源有两部分
1.官方给的标注
2.手工设计模式串
然而官方给的标注其实额很蛋疼,有些不能直接无脑匹配,需要考虑上下文,所以把这部分过滤掉,转化为手工设计规则

Filter filters={ //从标准的文件中读取模式串,但是删掉以下出现次数少或错误率很高的 
	"Tsize"_wstring,
	"伴"_wstring,
	"查见"_wstring,
	"未见"_wstring,
	"可见"_wstring,
	"胆管"_wstring,
	"形成"_wstring,
	"大小结节混合性"_wstring,
	"结节性"_wstring,
	"肝门部淋巴结"_wstring,
	"门静脉"_wstring,
	"脂肪性肝炎样"_wstring,
	"多发"_wstring,
	"门脉"_wstring,
	"下腔静脉"_wstring,
	"右侧肾上腺"_wstring,
	"16组淋巴结"_wstring,
	"S4"_wstring
};

然后就是手工设计的一大堆模式串了

OffsetPattern ext_patterns={
	make_tuple("{低分化 中分化 高分化}","Tdiff",make_pair(0,0),default_limit),
	make_tuple("{可见 查见 未见}{脉管 明显脉管内癌栓形成 脉管内癌栓形成}","MVI",make_pair(0,-2),default_limit),
	make_tuple("{可见 查见}{明显脉管内癌栓 脉管内癌栓}形成","MVI",make_pair(-2,0),default_limit),
	make_tuple("{未见 可见}{* 卫星* 明显卫星子灶 明确卫星子灶 明显卫星*灶 卫星子灶 卫星病灶 *卫星子灶 *卫星病灶}","Sate",make_pair(0,-2),default_limit),
	make_tuple("{未见 可见}{*微血管 微血管 明显微血管 明确微血管癌 明确微血管侵犯}","MVI",make_pair(0,-2),10),
	make_tuple("查见卫星*灶","Sate",make_pair(0,-2),5),
	make_tuple("查见{*微血管 微血管}","MVI",make_pair(0,-2),5),
	make_tuple("查见胆管内癌栓","Tloc",make_pair(2,3),default_limit),
	make_tuple("查见胆管癌栓","Tloc",make_pair(2,2),default_limit),
	make_tuple("卫星{病灶 病灶(*) 子灶}形成","Sate",make_pair(-2,0),default_limit),
	make_tuple("${cm CM 厘米}","Tsize",make_pair(0,0),default_limit),
	make_tuple("{大小 直径}$","Tsize",make_pair(2,0),default_limit),
	make_tuple("{巨细胞型 紫癜型 透明细胞亚型}","This",make_pair(0,0),default_limit),
	make_tuple("$个","Tnum",make_pair(0,0),default_limit),
	make_tuple("肿瘤共*个","Tnum",make_pair(3,0),4),
	make_tuple("G$S#","LC",make_pair(0,0),default_limit),
	make_tuple("#N#M#","TNM",make_pair(0,0),default_limit),
	make_tuple("×段","Tloc",make_pair(0,0),default_limit),
	make_tuple("×级","Tdiff",make_pair(0,0),default_limit),
	make_tuple("{S$ S$、S$}","Tloc",make_pair(0,0),default_limit),
	make_tuple("早期肝硬化","LC",make_pair(0,3),default_limit),
	make_tuple("肝纤维{化$ *}期","LC",make_pair(0,0),default_limit),
	make_tuple("纤维化分期S(0~4期):$期","LC",make_pair(-2,0),default_limit),
	make_tuple("纤维化分期S(0~4期):$-$期","LC",make_pair(-4,0),default_limit),
    make_tuple("{侵犯 侵犯肿瘤 可见 可见肿瘤 可见明显 未见 未见肿瘤 未见明显}包膜","Caps",make_pair(0,-2),default_limit),
    make_tuple("未见侵犯","Caps",make_pair(0,0),default_limit),
    make_tuple("无包膜","Caps",make_pair(0,-1),default_limit),
    make_tuple("{未突破 突破}包膜","Caps",make_pair(0,2),default_limit),
    make_tuple("包膜{不完整 完整}","Caps",make_pair(2,0),default_limit),
    make_tuple("肝*段肿物","Tloc",make_pair(1,3),default_limit),
    make_tuple("多发卫星*灶","Sate",make_pair(0,-2),5)
}; 

make_tuple传入四个参数,分别对应模式串,标签,offset,limit
以及显然,上面的模式串会匹配到很多错误的答案,并且很难设计模式串去排除,比如
未见卫星病灶形成,会同时匹配到未见和形成,需要删掉形成
所以设计了另一组模式串,和这部分匹配的将被删除(两个集合求差集的操作)
这里其实很多是多余的,早期的设计并没有Filter,因此未见查见可见会匹配到很多奇怪的结果(比如未见明显纤维组织)
后面也懒得删了,那就等他这样吧hhh

OffsetPattern invalid_ext_patterns={ //其实这里不用具体的实体类型.... 
	make_tuple("{CK CD}$个","Tnum",make_pair(2,0),default_limit),
	make_tuple("{< < ≤ > > ≥}$个","Tnum",make_pair(1,0),default_limit),
	make_tuple("{大于 小于 少于 多余}${个 cm CM 厘米}","Tnum",make_pair(2,0),default_limit),
	make_tuple("{左半肝 右半肝 左肝 右肝}切","Tloc",make_pair(0,1),default_limit),
	make_tuple("右肝肿瘤S$","Tloc",make_pair(0,4),default_limit),
	make_tuple("网状纤维染色未见","MVI",make_pair(-2,0),default_limit),
	make_tuple("{结节 间隔 息肉 瘤 假小叶 弓形纤维 增生灶 结石 错构瘤}形成","Sate",make_pair(-2,0),default_limit),
	make_tuple("未见{转移 肿瘤 恶性 癌栓 癌 明显纤维组织 明显淋巴细胞浸润 微血管内癌栓形成 恶性上皮 肝组织}","MVI",make_pair(0,-2),default_limit),
	make_tuple("未见{* 卫星* 明显卫星子灶}形成","Sate",make_pair(-2,0),default_limit),
	make_tuple("{查见 可见}出血","Sate",make_pair(0,2),default_limit),
	make_tuple("其中肝","Tloc",make_pair(1,0),default_limit),
	make_tuple("(淋巴结)*未见*癌","Tloc",make_pair(1,-3),default_limit),	
	make_tuple("{左肝 右肝 中肝}{癌 肿物 肿瘤 〇}S$","Tloc",make_pair(0,-2),default_limit),
	make_tuple("并见*(*$cm)形成","Tsize",make_pair(-5,3),default_limit),
	make_tuple("{S-100P S100P}","LC",make_pair(0,1),default_limit),
	make_tuple("{S-100 S100}","LC",make_pair(0,0),default_limit),
	make_tuple("{左右肝 左肝 右肝}管","Tloc",make_pair(-2,1),default_limit),
	make_tuple("NAS*S$","",make_pair(-4,0),default_limit),
	make_tuple("NAS*S$","",make_pair(-2,0),default_limit),
	make_tuple("其中低分化","Tdiff",make_pair(1,0),default_limit),
	make_tuple("肝纤维化$期({G$-$S$ G$S$-$})","LC",make_pair(0,8),default_limit),
	make_tuple("早期肝硬化(G$S$-$)","LC",make_pair(0,8),default_limit),
	make_tuple("肝纤维化*-*期*(G$S#)","LC",make_pair(0,-8),default_limit),
	make_tuple("肝纤维化*期*(G$S#)","LC",make_pair(0,-6),default_limit),
	make_tuple("早期肝硬化(G$S#)","LC",make_pair(0,-2),default_limit),
	make_tuple("肝纤维化*期(G$S#)","LC",make_pair(0,-6),default_limit), //Additonal 
	make_tuple("{肝纤维*-*期* 肝纤维*-*期}(G$S#)","LC",make_pair(0,-7),default_limit),
	make_tuple("{肝纤维$期* 肝纤维$期}(G$S#)","LC",make_pair(0,-5),default_limit),
	make_tuple("可见肝硬化","Sate",make_pair(0,-2),default_limit),
	make_tuple("诊断:*肝炎伴肝纤维化$期","LC",make_pair(-6,0),default_limit), 
	make_tuple("诊断:*肝炎伴肝纤维化$-$期","LC",make_pair(-8,0),default_limit), 
	make_tuple("诊断:{*肝炎伴肝纤维化$期 乙型肝炎肝硬化 乙型*肝炎 乙型*肝炎*}{(G$S$) (早期,G$S$)}","LC",make_pair(-4,1),default_limit),
	make_tuple("诊断:{*肝炎伴肝纤维化$-$期 乙型肝炎肝硬化 乙型*肝炎 乙型*肝炎*}{(G$-$S$) (早期,G$-$S$)}","LC",make_pair(-6,1),default_limit),
	make_tuple("诊断:*(早期,$S$)","LC",make_pair(-2,6),default_limit),
	make_tuple("诊断:*(早期,G$-$S$)","LC",make_pair(-2,8),default_limit),
	make_tuple("诊断:*({G$S$-$F$ G$-$S$F$})","LC",make_pair(-8,1),default_limit), 
	make_tuple("诊断:*({G$S$F$ G$-$S$ G$S$-$})","LC",make_pair(-6,1),default_limit), 
	make_tuple("诊断:*(G$S$)","LC",make_pair(-4,1),default_limit), 
	make_tuple("诊断:*({G$~$S$ G$-$S$ G$S$~$ G$S$-$})","LC",make_pair(-6,1),default_limit), 
	make_tuple("肝纤维化*期(G$S#)","LC",make_pair(0,-6),default_limit), //New
	make_tuple("诊断:肝硬化(*)","LC",make_pair(7,1),default_limit),
	make_tuple("{扩大左半肝 扩大右半肝}","Tloc",make_pair(2,0),default_limit),
	make_tuple("S","Tloc",make_pair(0,0),default_limit),   //Fix a BUG 
	make_tuple("未见{明显脉管内癌栓 脉管内癌栓}形成","MVI",make_pair(-2,0),default_limit),
    make_tuple("未见{明确 〇}微血管{内 〇}{癌栓 侵犯}(MVI分级*)","MVI",make_pair(0,-2),5),
    make_tuple("未见{明确 〇}脉管内癌栓{〇 *}(MVI分级*)","MVI",make_pair(0,-2),10) 
};

pattern_ops.h主要是把上面的OffsetPatterns对象拆分为
Offset,Limit,Pattern等对象,以及处理{}等问题
这个也是早期设计KMP算法部分没考虑到有Limit,Offset,就是很简单的KMP算法,这里不想花时间去重构,所以就这样凑合着能跑就行
目前,整个方案的主要部分就讲清楚了
接下来就是cpp代码了
嗯我最初也就想到一次处理一个文件,而不是文件夹
然后我不是很想去处理初始化的问题。。懒
那干脆main.exe就只能处理一个文件
文件夹的问题留给run.exe
首先也是using了很多,跟之前是一样的,然后就是模式串
main里面就是匹配和保存结果了

//main.cpp
#include<iostream>
#include<vector>
#include<fstream>
#include<map>
#include<tuple>
#include"utils.h"
#include"file_reader.h"
#include"kmp.h"
#include"pattern_ops.h"
#include<typeinfo>
using Dict=std::map<std::wstring,std::wstring>; //std::unordered_map,模式串对应的标签
using Patterns=std::list<std::wstring>;         //std::deque
using Filter=std::set<std::wstring>;       
using Offset=std::map<std::wstring,std::pair<int,int>>;
using OffsetPattern=std::list<std::tuple<std::string,std::string,std::pair<int,int>,int>>;
using Properties=std::list<Property>;           //std::deque 
using Limit=std::map<std::wstring,int>;
using namespace std;
constexpr const int default_limit=15;
Offset offset,invalid_offset;
Dict dict,invalid_dict;
Patterns patterns,invalid_patterns;
Properties all,invalid,valid;
Limit limit,invalid_limit;
Filter filters={ //从标准的文件中读取模式串,但是删掉以下出现次数少或错误率很高的 
...
};
OffsetPattern ext_patterns={
...
}; 
OffsetPattern invalid_ext_patterns={
...
};
/****数据结构和模式串***/

int main(int argc,char**argv)
{
	std::string input_file,output_file;
	if(argc>1)
	{
		input_file=std::string(argv[1]);
		output_file=std::string(argv[2]);
	}
	else
	{
		input_file="sample_example/21.txt";
		output_file="sample_example/default.tag";
	}
	ReadChsFile<std::wstring>fin;
	ofstream fout(output_file,std::ios::out);
	vector<string>files={"sample_example/21.tag","sample_example/22.tag"};//官方标注文件
	
	wstring text=fin.read(input_file);           //读取待匹配文件 
	for(auto&ch:text) //() -> () and :->: ,统一一下方便后续处理,好像没答案需要输出括号和冒号的把o(* ̄▽ ̄*)ブ 
	{
		if(ch==static_cast<wchar_t>('('))
		{
			ch=static_cast<wchar_t>(65288);
		}
		if(ch==static_cast<wchar_t>(')'))
		{
			ch=static_cast<wchar_t>(65289); 
		}
		if(ch==static_cast<wchar_t>(':'))
		{
			ch=static_cast<wchar_t>(65306);
		}
	}
	patterns=load_local_patterns(files,filters,dict,patterns); //从本地读取模式串
	append_patterns(patterns,dict,offset,ext_patterns,limit); //加载模糊匹配的模式串 
	
	KMP kmp(text);
	
	auto all=kmp.unique_match(patterns,offset,limit,4); //第一次匹配 
	
	reset_patterns(offset,invalid_dict,invalid_patterns,invalid_limit);//清空模式串
	append_patterns(invalid_patterns,invalid_dict,offset,invalid_ext_patterns,invalid_limit); //加载不合法的模式串 
	auto invalid=kmp.match(invalid_patterns,offset,invalid_limit,4); //第二次匹配,不去重,尽可能的排除
	vector<Property>valid;
	set_difference(all.begin(),//求差集的操作
				   all.end(),
				   invalid.begin(),
				   invalid.end(),
				   back_inserter(valid),
				   [](const Property&a,const Property&b){return (a.lpos<b.lpos)||(a.lpos==b.lpos&&a.rpos>b.rpos);}
	);
	cout<<"load_from:"<<input_file<<" "<<"save_to:"<<output_file<<" total iters:"<<valid.size()<<endl;
	for(auto&t:valid)
	{
		fout<<t.lpos<<"#"<<t.rpos<<"#"<<to_utf8(dict[t.key])<<"#"<<to_utf8(t.value)<<endl;//保存UTF8的结果
	}
}

对于批量处理……嗯其实就是重复调用main.exe
很蛋疼的实现,另外其实多线程应该放这里才是合理的
但没必要,毕竟不看时间效率

//run.cpp
int main(int argc,char**argv)
{
	string input_folder="";
	string output_folder="";
	Parser parser(argc,argv); 
	string message=("run --from[data folder] --to[label folder]\ne.g.run --from test/txt --to results/results");
	parser.add_message(message);
	parser.add_parm_helper("--from","dataset folder");
	parser.add_parm_helper("--to","label folder");
	parser.add_parm_helper("-d","default dataset and label folder(--from test/txt --to results/results)");
	if(parser.find("--from"))
		input_folder=parser.get("--from");  
	if(parser.find("--to"))
		output_folder=parser.get("--to");
	if(parser.find("-d"))
	{
		input_folder="test/txt";
		output_folder="results/results";
	}
	parser.print_message_if_empty();
	parser.exit_if_empty();
	vector<string>input_files(std::move(GetFiles(input_folder)));
	vector<string>output_files(std::move(GetFiles(output_folder)));
	auto start_time=clock();
	for(unsigned i=0;i<input_files.size();i++)
	{
 		string output_file=input_files[i].substr(input_folder.size(),input_files[i].size()-input_folder.size()-4)+".tag";	
		system((string("main ")+input_files[i]+string(" ")+output_folder+string(output_file)).c_str());
 	}
 	auto end_time=clock();
 	cout<<"total_time:"<<(end_time-start_time)/1000.0<<"seconds"<<endl;
 	system("pause");
}

可视化的话其实也就比main多了几句话,就是多了个染色
以及解析一些控制台传入的参数
我这里用黄色代表标签有而且模型正确标注
绿色代表模型标注多余的
红色是缺失标注的

int main(int argc,char**argv)
{
	std::string input_file;
	std::string label_file;
	Parser parser(argc,argv); 
	string message=("visualization [-args...] --txt [data path] --tag [label path]\ne.g.visualization -i --txt 1.txt --tag 1.tag");
	parser.add_message(message);
	parser.add_parm_helper("-i","print invalid partial only");
	parser.add_parm_helper("-l","print label");
	parser.add_parm_helper("-d1","default input and ouput path(where are sample_example/21.tag and sample_example/21.txt)");
	parser.add_parm_helper("-d2","default input and ouput path(where are sample_example/22.tag and sample_example/22.txt)");
	parser.add_parm_helper("--txt","where to load data.");
	parser.add_parm_helper("--tag","where to save output result.");
	parser.print_message_if_empty();
	parser.exit_if_empty();
	if(parser.find("--txt"))
		input_file=parser.get("--txt");
	if(parser.find("--tag"))
		label_file=parser.get("--tag"); 
	if(parser.find("-d1"))
	{	
		input_file="sample_example/21.txt";
		label_file="sample_example/21.tag";  
	}
	if(parser.find("-d2"))
	{	
		input_file="sample_example/22.txt";
		label_file="sample_example/22.tag";  
	}
	ReadChsFile<std::wstring>fin;
	vector<string>files={"sample_example/21.tag","sample_example/22.tag"};
	wstring text=fin.read(input_file);                                            //读取待匹配文件 
	for(auto&ch:text) //() -> () and :->: ,统一一下方便后续处理,好像没答案需要输出括号和冒号的把o(* ̄▽ ̄*)ブ 
	{
		if(ch==static_cast<wchar_t>('('))
			ch=static_cast<wchar_t>(65288);
		if(ch==static_cast<wchar_t>(')'))
			ch=static_cast<wchar_t>(65289); 
		if(ch==static_cast<wchar_t>(':'))
			ch=static_cast<wchar_t>(65306);
	}
	fill(color,color+text.size(),'W');
	if(input_file!="")
		patterns=load_local_patterns({label_file},{},dict,patterns,color,text.size()); //加载模式串,为了染色
	else
		patterns=load_local_patterns({"sample_example/21.tag"},{},dict,patterns);
	reset_patterns(offset,dict,patterns,limit);
	
	patterns=load_local_patterns(files,filters,dict,patterns); 
	
	append_patterns(patterns,dict,offset,ext_patterns,limit); //加载模糊匹配的模式串 
	
	
	KMP kmp(text);
	auto all=kmp.unique_match(patterns,offset,limit); //第一次匹配 
	
	reset_patterns(offset,invalid_dict,invalid_patterns,invalid_limit);

	append_patterns(invalid_patterns,invalid_dict,offset,invalid_ext_patterns,invalid_limit); //加载不合法的模式串  
	auto invalid=kmp.match(invalid_patterns,offset,invalid_limit); //第二次匹配
	list<Property>valid;
	set_difference(all.begin(),
				   all.end(),
				   invalid.begin(),
				   invalid.end(),
				   back_inserter(valid),
				   [](const Property&a,const Property&b){return (a.lpos<b.lpos)||(a.lpos==b.lpos&&a.rpos>b.rpos);}
	);
	string color(::color);
	auto&result=valid;
	if(parser.find("-i"))
		result=invalid;
	cout<<result.size()<<" items are found\n";
	for(auto&t:result)
	{
		cout<<t.lpos<<"#"<<t.rpos<<"#"<<wstring2string(dict[t.key])<<"("<<wstring2string(t.key)<<")"<<"#"<<wstring2string(t.value)<<endl;
		for(int i=t.lpos;i<t.rpos;i++)
		{
			if(color[i]=='W')
				color[i]='G';
			else if(color[i]=='R')
				color[i]='Y';
		}
	}
	if(parser.find("-l")) //显示标签 
	{
		for(auto it=result.rbegin();it!=result.rend();++it)
		{
			text.insert((*it).rpos,string2wstring("[")+dict[(*it).key]+string2wstring("]"));
			color.insert((*it).rpos,dict[(*it).key].size()+2,(wchar_t)'G');
		} 
	}
	cout<<"detail:\n";
	for(int i=0;i<(int)text.size();i++)
	{
		con.SetConsoleTextColor(color[i]);
		cout<<wstring2string(text.substr(i,1));
	}
}

可视化结果展示
visualization -d2 -l
2021DCIC智能医疗决策初赛第9名方案分享+体验(非深度学习方案)

2021DCIC智能医疗决策初赛第9名方案分享+体验(非深度学习方案)

编译并运行

最后,就是builder.cmd了
这里就是编译+运行+保存结果了

@echo off
echo compilng source/main.cpp
g++ -std=c++14 -Wall -O3 source/main.cpp -o main.exe
echo done
echo compilng source/visualization.cpp
g++ -std=c++14 -Wall source/visualization.cpp -o visualization.exe
echo done
echo compilng source/run.cpp
g++ -std=c++14 -Wall -O3 source/run.cpp -o run.exe
echo done
echo run name entity recognition
run  --from test/txt --to results/results
echo done

2021DCIC智能医疗决策初赛第9名方案分享+体验(非深度学习方案)
完结,撒花

参赛收获

1.数据质量好差啊!数据质量好差啊!数据质量好差啊!
2.A榜和B榜互相打脸,标注规则不一样你敢信
3.决定入坑NLP了,Transformer和BERT是个好东西
4.以后应该没机会用C++打比赛了,实不相瞒比赛的前一两个星期大部分上分的原因都是因为修了BUG导致的,和模式串本身关系不大。。。

上一篇:中国大学MOOC-陈越、何钦铭-数据结构-起步能力自测题-2


下一篇:哈理工新生赛S题Calculate Sum——莫比乌斯反演基础题