文本问题也被称为自然语言处理问题 Natural Language Processing,NLP。
NLP 问题从某种程度上说,也可以当作图像处理,但又和图像不同。通常需要创建 pipline 式的处理方法,基于业务场景来构建模型。
顺便一提,在机器学习领域,构建模型是一个方面,如何提升模型性能并将其应用于实际业务,更需要你理解业务中的各个需求。
有许多不同类型的 NLP 任务,最常见的是文本分类任务。人们对与表格数据和图像数据的处理基本没有困惑,然而在面对文本数据时,经常不知从何上手。
在计算机中,所有的数据都是数字,因此文本数据本质上和其他类型数据没有区别。
让我们从基础的情感分类任务开始学习,我们尝试对影评文本做情感分类。
如果你有一段文本,和对应的情感标签,接下来如何处理呢?直接丢给深度神经网络,然后期待该问题能够自动处理好?这种方法行不通。
下边我们一步一步来学习文本数据相关特征,使用 IMDB 影评数据集。该数据集包含 25000 个正面情感影评和 25000 个负面情感影评。
数据下载地址:
接下来讨论的概念可以用于任何文本分类数据集。
该数据集很容易理解,一个影评对应一个目标值。这里使用影评而不是句子,一个影评可以包含多个句子。你肯定见过对单个句子做分类的情况,然而该任务需要你对一批句子做分类。即目标情感得分由这一批句子各自的情感得分组成。
那么如何上手呢?
先演示一个简单的例子,手动构建两个单词序列,一个包含你能想到的正面单词,比如 good、awesome、nice 等,另一个包含负面单词,如 bad、evil 等。
一旦构建这俩序列后,你甚至不需要模型来做预测,这俩序列就是著名的情感词典 sentiment lexicons。网络上有大量不同语言的情感词典。
现在,你可以对句子中的单词进行统计,计算句中正面单词和负面单词出现的次数,如果正面单词出现次数更高,则将该句子分类为正面情感。否则,该句子分类为负面情感。如果句中没有这两类单词,那么该句子是中性的。
这种方法非常古典,但一些人还在使用。
def find_sentiment(sentence, pos, neg):
s = set(sentence.split())
num_pos = s.intersection(pos)
num_neg = s.intersection(neg)
if num_pos > num_neg:
return 'positive'
elif num_pos < num_neg:
return 'negative'
return 'neutral'
上述代码是最简单的实现,但是许多要点没有考虑在内,比如标点符号的处理,split 方法只对空格做切分,句中的标点符号会和单词划分在一起。
因此推荐使用 tokenization 工具来做分词预处理,最流行的分词工具包是 NLTK,Natural Language Tool Kit。
import nltk
from nltk.tokenize import word_tokenize
nltk.download('punkt')
sentence = 'Hi, how are you?'
print(sentence.split())
print(word_tokenize(sentence))
执行上述代码,你会看到 NLTK 对句子的划分效果更符合预期。这也是我们用来检测情感的第一个模型。
在面对 NLP 分类问题时,你应该首先使用的是词袋模型 bag of words。
在词袋模型中,我们基于语料创建大规模稀疏矩阵,存储各单词频率。
可以使用 scikit-learn 的 CountVectorize 方法来实现该模型。
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
'hello, how are you?',
'im getting bored at home. And you? what do you think?',
'did you know about counts?',
'let\'s see if this works.',
'YES!!!'
]
ctv = CountVectorizer()
print(ctv.fit_transform(corpus))
该模型将原始语料转换为稀疏矩阵,矩阵的行索引表示语料中的对应句子,列索引表示 token id。
CountVectorizer 的工作原理是先对句子分词,然后对每个 token 设置一个 id,这样可以通过数字 id 来表示文本 token,即 token id。
可通过以下代码查看 token id 映射。
print(ctv.vocabulary_)
上述操作在分词过程中,丢弃了某些特殊符号,比如 '?' ,有时候需要保留这些特殊符号。
接下来调整 CountVectorizer 的分词器。
from sklearn.feature_extraction.text import CountVectorizer
from nltk.tokenize import word_tokenize
corpus = [
'hello, how are you?',
'im getting bored at home. And you? what do you think?',
'did you know about counts?',
'let\'s see if this works.',
'YES!!!'
]
ctv = CountVectorizer(tokenizer=word_tokenize)
ctv.fit(corpus)
print(ctv.vocabulary_)
有了以上处理手段,我们可以在 IMDB 数据集上对句子进行分词,构建稀疏矩阵,创建模型。
笔者下载的数据集格式和原文不一致,需要将分散的各个文本文件整理为 csv 。
import os
import os.path as pth
import pandas as pd
ROOT_DIR = 'imdb/train/'
def handle_sentiment_text(sentiment):
dir_path = pth.join(ROOT_DIR, sentiment)
idxs = []
scores = []
texts = []
for fn in os.listdir(dir_path):
idx_score = fn.replace('.txt', '')
idx, score = idx_score.split('_')
idxs.append(idx)
scores.append(score)
with open(pth.join(dir_path, fn)) as f:
texts.append(''.join(f.readlines()))
return idxs, scores, texts
pos_idxs, pos_scores, pos_texts = handle_sentiment_text('pos')
neg_idxs, neg_scores, neg_texts = handle_sentiment_text('neg')
df = pd.DataFrame(dict(
idx=pos_idxs + neg_idxs,
score=pos_scores + neg_scores,
text=pos_texts + neg_texts
))
df.to_csv('imdb/imdb.csv', index=False)
数据集中正例与负例的比率是 1:1,平衡数据集,因此可以直接使用 accuracy 作为模型指标。
接着,我们使用 StratifiedKFold 将数据集划分为 5 个 fold,做交叉验证。
然后,对于高维稀疏数据,选择处理速度最快的 Logistic Regression 模型,使用 Logistic Regression 模型作为本章后续模型的 benchmark 。
import pandas as pd
from sklearn import model_selection, metrics, linear_model
from sklearn.feature_extraction.text import CountVectorizer
from nltk.tokenize import word_tokenize
df = pd.read_csv('imdb/imdb.csv')
df.loc[:, 'sentiment'] = df.score.astype(int).apply(
lambda x: 1 if x >= 7 else 0
)
df['kfold'] = -1
df = df.sample(frac=1).reset_index(drop=True)
y = df.sentiment.values
kf = model_selection.StratifiedKFold(n_splits=5)
for f, (t_, v_) in enumerate(kf.split(X=df, y=y)):
df.loc[v_, 'kfold'] = f
for f in range(5):
df_train = df[df.kfold != f].reset_index(drop=True)
df_valid = df[df.kfold == f].reset_index(drop=True)
vectorizer = CountVectorizer(tokenizer=word_tokenize)
vectorizer.fit(df_train.text)
x_train = vectorizer.transform(df_train.text)
x_valid = vectorizer.transform(df_valid.text)
model = linear_model.LogisticRegression()
model.fit(x_train, df_train.sentiment)
preds = model.predict(x_valid)
accuracy = metrics.accuracy_score(df_valid.sentiment, preds)
print(f'Fold: {f}, Accuracy: {accuracy}')
该模型准确率有 89%,我们仅仅用了词袋模型和 Logistic Regression,效果非常好。
然而该模型在训练时耗费不少时间,下边我们试着使用贝叶斯分类器优化训练时间。贝叶斯分类器也是在大型稀疏矩阵上处理 NLP 任务的流行模型。
我们只需修改上述代码的几行便可调整为贝叶斯分类器。
import pandas as pd
from sklearn import model_selection, metrics, naive_bayes
from sklearn.feature_extraction.text import CountVectorizer
from nltk.tokenize import word_tokenize
df = pd.read_csv('imdb/imdb.csv')
df.loc[:, 'sentiment'] = df.score.astype(int).apply(
lambda x: 1 if x >= 7 else 0
)
df['kfold'] = -1
df = df.sample(frac=1).reset_index(drop=True)
y = df.sentiment.values
kf = model_selection.StratifiedKFold(n_splits=5)
for f, (t_, v_) in enumerate(kf.split(X=df, y=y)):
df.loc[v_, 'kfold'] = f
for f in range(5):
df_train = df[df.kfold != f].reset_index(drop=True)
df_valid = df[df.kfold == f].reset_index(drop=True)
vectorizer = CountVectorizer(tokenizer=word_tokenize)
vectorizer.fit(df_train.text)
x_train = vectorizer.transform(df_train.text)
x_valid = vectorizer.transform(df_valid.text)
model = naive_bayes.MultinomialNB()
model.fit(x_train, df_train.sentiment)
preds = model.predict(x_valid)
accuracy = metrics.accuracy_score(df_valid.sentiment, preds)
print(f'Fold: {f}, Accuracy: {accuracy}')
模型训练速度变快了一些,准确率为 85% 左右。
另一种著名的 NLP 编码方法为 TF-IDF,TF 是词频 Term Frequencies,IDF 是逆文档频率 Inverse Document Frequency。
词频指的是某单词在一个文档中的出现频率除以该文档的单词总数。最后计算该单词在所有文档里的词频的平均值。
逆文档频率指的是包含某单词的文档除以文档总数的倒数,再取对数。
在代码中的用法和 CountVectorizer 类似。
from sklearn.feature_extraction.text import TfidfVectorizer
from nltk.tokenize import word_tokenize
corpus = [
'hello, how are you?',
'im getting bored at home. And you? what do you think?',
'did you know about counts?',
'let\'s see if this works.',
'YES!!!'
]
tfidf = TfidfVectorizer(tokenizer=word_tokenize)
tfidf.fit(corpus)
print(tfidf.transform(corpus))
TF-IDF 的值为 float,不再是词袋模型的 int。
在上述模型训练代码中替换 TF-IDF。
import pandas as pd
from sklearn import model_selection, metrics, linear_model
from sklearn.feature_extraction.text import TfidfVectorizer
from nltk.tokenize import word_tokenize
df = pd.read_csv('imdb/imdb.csv')
df.loc[:, 'sentiment'] = df.score.astype(int).apply(
lambda x: 1 if x >= 7 else 0
)
df['kfold'] = -1
df = df.sample(frac=1).reset_index(drop=True)
y = df.sentiment.values
kf = model_selection.StratifiedKFold(n_splits=5)
for f, (t_, v_) in enumerate(kf.split(X=df, y=y)):
df.loc[v_, 'kfold'] = f
for f in range(5):
df_train = df[df.kfold != f].reset_index(drop=True)
df_valid = df[df.kfold == f].reset_index(drop=True)
vectorizer = TfidfVectorizer(tokenizer=word_tokenize)
vectorizer.fit(df_train.text)
x_train = vectorizer.transform(df_train.text)
x_valid = vectorizer.transform(df_valid.text)
model = linear_model.LogisticRegression()
model.fit(x_train, df_train.sentiment)
preds = model.predict(x_valid)
accuracy = metrics.accuracy_score(df_valid.sentiment, preds)
print(f'Fold: {f}, Accuracy: {accuracy}')
得到的准确率要比使用词袋模型的 Benchmark 高一点点。
现在把该模型当作新的基准 Benchmark。
另一个 NLP 著名概念是 N-Gram,它是一组单词的有序集合。
我们可以使用 NLTK 快速构建 N-Gram,来具体尝试一下该概念。
from nltk import ngrams
from nltk.tokenize import word_tokenize
N = 3
s = 'Hello, how are you?'
tokens = word_tokenize(s)
print(list(ngrams(tokens, n=N)))
# [('Hello', ',', 'how'),
# (',', 'how', 'are'),
# ('how', 'are', 'you'),
# ('are', 'you', '?')]
调整参数 N,我们可以创建 2-grams、3-grams、4-grams 等。
现在可以把用整个 N-Gram 代替原来的单词作为新的 Token。
比如,在计算 TF-IDF 时,不再对单个 token 做统计,而是对整个 N-Gram 做统计。CountVectorizer 和 TfidfVerctorizer 都提供 ngram_range 参数,作为 N-Gram 版本实现,该参数默认为 (1, 1),这俩数值分别表示最小和最大 gram 长度。
调整该参数为 (1, 3),结果中的 vocabulary 会包含 1-gram、2-gram 和 3-gram。
调整后代码如下。
import pandas as pd
from sklearn import model_selection, metrics, linear_model
from sklearn.feature_extraction.text import TfidfVectorizer
from nltk.tokenize import word_tokenize
df = pd.read_csv('imdb/imdb.csv')
df.loc[:, 'sentiment'] = df.score.astype(int).apply(
lambda x: 1 if x >= 7 else 0
)
df['kfold'] = -1
df = df.sample(frac=1).reset_index(drop=True)
y = df.sentiment.values
kf = model_selection.StratifiedKFold(n_splits=5)
for f, (t_, v_) in enumerate(kf.split(X=df, y=y)):
df.loc[v_, 'kfold'] = f
for f in range(5):
df_train = df[df.kfold != f].reset_index(drop=True)
df_valid = df[df.kfold == f].reset_index(drop=True)
vectorizer = TfidfVectorizer(
tokenizer=word_tokenize,
ngram_range=(1, 3)
)
vectorizer.fit(df_train.text)
x_train = vectorizer.transform(df_train.text)
x_valid = vectorizer.transform(df_valid.text)
model = linear_model.LogisticRegression()
model.fit(x_train, df_train.sentiment)
preds = model.predict(x_valid)
accuracy = metrics.accuracy_score(df_valid.sentiment, preds)
print(f'Fold: {f}, Accuracy: {accuracy}')
执行结果没有提升,需要调整不同参数进行尝试。
NLP 还有许多基础概念。下边俩个术语也是我们必须掌握的:Stemming 和 Lemmatization。
Stemming 词干提取是去除单词的前后缀得到词根的过程,常见的前后词缀有「名词的复数」、「进行式」、「过去分词」。处理后得到的单词被称为 stemmed word。
Lemmatization 词形还原是基于词典,将单词的复杂形态转变成最基础的形态。处理后得到的单词被称为 lemma word。
对给定文本做 Stemming 和 Lemmatization 时,需要你对该语言的词法语法有详细了解。
NLTK 包提供了开箱即用的工具来实现这两种操作。
import nltk
from nltk.stem import WordNetLemmatizer, SnowballStemmer
nltk.download('wordnet')
lemmatizer = WordNetLemmatizer()
stemmer = SnowballStemmer('english')
words = ["fishing", "fishes", "fished"]
for word in words:
print(f"word={word}")
print(f"stemmed_word={stemmer.stem(word)}")
print(f"lemma={lemmatizer.lemmatize(word)}")
print("")
可以看到,Stemming 和 Lemmatization 对同一个单词对处理结果并不相同,在使用时需要注意。
你还需要了解主题抽取相关知识。
Topic Extraction 主题抽取可以通过 non-negative matrix factorization (NMF)和 latent semantic analysis (LSA) 完成。这两种方法都是著名的分解降维方法,能够将给定数据分解到指定维度。
可以将 CountVectorizer 和 TfidfVectorizer 后得到的稀疏矩阵输入给这两种分解算法,以获得目标维向量。
import pandas as pd
from sklearn import decomposition
from sklearn.feature_extraction.text import TfidfVectorizer
from nltk.tokenize import word_tokenize
corpus = pd.read_csv('imdb/imdb.csv', nrows=10000)
corpus = corpus.text.values
vectorizer = TfidfVectorizer(tokenizer=word_tokenize)
vectorizer.fit(corpus)
corpus_vector = vectorizer.transform(corpus)
svd = decomposition.TruncatedSVD(n_components=10)
corpus_svd = svd.fit(corpus_vector)
features = dict(zip(
vectorizer.get_feature_names_out(),
corpus_svd.components_[0]
))
print(sorted(features, key=features.get, reverse=True)[:5])
然而上述结果看起来似乎没啥用,接下来该如何优化这个结果?
首先需要清理无用符号,删除多余空格,重复标点等。
import re, string
import pandas as pd
from sklearn import decomposition
from sklearn.feature_extraction.text import TfidfVectorizer
from nltk.tokenize import word_tokenize
def clean_text(s: str):
s.split()
s = ' '.join(s)
s = re.sub(f'[{re.escape(string.punctuation)}]', '', s)
return s
corpus = pd.read_csv('imdb/imdb.csv', nrows=10000)
corpus.loc[:, 'text'] = corpus.text.apply(clean_text)
corpus = corpus.text.values
vectorizer = TfidfVectorizer(tokenizer=word_tokenize)
vectorizer.fit(corpus)
corpus_vector = vectorizer.transform(corpus)
svd = decomposition.TruncatedSVD(n_components=10)
corpus_svd = svd.fit(corpus_vector)
features = dict(zip(
vectorizer.get_feature_names_out(),
corpus_svd.components_[0]
))
print(sorted(features, key=features.get, reverse=True)[:5])
抽取出的主题词与上次相比,更有意义了。
接下来还可以删除停用词 Stopwords 来提升性能。停用词 Stopword 是高频使用的单词,如 a、an、the 等。
然而并不可以无脑移除停用词,具体情况需要根据业务场景判断。句子 ‘I need a new dog’ 在删除停用词后成了 ‘need new dog’ ,丢失上下文信息。
接下来,我们进入深度学习方法介绍。
首先要了解的概念是:词嵌入 Word Embedding。
在上边的代码中,我们把单词转换为数字数组,然后将数字数组输入给模型。
因此,如果语料库中有 N 个单词,那么我们可以用 0 到 N-1 的数字表示对应单词。该数字即可看作一维向量。
使用向量表示单词的思路便是词嵌入 Word Embedding。
Google 提出的 Word2Vec 是最经典的单词转向量方法。除此之外,还有 Facebook 的 FastText 和 Stanford 的 GloVe(Global Vector for Word Representation)。这些方法均不相同。
词嵌入的基本思路是,构建浅层神经网络来学习单词的向量表示,并能通过学习到的向量重构输入句子。
因此,你可以构建网络,通过给定位置前后的单词,预测当前位置单词。这种方式也被称为 Continuous Bag of Words(CBOW)模型。
你也可以通过指定位置单词,预测该位置前后的单词。这种方式被称为 Skip-Gram 模型。
FastText 学习字符级 n-gram 的向量表示。与单词级 n-gram 相似,如果处理的基本元素是字符,那么前后相邻的一串字符就是字符级 n-gram。
GloVe 通过单词的共现矩阵学习词嵌入。
然后,所有这些词嵌入技术都是训练一个字典,输入语料库中的单词,返回固定长度的词向量。
将单词映射到向量空间中,可以用向量距离表示单词之间的某些关系。然后,可以通过向量的线性组合来计算等概念距离的词对。
一种对词向量的使用方式是,构建句子向量 sentence vector。可以简单地用句中单词词向量的平均值代表句子向量。
import numpy as np
def sentence_vector(s, embedding_dict, stopwords, tokenizer):
words = str(s).lower()
words = tokenizer(words)
words = [w for w in words if w not in stopwords]
words = [w for w in words if w.isalpha()]
W = []
for w in words:
if w in embedding_dict:
W.append(embedding_dict[w])
if len(W) == 0:
return np.zeros(300)
W = np.array(W)
v = W.sum(axis=0)
return v / np.sqrt((v ** 2).sum())
那么,我们可以用 FastText 词向量来优化之前的 Benchmark 模型吗?
需要 pip install fasttext 安装 FastText 包,然后去官网下载对应向量文件。
import io
import numpy as np
import pandas as pd
from nltk.tokenize import word_tokenize
from sklearn import linear_model, metrics, model_selection
from sklearn.feature_extraction.text import TfidfVectorizer
def load_vectors(fname):
fin = io.open(
fname,
'r',
encoding='utf-8',
newline='\n',
errors='ignore'
)
n, d = map(int, fin.readline().split())
data = {}
for line in fin:
tokens = line.rstrip().split(' ')
data[tokens[0]] = list(map(float, tokens[1:]))
return data
def sentence_vector(s, embedding_dict, stopwords, tokenizer):
words = str(s).lower()
words = tokenizer(words)
words = [w for w in words if w not in stopwords]
words = [w for w in words if w.isalpha()]
W = []
for w in words:
if w in embedding_dict:
W.append(embedding_dict[w])
if len(W) == 0:
return np.zeros(300)
W = np.array(W)
v = W.sum(axis=0)
return v / np.sqrt((v ** 2).sum())
df = pd.read_csv('imdb/imdb.csv')
df.loc[:, 'sentiment'] = df.score.astype(int).apply(
lambda x: 1 if x >= 7 else 0
)
df['kfold'] = -1
df = df.sample(frac=1).reset_index(drop=True)
embeddings = load_vectors("wiki-news-300d-1M.vec")
vectors = []
for s in df.text.values:
vectors.append(sentence_vector(
s, embeddings, [], word_tokenize
))
vectors = np.array(vectors)
print(vectors.shape)
y = df.sentiment.values
kf = model_selection.StratifiedKFold(n_splits=5)
for f, (t_, v_) in enumerate(kf.split(X=vectors, y=y)):
train_x = vectors[t_, :]
train_y = y[t_]
valid_x = vectors[v_, :]
valid_y = y[v_]
model = linear_model.LogisticRegression()
model.fit(train_x, train_y)
preds = model.predict(valid_x)
accuracy = metrics.accuracy_score(valid_y, preds)
print(f'Fold: {f}, Accuracy: {accuracy}')
结果还不错。
说到文本数据时,我们必须注意一点,文本数据和时间序列数据很相似,都是具有先后顺序的序列。
这说明我们可以使用 Long Short Term Memory(LSTM)、Gated Recurrent Units(GRU)甚至 Convolutional Neural Networks(CNNs)来处理文本数据。
下边通过代码演示如何在该数据集上训练一个双向 LSTM 模型。
首先对数据集做交叉验证 fold 生成。
import pandas as np
from sklearn import model_selection
df = pd.read_csv('imdb/imdb.csv')
df.loc[:, 'sentiment'] = df.score.astype(int).apply(
lambda x: 1 if x >= 7 else 0
)
df['kfold'] = -1
df = df.sample(frac=1).reset_index(drop=True)
y = df.sentiment.values
kf = model_selection.StratifiedKFold(n_splits=5)
for f, (t_, v_) in enumerate(kf.split(X=df, y=y)):
df.loc[v_, 'kfold'] = f
df.to_csv('imdb/imdb_folds.csv', index=False)
然后创建 PyTorch 需要的 Dataset 类。
import torch
class IMDBDataset:
def __init__(self, texts, targets):
self.texts = texts
self.targets = targets
def __len__(self):
return len(self.targets)
def __getitem__(self, item):
return self.texts[item, :], self.targets[item]
然后构建双向 LSTM 模型。
import torch
import torch.nn as nn
class MyLSTM(nn.Module):
def __init__(self, embedding_matrix):
super().__init__()
num_words = embedding_matrix.shape[0]
num_dims = embedding_matrix.shape[1]
self.embedding = nn.Embedding(
num_embeddings=num_words,
embedding_dim=num_dims
)
self.embedding.weight = nn.Parameter(
torch.tensor(embedding_matrix, dtype=torch.float)
)
self.embedding.weight.requires_grad = False
self.lstm = nn.LSTM(
num_dims,
128,
bidirectional=True,
batch_first=True
)
self.out = nn.Linear(512, 1)
def forward(self, x):
x = self.embedding(x)
x, _ = self.lstm(x)
avg_pool = torch.mean(x, 1)
max_pool, _ = torch.max(x, 1)
out = torch.cat([avg_pool, max_pool], axis=1)
return self.out(out)
接着创建训练与计算方法。
import torch
import torch.nn as nn
def train(data_loader, model, optimizer, device):
model.train()
for texts, targets in data_loader:
texts = texts.to(device, dtype=torch.long)
targets = targets.to(device, dtype=torch.float)
optimizer.zero_grad()
preds = model(texts)
loss = nn.BCEWithLogitsLoss()(preds, targets.view(-1, 1))
loss.backward()
optimizer.step()
def evaluate(data_loader, model, device):
final_preds = []
final_targets = []
model.eval()
for texts, targets in data_loader:
texts = texts.to(device, dtype=torch.long)
preds = model(texts)
preds = preds.numpy().tolist()
final_preds.extend(preds)
final_targets.extend(targets)
return final_preds, final_targets
然后创建训练控制方法
import io, torch
import numpy as np
import pandas as pd
import tensorflow as tf
from sklearn import metrics
def load_vectors(fname):
fin = io.open(
fname,
'r',
encoding='utf-8',
newline='\n',
errors='ignore'
)
n, d = map(int, fin.readline().split())
data = {}
for line in fin:
tokens = line.rstrip().split(' ')
data[tokens[0]] = list(map(float, tokens[1:]))
return data
def create_embedding_matrix(word_index, embedding_dict):
embedding_matrix = np.zeros((len(word_index) + 1, 300))
for word, i in word_index.items():
if word in embedding_dict:
embedding_matrix[i] = embedding_dict[word]
return embedding_matrix
def run(df, fold):
train_df = df[df.kfold != fold].reset_index(drop=True)
valid_df = df[df.kfold == fold].reset_index(drop=True)
tokenizer = tf.keras.preprocessing.text.Tokenizer()
tokenizer.fit_on_texts(df.text.values.tolist())
train_x = tokenizer.texts_to_sequences(train_df.text.values)
valid_x = tokenizer.texts_to_sequences(valid_df.text.values)
train_x = tf.keras.preprocessing.sequence.pad_sequences(
train_x, 300
)
valid_x = tf.keras.preprocessing.sequence.pad_sequences(
valid_x, 300
)
train_dataset = IMDBDataset(train_x, train_df.sentiment.values)
train_loader = torch.utils.data.DataLoader(train_dataset, 16)
valid_dataset = IMDBDataset(valid_x, valid_df.sentiment.values)
valid_loader = torch.utils.data.DataLoader(valid_dataset, 16)
embedding_dict = load_vectors('wiki-news-300d-1M.vec')
embedding_matrix = create_embedding_matrix(
tokenizer.word_index,
embedding_dict
)
model = MyLSTM(embedding_matrix)
optimizer = torch.optim.Adam(model.parameters(), lr=5e-4)
best_accuracy = 0
early_stopping_counter = 0
for epoch in range(2):
train(train_loader, model, optimizer, 'cpu')
preds, targets = evaluate(valid_loader, model, 'cpu')
preds = (np.array(preds) > 0.5).astype(int)
accuracy = metrics.accuracy_score(targets, preds)
print(
f"FOLD:{fold}, Epoch: {epoch}, Accuracy Score = {accuracy}"
)
if accuracy > best_accuracy:
best_accuracy = accuracy
early_stopping_counter = 0
else:
early_stopping_counter += 1
if early_stopping_counterly_stopping_counter > 2:
break
df = pd.read_csv('imdb/imdb_folds.csv')
run(df, 0)
准确率可以达到 90%。代码中使用了提前终止技巧,可以在模型收敛时快速推出训练。
代码中使用了预训练 Embedding 参数和简单的双向 LSTM 网络结构,你还可以按需修改此模型。
在使用预训练 Embedding 时,要试着找出哪些单词不能被 Embedding。预训练 Embedding 中包含的单词越多,训练效果越好。
截止目前,我们已经对该分类任务创建了许多模型,然而这些模型都已经过时了,越来越多的人在使用基于 Transformer 的模型。
基于 Transformer 的网络能够处理自然语言中的长距离上下文信息,能够同时处理整个句子的所有单词,因此,该模型在 GPU 上心能更强。
Tranformer 是一个非常大的话题,包含许多模型:BERT、RoBERTa、XLNet、XLM-RoBERTa、T5 等。
下边我会给你展示使用这类模型等通用方法。需要注意的是,使用这类模型需要大量算力。没有足够的 GPU 算力的话,代码运行需要耗费很长时间。
首先,创建配置文件。
import transformers
MAX_LEN = 512
TRAIN_BATCH_SIZE = 8
VALID_BATCH_SIZE = 4
EPOCHS = 1
BERT_NAME = 'bert-base-cased'
TRAIN_FILE = 'imdb/imdb_folds.csv'
TOKENIZER = transformers.BertTokenizer.from_pretrained(
BERT_NAME,
return_tensors='pt'
)
接着创建 Dataset 类。
import torch
class BERTDataset:
def __init__(self, texts, targets):
self.texts = texts
self.targets = targets
self.tokenizer = TOKENIZER
self.max_len = MAX_LEN
def __len__(self):
return len(self.targets)
def __getitem__(self, item):
text = self.texts[item]
text = ' '.join(text.split())
inputs = self.tokenizer.encode_plus(
text,
None,
add_special_tokens=True,
max_length=self.max_len,
pad_to_max_length=True
)
# ids are ids of tokens generated
# after tokenizing reviews
ids = inputs["input_ids"]
# mask is 1 where we have input
# and 0 where we have padding
mask = inputs["attention_mask"]
# token type ids behave the same way as
# mask in this specific case
# in case of two sentences, this is 0
# for first sentence and 1 for second sentence
token_type_ids = inputs["token_type_ids"]
return (
torch.tensor(ids, dtype=torch.long),
torch.tensor(mask, dtype=torch.long),
torch.tensor(token_type_ids, dtype=torch.long),
torch.tensor(self.targets[item], dtype=torch.float)
)
然后是项目的核心部分:Model 类。
import transformers
import torch.nn as nn
class MyBERTBaseUncased(nn.Module):
def __init__(self):
super().__init__()
self.bert = transformers.BertModel.from_pretrained(BERT_NAME)
self.bert_drop = nn.Dropout(0.3)
self.out = nn.Linear(768, 1)
def forward(self, ids, mask, token_type_ids):
bert_outs = self.bert(
ids,
attention_mask=mask,
token_type_ids=token_type_ids
)
bo = self.bert_drop(bert_outs['pooler_output'])
return self.out(bo)
然后定义训练与预测方法。
import torch
import torch.nn as nn
def loss_fn(outputs, targets):
return nn.BCEWithLogitsLoss()(outputs, targets.view(-1, 1))
def train_fn(data_loader, model, optimizer, device, scheduler):
model.train()
for ids, mask, ttids, targets in data_loader:
ids = ids.to(device)
mask = mask.to(device)
ttids = ttids.to(device)
targets = targets.to(device)
optimizer.zero_grad()
preds = model(ids, mask, ttids)
loss = loss_fn(preds, targets)
loss.backward()
optimizer.step()
scheduler.step()
def eval_fn(data_loader, model, device):
model.eval()
fin_preds = []
fin_targets = []
for ids, mask, ttids, targets in data_loader:
ids = ids.to(device)
mask = mask.to(device)
ttids = ttids.to(device)
targets = targets.to(device)
preds = model(ids, mask, ttids, targets)
fin_preds.extend(torch.sigmoid(preds).detach().numpy().tolist())
fin_targets.extend(targets.detach().numpy().tolist())
return fin_preds, fin_targets
最后定义启动类。
import torch
import torch.nn as nn
import numpy as np
import pandas as pd
from sklearn import model_selection, metrics
from transformers import AdamW, get_linear_schedule_with_warmup
def train():
dfx = pd.read_csv(TRAIN_FILE).fillna('none')
df_train, df_valid = model_selection.train_test_split(
dfx,
test_size=0.1,
random_state=42,
stratify=dfx.sentiment.values
)
df_train = df_train.reset_index(drop=True)
df_valid = df_valid.reset_index(drop=True)
train_dataset = BERTDataset(
df_train.text.values, df_train.sentiment.values
)
train_loader = torch.utils.data.DataLoader(
train_dataset, TRAIN_BATCH_SIZE
)
valid_dataset = BERTDataset(
df_valid.text.values, df_valid.sentiment.values
)
valid_loader = torch.utils.data.DataLoader(
valid_dataset, VALID_BATCH_SIZE
)
model = MyBERTBaseUncased()
param_optimizer = list(model.named_parameters())
no_decay = ["bias", "LayerNorm.bias", "LayerNorm.weight"]
optimizer_parameters = [
{
"params": [
p for n, p in param_optimizer if
not any(nd in n for nd in no_decay)
],
"weight_decay": 0.001,
},
{
"params": [
p for n, p in param_optimizer if
any(nd in n for nd in no_decay)
],
"weight_decay": 0.0,
}
]
optimizer = AdamW(optimizer_parameters, lr=3e-5)
num_train_steps = int(len(df_train) / (TRAIN_BATCH_SIZE * EPOCHS))
scheduler = get_linear_schedule_with_warmup(
optimizer,
num_warmup_steps=0,
num_training_steps=num_train_steps
)
model = nn.DataParallel(model)
best_accuracy = 0
early_stopping_counter = 0
for i in range(EPOCHS):
train_fn(train_loader, model, optimizer, 'cpu', scheduler)
preds, targets = eval_fn(valid_loader, model, 'cpu')
preds = (np.array(preds) > 0.5).astype(int)
accuracy = metrics.accuracy_score(targets, preds)
print(f'Epoch: {i}, Accuracy: {accuracy}')
if accuracy > best_accuracy:
best_accuracy = accuracy
early_stopping_counter = 0
else:
early_stopping_counter += 1
if early_stopping_counterly_stopping_counter > 2:
break
train()
这里的代码一眼看上去太多了,似乎很复杂,其实都是标准模版化操作流程,即这些操作完全可以在不同的模型上使用。
该模型准确率高达 93%,比 Benchmark 好多了。
我们使用 LSTM 可以把准确率提升到 90%,且处理速度更快。
我们可以通过调整模型层数、节点数、dropout 参数、学习率等,来检查模型性能是否有提升,但这通常只会带来一个百分点左右的提升。而换用 BERT 模型可以带来两个百分点以上的提升,虽然训练与计算的耗时也提升了。
最后,你需要根据业务需要灵活选择模型, 不要觉得 BERT 很酷就直接用 BERT。
最后的最后,我们在本章里只讨论了文本分类问题,还有回归、多标签、多类型等问题,需要更复杂的处理逻辑。比如多类型的情况下,模型的输出里,单条数据会有多个维度的结果,需要按结构做对应处理。
自然语言处理是一个很大的领域,我们在此讨论的只有一小部分,当然,也是很重要的一部分,许多工业级模型都是分类或回归模型。