AAAMLP-Chapter-10: Approaching Text Classification/Regression

92 阅读15分钟

文本问题也被称为自然语言处理问题 Natural Language Processing,NLP。

NLP 问题从某种程度上说,也可以当作图像处理,但又和图像不同。通常需要创建 pipline 式的处理方法,基于业务场景来构建模型。

顺便一提,在机器学习领域,构建模型是一个方面,如何提升模型性能并将其应用于实际业务,更需要你理解业务中的各个需求。

有许多不同类型的 NLP 任务,最常见的是文本分类任务。人们对与表格数据和图像数据的处理基本没有困惑,然而在面对文本数据时,经常不知从何上手。

在计算机中,所有的数据都是数字,因此文本数据本质上和其他类型数据没有区别。

让我们从基础的情感分类任务开始学习,我们尝试对影评文本做情感分类。

如果你有一段文本,和对应的情感标签,接下来如何处理呢?直接丢给深度神经网络,然后期待该问题能够自动处理好?这种方法行不通。

下边我们一步一步来学习文本数据相关特征,使用 IMDB 影评数据集。该数据集包含 25000 个正面情感影评和 25000 个负面情感影评。

数据下载地址:

Sentiment Analysis (stanford.edu)

接下来讨论的概念可以用于任何文本分类数据集。

该数据集很容易理解,一个影评对应一个目标值。这里使用影评而不是句子,一个影评可以包含多个句子。你肯定见过对单个句子做分类的情况,然而该任务需要你对一批句子做分类。即目标情感得分由这一批句子各自的情感得分组成。

那么如何上手呢?

先演示一个简单的例子,手动构建两个单词序列,一个包含你能想到的正面单词,比如 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。

词频指的是某单词在一个文档中的出现频率除以该文档的单词总数。最后计算该单词在所有文档里的词频的平均值。

逆文档频率指的是包含某单词的文档除以文档总数的倒数,再取对数。

TFIDF(t)=TF(t)IDF(t)TF-IDF(t)=TF(t)*IDF(t)

在代码中的用法和 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。

最后的最后,我们在本章里只讨论了文本分类问题,还有回归、多标签、多类型等问题,需要更复杂的处理逻辑。比如多类型的情况下,模型的输出里,单条数据会有多个维度的结果,需要按结构做对应处理。

自然语言处理是一个很大的领域,我们在此讨论的只有一小部分,当然,也是很重要的一部分,许多工业级模型都是分类或回归模型。