xuzhengfu / pilot

进入编程世界的第一课
1 stars 0 forks source link

p1-b-final 第十一章(最终章) 课程练习 #29

Open xuzhengfu opened 4 years ago

xuzhengfu commented 4 years ago

1. 了解 本章学习目标

实现一个简单的对话机器人。

2. 理解问题

在我们想编程实现一个什么时,一定要先问下自己:这个东西的实质是什么?

对话机器人能够读取我们对它说的话(输入),理解其含义,然后做出合理的回应,然后把回应展示(输出)给我们。

对话的主题可以各种各样,以 Siri 为例,主要可以分几类:

  • 通过特定指令让 Siri 操作手机完成特定任务(设闹钟、发邮件等);
  • 提出特定领域的问题(query),Siri 搜索知识库并给出结果(问天气、问比赛结果、查地址等);
  • 自由的闲聊,这个是最难的,因为缺乏上下文限定,计算机很难准确理解我们人类的真实意图。
  • 一个语音识别(speech recognition)引擎来把我们说的话转换成文字;
  • 一个对话系统(dialog system)来理解我们说的话的含义,并且尝试寻找合适的回应;
  • 一个巨大并且不断增长的知识库(knowledge base)后台,同时也是语料库(text corpus),帮助 Siri 找到并构造回应;
  • 将回应呈现反馈给我们,其中有文字有图片,也有从文字自动生成的语音,后面这个需要一个文本合成语音(text-to-speech, TTS)的引擎。

这里面每一个都是人工智能领域的大课题,相对来说 speech recognition 和 TTS 这一对引擎比较成熟一些,虽然还有很多提升空间。

3. 设计

我们当然不可能做到像 Siri 那么完整,目前能有一点对话系统的感觉就好,同时我们还希望搭好一个架子,让我们可以不断迭代加强这个系统的能力。

可以这么去思考:

  • 简化输入和输出环节,用户直接输入文字,机器人直接回应文字;
  • 对系统进行功能分解,除了高度简化的输入和输出层,主要划分为 “理解” 和 “回应” 两个引擎;
  • 构造一个体系,让我们可以随时实现更好的 “理解” 和 “回应” 引擎并且无缝的替换。

为了进一步降低难度,我们可以从 "程序发起的对话" 开始,也就是把 “程序提问题-用户回应-程序再回应” 作为一个基本对话单元,这比由用户自由提出话题要容易。

程序设计不是画图纸,是像小孩玩沙子一样,一边尝试一边思考,在必要时才会画图纸。

在做 更复杂的思考和设计 之前可以先随便的玩一下,看看在 Python 里怎么实现 “程序提问题-用户回应-程序再回应” 这样的基本单元:

print("Hi, what is your name?")
name = input()
print(f"Hello {name}!")
print("How are you today?")
feeling = input()
if 'good' in feeling:
    print("I'm feeling good too")
else:
    print("Sorry to hear that")

在这里用各种输入测试几次之后,我们发现了一个小 bug:如果用户输入 good 或者 I'm good 这种,代码运行是如我们所想,但是如果用户输入的是 Good,程序会不认,而输出 Sorry to hear that,这显然不合理,这个问题源于我们代码中的不严谨,在判断时未考虑大小写问题。修正也很容易,改成下面这样就好了:

print("How are you today?")
feeling = input()
if 'good' in feeling.lower():
    print("I'm feeling good too")
else:
    print("Sorry to hear that")

我们使用来自 random 模块中的 choice 函数,从列表 colors 中随机抽选一个元素。

import random

print("What's your favorite color?")
favcolor = input()
colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'purple']
print(f"You like {favcolor.lower()}? My favorite color is {random.choice(colors)}")
  • 每一轮交互都是 print() 一句话,然后用 input() 获取用户输入,然后围绕输入内容进行计算,给出一个相关的输出,并 print() 出来;
  • 在识别和处理输入时要当心大小写问题;
  • 如果细心的话还会发现 体验 上的一些细节,比如程序 print() 输出太快了,如果能停顿一下再输出感觉会更像对话。

最重要的,我们知道 基本的交互模式 之后,只要愿意,我们可以重复上面的过程,写出好多好多交互剧本,和用户聊上一整天。

回忆我们在前面几章所讲的一些编程的基本思维,尤其是 “分而治之” 和 “责任分离”,把问题分解,每个子问题用一个程序模块来解决,每个程序模块力争把一个问题解决好,并且给出清晰的接口供其他模块使用。

在对话机器人这个课题中,我们可以建立这么几个抽象模块:

  1. 每轮交互可以用一个 bot 对象来实现,不同的剧本实现为不同的 Bot 类;
  2. 每轮交互中的共性功能,比如输入输出的 print-input-print 流程,可以在一个公共的 Bot 父类中处理;
  3. 理解用户输入并给出回应是核心的逻辑,每个 Bot 子类需要实现这个逻辑,但接口应该在 Bot 父类中统一。

4. 构建 "这一设计的代码实现"

4.1 公共父类 Bot

class Bot:
    def run(self):
        print(question)
        answer = input()
        print(result_of_processing_answer)
class Bot:
    def __init__(self):
        self.q = ''
        self.a = ''

    def run(self):
        print(self.q)
        self.a = input()
        print('result_of_processing_answer')

实例变量(instance variable)self.qself.a 分别代表这一轮对话中 "程序提出的问题" 和 "用户的回答",是两个字符串。

class Bot:
    def __init__(self):
        self.q = ''
        self.a = ''

    def _think(self, s):
        return '...'

    def run(self):
        print(self.q)
        self.a = input()
        print(self._think(self.a))

_think() 方法就是 以用户输入来计算程序回答 的核心算法,在 run() 方法中被调用,这个方法一般来说只在 Bot 类内部使用,外部最好不要直接访问,所以名字前面我们加了下划线 _

import time

class Bot:

    wait = 1

    def __init__(self):
        self.q = ''
        self.a = ''

    def _think(self, s):
        return '...'

    def run(self):
        time.sleep(Bot.wait)
        print(self.q)
        self.a = input()
        time.sleep(Bot.wait)
        print(self._think(self.a))

使用 Python time 模块中的 sleep() 方法,这个方法让解释器休息指定的秒数; 用一个类变量 wait 来管理程序说每句话之前要停顿的秒数;

4.2 Bot 子类

# HelloBot
class HelloBot(Bot):
    def __init__(self):
        self.q = "Hi, what is your name?"

    def _think(self, s):
        return f"Hello {s}!"
# GreetingBot
class GreetingBot(Bot):
    def __init__(self):
        self.q = "How are you today?"

    def _think(self, s):
        if 'good' in s.lower():
            return "I'm feeling good too"
        else:
            return "Sorry to hear that"
# FavoriteColorBot
import random

class FavoriteColorBot(Bot):
    def __init__(self):
        self.q = "What's your favorite color?"

    def _think(self, s):
        colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'purple']
        return f"You like {s.lower()}? My favorite color is {random.choice(colors)}"

4.3 对话系统的主类 Garfield

我们需要一个主流程把上面的这些 Bot 串起来,这是我们的对话系统的主类定义(我们给它起名叫 Garfield):

class Garfield:
    def run(self):
        bots_list = [HelloBot(), GreetingBot(), FavoriteColorBot()]
        for bot in bots_list:
            bot.run()

run() 方法是对话系统运行主方法:一个一个地运行我们加入的 bot,即循环调用每个 bot 的 run() 方法。

class Garfield:
    def _prompt(self, s):
        print(s)
        print()

    def run(self):
        bots_list = [HelloBot(), GreetingBot(), FavoriteColorBot()]
        self._prompt("This is Garfield dialog system. Let's talk.")
        for bot in bots_list:
            bot.run()
garfield = Garfield()
garfield.run()

结果如下:

This is Garfield dialog system. Let's talk.

Hi, what is your name?
 zhengfu
Hello zhengfu!
How are you today?
 GOOD!
I'm feeling good too
What's your favorite color?
 black
You like black? My favorite color is blue
class Garfield:

    def __init__(self):
        self.bots = []

    def add(self, bot):
        self.bots.append(bot)

    def _prompt(self, s):
        print(s)
        print()

    def run(self):
        self._prompt("This is Garfield dialog system. Let's talk.")
        for bot in self.bots:
            bot.run()

为了让 对话系统 以后可以方便地添加 更多的对话 bot,我们设置一个 实例变量 self.bots 用于放置这些对话 bot,这是一个列表,初始化为空列表 []; 提供 add() 方法来往系统中加入 bot,加入方法是用列表的 append() 方法;

garfield = Garfield()
garfield.add(FavoriteColorBot())
garfield.add(HelloBot())
garfield.add(GreetingBot())
garfield.run()

结果如下:

This is Garfield dialog system. Let's talk.

What's your favorite color?
 black
You like black? My favorite color is green
Hi, what is your name?
 zhengfu
Hello zhengfu!
How are you today?
 fine
I'm feeling good too
class Garfield:

    def __init__(self, wait=1):
        Bot.wait = wait
        self.bots = []
class Garfield:

    def __init__(self, wait=1):
        Bot.wait = wait
        self.bots = []

    def add(self, bot):
        self.bots.append(bot)

    def _prompt(self, s):
        print(s)
        print()

    def run(self):
        self._prompt("This is Garfield dialog system. Let's talk.")
        for bot in self.bots:
            bot.run()

原因在于:传入的参数是 wait=1 ,意味着对话系统默认聊天延迟为 1s,因此,如若没有修改 聊天延迟 的需求,实例化 Garfield 类时可以不传 wait 参数,比如,garfield = Garfield()。 如果 传入的参数是 wait,那 Garfield 类的每一次实例化,都必须传入 wait 参数,比如 ,garfield = Garfield(1),不传入这个参数会报错,显示:TypeError: __init__() missing 1 required positional argument: 'wait'。

# 创建一个聊天延时 2s 的对话系统
garfield = Garfield(2)
# 向其中加入我们已经定义好的各个对话 bot 的对象实例
garfield.add(HelloBot())
garfield.add(GreetingBot())
garfield.add(FavoriteColorBot())
# 运行之
garfield.run()

结果如下:

This is Garfield dialog system. Let's talk.

Hi, what is your name?
 zhengfu
Hello zhengfu!
How are you today?
 bad
Sorry to hear that
What's your favorite color?
 black
You like black? My favorite color is green

以后我们可以定义更多的 bot,每个代表了一轮不一样的对话,然后加入到 Garfield 的对象实例,就可以运行起来了。

5. 增量式优化

有了上面的第一版之后,我们可以随时 “增量式(progressively)” 更新和优化这个对话系统,比如:

  • 如果我们对交互的流程和样式不满意,就优化增强 Bot 父类的 run() 方法;
  • 如果我们需要更多对话内容,就定义更多不一样的 Bot 子类;
  • 如果想增加对话系统级的操作,比如提供系统帮助指令,可以修改 GarfieldBot 类的 run() 方法来实现。

5.1 “增量式优化” 实例1 —— 给程序输出的对话加上颜色,以区分用户输入的内容

$ pip3 install termcolor
Collecting termcolor
  Downloading termcolor-1.1.0.tar.gz (3.9 kB)
Building wheels for collected packages: termcolor
  Building wheel for termcolor (setup.py) ... done
  Created wheel for termcolor: filename=termcolor-1.1.0-py3-none-any.whl size=4830 sha256=9a14e6436f8a91081842c1cf44edf181fd03946b4b0c8afd0ff062b6d9a8ff5c
  Stored in directory: /Users/xuzhengfu/Library/Caches/pip/wheels/3f/e3/ec/8a8336ff196023622fbcb36de0c5a5c218cbb24111d1d4c7f2
Successfully built termcolor
Installing collected packages: termcolor
Successfully installed termcolor-1.1.0
from time import sleep
from termcolor import colored
class Bot:

    wait = 1

    def __init__(self):
        self.q = ''
        self.a = ''

    def _think(self, s):
        return s

    def _format(self, s):
        return colored(s, 'red')

    def run(self):
        sleep(Bot.wait)
        print(self._format(self.q))
        self.a = input()
        sleep(Bot.wait)
        print(self._format(self._think(self.a)))

要应用 “分而治之” 和 “责任分离” 的思维方式,把问题分解,每个子问题用一个程序模块来解决,每个程序模块力争把一个问题解决好,并且给出清晰的接口供其他模块使用。所以,我们不在 run 方法内直接使用 colored 函数,而是专门增加了一个内部方法 _format 来对程序输出的文字进行格式化。程序的其他部分不需要改变,所有 Bot 子类继承父类自动得到这一新特性。重新运行程序来检验一下。

5.2 “增量式优化” 实例2 —— 做一个新的 Bot 子类

class evalBot(Bot):
    def __init__(self):
        self.q = 'please type in your arithmetic expression: '

    def _think(self, s):
        return '...'
$ pip3 install simpleeval
Collecting simpleeval
  Downloading simpleeval-0.9.10.tar.gz (26 kB)
Building wheels for collected packages: simpleeval
  Building wheel for simpleeval (setup.py) ... done
  Created wheel for simpleeval: filename=simpleeval-0.9.10-py3-none-any.whl size=13758 sha256=ca463b2ec17b55f79ce2f01961c2cbf919caf165a1d57a8edfbdf10593199fc3
  Stored in directory: /Users/xuzhengfu/Library/Caches/pip/wheels/d4/aa/50/0b420d1eabad3c16a82368935c6a2050955bc3ee2a11ee4e06
Successfully built simpleeval
Installing collected packages: simpleeval
Successfully installed simpleeval-0.9.10

Basic Usage To get very simple evaluating:

from simpleeval import simple_eval

simple_eval("21 + 21")
returns 42.

Expressions can be as complex and convoluted as you want:

simple_eval("21 + 19 / 7 + (8 % 3) ** 9")
returns 535.714285714.
from simpleeval import simple_eval
class evalBot(Bot):
    def __init__(self):
        self.q = 'please type in your arithmetic expression: '

    def _think(self, s):
        return simple_eval(str(s))
from simpleeval import simple_eval
class evalBot(Bot):
    def __init__(self):
        self.q = 'please type in your arithmetic expression: '

    def _think(self, s):
        return simple_eval(s)
garfield = Garfield(1)
garfield.add(evalBot())
garfield.run()

结果如下:

This is Garfield dialog system. Let's talk.

please type in your arithmetic expression: 
 3 + 4
7

6. 作业

了解要求:我们想改进最后这个 evalBot,让它可以反复执行,用户可以一直输入算术表达式求值,直到用户输入 x q exit 或者 quit 为止,才跳到下一个 bot 执行。

from simpleeval import simple_eval
class evalBot(Bot):
    def __init__(self):
        self.q = 'please type in your arithmetic expression: \nif you want to exit, please enter "x" "q" "exit" or "quit".'

    def _think(self, s):
        return simple_eval(s)

    def run(self):
        sleep(Bot.wait)
        print(self._format(self.q))
        self.a = input()
        while not((self.a == 'x') or (self.a == 'q') or (self.a == 'exit') or (self.a == 'quit')):
            sleep(Bot.wait)
            print(self._format(self._think(self.a)))
            print(self._format(self.q))
            self.a = input()

while not ((self.a == 'x') or (self.a == 'q') or (self.a == 'exit') or (self.a == 'quit')): 想表达的逻辑是:当用户输入不为 'x'、'q'、'exit' 或 'quit' 时继续循环执行。这种写法过于冗长,更简洁的写法是:while not(self.a in ['x', 'q', 'exit', 'quit']):

在方框中输入表达式,想要退出的话,输入 'x'、'q'、'exit' 或 'quit',这些内容用户已经知道了,用不着反复提问。故删去 while 循环里的 print(self._format(self.q))run 函数最终应该写成这样:

def run(self):
    sleep(Bot.wait)
    print(self._format(self.q))
    self.a = input()
    while not(self.a in ['x', 'q', 'exit', 'quit']):
        sleep(Bot.wait)
        print(self._format(self._think(self.a)))
        self.a = input()

“重复” 几乎总是不好的,在以上代码中,self.a = input() 就出现了两次,应予以精减,留下循环体中的那句,成为:

def run(self):
    sleep(Bot.wait)
    print(self._format(self.q))
    while not(self.a in ['x', 'q', 'exit', 'quit']):
        self.a = input()
        sleep(Bot.wait)
        print(self._format(self._think(self.a)))
        self.a = input()

while 的逻辑判断式涉及到用户输入,可用户输入在循环体内,这代码是错的,应该让 while 循环先跑起来,逻辑判断在代码内执行。成为:

def run(self):
    sleep(Bot.wait)
    print(self._format(self.q))
    while True:
        self.a = input()
        if not(self.a in ['x', 'q', 'exit', 'quit']):
            sleep(Bot.wait)
            print(self._format(self._think(self.a)))
        else:
            break

这代码读起来可真别扭,改一下,另外还要注意用户输入大小写问题:

def run(self):
    sleep(Bot.wait)
    print(self._format(self.q))
    while True:
        self.a = input()
        if self.a.lower() in ['x', 'q', 'exit', 'quit']:
            break
        sleep(Bot.wait)
        print(self._format(self._think(self.a)))

大功告成:

from simpleeval import simple_eval
class evalBot(Bot):
    def __init__(self):
        self.q = 'please type in your arithmetic expression: \nif you want to exit, please enter "x" "q" "exit" or "quit".'

    def _think(self, s):
        return simple_eval(s)

    def run(self):
        sleep(Bot.wait)
        print(self._format(self.q))
        while True:
            self.a = input()
            if self.a.lower() in ['x', 'q', 'exit', 'quit']:
                break
            sleep(Bot.wait)
            print(self._format(self._think(self.a)))

经过思考,我们可以给 Bot 父类一个新的实例变量 runtype,其值可以为 'once' 或者 'loop',分别表示 bot 只运行一次或者循环运行,然后根据这个值来调用两个不同版本的 run() 方法。为此改写父类 Bot 如下:

from time import sleep
from termcolor import colored
class Bot:

    wait = 1
    # self.runtype: run type of the bot, can be 'once' or 'loop', default to 'once'
    def __init__(self, runtype='once'):
        self.q = ''
        self.a = ''
        self.runtype = runtype

    def _think(self, s):
        return s

    def _format(self, s):
        return colored(s, 'red')

    def run_once(self):
        sleep(Bot.wait)
        print(self._format(self.q))
        self.a = input()
        sleep(Bot.wait)
        print(self._format(self._think(self.a)))

    def run_loop(self):
        sleep(Bot.wait)
        print(self._format(self.q))
        while True:
            self.a = input()
            if self.a.lower() in ['x', 'q', 'exit', 'quit']:
                break
            sleep(Bot.wait)
            print(self._format(self._think(self.a)))

    def run(self):
        if self.runtype == 'once':
            self.run_once()
        elif self.runtype == 'loop':
            self.run_loop()
# HelloBot
class HelloBot(Bot):
    def __init__(self, runtype='once'):
        super().__init__(runtype)
        self.q = "Hi, what is your name?"

    def _think(self, s):
        return f"Hello {s}!"

在子类的 __init__() 方法中,调用了 super() 函数来获得父类对象,然后调用了父类的 __init__() 方法来做基本的初始化,而不需要自己再写一遍。在类定义中如果我们需要引用父类的一些实现,都可以用这个办法。

我尝试过后,有如下发现:

子类中若是没有 __init__ 函数,也就是说,子类对父类的 __init__ 函数没有任何修改的必要,父类的 __init__ 函数是会直接继承过来的。不需要你重新初始化 self.runtype

一旦子类对父类的 __init__ 函数有修改,那就需要重新初始化 self.runtype

我的理解是:

首先,super() 函数获得的是父类对象,在父类对象中,__init__ 方法有一个需要传入的参数 —— runtype,我们调用父类的 __init__() 方法来做基本的初始化,当然需要给 __init__ 方法传入参数。

因为在定义父类时,已经给 runtype 传了一个参数 'once',所以,使用 super().__init__()super().__init__('once') 或者 super().__init__(runtype='once'),结果都是一样的:实例变量 self.runtype 已经恒定为 'once',成为 self.runtype = 'once,子类 __init__ 函数的 runtype='once' 已经对实例变量 self.runtype 产生不了任何影响。之后对 evalBot 进行实例化时,无论你传入什么参数,'once','loop',还是 'fsfdsfdsfsd',等等,结果都是一样的。程序只会运行一次。

所以,我们必须写成 super().__init__(runtype),使得:self.runtype = runtype,这样,子类 __init__ 函数的 runtype 值才能在实例化时传的进去。

from simpleeval import simple_eval
class evalBot(Bot):
    def __init__(self, runtype='once'):
        super().__init__(runtype)
        self.q = 'please type in your arithmetic expression: \nif you want to exit, please enter "x" "q" "exit" or "quit".'

    def _think(self, s):
        return simple_eval(s)
garfield = Garfield()
garfield.add(HelloBot())
garfield.add(evalBot('loop'))
garfield.run()

小结

我们在本章实例中运用的程序设计方法叫做 UDB(Understand, Design and Build):

  • Understand:深入理解问题,探求问题的本质,聚焦关键问题;
  • Design:通过初步尝试了解问题的基本解决方案,并作出责任分离的模块化设计,包括各个模块的职责、实现思路以及将各模块粘合起来的方法;
  • Build:编写代码实现每个模块,一边构建一边测试;尽快完成第一个版本,然后增量化持续改进。

对于所有要解决问题的探索,这个方法基本都适用,格外适合编程,差别仅在问题的规模(难度和复杂度),你可以多多体会这个方法的思维模式,平时多尝试。

Logging

2020-03-01 22:33:43 initialize