starter_code.ipynb 69.1 KB

搭建一个简单的问答系统 (Building a Simple QA System)

本次项目的目标是搭建一个基于检索式的简易的问答系统,这是一个最经典的方法也是最有效的方法。

不要单独创建一个文件,所有的都在这里面编写,不要试图改已经有的函数名字 (但可以根据需求自己定义新的函数)

预估完成时间: 5-10小时

检索式的问答系统

问答系统所需要的数据已经提供,对于每一个问题都可以找得到相应的答案,所以可以理解为每一个样本数据是 <问题、答案>。 那系统的核心是当用户输入一个问题的时候,首先要找到跟这个问题最相近的已经存储在库里的问题,然后直接返回相应的答案即可(但实际上也可以抽取其中的实体或者关键词)。 举一个简单的例子:

假设我们的库里面已有存在以下几个<问题,答案>:

  • <"贪心学院主要做什么方面的业务?”, “他们主要做人工智能方面的教育”>
  • <“国内有哪些做人工智能教育的公司?”, “贪心学院”>
  • <"人工智能和机器学习的关系什么?", "其实机器学习是人工智能的一个范畴,很多人工智能的应用要基于机器学习的技术">
  • <"人工智能最核心的语言是什么?", ”Python“>
  • .....

假设一个用户往系统中输入了问题 “贪心学院是做什么的?”, 那这时候系统先去匹配最相近的“已经存在库里的”问题。 那在这里很显然是 “贪心学院是做什么的”和“贪心学院主要做什么方面的业务?”是最相近的。 所以当我们定位到这个问题之后,直接返回它的答案 “他们主要做人工智能方面的教育”就可以了。 所以这里的核心问题可以归结为计算两个问句(query)之间的相似度。

项目中涉及到的任务描述

问答系统看似简单,但其中涉及到的内容比较多。 在这里先做一个简单的解释,总体来讲,我们即将要搭建的模块包括:

  • 文本的读取: 需要从相应的文件里读取(问题,答案)
  • 文本预处理: 清洗文本很重要,需要涉及到停用词过滤等工作
  • 文本的表示: 如果表示一个句子是非常核心的问题,这里会涉及到tf-idf, Glove以及BERT Embedding
  • 文本相似度匹配: 在基于检索式系统中一个核心的部分是计算文本之间的相似度,从而选择相似度最高的问题然后返回这些问题的答案
  • 倒排表: 为了加速搜索速度,我们需要设计倒排表来存储每一个词与出现的文本
  • 词义匹配:直接使用倒排表会忽略到一些意思上相近但不完全一样的单词,我们需要做这部分的处理。我们需要提前构建好相似的单词然后搜索阶段使用
  • 拼写纠错:我们不能保证用户输入的准确,所以第一步需要做用户输入检查,如果发现用户拼错了,我们需要及时在后台改正,然后按照修改后的在库里面搜索
  • 文档的排序: 最后返回结果的排序根据文档之间余弦相似度有关,同时也跟倒排表中匹配的单词有关

项目中需要的数据:

  1. dev-v2.0.json: 这个数据包含了问题和答案的pair, 但是以JSON格式存在,需要编写parser来提取出里面的问题和答案。
  2. glove.6B: 这个文件需要从网上下载,下载地址为:https://nlp.stanford.edu/projects/glove/, 请使用d=200的词向量
  3. spell-errors.txt 这个文件主要用来编写拼写纠错模块。 文件中第一列为正确的单词,之后列出来的单词都是常见的错误写法。 但这里需要注意的一点是我们没有给出他们之间的概率,也就是p(错误|正确),所以我们可以认为每一种类型的错误都是同等概率
  4. vocab.txt 这里列了几万个英文常见的单词,可以用这个词库来验证是否有些单词被拼错
  5. testdata.txt 这里搜集了一些测试数据,可以用来测试自己的spell corrector。这个文件只是用来测试自己的程序。

在本次项目中,你将会用到以下几个工具:

第一部分:对于训练数据的处理:读取文件和预处理

  • 文本的读取: 需要从文本中读取数据,此处需要读取的文件是dev-v2.0.json,并把读取的文件存入一个列表里(list)
  • 文本预处理: 对于问题本身需要做一些停用词过滤等文本方面的处理
  • 可视化分析: 对于给定的样本数据,做一些可视化分析来更好地理解数据

1.1节: 文本的读取

把给定的文本数据读入到qlistalist当中,这两个分别是列表,其中qlist是问题的列表,alist是对应的答案列表

In [20]:
import json
def read_corpus():
    """
    读取给定的语料库,并把问题列表和答案列表分别写入到 qlist, alist 里面。 在此过程中,不用对字符换做任何的处理(这部分需要在 Part 2.3里处理)
    qlist = ["问题1", “问题2”, “问题3” ....]
    alist = ["答案1", "答案2", "答案3" ....]
    务必要让每一个问题和答案对应起来(下标位置一致)
    """
    # TODO 需要完成的代码部分 ...
    
    f_path = "train-v2.0.json"
    with open(f_path,'r',encoding='utf-8') as f:
        data = json.load(f)
    data = data["data"]
    qlist = []
    alist = []
    for d in data:
        print(d["title"])
        for x in d["paragraphs"]:
            for qa in x["qas"]:
                answer_key = "answers"
                if qa["is_impossible"]:
                    answer_key = "plausible_answers"
                qlist.append(qa["question"])
                alist.append(qa[answer_key][0]["text"])

    
    assert len(qlist) == len(alist)  # 确保长度一样
    return qlist, alist

qlist,alist = read_corpus()
Out [20]:
Beyoncé
Frédéric_Chopin
Sino-Tibetan_relations_during_the_Ming_dynasty
IPod
The_Legend_of_Zelda:_Twilight_Princess
Spectre_(2015_film)
2008_Sichuan_earthquake
New_York_City
To_Kill_a_Mockingbird
Solar_energy
Kanye_West
Buddhism
American_Idol
Dog
2008_Summer_Olympics_torch_relay
Genome
Comprehensive_school
Republic_of_the_Congo
Prime_minister
Institute_of_technology
Wayback_Machine
Dutch_Republic
Symbiosis
Canadian_Armed_Forces
Cardinal_(Catholicism)
Iranian_languages
Lighting
Separation_of_powers_under_the_United_States_Constitution
Architecture
Human_Development_Index
Southern_Europe
BBC_Television
Arnold_Schwarzenegger
Plymouth
Heresy
Warsaw_Pact
Materialism
Christian
Sony_Music_Entertainment
Oklahoma_City
Hunter-gatherer
United_Nations_Population_Fund
Russian_Soviet_Federative_Socialist_Republic
Alexander_Graham_Bell
Pub
Internet_service_provider
Comics
Saint_Helena
Aspirated_consonant
Hydrogen
Space_Race
Web_browser
BeiDou_Navigation_Satellite_System
Canon_law
Communications_in_Somalia
Catalan_language
Boston
Universal_Studios
Estonian_language
Paper
Adult_contemporary_music
Daylight_saving_time
Royal_Institute_of_British_Architects
National_Archives_and_Records_Administration
Tristan_da_Cunha
University_of_Kansas
Nanjing
Arena_Football_League
Dialect
Bern
Westminster_Abbey
Political_corruption
Classical_music
Slavs
Southampton
Treaty
Josip_Broz_Tito
Marshall_Islands
Szlachta
Virgil
Alps
Gene
Guinea-Bissau
List_of_numbered_streets_in_Manhattan
Brain
Near_East
Zhejiang
Ministry_of_Defence_(United_Kingdom)
High-definition_television
Wood
Somalis
Middle_Ages
Phonology
Computer
Black_people
The_Times
New_Delhi
Bird_migration
Atlantic_City,_New_Jersey
Immunology
MP3
House_music
Letter_case
Chihuahua_(state)
Imamah_(Shia_doctrine)
Pitch_(music)
England_national_football_team
Houston
Copper
Identity_(social_science)
Himachal_Pradesh
Communication
Grape
Computer_security
Orthodox_Judaism
Animal
Beer
Race_and_ethnicity_in_the_United_States_Census
United_States_dollar
Imperial_College_London
Hanover
Emotion
Everton_F.C.
Old_English
Aircraft_carrier
Federal_Aviation_Administration
Lancashire
Mesozoic
Videoconferencing
Gregorian_calendar
Xbox_360
Military_history_of_the_United_States
Hard_rock
Great_Plains
Infrared
Biodiversity
ASCII
Digestion
Gymnastics
FC_Barcelona
Federal_Bureau_of_Investigation
Mary_(mother_of_Jesus)
Melbourne
John,_King_of_England
Macintosh
Anti-aircraft_warfare
Sanskrit
Valencia
General_Electric
United_States_Army
Franco-Prussian_War
Adolescence
Antarctica
Eritrea
Uranium
Order_of_the_British_Empire
Circadian_rhythm
Elizabeth_II
Sexual_orientation
Dell
Capital_punishment_in_the_United_States
Age_of_Enlightenment
Nintendo_Entertainment_System
Athanasius_of_Alexandria
Seattle
Memory
Multiracial_American
Ashkenazi_Jews
Pharmaceutical_industry
Umayyad_Caliphate
Asphalt
Queen_Victoria
Freemasonry
Israel
Hellenistic_period
Bill_%26_Melinda_Gates_Foundation
Montevideo
Poultry
Dutch_language
Buckingham_Palace
Incandescent_light_bulb
Arsenal_F.C.
Clothing
Chicago_Cubs
Korean_War
Copyright_infringement
Greece
Royal_Dutch_Shell
Mammal
East_India_Company
Hokkien
Professional_wrestling
Film_speed
Mexico_City
Napoleon
Germans
Southeast_Asia
Brigham_Young_University
Department_store
Intellectual_property
Florida
Queen_(band)
Presbyterianism
Thuringia
Predation
Marvel_Comics
British_Empire
Botany
Madonna_(entertainer)
Law_of_the_United_States
Myanmar
Jews
Cotton
Data_compression
The_Sun_(United_Kingdom)
Pesticide
Somerset
Yale_University
Late_Middle_Ages
Ann_Arbor,_Michigan
Gothic_architecture
Cubism
Political_philosophy
Alloy
Norfolk_Island
Edmund_Burke
Samoa
Pope_Paul_VI
Electric_motor
Switzerland
Mali
Raleigh,_North_Carolina
Nutrition
Crimean_War
Nonprofit_organization
Literature
Avicenna
Chinese_characters
Bermuda
Nigeria
Utrecht
Molotov%E2%80%93Ribbentrop_Pact
Capacitor
History_of_science
Digimon
Glacier
Comcast
Tuberculosis
Affirmative_action_in_the_United_States
FA_Cup
New_Haven,_Connecticut
Alsace
Carnival
Baptists
Child_labour
North_Carolina
Heian_period
On_the_Origin_of_Species
Dissolution_of_the_Soviet_Union
Crucifixion_of_Jesus
Supreme_court
Textual_criticism
Gramophone_record
Turner_Classic_Movies
Hindu_philosophy
Political_party
A_cappella
Dominican_Order
Eton_College
Cork_(city)
Galicia_(Spain)
USB
Sichuan
Unicode
Detroit
London
Culture
Sahara
Rule_of_law
Tibet
Exhibition_game
Northwestern_University
Strasbourg
Oklahoma
History_of_India
Gamal_Abdel_Nasser
Pope_John_XXIII
Time
European_Central_Bank
St._John%27s,_Newfoundland_and_Labrador
John_von_Neumann
PlayStation_3
Royal_assent
Group_(mathematics)
Central_African_Republic
Asthma
LaserDisc
George_VI
Federalism
Annelid
God
War_on_Terror
Labour_Party_(UK)
Estonia
Alaska
Karl_Popper
Mandolin
Insect
Race_(human_categorization)
Paris
Apollo
United_States_presidential_election,_2004
Liberal_Party_of_Australia
Samurai
Software_testing
States_of_Germany
Glass
Planck_constant
Renewable_energy_commercialization
Palermo
Green
Zinc
Neoclassical_architecture
Serbo-Croatian
CBC_Television
Appalachian_Mountains
IBM
Energy
East_Prussia
Ottoman_Empire
Philosophy_of_space_and_time
Neolithic
Friedrich_Hayek
Diarrhea
Madrasa
Miami
Philadelphia
John_Kerry
Rajasthan
Guam
Empiricism
Idealism
Czech_language
Education
Tennessee
Post-punk
Canadian_football
Seven_Years%27_War
Richard_Feynman
Muammar_Gaddafi
Cyprus
Steven_Spielberg
Elevator
Neptune
Railway_electrification_system
Spanish_language_in_the_United_States
Charleston,_South_Carolina
The_Blitz
Endangered_Species_Act
Vacuum
Han_dynasty
Quran
Geography_of_the_United_States
Compact_disc
Transistor
Modern_history
51st_state
Antenna_(radio)
Flowering_plant
Hyderabad
Santa_Monica,_California
Washington_University_in_St._Louis
Central_Intelligence_Agency
Pain
Database
Tucson,_Arizona
Armenia
Bacteria
Printed_circuit_board
Greeks
Premier_League
Roman_Republic
Pacific_War
San_Diego
Muslim_world
Iran
British_Isles
Association_football
Georgian_architecture
Liberia
Alfred_North_Whitehead
Antibiotics
Windows_8
Swaziland
Translation
Airport
Kievan_Rus%27
Super_Nintendo_Entertainment_System
Sumer
Tuvalu
Immaculate_Conception
Namibia
Russian_language
United_States_Air_Force
Light-emitting_diode
Great_power
Bird
Qing_dynasty
Indigenous_peoples_of_the_Americas
Red
Egypt
Mosaic
University
Religion_in_ancient_Rome
YouTube
Separation_of_church_and_state_in_the_United_States
Protestantism
Bras%C3%ADlia
Economy_of_Greece
Party_leaders_of_the_United_States_House_of_Representatives
Armenians
Jehovah%27s_Witnesses
Dwight_D._Eisenhower
The_Bronx
Financial_crisis_of_2007%E2%80%9308
Portugal
Humanism
Geological_history_of_Earth
Police
Genocide
Saint_Barth%C3%A9lemy
Tajikistan
University_of_Notre_Dame
Anthropology
Montana
Punjab,_Pakistan
Richmond,_Virginia
Infection
Hunting
Kathmandu
Myocardial_infarction
Matter

1.2 理解数据(可视化分析/统计信息)

对数据的理解是任何AI工作的第一步, 需要对数据有个比较直观的认识。在这里,简单地统计一下:

  • qlist出现的总单词个数
  • 按照词频画一个histogram plot
In [3]:
# TODO: 统计一下在qlist中总共出现了多少个单词? 总共出现了多少个不同的单词(unique word)?
#       这里需要做简单的分词,对于英文我们根据空格来分词即可,其他过滤暂不考虑(只需分词)

def statistics(l):
    result = {}
    for sentence in l:
        words = sentence.split(" ")
        for word in words:
            if word in result:
                result[word] += 1
            else:
                result[word] = 1
    sorted_items = sorted(result.items(),key=lambda x: -x[1])
    return sorted_items

sorted_items = statistics(qlist)
word_total = len(sorted_items)
print (word_total)
Out [3]:
76475
In [16]:
# TODO: 统计一下qlist中出现1次,2次,3次... 出现的单词个数, 然后画一个plot. 这里的x轴是单词出现的次数(1,2,3,..), y轴是单词个数。
#       从左到右分别是 出现1次的单词数,出现2次的单词数,出现3次的单词数... 
import matplotlib.pyplot as plt
import numpy as np
max_freq = sorted_items[0][1]
x = { i+1:0 for i in range(max_freq)}
for items in sorted_items:
    x[items[1]] += 1

print(list(x.items())[:5])
print(list(x.values())[:5])
print(len(x))
print(len(list(x.values())))
limited = 100
plt.hist(x=np.array(list(x.values())[:limited]), bins=[i+1 for i in range(limited)], color='#0504aa')
plt.xlabel('Value')
plt.ylabel('Frequency')
plt.show()
Out [16]:
[(1, 35949), (2, 13166), (3, 5803), (4, 3747), (5, 2349)]
[35949, 13166, 5803, 3747, 2349]
88365
88365
# TODO: 从上面的图中能观察到什么样的现象? 这样的一个图的形状跟一个非常著名的函数形状很类似,能所出此定理吗? 
#       hint: [XXX]'s law
# 
# 

1.3 文本预处理

此部分需要做文本方面的处理。 以下是可以用到的一些方法:

    1. 停用词过滤 (去网上搜一下 "english stop words list",会出现很多包含停用词库的网页,或者直接使用NLTK自带的)
    1. 转换成lower_case: 这是一个基本的操作
    1. 去掉一些无用的符号: 比如连续的感叹号!!!, 或者一些奇怪的单词。
    1. 去掉出现频率很低的词:比如出现次数少于10,20.... (想一下如何选择阈值)
    1. 对于数字的处理: 分词完只有有些单词可能就是数字比如44,415,把所有这些数字都看成是一个单词,这个新的单词我们可以定义为 "#number"
    1. lemmazation: 在这里不要使用stemming, 因为stemming的结果有可能不是valid word。
In [21]:
# TODO: 需要做文本方面的处理。 从上述几个常用的方法中选择合适的方法给qlist做预处理(不一定要按照上面的顺序,不一定要全部使用)

import re
def clean(sentence):
    result = []
    for word in sentence.split(" "):
        # 如果是停用词 则 continue

        #  去标点
        if re.match(r"^[\!@#$%^&*\(\)\\\{\}\[\]:\"\';\<\>\,\./\-\+=_【】;‘:“《》,。?、\?]+$",word):
            continue
        word = re.sub(r"[\!@#$%^&*\(\)\\\{\}\[\]:\"\';\<\>\,\./\-\+=_【】;‘:“《》,。?、\?]+","",word)

        # 小写
        word = word.lower()
        
        # 去低频词

        # 去数字
        word = re.sub(r"[0-9]+","#number",word)

        # 词性还原 lemmazation

        result.append(word)
    return " ".join(result)
for i in range(len(qlist)):
    q = qlist[i]
    qlist[i] = clean(q)
    
print(qlist[:5])
qlist =  qlist   # 更新后的问题列表
Out [21]:
['when did beyonce start becoming popular', 'what areas did beyonce compete in when she was growing up', 'when did beyonce leave destinys child and become a solo singer', 'in what city and state did beyonce  grow up ', 'in which decade did beyonce become famous']

第二部分: 文本的表示

当我们做完必要的文本处理之后就需要想办法表示文本了,这里有几种方式

    1. 使用tf-idf vector
    1. 使用embedding技术如word2vec, bert embedding

下面我们分别提取这三个特征来做对比。

2.1 使用tf-idf表示向量

qlist中的每一个问题的字符串转换成tf-idf向量, 转换之后的结果存储在X矩阵里。 X的大小是: N* D的矩阵。 这里N是问题的个数(样本个数), D是词典库的大小

In [24]:
# TODO 
from sklearn import feature_extraction
from sklearn.feature_extraction.text import CountVectorizer 
from sklearn.feature_extraction.text import TfidfTransformer 

vectorizer =  CountVectorizer()    # 定义一个tf-idf的vectorizer
transformer = TfidfTransformer()

X = vectorizer.fit_transform(qlist[:1000])
tfidf = transformer.fit_transform(X)
X_tfidf = tfidf.toarray()  # 结果存放在X矩阵里

print(X_tfidf[:5])
print(X_tfidf[0])
print(sum(X_tfidf[0]))

Out [24]:
[[0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]]
[0. 0. 0. ... 0. 0. 0.]
2.220026903744897

2.2 使用wordvec + average pooling

词向量方面需要下载: https://nlp.stanford.edu/projects/glove/ (请下载glove.6B.zip),并使用d=200的词向量(200维)。国外网址如果很慢,可以在百度上搜索国内服务器上的。 每个词向量获取完之后,即可以得到一个句子的向量。 我们通过average pooling来实现句子的向量。

# TODO 基于Glove向量获取句子向量
def readWord2Vec(fd):
    word2id = dict()
    id2word = dict()
    word2vec = []
    for line in fd.readlines():
        l = line.strip()
        ele = l.split()
        if len(ele) != 201:
            continue
            print(line)
        word = ele[0]
        if word not in word2id:
            word2id[word] = len(word2id)
            id2word[len(id2word)] = word
            word2vec.append(ele[1:])
    word2vec = np.array(word2vec,dtype='float32')
    return word2id,id2word,word2vec

def getQueryW2V(query):
    words = query.split(" ")
    sentenceVec = np.zeros(200).reshape((1,200))
    count = 0
    for word in words:
        count += 1
        if word not in word2id:
            continue
        sentenceVec += word2vec[word2id[word]].reshape((1,200))
    if count != 0:
        sentenceVec = sentenceVec/count
    return sentenceVec

glovefile = open("glove.6B.200d.txt","r",encoding="utf-8") 
word2id,id2word,word2vec = readWord2Vec(glovefile)
print(list(word2id.items())[:5])
sentenceVecs = []
for q in qlist[:1000]:
    sentenceVec = getQueryW2V(q)
    sentenceVecs.append(sentenceVec)
print(np.sum(sentenceVecs[:5]))
emb  =  word2vec # 这是 D*H的矩阵,这里的D是词典库的大小, H是词向量的大小。 这里面我们给定的每个单词的词向量,
        # 这需要从文本中读取
    
X_w2v =   sentenceVecs # 初始化完emb之后就可以对每一个句子来构建句子向量了,这个过程使用average pooling来实现

2.3 使用BERT + average pooling

最近流行的BERT也可以用来学出上下文相关的词向量(contex-aware embedding), 在很多问题上得到了比较好的结果。在这里,我们不做任何的训练,而是直接使用已经训练好的BERT embedding。 具体如何训练BERT将在之后章节里体会到。 为了获取BERT-embedding,可以直接下载已经训练好的模型从而获得每一个单词的向量。可以从这里获取: https://github.com/imgarylai/bert-embedding , 请使用bert_12_768_12 当然,你也可以从其他source获取也没问题,只要是合理的词向量。

# TODO 基于BERT的句子向量计算
from bert_embedding import BertEmbedding
bert_embedding = BertEmbedding(model='bert_12_768_12')
result_bert = bert_embedding(qlist[:1000])
X_bert = [] # 每一个句子的向量结果存放在X_bert矩阵里。行数为句子的总个数,列数为一个句子embedding大小。 
for ele in result_bert:
    sentence_embedding = ele[1]
    X_bert.append(sentence_embedding)
print(X_bert[:5])   

第三部分: 相似度匹配以及搜索

在这部分里,我们需要把用户每一个输入跟知识库里的每一个问题做一个相似度计算,从而得出最相似的问题。但对于这个问题,时间复杂度其实很高,所以我们需要结合倒排表来获取相似度最高的问题,从而获得答案。

3.1 tf-idf + 余弦相似度

我们可以直接基于计算出来的tf-idf向量,计算用户最新问题与库中存储的问题之间的相似度,从而选择相似度最高的问题的答案。这个方法的复杂度为O(N)N是库中问题的个数。

tfidf_vocab = vectorizer.vocabulary_
idf = transformer.idf_
def get_top_results_tfidf_noindex(query):
# def get_top_results_tfidf_noindex(query,X,y):
    # TODO 需要编写
    """
    给定用户输入的问题 query, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 对于用户的输入 query 首先做一系列的预处理(上面提到的方法),然后再转换成tf-idf向量(利用上面的vectorizer)
    2. 计算跟每个库里的问题之间的相似度
    3. 找出相似度最高的top5问题的答案
    """
    q_emb = getTFIDF(query,tfidf_vocab,idf)
    all_cosine = {}
    queue = Q.PriorityQueue()
    for i in range(len(X_tfidf)):
        x = X_tfidf[i]
        cos = cosine(q_emb,x)
        if cos not in all_cosine:
            all_cosine[cos] = []
            queue.put(cos)
        all_cosine[cos].append(i)

    top_idxs = []  # top_idxs存放相似度最高的(存在qlist里的)问题的下标 
                   # hint: 请使用 priority queue来找出top results. 思考为什么可以这么做? 
    top = 5
    while len(top_idxs) < top and not queue.empty():
        cos = queue.get()
        top_idxs.extend(all_cosine[cos])
    result = []
    for idx in top_idxs:
        result.append(alist[idx])
    return result  # 返回相似度最高的问题对应的答案,作为TOP5答案    

def getTFIDF(query,vocab,idf):
    result = [ 0. for _ in range(len(vocab)) ]
    for word in query.split(" "):
        if word not in vocab:
            continue
        result[vocab[word]] += 1
    result = np.array(result)
    result = result * idf
    denominator = np.sum(result**2)**0.5
    return np.array(result/denominator,dtype='float32')
# TODO: 编写几个测试用例,并输出结果
print (get_top_results_tfidf_noindex(""))
print (get_top_results_tfidf_noindex(""))

你会发现上述的程序很慢,没错! 是因为循环了所有库里的问题。为了优化这个过程,我们需要使用一种数据结构叫做倒排表。 使用倒排表我们可以把单词和出现这个单词的文档做关键。 之后假如要搜索包含某一个单词的文档,即可以非常快速的找出这些文档。 在这个QA系统上,我们首先使用倒排表来快速查找包含至少一个单词的文档,然后再进行余弦相似度的计算,即可以大大减少时间复杂度

3.2 倒排表的创建

倒排表的创建其实很简单,最简单的方法就是循环所有的单词一遍,然后记录每一个单词所出现的文档,然后把这些文档的ID保存成list即可。我们可以定义一个类似于hash_map, 比如 inverted_index = {}, 然后存放包含每一个关键词的文档出现在了什么位置,也就是,通过关键词的搜索首先来判断包含这些关键词的文档(比如出现至少一个),然后对于candidates问题做相似度比较。

# TODO 请创建倒排表
def getInversedIndexedTable(l):
    result = {}
    for i in range(len(l)):
        sentence = l[i]
        for word in sentence.split(" "):
            if word not in result:
                result[word] = set()
            result[word].add(i)
    return result
inverted_idx = getInversedIndexedTable(qlist)  # 定一个一个简单的倒排表,是一个map结构。 循环所有qlist一遍就可以

3.3 语义相似度

这里有一个问题还需要解决,就是语义的相似度。可以这么理解: 两个单词比如car, auto这两个单词长得不一样,但从语义上还是类似的。如果只是使用倒排表我们不能考虑到这些单词之间的相似度,这就导致如果我们搜索句子里包含了car, 则我们没法获取到包含auto的所有的文档。所以我们希望把这些信息也存下来。那这个问题如何解决呢? 其实也不难,可以提前构建好相似度的关系,比如对于car这个单词,一开始就找好跟它意思上比较类似的单词比如top 10,这些都标记为related words。所以最后我们就可以创建一个保存related words的一个map. 比如调用related_words['car']即可以调取出跟car意思上相近的TOP 10的单词。

那这个related_words又如何构建呢? 在这里我们仍然使用Glove向量,然后计算一下俩俩的相似度(余弦相似度)。之后对于每一个词,存储跟它最相近的top 10单词,最终结果保存在related_words里面。 这个计算需要发生在离线,因为计算量很大,复杂度为O(V*V), V是单词的总数。

这个计算过程的代码请放在related.py的文件里,然后结果保存在related_words.txt里。 我们在使用的时候直接从文件里读取就可以了,不用再重复计算。所以在此notebook里我们就直接读取已经计算好的结果。 作业提交时需要提交related.pyrelated_words.txt文件,这样在使用的时候就不再需要做这方面的计算了。

In [1]:
# TODO 读取语义相关的单词
import json
def get_related_words(file):
    with open(file,"r",encoding="utf-8") as f:
        related_words = json.load(f)
    return related_words

related_words = get_related_words('related_words.txt') # 直接放在文件夹的根目录下,不要修改此路径。

3.4 利用倒排表搜索

在这里,我们使用倒排表先获得一批候选问题,然后再通过余弦相似度做精准匹配,这样一来可以节省大量的时间。搜索过程分成两步:

  • 使用倒排表把候选问题全部提取出来。首先,对输入的新问题做分词等必要的预处理工作,然后对于句子里的每一个单词,从related_words里提取出跟它意思相近的top 10单词, 然后根据这些top词从倒排表里提取相关的文档,把所有的文档返回。 这部分可以放在下面的函数当中,也可以放在外部。
  • 然后针对于这些文档做余弦相似度的计算,最后排序并选出最好的答案。

可以适当定义自定义函数,使得减少重复性代码

def get_top_results_tfidf(query):
    """
    给定用户输入的问题 query, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 利用倒排表来筛选 candidate (需要使用related_words). 
    2. 对于候选文档,计算跟输入问题之间的相似度
    3. 找出相似度最高的top5问题的答案
    """
    candidates = set()
    for word in query.split(" "):
        for idx in inverted_idx[word]:
            candidates.add(idx)
        for related_word in related_words[word]:
            for idx in inverted_idx[related_word]:
                candidates.add(idx)
    
    q_emb = getTFIDF(query,tfidf_vocab,idf) # 获取query tfidf embedding
    all_cosine = {}
    queue = Q.PriorityQueue()
    for i in candidates:
        x = X_tfidf[i] # 获取candidate embedding
        cos = cosine(q_emb,x)
        if cos not in all_cosine:
            all_cosine[cos] = []
            queue.put(cos)
        all_cosine[cos].append(i)
    top_idxs = []  # top_idxs存放相似度最高的(存在qlist里的)问题的下表 
                   # hint: 利用priority queue来找出top results. 思考为什么可以这么做? 
    
    top = 5
    while len(top_idxs) < top and not queue.empty():
        cos = queue.get()
        top_idxs.extend(all_cosine[cos])
    result = []
    for idx in top_idxs:
        result.append(alist[idx])
 
    return result  # 返回相似度最高的问题对应的答案,作为TOP5答案
def get_top_results_w2v(query):
    """
    给定用户输入的问题 query, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 利用倒排表来筛选 candidate (需要使用related_words). 
    2. 对于候选文档,计算跟输入问题之间的相似度
    3. 找出相似度最高的top5问题的答案
    """
    candidates = set()
    for word in query.split(" "):
        for idx in inverted_idx[word]:
            candidates.add(idx)
        for related_word in related_words[word]:
            for idx in inverted_idx[related_word]:
                candidates.add(idx)
    
    q_emb = getQueryW2V(query) # 获取query w2v embedding
    all_cosine = {}
    queue = Q.PriorityQueue()
    for i in candidates:
        x = X_w2v[i] # 获取candidate embedding
        cos = cosine(q_emb,x)
        if cos not in all_cosine:
            all_cosine[cos] = []
            queue.put(cos)
        all_cosine[cos].append(i)
    top_idxs = []  # top_idxs存放相似度最高的(存在qlist里的)问题的下表 
                   # hint: 利用priority queue来找出top results. 思考为什么可以这么做? 
    
    top = 5
    while len(top_idxs) < top and not queue.empty():
        cos = queue.get()
        top_idxs.extend(all_cosine[cos])
    result = []
    for idx in top_idxs:
        result.append(alist[idx])
 
    return result  # 返回相似度最高的问题对应的答案,作为TOP5答案
def get_top_results_bert(query):
    """
    给定用户输入的问题 query, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 利用倒排表来筛选 candidate (需要使用related_words). 
    2. 对于候选文档,计算跟输入问题之间的相似度
    3. 找出相似度最高的top5问题的答案
    """
    candidates = set()
    for word in query.split(" "):
        for idx in inverted_idx[word]:
            candidates.add(idx)
        for related_word in related_words[word]:
            for idx in inverted_idx[related_word]:
                candidates.add(idx)
    
    q_emb = bert_embedding([query])[0] # 获取bert embedding
    all_cosine = {}
    queue = Q.PriorityQueue()
    for i in candidates:
        x = X_bert[i] # 获取candidate embedding
        cos = cosine(q_emb,x)
        if cos not in all_cosine:
            all_cosine[cos] = []
            queue.put(cos)
        all_cosine[cos].append(i)
    top_idxs = []  # top_idxs存放相似度最高的(存在qlist里的)问题的下表 
                   # hint: 利用priority queue来找出top results. 思考为什么可以这么做? 
    
    top = 5
    while len(top_idxs) < top and not queue.empty():
        cos = queue.get()
        top_idxs.extend(all_cosine[cos])
    result = []
    for idx in top_idxs:
        result.append(alist[idx])
 
    return result # 返回相似度最高的问题对应的答案,作为TOP5答案
# TODO: 编写几个测试用例,并输出结果

test_query1 = ""
test_query2 = ""

print (get_top_results_tfidf(test_query1))
print (get_top_results_w2v(test_query1))
print (get_top_results_bert(test_query1))

print (get_top_results_tfidf(test_query2))
print (get_top_results_w2v(test_query2))
print (get_top_results_bert(test_query2))

4. 拼写纠错

其实用户在输入问题的时候,不能期待他一定会输入正确,有可能输入的单词的拼写错误的。这个时候我们需要后台及时捕获拼写错误,并进行纠正,然后再通过修正之后的结果再跟库里的问题做匹配。这里我们需要实现一个简单的拼写纠错的代码,然后自动去修复错误的单词。

这里使用的拼写纠错方法是课程里讲过的方法,就是使用noisy channel model。 我们回想一下它的表示:

$c^* = \text{argmax}{c\in candidates} ~~p(c|s) = \text{argmax}{c\in candidates} ~~p(s|c)p(c)$

这里的candidates指的是针对于错误的单词的候选集,这部分我们可以假定是通过edit_distance来获取的(比如生成跟当前的词距离为1/2的所有的valid 单词。 valid单词可以定义为存在词典里的单词。 c代表的是正确的单词, s代表的是用户错误拼写的单词。 所以我们的目的是要寻找出在candidates里让上述概率最大的正确写法c

$p(s|c)$,这个概率我们可以通过历史数据来获得,也就是对于一个正确的单词$c$, 有百分之多少人把它写成了错误的形式1,形式2... 这部分的数据可以从spell_errors.txt里面找得到。但在这个文件里,我们并没有标记这个概率,所以可以使用uniform probability来表示。这个也叫做channel probability。

$p(c)$,这一项代表的是语言模型,也就是假如我们把错误的$s$,改造成了$c$, 把它加入到当前的语句之后有多通顺?在本次项目里我们使用bigram来评估这个概率。 举个例子: 假如有两个候选 c1,c2 c_1, c_2, 然后我们希望分别计算出这个语言模型的概率。 由于我们使用的是bigram, 我们需要计算出两个概率,分别是当前词前面和后面词的bigram概率。 用一个例子来表示:

给定: We are go to school tomorrow, 对于这句话我们希望把中间的go替换成正确的形式,假如候选集里有个,分别是going, went, 这时候我们分别对这俩计算如下的概率: p(goingare)p(togoing) p(going|are)p(to|going)p(wentare)p(towent) p(went|are)p(to|went), 然后把这个概率当做是$p(c)$的概率。 然后再跟channel probability结合给出最终的概率大小。

那这里的$p(are|going)$这些bigram概率又如何计算呢?答案是训练一个语言模型! 但训练一个语言模型需要一些文本数据,这个数据怎么找? 在这次项目作业里我们会用到nltk自带的reuters的文本类数据来训练一个语言模型。当然,如果你有资源你也可以尝试其他更大的数据。最终目的就是计算出bigram概率。

4.1 训练一个语言模型

在这里,我们使用nltk自带的reuters数据来训练一个语言模型。 使用add-one smoothing

from nltk.corpus import reuters


# 循环所有的语料库并构建bigram probability. bigram[word1][word2]: 在word1出现的情况下下一个是word2的概率。 
import re
import numpy as np
import json
import os

def clean1(sentence):
    result = []
    for word in sentence:
        # 如果是停用词 则 continue

        #  去标点
        if re.match(r"^[\!@#$%^&*\(\)\\\{\}\[\]:\"\';\<\>\,\./\-\+=_【】;‘:“《》,。?、\?]+$",word):
            continue
        word = re.sub(r"[\!@#$%^&*\(\)\\\{\}\[\]:\"\';\<\>\,\./\-\+=_【】;‘:“《》,。?、\?]+","",word)

        # 小写
        word = word.lower()

        # 去低频词

        # 去数字
        word = re.sub(r"[0-9]+","#number",word)

        # 词性还原 lemmazation

        result.append(word)
    return result

def create(corpus):
    dual = {}
    single = {}
    single["<START>"] = len(corpus)
    single["<END>"] = len(corpus) 
    for sen in corpus:
        sen = clean(sen)
        for i in range(len(sen)):
            word = sen[i]
            if word not in single:
                single[word] = 0
            single[word] += 1
            if i == 0 :
                key = "{pre}##{cur}".format(pre="<START>",cur=word)
            else:
                key = "{pre}##{cur}".format(pre=sen[i-1],cur=word)
            if key not in dual:
                dual[key] = 0
            dual[key] += 1
        if len(sen) != 0:
            key = "{pre}##{cur}".format(pre=sen[-1],cur="<END>")
            if key not in dual:
                dual[key] = 0
            dual[key] += 1
    return single,dual

def getProb1(query,single,dual):
    if type(query) == type(str()):
        query = clean(query.split(" "))
    vocab_size = len(single)
    result = 0
    if len(query) != 0:
        numerator = 0
        if query[0] in single:
            numerator += single[query[0]]
        result += numerator/vocab_size
    for i in range(1,len(query)):
        word = query[i]
        denominator = 0
        if i == 0:
            pre = "<START>"
        else:
            pre = query[i-1]
        if pre in single:
            denominator += single[pre]
        key = "{pre}##{cur}".format(pre=pre,cur=word)
        numerator = 0
        if key in dual:
            numerator += dual[key]
        if denominator == 0 or numerator == 0:
            prob = (numerator + 1.0) / (denominator + vocab_size)
        else:
            prob = numerator / denominator
        result += np.log10(prob)
    if result != 0:
        result = result/len(query)
        result = 10**result
    return result

single_path = "single.json"
dual_path = "dual.json"
if not os.path.exists(single_path) or not os.path.exists(dual_path):
    # 读取语料库的数据
    categories = reuters.categories()
    corpus = reuters.sents(categories=categories)
    single,dual = create(corpus)
    with open(single_path,"w",encoding="utf-8") as f:
        json.dump(single,f,ensure_ascii=False)
    with open(dual_path,"w",encoding="utf-8") as f:
        json.dump(dual,f,ensure_ascii=False)
else:
    with open(single_path,"r",encoding="utf-8") as f:
        single = json.load(f)
    with open(dual_path,"r",encoding="utf-8") as f:
        dual = json.load(f)
sens =  ["They told Reuter correspondents in Asian capitals a U . S . Move against Japan might boost protectionist sentiment in the U . S . And lead to curbs on American imports of their products .","x y z", "world true good","car vechicle movies film music",
            "i like movie"]
for sen in sens:
    print(sen,getProb1(sen,single,dual))

4.2 构建Channel Probs

基于spell_errors.txt文件构建channel probability, 其中$channel[c][s]$表示正确的单词$c$被写错成$s$的概率。

# TODO 构建channel probability  
channel = {}

with open("spell-errors.txt","r",encoding="utf-8") as f:
    for line in f.readlines():
        eles = line.strip().split(":")
        if len(eles) <= 1:
            print(line)
            continue
        c = eles[0]
        errors_dict = {}
        errors = eles[1].split(",")
        for s in errors:
            s = s.strip()
            errors_dict[s] = 1/len(errors)
        channel[c] = errors_dict


print(channel)   

4.3 根据错别字生成所有候选集合

给定一个错误的单词,首先生成跟这个单词距离为1或者2的所有的候选集合。 这部分的代码我们在课程上也讲过,可以参考一下。

 # encoding: utf-8

vocab = set()
file_path = "spell-errors.txt"
with open(file_path,"r",encoding="utf-8") as f:
    for line in f.readlines():
        eles = line.strip().split(":")
        if len(eles) <= 1:
            continue
        vocab.add(eles[0])

def generate_candidates(word):
    # 基于拼写错误的单词,生成跟它的编辑距离为1或者2的单词,并通过词典库的过滤。
    # 只留写法上正确的单词。 
    
    candidates = {word}
    for _ in [1,2]:
        store = set()
        for cand in candidates:
            tmp = generate_candidates_with_one_ED(cand)
            for t in tmp:
                if t != word:
                    store.add(t)
        candidates = store
    result = set()
    for cand in candidates:
        if cand in vocab:
            result.add(cand)
    return result
        

def generate_candidates_with_one_ED(word):
    candidates = set()
    # 增
    alternative = " " + word + " "
    for i in range(1,len(alternative)-1):
        for j in range(ord("a"),ord("a") + 26):
            c = chr(j)
            candidate = alternative[:i] + c + alternative[i:]
            candidate = candidate.strip()
            candidates.add(candidate)
    # 删
    for i in range(1,len(alternative)-1):
        candidate = alternative[:i] + alternative[i+1:]
        candidate = candidate.strip()
        candidates.add(candidate)
    # 改
    for i in range(1,len(alternative)-1):
        for j in range(ord("a"),ord("a") + 26):
            c = chr(j)
            if c == alternative[i]:
                continue
            candidate = alternative[:i] + c + alternative[i+1:]
            candidate = candidate.strip()
            candidates.add(candidate)
    return candidates

words = ["reserve",
         "reverse"]
for word in words:
    print(word,word in vocab)
    cands = generate_candidates(word)
    print(cands)   
    

4.4 给定一个输入,如果有错误需要纠正

给定一个输入query, 如果这里有些单词是拼错的,就需要把它纠正过来。这部分的实现可以简单一点: 对于query分词,然后把分词后的每一个单词在词库里面搜一下,假设搜不到的话可以认为是拼写错误的! 人如果拼写错误了再通过channelbigram来计算最适合的候选。

import numpy as np


def spell_corrector(line):
    # 1. 首先做分词,然后把``line``表示成``tokens``
    # 2. 循环每一token, 然后判断是否存在词库里。如果不存在就意味着是拼写错误的,需要修正。 
    #    修正的过程就使用上述提到的``noisy channel model``, 然后从而找出最好的修正之后的结果。
    tokens = [ token for token in line.split(" ") if str(token).isalnum() ]
    optimal = []
    for i in range(len(tokens)):
        token = tokens[i]
        if token not in vocab:
            cands = generate_candidates(token)
            if len(cands) != 0:
                info = {"token":token,"idx":i,"correct":"","prob":0.0}
                for cand in cands:
                    # p(s|c)
                    if token not in channel[cand]:
                        psc = 1 / (len(vocab) + len(channel[cand]))
                    else:
                        psc = channel[cand][token]
                    pc = getProb1(" ".join(tokens[:i]+[cand]+tokens[i+1:]),single,dual)
                    prob = 10**(np.log10(psc)+np.log(pc))
                    if info["prob"] < prob:
                        info["prob"] = prob
                        info["correct"] = cand
                optimal.append(info)

    for info in optimal:
        tokens[info["idx"]] = info["correct"]
    print(optimal)
    newline = " ".join(tokens)
    return newline   # 修正之后的结果,假如用户输入没有问题,那这时候``newline = line``

sens = ["am rienind the book ","everything will become goad"]
for sen in sens :
    newsen = spell_corrector(sen)
    print(newsen)

4.5 基于拼写纠错算法,实现用户输入自动矫正

首先有了用户的输入query, 然后做必要的处理把句子转换成tokens的形状,然后对于每一个token比较是否是valid, 如果不是的话就进行下面的修正过程。

test_query1 = ""  # 拼写错误的
test_query2 = ""  # 拼写错误的

test_query1 = spell_corector(test_query1)
test_query2 = spell_corector(test_query2)

print (get_top_results_tfidf(test_query1))
print (get_top_results_w2v(test_query1))
print (get_top_results_bert(test_query1))

print (get_top_results_tfidf(test_query2))
print (get_top_results_w2v(test_query2))
print (get_top_results_bert(test_query2))

附录

在本次项目中我们实现了一个简易的问答系统。基于这个项目,我们其实可以有很多方面的延伸。

  • 在这里,我们使用文本向量之间的余弦相似度作为了一个标准。但实际上,我们也可以基于基于包含关键词的情况来给一定的权重。比如一个单词跟related word有多相似,越相似就意味着相似度更高,权重也会更大。
  • 另外 ,除了根据词向量去寻找related words也可以提前定义好同义词库,但这个需要大量的人力成本。
  • 在这里,我们直接返回了问题的答案。 但在理想情况下,我们还是希望通过问题的种类来返回最合适的答案。 比如一个用户问:“明天北京的天气是多少?”, 那这个问题的答案其实是一个具体的温度(其实也叫做实体),所以需要在答案的基础上做进一步的抽取。这项技术其实是跟信息抽取相关的。
  • 对于词向量,我们只是使用了average pooling, 除了average pooling,我们也还有其他的经典的方法直接去学出一个句子的向量。
  • 短文的相似度分析一直是业界和学术界一个具有挑战性的问题。在这里我们使用尽可能多的同义词来提升系统的性能。但除了这种简单的方法,可以尝试其他的方法比如WMD,或者适当结合parsing相关的知识点。

好了,祝你好运!