【敏感词检测】用DFA构建字典树完成敏感词检测任务

任务概述

敏感词检测是各类平台对用户发布内容(UGC)进行审核的必做任务。
对于文本内容做敏感词检测,最简单直接的方法就是规则匹配。构建一个敏感词词表,然后与文本内容进行匹配,如发现有敏感词,则提交报告给人工审核或者直接加以屏蔽。
当然也可以用机器学习的方法来做,不过需要收集及标注大量数据,有条件的话也可以加以实现。

任务难点及解决策略

1)对抗检测的场景:比如同音替换、字形替换、隐喻暗指、词中间插入特殊字符等。

解决策略:特殊字符可以使用特殊字符词表过滤,其他几种不好解决,主要通过扩大敏感词表规模来。

2)断章取义的问题:从语义上理解没有问题,但按窗口大小切出几个字来看,却属于敏感词,造成误报。

解决策略:这个问题主要是分词错误导致的,应当考虑分词规则,而不是无脑遍历,或者用正则匹配。

3)检测效率问题:随着词表的增大,循环查找词表的速度会变得很慢。

解决策略:使用DFA算法构建字典树。

4)词的歧义问题:一个词某个义项是违规的,但其他义项或许是正常的。

解决策略:这个要结合上下文考虑,机器学习类的方法比较容易解决这一问题。

5)词表质量问题:从网络上获取得到的敏感词词表,要么包含词汇较少,不能满足检测需求,要么包含词汇过多,检测出了很多当前业务场景下不需要屏蔽的词。

解决策略:需要定期人工整理。

敏感词字典树的构建

构建字典树使用的是确定有限自动机(DFA)。DFA算法的核心是建立了以敏感词为基础的许多敏感词树。
它的基本思想是基于状态转移来检索敏感词,只需要扫描一次待检测文本(长度n),就能对所有敏感词进行检测。
且DFA算法的时间复杂度O(n)基本上是与敏感词的个数无关的,只与文本长度有关。

如下图所示,比如abcd,abd,bcd,efg,hij这几个词在树中表示如下。
中文的常用字只有四五千个,但由这些字构成的词却难以计数,如果循环遍历,时间消耗极大,而通过字典树,沿着根节点向下,每走一步就可以极大地缩小搜索空间。

代码实现

import jieba

min_match = 1  # 最小匹配原则
max_match = 2  # 最大匹配原则

class SensitiveWordDetect:
    def __init__(self, sensitive_words_path, stopWords_path):
        #============把敏感词库加载到列表中====================  
        temp_line_list = []
        with open(sensitive_words_path, 'r', encoding='utf-8') as file:
            temp_line_list = file.readlines()
            
        self.sensitive_word_list = sorted([i.split('\n')[0] for i in temp_line_list])
        # print(self.sensitive_word_list[-10:])
        
        #============把停用词加载到列表中======================
        temp_line_list_2 = []
        with open(stopWords_path, 'r', encoding='utf-8') as file:
            temp_line_list_2 = file.readlines()
            
        self.stop_word_list = [i.split('\n')[0] for i in temp_line_list_2]
        
        #==============得到sensitive字典=======================
        self.sensitive_word_map = self.init_sensitive_word_map(self.sensitive_word_list)
        #print(self.sensitive_word_map)
        #print(len(self.sensitive_word_map)) 
        
    # 构建敏感词库
    def init_sensitive_word_map(self, sensitive_word_list):
        sensitive_word_map = {}
        # 读取每一行,每一个word都是一个敏感词
        for word in sensitive_word_list:
            now_map = sensitive_word_map
            # 遍历该敏感词的每一个特定字符
            for i in range(len(word)):
                keychar = word[i]
                word_map = now_map.get(keychar)
                if word_map != None:
                    # now_map更新为下一层
                    now_map = word_map
                else:
                    # 不存在则构建一个map, isEnd设置为0,因为不是最后一个
                    new_next_map = {}
                    new_next_map["isEnd"] = 0
                    now_map[keychar] = new_next_map
                    now_map = new_next_map
                # 到这个词末尾字符
                if i == len(word)-1:
                    now_map["isEnd"] = 1
        # print(sensitive_word_map)
        return sensitive_word_map
        
    def check_sensitive_word(self, txt, begin_index=0, match_mode=min_match):
        '''
        :param txt: 输入待检测的文本
        :param begin_index:输入文本开始的下标
        :return:返回敏感词字符的长度
        '''
        now_map = self.sensitive_word_map
        sensitive_word_len = 0 # 敏感词的长度
        contain_char_sensitive_word_len = 0 # 包括特殊字符敏感词的长度
        end_flag = False #结束标记位

        for i in range(begin_index, len(txt)):
            char = txt[i]
            if char in self.stop_word_list:
                contain_char_sensitive_word_len += 1
                continue

            now_map = now_map.get(char)
            if now_map != None:
                sensitive_word_len += 1
                contain_char_sensitive_word_len += 1
                # 结束位置为True
                if now_map.get("isEnd") == 1:
                    end_flag = True
                    # 最短匹配原则
                    if match_mode == min_match:
                        break
            else:
                break
        if end_flag == False:
            contain_char_sensitive_word_len = 0
        #print(sensitive_word_len)
        return contain_char_sensitive_word_len
        
    def get_sensitive_word_list(self, txt):
        # 去除停止词
        new_txt = ''
        for char in txt:
            if char not in self.stop_word_list:
                new_txt += char
        # 然后分词
        seg_list = list(jieba.cut(new_txt, cut_all=False))
        
        cur_txt_sensitive_list = []
        # 注意,并不是一个个char查找的,找到敏感词会增强敏感词的长度
        for i in range(len(txt)):
            length = self.check_sensitive_word(txt, i, match_mode=max_match)
            if length > 0:
                word = txt[i:i+length]
                cur_txt_sensitive_list.append(word)
                i = i+length-1  # 出了循环还要+1 i+length是没有检测到的,下次直接从i+length开始
        
        # 对得到的结果和分词结果进行匹配,不匹配的不要
        rst_list = []
        for line in cur_txt_sensitive_list:
            new_line = ''
            for char in line:
                if char not in self.stop_word_list:
                    new_line += char
            if new_line in seg_list:
                rst_list.append(line)
        return rst_list
        
    def replace_sensitive_word(self, txt, replace_char='*'):
        lst = self.get_sensitive_word_list(txt)
        #print(lst)
        # 如果需要加入的关键词,已经在关键词列表存在了,就不需要继续添加
        def judge(lst, word):
            if len(lst) == 0:
                return True
            for str in lst:
                if str.count(word) != 0:
                    return False
            return True
            
        # 最严格的打码,选取最长打码长度
        for word in lst:
            replace_str = len(word) * replace_char
            txt = txt.replace(word, replace_str)
            
        new_lst = []
        for word in lst:
            new_word = ""
            # newWord是除去停用词、最精炼版本的敏感词
            for char in word:
                if char in self.stop_word_list:
                    continue
                new_word += char
            length = self.check_sensitive_word(new_word, 0, match_mode=min_match)
            if judge(new_lst, new_word[:length]):
                new_lst.append(new_word[:length])
            else:
                continue

        return txt, new_lst # 最终返回的结果是屏蔽敏感词后的文本,以及检测出的敏感词
posted @ 2020-08-19 17:53  Yanqiang  阅读(3038)  评论(0编辑  收藏  举报