在我过去的一次采访中,我被要求实现一个模型来对论文摘要进行分类。我们的目标不是要有一个完美的模型,而是要看看我在最短时间内完成整个过程的能力。我就是这么做的。
数据
数据由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函数的强大功能,对整个数据帧应用相同的处理:
- 把所有的文字小写化
- 我发现文本中有一些标记,例如以指示粗体文本。即使这些标签可能有重要的意义,但这对于一个1h的练习来说太复杂了。所以我决定用正则表达式删除它们。
- 我们首先标记文本:即将其拆分为单个单词列表。
- 删除所有标点符号,如问号(?)或逗号(,)。
- 我们删除非字母,即数字。
- 我们删除停用词。我们首先使用NLTK检索英语停用词词汇表,然后使用它过滤我们的标记。
- 最后,我们将处理的数据连接起来。
数据嵌入
如果你熟悉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。在模型方面,我们还可以尝试其他分类器,甚至可以堆叠多个分类器以获得更好的性能。
也就是说,如果你的目标是拥有一个工作模型来对文档进行分类,那么这是一个很好的起点。
下一步就是把它投入生产!我将在另一篇文章中介绍这一部分。