在30分钟内编写一个文档分类器

2021-08-05 10:12:30 浏览数 (1)

在我过去的一次采访中,我被要求实现一个模型来对论文摘要进行分类。我们的目标不是要有一个完美的模型,而是要看看我在最短时间内完成整个过程的能力。我就是这么做的。

数据

数据由PubMed数据库的论文摘要组成。PubMed是所有生物医学文献的资料库。管理PubMed的机构NCBI提供了下载论文的API。许多库已经存在,可以用几种语言与API交互。我使用了Python,找到的最简单的库是Bio及其用于这个特定数据库的模块Entrez。

我们导入模块,并配置email,这是必须的,这可以让他们跟踪每秒的请求数。

代码语言:javascript复制
from Bio import Entrez

Entrez.email = 'your@email.com'
Entrez.api_key = "abcdefghijklmnopqrstuvwxyz42"

为了从PubMed获取文章,我们首先执行一个查询,返回每个文档的元数据,比如它的ID,然后使用ID获取细节(在我的例子中是abstracts)。

代码语言:javascript复制
def search(query, max_documents=1000):

    handle = Entrez.esearch(db=’pubmed’,
                            sort=’relevance’,
                            retmax=max_documents,
                            retmode=’xml’,
                            term=query)
    results = Entrez.read(handle)

 return results

该函数将在PubMed数据库的参数中执行查询,按相关性对结果进行排序,并将结果数限制为max_documents。

查询实际上非常简单。可以使用文档关键字和逻辑运算符。PubMed文档详细解释了如何构建查询。

在面试中,我被要求获取4个主题的文件。我们通过在查询中指定每个类的相关关键字来实现这一点。

该函数的结果是一个文档详细信息列表,不包含其内容。然后我们使用这些id来获取文档的所有细节。

代码语言:javascript复制
def fetch_details(id_list):
    handle = Entrez.efetch(db=”pubmed”, id=’,’.join(map(str,                 id_list)),rettype=”xml”, retmode=”text”)

    records = Entrez.read(handle)
    abstracts = [pubmed_article[‘MedlineCitation’][‘Article’]   [‘Abstract’][‘AbstractText’][0] for pubmed_article in records[‘PubmedArticle’] if ‘Abstract’ in pubmed_article[‘MedlineCitation’][‘Article’].keys()]

 return abstracts

函数将获取ID列表并返回一个包含所有摘要的数组。获取特定类的所有摘要的完整函数是:

代码语言:javascript复制
def get_abstracts_for_class(ab_class):
    list_abstracts = []

    ## 获取类的关键字

    query = " AND ".join(keywords[ab_class])

    res = search(query)
    list_abstracts = fetch_details(res["IdList"])

    return list_abstracts

我将所有关键字保存在字典中,并使用它们构建查询。

我们为每个类调用函数,以获得所有类的所有摘要。最后,我们将它们重新格式化为一个可用的数据帧。

代码语言:javascript复制
list_all_classes = []

list_all_classes  = [{“abs”: a, “class”: 1} for a in list_abs_class1]
list_all_classes  = [{“abs”: a, “class”: 2} for a in list_abs_class2]
list_all_classes  = [{“abs”: a, “class”: 3} for a in list_abs_class3]
list_all_classes  = [{“abs”: a, “class”: 4} for a in list_abs_class4]

abs_df = pd.DataFrame(list_all_classes)
数据清理

同样,这里的目标不是完美地清理数据集,但是需要一个小的预处理。我个人大部分时间都在使用NLTK,但你可以对几乎所有的NLP库执行相同的操作。

代码语言:javascript复制
from nltk.tokenize import word_tokenize
from nltk.corpus import stopwords
import string

## 1) 小写化
abs_df[“abs”] = abs_df[“abs”].str.lower()

## 2) 移除标签
abs_df[“abs”] = abs_df.apply(lambda x: re.sub(“<[^>]*>”, “”, x[“abs”]), axis=1)

## 3) 标识化
abs_df[“abs_proc”] = abs_df.apply(lambda x: word_tokenize(x[“abs”]), axis=1)

## 4) 删除标点符号
nltk.download('punkt')
table = str.maketrans(‘’, ‘’, string.punctuation)
abs_df[“abs_proc”] = abs_df.apply(lambda x: [w.translate(table) for w in x[“abs_proc”]], axis=1)

## 5) 去除非α
abs_df[“abs_proc”] = abs_df.apply(lambda x: [w for w in x[“abs_proc”] if w.isalpha()], axis=1)

## 6) 删除停用词
nltk.download('stopwords')
stop_words = set(stopwords.words(‘english’))
abs_df[“abs_proc”] = abs_df.apply(lambda x: [w for w in x[“abs_proc”] if not w in stop_words], axis=1)

## 7) 重新格式化为一个文本
abs_df[“abs_proc_res”] = abs_df.apply(lambda x: ‘ ‘.join(x[“abs_proc”]), axis=1)

我们使用Pandas apply函数的强大功能,对整个数据帧应用相同的处理:

  1. 把所有的文字小写化
  2. 我发现文本中有一些标记,例如以指示粗体文本。即使这些标签可能有重要的意义,但这对于一个1h的练习来说太复杂了。所以我决定用正则表达式删除它们。
  3. 我们首先标记文本:即将其拆分为单个单词列表。
  4. 删除所有标点符号,如问号(?)或逗号(,)。
  5. 我们删除非字母,即数字。
  6. 我们删除停用词。我们首先使用NLTK检索英语停用词词汇表,然后使用它过滤我们的标记。
  7. 最后,我们将处理的数据连接起来。
数据嵌入

如果你熟悉NLP问题,那么你知道处理文本数据时最重要的部分可能是向量表示,即嵌入。在这方面已经取得了很多进展,一些强大的模型已经被提出,如谷歌的伯特或OpenAI的GPT。

然而,这些都是非常棘手的模型,而且绝对不适合1小时的锻炼。而且,对于许多实际问题,一个非常简单的嵌入就足以使数据具有正确的矢量表示。

最简单的可能是TF-IDF。

sklearn库已经有TF-IDF模块,可以直接用于数据帧。

代码语言:javascript复制
from sklearn.feature_extraction.text import TfidfVectorizer

vec = TfidfVectorizer()
x = vec.fit_transform(abs_df["abs_proc_res"])

此时,我们有一个矩阵X,它对应于我们所有的向量化抽象。然而,看看X的形状,我们注意到了:

代码语言:javascript复制
print(x.shape)

(25054, 60329)

我们最终会有大量的列(即60329)。这是正常的,因为这个数字对应于整个语料库(即整个数据集)的词汇表的大小。这个数字有两个问题。

首先,它将使模型的训练变得复杂化。

其次,即使我们做了大量的预处理,词汇的大部分词都不会被关联到分类中,因为它们没有添加任何相关信息。

幸运的是,有一种方法可以减少列的数量,同时避免丢失相关信息。最常见的方法是PCA(主成分分析),它将矩阵分解为一组低维的不相关矩阵。我们应用奇异值分解(SVD),它是一种PCA。同样,还有一个sklearn模块来轻松地完成。

代码语言:javascript复制
from sklearn.decomposition import TruncatedSVD

svd = TruncatedSVD(n_components=100)
res = svd.fit_transform(x)

print(res.shape)
(25054, 100)

我选择将初始矩阵减少到100个特征。这是一个优化的参数:我们越接近初始维度,在减少过程中松散的信息就越少,而少量的信息将降低模型训练的复杂性。

我们现在准备好训练分类器了。

模型

有很多分类模型在外面。支持向量机(SVM)是最简单的理解和实现方法之一。在nutshell中,它将尝试画一条线,尽可能多地将点与每个类分开。

我们还使用交叉验证来更好地表示度量。

代码语言:javascript复制
from sklearn import svm
from sklearn.model_selection import RepeatedKFold
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import cross_validate
from numpy import mean
from numpy import std

y = abs_df["class"].values
X = res

cv = RepeatedKFold(n_splits=10, n_repeats=3, random_state=1)

model = svm.SVC(kernel='linear', C=1, decision_function_shape='ovo')

我们使用线性核,即它将尝试画一条线来分隔数据。其他核也存在于多项式,它试图找到一个多项式函数,更好地分离点。

决策函数设置为ovo,即一对一,这将需要忽略其他类。

我们去训练吧!

代码语言:javascript复制
metrics = cross_validate(model, res, y, scoring=['precision_macro', 'recall_macro'], cv=cv, n_jobs=-1)


print('Precision: %.3f (%.3f)' % (mean(metrics["test_precision_macro"]), std(metrics["test_precision_macro"])))
print('Recall: %.3f (%.3f)' % (mean(metrics["test_recall_macro"]), -std(metrics["test_recall_macro"])))

-----------------------------------

Precision: 0.740 (0.021)
Recall: 0.637 (0.014)

这里有两个有趣的指标:精确性和召回率。

精度意味着,在预测的文档中,每类预测的正确率为74%,这一点并不差。

另一方面,召回意味着,在某一类的所有文件中,我们能够捕获63%。

结论与展望

如你所见,实现快速分类器相对容易,只需使用机器学习的基础知识。当然这不是完美的,但是当你什么都没有的时候,即使是坏的模型也是可以接受的。

显然,我们可以做很多改进。预处理可能是模型中影响最大的部分。例如,我们可以尝试更复杂的算法,比如BERT,而不是使用TF-IDF。在模型方面,我们还可以尝试其他分类器,甚至可以堆叠多个分类器以获得更好的性能。

也就是说,如果你的目标是拥有一个工作模型来对文档进行分类,那么这是一个很好的起点。

下一步就是把它投入生产!我将在另一篇文章中介绍这一部分。

0 人点赞