本文简述实现自然语言处理中最基本需求的几个笨办法,也是最初引发思考的要点。文章不涉及专业的先进的系统化的自然语言处理方案,只是一篇散文。没错我就是标题党。
事情缘起于,我在做用户反馈的时候要对成百上千条用户留言进行贴标签的工作,也就是根据其反馈内容和重要程度来分类,把要解决的紧急问题反馈给工程师,建设性意见提交给产品部门,好评作为打广告的素材等等。懒惰的本性告诉我人肉分类不是办法,要诉诸更为高效的自动化手段。所以开始走进自然语言处理的坑。
我知道要做好这个,除了在语言学上了解句法词法,想要获得好的效果需要使用先进复杂的机器学习算法,来使得变化复杂多样的句子可以被程序“理解”。关于这些内容,有一本专门从实践角度介绍自然语言处理的书非常对口,叫做《Natural Language Processing with Python》,质量上乘,值得深入学习。
但是考虑到我没有大把时间和能力去深入理解复杂的算法和工具,我必须尽快在效率上给出看得见的改进,所以我开始自己思考如何解决这样的问题。
关键词过滤
因为用户给出的反馈不是天马行空的,关于产品内容有非常高的针对性,所以这套识别方法不需要具有普适性,只要能把这类特定内容的文本做出划分即可。
观察发现在“手机空间不够”这类反馈中大量出现关键词 storage、full、room、space、memory 等,这类词具有两种特点:其一,具有高度的指向性,即在手机 App 反馈这个大语境下,这类词所指的意思是精准而没有歧义的;其二,在其他反馈中很少见到这类词的使用。具有同样特点的还有分类“三俗内容”下的关键词 porn、naked、bitch、xvideos、fuck、inappropriate 等;在分类“费电”
中有关键词 power、battery 等;在分类“标题党和假新闻”中有关键词 fake、inaccurate、irrelevant 等。这些适用范围狭小而指代明确的关键词,在绝大多数情况下是足以作为定性依据的特征词,所以使用关键词查找来处理这些分类是比较靠谱的选择。
所以这个方法的思路就是,便利反馈中的所有词,当与预先设定的关键词列表相匹配时,增加这一分类的得分,最后比较不同分类得分的大小,取最高值(也就是匹配关键词数量最多)的分类作为整个反馈的分类。
如下定义每个分类的关键词,以及所有采用关键词过滤方法的分类的集合。
# Python Code
# Filename: Config.py
Map = {
'三俗内容': 'KW_SanSuNeiRong',
'标题党和假新闻': 'KW_BiaoTiDangHeJiaXinWen',
'手机空间不够': 'KW_ShouJiKongJianBuGou',
'费电': 'KW_FeiDian'
}
# 三俗内容
KW_SanSuNeiRong = [
'naked',
'porn',
'inappropriate',
'bitch',
'whore',
'unduly',
'xvideo',
'xvideos'
]
# 标题党和假新闻
KW_BiaoTiDangHeJiaXinWen = [
'fake',
'inaccurate',
'irrelevant',
'accurate'
]
# 手机空间不够
KW_ShouJiKongJianBuGou = [
'storage',
'space',
'memory',
'room',
'full'
]
# 费电
KW_FeiDian = [
'power',
'consumptiion',
'battery'
]
分析关键词时,按照分类和关键词得分建立字典,最后按照得分最大的分类作为结果,当有多个得分相同的分类时,由于循环结构的特性,将按照分类在 Map
中被定义的顺序进行取舍,所以 Map
也暗含了一个优先级的意思。
# Python Code
# Filename: Program.py
import Config
# words is a list of single word of lower case, preprocessed when reading files.
# example: ['my', 'phone', 'is', 'low', 'on', 'memory']
def analyzeKeyword(words):
chart = {}
for genre in Map:
score = analyzeFrequency(words, Map[genre])
chart[genre] = score
return getHighestScoreGenre(chart)
def analyzeFrequency(words, map)
score = 0
keywords = getattr(Config, map)
for kw in keywords:
for word in words:
if kw == word:
score += 1
return score
def getHighestScoreGenre(dict)
max = 0
result = "Unknown"
genres = [g for g in dict]
genres.reverse()
for genre in genres:
if dict[genre] >= max:
max = dict[genre]
result = genre
return result
res = analyzeKeyword(['my', 'phone', 'is', 'low', 'on', 'memory'])
print(res)
# Output: 手机空间不够
上例中,因为关键词 memory 的匹配,分类 手机空间不够 的得分为 1,其余分类都是 0,故最终分类被定位 手机空间不够。
几个小技巧:
- 将分类和关键词列表单独写在文件 Config.py 中,方便之后将新的关键词添加至分析列表中。
- 使用 getattr 函数,利用反射的方法动态获取 Config 中的列表,增加扩展性。
-
genres.reverse()
是为了反向遍历Map
字典,如此当“新值大于等于旧值”就更新最大值时,实际上实现了“排在上面的分类比排在下面的分类具有更高优先级”的逻辑,使得当两个多个分类得分相同时,总按照最上面的分类定性。
简单的情感分析
关键词过滤针对使用宽泛的形容词束手无策,比如认为 good 代表好评,那么前置一个否定词 not 的话,整句意思完全相反,同理 not bad 表示还不错。所以我进一步研究了简单的情感判断。
思路是:前置否定词(可有可无) + 后置形容词 = 一个组,否定词负负得正,结合最后的形容词是褒义还是贬义来定性这个组到底是好评还是差评。一个用户反馈中以句子为单位,每个句子统计好评组和差评组的个数,数量多的作为句子的特性,所有句子特性采用同样的标准组合起来,作为整个反馈的情感特性。
首先还是建立词库。
# Python Code
# Filename: Config.py
ST_Negative = [
"not",
"donot",
"don't",
"dont",
"doesnot",
"doesn't",
"doesnt",
"isnot",
"isn't",
"isnt",
"ain't",
"cannot",
"can't",
"cant",
"couldnot",
"couldn't",
"couldnt",
"never",
"didnot",
"didn't",
"didnt"
]
ST_Good = [
'good',
'great',
'awesome',
'amazing',
'wonderful',
'fabulous',
'cool',
'like',
'love',
'interesting',
'interested',
'use'
]
ST_Bad = [
'bad',
'useless',
'boring',
'junk',
'rubbish',
'nonsense',
'dumb',
'hate',
'stupid',
'suck',
'sucks',
'fuck',
'shitty'
]
接下来是主程序。
# Python Code
# Filename: Program.py
import Config
# sentences is a list of sentences of lower-case character preprocessed when reading files.
# example: ['it's not bad', 'and really good and shitty']
def analyzeSentence(sentences)
score = 0
for sentence in sentences:
tmp = analyzeSentenceScore(sentence)
score += tmp
return 'Good' if score > 0 else ('Bad' if score < 0 else 'Unknown')
def analyzeSentenceScore(sentence):
words = [w for w in text.split(' ') if w != ' ']
isnegative = 0
good_score = 0
bad_score = 0
for word in words:
# Check negative.
negative = 1 if word in Config.ST_Negative else 0
isnegative = 1 if negative == 1 else isnegative
# Check sentiment.
good = 1 if word in Config.ST_Good else 0
bad = 1 if word in Config.ST_Bad else 0
sentiment = 1 if good > bad else (0 if good < bad else -1)
# Score.
if sentiment != -1:
good_score += 1 if ((not isnegative) and sentiment) or (isnegative and (not sentiment)) else 0
bad_score += 0 if ((not isnegative) and sentiment) or (isnegative and (not sentiment)) else 1
isnegative = 0
# Return logic.
result = 1 if good_score > bad_score else (-1 if bad_score > good_score else 0)
return result
res = analyzeSentence(['it's not bad', 'and really good and shitty'])
print(res)
# Output: Good
上例中,第一句定性为 Good,得分 1,第二句存在一个 Good 和一个 Bad,故无法判断,得分 0,总分 1,为正,故整个反馈判定为 Good。
几个小技巧:
- Python 的三元运算符
<TrueValue> if <Expression> else <FalseValue>
非常好用,能极大地节约写作空间,在合适的嵌套下可以将比较复杂的思路简单地表示出来。 - 每一个词只可能是 好评词、差评词 和 无关词 中的一种,前两者作为 形容词,所以当
sentiment == -1
时,实际上表示这个词是无关词,那么需要继续向后寻找形容词,所以“一组”的概念没有结束。 - 当出现一个 形容词 的时候,意味着“一组”结束了,所以重置标记变量
isnegative = 0
,表示“默认情况下,在检测到否定词之前,都认为是没有否定词”。 - 关键的逻辑判断,可以翻译如下:对于当前一个组来说,整个句子的好评得分 += 1 如果(没有否定词,并且是好评;或者有否定词,并且是差评),否则 0。差评得分同理。说白了就是,“very good” 加一分,“not bad” 加一分;“very bad” 减一分,“not good” 减一分。
- 最后比较整个句子多个组的好评得分和差评得分,来确定整句话的特性。
整合与总结
因为情感判断的逻辑更复杂,且稳健性更低,所以将二者结合使用。当关键词出现无法判断的情况时,使用情感判断,给出一个大概的方向。当情感判断也无法给出结论时,判定为最终无法判断。
实测表明,这套方法在训练集上有 55% 的准确度,在两个全新的测试集上分别有 37% 和 43% 左右的准确度。说实话我已经很欣慰了,如此简陋的方法竟然可以省掉平均 40%+ 的人力,π_π……
扣题,所谓“拙劣”的方法,是因为这里除了思想还有点东西以外,在纯统计分析技术上几乎没有任何深度可言,代码写的也是大白话。核心功能十分有限,解决不了单词拼写错误,一词多义和复杂句前后逻辑识别等更深奥的问题。在最终的实现里,定义了 Review
和 Sentence
两个类,来实现更简明清晰的封装,在程序结构上看着还不错。另外,上一段借用了机器学习中的概念,也是强行打肿脸充胖子了。
自然语言处理是一个长久又新潮的问题,专业的处理方法可以使用 NLTK 包等,用更高级的机器学习和文本挖掘的方法去处理更复杂的句子。我呢,学不来那些高深的东西,只能在能力所及之内把有限的思考投入到无限的尝试中,解决一点算一点。但是在这个过程中我体会得到这些高级方法诞生伊始时的一些问题,以及想要解决它们所引发的一些思考。比如为什么会使用向量的运算,实际上就是对具有多个分类得分的句子之间的相似性,关联性进行计算,因为每个分类方向都是一个得分,所以可以看做一个向量;而语义理解上,根据词法语法和惯用句式来分析一句话,采用不同的提取方法获得特征值,也是常见的手段。
之前文章里提到的大神同学,最近在深入学习机器学习和文本挖掘的理论,看他写的东西档次就是不一样,起点比我高多了,相比之下我真是土鳖一只……接下来有时间的话我会继续看前文提到的那本书(毕竟现在才看到第二章),然后搞一点高端的方法吧。
哈哈,哈哈哈哈哈……自娱自乐中~~~