实用技术
同义词替换
故障搜索领域,查询关键字往往有固定的模式,比如“某某指标差”、“某某速率低”,为一主谓结构,主语是名词,谓语是形容词或动词,且谓语为形容词时,都是“差”、“不好”、“不达标”这样的负面形容词。
现在有个诉求:如果搜索“XX指标差”时搜不出内容,能自动用其他的负面形容词做同义词替换,比如“XX指标不好”。
方案一:词性标注序列
用词性标注来做,我们可使用下面的正则式来匹配词性序列(词性前后加<>加以区分):
<v?n.*>(<d>)*(<a.*>|<v.*>)$
亦即查询关键字遵循:
名词+副词+[形容词或动词]
这么一种序列模式。
接下来要处理几点:
- 处理否定副词(例如“不”、“非”),程度副词(例如“太”、“非常”)不用处理
- 对作为谓语的形容词或动词,找出其同义词;特别的,若副词为否定副词,则要找“否定副词+谓语”的同义词
- 同义词替换即可。
上述过程中,我们需要准备:同义词词典+否定副词列表。同义词词典有实体书,针对特定领域(如故障查询)我们也可手写。
方案二:依存句法
基于依存句法分析,比如“XX速率不高”。
我们只需分析出核心词的SBV和ADV关系即可,后续操作同前。查找核心词的SBV和ADV代码如下(使用HanLP):
public void testDependency()
{
CoNLLSentence dep = HanLP.parseDependency("XX速率不高");
System.out.println(dep);
CoNLLWord core = findHED(dep);
CoNLLWord subject = findSBV(core, dep);
CoNLLWord adv = findADV(core, dep);
System.out.println("subject:" + (subject != null ? subject.NAME:null) + ",adv:" + (adv != null ? adv.NAME:null) + ",verb:" + core.NAME);
}
private CoNLLWord findHED(CoNLLSentence dep)
{
for (CoNLLWord w: dep.word)
{
if (w.DEPREL.equals("核心关系"))
{
return w;
}
}
return null;
}
private CoNLLWord findSBV(CoNLLWord hed, CoNLLSentence dep)
{
for (CoNLLWord w: dep.word)
{
if (w.HEAD.equals(hed) && w.DEPREL.equals("主谓关系"))
{
System.out.println(w.NAME);
return w;
}
}
return null;
}
private CoNLLWord findADV(CoNLLWord hed, CoNLLSentence dep)
{
for (CoNLLWord w: dep.word)
{
if (w.HEAD.equals(hed) && w.DEPREL.equals("状中结构"))
{
return w;
}
}
return null;
}
文本分类
文本分类用途很广,比如垃圾邮件检测、文档划分等。
一般的,我们使用sklearn来做原型,选择效果最好的分类器,再用java来做版本实现。
sklearn的分类器遵循下面的接口:
#训练
fit(train_features, train_labels)
#预测
predict(test_features)
其中,train_features和test_features是二维特征矩阵
,其中,行为采样数,列为特征数,矩阵元素类型为数值;train_labels为数组,数组长度为采样数,数组元素类型则没有限制,能区别分类结果即可(比如,对于二分类问题可定义为数字1/0或字符串Y/N)。
特征提取
对于文本分类,首先要做的是从文本中抽取出数值型的特征值
,构成二维特征矩阵,这个过程也是文本向量化
的过程,可以使用前面提到的词袋法。
词袋法
使用sklearn提供的CountVectorizer类,样例如下:
def bow_extracter(self, corpus):
# ngram_range是说可以把几个词连起来视作一个特征,bow一般就是一个词一个特征,否则特征数会爆炸
vectorizer = CountVectorizer(stop_words=None, ngram_range=(1,1), analyzer='word')
features = vectorizer.fit_transform(corpus)
feature_names = vectorizer.get_feature_names()
trace('feature num:%d' % len(feature_names))
return vectorizer, features
提取的结果是语料中的每个词作为一个特征,语料中的每篇文档形成
[1,0,2,0...]
这样的向量。从前面词袋法的介绍得知,这里的特征数目可能会非常巨大(取决于词典里词的总数)。
CountVectorizer具体说明参看:
http://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.CountVectorizer.html#sklearn.feature_extraction.text.CountVectorizer.fit_transform
tf-idf特征提取
也可使用tf-idf方法完成文本向量化,该方法是在词袋法的基础上考虑了idf,即“逆向文档频率”,削弱了那些在多个文档中频繁出现的词的影响(这些词往往不那么重要,因为它们无法区分文档)。样例代码如下:
def tfidf_extracter(self, corpus):
# 做l2正则化,即均方。l1则为误差绝对值的和
vectorizer = TfidfVectorizer(min_df=1,
norm='l2',
smooth_idf=True,
use_idf=True,
ngram_range=(1,1))
return self.extracter_i(vectorizer, corpus)
def extracter_i(self, vectorizer, corpus):
features = vectorizer.fit_transform(corpus)
feature_names = vectorizer.get_feature_names()
trace('feature num:%d' % len(feature_names))
trace('/ '.join(feature_names))
# trace(features.toarray())
return vectorizer, features
tf-idf介绍
tf-idf(d, t) = tf(t) * idf(d, t)
其中:
t为词,tf(t)为词频;
d为文档,idf(d, t)为逆文档频率。
idf(d, t)=log [ N / df(d, t) ] + 1
N为文档总数,df(d, t)为包含词t的文档数。可见,词t在文档里出现的越少,idf值越大,该词的影响力就越大。+1主要是确保那些在所有文档里出现的词(显然,此时df(d, t)=N,所以log [ N / df(d, t) ] = 0)不会完全被忽略。
为避免除数为0,sklearn的tf-idf向量化默认会做idf平滑
,上述公式演变成:
idf(d, t) = log [ (1 + N) / (1 + df(d, t)) ] + 1
l1和l2正则化
l2正则化,即均方。l1则为误差绝对值的和。
下面是一个例子:
from sklearn import preprocessing
fea = [
[1,0,-1],
[1,1,1],
]
# [[ 1/2=0.5 0. -0.5 ]
# [ 1/3=0.33333333 0.33333333 0.33333333]]
X = preprocessing.normalize(fea, norm='l1', axis=1)
print X
# [[ 1/sart(2)=0.70710678 0. -0.70710678]
# [ 1/sqrt(3)=0.57735027 0.57735027 0.57735027]]
X = preprocessing.normalize(fea, norm='l2', axis=1)
print X
注意
特征向量正则化的方向有根据采样记录正则,此时axis=1,也可根据特征正则,此时axis=0。有时候,根据采样记录正则是有问题的,比如采样记录里有不同性质的特征,如电压、温度,强行正则没有意义。
doc2vec向量化
还可以使用前面提到的word2vec的改进版doc2vec来生成文档向量,相比于词袋法,doc2vec的特征数更可控。核心代码为:
def extract_features(self, norm_corpus):
model = gensim.models.Doc2Vec(norm_corpus,
vector_size=500,
dm=1,
window=5,
min_count=2)
trace('before scale:%s' % model.docvecs[0])
##获得文档向量
return model.docvecs
其他方法
我们也可以采用其他做法来获得特征值,例如:
- 文档标题
- 业务关键词打分
- 长、短段落打分
模型训练及评估
模型训练使用前面提到的sklearn的fit方法。我们重点关注评估。
分类器效果评估有几种方法:
- hold-out,将原始数据随机分为两组,一组做为训练集,一组做为验证集,利用训练集训练分类器,然后利用验证集验证模型,记录最后的分类准确率作为分类器的性能指标。
- k-fold,将原始数据分成K组(一般是均分),将每个子集数据分别做一次验证集,其余的K-1组子集数据作为训练集,这样会得到K个模型。用这K个模型的验证集的分类准确率的平均数作为此K-CV下分类器的性能指标。K一般大于等于2
k-fold就是通常所谓的“交叉验证”(cross-validation)。
hold-out样例代码如下:
def hold_out(self, corpus, labels, clfs, clf_names):
train_corpus, test_corpus, train_labels, test_labels = self.split_data(corpus, labels)
trace('split corpus complete')
norm_train_corpus = self.normalize(train_corpus)
norm_test_corpus = self.normalize(test_corpus)
trace('normalize corpus complete')
# 训练和测试必须使用相同的特征,所以此处获得测试特征用的是transform而非fit_transform
bow_vectorizer, bow_train_features = self.bow_extracter(norm_train_corpus)
bow_test_features = bow_vectorizer.transform(norm_test_corpus)
for clf,cn in zip(clfs, clf_names):
trace('classifier:%s' % cn)
pred = self.train_and_classify(clf, bow_train_features, train_labels, bow_test_features, test_labels)
def split_data(self, corpus, labels):
#使用sklearn提供的train_test_split做训练集、验证集的划分
X_train, X_test, y_train, y_test = train_test_split(corpus, labels, test_size=0.3, random_state=42)
return X_train, X_test, y_train, y_test
def train_and_classify(self, clf, train_features, train_labels, test_features, test_labels):
# 训练模型
clf.fit(train_features, train_labels)
# 预测
predictions = clf.predict(test_features)
# 将预测值与真实值做对比,生成混淆矩阵
self.get_metrics(test_labels, predictions)
return predictions
def get_metrics(self, true_labels, predicted_labels):
trace('准确率:%.2f' % np.round(
metrics.accuracy_score(true_labels,
predicted_labels),
2))
trace('精度:%.2f' % np.round(
metrics.precision_score(true_labels,
predicted_labels,
average='weighted'),
2))
trace('召回率:%.2f' % np.round(
metrics.recall_score(true_labels,
predicted_labels,
average='weighted'),
2))
trace('F1得分:%.2f' % np.round(
metrics.f1_score(true_labels,
predicted_labels,
average='weighted'),
2))
这是k-fold的例子:
def kfold(self, corpus, labels, clfs, clf_names):
norm_corpus = self.normalize(corpus)
_, bow_features = self.tfidf_extracter(norm_corpus)
for clf, cn in zip(clfs, clf_names):
trace('classifier:%s' % cn)
#使用sklearn提供的cross_val_score自动做K-CV,这里我们使用F1作为评估指标
trace(cross_val_score(clf, bow_features, labels, cv=5, scoring='f1'))
precision和recall
混淆矩阵(confusion matrix)定义如下,1代表正类,0代表负类:
预测1 | 预测0 | |
---|---|---|
实际1 | True Positive(TP) | False Negative(FN) |
实际0 | False Positive(FP) | True Negative(TN) |
precision的公式是P=TP/(TP+FP)或 P=TN/TN+FN,对于正类别而言,表示预测为正的样本中有多少预测对了
。precison是从预测结果来看的,表示通常意义上的分类准确率。
recall的公式是R=TP/(TP+FN)或R=TN/TN+FP ,对于正类别而言,表示实际为正的样本中有多少预测对了(即召回了多少正样本,剩下的正样本都丢失了)
。recall则是从实际结果来看的。
文档聚类
文档聚类的思路是先将文档向量化(词袋法、doc2vec皆可),再利用KMean等聚类算法进行聚类。
聚类可以跟人工标注结合起来使用。
下面的例子使用doc2vec做文档向量化,再利用sklearn里的KMean算法做聚类:
class TextCluster(TextClassifier_d2v):
"""docstring for TextCluster"""
def __init__(self):
super(TextCluster, self).__init__()
def run(self, work_dir):
spam_path = '%s/data/spam_data.txt' % work_dir
ham_path = '%s/data/ham_data.txt' % work_dir
corpus, labels = self.load_raw_data(spam_path, ham_path)
norm_corpus = self.normalize(corpus)
features = self.extract_features(norm_corpus)
#设定簇数
clf = KMeans(n_clusters=2)
#模型训练
s = clf.fit(features)
#输出每个样本所属的簇,这里有0和1两个簇
labels = clf.labels_.tolist()
trace(labels)
trace('cluster 0 count:%d, cluster 1 count:%d' % (labels.count(0), labels.count(1)))
#用来评估簇的个数是否合适,距离越小说明簇分的越好,选取临界点的簇个数
trace(clf.inertia_)
trace(clf.predict([list(features[0])]))
#用doc2vec将文档转成向量
def extract_features(self, norm_corpus):
model_path = 'd2v.model'
if os.path.exists(model_path):
model = gensim.models.Doc2Vec.load(model_path)
trace('load d2v model success')
else:
model = gensim.models.Doc2Vec(norm_corpus,
vector_size=self.vec_size,
dm=1,
window=5,
min_count=2)
model.save(model_path)
trace('doc vector size:%d' % len(model.docvecs))
trace('model first rec:%s, type:%s' % (model.docvecs[0], type(model.docvecs[0])))
return self.to_list(model.docvecs)
def to_list(self, features):
new_features = []
for i in xrange(0, len(features)):
new_features.append(features[i])
return new_features