如何用Python处理天然语言?(Spacy与Word Embedding)

本文教你用简单易学的工业级Python天然语言处理软件包Spacy,对天然语言文本作词性分析、命名实体识别、依赖关系刻画,以及词嵌入向量的计算和可视化。html

盲维

我总爱重复一句芒格爱说的话:python

To the one with a hammer, everything looks like a nail. (手中有锤,看什么都像钉)git

这句话是什么意思呢?github

就是你不能只掌握数量不多的方法、工具。web

不然你的认知会被本身能力框住。不仅是存在盲点,而是存在“盲维”。小程序

你会尝试用不合适的方法解决问题(还自夸“一招鲜,吃遍天”),却对本来合适的工具视而不见。数组

结果可想而知。浏览器

因此,你得在本身的工具箱里面,多放一些兵刃。微信

最近我又对本身的学生,念叨芒格这句话。网络

由于他们开始作实际研究任务的时候,一遇到天然语言处理(Natural Language Processing, NLP),脑子里想到的就是词云情感分析LDA主题建模

为何?

由于个人专栏和公众号里,天然语言处理部分,只写过这些内容。

你若是认为,NLP只能作这些事,就大错特错了。

看看这段视频,你大概就能感觉到目前天然语言处理的前沿,已经到了哪里。

固然,你手头拥有的工具和数据,尚不能作出Google展现的黑科技效果。

可是,现有的工具,也足可让你对天然语言文本,作出更丰富的处理结果。

科技的发展,蓬勃迅速。

除了我们以前文章中已介绍过的结巴分词、SnowNLP和TextBlob,基于Python的天然语言处理工具还有不少,例如 NLTK 和 gensim 等。

我没法帮你一一熟悉,你可能用到的全部天然语言处理工具。

可是我们不妨开个头,介绍一款叫作 Spacy 的 Python 工具包。

剩下的,本身触类旁通。

工具

Spacy 的 Slogan,是这样的:

Industrial-Strength Natural Language Processing. (工业级别的天然语言处理)

这句话听上去,是否是有些狂妄啊?

不过人家仍是用数听说话的。

数据采自同行评议(Peer-reviewed)学术论文:

看完上述的数据分析,咱们大体对于Spacy的性能有些了解。

可是我选用它,不只仅是由于它“工业级别”的性能,更是由于它提供了便捷的用户调用接口,以及丰富、详细的文档。

仅举一例。

上图是Spacy上手教程的第一页。

能够看到,左侧有简明的树状导航条,中间是详细的文档,右侧是重点提示。

仅安装这一项,你就能够点击选择操做系统、Python包管理工具、Python版本、虚拟环境和语言支持等标签。网页会动态为你生成安装的语句。

这种设计,对新手用户,颇有帮助吧?

Spacy的功能有不少。

从最简单的词性分析,到高阶的神经网络模型,五花八门。

篇幅所限,本文只为你展现如下内容:

  • 词性分析
  • 命名实体识别
  • 依赖关系刻画
  • 词嵌入向量的近似度计算
  • 词语降维和可视化

学完这篇教程,你能够按图索骥,利用Spacy提供的详细文档,自学其余天然语言处理功能。

咱们开始吧。

环境

请点击这个连接(http://t.cn/R35fElv),直接进入我们的实验环境。

对,你没看错。

不须要在本地计算机安装任何软件包。只要有一个现代化浏览器(包括Google Chrome, Firefox, Safari和Microsoft Edge等)就能够了。所有的依赖软件,我都已经为你准备好了。

打开连接以后,你会看见这个页面。

不一样于以前的 Jupyter Notebook,这个界面来自 Jupyter Lab。

你能够将它理解为 Jupyter Notebook 的加强版,它具有如下特征:

  • 代码单元直接鼠标拖动;
  • 一个浏览器标签,可打开多个Notebook,并且分别使用不一样的Kernel;
  • 提供实时渲染的Markdown编辑器;
  • 完整的文件浏览器;
  • CSV数据文件快速浏览
  • ……

图中左侧分栏,是工做目录下的所有文件。

右侧打开的,是我们要使用的ipynb文件。

根据我们的讲解,请你逐条执行,观察结果。

咱们说一说样例文本数据的来源。

若是你以前读过个人其余天然语言处理方面的教程,应该记得这部电视剧。

对,就是"Yes, Minister"。

出于对这部80年代英国喜剧的喜好,我仍是用维基百科上"Yes, Minister"的介绍内容,做为文本分析样例。

下面,咱们就正式开始,一步步执行程序代码了。

我建议你先彻底按照教程跑一遍,运行出结果。

若是一切正常,再将其中的数据,替换为你本身感兴趣的内容

以后,尝试打开一个空白 ipynb 文件,根据教程和文档,本身敲代码,而且尝试作调整。

这样会有助于你理解工做流程和工具使用方法。

实践

咱们从维基百科页面的第一天然段中,摘取部分语句,放到text变量里面。

text = "The sequel, Yes, Prime Minister, ran from 1986 to 1988. In total there were 38 episodes, of which all but one lasted half an hour. Almost all episodes ended with a variation of the title of the series spoken as the answer to a question posed by the same character, Jim Hacker. Several episodes were adapted for BBC Radio, and a stage play was produced in 2010, the latter leading to a new television series on UKTV Gold in 2013."
复制代码

显示一下,看是否正确存储。

text
复制代码
'The sequel, Yes, Prime Minister, ran from 1986 to 1988. In total there were 38 episodes, of which all but one lasted half an hour. Almost all episodes ended with a variation of the title of the series spoken as the answer to a question posed by the same character, Jim Hacker. Several episodes were adapted for BBC Radio, and a stage play was produced in 2010, the latter leading to a new television series on UKTV Gold in 2013.'
复制代码

没问题了。

下面咱们读入Spacy软件包。

import spacy
复制代码

咱们让Spacy使用英语模型,将模型存储到变量nlp中。

nlp = spacy.load('en')
复制代码

下面,咱们用nlp模型分析我们的文本段落,将结果命名为doc。

doc = nlp(text)
复制代码

咱们看看doc的内容。

doc
复制代码
The sequel, Yes, Prime Minister, ran from 1986 to 1988. In total there were 38 episodes, of which all but one lasted half an hour. Almost all episodes ended with a variation of the title of the series spoken as the answer to a question posed by the same character, Jim Hacker. Several episodes were adapted for BBC Radio, and a stage play was produced in 2010, the latter leading to a new television series on UKTV Gold in 2013.
复制代码

好像跟刚才的text内容没有区别呀?不仍是这段文本吗?

别着急,Spacy只是为了让咱们看着舒服,因此只打印出来文本内容。

其实,它在后台,已经对这段话进行了许多层次的分析。

不信?

咱们来试试,让Spacy帮咱们分析这段话中出现的所有词例(token)。

for token in doc:
    print('"' + token.text + '"')
复制代码

你会看到,Spacy为咱们输出了一长串列表。

"The"
"sequel"
","
"Yes"
","
"Prime"
"Minister"
","
"ran"
"from"
"1986"
"to"
"1988"
"."
"In"
"total"
"there"
"were"
"38"
"episodes"
","
"of"
"which"
"all"
"but"
"one"
"lasted"
"half"
"an"
"hour"
"."
"Almost"
"all"
"episodes"
"ended"
"with"
"a"
"variation"
"of"
"the"
"title"
"of"
"the"
"series"
"spoken"
"as"
"the"
"answer"
"to"
"a"
"question"
"posed"
"by"
"the"
"same"
"character"
","
"Jim"
"Hacker"
"."
"Several"
"episodes"
"were"
"adapted"
"for"
"BBC"
"Radio"
","
"and"
"a"
"stage"
"play"
"was"
"produced"
"in"
"2010"
","
"the"
"latter"
"leading"
"to"
"a"
"new"
"television"
"series"
"on"
"UKTV"
"Gold"
"in"
"2013"
"."
复制代码

你可能不觉得然——这有什么了不得?

英语原本就是空格分割的嘛!我本身也能编个小程序,以空格分段,依次打印出这些内容来!

别忙,除了词例内容自己,Spacy还把每一个词例的一些属性信息,进行了处理。

下面,咱们只对前10个词例(token),输出如下内容:

  • 文本
  • 索引值(即在原文中的定位)
  • 词元(lemma)
  • 是否为标点符号
  • 是否为空格
  • 词性
  • 标记
for token in doc[:10]:
    print("{0}\t{1}\t{2}\t{3}\t{4}\t{5}\t{6}\t{7}".format(
        token.text,
        token.idx,
        token.lemma_,
        token.is_punct,
        token.is_space,
        token.shape_,
        token.pos_,
        token.tag_
    ))
复制代码

结果为:

The 0   the False   False   Xxx DET DT
sequel  4   sequel  False   False   xxxx    NOUN    NN
,   10  ,   True    False   ,   PUNCT   ,
Yes 12  yes False   False   Xxx INTJ    UH
,   15  ,   True    False   ,   PUNCT   ,
Prime   17  prime   False   False   Xxxxx   PROPN   NNP
Minister    23  minister    False   False   Xxxxx   PROPN   NNP
,   31  ,   True    False   ,   PUNCT   ,
ran 33  run False   False   xxx VERB    VBD
from    37  from    False   False   xxxx    ADP IN
复制代码

看到Spacy在后台默默为咱们作出的大量工做了吧?

下面咱们再也不考虑所有词性,只关注文本中出现的实体(entity)词汇。

for ent in doc.ents:
    print(ent.text, ent.label_)
复制代码
1986 to 1988 DATE
38 CARDINAL
one CARDINAL
half an hour TIME
Jim Hacker PERSON
BBC Radio ORG
2010 DATE
UKTV Gold ORG
2013 DATE
复制代码

在这一段文字中,出现的实体包括日期、时间、基数(Cardinal)……Spacy不只自动识别出了Jim Hacker为人名,还正确断定BBC Radio和UKTV Gold为机构名称。

若是你平时的工做,须要从海量评论里筛选潜在竞争产品或者竞争者,那看到这里,有没有一点儿灵感呢?

执行下面这段代码,看看会发生什么:

from spacy import displacy
displacy.render(doc, style='ent', jupyter=True)
复制代码

如上图所示,Spacy帮咱们把实体识别的结果,进行了直观的可视化。不一样类别的实体,还采用了不一样的颜色加以区分。

把一段文字拆解为语句,对Spacy而言,也是小菜一碟。

for sent in doc.sents:
    print(sent)
复制代码
The sequel, Yes, Prime Minister, ran from 1986 to 1988.
In total there were 38 episodes, of which all but one lasted half an hour.
Almost all episodes ended with a variation of the title of the series spoken as the answer to a question posed by the same character, Jim Hacker.
Several episodes were adapted for BBC Radio, and a stage play was produced in 2010, the latter leading to a new television series on UKTV Gold in 2013.
复制代码

注意这里,doc.sents并非个列表类型。

doc.sents
复制代码
<generator at 0x116e95e18>
复制代码

因此,假设咱们须要从中筛选出某一句话,须要先将其转化为列表。

list(doc.sents)
复制代码
[The sequel, Yes, Prime Minister, ran from 1986 to 1988.,
 In total there were 38 episodes, of which all but one lasted half an hour.,
 Almost all episodes ended with a variation of the title of the series spoken as the answer to a question posed by the same character, Jim Hacker.,
 Several episodes were adapted for BBC Radio, and a stage play was produced in 2010, the latter leading to a new television series on UKTV Gold in 2013.]
复制代码

下面要展现的功能,分析范围局限在第一句话。

咱们将其抽取出来,而且从新用nlp模型处理,存入到新的变量newdoc中。

newdoc = nlp(list(doc.sents)[0].text)
复制代码

对这一句话,咱们想要搞清其中每个词例(token)之间的依赖关系。

for token in newdoc:
    print("{0}/{1} <--{2}-- {3}/{4}".format(
        token.text, token.tag_, token.dep_, token.head.text, token.head.tag_))
复制代码
The/DT <--det-- sequel/NN
sequel/NN <--nsubj-- ran/VBD
,/, <--punct-- sequel/NN
Yes/UH <--intj-- sequel/NN
,/, <--punct-- sequel/NN
Prime/NNP <--compound-- Minister/NNP
Minister/NNP <--appos-- sequel/NN
,/, <--punct-- sequel/NN
ran/VBD <--ROOT-- ran/VBD
from/IN <--prep-- ran/VBD
1986/CD <--pobj-- from/IN
to/IN <--prep-- from/IN
1988/CD <--pobj-- to/IN
./. <--punct-- ran/VBD
复制代码

很清晰,可是列表的方式,彷佛不大直观。

那就让Spacy帮咱们可视化吧。

displacy.render(newdoc, style='dep', jupyter=True, options={'distance': 90})
复制代码

结果以下:

这些依赖关系连接上的词汇,都表明什么?

若是你对语言学比较了解,应该能看懂。

不懂?查查字典嘛。

跟语法书对比一下,看看Spacy分析得是否准确。

前面咱们分析的,属于语法层级。

下面咱们看语义。

咱们利用的工具,叫作词嵌入(word embedding)模型。

以前的文章《如何用Python从海量文本抽取主题?》中,咱们提到过如何把文字表达成电脑能够看懂的数据。

文中处理的每个单词,都仅仅对应着词典里面的一个编号而已。你能够把它当作你去营业厅办理业务时领取的号码。

它只提供了先来后到的顺序信息,跟你的职业、学历、性别通通没有关系。

咱们将这样过于简化的信息输入,计算机对于词义的了解,也必然少得可怜。

例如给你下面这个式子:

? - woman = king - queen
复制代码

只要你学过英语,就不难猜到这里大几率应该填写“man”。

可是,若是你只是用了随机的序号来表明词汇,又如何可以猜到这里正确的填词结果呢?

幸亏,在深度学习领域,咱们可使用更为顺手的单词向量化工具——词嵌入(word embeddings )。

如上图这个简化示例,词嵌入把单词变成多维空间上面的向量。

这样,词语就再也不是冷冰冰的字典编号,而是具备了意义。

使用词嵌入模型,咱们须要Spacy读取一个新的文件。

nlp = spacy.load('en_core_web_lg')
复制代码

为测试读取结果,咱们让Spacy打印“minister”这个单词对应的向量取值。

print(nlp.vocab['minister'].vector)
复制代码

能够看到,每一个单词,用总长度为300的浮点数组成向量来表示。

顺便说一句,Spacy读入的这个模型,是采用word2vec,在海量语料上训练的结果。

咱们来看看,此时Spacy的语义近似度判别能力。

这里,咱们将4个变量,赋值为对应单词的向量表达结果。

dog = nlp.vocab["dog"]
cat = nlp.vocab["cat"]
apple = nlp.vocab["apple"]
orange = nlp.vocab["orange"]
复制代码

咱们看看“狗”和“猫”的近似度:

dog.similarity(cat)
复制代码
0.80168545
复制代码

嗯,都是宠物,近似度高,能够接受。

下面看看“狗”和“苹果”。

dog.similarity(apple)
复制代码
0.26339027
复制代码

一个动物,一个水果,近似度一会儿就跌落下来了。

“狗”和“橘子”呢?

dog.similarity(orange)
复制代码
0.2742508
复制代码

可见,类似度也不高。

那么“苹果”和“橘子”之间呢?

apple.similarity(orange)
复制代码
0.5618917
复制代码

水果间近似度,远远超过水果与动物的类似程度。

测试经过。

看来Spacy利用词嵌入模型,对语义有了必定的理解。

下面为了好玩,咱们来考考它。

这里,咱们须要计算词典中可能不存在的向量,所以Spacy自带的similarity()函数,就显得不够用了。

咱们从scipy中,找到类似度计算须要用到的余弦函数。

from scipy.spatial.distance import cosine
复制代码

对比一下,咱们直接代入“狗”和“猫”的向量,进行计算。

1 - cosine(dog.vector, cat.vector)
复制代码
0.8016855120658875
复制代码

除了保留数字外,计算结果与Spacy自带的similarity()运行结果没有差异。

咱们把它作成一个小函数,专门处理向量输入。

def vector_similarity(x, y):
    return 1 - cosine(x, y)
复制代码

用咱们自编的类似度函数,测试一下“狗”和“苹果”。

vector_similarity(dog.vector, apple.vector)
复制代码
0.2633902430534363
复制代码

与刚才的结果对比,也是一致的。

咱们要表达的,是这个式子:

? - woman = king - queen
复制代码

咱们把问号,称为 guess_word

因此

guess_word = king - queen + woman
复制代码

咱们把右侧三个单词,通常化记为 words。编写下面函数,计算guess_word取值。

def make_guess_word(words):
    [first, second, third] = words
    return nlp.vocab[first].vector - nlp.vocab[second].vector + nlp.vocab[third].vector
复制代码

下面的函数就比较暴力了,它实际上是用咱们计算的 guess_word 取值,和字典中所有词语一一核对近似性。把最为近似的10个候选单词打印出来。

def get_similar_word(words, scope=nlp.vocab):

    guess_word = make_guess_word(words)

    similarities = []

    for word in scope:
        if not word.has_vector:
            continue

        similarity = vector_similarity(guess_word, word.vector)
        similarities.append((word, similarity))


    similarities = sorted(similarities, key=lambda item: -item[1])
    print([word[0].text for word in similarities[:10]])
复制代码

好了,游戏时间开始。

咱们先看看:

? - woman = king - queen
复制代码

即:

guess_word = king - queen + woman
复制代码

输入右侧词序列:

words = ["king", "queen", "woman"]
复制代码

而后执行对比函数:

get_similar_word(words)
复制代码

这个函数运行起来,须要一段时间。请保持耐心。

运行结束以后,你会看到以下结果:

['MAN', 'Man', 'mAn', 'MAn', 'MaN', 'man', 'mAN', 'WOMAN', 'womAn', 'WOman']
复制代码

原来字典里面,“男人”(man)这个词汇有这么多的变形啊。

可是这个例子太经典了,咱们尝试个新鲜一些的:

? - England = Paris - London
复制代码

即:

guess_word = Paris - London + England
复制代码

对你来说,绝对是简单的题目。左侧国别,右侧首都,对应来看,天然是巴黎所在的法国(France)。

问题是,Spacy能猜对吗?

咱们把这几个单词输入。

words = ["Paris", "London", "England"]
复制代码

让Spacy来猜:

get_similar_word(words)
复制代码
['france', 'FRANCE', 'France', 'Paris', 'paris', 'PARIS', 'EUROPE', 'EUrope', 'europe', 'Europe']
复制代码

结果很使人振奋,前三个都是“法国”(France)。

下面咱们作一个更有趣的事儿,把词向量的300维的高空间维度,压缩到一张纸(二维)上,看看词语之间的相对位置关系。

首先咱们须要读入numpy软件包。

import numpy as np
复制代码

咱们把词嵌入矩阵先设定为空。一下子慢慢填入。

embedding = np.array([])
复制代码

须要演示的单词列表,也先空着。

word_list = []
复制代码

咱们再次让Spacy遍历“Yes, Minister”维基页面中摘取的那段文字,加入到单词列表中。注意此次咱们要进行判断:

  • 若是是标点,丢弃;
  • 若是词汇已经在词语列表中,丢弃。
for token in doc:
    if not(token.is_punct) and not(token.text in word_list):
        word_list.append(token.text)
复制代码

看看生成的结果:

word_list
复制代码
['The',
 'sequel',
 'Yes',
 'Prime',
 'Minister',
 'ran',
 'from',
 '1986',
 'to',
 '1988',
 'In',
 'total',
 'there',
 'were',
 '38',
 'episodes',
 'of',
 'which',
 'all',
 'but',
 'one',
 'lasted',
 'half',
 'an',
 'hour',
 'Almost',
 'ended',
 'with',
 'a',
 'variation',
 'the',
 'title',
 'series',
 'spoken',
 'as',
 'answer',
 'question',
 'posed',
 'by',
 'same',
 'character',
 'Jim',
 'Hacker',
 'Several',
 'adapted',
 'for',
 'BBC',
 'Radio',
 'and',
 'stage',
 'play',
 'was',
 'produced',
 'in',
 '2010',
 'latter',
 'leading',
 'new',
 'television',
 'on',
 'UKTV',
 'Gold',
 '2013']
复制代码

检查了一下,一长串(63个)词语列表中,没有出现标点。一切正常。

下面,咱们把每一个词汇对应的空间向量,追加到词嵌入矩阵中。

for word in word_list:
    embedding = np.append(embedding, nlp.vocab[word].vector)
复制代码

看看此时词嵌入矩阵的维度。

embedding.shape
复制代码
(18900,)
复制代码

能够看到,全部的向量内容,都被放在了一个长串上面。这显然不符合咱们的要求,咱们将不一样的单词对应的词向量,拆解到不一样行上面去。

embedding = embedding.reshape(len(word_list), -1)
复制代码

再看看变换后词嵌入矩阵的维度。

embedding.shape
复制代码
(63, 300)
复制代码

63个词汇,每一个长度300,这就对了。

下面咱们从scikit-learn软件包中,读入TSNE模块。

from sklearn.manifold import TSNE
复制代码

咱们创建一个同名小写的tsne,做为调用对象。

tsne = TSNE()
复制代码

tsne的做用,是把高维度的词向量(300维)压缩到二维平面上。咱们执行这个转换过程:

low_dim_embedding = tsne.fit_transform(embedding)
复制代码

如今,咱们手里拥有的 low_dim_embedding ,就是63个词汇下降到二维的向量表示了。

咱们读入绘图工具包。

import matplotlib.pyplot as plt
%pylab inline
复制代码

下面这个函数,用来把二维向量的集合,绘制出来。

若是你对该函数内容细节不理解,不要紧。由于我尚未给你系统介绍过Python下的绘图功能。

好在这里咱们只要会调用它,就能够了。

def plot_with_labels(low_dim_embs, labels, filename='tsne.pdf'):
    assert low_dim_embs.shape[0] >= len(labels), "More labels than embeddings"
    plt.figure(figsize=(18, 18))  # in inches
    for i, label in enumerate(labels):
        x, y = low_dim_embs[i, :]
        plt.scatter(x, y)
        plt.annotate(label,
                 xy=(x, y),
                 xytext=(5, 2),
                 textcoords='offset points',
                 ha='right',
                 va='bottom')
    plt.savefig(filename)
复制代码

终于能够进行降维后的词向量可视化了。

请执行下面这条语句:

plot_with_labels(low_dim_embedding, word_list)
复制代码

你会看到这样一个图形。

请注意观察图中的几个部分:

  • 年份
  • 同一单词的大小写形式
  • Radio 和 television
  • a 和 an

看看有什么规律没有?

我发现了一个有意思的现象——每次运行tsne,产生的二维可视化图都不同!

不过这也正常,由于这段话之中出现的单词,并不是都有预先训练好的向量。

这样的单词,被Spacy进行了随机化等处理。

所以,每一次生成高维向量,结果都不一样。不一样的高维向量,压缩到二维,结果天然也会有区别。

问题来了,若是我但愿每次运行的结果都一致,该如何处理呢?

这个问题,做为课后思考题,留给你自行解答。

细心的你可能发现了,执行完最后一条语句后,页面左侧边栏文件列表中,出现了一个新的pdf文件。

这个pdf,就是你刚刚生成的可视化结果。你能够双击该文件名称,在新的标签页中查看。

看,就连pdf文件,Jupyter Lab也能正确显示。

下面,是练习时间。

请把ipynb出现的文本内容,替换为你感兴趣的段落和词汇,再尝试运行一次吧。

源码

执行了所有代码,而且尝试替换了本身须要分析的文本,成功运行后,你是否是颇有成就感?

你可能想要更进一步挖掘Spacy的功能,而且但愿在本地复现运行环境与结果。

没问题,请使用这个连接t.cn/R35MIKh)下载本文用到的所有源代码和运行环境配置文件(Pipenv)压缩包。

若是你知道如何使用github,也欢迎用这个连接t.cn/R35MEqk)访问对应的github repo,进行clone或者fork等操做。

固然,要是能给个人repo加一颗星,就更好了。

谢谢!

小结

本文利用Python天然语言处理工具包Spacy,很是简要地为你演示了如下NLP功能:

  • 词性分析
  • 命名实体识别
  • 依赖关系刻画
  • 词嵌入向量的近似度计算
  • 词语降维和可视化

但愿学过以后,你成功地在工具箱里又添加了一件趁手的兵器。

愿它在之后的研究和工做中,助你披荆斩棘,马到成功。

加油!

讨论

你以前作过天然语言处理项目吗?使用过哪些工具包?除了本文介绍的这些基本功能外,你以为还有哪些NLP功能是很是基础而重要的?你是如何学习它们的呢?欢迎留言,把你的经验和思考分享给你们,咱们一块儿交流讨论。

喜欢请点赞。还能够微信关注和置顶个人公众号“玉树芝兰”(nkwangshuyi)

若是你对数据科学感兴趣,不妨阅读个人系列教程索引贴《如何高效入门数据科学?》,里面还有更多的有趣问题及解法。

相关文章
相关标签/搜索