Unix/Linux操作系统提供了一个fork()
系统调用,它非常特殊。普通的函数调用,调用一次,返回一次,但是fork()
调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。
子进程永远返回0
,而父进程返回子进程的ID。这样做的理由是,一个父进程可以fork出很多子进程,所以,父进程要记下每个子进程的ID,而子进程只需要调用getppid()
就可以拿到父进程的ID。
Python的os
模块封装了常见的系统调用,其中就包括fork
,可以在Python程序中轻松创建子进程:
1 | import os |
运行结果如下:
1 | Process (876) start... |
由于Windows没有fork
调用,上面的代码在Windows上无法运行。而Mac系统是基于BSD(Unix的一种)内核,所以,在Mac下运行是没有问题的,推荐大家用Mac学Python!
有了fork
调用,一个进程在接到新任务时就可以复制出一个子进程来处理新任务,常见的Apache服务器就是由父进程监听端口,每当有新的http请求时,就fork出子进程来处理新的http请求。
如果你打算编写多进程的服务程序,Unix/Linux无疑是正确的选择。由于Windows没有fork
调用,难道在Windows上无法用Python编写多进程的程序?
由于Python是跨平台的,自然也应该提供一个跨平台的多进程支持。multiprocessing
模块就是跨平台版本的多进程模块。
multiprocessing
模块提供了一个Process
类来代表一个进程对象,下面的例子演示了启动一个子进程并等待其结束:
1 | from multiprocessing import Process |
执行结果如下:
1 | Parent process 928. |
创建子进程时,只需要传入一个执行函数和函数的参数,创建一个Process
实例,用start()
方法启动,这样创建进程比fork()
还要简单。
join()
方法可以等待子进程结束后再继续往下运行,通常用于进程间的同步。
如果要启动大量的子进程,可以用进程池的方式批量创建子进程:
1 | from multiprocessing import Pool |
执行结果如下:
1 | Parent process 505792. |
代码解读:
apply_async(func[, args[, kwds[, callback]]]):它是非阻塞,apply(func[, args[, kwds]])是阻塞的。如果是后者的话,执行如果如下:
1 | Parent process 502288. |
对Pool
对象调用join()
方法会等待所有子进程执行完毕,调用join()
之前必须先调用close()
,调用close()
之后就不能继续添加新的Process
了。
请注意输出的结果,task 0
,1
,2
,3
是立刻执行的,而task 4
要等待前面某个task完成后才执行,这是因为Pool
的默认大小在我的电脑上是4,因此,最多同时执行4个进程。这是Pool
有意设计的限制,并不是操作系统的限制。如果改成:
1 | p = Pool(5) |
就可以同时跑5个进程。
由于Pool
的默认大小是CPU的核数,如果你不幸拥有8核CPU,你要提交至少9个子进程才能看到上面的等待效果。
很多时候,子进程并不是自身,而是一个外部进程。我们创建了子进程后,还需要控制子进程的输入和输出。
subprocess
模块可以让我们非常方便地启动一个子进程,然后控制其输入和输出。
下面的例子演示了如何在Python代码中运行命令nslookup www.python.org
,这和命令行直接运行的效果是一样的:
1 | import subprocess |
运行结果:
1 | $ nslookup www.python.org |
如果子进程还需要输入,则可以通过communicate()
方法输入:
1 | import subprocess |
上面的代码相当于在命令行执行命令nslookup
,然后手动输入:
1 | set q=mx |
运行结果如下:
1 | $ nslookup |
Process
之间肯定是需要通信的,操作系统提供了很多机制来实现进程间的通信。Python的multiprocessing
模块包装了底层的机制,提供了Queue
、Pipes
等多种方式来交换数据。
我们以Queue
为例,在父进程中创建两个子进程,一个往Queue
里写数据,一个从Queue
里读数据:
1 | from multiprocessing import Process, Queue |
运行结果如下:
1 | Process to write: 50563 |
在Unix/Linux下,multiprocessing
模块封装了fork()
调用,使我们不需要关注fork()
的细节。由于Windows没有fork
调用,因此,multiprocessing
需要“模拟”出fork
的效果,父进程所有Python对象都必须通过pickle序列化再传到子进程去,所以,如果multiprocessing
在Windows下调用失败了,要先考虑是不是pickle失败了。
代码如下:
1 | #coding: utf-8 |
运行结果:
1 | parent process 24276 |
在Unix/Linux下,可以使用fork()
调用实现多进程。
要实现跨平台的多进程,可以使用multiprocessing
模块。
进程间通信是通过Queue
、Pipes
等实现的。
要想实现分布式进程,可以考虑multiprocessing的managers子模块,把多进程分布到多台机器上。详细请参考分布式进程。
关于Pool.apply
, Pool.apply_async
, Pool.map
and Pool.map_async
的区别请参考multiprocessing.Pool: When to use apply, apply_async or map?
多任务可以由多进程完成,也可以由一个进程内的多线程完成。
我们前面提到了进程是由若干线程组成的,一个进程至少有一个线程。
由于线程是操作系统直接支持的执行单元,因此,高级语言通常都内置多线程的支持,Python也不例外,并且,Python的线程是真正的Posix Thread,而不是模拟出来的线程。
Python的标准库提供了两个模块:_thread
和threading
,_thread
是低级模块,threading
是高级模块,对_thread
进行了封装。绝大多数情况下,我们只需要使用threading
这个高级模块。
启动一个线程就是把一个函数传入并创建Thread
实例,然后调用start()
开始执行:
1 | import time, threading |
执行结果如下:
1 | thread MainThread is running... |
由于任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程又可以启动新的线程,Python的threading
模块有个current_thread()
函数,它永远返回当前线程的实例。主线程实例的名字叫MainThread
,子线程的名字在创建时指定,我们用LoopThread
命名子线程。名字仅仅在打印时用来显示,完全没有其他意义,如果不起名字Python就自动给线程命名为Thread-1
,Thread-2
……
多线程和多进程最大的不同在于,多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。
来看看多个线程同时操作一个变量怎么把内容给改乱了:
1 | import time, threading |
我们定义了一个共享变量balance
,初始值为0
,并且启动两个线程,先存后取,理论上结果应该为0
,但是,由于线程的调度是由操作系统决定的,当t1、t2交替执行时,只要循环次数足够多,balance
的结果就不一定是0
了。
原因是因为高级语言的一条语句在CPU执行时是若干条语句,即使一个简单的计算:
1 | balance = balance + n |
也分两步:
balance + n
,存入临时变量中;balance
。也就是可以看成:
1 | x = balance + n |
由于x是局部变量,两个线程各自都有自己的x,当代码正常执行时:
1 | 初始值 balance = 0 |
但是t1和t2是交替运行的,如果操作系统以下面的顺序执行t1、t2:
1 | 初始值 balance = 0 |
究其原因,是因为修改balance
需要多条语句,而执行这几条语句时,线程可能中断,从而导致多个线程把同一个对象的内容改乱了。
两个线程同时一存一取,就可能导致余额不对,你肯定不希望你的银行存款莫名其妙地变成了负数,所以,我们必须确保一个线程在修改balance
的时候,别的线程一定不能改。
如果我们要确保balance
计算正确,就要给change_it()
上一把锁,当某个线程开始执行change_it()
时,我们说,该线程因为获得了锁,因此其他线程不能同时执行change_it()
,只能等待,直到锁被释放后,获得该锁以后才能改。由于锁只有一个,无论多少线程,同一时刻最多只有一个线程持有该锁,所以,不会造成修改的冲突。创建一个锁就是通过threading.Lock()
来实现:
1 | balance = 0 |
当多个线程同时执行lock.acquire()
时,只有一个线程能成功地获取锁,然后继续执行代码,其他线程就继续等待直到获得锁为止。
获得锁的线程用完后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程。所以我们用try...finally
来确保锁一定会被释放。
锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处当然也很多,首先是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。其次,由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止。
如果你不幸拥有一个多核CPU,你肯定在想,多核应该可以同时执行多个线程。
如果写一个死循环的话,会出现什么情况呢?打开Mac OS X的Activity Monitor,或者Windows的Task Manager,都可以监控某个进程的CPU使用率。我们可以监控到一个死循环线程会100%占用一个CPU。如果有两个死循环线程,在多核CPU中,可以监控到会占用200%的CPU,也就是占用两个CPU核心。要想把N核CPU的核心全部跑满,就必须启动N个死循环线程。
试试用Python写个死循环:
1 | import threading, multiprocessing |
启动与CPU核心数量相同的N个线程,在4核CPU上可以监控到CPU占用率仅有102%,也就是仅使用了一核。
但是用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,4核就跑到400%,8核就跑到800%,为什么Python不行呢?
因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。
GIL是Python解释器设计的历史遗留问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。
所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。
不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。
多线程编程,模型复杂,容易发生冲突,必须用锁加以隔离,同时,又要小心死锁的发生。
Python解释器由于设计时有GIL全局锁,导致了多线程无法利用多核。多线程的并发在Python中就是一个美丽的梦
在多线程环境下,每个线程都有自己的数据。一个线程使用自己的局部变量比使用全局变量好,因为局部变量只有线程自己能看见,不会影响其他线程,而全局变量的修改必须加锁。
但是局部变量也有问题,就是在函数调用的时候,传递起来很麻烦。如下代码所示,假设这份代码需要多线程调用。
1 | def process_student(name): |
每个函数一层一层调用都这么传参数那还得了?用全局变量?也不行,因为每个线程处理不同的Student
对象,不能共享。
如果用一个全局dict
存放所有的Student
对象,然后以thread
自身作为key
获得线程对应的Student
对象如何?
1 | global_dict = {} |
这种方式理论上是可行的,它最大的优点是消除了std
对象在每层函数中的传递问题,但是,每个函数获取std
的代码有点丑。
有没有更简单的方式?
ThreadLocal
应运而生,不用查找dict
,ThreadLocal
帮你自动做这件事:
1 | import threading |
执行结果:
1 | Hello, Alice (in Thread-A) |
全局变量local_school
就是一个ThreadLocal
对象,每个Thread
对它都可以读写student
属性,但互不影响。你可以把local_school
看成全局变量,但每个属性如local_school.student
都是线程的局部变量,可以任意读写而互不干扰,也不用管理锁的问题,ThreadLocal
内部会处理。
可以理解为全局变量local_school
是一个dict
,不但可以用local_school.student
,还可以绑定其他变量,如local_school.teacher
等等。
ThreadLocal
最常用的地方就是为每个线程绑定一个数据库连接,HTTP请求,用户身份信息等,这样一个线程的所有调用到的处理函数都可以非常方便地访问这些资源。
一个ThreadLocal
变量虽然是全局变量,但每个线程都只能读写自己线程的独立副本,互不干扰。ThreadLocal
解决了参数在一个线程中各个函数之间互相传递的问题。
协程,又称微线程,纤程。英文名 Coroutine。协程的概念很早就提出来了,但直到最近几年才在某些语言(如 Lua)中得到广泛应用。
子程序,或者称为函数,在所有语言中都是层级调用,比如 A 调用 B,B 在执行过程中又调用了 C,C 执行完毕返回,B 执行完毕返回,最后是 A 执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。
子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。
注意,在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似 CPU 的中断。比如子程序 A、B:
1 | def A(): |
假设由协程执行,在执行 A 的过程中,可以随时中断,去执行 B,B 也可能在执行过程中中断再去执行 A,结果可能是:
1 | 1 |
但是在 A 中是没有调用 B 的,所以协程的调用比函数调用理解起来要难一些。
看起来 A、B 的执行有点像多线程,但协程的特点在于是一个线程执行,那和多线程比,协程有何优势?
最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
因为协程是一个线程执行,那怎么利用多核 CPU 呢?最简单的方法是多进程 + 协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
Python 对协程的支持是通过 generator 实现的。在 generator 中,我们不但可以通过for
循环来迭代,还可以不断调用next()
函数获取由yield
语句返回的下一个值。但是 Python 的yield
不但可以返回一个值,它还可以接收调用者发出的参数。
来看例子:
传统的生产者 - 消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。如果改用协程,生产者生产消息后,直接通过yield
跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:
1 | def consumer(): |
执行结果:
1 | [PRODUCER] Producing 1... |
注意到consumer
函数是一个generator
,把一个consumer
传入produce
后:
首先调用c.send(None)
启动生成器;
然后,一旦生产了东西,通过c.send(n)
切换到consumer
执行;
consumer
通过yield
拿到消息,处理,又通过yield
把结果传回;
produce
拿到consumer
处理的结果,继续生产下一条消息;
produce
决定不生产了,通过c.close()
关闭consumer
,整个过程结束。
整个流程无锁,由一个线程执行,produce
和consumer
协作完成任务,所以称为 “协程”,而非线程的抢占式多任务。
看完之后还是一脸懵逼,要看懂上面的例子,关键在于要理解下面几点:
1、例子中的c.send(None)
,其功能类似于next(c)
,比如:
1 | def num(): |
2、n = yield r
,这里是一条语句,但要理解两个知识点,赋值语句先计算=
右边,由于右边是 yield
语句,所以yield
语句执行完以后,进入暂停,而赋值语句在下一次启动生成器的时候首先被执行;
3、send
在接受None
参数的情况下,等同于next(generator)
的功能,但send
同时也可接收其他参数,比如例子中的c.send(n)
,要理解这种用法,先看一个例子:
1 | def num(): |
在上面的例子中,首先使用 c.send(None)
,返回生成器的第一个值,a = yield 1
,也就是1
(但此时,并未执行赋值语句),
接着我们使用了c.send(5)
,再次启动生成器,并同时传入了一个参数5
,再次启动生成的时候,从上次yield
语句断掉的地方开始执行,即 a
的赋值语句,由于我们传入了一个参数5
,所以a
被赋值为5,接着程序进入whlie
循环,当程序执行到 a = yield a
,同理,先返回生成器的值 5
,下次启动生成器的时候,再执行赋值语句,以此类推…
所以c.send(n)
的用法就是老师上文中所说的 ,” Python的yield
不但可以返回一个值,它还可以接收调用者发出的参数。”
但注意,在一个生成器函数未启动之前,是不能传递值进去。也就是说在使用c.send(n)
之前,必须先使用c.send(None)
或者next(c)
来返回生成器的第一个值。
最后我们来看上文中的例子,梳理下执行过程:
1 | def consumer(): |
第一步:执行 c.send(None)
,启动生成器返回第一个值,n = yield r
,此时 r
为空,n
还未赋值,然后生成器暂停,等待下一次启动。
第二步:生成器返回空值后进入暂停,produce(c)
接着往下运行,进入While
循环,此时 n
为1
,所以打印:
1 | [PRODUCER] Producing 1... |
第三步:produce(c)
往下运行到 r = c.send(1)
,再次启动生成器,并传入了参数1
,而生成器从上次n
的赋值语句开始执行,n
被赋值为1
,n
存在,if
语句不执行,然后打印:
1 | [CONSUMER] Consuming 1... |
接着r
被赋值为'200 OK'
,然后又进入循环,执行n = yield r
,返回生成器的第二个值,'200 OK'
,然后生成器进入暂停,等待下一次启动。
第四步:生成器返回'200 OK'
进入暂停后,produce(c)
往下运行,进入r
的赋值语句,r
被赋值为'200 OK'
,接着往下运行,打印:
1 | [PRODUCER] Consumer return: 200 OK |
以此类推…
当n
为5
跳出循环后,使用c.close()
结束生成器的生命周期,然后程序运行结束。
最后套用 Donald Knuth 的一句话总结协程的特点:“子程序就是协程的一种特例。”
asyncio
是Python 3.4版本引入的标准库,直接内置了对异步IO的支持。具体来说,协程都是通过使用yield from
和asyncio模块
中的@asyncio.coroutine
来实现的。asyncio
专门被用来实现异步IO操作。
下面我们先来看一个用普通同步代码实现多个 IO 任务的案例。
1 | # 普通同步代码实现多个IO任务 |
执行结果:
1 | 开始运行IO任务1... |
上面,我们顺序实现了两个同步 IO 任务taskIO_1()
和taskIO_2()
,则最后总耗时就是 5 秒。我们都知道,在计算机中 CPU 的运算速率要远远大于 IO 速率,而当 CPU 运算完毕后,如果再要闲置很长时间去等待 IO 任务完成才能进行下一个任务的计算,这样的任务执行效率很低。
所以我们需要有一种异步的方式来处理类似上述任务,会极大增加效率 (当然就是协程啦~)。而我们最初很容易想到的,是能否在上述 IO 任务执行前中断当前 IO 任务 (对应于上述代码time.sleep(2)
),进行下一个任务,当该 IO 任务完成后再唤醒该任务。
而在 Python 中生成器中的关键字yield
可以实现中断功能。所以起初,协程是基于生成器的变形进行实现的,之后虽然编码形式有变化,但基本原理还是一样的。
yield
在生成器中有中断的功能,可以传出值,也可以从函数外部接收值,而yield from
的实现就是简化了yield
操作。看下面案例:
1 | def generator_1(titles): |
执行结果如下:
1 | 生成器1: ['Python', 'Java', 'C++'] |
在这个例子中yield titles
返回了titles
完整列表,而yield from titles
实际等价于:
1 | for title in titles: # 等价于yield from titles |
而yield from功能还不止于此,它还有一个主要的功能是省去了很多异常的处理,不再需要我们手动编写,其内部已经实现大部分异常处理。
举个例子,下面通过生成器来实现一个整数加和的程序,通过send()函数向生成器中传入要加和的数字,然后最后以返回None结束,total保存最后加和的总数。
1 | def generator_1(): |
执行结果如下。可见对于生成器g1
,在最后传入None
后,程序退出,报StopIteration
异常并返回了最后total
值是5。
1 | 加 2 |
如果把g1.send()
那5行注释掉,解注下面的g2.send()
代码,则结果如下。可见yield from
封装了处理常见异常的代码。对于g2即便传入None也不报异常,其中total = yield from generator_1()
返回给total的值是generator_1()
最终的return total
1 | 加 2 |
借用上述例子,这里有几个概念需要理一下:
yield from
后的generator_1()
生成器函数是子生成器generator_2()
是程序中的委托生成器,它负责委托子生成器完成具体任务。main()
是程序中的调用方,负责调用委托生成器。yield from
在其中还有一个关键的作用是:建立调用方和子生成器的通道,
main()
每一次在调用send(value)
时,value
不是传递给了委托生成器generator_2(),而是借助yield from
传递给了子生成器generator_1()中的yield
yield
直接发送到调用方main()中。那yield from
通常用在什么地方呢?在协程中,只要是和IO任务类似的、耗费时间的任务都需要使用yield from
来进行中断,达到异步功能!
我们在上面那个同步IO任务的代码中修改成协程的用法如下:
1 | # 使用同步方式编写异步功能 |
执行结果如下:
1 | 开始运行IO任务1... |
【使用方法】: @asyncio.coroutine
装饰器是协程函数的标志,我们需要在每一个任务函数前加这个装饰器,并在函数中使用yield from
。在同步 IO 任务的代码中使用的time.sleep(2)
来假设任务执行了 2 秒。但在协程中yield from
后面必须是子生成器函数,而time.sleep()
并不是生成器,所以这里需要使用内置模块提供的生成器函数asyncio.sleep()
。
【功能】:通过使用协程,极大增加了多任务执行效率,最后消耗的时间是任务队列中耗时最多的时间。上述例子中的总耗时 3 秒就是taskIO_2()
的耗时时间。
【执行过程】:
get_event_loop()
获取了一个标准事件循环 loop(因为是一个,所以协程是单线程)run_until_complete(main())
来运行协程 (此处把调用方协程 main() 作为参数,调用方负责调用其他委托生成器),run_until_complete
的特点就像该函数的名字,直到循环事件的所有事件都处理完才能完整结束。taskIO_1()
和taskIO_2()
] 放到一个task
列表中,可理解为打包任务。asyncio.wait(tasks)
来获取一个 awaitable objects 即可等待对象的集合 (此处的 aws 是协程的列表),并发运行传入的 aws,同时通过yield from
返回一个包含(done, pending)
的元组,done 表示已完成的任务列表,pending 表示未完成的任务列表;如果使用asyncio.as_completed(tasks)
则会按完成顺序生成协程的迭代器 (常用于 for 循环中),因此当你用它迭代时,会尽快得到每个可用的结果。【此外,当轮询到某个事件时 (如 taskIO_1()),直到遇到该任务中的yield from
中断,开始处理下一个事件 (如 taskIO_2())),当yield from
后面的子生成器完成任务时,该事件才再次被唤醒】done
里面有我们需要的返回结果,但它目前还是个任务列表,所以要取出返回的结果值,我们遍历它并逐个调用result()
取出结果即可。(注:对于asyncio.wait()
和asyncio.as_completed()
返回的结果均是先完成的任务结果排在前面,所以此时打印出的结果不一定和原始顺序相同,但使用gather()
的话可以得到原始顺序的结果集,两者更详细的案例说明见此)loop.close()
关闭事件循环。综上所述:异步IO的完整实现是靠①事件循环+②协程。有关更底层的原理可以参考How does asyncio work?。
我们用asyncio
的异步网络连接来获取sina、sohu和163的网站首页:
1 | import asyncio |
执行结果如下:
1 | wget www.sohu.com... |
可见3个连接由一个线程通过coroutine
并发完成。
asyncio
提供了完善的异步IO支持;
异步操作需要在coroutine
中通过yield from
完成;
多个coroutine
可以封装成一组Task然后并发执行。
用asyncio
提供的@asyncio.coroutine
可以把一个generator标记为coroutine类型,然后在coroutine内部用yield from
调用另一个coroutine实现异步操作。
为了简化并更好地标识异步IO,从Python 3.5开始引入了新的语法async
和await
,可以让coroutine的代码更简洁易读。
请注意,async
和await
是针对coroutine的新语法,要使用新的语法,只需要做两步简单的替换:
@asyncio.coroutine
替换为async
;yield from
替换为await
。让我们对比一下上一节的代码:
1 | import threading |
用新语法重新编写如下:
1 | import threading |
剩下的代码保持不变。
Python从3.5版本开始为asyncio
提供了async
和await
的新语法;
注意新语法只能用在Python 3.5以及后续版本,如果使用3.4版本,则仍需使用上一节的方案。
首先介绍一个什么是CPU密集型计算、IO密集型计算?
其次,对比一下多进程、多线程、多协程。
那么该如何选择呢?
协程
python并发编程 多进程 多线程 多协程
Python异步IO之协程(一):从yield from到async的使用
并发:两个或多个事件在同一时间间隔内发生。这些事情宏观上同时发生,微观上交替进行。
并行:两个或多个事件在同一时刻同时发生。
假设你同时被赋予了唱歌和吃饭的任务。在给定的时间,你要么唱歌,要么吃东西,因为在这两种情况下,你的嘴都会受到影响。因此,为了做到这一点,你会吃一段时间,然后唱歌,重复这个过程,直到你的食物吃完或歌曲结束。所以你并发完成了任务。
在单核环境中,通过上下文切换,在同一时间段内执行的任务发生并发,即在特定的时间段,只有一个任务被执行。
在多核环境中,可以通过同时执行多个任务的并行来实现并发。但是并发性仍不可少。比如对于4核CPU,同一时刻可以用四个程序并行执行,但通常我们需要四个以上程序同时工作,此时就需要并发了。
假设你有两项任务:做饭和打电话给你的朋友。你可以同时做这两件事。你既可以做饭,也可以打电话。现在你是在并行地做你的任务。
并行意味着同时执行两个或多个任务。
线程是CPU调度的最小单元,进程是资源分配的最小单元。
进程是running program的一个instance,一个program可以有多个进程。
同步:任务一个接一个地执行。每个任务等待任何先前的任务完成,然后执行。
异步:当一个任务被执行时,您可以切换到另一个任务,而无需等待前一个任务完成。
想象一下,你被安排写两封信,一封是给你妈妈的,另一封是给你最好的朋友的。你不能同时写两封信,除非你是个两手都能写的人。
在同步编程模型中,任务一个接一个地执行。每个任务都会等待之前的任何任务完成,然后执行。
想象一下,有人让你做三明治,然后在洗衣机里洗衣服。你可以把衣服放进洗衣机里,不用等它洗好,你就可以去做三明治了。
在这里,您异步执行了这两个任务。在异步编程模型中,当执行一项任务时,您可以切换到另一项任务,而无需等待前一项任务完成。
那么,在同步调用下,调用方不再继续执行而是暂停等待,被调函数执行完后很自然的就是调用方继续执行,那么异步调用下调用方怎知到被调函数是否执行完成呢?
这就分为了两种情况:
第一种情况比较简单,该情况无需讨论。第二种情况下通常有两种实现方式:
通知机制,也就是说当任务执行完成后发送信号用来通知调用方任务完成,注意这里的信号就有很多实现方式了,Linux中的signal,或者使用信号量等机制都可以实现。
回调,也就是我们常说的callback,
单线程,每个任务都被一个接一个地执行。每个任务都等待其前一个任务执行。
多线程:Tasks get executed in different threads but wait for any other executing tasks on any other thread.
单线程:任务开始执行时不需要等待其他任务完成。在给定的时间,执行单个任务。
多线程:Tasks get executed in different threads without waiting for any tasks and independently finish off their executions.
异步编程模型帮助我们实现并发。
多线程环境中的异步编程模型是实现并行性的一种方式。
详细参考怎样理解阻塞非阻塞与同步异步的区别? - 萧萧的回答 - 知乎。
Concurrency and Parallelism -> Way tasks are executed.
Synchronous and Asynchronous -> Programming model.
Single Threaded and Multi-Threaded -> The environment of task execution.
Concurrency, Parallelism, Threads, Processes, Async, and Sync — Related?
从小白到高手,你需要理解同步与异步
怎样理解阻塞非阻塞与同步异步的区别? - 萧萧的回答 - 知乎
有的时候在运行Python的时候,会遇到python -u xx.py
,这是什么意思呢?
python中标准错误(std.err)和标准输出(std.out)的输出规则:标准输出默认需要缓存后再输出到屏幕,而标准错误则直接打印到屏幕。如在test.py
中有如下内容:
1 | import sys |
其中sys.stdout.write()和sys.stderr.write()均是向屏幕打印的语句。其实python中的print语句就是调用了sys.stdout.write(),例如在打印对象调用print obj 时,事实上是调用了 sys.stdout.write(obj+’\n’)。
预想的结果是:
1 | Stdout1Stderr1Stdout2Stderr2 |
实际的结果为:
1 | Stderr1Stderr2Stdout1Stdout2 |
原因:是python缓存机制,虽然stderr和stdout默认都是指向屏幕的,但是stderr是无缓存的,程序往stderr输出一个字符,就会在屏幕上显示一个;而stdout是有缓存的,只有遇到换行或者积累到一定的大小,才会显示出来。这就是为什么上面的会最先显示两个stderr的原因。
注意要使用
python test.py
才能验证,不要在ipython中。
python命令加上-u(unbuffered)参数后会强制其标准输出也同标准错误一样不通过缓存直接打印到屏幕。
运行结果:
1 | Stdout1Stderr1Stdout2Stderr2 |
u/U:表示unicode字符串。不是仅仅是针对中文, 可以针对任何的字符串,代表是对字符串进行unicode编码。
r/R:非转义的原始字符串。与普通字符相比,其他相对特殊的字符,其中可能包含转义字符,即那些,反斜杠加上对应字母,表示对应的特殊含义的,比如最常见的”\n”表示换行,”\t”表示Tab等。而如果是以r开头,那么说明后面的字符,都是普通的字符了,即如果是“\n”那么表示一个反斜杠字符,一个字母n,而不是表示换行了。以r开头的字符,常用于正则表达式,对应着re模块。变量前面可以添加repr
。
b:bytes。
字符在内存里的表示是unicode,如果要存盘或者发到网络就经过编码,具体为使用encode
函数将其转为bytes形式,然后对端收到依次解码,具体为使用decode
函数将其转为str形式。
Python 3里面,str在内存里是unicode表示的,所以’中文’ == ‘\u4e2d\u6587’,类型都是str。
1 | '\u4e2d\u6587' |
对str编码,本质上还是对str表示的字符编码,可以用ascii(如果字符属于ascii字符集的话),也可以用utf-8,也可以用gb2312(中文),都行。但是注意并没有unicode这个encode形式。
1 | '\u0041').encode('ascii') ( |
编码后是bytes,俗称的01010101,如果这个010101不在ascii的表示范围内,就会显示成\x(010101的十六进制形式)。
这就是说,像汉字编码成bytes以后,去查看这个bytes肯定只能看到\x系列,因为这个bytes的内容肯定不在ascii范围内;但如果换英文,就可以看到对应的英文字母,不过不要误会,本质上它还是没有含义的010101而不是字符。
1 | "abc".encode('utf-8') |
由于对同一个英文字符,ascii编码和utf-8编码的结果是一致的,所以用一个编码然后再另一个解码,是可以成功还原的。不过一般是不会这么做的。
1 | >>>'abc'.encode('ascii').decode('utf-8') |
爬虫若拿到的是形如0101的bytes,首先会指定一个编码做decode,这时候可能会碰到部分不符合出错,可以加上ignore参数试试。
1 | b'\xe4\xb8\xad\xff'.decode('utf-8', errors='ignore') |
ord函数获取字符的整数表示和chr数把编码转换为对应的字符
1 | ord('A') |
另外,对str和对bytes用len,意义是不同的。
len(str)统计字符数,len(bytes)统计bytes数,即——这串010101一共是多少个bits,除以8就是bytes。
1 | '中文') len( |
我们经常遇到的编码其实主要的就只有三种:utf-8,gbk,unicode
\u
带头的,然后后面跟四位数字或字母,例如 \u6d4b\u8bd5
,一个 \u
对应一个汉字\x
带头的,后面跟两位字母或数字,例如 \xe6\xb5\x8b\xe8\xaf\x95\xe5\x95\x8a
,三个 \x
代表一个汉字\x
带头的,后面跟两位字母或数字,例如 \xb2\xe2\xca\xd4\xb0\xa1
,两个 \x
代表一个汉字。除了粗略的根据是否乱码看编码方式外,还可以用chardet模块猜测、
1 | import chardet |
输出:
1 | {'encoding': 'utf-8', 'confidence': 0.99, 'language': ''} |
chardet模块可以计算这个字符串是某个编码的概率,基本对于99%的应用场景,这个模块都够用了。
在使用Python的时候,经常遇到类似于\xe4\xbd\xa0\xe5\xa5\xbd\xe4\xb8\x96\xe7\x95\x8c
的字符串,其实它是utf-8编码,但数据类型是字符串类型,而不是bytes类型的utf-8编码。如果我们需要将\x开头的字符串编码转换中文。
方法一:先将字符串编码指定为unicode_escape,
1 | s = '\xe4\xbd\xa0\xe5\xa5\xbd\xe4\xb8\x96\xe7\x95\x8c' |
接着再对bytes类型进行utf-8解码,得到字符串,将字符串中的 “ \x “ 替换为 “ % “,
1 | # bytes to string |
最后利用urllib中的unquote方法将url编码解码,得到中文
1 | import urllib.parse |
方法二:
1 | s = '\xe4\xbd\xa0\xe5\xa5\xbd\xe4\xb8\x96\xe7\x95\x8c' |
在使用Python的时候,经常遇到类似于'\u5403\u9e21\u6218\u573a'
的字符串,其实它是Unicode编码,可以直接解码。
1 | In [63]: s = '\u5403\u9e21\u6218\u573a' |
关于
unicode-escape
以及raw-unicode-escape
,可以参考codecs —- 编解码器注册和相关基类。
解析python 命令的-u参数
关于\x开头的字符串编码转换中文解决方法
【Python】笔记:关于\u和\x
python中的编码和解码及\x和\u问题
python学习:字符串前面添加u,r,b的含义
python对变量的字符串不转义 变量如何加r
https://blog.csdn.net/mijichui2153/article/details/105516152
不得不知道的Python字符串编码相关的知识
在开始之前,我们需要进行一些准备工作:
yum install nginx
安装。www.xxxx.com
,以及对应的ssl证书(腾讯云可以在这里申请免费的证书)执行vim /etc/nginx/nginx.conf
,添加如下配置:
1 | # 省略其它配置,可以保持默认配置不变 |
{your_domain_name}
可以填写www.xxx.com
。关于配置的介绍,可部分参考Nginx反向代理OpenAI API
重启nginx
服务
1 | sudo nginx -s stop |
配置环境:
1 | export OPENAI_API_KEY=sk-xxxxx |
测试模型列表:
1 | curl https://xxx.com/v1/models \ |
测试对话:
1 | curl https://xxx.com/v1/chat/completions -H "Content-Type: application/json" -H "Authorization: Bearer $OPENAI_API_KEY" -d '{ |
这里使用
https://xxx.com
与https://www.xxx.com
均可。
若使用openai-python包,则注意要设置openai代理服务器地址,主要有两种方法:
方法一:在openai/__init__.py
(例如/data/home/zdaiot/.local/lib/python3.10/site-packages/openai/__init__.py
)中添加如下行:
1 | api_base = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") |
方法二:添加环境变量export OPENAI_API_BASE= https://xxx.com/v1
。
Nginx反向代理OpenAI API
使用Nginx反向代理OpenAI API
如何用Nginx反向代理openAI接口
Systemd 是 Linux 系统工具,用来启动守护进程,已成为大多数发行版的标准配置。
“守护进程”(daemon)就是一直在后台运行的进程(daemon)。比如说我们要开启一个服务node server.js
,怎么才能让它变成系统的守护进程(daemon),成为一种服务(service),一直在那里运行,而不是退出终端就停止呢?有如下几种方法,更详细的介绍参考Linux 守护进程的启动方法。
$ nohup node server.js &
指令。历史上,Linux 的启动一直采用init
进程。在类Unix 的计算机操作系统中,Init(初始化的简称)是在启动计算机系统期间启动的第一个进程。init 是一个守护进程,它将持续运行,直到系统关闭。它是所有其他进程的直接或间接的父进程。
因为init 的参数全在/etc/init.d
目录下,所以使用 init 启动一个服务,应该这样做:
1 | sudo /etc/init.d/nginx start |
service是一个运行System V init
(也就是/etc/init.d
目录下的参数)的脚本命令。
System V,曾经也被称为AT&T System V,是Unix操作系统众多版本中的一支。它最初由AT&T开发,在1983年第一次发布。一共发行了4个System V的主要版本:版本1、2、3和4。System V Release 4,或者称为SVR4,是最成功的版本,成为一些UNIX共同特性的源头,例如 ”SysV 初始化脚本“ (/etc/init.d),用来控制系统启动和关闭,System V Interface Definition (SVID) 是一个System V 如何工作的标准定义。
所以分析可知service 是去/etc/init.d
目录下执行相关程序。
使用 service 启动一个服务:
1 | $ service nginx start |
可以理解成 service 就是init.d
的一种实现方式。所以这两者启动方式(或者是停止、重启)并没有什么区别。
1 | $ sudo /etc/init.d/nginx start |
这两种方法都有两个缺点。
一是启动时间长。init
进程是串行启动,只有前一个进程启动完,才会启动下一个进程。
二是启动脚本复杂。init
进程只是执行启动脚本,不管其他事情。脚本需要自己处理各种情况,这往往使得脚本变得很长。
Systemd 就是为了解决这些问题而诞生的。它的设计目标是,为系统的启动和管理提供一套完整的解决方案。
根据 Linux 惯例,字母d
是守护进程(daemon)的缩写。 Systemd 这个名字的含义,就是它要守护整个系统。
使用了 Systemd,就不需要再用init
了。Systemd 取代了initd
,成为系统的第一个进程(PID 等于 1),其他进程都是它的子进程。
1 | $ systemctl --version |
上面的命令查看 Systemd 的版本。
Systemd 的优点是功能强大,使用方便,缺点是体系庞大,非常复杂。事实上,现在还有很多人反对使用 Systemd,理由就是它过于复杂,与操作系统的其他部分强耦合。
Systemd 并不是一个命令,而是一组命令,涉及到系统管理的方方面面。
systemctl
是 Systemd 的主命令,用于管理系统。
1 | # 重启系统 |
systemd-analyze
命令用于查看启动耗时。
1 | # 查看启动耗时 |
hostnamectl
命令用于查看当前主机的信息。
1 | # 显示当前主机的信息 |
localectl
命令用于查看本地化设置。
1 | # 查看本地化设置 |
timedatectl
命令用于查看当前时区设置。
1 | # 查看当前时区设置 |
loginctl
命令用于查看当前登录的用户。
1 | # 列出当前session |
Systemd 可以管理所有系统资源。不同的资源统称为 Unit(单位)。
Unit 一共分成 12 种。
systemctl list-units
命令可以查看当前系统的所有 Unit 。
1 | # 列出正在运行的 Unit |
systemctl status
命令用于查看系统状态和单个 Unit 的状态。
1 | # 显示系统状态 |
除了status
命令,systemctl
还提供了三个查询状态的简单方法,主要供脚本内部的判断语句使用。
1 | # 显示某个 Unit 是否正在运行 |
对于用户来说,最常用的是下面这些命令,用于启动和停止 Unit(主要是 service)。
1 | # 立即启动一个服务 |
Unit 之间存在依赖关系:A 依赖于 B,就意味着 Systemd 在启动 A 的时候,同时会去启动 B。
systemctl list-dependencies
命令列出一个 Unit 的所有依赖。
1 | $ systemctl list-dependencies nginx.service |
上面命令的输出结果之中,有些依赖是 Target 类型(详见下文),默认不会展开显示。如果要展开 Target,就需要使用--all
参数。
1 | $ systemctl list-dependencies --all nginx.service |
每一个 Unit 都有一个配置文件,告诉 Systemd 怎么启动这个 Unit 。
Systemd 默认从目录/etc/systemd/system/
读取配置文件。但是,里面存放的大部分文件都是符号链接,指向目录/usr/lib/systemd/system/
,真正的配置文件存放在那个目录。
systemctl enable
命令用于在上面两个目录之间,建立符号链接关系。
1 | $ sudo systemctl enable clamd@scan.service |
如果配置文件里面设置了开机启动,systemctl enable
命令相当于激活开机启动。
与之对应的,systemctl disable
命令用于在两个目录之间,撤销符号链接关系,相当于撤销开机启动。
1 | $ sudo systemctl disable clamd@scan.service |
配置文件的后缀名,就是该 Unit 的种类,比如sshd.socket
。如果省略,Systemd 默认后缀名为.service
,所以sshd
会被理解成sshd.service
。
systemctl list-unit-files
命令用于列出所有配置文件。
1 | # 列出所有配置文件 |
这个命令会输出一个列表。
1 | $ systemctl list-unit-files |
这个列表显示每个配置文件的状态,一共有四种。
[Install]
部分(无法执行),只能作为其他配置文件的依赖注意,从配置文件的状态无法看出,该 Unit 是否正在运行。这必须执行前面提到的systemctl status
命令。
1 | $ systemctl status bluetooth.service |
一旦修改配置文件,就要让 SystemD 重新加载配置文件,然后重新启动,否则修改不会生效。
1 | $ sudo systemctl daemon-reload |
配置文件就是普通的文本文件,可以用文本编辑器打开。
systemctl cat
命令可以查看配置文件的内容。
1 | $ systemctl cat atd.service |
从上面的输出可以看到,配置文件分成几个区块。每个区块的第一行,是用方括号表示的区别名,比如[Unit]
。注意,配置文件的区块名和字段名,都是大小写敏感的。
每个区块内部是一些等号连接的键值对。
1 | [Section] |
注意,键值对的等号两侧不能有空格。
[Unit]
区块通常是配置文件的第一个区块,用来定义 Unit 的元数据,以及配置与其他 Unit 的关系。它的主要字段如下。
Description
:简短描述Documentation
:文档地址Requires
:当前 Unit 依赖的其他 Unit,如果它们没有运行,当前 Unit 会启动失败Wants
:与当前 Unit 配合的其他 Unit,如果它们没有运行,当前 Unit 不会启动失败BindsTo
:与Requires
类似,它指定的 Unit 如果退出,会导致当前 Unit 停止运行Before
:如果该字段指定的 Unit 也要启动,那么必须在当前 Unit 之后启动After
:如果该字段指定的 Unit 也要启动,那么必须在当前 Unit 之前启动Conflicts
:这里指定的 Unit 不能与当前 Unit 同时运行Condition...
:当前 Unit 运行必须满足的条件,否则不会运行Assert...
:当前 Unit 运行必须满足的条件,否则会报启动失败[Install]
通常是配置文件的最后一个区块,用来定义如何启动,以及是否开机启动。它的主要字段如下。
WantedBy
:它的值是一个或多个 Target,当前 Unit 激活时(enable)符号链接会放入/etc/systemd/system
目录下面以 Target 名 + .wants
后缀构成的子目录中RequiredBy
:它的值是一个或多个 Target,当前 Unit 激活时,符号链接会放入/etc/systemd/system
目录下面以 Target 名 + .required
后缀构成的子目录中Alias
:当前 Unit 可用于启动的别名Also
:当前 Unit 激活(enable)时,会被同时激活的其他 Unit[Service]
区块用来 Service 的配置,只有 Service 类型的 Unit 才有这个区块。它的主要字段如下。
Type
:定义启动时的进程行为。它有以下几种值。Type=simple
:默认值,执行ExecStart
指定的命令,启动主进程Type=forking
:以 fork 方式从父进程创建子进程,创建后父进程会立即退出Type=oneshot
:一次性进程,Systemd 会等当前服务退出,再继续往下执行Type=dbus
:当前服务通过 D-Bus 启动Type=notify
:当前服务启动完毕,会通知Systemd
,再继续往下执行Type=idle
:若有其他任务执行完毕,当前服务才会运行ExecStart
:启动当前服务的命令ExecStartPre
:启动当前服务之前执行的命令ExecStartPost
:启动当前服务之后执行的命令ExecReload
:重启当前服务时执行的命令ExecStop
:停止当前服务时执行的命令ExecStopPost
:停止当其服务之后执行的命令RestartSec
:自动重启当前服务间隔的秒数Restart
:定义何种情况 Systemd 会自动重启当前服务,可能的值包括always
(总是重启)、on-success
、on-failure
、on-abnormal
、on-abort
、on-watchdog
TimeoutSec
:定义 Systemd 停止当前服务之前等待的秒数Environment
:指定环境变量Unit 配置文件的完整字段清单,请参考官方文档。
启动计算机的时候,需要启动大量的 Unit。如果每一次启动,都要一一写明本次启动需要哪些 Unit,显然非常不方便。Systemd 的解决方案就是 Target。
简单说,Target 就是一个 Unit 组,包含许多相关的 Unit 。启动某个 Target 的时候,Systemd 就会启动里面所有的 Unit。从这个意义上说,Target 这个概念类似于 “状态点”,启动某个 Target 就好比启动到某种状态。
传统的init
启动模式里面,有 RunLevel 的概念,跟 Target 的作用很类似。不同的是,RunLevel 是互斥的,不可能多个 RunLevel 同时启动,但是多个 Target 可以同时启动。
1 | # 查看当前系统的所有 Target |
Target 与 传统 RunLevel 的对应关系如下。
1 | Traditional runlevel New target name Symbolically linked to... |
它与init
进程的主要差别如下。
(1)默认的 RunLevel(在/etc/inittab
文件设置)现在被默认的 Target 取代,位置是/etc/systemd/system/default.target
,通常符号链接到graphical.target
(图形界面)或者multi-user.target
(多用户命令行)。
(2)启动脚本的位置,以前是/etc/init.d
目录,符号链接到不同的 RunLevel 目录 (比如/etc/rc3.d
、/etc/rc5.d
等),现在则存放在/lib/systemd/system
和/etc/systemd/system
目录。
(3)配置文件的位置,以前init
进程的配置文件是/etc/inittab
,各种服务的配置文件存放在/etc/sysconfig
目录。现在的配置文件主要存放在/lib/systemd
目录,在/etc/systemd
目录里面的修改可以覆盖原始设置。
Systemd 统一管理所有 Unit 的启动日志。带来的好处就是,可以只用journalctl
一个命令,查看所有日志(内核日志和应用日志)。日志的配置文件是/etc/systemd/journald.conf
。
journalctl
功能强大,用法非常多。
1 | # 查看所有日志(默认情况下 ,只保存本次启动的日志) |
现如今,前端开发的同学已经离不开 npm
这个包管理工具,其优秀的包版本管理机制承载了整个繁荣发展的NodeJS
社区,理解其内部机制非常有利于加深我们对模块开发的理解、各项前端工程化的配置以加快我们排查问题(相信不少同学收到过各种依赖问题的困扰)的速度。
本文从三个角度:package.json
、版本管理、依赖安装结合具体实例对 npm
的包管理机制进行了详细分析。
在 Node.js
中,模块是一个库或框架,也是一个 Node.js
项目。Node.js
项目遵循模块化的架构,当我们创建了一个 Node.js
项目,意味着创建了一个模块,这个模块必须有一个描述文件,即 package.json
。它是我们最常见的配置文件,但是它里面的配置你真的有详细了解过吗?配置一个合理的 package.json
文件直接决定着我们项目的质量,所以首先带大家分析下 package.json
的各项详细配置。
package.json
中有非常多的属性,其中必须填写的只有两个:name
和 version
,这两个属性组成一个 npm
模块的唯一标识。
name
即模块名称,其命名时需要遵循官方的一些规范和建议:
包名会成为模块url
、命令行中的一个参数或者一个文件夹名称,任何非url
安全的字符在包名中都不能使用,可以使用 validate-npm-package-name
包来检测包名是否合法。
语义化包名,可以帮助开发者更快的找到需要的包,并且避免意外获取错误的包。
若包名称中存在一些符号,将符号去除后不得与现有包名重复
例如:由于react-native
已经存在,react.native
、reactnative
都不可以再创建。
例如:用户名 conard
,那么作用域为 @conard
,发布的包可以是@conard/react
。
name
是一个包的唯一标识,不得和其他包名重复,我们可以执行 npm view packageName
查看包是否被占用,并可以查看它的一些基本信息:
若包名称从未被使用过,则会抛出 404
错误:
另外,你还可以去 https://www.npmjs.com/
查询更多更详细的包信息。
1 | { |
description
用于添加模块的的描述信息,方便别人了解你的模块。
keywords
用于给你的模块添加关键字。
当然,他们的还有一个非常重要的作用,就是利于模块检索。当你使用 npm search
检索模块时,会到description
和 keywords
中进行匹配。写好 description
和 keywords
有利于你的模块获得更多更精准的曝光:
描述开发人员的字段有两个:author
和 contributors
, author
指包的主要作者,一个 author
对应一个人。 contributors
指贡献者信息,一个 contributors
对应多个贡献者,值为数组,对人的描述可以是一个字符串,也可以是下面的结构:
1 | { |
1 | { |
homepage
用于指定该模块的主页。
repository
用于指定模块的代码仓库。
bugs
指定一个地址或者一个邮箱,对你的模块存在疑问的人可以到这里提出问题。
我们的项目可能依赖一个或多个外部依赖包,根据依赖包的不同用途,我们将他们配置在下面几个属性下:dependencies、devDependencies、peerDependencies、bundledDependencies、optionalDependencies
。
在介绍几种依赖配置之前,首先我们来看一下依赖的配置规则,你看到的依赖包配置可能是下面这样的:
1 | "dependencies": { |
依赖配置遵循下面几种配置规则:
依赖包名称:VERSION
VERSION
是一个遵循SemVer
规范的版本号配置,npm install
时将到npm服务器下载符合指定版本范围的包。依赖包名称:DWONLOAD_URL
DWONLOAD_URL
是一个可下载的tarball
压缩包地址,模块安装时会将这个.tar
下载并安装到本地。依赖包名称:LOCAL_PATH
LOCAL_PATH
是一个本地的依赖包路径,例如 file:../pacakges/pkgName
。适用于你在本地测试一个npm
包,不应该将这种方法应用于线上。依赖包名称:GITHUB_URL
GITHUB_URL
即 github
的 username/modulename
的写法,例如:ant-design/ant-design
,你还可以在后面指定 tag
和 commit id
。依赖包名称:GIT_URL
GIT_URL
即我们平时clone代码库的 git url
,其遵循以下形式:1 | <protocol>://[<user>[:<password>]@]<hostname>[:<port>][:][/]<path>[#<commit-ish> | #semver:<semver>] |
其中 protocal
可以是以下几种形式:
git://github.com/user/project.git#commit-ish
git+ssh://user@hostname:project.git#commit-ish
git+ssh://user@hostname/project.git#commit-ish
git+http://user@hostname/project/blah.git#commit-ish
git+https://user@hostname/project/blah.git#commit-ish
dependencies
指定了项目运行所依赖的模块,开发环境和生产环境的依赖模块都可以配置到这里,例如
1 | "dependencies": { |
有一些包有可能你只是在开发环境中用到,例如你用于检测代码规范的 eslint
,用于进行测试的 jest
,用户使用你的包时即使不安装这些依赖也可以正常运行,反而安装他们会耗费更多的时间和资源,所以你可以把这些依赖添加到 devDependencies
中,这些依赖照样会在你本地进行 npm install
时被安装和管理,但是不会被安装到生产环境:
1 | "devDependencies": { |
peerDependencies
用于指定你正在开发的模块所依赖的版本以及用户安装的依赖包版本的兼容性。
上面的说法可能有点太抽象,我们直接拿 ant-design
来举个例子,ant-design
的 package.json
中有如下配置:1
2
3
4"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
当你正在开发一个系统,使用了 ant-design
,所以也肯定需要依赖 React
。同时, ant-design
也是需要依赖 React
的,它要保持稳定运行所需要的 React
版本是16.0.0
,而你开发时依赖的 React
版本是 15.x
:
这时,ant-design
要使用 React
,并将其引入:
1 | import * as React from 'react'; |
这时取到的是宿主环境也就是你的环境中的 React
版本,这就可能造成一些问题。在 npm2
的时候,指定上面的 peerDependencies
将意味着强制宿主环境安装 react@>=16.0.0和react-dom@>=16.0.0
的版本。
npm3
以后不会再要求 peerDependencies
所指定的依赖包被强制安装,相反 npm3
会在安装结束后检查本次安装是否正确,如果不正确会给用户打印警告提示。
1 | "dependencies": { |
例如,我在项目中依赖了 antd
的最新版本,然后依赖了 react
的 15.6.0
版本,在进行依赖安装时将给出以下警告:
某些场景下,依赖包可能不是强依赖的,这个依赖包的功能可有可无,当这个依赖包无法被获取到时,你希望 npm install
继续运行,而不会导致失败,你可以将这个依赖放到 optionalDependencies
中,注意 optionalDependencies
中的配置将会覆盖掉 dependencies
所以只需在一个地方进行配置。
当然,引用 optionalDependencies
中安装的依赖时,一定要做好异常处理,否则在模块获取不到时会导致报错。
和以上几个不同,bundledDependencies
的值是一个数组,数组里可以指定一些模块,这些模块将在这个包发布时被一起打包。
1 | "bundledDependencies": ["package1" , "package2"] |
1 | { |
license
字段用于指定软件的开源协议,开源协议里面详尽表述了其他人获得你代码后拥有的权利,可以对你的的代码进行何种操作,何种操作又是被禁止的。同一款协议有很多变种,协议太宽松会导致作者丧失对作品的很多权利,太严格又不便于使用者使用及作品的传播,所以开源作者要考虑自己对作品想保留哪些权利,放开哪些限制。
软件协议可分为开源和商业两类,对于商业协议,或者叫法律声明、许可协议,每个软件会有自己的一套行文,由软件作者或专门律师撰写,对于大多数人来说不必自己花时间和精力去写繁长的许可协议,选择一份广为流传的开源协议就是个不错的选择。
以下就是几种主流的开源协议:
MIT
:只要用户在项目副本中包含了版权声明和许可声明,他们就可以拿你的代码做任何想做的事情,你也无需承担任何责任。Apache
:类似于 MIT
,同时还包含了贡献者向用户提供专利授权相关的条款。GPL
:修改项目代码的用户再次分发源码或二进制代码时,必须公布他的相关修改。如果你对开源协议有更详细的要求,可以到 https://choosealicense.com/ 获取更详细的开源协议说明。
1 | { |
main
属性可以指定程序的主入口文件,例如,上面 antd
指定的模块入口 lib/index.js
,当我们在代码用引入 antd
时:import { notification } from 'antd';
实际上引入的就是 lib/index.js
中暴露出去的模块。
当你的模块是一个命令行工具时,你需要为命令行工具指定一个入口,即指定你的命令名称和本地可指定文件的对应关系。如果是全局安装,npm 将会使用符号链接把可执行文件链接到 /usr/local/bin
,如果是本地安装,会链接到 ./node_modules/.bin/
。
1 | { |
例如上面的配置:当你的包安装到全局时:npm
会在 /usr/local/bin
下创建一个以 conard
为名字的软链接,指向全局安装下来的 conard
包下面的 "./bin/index.js"
。这时你在命令行执行 conard
则会调用链接到的这个js文件。
这里不再过多展开,更多内容在我后续的命令行工具文章中会进行详细讲解。
1 | { |
files
属性用于描述你 npm publish
后推送到 npm
服务器的文件列表,如果指定文件夹,则文件夹内的所有内容都会包含进来。我们可以看到下载后的包是下面的目录结构:
另外,你还可以通过配置一个
.npmignore
文件来排除一些文件, 防止大量的垃圾文件推送到npm
, 规则上和你用的.gitignore
是一样的。.gitignore
文件也可以充当.npmignore
文件。
man
命令是 Linux
下的帮助指令,通过 man
指令可以查看 Linux
中的指令帮助、配置文件帮助和编程帮助等信息。
如果你的 node.js
模块是一个全局的命令行工具,在 package.json
通过 man
属性可以指定 man
命令查找的文档地址。
man
文件必须以数字结尾,或者如果被压缩了,以 .gz
结尾。数字表示文件将被安装到 man
的哪个部分。如果 man
文件名称不是以模块名称开头的,安装的时候会给加上模块名称前缀。
例如下面这段配置:
1 | { |
在命令行输入 man npm-audit
:
一个 node.js
模块是基于 CommonJS
模块化规范实现的,严格按照 CommonJS
规范,模块目录下除了必须包含包描述文件 package.json
以外,还需要包含以下目录:
bin
:存放可执行二进制文件的目录lib
:存放js代码的目录doc
:存放文档的目录test
:存放单元测试用例代码的目录在模块目录中你可能没有严格按照以上结构组织或命名,你可以通过在 package.json
指定 directories
属性来指定你的目录结构和上述的规范结构的对应情况。除此之外 directories
属性暂时没有其他应用。
1 | { |
不过官方文档表示,虽然目前这个属性没有什么重要作用,未来可能会整出一些花样出来,例如:doc 中存放的 markdown 文件、example 中存放的示例文件,可能会友好的展示出来。
1 | { |
scripts
用于配置一些脚本命令的缩写,各个脚本可以互相组合使用,这些脚本可以覆盖整个项目的生命周期,配置后可使用 npm run command
进行调用。如果是 npm
关键字,则可以直接调用。例如,上面的配置制定了以下几个命令:npm run test
、npm run dist
、npm run compile
、npm run build
。
config
字段用于配置脚本中使用的环境变量,例如下面的配置,可以在脚本中使用process.env.npm_package_config_port
进行获取。
1 | { |
如果你的 node.js
模块主要用于安装到全局的命令行工具,那么该值设置为 true
,当用户将该模块安装到本地时,将得到一个警告。这个配置并不会阻止用户安装,而是会提示用户防止错误使用而引发一些问题。
如果将 private
属性设置为 true
,npm将拒绝发布它,这是为了防止一个私有模块被无意间发布出去。
1 | "publishConfig": { |
发布模块时更详细的配置,例如你可以配置只发布某个 tag
、配置发布到的私有 npm
源。更详细的配置可以参考 npm-config
假如你开发了一个模块,只能跑在 darwin
系统下,你需要保证 windows
用户不会安装到你的模块,从而避免发生不必要的错误。
使用 os
属性可以帮助你完成以上的需求,你可以指定你的模块只能被安装在某些系统下,或者指定一个不能安装的系统黑名单:
1 | "os" : [ "darwin", "linux" ] |
例如,我把一个测试模块指定一个系统黑名单:"os" : [ "!darwin" ]
,当我在此系统下安装它时会爆出如下错误:
在node环境下可以使用 process.platform 来判断操作系统。
和上面的 os
类似,我们可以用 cpu
属性更精准的限制用户安装环境:
1 | "cpu" : [ "x64", "ia32" ] |
在node环境下可以使用 process.arch 来判断 cpu 架构。
Nodejs
成功离不开 npm
优秀的依赖管理系统。在介绍整个依赖系统之前,必须要了解 npm
如何管理依赖包的版本,本章将介绍 npm包
的版本发布规范、如何管理各种依赖包的版本以及一些关于包版本的最佳实践。
你可以执行 npm view package version
查看某个 package
的最新版本。
执行 npm view conard versions
查看某个 package
在npm服务器上所有发布过的版本。
执行 npm ls
可查看当前仓库依赖树上所有包的版本信息。
npm包
中的模块版本都需要遵循 SemVer
规范——由 Github
起草的一个具有指导意义的,统一的版本号表示规则。实际上就是 Semantic Version
(语义化版本)的缩写。
SemVer规范官网: https://semver.org/
SemVer
规范的标准版本号采用 X.Y.Z
的格式,其中 X、Y 和 Z 为非负的整数,且禁止在数字前方补零。X 是主版本号、Y 是次版本号、而 Z 为修订号。每个元素必须以数值来递增。
major
):当你做了不兼容的API 修改minor
):当你做了向下兼容的功能性新增patch
):当你做了向下兼容的问题修正。 例如:1.9.1 -> 1.10.0 -> 1.11.0
当某个版本改动比较大、并非稳定而且可能无法满足预期的兼容性需求时,你可能要先发布一个先行版本。
先行版本号可以加到“主版本号.次版本号.修订号”的后面,先加上一个连接号再加上一连串以句点分隔的标识符和版本编译信息。
alpha
): beta
): rc
: 即 Release candiate
下面我们来看看 React
的历史版本:
可见是严格按照 SemVer
规范来发版的:
主版本号.次版本号.修订号
格式命名16.8.0 -> 16.8.1 -> 16.8.2
alpha
、beta
、rc
等先行版本在修改 npm
包某些功能后通常需要发布一个新的版本,我们通常的做法是直接去修改 package.json
到指定版本。如果操作失误,很容易造成版本号混乱,我们可以借助符合 Semver
规范的命令来完成这一操作:
npm version patch
: 升级修订版本号npm version minor
: 升级次版本号npm version major
: 升级主版本号在开发中肯定少不了对一些版本号的操作,如果这些版本号符合 SemVer
规范 ,我们可以借助用于操作版本的npm包semver
来帮助我们进行比较版本大小、提取版本信息等操作。
Npm 也使用了该工具来处理版本相关的工作。
1 | npm install semver |
比较版本号大小
1 | semver.gt('1.2.3', '9.8.7') // false |
判断版本号是否符合规范,返回解析后符合规范的版本号。
1 | semver.valid('1.2.3') // '1.2.3' |
1 | semver.valid(semver.coerce('v2')) // '2.0.0' |
1 | semver.clean(' =v1.2.3 ') // '1.2.3' |
以上都是semver最常见的用法,更多详细内容可以查看 semver文档:https://github.com/npm/node-semver
我们经常看到,在 package.json
中各种依赖的不同写法:
1 | "dependencies": { |
前面三个很容易理解:
"signale": "1.4.0"
: 固定版本号"figlet": "*"
: 任意版本(>=0.0.0
)"react": "16.x"
: 匹配主要版本(>=16.0.0 <17.0.0
)"react": "16.3.x"
: 匹配主要版本和次要版本(>=16.3.0 <16.4.0
)再来看看后面两个,版本号中引用了 ~
和 ^
符号:
~
: 当安装依赖时获取到有新版本时,安装到 x.y.z
中 z
的最新的版本。即保持主版本号、次版本号不变的情况下,保持修订号的最新版本。^
: 当安装依赖时获取到有新版本时,安装到 x.y.z
中 y
和 z
都为最新版本。 即保持主版本号不变的情况下,保持次版本号、修订版本号为最新版本。在 package.json
文件中最常见的应该是 "yargs": "^14.0.0"
这种格式的 依赖, 因为我们在使用 npm install package
安装包时,npm
默认安装当前最新版本,然后在所安装的版本号前加 ^
号。
注意,当主版本号为 0
的情况,会被认为是一个不稳定版本,情况与上面不同:
0
: ^0.0.z
、~0.0.z
都被当作固定版本,安装依赖时均不会发生变化。0
: ^0.y.z
表现和 ~0.y.z
相同,只保持修订号为最新版本。1.0.0 的版本号用于界定公共 API。当你的软件发布到了正式环境,或者有稳定的API时,就可以发布1.0.0版本了。所以,当你决定对外部发布一个正式版本的npm包时,把它的版本标为1.0.0。
实际开发中,经常会因为各种依赖不一致而产生奇怪的问题,或者在某些场景下,我们不希望依赖被更新,建议在开发中使用 package-lock.json
。
锁定依赖版本意味着在我们不手动执行更新的情况下,每次安装依赖都会安装固定版本。保证整个团队使用版本号一致的依赖。
每次安装固定版本,无需计算依赖版本范围,大部分场景下能大大加速依赖安装时间。
使用 package-lock.json 要确保npm的版本在5.6以上,因为在5.0 - 5.6中间,对 package-lock.json的处理逻辑进行过几次更新,5.6版本后处理逻辑逐渐稳定。
关于 package-lock.json
详细的结构,我们会在后面的章节进行解析。
我们的目的是保证团队中使用的依赖一致或者稳定,而不是永远不去更新这些依赖。实际开发场景下,我们虽然不需要每次都去安装新的版本,仍然需要定时去升级依赖版本,来让我们享受依赖包升级带来的问题修复、性能提升、新特性更新。
使用 npm outdated
可以帮助我们列出有哪些还没有升级到最新版本的依赖:
执行 npm update
会升级所有的红色依赖。
1.0.0
。主版本号.次版本号.修订号
格式命名alpha、beta、rc
等先行版本npm
包,此时建议把版本前缀改为~
,如果锁定的话每次子依赖更新都要对主工程的依赖进行升级,非常繁琐,如果对子依赖完全信任,直接开启^
每次升级到最新版本。docker
线上,本地还在进行子依赖开发和升级,在docker
版本发布前要锁定所有依赖版本,确保本地子依赖发布后线上不会出问题。npm
的版本在5.6
以上,确保默认开启 package-lock.json
文件。npm inatall
后,将 package-lock.json
提交到远程仓库。不要直接提交 node_modules
到远程仓库。npm update
升级依赖,并提交 lock
文件确保其他成员同步更新依赖,不要手动更改 lock
文件。package.json
文件的依赖版本,执行 npm install
npm install package@version
(改动package.json
不会对依赖进行降级)lock
文件npm install
大概会经过上面的几个流程,这一章就来讲一讲各个流程的实现细节、发展以及为何要这样实现。
我们都知道,执行 npm install
后,依赖包被安装到了 node_modules
,下面我们来具体了解下,npm
将依赖包安装到 node_modules
的具体机制是什么。
在 npm
的早期版本, npm
处理依赖的方式简单粗暴,以递归的形式,严格按照 package.json
结构以及子依赖包的 package.json
结构将依赖安装到他们各自的 node_modules
中。直到有子依赖包不在依赖其他模块。
举个例子,我们的模块 my-app
现在依赖了两个模块:buffer
、ignore
:
1 | { |
ignore
是一个纯 JS
模块,不依赖任何其他模块,而 buffer
又依赖了下面两个模块:base64-js
、 ieee754
。
1 | { |
那么,执行 npm install
后,得到的 node_modules
中模块目录结构就是下面这样的:
这样的方式优点很明显, node_modules
的结构和 package.json
结构一一对应,层级结构明显,并且保证了每次安装目录结构都是相同的。
但是,试想一下,如果你依赖的模块非常之多,你的 node_modules
将非常庞大,嵌套层级非常之深:
Windows
系统中,文件路径最大长度为260个字符,嵌套层级过深可能导致不可预知的问题。为了解决以上问题,NPM
在 3.x
版本做了一次较大更新。其将早期的嵌套结构改为扁平结构:
node_modules
根目录。还是上面的依赖结构,我们在执行 npm install
后将得到下面的目录结构:
此时我们若在模块中又依赖了 base64-js@1.0.1
版本:
1 | { |
node_modules
下安装该模块。此时,我们在执行 npm install
后将得到下面的目录结构:
对应的,如果我们在项目代码中引用了一个模块,模块查找流程如下:
node_modules
路径下搜素node_modules
路径下搜索node_modules
假设我们又依赖了一个包 buffer2@^5.4.3
,而它依赖了包 base64-js@1.0.3
,则此时的安装结构是下面这样的:
所以 npm 3.x
版本并未完全解决老版本的模块冗余问题,甚至还会带来新的问题。
试想一下,你的APP假设没有依赖 base64-js@1.0.1
版本,而你同时依赖了依赖不同 base64-js
版本的 buffer
和 buffer2
。由于在执行 npm install
的时候,按照 package.json
里依赖的顺序依次解析,则 buffer
和 buffer2
在 package.json
的放置顺序则决定了 node_modules
的依赖结构:
先依赖buffer2
:
先依赖buffer
:
另外,为了让开发者在安全的前提下使用最新的依赖包,我们在 package.json
通常只会锁定大版本,这意味着在某些依赖包小版本更新后,同样可能造成依赖结构的改动,依赖结构的不确定性可能会给程序带来不可预知的问题。
为了解决 npm install
的不确定性问题,在 npm 5.x
版本新增了 package-lock.json
文件,而安装方式还沿用了 npm 3.x
的扁平化的方式。
package-lock.json
的作用是锁定依赖结构,即只要你目录下有 package-lock.json
文件,那么你每次执行 npm install
后生成的 node_modules
目录结构一定是完全相同的。
例如,我们有如下的依赖结构:
1 | { |
在执行 npm install
后生成的 package-lock.json
如下:
1 | { |
我们来具体看看上面的结构:
最外面的两个属性 name
、version
同 package.json
中的 name
和 version
,用于描述当前包名称和版本。
dependencies
是一个对象,对象和 node_modules
中的包结构一一对应,对象的 key
为包名称,值为包的一些描述信息:
version
:包版本 —— 这个包当前安装在 node_modules
中的版本resolved
:包具体的安装来源integrity
:包 hash
值,基于 Subresource Integrity
来验证已安装的软件包是否被改动过、是否已失效requires
:对应子依赖的依赖,与子依赖的 package.json
中 dependencies
的依赖项相同。dependencies
:结构和外层的 dependencies
结构相同,存储安装在子依赖 node_modules
中的依赖包。这里注意,并不是所有的子依赖都有 dependencies
属性,只有子依赖的依赖和当前已安装在根目录的 node_modules
中的依赖冲突之后,才会有这个属性。
例如,回顾下上面的依赖关系:
我们在 my-app
中依赖的 base64-js@1.0.1
版本与 buffer
中依赖的 base64-js@^1.0.2
发生冲突,所以 base64-js@1.0.1
需要安装在 buffer
包的 node_modules
中,对应了 package-lock.json
中 buffer
的 dependencies
属性。这也对应了 npm
对依赖的扁平化处理方式。
所以,根据上面的分析, package-lock.json
文件 和 node_modules
目录结构是一一对应的,即项目目录下存在 package-lock.json
可以让每次安装生成的依赖目录结构保持相同。
另外,项目中使用了 package-lock.json
可以显著加速依赖安装时间。
我们使用 npm i --timing=true --loglevel=verbose
命令可以看到 npm install
的完整过程,下面我们来对比下使用 lock
文件和不使用 lock
文件的差别。在对比前先清理下npm
缓存。
不使用 lock
文件:
使用 lock
文件:
可见, package-lock.json
中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查询,然后直接进入文件完整性校验环节,减少了大量网络请求。
开发系统应用时,建议把 package-lock.json
文件提交到代码版本仓库,从而保证所有团队开发者以及 CI
环节可以在执行 npm install
时安装的依赖版本都是一致的。
在开发一个 npm
包 时,你的 npm
包 是需要被其他仓库依赖的,由于上面我们讲到的扁平安装机制,如果你锁定了依赖包版本,你的依赖包就不能和其他依赖包共享同一 semver
范围内的依赖包,这样会造成不必要的冗余。所以我们不应该把package-lock.json
文件发布出去( npm
默认也不会把 package-lock.json
文件发布出去)。
在执行 npm install
或 npm update
命令下载依赖后,除了将依赖包安装在node_modules
目录下外,还会在本地的缓存目录缓存一份。
通过 npm config get cache
命令可以查询到:在 Linux
或 Mac
默认是用户主目录下的 .npm/_cacache
目录。
在这个目录下又存在两个目录:content-v2
、index-v5
,content-v2
目录用于存储 tar
包的缓存,而index-v5
目录用于存储tar
包的 hash
。
npm 在执行安装时,可以根据 package-lock.json
中存储的 integrity、version、name
生成一个唯一的 key
对应到 index-v5
目录下的缓存记录,从而找到 tar
包的 hash
,然后根据 hash
再去找缓存的 tar
包直接使用。
我们可以找一个包在缓存目录下搜索测试一下,在 index-v5
搜索一下包路径:
1 | grep "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz" -r index-v5 |
然后我们将json格式化:
1 | { |
上面的 _shasum
属性 6926d1b194fbc737b8eed513756de2fcda7ea408
即为 tar
包的 hash
, hash
的前几位 6926
即为缓存的前两层目录,我们进去这个目录果然找到的压缩后的依赖包:
以上的缓存策略是从 npm v5 版本开始的,在 npm v5 版本之前,每个缓存的模块在 ~/.npm 文件夹中以模块名的形式直接存储,储存结构是{cache}/{name}/{version}。
npm
提供了几个命令来管理缓存数据:
npm cache add
:官方解释说这个命令主要是 npm
内部使用,但是也可以用来手动给一个指定的 package 添加缓存。npm cache clean
:删除缓存目录下的所有数据,为了保证缓存数据的完整性,需要加上 --force
参数。npm cache verify
:验证缓存数据的有效性和完整性,清理垃圾数据。基于缓存数据,npm 提供了离线安装模式,分别有以下几种:
--prefer-offline
: 优先使用缓存数据,如果没有匹配的缓存数据,则从远程仓库下载。--prefer-online
: 优先使用网络数据,如果网络数据请求失败,再去请求缓存数据,这种模式可以及时获取最新的模块。--offline
: 不请求网络,直接使用缓存数据,一旦缓存数据不存在,则安装失败。上面我们多次提到了文件完整性,那么什么是文件完整性校验呢?
在下载依赖包之前,我们一般就能拿到 npm
对该依赖包计算的 hash
值,例如我们执行 npm info
命令,紧跟 tarball
(下载链接) 的就是 shasum
(hash
) :
用户下载依赖包到本地后,需要确定在下载过程中没有出现错误,所以在下载完成之后需要在本地在计算一次文件的 hash
值,如果两个 hash
值是相同的,则确保下载的依赖是完整的,如果不同,则进行重新下载。
注意,下面作者在这整体流程的时候,说直接将包解压到 node_modules
。其实这样是不严谨的,当遇到需要安装nodejs-addons
的时候(对应包下面有binding.gyp
文件),并不仅是压缩,还会自动执行node-gyp rebuild
编译C/C++代码。关于nodejs-addons
的详细介绍可以看下面刨根问底之node-gyp
部分的介绍。
该部分的官方文档说明如下:
If there is a
binding.gyp
file in the root of your package and you have not defined aninstall
orpreinstall
script, npm will default theinstall
command to compile using node-gyp.
好了,我们再来整体总结下上面的流程:
.npmrc
文件:优先级为:项目级的 .npmrc
文件 > 用户级的 .npmrc
文件> 全局级的 .npmrc
文件 > npm 内置的 .npmrc
文件检查项目中有无 lock
文件。
无 lock
文件:
npm
远程仓库获取包信息package.json
构建依赖树,构建过程:node_modules
根目录。node_modules
下放置该模块。npm
远程仓库下载包npm
缓存目录node_modules
node_modules
node_modules
lock
文件有 lock
文件:
package.json
中的依赖版本是否和 package-lock.json
中的依赖有冲突。上面的过程简要描述了 npm install
的大概过程,这个过程还包含了一些其他的操作,例如执行你定义的一些生命周期函数,你可以执行 npm install package --timing=true --loglevel=verbose
来查看某个包具体的安装流程和细节。
yarn
是在 2016
年发布的,那时 npm
还处于 V3
时期,那时候还没有 package-lock.json
文件,就像上面我们提到的:不稳定性、安装速度慢等缺点经常会受到广大开发者吐槽。此时,yarn
诞生:
上面是官网提到的 yarn
的优点,在那个时候还是非常吸引人的。当然,后来 npm
也意识到了自己的问题,进行了很多次优化,在后面的优化(lock
文件、缓存、默认-s…)中,我们多多少少能看到 yarn
的影子,可见 yarn
的设计还是非常优秀的。
yarn
也是采用的是 npm v3
的扁平结构来管理依赖,安装依赖后默认会生成一个 yarn.lock
文件,还是上面的依赖关系,我们看看 yarn.lock
的结构:
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. |
可见其和 package-lock.json
文件还是比较类似的,还有一些区别就是:
package-lock.json
使用的是 json
格式,yarn.lock
使用的是一种自定义格式yarn.lock
中子依赖的版本号不是固定的,意味着单独又一个 yarn.lock
确定不了 node_modules
目录结构,还需要和 package.json
文件进行配合。而 package-lock.json
只需要一个文件即可确定。yarn
的缓策略看起来和 npm v5
之前的很像,每个缓存的模块被存放在独立的文件夹,文件夹名称包含了模块名称、版本号等信息。使用命令 yarn cache dir
可以查看缓存数据的目录:
yarn
默认使用prefer-online
模式,即优先使用网络数据,如果网络数据请求失败,再去请求缓存数据。
1 | // 创建项目 |
1 | 1.查看镜像源 |
使用npm install 模块名
来安装,你可以使用其简写npm i
。
无需为你要安装的每个模块都输入一遍 npm i 指令,像这样
1 | npm i gulp-pug |
你只需要输入一行命令即可一次性批量安装模块
1 | npm i gulp-pug gulp-debug gulp-sass |
如果安装的所有模块的前缀是相同的,则可以这样安装,无需输入完整模块名
1 | npm i gulp{-debug,-sass,-pug} |
如果你想安装一些包到生产环境依赖
下面,你通常是这样安装:
1 | npm i gulp --save-prod |
同理,开发环境下的依赖安装,你可以用 -D
代替 --save-dev
1 | npm i gulp --save-dev |
当你不带任何安装标志时
,npm 默认
将模块作为依赖项目添加到package.json
文件中。如果你想避免这样,你可以使用 no-save, 这样安装:
1 | npm i vue --no-save |
以 npm 安装echarts为例:
1 | npm install echarts |
node_modules
目录中不会修改 package.json
npm install
命令时,不会自动安装 echarts
1 | npm install echarts --save |
1 | npm install echarts --save-dev |
node_modules
目录中会在 package.json 的 devDependencies 属性下添加 echarts
npm install -production
或者注明 NODE_ENV 变量值为 production
时,不会自动安装
echarts 到 node_modules 目录中devDependencies
节点下的模块是我们在开发时
需要用的,比如项目中使用的 gulp ,压缩 css、js 的模块。这些模块在我们的项目部署后是不需要的,所以我们可以使用 -save-dev
的形式安装。
像 echarts 这些模块是项目运行必备的,应该安装在 dependencies 节点下,所以我们应该使用 --save
的形式安装。
使用 npm view xxx 或 npm v xxx 可以查看包信息,例如:
1 | npm v vue |
如果你想安装一个不是最新版本的安装包,你可以指定某个版本来安装,如:
1 | npm i vue@2.5.15 |
鉴于记住标签比记住版本数字容易多了,你可以使用用 npm v 命令来查到的版本信息列表里面的 dist-tag 来安装, 比如
1 | npm i vue@beta |
1 | // 更新全局包: |
如果你不想转到 package.json 文件并手动删除依赖包,则可以用以下方法删除:
1 | npm uninstall vue |
这个命令会删除 node_modules 文件夹及 package.json 中对应的包。当然,你也可以用 rm,un 或者 r 来达到相同的效果:
1 | npm rm vue |
如果由于某些原因,你只想从 node_modules 文件夹中删除安装包,但是想在 package.json 中保留其依赖项,那么你可以使用 no-save 标志,如:
1 | npm rm vue --no-save |
如果你想看一下你的项目依赖了哪些安装包,你可以这样看:
1 | npm ls |
这个命令会将你项目的依赖列举出来,并且各个安装包的依赖也会显示出来。如果你只想看本项目的依赖,你可以这样:
1 | npm ls --depth=0 |
这样打印出来的结果就是本项目的依赖,像这样:
1 | ├── jquery@3.3.1 |
当然,你也可以加上 g 来看看你全局安装的依赖包,如:
1 | npm ls -g -depth 0 |
大多数时候,你需要保持本地依赖的更新,你可以在项目目录下先查看一下安装包有没有版本更新,如:
1 | npm outdate |
这个命令将会列出所有你可能有更新的过时的安装包列表,如图:
你可以使用npm run tests
来执行测试用例,但是你可以更方便地用 npm test 或者 npm t 来执行。
我们可以通过打开 package.json 文件来查看有哪些可执行的脚本,但是我们还可以这样查看:
1 | npm run |
如果在 package.json 中有如下配置:
1 | "scripts": { |
那么执行这个命令之后,会显示以下信息:
1 | Lifecycle scripts included in npm: |
你可以使用这个命令来列出所有 NPM 环境的可用变量:
1 | npm run env | grep npm_ |
执行后,将会打印出这样的信息:
1 | npm_config_fetch_retry_maxtimeout=60000 |
这样变量的用处就是,可以在脚本中使用它们,还可以创建自己的变量。
你可以在 package.json 中添加新的 key 来创建自己的 npm 变量,可以是任何 key ,我更喜欢将所有的 npm 变量都放在一个 config 中,这样看起来比较清晰:
1 | "config": { |
你添加了之后,重新执行npm run env | grep npm_
,就能看到以下信息:
1 | npm_package_config_build_folder=./dist |
默认情况下,npm 会重命名你的变量,给其加上前缀npm_package
,并将其结构保留在 package.json 文件中,即变为config_build_folder
。
你可以看到可用变量的完整列表,如果你想使用这些变量中的任何值,就可以在 package.json 中使用了,如:
1 | "scripts": { |
当你执行 npm run build 的时候,实际执行的是这样:
1 | gulp build --dist ./dist |
在我们写 node addon 时,需要使用 node-gyp 命令行工具,大部分同学会用configue
生成配置文件,然后使用build
进行构建。但是 node-gyp 到底是什么?底层有什么呢?下面我们来刨根问底。
本文的线索是自底向上的讲解 node-gyp 的各层次依赖,主要有以下几个部分:
1 | 1. make |
层次结构如下图所示:
从源文件到可执行文件叫做编译(包括预编译、编译、链接),而 make 作为构建工具掌握着编译的过程,也就是如何去编译、文件编译的顺序等。
make 是最常用的构建工具,针对用户制定的构建规则(makefile)去执行响应的任务。make 会根据构建规则去查找依赖,决定编译顺序等。大致了解可参考 Make 命令教程
Makefile(makefile)中定义了 make 的构建规则,当然也可以自己指定规则文件。例如:
1 | $ make -f rules.txt |
Makefile 由一条条的规则组成,每条规则由 target(目标)、source(前置条件 / 依赖)、command(指令) 三者组成。
形式如下:
1 | <target> : <prerequisites> |
当make target
时,主要做了以下几件事:
1 | 1.检查目标是否存在 |
以编译一个 C++ 文件的规则为例:
1 | hellomake: hellomake.c hellofunc.c |
当我们执行 make hellomake,会使用 gcc 编译器编译产出 hellomake。如果 make 不带有参数,则执行 makefile 中的第一条指令。
make 也允许我们定义一些纯指令(伪指令)去执行一些操作,相当于把上面的 target 写成指令名称,只不过在 command 中不生成文件,所以每次执行该规则时都会执行 command。为了和真实的目标文件做区分,make 中使用了.PHONY
关键字,关键字. PHONY 可以解决这问题,告诉 make 该目标是 “假的”(磁盘上其实没有这个目标文件)。例如
1 | .PHONY: clean |
由于 makefile 目标只能写一个,所以我们可以使用 all 来将多个目标组合起来。例如:
1 | all: executable1 executable2 |
一般情况下可以把 all 放在 makefile 的第一行,这样不带参数执行 make 就会找到 all。
make install 用来安装文件,它从 Makefile 中读取指令,安装到系统目录中。
上面提到了 make,似乎已经够了,如果我是一个开发者,我定义了 makefile,让使用者执行 make 编译就好了。但是不同平台的编译器、动态链接库的路径都有可能不同,如果想让你的软件能够跨平台编译、运行,必须要保证能够在不同平台编译。如果使用上面的 Make 工具,就得为每一种标准写一次 Makefile,这是很繁琐并且容易出错的地方。
cmake 的出现就是为了解决上述问题,它首先允许开发者编写一种平台无关的 CMakeList.txt 文件来定制整个编译流程,cmake 会根据操作系统选择不同编译器,当然也可以在 CMakeList.txt 中去指定,执行 cmake 时会目标用户的平台和自定义的配置生成所需的 Makefile 或工程文件,如 Unix 的 Makefile、Windows 的 Visual Studio。
CMake 是一个跨平台的安装 (编译) 工具, 可以用简单的语句来描述所有平台的安装(编译过程)。他能够输出各种各样的 makefile 或者 project 文件,能测试编译器所支持的 C++ 特性,类似 UNIX 下的 automake。
在 linux 平台下使用 CMake 生成 Makefile 并编译的流程如下:
1 | 1.编写 CMake 配置文件 CMakeLists.txt 。 |
CMakeList.txt 中由面向过程的一条条指令组成,例如:
1 | # CMake 最低版本号要求 |
具体可参考 cmake 文档
Gyp 是一个类似 CMake 的项目生成工具, 用于管理你的源代码, 在 google code 主页上唯一的一句 slogan 是”GYP can Generate Your Projects.”。GYP 是由 Chromium 团队开发的跨平台自动化项目构建工具,Chromium 便是通过 GYP 进行项目构建管理。
首先看 GYP 与 cmake 类似,那为什要有 GYP 呢?GYP 和 cmake 有哪些相同点、不同点呢?
支持跨平台项目工程文件输出,Windows 平台默认是 Visual Studio,Linux 平台默认是 Makefile,Mac 平台默认是 Xcode,这个功能 CMake 也同样支持,只是缺少了 Xcode。
配置文件形式不同,GYP 的配置文件更像一个 “配置文件”,而 Cmake 的上述所言更像一个面向过程的一个脚本,也就是说在项目设置的层次上进行抽象;同时 GYP 支持交叉编译。
具体比较可参考 GYP vs. CMake
GYP 的配置文件以.gyp
结尾,一个典型的.gyp
文件如下所示:
1 | { |
variables
: 定义可以在文件其他地方访问的变量;
includes
: 将要被引入到该文件中的文件列表,通常是以.gypi结尾的文件
;
target_defaults
: 将作用域所有目标的默认配置;
targets
: 构建的目标列表,每个 target 中包含构建此目标的所有配置;
conditions
: 条件列表,会根据不同条件选择不同的配置项。在最顶级的配置中,通常是平台特定的目标配置。
具体可参考 GYP 文档
node-gyp 是一个跨平台的命令行工具,目的是编译 node addon 模块。
常用的命令有configure
和build
,configure
原理就是利用 gyp 生成不同的编译配置文件,build
则根据不同平台、不同构建配置进行编译。
我们分步骤看下 configure 的代码:
1 | findPython(python, function (err, found) { |
由于 GYP 是 python 写的,所以这里首先找当前系统下的 python,内部利用的是which
这个第三方库。
1 | function getNodeDir () { |
找到 node 所在目录,如果没有,则下载 node 压缩包并解压。
1 | function createBuildDir () { |
创建 build 目录,这里区分了是否有 vs,查找 vs 的方法是打开 powershell(windows),试图打开 vs。
1 | function createConfigFile (err, vsSetup) { |
这里创建config.gypi文件
,主要包含target_defaults
和variables
。
1 | // config = ['config.gypi'] |
这里主要是区分了不同平台,给 GYP 命令加入各种参数,其中-I
代表 include,最后执行 gyp 脚本生成构建配置文件,比如 unix 下生成 makefile。
build
比较简单,言简意赅就是就是区分不同平台,收集不同参数,利用不同编译工具进行编译。
1 | command = win ? 'msbuild' : makeCommand |
区分编译工具。
1 | function loadConfigGypi () { |
加载config.gypi
, 为构建收集一波参数。如果在 windows 下,收集build/*.sln
。
1 | function doBuild () { |
执行编译命令。
希望阅读完本篇文章能对你有如下帮助:
pacakge.json
中的各项详细配置从而对项目工程化配置有更进一步的见解npm
的版本管理机制,能合理配置依赖版本npm install
安装原理,能合理运用 npm
缓存、package-lock.json
从根本上来讲,Git是一个内容寻址的文件系统,其次才是一个版本控制系统。记住这点,对于理解Git的内部原理及其重要。所谓“内容寻址的文件系统”,意思是根据文件内容的hash码来定位文件。这就意味着同样内容的文件,在这个文件系统中会指向同一个位置,不会重复存储。
Git对象包含三种:数据对象、树对象、提交对象。Git文件系统的设计思路与linux文件系统相似,即将文件的内容与文件的属性分开存储,文件内容以“装满字节的袋子”存储在文件系统中,文件名、所有者、权限等文件属性信息则另外开辟区域进行存储。在Git中,数据对象相当于文件内容,树对象相当于文件目录树,提交对象则是对文件系统的快照。
下面的章节,会分别对每种对象进行说明。开始说明之前,先初始化一个Git文件系统:
1 | $ mkdir git-test |
接下来的操作都会在git-test
这个目录中进行。
数据对象是文件的内容,不包括文件名、权限等信息。Git会根据文件内容计算出一个hash值,以hash值作为文件索引存储在Git文件系统中。由于相同的文件内容的hash值是一样的,因此Git将同样内容的文件只会存储一次。git hash-object
可以用来计算文件内容的hash值,并将生成的数据对象存储到Git文件系统中:
1 | $ echo 'version 1' | git hash-object -w --stdin |
上面示例中,-w
表示将数据对象写入到Git文件系统中,如果不加这个选项,那么只计算文件的hash值而不写入;--stdin
表示从标准输入中获取文件内容,当然也可以指定一个文件路径代替此选项。
上面讲数据对象写入到Git文件系统中,那如何读取数据对象呢?git cat-file
可以用来实现所有Git对象的读取,包括数据对象、树对象、提交对象的查看:
1 | $ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 |
上面示例中,-p
表示查看Git对象的内容,-t
表示查看Git对象的类型。
通过这一节,我们能够对Git文件系统中的数据对象进行读写。但是,我们需要记住每一个数据对象的hash值,才能访问到Git文件系统中的任意数据对象,这显然是不现实的。数据对象只是解决了文件内容存储的问题,而文件名的存储则需要通过下一节的树对象来解决。
树对象是文件目录树,记录了文件获取目录的名称、类型、模式信息。使用git update-index
可以为数据对象指定名称和模式,然后使用git write-tree
将树对象写入到Git文件系统中:
1 | $ git update-index --add --cacheinfo 100644 83baae61804e65cc73a7201a7252750c76066a30 test.txt |
--add
表示新增文件名,如果第一次添加某一文件名,必须使用此选项;--cacheinfo <mode> <object> <path>
是要添加的数据对象的模式、hash值和路径,<path>
意味着为数据对象不仅可以指定单纯的文件名,也可以使用路径。另外要注意的是,使用git update-index
添加完文件后,一定要使用git write-tree
写入到Git文件系统中,否则只会存在于index区域。
树对象仍然可以使用git cat-file
查看:
1 | $ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
上面表示这个树对象只有test.txt
这个文件,接下来我们将version 2
的数据对象指定为test.txt
,并添加一个新文件new.txt
:
1 | $ git update-index --cacheinfo 100644 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt |
查看树对象0155eb
,可以发现这个树对象有两个文件了:
1 | $ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341 |
我们甚至可以使用git read-tree
,将已添加的树对象读取出来,作为当前树的子树:
1 | $ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
--prefix
表示把子树对象放到哪个目录下。查看树对象,可以发现当前树对象有一个文件夹和两个文件:
1 | $ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614 |
最终,整个树对象的结构如下图:
树对象解决了文件名的问题,而且,由于我们是分阶段提交树对象的,树对象可以看做是开发阶段源代码目录树的一次次快照,因此我们可以是用树对象作为源代码版本管理。但是,这里仍然有问题需要解决,即我们需要记住每个树对象的hash值,才能找到个阶段的源代码文件目录树。在源代码版本控制中,我们还需要知道谁提交了代码、什么时候提交的、提交的说明信息等,接下来的提交对象就是为了解决这个问题的。
提交对象是用来保存提交的作者、时间、说明这些信息的,可以使用git commit-tree
来将提交对象写入到Git文件系统中:
1 | $ echo 'first commit' | git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
上面commit-tree
除了要指定提交的树对象,也要提供提交说明,至于提交的作者和时间,则是根据环境变量自动生成,并不需要指定。这里需要提醒一点的是,读者在测试时,得到的提交对象hash值一般和这里不一样,这是因为提交的作者和时间是因人而异的。
提交对象的查看,也是使用git cat-file
:
1 | $ git cat-file -p db1d6f137952f2b24e3c85724ebd7528587a067a |
上面是属于首次提交,那么接下来的提交还需要指定使用-p
指定父提交对象,这样代码版本才能成为一条时间线:
1 | $ echo 'second commit' | git commit-tree 0155eb4229851634a0f03eb265b69f5a2d56f341 -p db1d6f137952f2b24e3c85724ebd7528587a067a |
使用git cat-file
查看一下新的提交对象,可以看到相比于第一次提交,多了parent
部分:
1 | $ git cat-file -p d4d2c6cffb408d978cb6f1eb6cfc70e977378a5c |
最后,我们再将树对象3c4e9c
提交:
1 | $ echo 'third commit' | git commit-tree 3c4e9cd789d88d8d89c1073707c3585e41b0e614 -p d4d2c6cffb408d978cb6f1eb6cfc70e977378a5c |
使用git log
可以查看整个提交历史:
1 | $ git log --stat 3ac728ac62f0a7b5ac201fd3ed1f69165df8be31 |
最终的提交对象的结构如下图:
Git中的数据对象解决了数据存储的问题,树对象解决了文件名存储问题,提交对象解决了提交信息的存储问题。另外,对于git中的某个文件,一般来说,每次修改之后commit,都会新建一个数据对象进行存储(可以看后面关于.git/objects/pack
文件夹的介绍)。
从Git设计中可以看出,Linus对一个源代码版本控制系统做了很好的抽象和解耦,每种对象解决的问题都很明确,相比于使用一种数据结构,无疑更灵活和更易维护。每种Git对象都有一个hash值,这个值是怎么计算出来的?Git的各种对象是如何存储的?我们继续看下一节。
Git中的数据对象、树对象和提交对象的hash方法原理是一样的,可以描述为:
1 | header = "<type> " + content.length + "\0" |
上面公式表示,Git在计算对象hash时,首先会在对象头部添加一个header
。这个header
由3部分组成:第一部分表示对象的类型,可以取值blob
、tree
、commit
以分别表示数据对象、树对象、提交对象;第二部分是数据的字节长度;第三部分是一个空字节,用来将header
和content
分隔开。将header
添加到content
头部之后,使用sha1
算法计算出一个40位的hash值。
在手动计算Git对象的hash时,有两点需要注意:
header
中第二部分关于数据长度的计算,一定是字节的长度而不是字符串的长度;
header + content
的操作并不是字符串级别的拼接,而是二进制级别的拼接。
各种Git对象的hash方法相同,不同的在于:
头部类型不同,数据对象是blob
,树对象是tree
,提交对象是commit
;
数据内容不同,数据对象的内容可以是任意内容,而树对象和提交对象的内容有固定的格式。
接下来分别讲数据对象、树对象和提交对象的具体的hash方法。
数据对象的格式如下:
1 | blob <content length><NULL><content> |
从上一节中我们知道,使用git hash-object
可以计算出一个40位的hash值,例如:
1 | $ echo -n "what is up, doc?" | git hash-object --stdin |
注意,上面在echo
后面使用了-n
选项,用来阻止自动在字符串末尾添加换行符,否则会导致实际传给git hash-object
是what is up, doc?\n
,而不是我们直观认为的what is up, doc?
。
为验证前面提到的Git对象hash方法,我们使用openssl sha1
来手动计算what is up, doc?
的hash值:
1 | $ echo -n "blob 16\0what is up, doc?" | openssl sha1 |
可以发现,手动计算出的hash值与git hash-object
计算出来的一模一样。
在Git对象hash方法的注意事项中,提到header
中第二部分关于数据长度的计算,一定是字节的长度而不是字符串的长度。由于what is up, doc?
只有英文字符,在UTF8中恰好字符的长度和字节的长度都等于16,很容易将这个长度误解为字符的长度。假设我们以中文
来试验:
1 | $ echo -n "中文" | git hash-object --stdin |
我们可以看到,git hash-object
和openssl sha1
计算出来的hash值根本不一样。这是因为中文
两个字符作为UTF格式存储后的字符长度不是2,具体是多少呢?可以使用wc
来计算:
1 | $ echo -n "中文" | wc -c |
中文
字符串的字节长度是6,重新手动计算发现得出的hash值就能对应上了:
1 | $ echo -n "blob 6\0中文" | openssl sha1 |
树对象的内容格式如下:
1 | tree <content length><NUL><file mode> <filename><NUL><item sha>... |
需要注意的是,<item sha>
部分是二进制形式的sha1码,而不是十六进制形式的sha1码。
我们从上一节摘出一个树对象做实验,其内容如下:
1 | $ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
我们首先使用xxd
把83baae61804e65cc73a7201a7252750c76066a30
转换成为二进制形式,并将结果保存为sha1.txt
以方便后面做追加操作:
1 | $ echo -n "83baae61804e65cc73a7201a7252750c76066a30" | xxd -r -p > sha1.txt |
接下来构造content部分,并保存至文件content.txt
:
1 | $ echo -n "100644 test.txt\0" | cat - sha1.txt > content.txt |
计算content的长度:
1 | $ cat content.txt | wc -c |
那么最终该树对象的内容为:
1 | $ echo -n "tree 36\0" | cat - content.txt |
最后使用openssl sha1
计算hash值,可以发现和实验的hash值是一样的:
1 | $ echo -n "tree 36\0" | cat - content.txt | openssl sha1 |
提交对象的格式如下:
1 | commit <content length><NUL>tree <tree sha> |
我们从上一节摘出一个提交对象做实验,其内容如下:
1 | $ echo 'first commit' | git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
这里需要注意的是,由于echo 'first commit'
没有添加-n
选项,因此实际的提交信息是first commit\n
。使用wc
计算出提交内容的字节数:
1 | $ echo -n "tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
那么,这个提交对象的header
就是commit 163\0
,手动把头部添加到提交内容中:
1 | commit 163\0tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
使用openssl sha1
计算这个上面内容的hash值:
1 | $ echo -n "commit 163\0tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
可以看见,与实验的hash值是一样的。
这篇文章详细地分析了Git中的数据对象、树对象和提交对象的hash方法,可以发现原理是非常简单的。数据对象和提交对象打印出来的内容与存储内容组织是一模一样的,可以很直观的理解。对于树对象,其打印出来的内容和实际存储是有区别的,增加了一些实现上的难度。例如,使用二进制形式的hash值而不是直观的十六进制形式。
数据对象、树对象和提交对象都是存储在.git/objects
目录下,目录的结构如下:
1 | .git |
从上面的目录结构可以看出,Git对象的40位hash分为两部分:头两位作为文件夹,后38位作为对象文件名。所以一个Git对象的存储路径规则为:
1 | .git/objects/hash[0, 2]/hash[2, 40] |
这里就产生了一个疑问:为什么Git要这么设计目录结构,而不直接用Git对象的40位hash作为文件名?原因是有两点:
有些文件系统对目录下的文件数量有限制。例如,FAT32限制单目录下的最大文件数量是65535个,如果使用U盘拷贝Git文件就可能出现问题。
有些文件系统访问文件是一个线性查找的过程,目录下的文件越多,访问越慢。
在Git对象哈希
小节中,我们知道Git对象会在原内容前加个一个头部:
1 | store = header + content |
Git对象在存储前,会使用zlib的deflate算法进行压缩,即简要描述为:
1 | zlib_store = zlib.deflate(store) |
压缩后的zlib_store
按照Git对象的路径规则存储到.git/objects
目录下。
总结下Git对象存储的算法步骤:
计算content
长度,构造header
;
将header
添加到content
前面,构造Git对象;
使用sha1算法计算Git对象的40位hash码;
使用zlib的deflate算法压缩Git对象;
将压缩后的Git对象存储到.git/objects/hash[0, 2]/hash[2, 40]
路径下;
接下来,我们使用Nodejs来实现git hash-object -w
的功能,即计算Git对象的hash值并存储到Git文件系统中:
1 | const fs = require('fs') |
最后,测试下能否正确存储Git对象:
1 | $ node index.js 'hello, world' blob |
由此可见,我们生成了一个合法的Git数据对象,证明算法是正确的。
首先来搞清楚什么是Git引用,前文讲了Git提交对象的哈希、存储原理,理论上我们只要知道该对象的hash值,就能往前推出整个提交历史,例如:
1 | $ git log --pretty=oneline 3ac728ac62f0a7b5ac201fd3ed1f69165df8be31 |
现在问题来了,提交对象的这40位hash值不好记忆,Git引用相当于给40位hash值取一个别名,便于识别和读取。Git引用对象都存储在.git/refs
目录下,该目录下有3个子文件夹heads
、tags
和remotes
,分别对应于HEAD引用、标签引用和远程引用,下面分别讲一讲每种引用的原理。
HEAD引用是用来指向每个分支的最后一次提交对象,这样切换到一个分支之后,才能知道分支的“尾巴”在哪里。HEAD引用存储在.git/refs/heads
目录下,有多少个分支,就有相应的同名HEAD引用对象。例如代码库里面有master
和test
两个分支,那么.git/refs/heads
目录下就存在master
和test
两个文件,分别记录了分支的最后一次提交。
HEAD引用的内容就是提交对象的hash值,理论上我们可以手动地构造一个HEAD引用:
1 | $ echo "3ac728ac62f0a7b5ac201fd3ed1f69165df8be31" > .git/refs/heads/master |
Git提供了一个专有命令update-ref
,用来查看和修改Git引用对象,当然也包括HEAD引用:
1 | $ git update-ref refs/heads/master 3ac728ac62f0a7b5ac201fd3ed1f69165df8be31 |
上面的命令我们将master
分支的HEAD指向了3ac728ac62f0a7b5ac201fd3ed1f69165df8be31
,现在用git log
查看下master
的提交历史,可以发现最后一次提交就是所更新的hash值:
1 | $ git log --pretty=oneline master |
这里的
HEAD -> master
表示当前整个代码库级别的HEAD引用为master分支。具体含义请往下看。
同理,可以使用同样的方法更新test
分支的HEAD:
1 | $ git update-ref refs/heads/test d4d2c6cffb408d978cb6f1eb6cfc70e977378a5c |
若当前整个代码库级别的HEAD引用为test分支,则会显示
(HEAD -> test)
。
.git/refs/heads
目录下存储了每个分支的HEAD,那怎么知道代码库当前处于哪个分支呢?这就需要一个代码库级别的HEAD引用。.git/HEAD
这个文件就是整个代码库级别的HEAD引用。我们先查看一下.git/HEAD
文件的内容:
1 | $ cat .git/HEAD |
我们发现.git/HEAD
文件的内容不是40位hash值,而像是指向.git/refs/heads/master
。尝试切换到test
:
1 | $ git checkout test |
切换分支后,.git/HEAD
文件的内容也跟着指向.git/refs/heads/test
。.git/HEAD
也是HEAD引用对象,与一般引用不同的是,它是“符号引用”。符号引用类似于文件的快捷方式,链接到要引用的对象上。
Git提供专门的命令git symbolic-ref
,用来查看和更新符号引用:
1 | $ git symbolic-ref HEAD refs/heads/master |
至此,我们分析了两种HEAD引用,一种是分支级别的HEAD引用,用来记录各分支的最后一次提交,存储在.git/refs/heads
目录下,使用git update-ref
来维护;一种是代码库级别的HEAD引用,用来记录代码库所处的分支,存储在.git/HEAD
文件,使用git symbolic-ref
来维护。
标签引用,顾名思义就是给Git对象打标签,便于记忆。例如,我们可以将某个提交对象打v1.0标签,表示是1.0版本。标签引用都存储在.git/refs/tags
里面。
标签引用和HEAD引用本质是Git引用对象,同样使用git update-ref
来查看和修改:
1 | $ git update-ref refs/tags/v1.0 d4d2c6cffb408d978cb6f1eb6cfc70e977378a5c |
还有一种标签引用称为“附注引用”,可以为标签添加说明信息。上面的标签引用打了一个v1.0
的标签表示发布1.0版本,有时候发布软件的时候除了版本号信息,还要写更新说明。附注引用就是用来实现打标签的同时,也可以附带说明信息。
附注引用是怎么实现的呢?与常规标签引用不同的是,它不直接指向提交对象,而是新建一个Git对象存储到.git/objects
中,用来记录附注信息,然后附注标签指向这个Git对象。
使用git tag
建立一个附注标签:
1 | $ git tag -a v1.1 3ac728ac62f0a7b5ac201fd3ed1f69165df8be31 -m "test tag" |
使用git cat-file
来查看附注标签所指向的Git对象:
1 | $ git cat-file -p 8be4d8e4e8e80711dd7bae304ccfa63b35a6eb8c |
可以看到,上面的Git对象存储了我们填写的附注信息。
总之,普通的标签引用和附注引用同样都是存储的是40位hash值,指向一个Git对象,所不同的是普通的标签引用是直接指向提交对象,而附注标签是指向一个附注对象,附注对象再指向具体的提交对象。
另外,本质上标签引用并不是只可以指向提交对象,实际上可以指向任何Git对象,即可以给任何Git对象打标签。
远程引用,类似于.git/refs/heads
中存储的本地仓库各分支的最后一次提交,在.git/refs/remotes
是用来记录多个远程仓库各分支的最后一次提交。
我们可以使用git remote
来管理远程分支:
1 | $ git remote add origin git@github.com:jingsam/git-test.git |
上面添加了一个origin
远程链接,接下来我们把本地仓库的master
推送到远程仓库上:
1 | $ git push origin master |
这时候在.git/refs/remotes
中的远程引用就会更新:
1 | $ cat .git/refs/remotes/origin/master |
和本地仓库的master
比较一下,发现是一模一样的,表示远程分支和本地分支是同步的:
1 | $ cat .git/refs/heads/master |
由于远程引用也是Git引用对象,所以理论上也可以使用git update-ref
来手动维护。但是,我们需要先把代码与远程仓库进行同步,在远程仓库中找到对应分支的HEAD,然后使用git update-ref
进行更新,过程比较麻烦。而我们在执行git pull
或git push
这样的高层命令的时候,远程引用会自动更新。
到这里,三种Git引用都已分析完毕。总的来说,三种Git引用都统一存储到.git/refs
目录下,Git引用中的内容都是40位的hash值,指向某个Git对象,这个对象可以是任意的Git对象,可以是数据对象、树对象、提交对象。三种Git引用都可以使用git update-ref
来手动维护。
三种Git引用对象所不同的是,分别存储于.git/refs/heads
、.git/refs/tags
、.git/refs/remotes
,存储的文件夹不同,赋予了引用对象不同的功能。HEAD引用用来记录本地各个分支的最后一次提交,标签引用用来给任意Git对象打标签,远程引用正式用来记录远程各个分支的最后一次提交。
新建一个git仓库,新建一个a.py
,第一次提交add a.py
后,.git
文件夹如下所示。
1 | . |
COMMIT-EDITMSG是一个临时文件,存储最后一次提交的message,当敲入git commit
命令,不加-m
的话, 会打开编辑器,其实就是在编辑此文件,而你退出编辑器后,git 会把此文件内容写入 commit 记录。 而执行git commit -m 'add a.py'
时,add a.py
就是COMMIT_EDITMSG的文件内容。
例如打开文件如下
1 | add a.py |
该文件的一个应用场景:当你git pull 远程仓库后,新增了很多提交,淹没了本地提交记录,直接 cat .git/COMMIT_EDITMSG
就可以弄清楚自己最后工作的位置了。
整个代码库级别的HEAD引用,也就是当前位于哪个分支。打开内容如下:
1 | ref: refs/heads/master |
config
文件包含项目特有的配置选项。
Git配置分为三个级别:
本地级别的配置信息,就记录在config
文件中,使用git config --local
或git config 不加任何参数
命令进行配置。
例如:
1 | [core] |
仓库的描述信息。打开文件内容如下:
1 | Unnamed repository; edit this file 'description' to name the repository. |
说明:该文件仅供 GitWeb (Github 的一种前身) 程序使用,我们无需关心。
和其它版本控制系统一样,Git 能在特定的重要动作发生时,触发自定义脚本(钩子脚本)。这些被称为钩子的脚本可以在提交 (commit)、变基 (rebase)、拉取 ( pull ) 操作的前后运行。脚本名预示着它的执行时机。如我们可以编写 pre-push 的作为钩子,进行推送代码前的检查。
钩子分为:客户端的钩子和服务器端的钩子。客户端钩子由提交和合并这样的操作所调用,而服务器端钩子作用于接收被推送的提交这样的联网操作。
当你用 git init
命令初始化一个新版本库时,Git 默认会在这个目录中放置一些示例脚本。这些脚本都是 shell 脚本,其中一些还混杂了 Perl 代码。你可以使用任何你熟悉的语言编写Git钩子脚本,如Ruby 或 Python等编写的可执行脚本,都可以正常使用。比如打开hooks/pre-push.sample
,内容如下:
1 | remote="$1" |
将编写好的可执行脚本(不带扩展名),放入 .git
目录下的 hooks
子目录中,即可激活该钩子脚本。
pre-commit
钩子:在创建提交信息前运行,它用于检查即将提交的快照。例如,检查是否有所遗漏,确保测试运行,以及核查代码。
如果该钩子以非零值退出,Git 将放弃此次提交,不过你可以用 git commit --no-verify
来绕过这个环节。
你可以利用该钩子,来检查代码风格是否一致、尾随空白字符是否存在,或新方法的文档是否适当等操作。
commit-msg
钩子:接收一个参数,此参数存有当前提交信息的临时文件的路径。 如果该钩子脚本以非零值退出,Git 将放弃提交,因此,可以用来在提交通过前验证项目状态或提交信息。
post-commit
钩子:在整个提交过程完成后运行。 它不接收任何参数,但你可以很容易地通过运行 git log -1 HEAD
来获得最后一次的提交信息。 该钩子一般用于通知之类的事情。
这里只简单介绍三个hooks
目录中的钩子脚本,如果想查看更多钩子示例脚本说明,可以查看Git 钩子,简略信息如下:
1 | # -F1:在列出的文件名称后加一符号;例如可执行档则加 "*", 目录则加 "/",1代表一个文件占据一行。 |
index
文件:该文件就是我们平时说的 暂存区 (stage),是一个二进制文件,保存了下次将提交的文件列表信息,我们执行git add
命令后,这个文件就会更新刚刚添加的文件信息。
我们可以使用git ls-files --stage
命令看到当前仓库中每一个文件及其所对应的文件对象。例如:
1 | $ git ls-files --stage |
提示:刚刚初始化的Git本地版本库中是没有index
文件的,只有执行一次暂存操作后,才在.git
目录自动生成index
文件。
info
目录包含一个全局性排除(global exclude)文件, 用以放置那些不希望被记录在 .gitignore
文件中的忽略模式(ignored patterns),它不会影响到其他人,也不会提交到版本库中去。.gitignore
文件会被提交到版本库。
此文件夹主要记录每个分支的每次修改的日志。比如说logs/refs/heads/master
内容如下:
1 | 0000000000000000000000000000000000000000 ed0a1ffd4b9ad6c8d2dd687ebd30762087cec86e zdaiot <zdaiot@163.com> 1685004210 +0800commit (initial): add a.py |
打开logs文件夹可以看到其中有两个文件,refs
文件夹和HEAD
文件。
HEAD文件保存的是,所有的引起HEAD指针移动的操作记录,使用git reflog
命令,查询的结果就是来自这个文件。
refs
文件夹中有两个文件夹:heads
目录和remotes
目录。例如:
1 | $ git remote -v |
heads
目录中都是以分支命名的文件,即:每个文件名对应着本地版本库的一个分支。每个文件中,记录的都是该分支历史操作记录。remotes
目录和heads
目录的作用同理,只不过remotes
目录中存储的是远程分支的历史操作记录。heads目录中所有分支历史操作记录的总和,是HEAD文件文件的内容。
例如版本库中有两个分支,分别查看他们历史操作记录,我们可以看到,master
分支和dev
分支的历史操作记录总和,就是HEAD文件中的内容。
1 | # 1.查看master分支的历史操作记录 |
详细可以看前文Git对象如何存储
小节。我们这里只介绍pack和info文件夹。
Git 往磁盘保存对象时默认使用的格式叫松散对象 (loose object) 格式,当你对同一个文件修改哪怕一行,git 都会使用全新的文件存储这个修改了的文件,放在了objects中。Git 时不时地将这些对象打包至一个叫 packfile 的二进制文件以节省空间并提高效率,当版本库中有太多的松散对象,或者你手动执行 git gc
命令,或者你向远程服务器执行推送时,Git 都会这样做。
1 | $ find .git/objects -type f |
这里只有6d一个文件夹,已经成功打包到pack里了,即使有很多很多文件对象,执行 git gc 后都会全部打包到 pack 里。.pack 存储对象文件,.idx 是索引文件,用于允许它们被随机访问;info 文件夹记录对象存储的附加信息,这里存储着打包后的文件名。
详细可以看前文Git引用小节
前面有提过git gc
会打包objects,其实它会做的另一件事是打包你的引用到一个单独的文件。 假设你的仓库包含以下分支与标签:
1 | $ find .git/refs -type f |
如果你执行了 git gc
命令,refs
目录中将不会再有这些文件。 为了保证效率 Git 会将它们移动到名为 .git/packed-refs
的文件中,就像这样:
1 | $ cat .git/packed-refs |
如果你更新了引用,Git 并不会修改这个文件,而是向 refs/heads
创建一个新的文件。 为了获得指定引用的正确 SHA-1 值,Git 会首先在 refs
目录中查找指定的引用,然后再到 packed-refs
文件中查找。 所以,如果你在 refs
目录中找不到一个引用,那么它或许在 packed-refs
文件中。
注意这个文件的最后一行,它会以 ^
开头。 这个符号表示它上一行的标签是tag标签,^
所在的那一行是tag标签指向的那个提交。
最后,值得注意的是,.git/packed-refs
中关于refs的内容并不是固定的。直接git clone
的可能含有refs/remotes/origin/xxx
与refs/tags/xxx
,而git clone --mirror
得到的bare仓库,可能含有refs/heads/xxx
与refs/tags/xxx
。具体情况可以直接查看该文件的内容。
波浪号~
,英文名叫 tilde。脱字符^
,英文名叫caret。那么关于这两个该怎么区分呢?
在What’s the difference between HEAD^ and HEAD~ in Git?中介绍如下:
Rules of thumb
~
most of the time — to go back a number of generations, usually what you want^
on merge commits — because they have two or more (immediate) parentsMnemonics:
~
is almost linear in appearance and wants to go backward in a straight line^
suggests an interesting segment of a tree or a fork in the road该如何理解呢?看如下例子。在该例子中,A、B、D、G位于同一个branch,D由G与H merge合并而来,所以G=D^1
,G是D第一个parent;H=D^2
,H是D的第二个parent。
B是A的当前分支所在时间线中的父节点,所以B=A~1
;D是A在当前分支所在时间线中的父节点的父节点,所以D=A~2
。
关于更详细的例子可以参考https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt-emltrevgtltngtemegemHEADv1510em:
1 | G H I J |
git可以使用四种主要的协议来传输资料: 本地协议(Local),HTTP 协议,SSH(Secure Shell)协议及 git 协议。其中,本地协议由于目前大都是进行远程开发和共享代码所以一般不常用,而git协议由于缺乏授权机制且较难架设所以也不常用。
最常用的便是SSH和HTTP(S)协议。git关联远程仓库可以使用http协议或者ssh协议。
ssh:
https:
一般企业防火墙会打开80和443这两个http/https协议的端口,因此在架设了企业防火墙的时候使用http就可以很好的绕开安全限制使用git了,很方便;而对于ssh来说,企业防火墙很可能没打开22端口。
clone项目:
当clone public仓库时,若没有上传本地ssh key,则无法通过ssh clone仓库。
push项目(前提是你有这个仓库的push权限):
使用ssh方式时,不需要验证用户名和密码,之前配置过ssh key(如果你没设置密码),直接push即可;
使用https方式时,需要验证用户名和密码。
总结:
HTTPS利于匿名访问,适合开源项目,可以方便被别人克隆和读取(但没有push权限);
SSH不利于匿名访问,比较适合内部项目,只要配置了SSH公钥极可自由实现clone和push操作。
Git 是分布式 版本控制系统,这意味着在克隆过程中会将仓库的整个历史记录传输到客户端。对于包涵大文件(尤其是经常被修改的大文件)的项目,初始克隆需要大量时间,因为客户端会下载每个文件的每个版本。Git LFS(Large File Storage)是由 Atlassian, GitHub 以及其他开源贡献者开发的 Git 扩展,它通过延迟地(lazily)下载大文件的相关版本来减少大文件在仓库中的影响,具体来说,大文件是在 checkout 的过程中下载的,而不是 clone 或 fetch 过程中下载的(这意味着你在后台定时 fetch 远端仓库内容到本地时,并不会下载大文件内容,而是在你 checkout 到工作区的时候才会真正去下载大文件的内容)。
Git LFS 通过将仓库中的大文件替换为微小的指针(pointer) 文件来做到这一点。在正常使用期间,你将永远不会看到这些指针文件,因为它们是由 Git LFS 自动处理的:
当你添加(执行 git add 命令)一个文件到你的仓库时,Git LFS 用一个指针替换其内容,并将文件内容存储在本地 Git LFS 缓存中(本地 Git LFS 缓存位于仓库的.git/lfs/objects 目录中)。
当你推送新的提交到服务器时,新推送的提交引用的所有 Git LFS 文件都会从本地 Git LFS 缓存传输到绑定到 Git 仓库的远程 Git LFS 存储(即 LFS 文件内容会直接从本地 Git LFS 缓存传输到远程 Git LFS 存储服务器)。
当你 checkout 一个包含 Git LFS 指针的提交时,指针文件将替换为本地 Git LFS 缓存中的文件,或者从远端 Git LFS 存储区下载。
LFS 的指针文件是一个文本文件,存储在 Git 仓库中,对应大文件的内容存储在 LFS 服务器里,而不是 Git 仓库中。
下面为一个图片 LFS 文件的指针文件内容:
1 | version https://git-lfs.github.com/spec/v1 |
指针文件很小,小于 1KB。其格式为 key-value 格式,第一行为指针文件规范 URL,第二行为文件的对象 id,也即 LFS 文件的存储对象文件名,可以在.git/lfs/objects 目录中找到该文件的存储对象,第三行为文件的实际大小(单位为字节)。所有 LFS 指针文件都是这种格式。
Git LFS 是无缝的:在你的工作副本中,你只会看到实际的文件内容。这意味着你不需要更改现有的 Git 工作流程就可以使用 Git LFS。你只需按常规进行 git checkout、编辑文件、git add 和 git commit。git clone 和 git pull 将明显更快,因为你只下载实际检出的提交所引用的大文件版本,而不是曾经存在过的文件的每一个版本。
为了使用 Git LFS,你将需要一个支持 Git LFS 的托管服务器,例如github。用户将需要安装 Git LFS 命令行客户端,或支持 Git LFS 的 GUI 客户端,例如Sourcetree。
What’s the difference between HEAD^ and HEAD~ in Git?
What’s the difference between HEAD^ and HEAD~ in Git?
【git】git中使用https和ssh协议的区别以及它们的用法
Git内部原理之Git对象
Git内部原理之Git对象存储
Git内部原理之Git对象哈希
Git内部原理之Git引用
【学了就忘】Git原理 — 58.详解.git目录(二)
【学了就忘】Git原理 — 57.详解.git目录(一)
通过 .git 目录深入理解 Git!
Git|探寻Git如何管理文件版本的小秘密
Git——.git目录详解
解析.git文件夹,深入了解git内部原理
10.7 Git Internals - Maintenance and Data Recovery
详解 Git 大文件存储(Git LFS)
shell有多个含义:
bash(GNU Bourne-Again Shell)是最常用的一种shell,是当前大多数Linux发行版的默认Shell。除了bash外,其它的shell还有zsh、sh等。
sh的全名是Bourne Shell。名字中的玻恩就是这个Shell的作者。而bash的全名是Bourne Again Shell。最开始在Unix系统中流行的是sh,而bash作为sh的改进版本,提供了更加丰富的功能。一般来说,都推荐使用bash作为默认的Shell。
如何查看当前系统中默认shell?
1 | echo $SHELL |
当前正在使用的 Shell 不一定是默认 Shell,一般来说,ps
命令结果的倒数第二行是当前 Shell。
1 | $ ps |
Shell相当于是一个翻译,把我们在计算机上的操作或我们的命令,翻译为计算机可识别的二进制命令,传递给内核,以便调用计算机硬件执行相关的操作;同时,计算机执行完命令后,再通过Shell翻译成自然语言,呈现在我们面前。
Linux shell是用户与Linux系统进行交互的媒介,而bash作为目前Linux系统中最常用的shell,它在运行时具有两种属性,即“交互”与“登陆”。
那么我们启动的时候,如何才能进行进行交互或登陆呢?
根据bash手册上的描述:
An interactive shell is one started without non-option arguments and without the -c option whose standard input and error are both connected to terminals (as determined by isatty(3)), or one started with the -i option.
从上面的描述看,只要执行bash命令的时候,不带有“选项以外的参数”或者-c选项,就会启动一个交互式shell。
1 | [chen@localhost Temp]$ echo "uname -r; date" > script.sh |
另外,从上面描述来看,通常来说,用于执行脚本的shell都是“非交互式”的,但我们也有办法把它启动为“交互式”shell,方法就是在执行bash命令时,添加-i
选项:
1 | [chen@localhost Temp]$ bash -c "echo \$-" |
这里解释一下
echo \$-
:It shows your Builtin Set Flags.man bash
then look for SHELL BUILTIN COMMANDS and then look for theset subsection
. You will find the meanings of all those flags:
1
2
3
4
5
6 > h: Remember the location of commands as they are looked up for execution. This is enabled by default.
> i: interactive
> m: Monitor mode. Job control is enabled
> B: The shell performs brace expansion (see Brace Expansion above). This is on by default
> H: Enable ! style history substitution. This option is on by default when the shell is interactive.
>
那么我们如何在shell脚本或者startup文件中判断当前shell的运行方式呢?
我们首先来看,bash手册的描述:
PS1
is set and$-
includesi
if bash is interactive, allowing a shell script or a startup file to test this state.
也就是说,可以判断变量PS1
是否有值,或者判断变量$-
是否包含i
,实现在shell脚本或者startup文件中判断当前shell的运行方式。
1 | # 在shell脚本中写入如下语句,通过输出判断当前shell运行方式 |
“登陆shell”通常指的是:
-l|--login
参数的bash
命令启动的shell。例如,系统启动、远程登录、使用su -
切换用户、通过bash --login
命令启动bash等。而其他情况启动的shell基本上就都是“非登陆shell”了。例如,从图形界面启动终端、使用su
切换用户、通过bash
命令启动bash等。
根据bash手册上的描述:
A login shell is one whose first character of argument zero is a
-
, or one started with the--login
option.
我们可以通过在shell中echo $0
查看,显示-bash
的一定是“登陆shell”,反之显示bash
的则不好说。
1 | [chen@localhost ~]$ bash --login |
可以看出,使用bash --login
启动的“登陆shell”,其$0
也并非以-
开头,这也就是为什么手册上的描述里使用“or”的原因。
另外,当我们执行exit
命令退出shell时,也可以观察到它们的不同之处:
1 | [chen@localhost ~]$ bash --login |
原则上讲,我们使用logout
退出“登陆shell”,使用exit
退出“非登录shell”。但其实exit
命令会判断当前shell的“登陆”属性,并分别调用logout
或exit
指令,因此使用起来相对方便。
对于用户而言,“登录shell”和“非登陆shell”的主要区别在于启动shell时所执行的startup文件不同。
简单来说,“登录shell”执行的startup文件为~/.bash_profile
,而“非登陆shell”执行的startup文件为~/.bashrc
。
下面我们进行详细说明。
它支持的startup文件也并不单一,甚至容易让人感到费解。接下来以CentOS7系统为例,对bash的startup文件进行一些必要的梳理和总结。
根据bash手册的描述:
/etc/profile
The systemwide initialization file, executed for login shells/etc/bash.bash_logout
The systemwide login shell cleanup file, executed when a login shell exits~/.bash_profile
The personal initialization file, executed for login shells~/.bashrc
The individual per-interactive-shell startup file~/.bash_logout
The individual login shell cleanup file, executed when a login shell exits
此外,bash还支持~/.bash_login
和~/.profile
文件,作为对其他shell的兼容,它们与~/.bash_profile
文件的作用是相同的。
备注:Debian系统会使用~/.profile
文件取代~/.bash_profile
文件,因此在相关细节上,会与CentOS略有不同。
通过名字的不同,我们可以直观地将startup文件分为“profile”与“rc”两个系列,其实他们的功能都很类似,但是使用的场景不同,这也是大家最容易忽略的地方。
所谓的不同场景,其实就是shell的运行模式。我们知道运行中的bash有“交互”和“登陆”两种属性,而执行“profile”系列还是“rc”系列,就与shell的这两个属性有关。
原理上讲,“登陆shell”启动时会加载“profile”系列的startup文件,而“交互式非登陆shell”启动时会加载“rc”系列的startup文件。
对于“登录shell”而言,“交互式”执行“登陆”和“登出”相关的“profile”系列startup文件,“非交互式”只执行“登陆”相关的“profile”系列startup文件;对于“非登陆shell”而言,“交互式”执行“rc”系列的startup文件,而“非交互式”执行的配置文件由环境变量BASH_ENV
指定。
Linux中startup文件区分全局和个人:全局startup文件放在/etc
目录下,用于设置所有用户共同的配置,除非你清楚地知道你在做的事情,否则不要轻易改动它们;个人startup文件放在~
目录下,用于设置某个用户的个性化配置。
~/.bash_profile
会显式调用~/.bashrc
文件,而~/.bashrc
又会显式调用/etc/bashrc
文件,这是为了让所有交互式界面看起来一样。无论你是从远程登录(登陆shell),还是从图形界面打开终端(非登陆shell),你都拥有相同的提示符,因为环境变量PS1
在/etc/bashrc
文件中被统一设置过。
下面我来对startup文件进行一个完整的总结:
startup文件 | 交互登陆 | 非交互登陆 | 交互非登陆 | 非交互非登陆 |
---|---|---|---|---|
/etc/profile | 直接执行1 | 直接执行1 | - | - |
~/.bash_profile | 直接执行2 | 直接执行2 | - | - |
~/.bash_login | 条件执行2 | 条件执行2 | - | - |
~/.profile | 条件执行2 | 条件执行2 | - | - |
~/.bash_logout | 直接执行3 | 不执行 | - | - |
/etc/bash.bash_logout | 直接执行4 | 不执行 | - | - |
~/.bashrc | 引用执行2.1 | 引用执行2.1 | 直接执行1 | - |
/etc/bashrc | 引用执行2.2 | 引用执行2.2 | 引用执行1.1 | - |
备注:
BASH_ENV
环境变量指定;如果你想对bash的功能进行设置或者是定义一些别名,推荐你修改~/.bashrc
文件,这样无论你以何种方式打开shell,你的配置都会生效。而如果你要更改一些环境变量,推荐你修改~/.bash_profile
文件,因为考虑到shell的继承特性,这些更改确实只应该被执行一次(而不是多次)。针对所有用户进行全局设置,推荐你在/etc/profile.d
目录下添加以.sh
结尾的文件,而不是去修改全局startup文件。
具体更加详细的解释可以参考文章关于“.bash_profile”和“.bashrc”区别的总结。
默认情况下, bash 只在退出的时候更新命令历史, 而且这个”更新”是用新版直接覆盖旧版。这会使你无法保持一份完整的命令历史记录, 原因有两个:
你设置在 .bashrc 文件中添加下面这句就够了
1 | shopt -s histappend |
它让 shell 退出时是添加新记录,而不是覆盖原来的文件。这样你关闭多个终端时就不会挨个覆盖了。
顺便,你也许想把历史记录保存条数设置大一点
1 | # 设置历史记录条数 |
另外下面非常推荐设置,可以让你能够用方向键翻阅历史
1 | bind '"\e[A": history-search-backward' |
Shell 教程
Bash 简介
Bash编程入门-1:Shell与Bash
关于“交互式-非交互式”与“登录-非登陆”shell的总结
Why does running “echo $-“ output “himBH” on the bash shell?
关于“.bash_profile”和“.bashrc”区别的总结
bash 下 history 会因多个终端而覆盖丢失,有好的解决方案吗? - Zhou Zhao的回答 - 知乎
[译] 如何防止丢失任何 bash 历史命令?
XGBoost 是大规模并行 boosting tree 的工具,它是目前最快最好的开源 boosting tree 工具包,比常见的工具包快 10 倍以上。Xgboost 和 GBDT 两者都是 boosting 方法,除了工程实现、解决问题上的一些差异外,最大的不同就是目标函数的定义。故本文将从数学原理和工程实现上进行介绍,并在最后介绍下 Xgboost 的优点。
我们知道 XGBoost 是由 k 个基模型组成的一个加法运算式:
其中 $f_k$ 为第 $k$ 个基模型, $\hat{y}_i$ 为第 $i$ 个样本的预测值。
损失函数可由预测值 $\hat{y}_i$ 与真实值 $y_i$ 进行表示:
其中 $n$ 为样本数量。
我们知道模型的预测精度由模型的偏差和方差共同决定,损失函数代表了模型的偏差,想要方差小则需要简单的模型,所以目标函数由模型的损失函数 $L$ 与抑制模型复杂度的正则项 $\Omega$ 组成,所以我们有:
$\Omega$ 为模型的正则项,由于 XGBoost 支持决策树也支持线性模型,所以这里再不展开描述。
我们知道 boosting 模型是前向加法,以第 $t$ 步的模型为例,模型对第 $i$ 个样本 $x_{i}$ 的预测为:
其中 $\hat{y}_i^{t-1}$ 由第 $t-1$ 步的模型给出的预测值,是已知常数,$f_t(x_i)$ 是我们这次需要加入的新模型的预测值,此时,目标函数就可以写成:
求此时最优化目标函数,就相当于求解 $f_t(x_i)$ 。
泰勒公式是将一个在 $x=x_0$ 处具有 $n$ 阶导数的函数 $f(x)$ 利用关于 $x-x_0$ 的 $n$ 次多项式来逼近函数的方法,若函数 $f(x)$ 在包含 $x_0$ 的某个闭区间 [a,b] 上具有 $n$ 阶导数,且在开区间 (a,b) 上具有 $n+1$ 阶导数,则对闭区间 [a,b] 上任意一点 $x$ 有 $\displaystyle f(x)=\sum_{i=0}^{n}\frac{f^{(i)}(x_0)}{i!}(x-x_0)^ i+R_n(x)$ ,其中的多项式称为函数在 $x_0$ 处的泰勒展开式, $R_n(x)$ 是泰勒公式的余项且是 $(x−x_0)^n$ 的高阶无穷小。
根据泰勒公式我们把函数 $f(x+\Delta x)$ 在点 $x$ 处进行泰勒的二阶展开,可得到如下等式:
我们把 $\hat{y}_i^{t-1}$ 视为 $x$ , $f_t(x_i)$ 视为 $\Delta x$ ,故可以将目标函数写为:
其中 $g_{i}$ 为损失函数的一阶导, $h_{i}$ 为损失函数的二阶导,注意这里的导是对 $\hat{y}_i^{t-1}$ 求导。
我们以平方损失函数为例:
则:
由于在第 $t$ 步时 $\hat{y}_i^{t-1}$ 其实是一个已知的值,所以 $l(y_i, \hat{y}_i^{t-1})$ 是一个常数,其对函数的优化不会产生影响,因此目标函数可以写成:
所以我们只需要求出每一步损失函数的一阶导和二阶导的值(由于前一步的 $\hat{y}^{t-1}$ 是已知的,所以这两个值就是常数),然后最优化目标函数,就可以得到每一步的 $f(x)$ ,最后根据加法模型得到一个整体模型。
我们知道 Xgboost 的基模型不仅支持决策树,还支持线性模型,这里我们主要介绍基于决策树的目标函数。
我们可以将决策树定义为 $f_t(x)=w_{q(x)}$ ,其中$w \in \mathbf{R}^{T}, q: \mathbf{R}^{d} \rightarrow\{1,2, \cdots, T\}$,$t$表示boosting在进行前向加法时的第$t$个模型。 $x$ 为某一样本,这里的 $q(x)$ 代表了该样本在哪个叶子结点上,而 $w_q$ 则代表了叶子结点取值 $w$ ,所以 $w_{q(x)}$ 就代表了每个样本的取值 $w$ (即预测值)。如下图所示:
决策树的复杂度可由叶子数 $T$ 组成,叶子节点越少模型越简单,此外叶子节点也不应该含有过高的权重 $w$ (类比 LR 的每个变量的权重),所以目标函数的正则项可以定义为:
即决策树模型的复杂度由生成的所有决策树的叶子节点数量,和所有节点权重所组成的向量的 $L_2$ 范式共同决定。下图给出了基于决策树的 XGBoost 的正则项的求解方式。
我们设 $I_j= \{ i \vert q(x_i)=j \}$ 为第 $j$ 个叶子节点的样本集合,故我们的目标函数可以写成:
第二步到第三步可能看的不是特别明白,这边做些解释:第二步是遍历所有的样本后求每个样本的损失函数,但样本最终会落在叶子节点上,所以我们也可以遍历叶子节点,然后获取叶子节点上的样本集合,最后在求损失函数。即我们之前样本的集合,现在都改写成叶子结点的集合,由于一个叶子结点有多个样本存在,因此才有了 $\sum_{i \in I_j}g_i$ 和 $\sum_{i \in I_j}h_i$ 这两项, $w_j$ 为第 $j$ 个叶子节点取值。
为简化表达式,我们定义 $G_j=\sum_{i \in I_j}g_i , H_j=\sum_{i \in I_j}h_i$ ,则目标函数为:
这里我们要注意 $G_j $和 $H_j$ 是前 t-1 步得到的结果,其值已知可视为常数,只有最后一棵树的叶子节点 $w_j$ 不确定,那么将目标函数对 $w_j$ 求一阶导,并令其等于 0 ,则可以求得叶子结点 $j$ 对应的权值:
所以目标函数可以化简为:
下图给出目标函数计算的例子,求每个节点每个样本的一阶导数 $g_i$ 和二阶导数 $h_i$ ,然后针对每个节点对所含样本求和得到的 $G_j$ 和 $H_j$ ,最后遍历决策树的节点即可得到目标函数。
在决策树的生长过程中,一个非常关键的问题是如何找到叶子的节点的最优切分点,Xgboost 支持两种分裂节点的方法——贪心算法和近似算法。
1)贪心算法
那么如何计算每个特征的分裂收益呢?
假设我们在某一节点完成特征分裂,则分裂前的目标函数可以写为:
分裂后的目标函数为:
则对于目标函数来说,分裂后的收益为:
注意该特征收益也可作为特征重要性输出的重要依据。
对于每次分裂,我们都需要枚举所有特征可能的分割方案,如何高效地枚举所有的分割呢?
我假设我们要枚举所有 $x < a$ 这样的条件,对于某个特定的分割点 $a$ 我们要计算 $a$ 左边和右边的导数和。
我们可以发现对于所有的分裂点 $a$ ,我们只要做一遍从左到右的扫描就可以枚举出所有分割的梯度和 $G_L$ 和 $G_R$ 。然后用上面的公式计算每个分割方案的分数就可以了。
观察分裂后的收益,我们会发现节点划分不一定会使得结果变好,因为我们有一个引入新叶子的惩罚项,也就是说引入的分割带来的增益如果小于一个阀值的时候,我们可以剪掉这个分割。
2)近似算法
贪婪算法可以得到最优解,但当数据量太大时则无法读入内存进行计算,近似算法主要针对贪婪算法这一缺点给出了近似最优解。
对于每个特征,只考察分位点可以减少计算复杂度。
该算法会首先根据特征分布的分位数提出候选划分点,然后将连续型特征映射到由这些候选点划分的桶中,然后聚合统计信息找到所有区间的最佳分裂点。
在提出候选切分点时有两种策略:
直观上来看,Local 策略需要更多的计算步骤,而 Global 策略因为节点没有划分所以需要更多的候选点。
下图给出不同种分裂策略的 AUC 变换曲线,横坐标为迭代次数,纵坐标为测试集 AUC,eps 为近似算法的精度,其倒数为桶的数量。
我们可以看到 Global 策略在候选点数多时(eps 小)可以和 Local 策略在候选点少时(eps 大)具有相似的精度。此外我们还发现,在 eps 取值合理的情况下,分位数策略可以获得与贪婪算法相同的精度。
下图给出近似算法的具体例子,以三分位为例:
根据样本特征进行排序,然后基于分位数进行划分,并统计三个桶内的 $G,H$ 值,最终求解节点划分的增益。
事实上, XGBoost 不是简单地按照样本个数进行分位,而是以二阶导数值 $h_i$ 作为样本的权重进行划分,如下:
那么问题来了:为什么要用 $h_i$ 进行样本加权?
我们知道模型的目标函数为:
我们稍作整理,便可以看出 $h_i$ 有对 loss 加权的作用。
其中 $\frac{1}{2}\frac{g_i^2}{h_i}$ 与 $C$ 皆为常数。我们可以看到 $h_i$ 就是平方损失函数中样本的权重。
对于样本权值相同的数据集来说,找到候选分位点已经有了解决方案(GK 算法),但是当样本权值不一样时,该如何找到候选分位点呢?(作者给出了一个 Weighted Quantile Sketch 算法,这里将不做介绍。)
在决策树的第一篇文章中我们介绍 CART 树在应对数据缺失时的分裂策略,XGBoost 也给出了其解决方案。
XGBoost 在构建树的节点过程中只考虑非缺失值的数据遍历,而为每个节点增加了一个缺省方向,当样本相应的特征值缺失时,可以被归类到缺省方向上,最优的缺省方向可以从数据中学到。至于如何学到缺省值的分支,其实很简单,分别枚举特征缺省的样本归为左右分支后的增益,选择增益最大的枚举项即为最优缺省方向。
在构建树的过程中需要枚举特征缺失的样本,乍一看该算法的计算量增加了一倍,但其实该算法在构建树的过程中只考虑了特征未缺失的样本遍历,而特征值缺失的样本无需遍历只需直接分配到左右节点,故算法所需遍历的样本量减少,下图可以看到稀疏感知算法比 basic 算法速度块了超过 50 倍。
我们知道,决策树的学习最耗时的一个步骤就是在每次寻找最佳分裂点是都需要对特征的值进行排序。而 XGBoost 在训练之前对根据特征对数据进行了排序,然后保存到块结构中,并在每个块结构中都采用了稀疏矩阵存储格式(Compressed Sparse Columns Format,CSC)进行存储,后面的训练过程中会重复地使用块结构,可以大大减小计算量。
这种块结构存储的特征之间相互独立,方便计算机进行并行计算。在对节点进行分裂时需要选择增益最大的特征作为分裂,这时各个特征的增益计算可以同时进行,这也是 Xgboost 能够实现分布式或者多线程计算的原因。
块结构的设计可以减少节点分裂时的计算量,但特征值通过索引访问样本梯度统计值的设计会导致访问操作的内存空间不连续,这样会造成缓存命中率低,从而影响到算法的效率。
为了解决缓存命中率低的问题,XGBoost 提出了缓存访问优化算法:为每个线程分配一个连续的缓存区,将需要的梯度信息存放在缓冲区中,这样就是实现了非连续空间到连续空间的转换,提高了算法效率。
此外适当调整块大小,也可以有助于缓存优化。
当数据量过大时无法将数据全部加载到内存中,只能先将无法加载到内存中的数据暂存到硬盘中,直到需要时再进行加载计算,而这种操作必然涉及到因内存与硬盘速度不同而造成的资源浪费和性能瓶颈。为了解决这个问题,XGBoost 独立一个线程专门用于从硬盘读入数据,以实现处理数据和读入数据同时进行。
此外,XGBoost 还用了两种方法来降低硬盘读写的开销:
LightGBM 由微软提出,主要用于解决 GDBT 在海量数据中遇到的问题,以便其可以更好更快地用于工业实践中。
从 LightGBM 名字我们可以看出其是轻量级(Light)的梯度提升机(GBM),其相对 XGBoost 具有训练速度快、内存占用低的特点。下图分别显示了 XGBoost、XGBoost_hist(利用梯度直方图的 XGBoost) 和 LightGBM 三者之间针对不同数据集情况下的内存和训练时间的对比:
那么 LightGBM 到底如何做到更快的训练速度和更低的内存使用的呢?
我们刚刚分析了 XGBoost 的缺点,LightGBM 为了解决这些问题提出了以下几点解决方案:
本节将继续从数学原理和工程实现两个角度介绍 LightGBM。
GBDT 算法的梯度大小可以反映样本的权重,梯度越小说明模型拟合的越好,单边梯度抽样算法(Gradient-based One-Side Sampling, GOSS)利用这一信息对样本进行抽样,减少了大量梯度小的样本,在接下来的计算过程中只需关注梯度高的样本,极大的减少了计算量。
GOSS 算法保留了梯度大的样本,并对梯度小的样本进行随机抽样,为了不改变样本的数据分布,在计算增益时为梯度小的样本引入一个常数进行平衡。具体算法如下所示:
我们可以看到 GOSS 事先基于梯度的绝对值对样本进行排序(无需保存排序后结果),然后拿到前 a% 的梯度大的样本,和总体样本的 b%,在计算增益时,通过乘上 $\frac{1-a}{b}$ 来放大梯度小的样本的权重。一方面算法将更多的注意力放在训练不足的样本上,另一方面通过乘上权重来防止采样对原始数据分布造成太大的影响。
采样之前梯度小的样本数量是$整体数量1-a$,采样之后变为了$整体b$,会影响数据分布,乘以这个系数之后,数量就回到了$1-a$的量级。
1) 直方图算法
直方图算法的基本思想是将连续的特征离散化为 k 个离散特征,同时构造一个宽度为 k 的直方图用于统计信息(含有 k 个 bin)。利用直方图算法我们无需遍历数据,只需要遍历 k 个 bin 即可找到最佳分裂点。
我们知道特征离散化的具有很多优点,如存储方便、运算更快、鲁棒性强、模型更加稳定等等。对于直方图算法来说最直接的有以下两个优点(以 k=256 为例):
虽然将特征离散化后无法找到精确的分割点,可能会对模型的精度产生一定的影响,但较粗的分割也起到了正则化的效果,一定程度上降低了模型的方差。
2) 直方图加速
在构建叶节点的直方图时,我们还可以通过父节点的直方图与相邻叶节点的直方图相减的方式构建,从而减少了一半的计算量。在实际操作过程中,我们还可以先计算直方图小的叶子节点,然后利用直方图作差来获得直方图大的叶子节点。
3) 稀疏特征优化
XGBoost 在进行预排序时只考虑非零值进行加速,而 LightGBM 也采用类似策略:只用非零特征构建直方图。
高维特征往往是稀疏的,而且特征间可能是相互排斥的(如两个特征不同时取非零值),如果两个特征并不完全互斥(如只有一部分情况下是不同时取非零值),可以用互斥率表示互斥程度。互斥特征捆绑算法(Exclusive Feature Bundling, EFB)指出如果将一些特征进行融合绑定,则可以降低特征数量。
针对这种想法,我们会遇到两个问题:
对于问题一:EFB 算法利用特征和特征间的关系构造一个加权无向图,并将其转换为图着色算法。我们知道图着色是个 NP-Hard 问题,故采用贪婪算法得到近似解,具体步骤如下:
算法允许两两特征并不完全互斥来增加特征捆绑的数量,通过设置最大互斥率 $\gamma$ 来平衡算法的精度和效率。EFB 算法的伪代码如下所示:
我们看到时间复杂度为 $O(#feature^2)$ ,在特征不多的情况下可以应付,但如果特征维度达到百万级别,计算量则会非常大,为了改善效率,我们提出了一个更快的解决方案:将 EFB 算法中通过构建图,根据节点度来排序的策略改成了根据非零值的技术排序,因为非零值越多,互斥的概率会越大。
对于问题二:论文给出特征合并算法,其关键在于原始特征能从合并的特征中分离出来。假设 Bundle 中有两个特征值,A 取值为 [0, 10]、B 取值为 [0, 20],为了保证特征 A、B 的互斥性,我们可以给特征 B 添加一个偏移量转换为 [10, 30],Bundle 后的特征其取值为 [0, 30],这样便实现了特征合并。具体算法如下所示:
在建树的过程中有两种策略:
XGBoost 采用 Level-wise 的增长策略,方便并行计算每一层的分裂节点,提高了训练速度,但同时也因为节点增益过小增加了很多不必要的分裂,降低了计算量;LightGBM 采用 Leaf-wise 的增长策略减少了计算量,配合最大深度的限制防止过拟合,由于每次都需要计算增益最大的节点,所以无法并行分裂。
大部分的机器学习算法都不能直接支持类别特征,一般都会对类别特征进行编码,然后再输入到模型中。常见的处理类别特征的方法为 one-hot 编码,但我们知道对于决策树来说并不推荐使用 one-hot 编码:
LightGBM 原生支持类别特征,采用 many-vs-many 的切分方式将类别特征分为两个子集,实现类别特征的最优切分。假设有某维特征有 $k$ 个类别,则有 $2^{(k-1)} - 1$ 种可能,时间复杂度为 $O(2^k)$ ,LightGBM 基于 Fisher 大佬的 《On Grouping For Maximum Homogeneity》实现了 $O(klogk)$ 的时间复杂度。
下图为左边为基于 one-hot 编码进行分裂,右边为 LightGBM 基于 many-vs-many 进行分裂(叶子节点的含义是X=A或者X=C放到左孩子,其余放到右孩子),在给定深度情况下,后者能学出更好的模型。
其基本思想在于每次分组时都会根据训练目标对类别特征进行分类,根据其累积值 $\frac{\sum gradient }{\sum hessian}$ 对直方图进行排序,然后在排序的直方图上找到最佳分割。此外,LightGBM 还加了约束条件正则化,防止过拟合。
下图是根据类标sum(y)/count(y)进行排序并划分的示意图。
我们可以看到这种处理类别特征的方式使得 AUC 提高了 1.5 个点,且时间仅仅多了 20%。
对于类别特征,补充另外的处理方式。
转成数值特征。在使用 sklearn 或 XGBoost 等不支持类别特征的最优切分工具时,可以用这个方法。常见的转换方法有: a) 把类别特征转成one-hot coding扔到NN里训练个embedding;b) 类似于CTR特征,统计每个类别对应的label(训练目标)的均值。统计的时候有一些小技巧,比如不把自身的label算进去(leave-me-out, leave-one-out)统计, 防止信息泄露。
关于”leave-me-out”的统计方法。一个简单的例子,比如样本1,3,5属于同个类别(在类别特征上的属性一样),对于样本1,可以用3和5的label均值,样本3用1和5的均值……这样可以防止每一个样本直接把自身的label信息放到特征里面,减少统计特征的信息泄露,防止过拟合。
其他的编码方法,比如binary coding等等,同样可以用于不支持类别特征的算法。这里有一个比较好的开源项目,封装了常见的各种编码方法: https://github.com/scikit-learn
传统的特征并行算法在于对数据进行垂直划分,然后使用不同机器找到不同特征的最优分裂点,基于通信整合得到最佳划分点,然后基于通信告知其他机器划分结果。
传统的特征并行方法有个很大的缺点:需要告知每台机器最终划分结果,增加了额外的复杂度(因为对数据进行垂直划分,每台机器所含数据不同,划分结果需要通过通信告知)。
LightGBM 则不进行数据垂直划分,每台机器都有训练集完整数据,在得到最佳划分方案后可在本地执行划分而减少了不必要的通信。
传统的数据并行策略主要为水平划分数据,然后本地构建直方图并整合成全局直方图,最后在全局直方图中找出最佳划分点。
这种数据划分有一个很大的缺点:通讯开销过大。如果使用点对点通信,一台机器的通讯开销大约为 $O(#machine #feature #bin )$ ;如果使用集成的通信,则通讯开销为 $O(2 #feature #bin )$ 。
LightGBM 采用分散规约(Reduce scatter)的方式将直方图整合的任务分摊到不同机器上,从而降低通信代价,并通过直方图做差进一步降低不同机器间的通信。
针对数据量特别大特征也特别多的情况下,可以采用投票并行。投票并行主要针对数据并行时数据合并的通信代价比较大的瓶颈进行优化,其通过投票的方式只合并部分特征的直方图从而达到降低通信量的目的。
大致步骤为两步:
上边说到 XGBoost 的预排序后的特征是通过索引给出的样本梯度的统计值,因其索引访问的结果并不连续,XGBoost 提出缓存访问优化算法进行改进。
而 LightGBM 所使用直方图算法对 Cache 天生友好:
本节主要总结下 LightGBM 相对于 XGBoost 的优点,从内存和速度两方面进行介绍。
【机器学习】决策树(下)——XGBoost、LightGBM(非常详细)
陈天奇论文演讲 PPT
关于sklearn中的决策树是否应该用one-hot编码? - 柯国霖的回答 - 知乎
Lightgbm如何处理类别特征?
常见的集成学习框架有三种:Bagging,Boosting 和 Stacking。三种集成学习框架在基学习器的产生和综合结果的方式上会有些区别,我们先做些简单的介绍。
Bagging 全称叫 Bootstrap aggregating( Bootstrap 抽样方法) ,每个基学习器都会对训练集进行有放回抽样得到子训练集,比较著名的采样法为 0.632 自助法。每个基学习器基于不同子训练集进行训练,并综合所有基学习器的预测值得到最终的预测结果。Bagging 常用的综合方法是投票法,票数最多的类别为预测类别。
Boosting 训练过程为阶梯状,基模型的训练是有顺序的,每个基模型都会在前一个基模型学习的基础上进行学习,最终综合所有基模型的预测值产生最终的预测结果,用的比较多的综合方式为加权法。
Stacking 是先用全部数据训练好基模型,然后每个基模型都对每个训练样本进行的预测,其预测值将作为训练样本的特征值,最终会得到新的训练样本,然后基于新的训练样本进行训练得到模型。对测试集也做相同的操作,得到最终预测结果。
上图绿色是训练过程,红色是预测过程。
那么,为什么集成学习会好于单个学习器呢?原因可能有三:
我们从偏差和方差的角度来理解集成学习。
偏差(Bias)描述的是预测值和真实值之差;方差(Variance)描述的是预测值作为随机变量的离散程度。放一张很经典的图:
我们常说集成学习中的基模型是弱模型,通常来说弱模型是偏差高(在训练集上准确度低)方差小(防止过拟合能力强)的模型,但并不是所有集成学习框架中的基模型都是弱模型。Bagging 和 Stacking 中的基模型为强模型(偏差低,方差高),而Boosting 中的基模型为弱模型(偏差高,方差低)。
弱模型,模型简单,偏差高,方差小,防止过拟合能力强;
强模型,模型复杂,偏差小,方差大,容易过拟合。
在 Bagging 和 Boosting 框架中,通过计算基模型的期望和方差我们可以得到模型整体的期望和方差。为了简化模型,我们假设基模型的期望为 $\mu$ ,方差 $\sigma ^ 2$ ,模型的权重为 $r$ ,两两模型间的相关系数 $\rho$ 相等。由于 Bagging 和 Boosting 的基模型都是线性组成的,那么有:
模型总体期望:
模型总体方差(公式推导参考协方差的性质,协方差与方差的关系):
模型的准确度可由偏差和方差共同决定:
对于 Bagging 来说,每个基模型的权重等于 1/m 且期望近似相等,故我们可以得到:
通过上式我们可以看到:
在此我们知道了为什么 Bagging 中的基模型一定要为强模型,如果 Bagging 使用弱模型则会导致整体模型的偏差提高,而准确度降低。
Random Forest 是经典的基于 Bagging 框架的模型,并在此基础上通过引入特征采样和样本采样来降低基模型间的相关性,在公式中显著降低方差公式中的第二项,略微升高第一项,从而使得整体降低模型整体方差。
对于 Boosting 来说,由于基模型共用同一套训练集,所以基模型间具有强相关性,故模型间的相关系数近似等于 1,针对 Boosting 化简公式为:
通过观察整体方差的表达式我们容易发现:
基于 Boosting 框架的 Gradient Boosting Decision Tree 模型中基模型也为树模型,同 Random Forrest,我们也可以对特征进行随机抽样来使基模型间的相关性降低,从而达到减少方差的效果。
Random Forest(随机森林),用随机的方式建立一个森林。RF 算法由很多决策树组成,每一棵决策树之间没有关联。建立完森林后,当有新样本进入时,每棵决策树都会分别进行判断,然后基于投票法给出分类结果。
Random Forest(随机森林)是 Bagging 的扩展变体,它在以决策树为基学习器构建 Bagging 集成的基础上,进一步在决策树的训练过程中引入了随机特征选择,因此可以概括 RF 包括四个部分:
随机选择样本和 Bagging 相同,采用的是 Bootstrap 自助采样法;随机选择特征具体来说,传统决策树在选择划分属性时是在当前节点的属性集合(假定有$d$个属性)中选择一个最优属性;而在RF中,对基决策树的每个节点,先从该节点的属性集合中随机选择一个包含$k$个属性的子集,然后再从这个子集中选择一个最优属性用于划分。这里的参数$k$控制了随机性的引入程度:若$k=d$,则基决策树的构建与传统决策树相同;若令$k=1$,则是随机选择一个属性用于划分;一般情况下,推荐值$k=log_2 d$。
这种随机性导致随机森林的偏差会有稍微的增加(相比于单棵不随机树),但是由于随机森林的“平均”特性,会使得它的方差减小,而且方差的减小补偿了偏差的增大,因此总体而言是更好的模型。
随机采样由于引入了两种采样方法保证了随机性,所以每棵树都是最大可能的进行生长就算不剪枝也不会出现过拟合。
AdaBoost(Adaptive Boosting,自适应增强),其自适应在于:前一个基本分类器分错的样本会得到加强,加权后的全体样本再次被用来训练下一个基本分类器。同时,在每一轮中加入一个新的弱分类器,直到达到某个预定的足够小的错误率或达到预先指定的最大迭代次数。
Adaboost 迭代算法有三步:
Adaboost 模型是加法模型,学习算法为前向分步学习算法,损失函数为指数函数的分类问题。
加法模型:最终的强分类器是由若干个弱分类器加权平均得到的。
前向分布学习算法:算法是通过一轮轮的弱学习器学习,利用前一个弱学习器的结果来更新后一个弱学习器的训练集权重。第 k 轮的强学习器为:
定义损失函数为 n 个样本的指数损失函数,在分类正确的时候,指数部分为负数,单调递减;在分类错误的时候,指数部分为正数,单调递增,满足损失函数的定义:
利用前向分布学习算法的关系可以得到:
因为 $F_{k-1}(x)$ 已知,所以令 $w_{k,i} = exp(-y_iF_{k-1}(x_i))$ ,随着每一轮迭代而将这个式子带入损失函数,损失函数转化为:
于是分类器$f_k(x)$ 和这个分类器的权重$\alpha_k$可以表示成:
我们先求$f_k(x)$ ,分类器的权重$\alpha_k$可以认为是一个确定的数,$f_k(x)$是使得分错的(带权重的)样本里损失函数最小的那个,可以得到:
注意:重点理解上面两个式子的等价性,这一步相当于针对不同权重的样本训练分类器。
然后再求$\alpha_k$,将 $f_k(x)$ 带入损失函数,并对 $\alpha$ 求导,使其等于 0,则就得到了:
其中$e_k$ 即为我们前面的分类误差率。
最后看样本权重的更新。利用 $F_{k}(x) = F_{k-1}(x) + \alpha_kf_k(x)$ 和 $w_{k+1,i}=w_{k,i}exp[-y_i\alpha_kf_k(x,i)]$ ,即可得:
这样就得到了样本权重更新公式。
$\alpha_k$的详细推导过程可以参考机器学习笔记:AdaBoost 公式推导。
正则化
为了防止 Adaboost 过拟合,我们通常也会加入正则化项,这个正则化项我们通常称为步长(learning rate)。对于前面的弱学习器的迭代
加上正则化项 $\mu$ 我们有:
$\mu$ 的取值范围为 $0<\mu\leq1$ 。对于同样的训练集学习效果,较小的 $\mu$ 意味着我们需要更多的弱学习器的迭代次数。通常我们用步长和迭代最大次数一起来决定算法的拟合效果。
GBDT(Gradient Boosting Decision Tree)是一种迭代的决策树算法,该算法由多棵决策树组成,从名字中我们可以看出来它是属于 Boosting 策略。GBDT 是被公认的泛化能力较强的算法。
Gradient Boosting 和其它 Boosting 算法一样,通过将表现一般的数个模型(通常是深度固定的决策树)组合在一起来集成一个表现较好的模型。抽象地说,模型的训练过程是对一任意可导目标函数的优化过程。通过反复地选择一个指向负梯度方向的函数,该算法可被看做在函数空间里对目标函数进行优化。因此可以说 Gradient Boosting = Gradient Descent + Boosting。
和 AdaBoost 一样,Gradient Boosting 也是重复选择一个表现一般的模型并且每次基于先前模型的表现进行调整。不同的是,AdaBoost 是通过提升错分数据点的权重来定位模型的不足而 Gradient Boosting 是通过算梯度(gradient)来定位模型的不足。因此相比 AdaBoost, Gradient Boosting 可以使用更多种类的目标函数。
GBDT 由三个概念组成:Regression Decision Tree(即 DT)、Gradient Boosting(即 GB),和 Shrinkage(一个重要演变)
如果认为 GBDT 由很多分类树那就大错特错了(虽然调整后也可以分类)。对于分类树而言,其值加减无意义(如性别),而对于回归树而言,其值加减才是有意义的(如说年龄)。GBDT 的核心在于累加所有树的结果作为最终结果,所以 GBDT 中的树都是回归树,不是分类树,这一点相当重要。
回归树在分枝时会穷举每一个特征的每个阈值以找到最好的分割点,衡量标准是最小化均方误差。
上面说到 GBDT 的核心在于累加所有树的结果作为最终结果,GBDT 的每一棵树都是以之前树得到的残差来更新目标值,这样每一棵树的值加起来即为 GBDT 的预测值。
模型的预测值可以表示为:
$f_{i}(x)$ 为基模型与其权重的乘积,模型的训练目标是使预测值 $F_k(x)$ 逼近真实值 $y$,也就是说要让每个基模型的预测值逼近各自要预测的部分真实值。由于要同时考虑所有基模型,导致了整体模型的训练变成了一个非常复杂的问题。所以研究者们想到了一个贪心的解决手段:每次只训练一个基模型。那么,现在改写整体模型为迭代式:
这样一来,每一轮迭代中,只要集中解决一个基模型的训练问题:使 $F_k(x)$ 逼近真实值 $y$ 。
举个例子:比如说 A 用户年龄 20 岁,第一棵树预测 12 岁,那么残差就是 8,第二棵树用 8 来学习,假设其预测为 5,那么其残差即为 3,如此继续学习即可。
那么 Gradient 从何体现?其实很简单,其残差其实是最小均方损失函数关于预测值的反向梯度(划重点):
也就是说,预测值和实际值的残差与损失函数的负梯度相同。
但要注意,基于残差 GBDT 容易对异常值敏感,举例:
很明显后续的模型会对第 4 个值关注过多,这不是一种好的现象,所以一般回归类的损失函数会用绝对损失或者 Huber 损失函数来代替平方损失函数。
GBDT 的 Boosting 不同于 Adaboost 的 Boosting,GBDT 的每一步残差计算其实变相地增大了被分错样本的权重,而对与分对样本的权重趋于 0,这样后面的树就能专注于那些被分错的样本。
可以理解:adaboost中是显示的设置了每个样本的权重,而gbdt则是由于错分样本导致残差变大,变相的加大了下一次基模型学习时样本的权重,所以两种方式不同,但有类似的效果。
Shrinkage 的思想认为,每走一小步逐渐逼近结果的效果要比每次迈一大步很快逼近结果的方式更容易避免过拟合。即它并不是完全信任每一棵残差树。
Shrinkage 不直接用残差修复误差,而是只修复一点点,把大步切成小步。本质上 Shrinkage 为每棵树设置了一个 weight,累加时要乘以这个 weight,当 weight 降低时,基模型数会配合增大。
从决策边界来说,线性回归的决策边界是一条直线,逻辑回归的决策边界根据是否使用核函数可以是一条直线或者曲线(如下图所示),而GBDT的决策边界可能是很多条线。
GBDT并不一定总是好于线性回归或逻辑回归。根据没有免费的午餐原则,没有一个算法是在所有问题上都能好于另一个算法的。根据奥卡姆剃刀原则,如果GBDT和线性回归或逻辑回归在某个问题上表现接近,那么我们应该选择相对比较简单的线性回归或逻辑回归。具体选择哪一个算法还是要根据实际问题来决定。
随机森林 – Random forest
机器学习笔记:AdaBoost 公式推导
【机器学习】决策树(中)——Random Forest、Adaboost、GBDT (非常详细)
Adaboost, GBDT 与 XGBoost 的区别
Boosting和AdaBoost的可视化的清晰的解释
机器学习算法中GBDT与AdaBoost的区别与联系
本文主要参考了阿泽作者的笔记,对于不理解的地方,我会添加个人注释。
决策树是一个非常常见并且优秀的机器学习算法,它易于理解、可解释性强,其可作为分类算法,也可用于回归模型。
这部分内容主要参考了决策树算法-理论篇-如何计算信息纯度 。
信息熵(一般用H 表示),度量信息熵的单位是比特。就是说,信息量的多少是可以量化的。一条信息量的多少与信息的不确定性有关,可以认为,信息量就等于不确定性的多少(信息的不确定度)。
设$X$是一个取有限个值的离散随机变量,其信息熵的计算公式如下:
其中,该公式的含义是:
总之,就是要知道,信息量的多少是可以用数学公式计算出来的,用信息论中的专业术语就叫做信息熵。信息熵越大,信息量也就越大。
下面这张图片解释了信息熵的由来。
假设我们有如下数据集:
序号 | 条件:天气晴朗? | 条件:是否刮风? | 结果:去踢球吗? |
---|---|---|---|
1 | 是 | 否 | 去 |
2 | 是 | 是 | 不去 |
3 | 否 | 是 | 不去 |
4 | 否 | 否 | 不去 |
可以看到这个表格中有4 行(第一行表头不算),4 列数据。一般在机器学习中,最后一列称为目标(target),前边的列都称为特征(features)。
根据表格,我们可以知道,所有的分类共有2 种,也就是“去” 和“不去”,“去”出现了1 次,“不去”出现了3 次。
分别计算“去” 和“不去” 出现的概率:
P(去) = 1 / 4 = 0.25
P(不去) = 3 / 4 = 0.75
然后,根据熵的计算公式来计算“去”和“不去” 的信息熵,其中log 以2 为底:
H(去) = 0.25 * log 0.25 = -0.5
H(不去) = 0.74 * log 0.75 = -0.31127812445913283
所以,整个表格含有的信息量就是:
H(表格) = -(H(去) + H(不去)) = 0.81127812445913283
将计算信息熵的过程用Python
代码实现,如下:
1 | import math |
下面用该函数来计算表格的信息熵:
1 | # 将表格转化为 python 列表 |
可见,用代码计算出来的结果是 0.811278124459,跟我们手算的结果 0.81127812445913283 是一样的(保留的小数位数不同)。
信息的纯度与信息熵成反比,可以将信息熵理解为 “不纯度” 。
举一个例子,比如我们想分类A和B,用公式量化来体现就是:
1)如果分类结果中A和B各占50%,那么意味着分类结果很失败,这无异于随机地乱猜,完全没起到分类效果,公式计算结果如下:
2)如果分类结果中A占比100%,B占比0%或者B占比100%,A占比0%时,那么意味着分类很成功,因为我们成功地区分了A和B,我们就说此时的纯度很高,公式计算结果如下:
条件熵$H(Y|X)$表示在已知随机变量$X$的条件下随机变量$Y$的不确定性。随机变量$X$给定的条件下随机变量$Y$的条件熵$H(Y|X)$,定义为$X$给定条件下$Y$的条件概率分布的熵对$X$的数学期望。
信息增益就是,在根据某个属性划分数据集的前后,信息量发生的变化。
信息熵代表不纯度,只要将分类前后的不纯度相减,那就可以得到一种 “纯度提升值” 的指标,我们把它叫做 “信息增益”。
特征$A$对训练数据集$D$的信息增益$Gain(D,A)$,定义为集合$D$的经验熵$H(D)$与特征$A$给定条件下$D$的条件熵$H(D|A)$之差。信息增益的计算公式如下:
所有子节点的信息熵会按照子节点在父节点中的出现的概率来计算,这叫做归一化信息熵。
信息增益的目的在于,将数据集划分之后带来的纯度提升,也就是信息熵的下降。如果数据集在根据某个属性划分之后,能够获得最大的信息增益,那么这个属性就是最好的选择。
所以,我们想要找到根节点,就需要计算每个属性作为根节点时的信息增益,那么获得信息增益最大的那个属性,就是根节点。
信息增益等于按照某个属性划分前后的信息熵之差。它的计算方式简单来说,先基于特征A进行划分,再基于目标变量进行划分,这是一个嵌套的过程。
这个表格划分之前的信息熵我们已经知道了,就是我们在上面计算的结果:
H(表格) = 0.81127812445913283
。接下来,我们计算按照“天气晴朗”划分的信息增益。按照“天气晴朗”划分后有两个表格。
表格1,“天气晴朗”的值为“是”:
序号 | 条件:天气晴朗? | 条件:是否刮风? | 结果:去踢球吗? |
---|---|---|---|
1 | 是 | 否 | 去 |
2 | 是 | 是 | 不去 |
分类共有2 种,也就是“去” 和“不去”,“去”出现了1 次,“不去”出现了1 次。
所以,“去” 和“不去” 出现的概率均为0.5:
P(去) = P(不去) = 1 / 2 = 0.5
然后,“去”和“不去” 的信息熵,其中log 以2 为底:
H(去) = H(不去) = 0.5 * log 0.5 = -0.5
所以,表格1 含有的信息量就是:
H(表格1) = -(H(去) + H(不去)) = 1
表格2,“天气晴朗”的值为“否”:
序号 | 条件:天气晴朗? | 条件:是否刮风? | 结果:去踢球吗? |
---|---|---|---|
3 | 否 | 是 | 不去 |
4 | 否 | 否 | 不去 |
所有的分类只有1 种,是“不去”。所以:
P(不去) = 1
然后,“不去” 的信息熵,其中log 以2 为底:
H(不去) = 1 * log 1 = 0
所以,表格2 含有的信息量就是:
H(表格2) = 0
总数据共有4 份:
所以,最终按照“天气晴朗”划分的信息增益为:
G(天气晴朗) = H(表格) - (0.5*H(表格1) + 0.5*H(表格2)) = H(表格) - 0.5 = 0.31127812445913283。
不同于逻辑回归,决策树属于非线性模型,可以用于分类,也可用于回归。它是一种树形结构,可以认为是if-then规则的集合,是以实例为基础的归纳学习。基本思想是自顶向下,以信息增益(或信息增益比,基尼系数等)为度量构建一颗度量标准下降最快的树,每个内部节点代表一个属性的测试,直到叶子节点处只剩下同一类别的样本。它的决策流程如下所示:
ID3 算法是建立在奥卡姆剃刀(用较少的东西,同样可以做好事情)的基础上:越是小型的决策树越优于大的决策树。
奥卡姆剃刀:“如无必要,勿增实体”,即“简单有效原理”。
从信息论的知识中我们知道:信息熵越大,从而样本纯度越低。ID3 算法的核心思想就是以信息增益来度量特征选择,选择信息增益最大的特征进行分裂。算法采用自顶向下的贪婪搜索遍历可能的决策树空间(C4.5 也是贪婪搜索)。 其大致步骤为:
从这个过程,我们可以发现:最开始选择的特征肯定是提供信息量最大的,因为它是遍历所有特征后选择的结果。因此,按照决策过程中特征从上到下的顺序,我们也可以将特征的重要程度进行排序。这也就解释了为什么树模型有feature_importance这个参数了。
ID3 使用的分类标准是信息增益,它表示得知特征 A 的信息而使得样本集合不确定性减少的程度。
数据集的信息熵:
其中$C_k$表示集合$D$中属于第$k$类样本的样本子集。
针对某个特征 $A$,对于数据集 $D$ 的条件熵 $H(D|A)$为:
其中 $D_i$ 表示 $D$ 中特征 $A$ 取第$ i$ 个值的样本子集,$ D_{ik}$ 表示$ D_i $中属于第$ k $类的样本子集。
信息增益 = 信息熵 - 条件熵:
信息增益越大表示使用特征 A 来划分所获得的“纯度提升越大”。
C4.5 算法最大的特点是克服了 ID3 对特征数目的偏重这一缺点,引入信息增益率来作为分类标准。
C4.5 相对于 ID3 的缺点对应有以下改进方式:
对于缺失值的处理可以分为两个子问题:
问题一:在特征值缺失的情况下进行划分特征的选择?(即如何计算特征的信息增益率)
C4.5 的做法是:对于具有缺失值特征,用没有缺失的样本子集所占比重来折算;
问题二:选定该划分特征,对于缺失该特征值的样本如何处理?(即到底把这个样本划分到哪个结点里)
C4.5 的做法是:将样本同时划分到所有子节点,不过要调整样本的权重值,其实也就是以不同概率划分到不同节点中。
利用信息增益率可以克服信息增益的缺点,其公式为
$H_A(D)$ 称为特征 $A$ 的固有值,$n$是特征$A$取值的个数。它的计算公式与熵类似,只不过数据集的熵是依据类别进行划分的,而这里是将特征A取值相同的样本划分到同一个子集中,来计算熵。
信息增益比本质: 是在信息增益的基础之上乘上一个惩罚参数——分裂信息(Split information)。特征个数较多时,惩罚参数较小;特征个数较少时,惩罚参数较大。
这里需要注意,信息增益率对可取值较少的特征有所偏好(分母越小,整体越大),因此 C4.5 并不是直接用增益率最大的特征进行划分,而是使用一个启发式方法:先从候选划分特征中找到信息增益高于平均值的特征,再从中选择增益率最高的。
为什么要剪枝:过拟合的树在泛化能力的表现非常差。
在节点划分前来确定是否继续增长,及早停止增长的主要方法有:
预剪枝不仅可以降低过拟合的风险而且还可以减少训练时间,但另一方面它是基于“贪心”策略,会带来欠拟合风险。
在已经生成的决策树上进行剪枝,从而得到简化版的剪枝决策树。
C4.5 采用的悲观剪枝方法,用递归的方式从低往上针对每一个非叶子节点,评估用一个最佳叶子节点去代替这课子树是否有益。如果剪枝后与剪枝前相比其错误率是保持或者下降,则这棵子树就可以被替换掉。C4.5 通过训练数据集上的错误分类数量来估算未知样本上的错误率。
后剪枝决策树的欠拟合风险很小,泛化性能往往优于预剪枝决策树。但同时其训练时间会大的多。
ID3 和 C4.5 虽然在对训练样本集的学习中可以尽可能多地挖掘信息,但是其生成的决策树分支、规模都比较大,CART 算法的二分法可以简化决策树的规模,提高生成决策树的效率。
CART 包含的基本过程有分裂,剪枝和树选择。
CART 在 C4.5 的基础上进行了很多提升。
熵模型拥有大量耗时的对数运算,基尼指数在简化模型的同时还保留了熵模型的优点。基尼指数代表了模型的不纯度,基尼系数越小,不纯度越低,特征越好。所以决策树分裂选取Feature的时候,要选择使基尼指数最小的Feature,但注意信息增益(率)则是选择最大值,这个值的选取是相反的。
对于给定的样本集合$D$,其基尼指数定义为:
在特征$A$的条件下,集合$D$的基尼指数定义为:
其中 $k$ 代表类别。
基尼指数反映了从数据集中随机抽取两个样本,其类别标记不一致的概率。因此基尼指数越小,则数据集纯度越高。基尼指数偏向于特征值较多的特征,类似信息增益。基尼指数可以用来度量任何不均匀分布,是介于 0~1 之间的数,0 是完全相等,1 是完全不相等,
此外,当 CART 为二分类,其表达式为:
我们可以看到在平方运算和二分类的情况下,其运算更加简单。当然其性能也与熵模型非常接近。
那么问题来了:基尼指数与熵模型性能接近,但到底与熵模型的差距有多大呢?
我们知道 $ln(x) = -1+x +o(x)$ ,所以
我们可以看到,基尼指数可以理解为熵模型的一阶泰勒展开。这边在放上一张很经典的图:
如果特征值是连续值:特征a有连续值m个,从小到大排列。m个数值就有m-1个切分点,分别使用每个切分点把连续数值离散划分成两类,将节点数据集按照划分点分为D1和D2子集,然后计算每个划分点下对应的基尼指数,对比所有信息增益比(CART基尼指数),选择值最小(最大)的一个作为最终的特征划分。
以上就实现了将连续特征值离散化,但是CART与ID3,C4.5处理离散属性不同的是:如果当前节点为连续属性,则该属性(剩余的属性值)后面还可以参与子节点的产生选择过程。
如果特征值是离散值:CART的处理思想与C4.5稍微所有不同。如果离散特征值多于两个,那么C4.5会在节点上根据特征值划分出多叉树。但是CART则不同,无论离散特征值有几个,在节点上都划分成二叉树。CART树是如何进行分类的呢?
还是假设特征a有m个离散值。分类标准是:每一次将其中一个特征分为一类,其它非该特征分为另外一类。依照这个标准遍历所有的分类情况,计算每种分类下的基尼指数,最后选择值最小的一个作为最终的特征划分。
特征值连续和离散有各自的处理方法,不应该混淆使用。比如分类0,1,2只代表标签含义,如果进行加减的运算或者求平均则没有任何意义。因此,CART分类树会根据特征类型选择不同的划分方法,并且与C4.5不同是,它永远只有两个分支。
上文说到,模型对于缺失值的处理会分为两个子问题:
对于问题 1,CART 一开始严格要求分裂特征评估时只能使用在该特征上没有缺失值的那部分数据,在后续版本中,CART 算法使用了一种惩罚机制来抑制提升值,从而反映出缺失值的影响(例如,如果一个特征在节点的 20% 的记录是缺失的,那么这个特征就会减少 20% 或者其他数值)。
对于问题 2,CART 算法的机制是为树的每个节点都找到代理分裂器,无论在训练数据上得到的树是否有缺失值都会这样做。在代理分裂器中,特征的分值必须超过默认规则的性能才有资格作为代理(即代理就是代替缺失值特征作为划分特征的特征),当 CART 树中遇到缺失值时,这个实例划分到左边还是右边是决定于其排名最高的代理,如果这个代理的值也缺失了,那么就使用排名第二的代理,以此类推,如果所有代理值都缺失,那么默认规则就是把样本划分到较大的那个子节点。代理分裂器可以确保无缺失训练数据上得到的树可以用来处理包含确实值的新数据。
采用一种“基于代价复杂度的剪枝”方法进行后剪枝,这种方法会生成一系列树,每个树都是通过将前面的树的某个或某些子树替换成一个叶节点而得到的,这一系列树中的最后一棵树仅含一个用来预测类别的叶节点。然后用一种成本复杂度的度量准则来判断哪棵子树应该被一个预测类别值的叶节点所代替。这种方法需要使用一个单独的测试数据集来评估所有的树,根据它们在测试数据集熵的分类性能选出最佳的树。
我们来看具体看一下代价复杂度剪枝算法:
首先我们将最大树称为 $T_0$,我们希望减少树的大小来防止过拟合,但又担心去掉节点后预测误差会增大,所以我们定义了一个损失函数来达到这两个变量之间的平衡。损失函数定义如下:
$T$ 为任意子树,$ C(T) $为预测误差, $|T|$ 为子树 $T$ 的叶子节点个数, $\alpha$ 是参数,$ C(T) $衡量训练数据的拟合程度, $|T| $衡量树的复杂度, $\alpha$ 权衡拟合程度与树的复杂度。
那么如何找到合适的 $\alpha$ 来使得复杂度和拟合度达到最好的平衡点呢,最好的办法就是令$ \alpha$ 从 0 取到正无穷,对于每一个固定的 $\alpha $,我们都可以找到使得 $C_\alpha(T) $最小的最优子树 $T(\alpha) $。当$ \alpha$ 很小的时候,$ T_0$ 是最优子树;当 $\alpha $最大时,单独的根节点是这样的最优子树。随着 $\alpha$ 增大,我们可以得到一个这样的子树序列:$ T_0, T_1, T_2, T_3, … ,T_n$ ,这里的子树$ T_{i+1} $生成是根据前一个子树 $T_i $剪掉某一个内部节点生成的。
Breiman 证明:将 $\alpha$ 从小增大, $0=\alpha_0<\alpha_0<…<\alpha_n<\infty$ ,在每个区间$ [\alpha_i,\alpha_{i+1}) $中,子树 $T_i $是这个区间里最优的。
这是代价复杂度剪枝的核心思想。
我们每次剪枝都是针对某个非叶节点,其他节点不变,所以我们只需要计算该节点剪枝前和剪枝后的损失函数即可。
对于任意内部节点 $t$,剪枝前的状态,有 $|T_t|$ 个叶子节点,预测误差是$ C(T_t) $;剪枝后的状态:只有本身一个叶子节点,预测误差是$ C(t) $。
因此剪枝前以 $t $节点为根节点的子树的损失函数是:
剪枝后的损失函数是
通过 Breiman 证明我们知道一定存在一个 $\alpha $使得 $C_\alpha(T)=C_\alpha(t)$ ,使得这个值为:
$\alpha$ 的意义在于,$ [\alpha_i,\alpha_{i+1}) $中,子树 $T_i $是这个区间里最优的。当 $\alpha $大于这个值是,一定有 $C_\alpha(T)>C_\alpha(t)$ ,也就是剪掉这个节点后都比不剪掉要更优。所以每个最优子树对应的是一个区间,在这个区间内都是最优的。
然后我们对 $T_i$ 中的每个内部节点$ t $都计算:
$g(t)$ 表示阈值,故我们每次都会减去最小的$ T_t $。
CART 的一大优势在于:无论训练数据集有多失衡,它都可以将其子冻消除不需要建模人员采取其他操作。
CART 使用了一种先验机制,其作用相当于对类别进行加权。这种先验机制嵌入于 CART 算法判断分裂优劣的运算里,在 CART 默认的分类模式中,总是要计算每个节点关于根节点的类别频率的比值,这就相当于对数据自动重加权,对类别进行均衡。
对于一个二分类问题,节点 node 被分成类别 1 当且仅当:
比如二分类,根节点属于 1 类和 0 类的分别有 20 和 80 个。在子节点上有 30 个样本,其中属于 1 类和 0 类的分别是 10 和 20 个。如果 10/20>20/80,该节点就属于 1 类。
通过这种计算方式就无需管理数据真实的类别分布。假设有 $K $个目标类别,就可以确保根节点中每个类别的概率都是$ 1/K$。这种默认的模式被称为“先验相等”。
先验设置和加权不同之处在于先验不影响每个节点中的各类别样本的数量或者份额。先验影响的是每个节点的类别赋值和树生长过程中分裂的选择。
CART(Classification and Regression Tree,分类回归树),从名字就可以看出其不仅可以用于分类,也可以应用于回归。与分类树不同,回归树的预测变量是连续值,比如预测一个人的年龄,又或者预测季度的销售额等等。另外,回归树在选择特征的度量标准和决策树建立后预测的方式上也存在不同。
对于连续值,CART 分类树采用基尼系数的大小来度量特征的各个划分点。在回归模型中,我们使用常见的RSS残差平方和。线性回归的损失函数是以最小化离差平方和的形式给出的,回归树使用的度量标准也是一样的,通过最小化残差平方和作为判断标准,公式如下:
其中,$R_1、R_2$是划分的两个子集,回归树是二叉树,固只有两个子集; $c_1、c_2$是$R_1、R_2$子集的样本均值,$y_i$是样本目标变量的真实值,$j$是当前的样本特征,$s$是划分点。
上面公式的含义是:计算所有的特征以及相应所有切分点下的残差平方和,找到一组(特征$j$,切分点$s$),以满足:分别最小化左子树和右子树的残差平方和,并在此基础上再次最小化二者之和。
对于决策树建立后做预测的方式,上面讲到了 CART 分类树采用叶子节点里概率最大的类别作为当前节点的预测类别。而回归树输出不是类别,它采用的是用最终叶子的均值或者中位数来预测输出结果。以均值为例,进行详细描述。
一个回归树对应着输入特征空间的一个划分,以及在划分单元上的输出值。先假设数据集已被划分,R1,R2,…,Rm共m的子集,回归树要求每个划分Rm中都对应一个固定的输出值$c_m$。这个$c_m$值其实就是每个子集中所有样本的目标变量$y$的平均值,并以此$c_m$作为该子集的预测值。
最后通过总结的方式对比下 ID3、C4.5 和 CART 三者之间的差异。
除了之前列出来的划分标准、剪枝策略、连续值确实值处理方式等之外,我再介绍一些其他差异:
我们前面介绍了决策树的特征选择,生成,和剪枝,然后对ID3, C4.5和CART算法也分别进行了详细的分析。下面我们来看看决策树算法作为一个大类别的分类回归算法的优缺点。
决策树算法的缺点
决策树算法-理论篇-如何计算信息纯度
【机器学习】决策树(上)——ID3、C4.5、CART(非常详细)
决策树—信息增益,信息增益比,Geni指数的理解
决策树学习笔记(一):特征选择
决策树学习笔记(三):CART算法,决策树总结
我们常常需要根据正则表达式,来对字符串进行过滤。例如仅保留汉字、数字、字母等。用法如下例所示:
1 | # coding = utf-8 |
输出结果如下所示:
1 | 原字符串: a¥1aB23Cqqq$我.04 |
nohup
是 no hung up 的缩写,意思是不挂断 。使用 Xshell 等 Linux 客户端工具,远程执行 Linux 脚本时,有时候会由于网络问题,导致客户端失去连接,终端断开,脚本运行一半就意外结束了。这种时候,就可以用
nohup
指令来运行指令,即使客户端与服务端断开,服务端的脚本仍可继续运行。
nohup
语法格式:
1 | nohup command [arg...] |
说明:
nohup.out
文件中。例如:
执行 nohup sh test.sh
脚本命令后,终端不能接收任何输入,标准输出 会输出到当前目录的nohup.out
文件。即使关闭 xshell 退出后,当前 session 依然继续运行。
&
语法格式:
1 | command [arg...] & |
说明:
nohup.out
文件中。例如:
执行 sh test.sh &
脚本命令后 ,关闭 xshell,脚本程序也立刻停止。
语法格式:
1 | nohup command [arg...] & |
说明:
nohup.out
中,例子:
执行 nohup sh test.sh &
命令后,能进行输入操作,标准输出 的日志写入到 nohup.out
文件,即使关闭 xshell,退出当前 session 后,脚本命令依然继续运行。
输入输出问题已经解决了, 是不是就完美了? 其实还有一个问题没有解决, 请往下看!
上面提到的日志文件默认名称是 nohup.out
,如果修改日志文件的名称,则用到 重定向
,符号是 >
,语法格式是
1 | > logFile |
说明:
>
是重定向的符号。>>
表示输出以追加的方式重定向。此时, nohup
、 &
、 >
三者一块使用的 语法格式 :
1 | nohup command >logFile & |
示例:
1 | nohup start.sh >aa.log & |
说明:执行上面的命令后,可以进行输入,也能在后台运行,运行的日志输出到 aa.log
日志中。
1 | nohup command >logFile & |
虽然解决输入输出,后台也能运行问题,但是还有一项是 错误信息 无法输出到 日志文件中,要解决这个问题,需要增加命令 2 > file
。
标准输出 和 错误信息 同时使用,语法格式如下:
1 | >logFile1 2 >logFile2 |
有人会疑问,2
是什么意思? 请往下看。
Linux 标准输入、输出、错误信息的符号:
0
表示 stdin (standard input) 标准信息输入
;1
表示 stdout (standard output) 标准信息输出
;2
表示 stderr (standard error) 错误信息
;/dev/null
表示空设备文件。 如果不想输出任何的日志时,使用此参数 。再来回顾上面的示例:
1 | >logFile1 2 >logFile2 |
> logFile1
:即 1 >logFile1
,1 是标准信息输出
,是默认的,可以省略,logFile1 是 日志文件名字。
2 >logFile2
:2 是错误信息
,即将 错误信息
输出 到 logFile2 文件中 。
到这时,明白 2
含义了吧!
如果想把 错误信息 和 标准输出 在同一个文件中 ,使用 2>&1
。 语法如下:
1 | >logFile 2>&1 |
说明:
>logFile
表示 标准信息 输出到 logFile 文件中;2>&1
表示 把 2(错误信息) 重定向, 输出到 1(标准输出) 中 。两者的共同使用,表示 把 2(错误信息) 、1(标准输出) 都输出到同一个文件(logFile)中。
提示:/dev/null
表示空设备文件。 如果不想输出任何的日志时,使用此参数 。
综上所述, 功能最全、推荐语法如下:
1 | nohup command >logFile 2>&1 & |
示例:
1 | nohup start.sh > mySysLog.log 2>&1 & |
说明: 执行命令后,并且将 标准输出(1)
、错误信息(2)
写入到 mySysLog.log 文件中。
如果脚本一直运行下去,nohup.out 日志会一直增长,日志但是硬盘容量有限,怎么把日志文件的大小减少 ?
注意,千万别直接删除日志文件,会造成服务无法输出日志,服务异常直接停止运行,这是最严重生产事故。
不停止服务,直接清空 nohup.out 文件有两种方法:
1 | # 第1种: |
输出的日志太多,nohup.out 增长特别快,对于不重要的日记,可以不记录,选择只记录警告级别比较高的日志。
1 | # 只输出错误信息到日志文件,其它日志不输出 |
不想输出日志,什么日志都不要,只要服务能正常运行就行了。
1 | # 什么日志也不输出 |
top命令是Linux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况,类似于Windows的任务管理器。
top显示系统当前的进程和其他状况,是一个动态显示过程,即可以通过用户按键来不断刷新当前状态。如果在前台执行该命令,它将独占前台,直到用户终止该程序为止。 比较准确的说,top命令提供了实时的对系统处理器的状态监视。它将显示系统中CPU最“敏感”的任务列表。该命令可以按CPU使用。内存使用和执行时间对任务进行排序;而且该命令的很多特性都可以通过交互式命令或者在个人定制文件中进行设定。
下面详细介绍它的使用方法。
1 | top - 01:06:48 up 1:22, 1 user, load average: 0.06, 0.60, 0.48 |
统计信息区前五行是系统整体的统计信息。第一行是任务队列信息,同 uptime 命令的执行结果。其内容如下:
1 | 01:06:48 当前时间 |
第二、三行为进程和CPU的信息。当有多个CPU时,这些内容可能会超过两行。内容如下:
1 | total 进程总数 |
最后两行为内存信息。内容如下:
1 | Mem: |
进程信息区统计信息区域的下方显示了各个进程的详细信息。首先来认识一下各列的含义。
1 | 序号 列名 含义 |
默认情况下仅显示比较重要的 PID、USER、PR、NI、VIRT、RES、SHR、S、%CPU、%MEM、TIME+、COMMAND 列。可以通过下面的快捷键来更改显示内容。
top使用格式:
1 | top [-] [d] [p] [q] [c] [C] [S] [s] [n] |
参数说明:
1 | d 指定每两次屏幕信息刷新之间的时间间隔。当然用户可以使用s交互命令来改变之。 |
附常用操作:
1 | top //每隔5秒显式所有进程的资源占用情况 |
在top命令执行过程中可以使用的一些交互命令。这些命令都是单字母的,如果在命令行中使用了-s选项, 其中一些命令可能会被屏蔽。
1 | h:显示帮助画面,给出一些简短的命令总结说明; |
顶部的内存信息可以在top运行时按E切换,每次切换转换率为1000,只是没有单位,切换的单位为 k,m,g,t,p。
例如:
底下的进程信息按e切换,每次切换转换率为1000,切换的单位也是 k,m,g,t,p。
Expressive TTS是目前语音合成领域中比较活跃的方向,它和单纯TTS的区别是,它更关注合成声音的风格(例如新闻播报,讲故事,解说)、情感(例如生气,兴奋,悲伤)、韵律(例如重读,强调、语调)等等。自从深度学习技术大放异彩后,语音合成模型在合成声音的自然度方面有了极大的提高(例如Tacotron,Tacotron2,WaveNet),跳词复读的问题也在最近得到了解决(例如DurIAN,FastSpeech),而深度学习不仅可以让语音的自然度得到大幅度的提升,对一些难以显式建模的特征上也有很强大的学习能力,因此,让语音合成能更加具有expressive成为了一个研究热点。
https://arxiv.org/abs/1711.00520
这一篇是 google 的王宇轩大佬早在 2017 年上传到 arxiv 上的一篇文章,也是表现力语音合成领域可追溯到的比较早的一篇文章。
理想情况下,生成的语音应该传达正确的信息(可理解性 intelligibility),同时听起来像人类的语言(自然性 naturalness),具有正确的语调(表现力 expressiveness)。 然而,大多数现有的合成模型如 Tacotron 仅关注前两个问题,并没有明确地对语调进行建模。尽管有重要的应用,如对话助手和长篇阅读,但富有表现力的 TTS 仍然被认为是一个重要的开放问题。
韵律变化本质上是多尺度的。音调和说话持续时间的局部变化可以传达语义,而整体音调轨迹等全局属性可以传达情绪和情感。
在这项工作中,作者引入了 “风格标记(style tokens)” 的概念,它可以被视为捕捉韵律变化的潜在变量,而单靠文本输入是无法捕捉的。
理想情况下,一个富有表现力的 TTS 模型应该允许在合成过程中明确地控制对韵律(prosody)的选择。由于 Tacotron 只将文本作为输入,为了准确地重建训练信号,它必须学会将任何韵律信息隐式地储存在它的权重中,而我们并不能明确地控制它。为了允许显式的对韵律进行控制,作者在 Tacotron 中引入了一个专门的网络组件,用一个新的风格注意力模块(style attention)来增强现有的文本编码器的注意力模块(text attention)。新的注意力模块关注一个风格编码器(style encoder),它将 K 个 “风格标记” 作为输入,并输出它们的嵌入向量来作为风格注意力模块的输入(可以简单的理解为 K 个风格标记就是 1 到 K 的数字,然后经过一个 embedding,得到 K 个一维的 embedding vector 作为 style attention 的输入)。在解码器中,通过一种加权求和的操作将来自文本注意力和风格注意力的两个上下文向量结合起来。计算 weighted 的操作作者称为一个 controller layer(在图中并未明显体现,文章提到 The weights are predicted by a single layer MLP with sigmoid outputs)。Tacotron 模型的其余部分保持不变。
作者提到 style tokens 的嵌入值是随机初始化的,并通过反向传播自动学习,它们的学习仅由解码器的重建损失指导。因此,风格标记本身的学习是完全无监督的。
为什么使用基于注意力的风格标记呢?
首先,注意力有助于学习整体韵律风格的解耦(decomposition),鼓励产生具有独立韵律风格的可解释 tokens。这类似于学习一个风格原子的字典,可以结合起来重现整体风格(举个例子:每个原子都有自己的一个风格如 A 原子语速快、B 原子音调高,将 AB 原子组合起来可能能够产生一种听起来生气的情绪(即合成的语音语速又快音调又高))。
此外,注意力机制在解码器的时间分辨率上学习风格标记的组合,这使得时间变化的韵律操作成为可能。(简单的理解为就是比如说在生成一句长时间语音的时候,某个时刻可能音调高,某个时刻可能语速快些,从而生成的这一段语音在时间维度上可以组合不同的风格标记)。
为什么这种方式能够在合成语音的时候实现可控性(controllability)呢?
源自于风格编码器和文本编码器之间的一个重要区别。文本编码器是以输入的文本序列为条件的,而风格编码器则不需要输入,所有的训练序列都共享 tokens。换句话说,风格编码器计算的是训练集的先验 prior,而文本编码器计算的是后验 posteriors(以单个输入序列为条件)。这种设计允许风格标记捕捉与文本无关的韵律变化,这使得推理中的可控性得以实现。
为了合成特定风格的语音,作者将所选风格标记的嵌入向量广播式地添加到完整的风格嵌入矩阵 style embedding matrix 中,从而使合成的语音偏向于指定风格。同样,可以通过连续广播添加或线性内插风格嵌入向量来混合不同的风格。
从图 2 中可以看出,”token 1” 大致对应于具有正常音高范围的马虎、草率(sloppy)风格,”token 8” 大致对应于机器人声音的风格,而 “token 9” 大致对应于高音调声音。这些风格在一定程度上反映在平滑的 F0 轨迹上。例如,”token 9” 倾向于比其他两个有更高的音调,而 “token 8” 的音调轨迹则保持平缓和低沉。同样的趋势可以从图 2(b) 中看出,该图是由不同的语句产生的,表明风格标记的运作独立于文本输入。
本文提出的风格标记(style tokens)可以在无监督的情况下学习,不需要注释的标签。在 Tacotron 模型中实现了风格标记,并证明它们确实对应于不同的声音风格因素,通过在推理中指定所需的风格来实现某种程度的声音控制。
https://arxiv.org/abs/1803.09017
这一篇是王大佬接第一篇的续作,在这一篇文章中王大佬正式提出了 GSTs(global style tokens)的概念。
韵律 prosody 是语音中一些现象的汇合,如副语言信息、语调 intonation、重音 stress 和风格 style。这篇文章主要关注 style modeling,它的目标是为模型提供一种能力,这种能力能够为给定内容选择一种说话风格。风格包含丰富的信息,如意图和情感,并影响说话人对语调和语速的选择。适当的风格呈现会影响整体感知。
风格建模 style modeling 有如下几个挑战:
1)参考编码器 reference encoder,将可变长度的音频序列的 prosody 压缩成一个固定长度的向量,称之为 reference embedding。在训练期间,参考音频是真实音频。
reference encoder 架构的细节:输入 log-mel spectrogram ——> 6*(2DConv+Batch Norm+ReLU) ——>reshape 3 dimensions (保留时间维度,将 channel 和 freq reshape 成一维,即 [batch_size, channel, freq, time]——>[batch_size, channel*freq, time]) ——>single layer unidirectional GRU (128 unit) ——> 输出:reference embedding (the last GRU state)。
2)reference embedding 被传递到一个风格标记层 style token layer,在那里它被用作注意力模块的查询向量。注意,这里的注意力不是用来学习对齐的。相反,它学习 referensce embedding 和随机初始化的嵌入库中 (a bank of randomly initialized embeddings) 的每个 token 之间的相似性测量。这组嵌入被称之为全局风格标记 global style tokens (GSTs) 或标记嵌入 token embeddings,在所有训练序列中共享。
3) 注意力模块输出一组组合权重,代表每个 style token 对 referensce embedding 的贡献。GSTs 的加权总和被称之为风格嵌入 style embedding,在每个时间段被传递给文本编码器进行调节。
style token layer 由 10 (实验发现 10 个足以代表训练数据中小而丰富的韵律维度) 个 style token embeddings (为了和 text encoder 匹配,所以维度是 256D) 和一个 multi-head attention 组成。输入 reference embedding(128D) 和 style token embeddings(256D) ——> multi-head attention ——> 输出 style embedding (256D)。最后将 style embedding 加到对应的 text encoder states 上。
4)style token layer 与模型的其他部分共同训练,只由 Tacotron 解码器的重建损失驱动。因此,GSTs 不需要任何明确的风格或韵律标签。
1)可以直接将文本编码器限定在某些标记上,如图 3 推理模式图的右侧所描述的(”以 tokenB 为条件”)。这允许在没有参考音频的情况下进行风格控制和操作。
2)可以输入一个不同的音频(其对应的文本内容不需要与要合成的文本相同)来实现风格转移。这在图 3 的推理模式图的左侧被描绘出来(”以音频为条件”)。
总的来说,GSTs 模型可以被认为是一种将 reference embedding 分解为一组基础向量或 style token embedding 的端到端方法。GSTs 层在概念上与 VQ-VAE 编码器有些类似,因为它学习了其输入的量化表示。感觉用 VQ-VAE 来做 reference embedding 的解耦也可以,但是作者说实验效果很差。
GSTs embeddings 也可以被看作是一个外部存储器,存储从训练数据中提取的风格信息。参考信号在训练时指导记忆的写入,在推理时指导记忆的读取。
感兴趣的可以点击上边这个链接听一下文章提供的 Audio samples。
这项工作介绍了 GSTs。GSTs 是直观的,不需要明确的标签就能学习。当对富有表现力的语音数据进行训练时,GSTs 模型会产生可解释的嵌入 embeddings,可用于控制 control 和转移风格 transfer style。
然而,在实验中也发现了一些问题,比如并非所有的 single-token 都能捕捉到单一的属性:虽然一个 token 可能学会了代表说话速度,但其他 tokens 可能学会了反映训练数据中风格共现的混合属性(例如,一个低音调的 token 也能编码较慢的说话速度)。探索更多独立的风格属性学习(可以理解为解耦的更彻底)仍是目前工作的一个重点。
Learning latent representations for style control and transfer in end-to-end speech synthesis
VAE 有很多优点,如学习分解因素、平滑插值或在潜在表征 latent representation 之间连续取样,可以获得可解释的同(重)构体。
最主要的就是,VAE 可以很容易地得到解耦 disentangle 之后的 latent code,每个 latent code 的维度都可以代表一个特定的概念,通过调整某个概念的值,我们就能控制特定的概念。比如在 image synthesis 中,调整特定维度的 latent code 就可以控制合成出来的物体的角度、大小等特定概念。
直观地说,在语音合成中,说话人的潜在状态 latent state,如 affect 和意图 intent,有助于形成韵律 prosody、情感 emotion 或说话风格 speaker style。latent state 所起的作用与 VAE 中的潜在表征 latent representation 相当相似。因此,本文将 VAE 引入 Tacotron2,以学习说话人状态在连续空间中的潜态表示,并进一步控制语音合成中的说话风格。
如图 4 所示,整个网络结构由两部分组成,(1) 识别模型或推理网络,它将参考音频编码为固定长度的潜在表示(潜在表征 z 代表风格表示);(2)一个基于 Tacotron2 的端到端 TTS 模型,它将综合的编码器状态(包括潜在表征和文本编码器状态)转换为具有特定风格的目标句。
识别模型架构的细节(其中 reference encoder 和第二篇一致):输入 mel spectrogram ——> 6*(2DConv+Batch Norm+ReLU) ——>reshape 3 dimensions (保留时间维度,将 channel 和 freq reshape 成一维,即 [batch_size, channel, freq, time]——>[batch_size, channel*freq, time]) ——>a GRU ——> 输出:reference embedding (the last GRU state) ——> 两个单独的全连接层 + linear activation function——> 输出:均值和方差 ——> 重采样 reparameterization ——>输出:z。
输出的 text encoder state 加上 z(先经过一个 linear layer 调整一下维度)被送到 Tacotron2 的 decoder 中。
损失函数 loss 为 VAE 的损失 (其中重构损失选择 L2-loss)+ $l_{stop}$ (stop token loss)。
所谓 KL collapse problem 是指在训练过程中 KL loss 下降得比其它 loss 都快从而造成 KL loss 很快收敛到 0 并不再上升,这会造成 encoder 无法继续训练。因此作者使用了 KL annealing 来解决这个问题,具体来说,首先用一个动态调整的 weight 来控制 KL loss 的训练强度,其次是减少 KL loss 的训练次数,即每隔 K 个 step 才训练一次 KL loss。
1 | def kl_anneal_function(self, anneal_function, lag, step, k, x0, upper): |
在推理阶段,在 style control 评估中,直接操作 z,不需要经过整个识别模型。在 style transfer 的评估中,需要把音频片段作为参考,并通过识别模型。 Parallel transfer 意味着目标文本信息与参考音频的相同,反之 non-parallel style transfer 意味着目标文本信息与参考音频的不同。
如图 5 所示,这两个 z 是通过向识别模型提供两个参考音频而得到的, $z_a$表示说话语速快和高音调, $z_d$表示说话语速慢和低音调。通过对这两个 z 的插值 interpolation 操作,可以看到生成的语音的音高和语速都在逐渐下降。这一结果表明,学习到的潜在空间在控制声谱图的趋势方面是连续的,这将进一步反映在风格的变化上。
一个解耦过的表征(也就是 z)意味着一个潜在变量(也就是其中的一个维度或者值)能够完全单独控制一个概念,并且对其他因素的变化没有影响。从图 6 可以看出,通过操纵单一维度而固定其他维度对声谱图的改变,调整其中一个维度,生成的语音只有一个属性发生变化,如 z 的几个维度可以分别控制着合成语音的音高、局部音调的变化、语速等风格属性。这表明,在这个模型中,VAE 具有学习解耦 latent state 的能力。
从图 7 可以看出,生成的声谱图和参考的声谱图具有相似的属性。
本文提出了 VAE+Tacotron2 来对生成的语音进行控制,最终得到了与 GSTs 模型相似的效果。
语音合成是通过文字人工生成人类声音, 也可以说语音生成是给定一段文字去生成对应的人类读音。 这里声音是一个连续的模拟的信号。而合成过程是通过计算机, 数字信号去模拟。 这里就需要数字信号处理模拟信号信息,详细内容可参考 [1]。
Fig. 1 an example of voice signal.
图片1, 就是一个例子用来表示人类声音的信号图。 这里横轴是时间, 纵轴是声音幅度大小。声音有三个重要的指标,振幅(amplitude), 周期(period)和频率(frequency)。 振幅指的是波的高低幅度,表示声音的强弱,周期和频率互为倒数的关系, 用来表示两个波之间的时间长度,或者每秒震动的次数。 而声音合成是根据声波的特点, 用数字的方式去生成类似人声的频率和振幅, 即音频的数字化。了解了音频的数字化,也就知道了我们要生成的目标函数。
音频的数字化主要有三个步骤。
取样(sampling):在音频数字化的过程,采样是指一个固定的频率对音频信号进行采样, 采样的频率越高, 对应的音频数据的保真度就越好。 当然, 数据量越大,需要的内存也就越大。 如果想完全无损采样, 需要使用Nyquist sampling frequency, 就是原音频的频率2倍。
量化 (quantization): 采样的信号都要进行量化, 把信号的幅度变成有限的离散数值。比如从0 到 1, 只有 四个量化值可以用0, 0.25, 0.5, 0.75的话, 量化就是选择最近的量化值来表示。
编码 (coding):编码就是把每个数值用二进制的方式表示, 比如上面的例子, 就可以用2bit 二进制表示, 00, 01, 10, 11。 这样的数值用来保存在计算机上。
采样频率和采样量化级数是数字化声音的两个主要指标,直接影响声音的效果。 对于语音合成也是同样, 生成更高的采样频率和更多多的量化级数(比如16 bit), 会产生更真实的声音。 通常有三个采样频率标准:
1. 44.1kHz 采样, 用于高品质CD 音乐
2. 22.05kHz 采样, 用于语音通话, 中品质音乐
3. 11.025kHz 采样, 用于低品质声音。
而量化标准一般有8位字长(256阶)低品质量化 和16位字长(65536阶)高品质量化。
还有一个重要参数就是通道(channel), 一次只采样一个声音波形为单通道, 一次采样多个声音波形就是多通道。
所以在语音合成的时候,产生的数据量是 数据量=采样频率\ 量化位数*声道数, 单位是bit/s。 一般声道数都假设为1.。 *采样率和量化位数都是语音合成里的重要指标,也就是设计好的神经网络1秒钟必须生成的数据量。
Fig. 2 Two stage text-to-speech synthsis (source [2])
文本分析就是把文字转成类似音标的东西。 比如下图就是一个文本分析,用来分析 “PG&E will file schedules on April 20. ” 文本分析主要有三个步骤:文字规范化, 语音分析, 还有韵律分析。 下面一一道来。
Fig. 3 文本分析
文本分析首先是要确认单词和句子的结束。 空格会被用来当做隔词符. 句子的结束一般用标点符号来确定, 比如问号和感叹号 (?!), 但是句号有的时候要特别处理。 因为有些单词的缩写也包含句号, 比如 str. “My place on Main Str. is around the corner”. 这些特别情况一般都会采取规则(rule)的方式过滤掉。
接下来 是把非文字信息变成对应的文字, 比如句子中里有日期, 电话号码, 或者其他阿拉伯数字和符号。 这里就举个例子, 比如, I was born April 14. 就要变成, I was born April fourteen. 这个过程其实非常繁琐,现实文字中充满了 缩写,比如CS, 拼写错误, 网络用语, tmr —> tomorrow. 解决方式还是主要依靠rule based method, 建立各种各样的判断关系来转变。
语音分析就是把每个单词中的发音单词标出来, 比如Fig. 3 中的P, 就对应p和iy, 作为发音。 这个时候也很容易发现,发音的音标和对应的字母 不是一一对应的关系,反而需要音标去对齐 (allignment)。 这个对齐问题很经典, 可以用很多机器学习的方法去解决, 比如Expectation–maximization algorithm.
韵律分析就是英语里的语音语调, 汉语中的抑扬顿挫。 我们还是以英语为例, 韵律分析主要包含了: 重音 (Accent),边界 (boundaries), 音长 (duration),主频率 (F0)。
重音(Accent)就是指哪个音节发生重一点。 对于一个句子或者一个单词都有重音。 单词的重音一般都会标出来,英语语法里面有学过, 比如banana 这个单词, 第二个音节就是重音。 而对于句子而言,一样有的单词会重音,有的单词会发轻音。 一般有新内容的名词, 动词, 或者形容词会做重音处理。 比如下面的英语句子, surprise 就会被重音了, 而句子的重音点也会落到单词的重音上, 第二个音节rised, 就被重音啦。 英语的重音规则是一套英语语法,读者可以自行百度搜索。
I’m a little surprised to hear it characterized as upbeat.
边界 (Boundaries) 就是用来判断声调的边界的。 一般都是一个短语结束后,有个语调的边界。 比如下面的句子, For language, 就有一个边界, 而I 后面也是一个边界.
For language, I , the author of the blog, like Chinese.
音长(Duration)就是每个音节的发声长度。 这个通俗易懂。 NLP 里可以假定每个音节单词长度相同都是 100ms, 或者根据英语语法, 动词, 形容词之类的去确定。 也可以通过大量的数据集去寻找规律。
主频率 (F0)就是声音的主频率。 应该说做傅里叶转换后, 值 (magnitude) 最大的那个。 也是人耳听到声音认定的频率。一个成年人的声音主频率在 100-300Hz 之间。 这个值可以用 线性回归来预测, 机器学习的方法预测也可以。一般会认为,人的声音频率是连续变化的,而且一个短语说完频率是下降趋势。
文本分析就介绍完了,这个方向比较偏语言学, 传统上是语言学家的研究方向,但是随着人工智能的兴起,这些feature 已经不用人为设计了,可以用端到端学习的方法来解决。 比如谷歌的文章 TACOTRON: TOWARDS END-TO-END SPEECH SYNTHESIS 就解救了我们。
这个部分就比较像我们算法工程师的工作内容了。 在下面, 会详细介绍如何用Wavenet 和WaveRNN 来实现这一步骤的。
这里说所谓的waveform synthesis 就是用这些 语言特征值(text features)去生成对应的声波,也就是生成前文所说的采样频率 和 振幅大小(对应的数字信号)。 这里面主要有两个算法。
串接合成(concatenative speech synthesis): 这个方法呢, 就是把记录下来的音节拼在一起来组成一句话,在通过调整语音语调让它听起来自然些。 比较有名的有双音节拼接(Diphone Synthesis) 和单音节拼接(Unit Selection Synthesis)。这个方法比较繁琐, 需要对音节进行对齐(alignment), 调整音节的长短之类的。
参数合成 (Parametric Synthesis): 这个方法呢, 需要的内存比较小,是通过统计的方法来生成对应的声音。 模型一般有隐马尔科夫模型 (HMM),还有最近提出的神经网络算法Wavenet, WaveRNN.
对于隐马尔科夫模型的算法, 一般都会生成梅尔频率倒谱系数 (MFCC),这个是声音的特征值。 感兴趣的可以参考这篇博客去了解 MFCC。
对于神经网络的算法来说, 一般都是生成256 个 quantized values 基于softmax 的分类器, 对应 声音的 256 个量化值。 WaveRNN 和wavenet 就是用这种方法生成的。
以下内容主要来源于论文阅读笔记:Tacotron和Tacotron2。
我们首先对 Tacotron 和 Tacotron2 论文中的关键部分进行阐述和总结,之所以两篇论文放在一起,是因为方便比较模型结构上的不同点,更清晰的了解 Tacotron2 因为改进了哪些部分,在性能上表现的比 Tacotron 更好。
语音合成系统通常包含多个阶段,例如 TTS Frontend(文本前端),Acoustic model(声学模型) 和 Vocoder(声码器),如下图更直观清晰一点:
构建这些组件通常需要广泛的领域专业知识,并且可能包含脆弱的设计选择。在很多人困扰于繁杂的特征处理的时候,Google 推出了 Tacotron,一种从文字直接合成语音的端到端的语音合成模型,虽然在效果上相较于传统方法要好,但是相比 Wavenet 并没有明显的提升(甚至不如 Wavenet),不过它更重要的意义在于 end-to-end(Wavenet 是啥将在后面对比 vocoder 的时候讲解,顺便提一下 Tacotron 使用的是 Griffin-Lim 算法,而 Tacotron2 使用的是修改版 Wavenet)。此外,相较于其他样本级自回归方法合成语音,Tacotron 和 Tacotron2 是在帧级生成语音,因此要快得多。
在传统的 Pipeline 的统计参数 TTS,通常有一个文本前端提取各种语言特征,持续时间模型,声学特征预测模型和基于复杂信号处理的声码器。而端到端的语音合成模型,只需要对文本语音进行简单的处理,就能喂给模型进行学习,极大的减少的人工干预,对文本的处理只需要进行文本规范化以及分词 token 转换(论文中使用 character,不过就语音合成而言,使用 Phoneme 字典更佳),关于文本规范化(数字、货币、时间、日期转完整单词序列)以及 text-to-phoneme 可以参见利器:TTS Frontend 中英 Text-to-Phoneme Converter,附代码。端到端语音合成系统的优点如下:
端到端语音合成模型的困难所在:
不同 Speaker styles 以及不同 pronunciations 导致的对于给定的输入,模型必须对不同的信号有着更大的健壮性,除此之外 Tacotron 原本下描述:
TTS is a large-scale inverse problem: a highly compressed source (text) is “decompressed” into audio
上面这句是 Tacotron 原文中说的,简单来说就是 TTS 输出是连续的,并且输出序列(音频)通常比输入序列(文本)长得多,导致预测误差迅速累积。想要了解更多关于语音合成的背景知识,可以参考文章 Text-to-speech。
Tacotron 的基础架构是带有注意力机制(Attention Mechanism)的 Seq2Seq 模型,下图是模型的总体架构。网络部分大体可分为 4 部分,分别是左:Encoder、中:Attention、右下:Decoder、右上:Post-processing。从高层次上讲,模型将字符作为输入,并生成频谱图,然后将其转换为波形。
要特别说明的是架构中,raw text 经过 pre-net 后,将会把输出喂给一个叫 CBHG 的模块以映射为 hidden representation,再之后 decoder 会生成 Linear-Spectrum,再经过 Griffin-Lim 转换为波形。
raw text的选择可以可以有多种选择,以中文和英文合成系统为例:
英文文本,训练英文模型,最直观的想法是直接将英文文本当做输入,Tacotron1 也是这么做的。但这样可能会引入一些问题,比如未登录词发音问题。
英文注音符,用英文注音符(比如 CMUDict )作为输入可以提高发音稳定性,除了注音词典,还可以引入注音前端,增强对模型的控制。 中文拼音,由于中文汉字数量多,且存在大量多音字,直接通过文本训练
中文拼音,由于中文汉字数量多,且存在大量多音字,直接通过文本训练是不现实的。所以我们退而求其次,通过拼音训练模型,拼音有注音前端生成,既去掉了汉字的冗余发音又提高了模型的可控性。
中文|英文 IPA (International Phonetic Alphabet) 音标,IPA 音标是一种更强的注音体系,一套注音体系可以标注多种语言。对于中文,IPA 音标的标注粒度比拼音更细,实验中,我们观察到用 IPA 作为输入,可以略微提升对齐稳定性。另外,在中文发音人+英文发音人混合训练试验中,我们观察到了一个有意思的现象:由于中英文 IPA 标注中共享了部分发音单元,导致跨语种发音人可以学会对方的语言,也就是中文发音人可以合成英文,英文发音人可以合成中文。在这个联合学习过程中存在着迁移学习的味道。
根据不同的用途,Tacotron 可以输出 Linear-Spectrum 或 Mel-Spectrum,如果使用 Griffin-Lim 需要 Tacotron 输出 Linear-Spectrum;如果使用 WaveNet 做 Vocoder(即Tacotron2,下文会介绍) ,则 Tacotron 输出 Linear-Spectrum 或 Mel-Spectrum 均可,但 Mel-Spectrum 的计算代价显然更小,Tacotron2 中,作者使用 80 维 Mel-Spectrum 作为 WaveNet Vocoder 的输入。
我们知道在训练模型的时候,我们拿到的数据是一条长短不一的(text, audio)的数据,深度学习的核心其实就是大量的矩阵乘法,对于模型而言,文本类型的数据是不被接受的,所以这里我们需要先把文本转化为对应的向量。这里涉及到如下几个操作
构造字典
因为纯文本数据是没法作为深度学习输入的,所以我们首先得把文本转化为一个个对应的向量,这里我使用字典下标作为字典中每一个字对应的id,然后每一条文本就可以通过遍历字典转化成其对应的向量了。所以字典主要是应用在将文本转化成其在字典中对应的id,根据语料库构造,这里我使用的方法是根据语料库中的字频构造字典(我使用的是基于语料库中的字构造字典,有的人可能会先分词,基于词构造。不使用基于词是现在就算是最好的分词都会有一些误分词问题,而且基于字还可以在一定程度上缓解OOV的问题)。
然后我们就可以将文本数据转化成对应的向量作为模型的输入。
embed layer
光有对应的id,没法很好的表征文本信息,这里就涉及到构造词向量,关于词向量不在说明,网上有很多资料,模型中使用词嵌入层,通过训练不断的学习到语料库中的每个字的词向量。
值得注意的是,这里是随机初始化词嵌入层,另一种方法是引入预先在语料库训练的词向量(word2vec),可以在一定程度上提升模型的效果。
对于音频,我们主要是提取出它的mel-spectrogram,然后变换得到比较常用的音频特征MFCC。对于声音来说,它其实是一个一维的时域信号,直观上很难看出频域的变化规律,我们知道,可以使用傅里叶变化,得到它的频域信息,但是又丢失了时域信息,无法看到频域随时域的变化,这样就没法很好的描述声音, 为了解决这个问题,很多时频分析手段应运而生。短时傅里叶,小波,Wigner分布等都是常用的时频域分析方法。这里我们使用短时傅里叶。
所谓短时傅里叶变换,顾名思义,是对短时的信号做傅里叶变化。那么短时的信号怎么得到的? 是长时的信号分帧得来的。这么一想,STFT的原理非常简单,把一段长信号分帧(傅里叶变换适用于分析平稳的信号。我们假设在较短的时间跨度范围内,语音信号的变换是平坦的,这就是为什么要分帧的原因)、加窗,再对每一帧做傅里叶变换(FFT),最后把每一帧的结果沿另一个维度堆叠起来,得到类似于一幅图的二维信号形式。如果我们原始信号是声音信号,那么通过STFT展开得到的二维信号就是所谓的声谱图。
声谱图往往是很大的一张图,为了得到合适大小的声音特征,往往把它通过梅尔标度滤波器组(mel-scale filter banks),变换为梅尔频谱(mel-spectrogram)。在梅尔频谱上做倒谱分析(取对数,做DCT变换)就得到了梅尔倒谱系数(MFCC,Mel Frequency Cepstral Coefficents)。我们主要使用第三方库librosa提取MFCC特征。
所谓 CBHG 就是作者使用的一种用来从序列中提取高层次特征的模块,如下图所示:
CBHG 使用了 1D 卷积、highway、残差链接和双向 GRU 的组合,输入序列,输出同样也是序列,因此,它从序列中提取表示非常强大。CBHG 架构流程如下:
在 Encoder 中,输入被 CBHG 处理之前还需要经过 pre-net 进行预处理,作者设计 pre-net(pre-net 是由全连接层 + dropout 组成的模块)的意图是让它成为一个 bottleneck layer 来提升模型的泛化能力,以及加快收敛速度。
随后就是 Decoder 了,论文中使用两个 decoder
作者并没有选择直接用 output decoder 来生成 spectrogram,而是生成了 80-band mel-scale spectrogram,也就是我们之前提到的 mel-spectrogram,熟悉信号处理的同学应该知道,spectrogram 的 size 通常是很大的,因此直接生成会非常耗时,而 mel-spectrogram 虽然损失了信息,但是相比 spectrogram 就小了很多,且由于它是针对人耳来设计的,因此对最终生成的波形的质量不会有很多影响。
随后使用 post-processing network(下面会讲)将 seq2seq 目标转换为波形,然后使用一个全连接层来预测 decoder 输出。Decoder 中有一个 trick 就是在每个 decoder step 预测多个 (r 个)非重叠frame,这样做可以缩减计算量,且作者发现这样做还可以加速模型的收敛。
预测多个非重叠帧的直观解释:因为就像我们前面说到的提取音频特征的时候,我们会先分帧,相邻的帧其实是有一定的关联性的,所以每个字符在发音的时候,可能对应了多个帧,因此每个GRU单元输出为多个帧的音频文件。
论文提到 scheduled sampling 在这里使用会损失音频质量
和seq2seq网络不同的是,tacotron在decoder-RNN输出之后并没有直接将其作为输出通过Griffin-Lim算法合成音频,而是添加了一层post-processing模块。为什么要添加这一层呢?
首先是因为我们使用了Griffin-Lim重建算法,根据频谱生成音频,Griffin-Lim原理是:我们知道相位是描述波形变化的,我们从频谱生成音频的时候,需要考虑连续帧之间相位变化的规律,如果找不到这个规律,生成的信号和原来的信号肯定是不一样的,Griffin Lim算法解决的就是如何不弄坏左右相邻的幅度谱和自身幅度谱的情况下,求一个近似的相位,因为相位最差和最好情况下天壤之别,所有应该会有一个相位变化的迭代方案会比上一次更好一点,而Griffin Lim算法找到了这个方案。这里说了这么多,其实就是Griffin-Lim算法需要看到所有的帧。post-processing可以在一个线性频率范围内预测幅度谱(spectral magnitude)。
其次,post-processing能看到整个解码的序列,而不像seq2seq那样,只能从左至右的运行。它能够通过正向传播和反向传播的结果来修正每一帧的预测错误。
论文中使用了CBHG的结构来作为post-processing net,前面已经详细介绍过。实际上这里 post-processing net 中的 CBHG 是可以被替换成其它模块用来生成其它东西的,比如直接生成 waveform,在 Tacotron2 中,CBHG 就被替换为 Wavenet 来直接生成波形。
对 Decoder 和 post-processing net 使用 L1 损失,并取平均。作者使用 32batch,并将序列 padding 到最大长度。关于 padding 的说明,Tacotron 原文如下:
It’s a common practice to train sequence models with a loss mask, which masks loss on zero-padded frames. However, we found that models trained this way don’t know when to stop emitting outputs, causing repeated sounds towards the end. One simple trick to get around this problem is to also reconstruct the zero-padded frames.
Tacotron有啥缺点呢?
Tacotron中使用了CBHG模块(包括编码器部分和解码器部分),虽然在实验中发现该模块可以一定程度上减轻过拟合问题,和减少合成语音中的发音错误,但是该模块本身比较复杂,能否用其余更简单的模块替换该模块?
Tacotron中使用的Attention机制能够隐式的进行语音声学参数序列与文本语言特征序列的隐式对齐,但是由于Tacotron中使用的Attention机制没有添加任何的约束,导致模型在训练的时候可能会出现错误对齐的现象,使得合成出的语音出现部分发音段发音不清晰、漏读、重复、无法结束还有误读等问题。
Tacotron中一次可生成r帧梅尔谱,r可以看成一个超参数,r可以设置的大一点,这样可以加快训练速度和合成语音的速度,但是r值如果设置的过大会破坏Attention RNN隐状态的连续性,也会导致错误对齐的现象。
Tacotron使用Griffin-Lim作为vocoder来生成语音波形,这一过程会存在一定的信息丢失,导致合成出的语音音质有所下降(不够自然)。Tacotron 中作者也提到了,这个算法只是一个简单、临时的 neural vocoder 的替代,因此要改进 Tacotron 就需要有一个更好更强大的 vocoder。
接下来我们来看看 Tacotron2,它的模型大体上分为两个部分:
结构图如下:
在Tacotron2中,对于编码器部分的CBHG模块,作者采用了一个3Conv1D+BiLSTM模块进行替代,如图2下方蓝色部分所示;对于解码器部分的CBHG模块,作者使用了Post-Net(5Conv1D)和残差连接进行替代。
在Tacotron2中,作者使用了Location-sensitive Attention代替了原有的基于内容的注意力机制,前者在考虑内容信息的同时,也考虑了位置信息,这样就使得训练模型对齐的过程更加的容易。一定程度上缓解了部分合成的语音发音不清晰、漏读、重复等问题。对于Tacotron中无法在适当的时间结束而导致合成的语音末尾有静音段的问题,作者在Tacotron2中设计了一个stop token进行预测模型应该在什么时候进行停止解码操作。
在Tacotron2中,r值被设定为1,发现模型在一定时间内也是可以被有效训练的。猜测这归功于模型整体的复杂度下降,使得训练变得相对容易。
Tacotron2 选择预测 a low-level acoustic 表示,即 mel-frequency spectrograms(Tacotron 使用 linear-frequency scale spectrograms),Tacotron2 原文描述如下:
This representation is also smoother than waveform samples and is easier to train using a squared error loss because it is invariant to phase within each frame.
mel-frequency spectrogram 与 linear-frequency spectrograms 有关,即短时傅立叶变换(STFT)幅度。mel-frequency 是通过对 STFT 的频率轴进行非线性变换而获得的,同时受到人类听觉系统的启发,用较少的维度表示频率内容,原因很好理解,低频中的细节对于音频质量至关重要,而高频中往往包含摩擦音等噪音,因此通常不需要对高频细节建模。
虽然 linear spectrograms 会丢弃相位信息(因此是有损的),但是诸如 Griffin-Lim 之类的算法能够估算此丢弃的信息,从而可以通过短时傅立叶逆变换进行时域转换。而 mel spectrogram 会丢弃更多信息,因此它的逆问题更具有挑战性,这个时候作者想到了 WaveNet替换了原先的Griffin-Lim,进一步加快了模型训练和推理的速度,因为wavenet可以直接将梅尔谱转换成原始的语音波形。(Tacotron2合成语音音质的提升貌似主要归功于Wavenet替换了原有的Griffin-Lim)。
除了 Wavenet,Tacotron2 和 Tacotron 的主要不同在于:
下图展示 Decoder step 中,使用不同组件学习到 attention alignment 的效果:
下图展示了 post-processing net 的实验效果,可以看到有 post-processing net 的网络效果更好:
MOS 分数对比如下表:
下表展示了 Tacotron2 与各种现有系统的 MOS 分数比较。Tacotron2 的分数已经和人类不相上下了,这在很大程度上要归功于 Wavenet。
下表是对合成的音频的评价:
文中提到,Wavenet 在这个模型中是和剩下的模型分开训练的,Wavenet 的输入是 mel-spectrogram,输出是 waveform,这个时候就需要考虑输入的 mel-spectrogram 是选择 ground truth,还是选用 prediction,作者做了相关实验,结果如下图所示:
可以看到使用模型生成的 mel-spectrogram 来训练的 Wavenet 取得了最好的结果,作者认为这是因为这种做法保证了数据的一致性。下表是生成 mel-spectrogram 和 linear spectrogram 的区别(结果证明 mel-spectrogram 是最好的,同时还能够减少计算,加快 inference 的时间):
下表是对 WaveNet 简化之后的 MOS 分数情况:
声码器(Vocoder)在语音合成中往往被用于将生成的语音特征转换为我们所需要的语音波形。在Tacotron中,由于前端的神经网络所预测出的梅尔谱图仅包含了幅值信息而缺乏相应的相位信息,我们难以直接通过短时傅里叶变换(STFT)的逆变换将梅尔谱图还原为声音波形文件;因此,我们需要使用声码器进行相位估计,并将输入的梅尔谱图转换为语音波形。
Tacotron 使用的是 Griffin-Lim 算法,Griffin-Lim 是一种声码器,常用于语音合成,用于将语音合成系统生成的声学参数转换成语音波形,这种声码器不需要训练,不需要预知相位谱,而是通过帧与帧之间的关系估计相位信息,从而重建语音波形。更正式一点的解释是 Griffin-Lim 算法是一种已知幅度谱,未知相位谱,通过迭代生成相位谱,并用已知的幅度谱和计算得出的相位谱,重建语音波形的方法,具体可参考这篇 Griffin-Lim 声码器介绍。
Griffin-Lim 的优点是算法简单,可以快速建立调研环境,缺点是速度慢,很难在 CPU 上做到实时,无法实时解码也就意味着系统无法在生产环境使用。而且通过Griffin-Lim生成波形过于平滑,空洞较多,听感不佳。
种种迹象表明,Griffin-Lim 算法是音质瓶颈,经过一些列工作尤其是 Tacotron2 ,人们逐渐意识到,Mel-Spectrogram 可以作为采样点自回归模型的 condition,利用强大的采样点自回归模型提高合成质量。
目前公认的效果有保障的采样点自回归模型主要如下几种,1) SampleRNN、2)WaveNet、3)WaveRNN。我们重点介绍前两种。
其模型结构如下:
图3: SampleRNN 模型结构
SampleRNN 是一个精心设计的 RNN 自回归模型。标准的 RNN 模型包括 LSTM、GRU,可以用来处理一些长距离依赖的场景,比如语言模型。但对于音频采样点这样的超长距离依赖场景(比如:24k采样率,意味着 1s 中包含 24000 个采样点),RNN 处理起来已经非常困难了 。SampleRNN 的作者,将问题分解,分辨率由低到高逐层建模,例如图中,Tier3 层每时刻输入16个采样点,输出状态 S1;Tier2 层每时刻输入 4 个采样点,同时输入 Tier3 输出的 S1,输出状态 S2 ; Tier1 层每时刻输入 4 个采样点,同时输入 Tier2 输出的 S2,输出一个采样点,由于 Tier1 没有循环结构,同一时刻可以输出 4 个采样点。
如果有兴趣,可以点击 SampleRNN Samples,在里面你能找到总长度为 1小时 的 Samples。
总体来看模型的波形生成能力相当了得,发音、音色以及韵律风格的还原度都非常高。但 SampleRNN 也存在一些问题,最主要的是训练收敛速度太慢了,导致调参优化效率低下,我们将介绍另一个采样点自回归模型 WaveNet,相比 SampleRNN ,WaveNet 不但保留了高水平的波形生成能力,而且还提升了训练速度,单卡训练一天就能获得较好的效果。
其模型结构如下
图4: 采样点自回归 WaveNet
图4, 描述了 WaveNet 这类采样点自回归模型的工作方式,模型输入若干历史采样点,输出下一采样点的预测值,也就是根据历史预测未来。如果你对 NLP 较为熟悉,一定会觉得这种工作方式很像语言模型,没错,只不过音频采样点自回归更难一些罢了,需要考虑更长的历史信息才能保证足够的预测准确率。
WaveNet 最初由 DeepMind 推出,是基于 CNN 的采样点自回归模型,由于 CNN 结构的限制,为了解决长距离依赖问题,必须想办法扩大感受野,但扩大感受野又会增加参数量。为了在扩大感受野和控制参数量间寻找平衡,作者引入所谓“扩展卷积”的结构。如上图所示,“扩张卷积”,也可以称为“空洞卷积”,顾名思义就是计算卷积时跨越若干个点,WaveNet 层叠了多层这种 1D 扩张卷积,卷积核宽度为 2 (Parallel WaveNet 为 3),Dilated 宽度随层数升高而逐渐加大。可以想象,通过这种结构,CNN 感受野随着层数的增多而指数级增加。
训练好了 WaveNet ,我们就可以来合成音频波形了。但是,你会发现这时合成的音频完全没有语义信息,听起来更像是鹦鹉学舌,效果就如上一节 SampleRNN 的样例一样。 要使 WaveNet 合成真正的语音,那么就需要为其添加 condition ,condition 包含了文本的语义信息,这些语义信息可以帮助 WaveNet 合成我们需要的波形,condition 的形式并不唯一,但本文中我们只介绍 Mel-Spectrum condition 。
Mel-Spectrum condition
为什么要引入 Mel-Spectrum condition 呢?有两个原因:其一是为了和 Tacotron 打通,Tacotron 的输出可以直接作为 WaveNet 的输入,构成一套完整的端到端语音合成流水线;其二是因为 Mel-Spectrum 本身包含了丰富的语音语义信息,这些语音语义信息可以支持后期的多人混合训练、以及韵律风格迁移等工作。
下面我们将着重介绍如何在模型中融入 Mel-Spectrum condition 。
由于采样点长度和 Mel-Spectrum 长度不匹配,我们需要想办法将长度对齐,完成这一目标有两种方法:一种是将 Mel-Spectrum 反卷积上采样到采样点长度,另一种是将 Mel-Spectrum 直接复制上采样到采样点长度,两种方案效果差异很小。我们希望模型尽量简洁,故而采用第二种方法,如图6所示。
方便起见,我们借用 Deep Voice1 (图5)来说明。经过复制上采样的 Mel-Spectrum condition,首先需要经过一个 1x1 卷积,使 Mel-Spectrum condition 维度与 WaveNet GAU 输入维度相同,然后分两部分累加送入 GAU 即可,注意,WaveNet 每层 GAU 都需要独立添加 Mel-Spectrum condition。
图5: Mel-Spectrum condition 计算方法
图6: Mel-Spectrum 时间分辨率对齐
WaveNet 有很多优点,训练快、效果好、网络结构清晰简洁。但 WaveNet 也引入了新问题:inference 性能差,在 CPU 平台通常需要数十秒时间合成一秒语音,这让商业化几乎不可能。
针对这一问题,DeepMind 推出了 WaveNet 加速方案 Parallel WaveNet,Parallel WaveNet 将 inference 速度提升上千倍。
该方法来自于Neural Speech Synthesis with Transformer Network (2018)。
虽然Tacotron2解决了一些在Tacotron中存在的问题,但是Tacotron2和Tacotron整体结构依然一样,二者都是一个自回归模型,也就是每一次的解码操作都需要先前的解码信息,导致模型难以进行并行计算训练和推理过程中的效率低下。其次,二者在编码上下文信息的时候,都使用了LSTM进行建模。理论上,LSTM可以建模长距离的上下文信息,但是实际应用上,LSTM对于建模较长距离的上下文信息能力并不强。
如果对Tacotron2和Transformer比较熟悉的话,可以从上图3中看出,其实Transformer TTS就是Tacotron2和Transformer的结合体。其中,一方面,Transformer TTS继承了Transformer Encoder,MHAttention,Decoder的整体架构;另一方面,Transformer TTS的Encoder Pre-net、Decoder Pre-net、Post-net、stop Linear皆来自于Tacotron2,所起的作用也都一致。换句话说,
将Tacotron2: Encoder BiLSTM ——>Transformer: Multi-head Attention(+positional encoding);
Tacotron2: Decoder Location-sensitive Attention + LSTM ——>Transformer: Multi-head Attention (+positional encoding);
其余保持不变,就变成了Transformer TTS。
也正是Transformer相对于LSTM的优势,使得Transformer TTS解决了Tacotron2中存在的训练速度低下和难以建立长依赖性模型的问题。
其中值得一提的是,Transformer TTS保留了原始Transformer中的scaled positional encoding信息。为什么非得保留这个呢?原因就是Multi-head Attention无法对序列的时序信息进行建模。可以用下列公式表示:
其中,$ \alpha $ 是可训练的权重,使得编码器和解码器预处理网络可以学习到输入音素级别对梅尔谱帧级别的尺度适应关系。
本文作者结合Tacotron2和Transformer提出了Transformer TTS,在一定程度上解决了Tacotron2中存在的一些问题。但仍然存在一些问题:如1)在训练的时候可以并行计算,但是在推理的时候,模型依旧是自回归结构,运算无法并行化处理;2)相比于Tacotron2,位置编码导致模型无法合成任意长度的语音;3)Attention encoder-decoder依旧存在错误对齐的现象。
有关代码解读可以参考声学模型(02):Transformer based TTS
该算法来自于 FastSpeech: Fast, Robust and Controllable Text to Speech (2019)。
在先前基于神经网络的TTS系统中,mel-spectrogram是以自回归(auto-regressive)方式产生的。由于mel-spectrogram的长序列和自回归性质,这些系统依旧面临着几个问题:
基于以上动机,作者提出了Fastspeech。作者描述到:与自回归TTS模型相比,FastSpeech在mel谱图生成上实现了270倍的加速,在最终语音合成上实现了38倍的加速,几乎消除了跳词和重复的问题,并且可以平滑地调整语音速度。
如图1所示,Fastspeech的整体框架和Transformer的Encoder很像,可以简单的理解为是移除了Decoder部分的Transformer模块,以此实现了模型的并行训练和加快推理速度(采用了non auto-regressive的seq-to-seq模型(如上图(a)),不需要依赖上一个时间步的输入,可以让整个模型真正的并行化)。可以看出,Fastspeech主要由三部分构成:FFT Block,Length Regulator和Duration Predictor。
从图1(a)中可以看出,Fastspeech的整体流程和先前的自回归模型还是有几分相似之处的。
先前的自回归模型流程是:Encoder+Attention(隐式alignment)+Decoder;
Fastspeech的流程是FFT Block+Length Regulator+FFT Block;其中第一个FFT Block可以看成是Encoder部分,第二个FFT Block可以看成是Decoder部分。明显不同的是Length Regulator,可以看成是一种显式的Attention alignment方式,至于为什么下文有介绍。
从上图b可以看出,其实这个模块和Transformer中Multihead attention+Feed-forward结构很相似。稍微有点不同的是作者把原始Feed-forward中的全连接层换成了1D卷积层。为什么要这么做呢?作者描述到:其动机是,在语音任务中,相邻的隐藏状态在字符/音素和mel谱图序列中的关系更为密切。说白了就是作者认为在合成语音的时候局部范围的上下文信息更为重要,较远距离的上下文信息则不那么重要。
The motivation is that the adjacent hidden states are more closely related in the character/phoneme and mel-spectrogram sequence in speech tasks.
其余的部分均和Transformer中的一致,包括positional encoding、multi-head attention、LayerNorm、residual connections。
正上图a所示,第一个FFT Block模块可以简单理解为把因素序列转为一个隐状态,而第二FFT Block模块可以简单理解为把隐状态转换为mel谱图。这就意味着一个问题,要知道因素序列的长度是普遍远远短于mel谱图的长度,那么模型是怎么知道每一个因素应该到底对应多长时间的mel谱帧呢?
基于以上考虑,作者设计了长度调节器模块(如上图c所示),其作用也就显而易见了,主要是用于解决转换过程中音素和mel谱图序列之间的长度不匹配问题,并且还可以控制语音合成的速度(如何控制下文会有介绍)。
形式上,一个音素映射到mel谱图上帧的个数称为音素的持续时间。根据音素的持续时间d,长度调节器将音素序列的隐藏状态扩大d倍,同时确保隐藏状态的总长度等于mel谱图的长度。可以用公式表示:
其中 $ \mathcal{H}_{p h o}=\left[h_{1}, h_{2}, \ldots, h_{n}\right], \mathcal{D}=\left[d_{1}, d_{2}, \ldots, d_{n}\right], \mathrm{n} $ 表示音素序列的长度,$\quad \sum_{i=1}^{n} d_{i}=m$ , $ \mathrm{m} $ 表示mel谱图的长度,而 $ \alpha $ 就是用来控制合成mel谱图长度的超参数, 以此来控制合成语音的语速。举个例子, 比如说音素序列 $ \mathcal{H}_{p h o}=\left[h_{1}, h_{2}, h_{3}, h_{4}\right] $ 对应的每个音素的持续时间为 $ \mathcal{D}=[2,2,3,1] $, 如果 $ \alpha=1 $,那么长度调节器模块就会将h1复制1次、h2复制1次、h3复制2次、 $ \mathrm{h} 4 $ 不复制,最终得到 $ \mathcal{H}_{m e l}=\left[h_{1}, h_{1}, h_{2}, h_{2}, h_{3}, h_{3}, h_{3}, h_{4}\right] $ 。如果 $ \alpha=0 $.5代表合成的语速为原先的两倍(变快), 则每个音素对应的持续时间为 $ \mathcal{D}=[1,1,1.5,0.5] $,因为时间对应的是mel谱图的帧数,帧数不存在小数之说,所以在实际处理的时候会进行向上取整,也就是
$ \mathcal{D}=[1,1,2,1] $,因此最终得到的 $ \mathcal{H}_{m e l}=\left[h_{1}, h_{2}, h_{3}, h_{3}, h_{4}\right] $ 。如果 $ \alpha=2 $ 代表合成的语速为原先的0.5倍(变慢),原理和上述分析类似, 此处不再阐述。
那么问题就来了,模型应该如何确定每个音素的持续时间呢?为了解决这个问题,作者设计了一个持续时间预测器模块。如图1d所示,持续时间预测器包括一个具有ReLU激活函数的2层1D卷积网络,每层后面都有归一化和dropout层,还有一个额外的线性层来输出一个标量,这个标量就表示预测的音素对应的持续时间。
值得一提的是,训练后的长度预测器只用于TTS推理阶段。在训练阶段,直接使用从训练好的自回归teacher模型中提取的音素长度。
具体来说就是在训练阶段,首先用训练一个auto-regressive的TTS模型,这个时候我们不需要知道phoneme duration。
接下来我们用这个TTS模型来为每个训练数据对儿生成attention alignment(也就是说真实的音素持续时间是由已经训练好的Transformer TTS的multi-head attention提供)。因为multi-head attention,包含多种注意力排列,而不是所有的注意头都表现出对角线的特性(即attention weight分布在对角上)。所以,作者制定了一种方式:$ F=\frac{1}{S} \sum_{s=1}^{S} \max _{1 \leq t \leq T} a_{s, t} $,其中S和T分别代表真实的mel谱图和音素序列的长度、 $ a_{s, t} $ 表示 attention矩阵中第s行第t列的元素的数值,最终选择F最大的head用作attention alignment。
有了上面得到的alignment,我们用下面的式子计算$ \mathcal{D}=\left[d_{1}, d_{2}, \ldots, d_{n}\right] $:
That is, the duration of a phoneme is the number of mel-spectrograms attended to it according to the attention head selected in the above step.
在训练过程中,Duration Predictor模块与Fastspeech一起做联合训练,其预测结果与目标做Loss。
也就是在这个模块中,作者抛弃了传统Encoder+attention+Decoder模型中隐式的attention alignment方式,加入了显式的alignment标签。
在两个小节中我们分析了FastSpeech主要解决了以下三个问题:
1)解决已有自回归模型推理过程中合成语音速度慢的问题;
2)取消了先前模型中编码器-解码器之间的隐式注意力机制,从而避免因为注意力对齐不准而带来的合成语句不稳定的问题;
3)在音素持续时间预测模块中引入了$\alpha$因子,使得合成语音的时长(语速)可控。
但是一些不足也很明显,如:
1)合成语音的质量(上限)会受到teacher模型的严重影响;
2)只能控制合成语音的速度,可控性依旧有限。
代码解读可以参考声学模型(03):Fastspeech。
个人补充一下关于注意力对齐:注意力对齐应该是文本和音频的对齐。假设我们的输入时音素和对应的音频,由于对齐不准,可能导致其中一个音素包含了其它音素的音频或者缺失了一部分,导致跳词和重复的问题。
虽然FastSpeech作为一个non-autogressive TTS模型已经取得了比auto-regressive模型如Tacotron更快的生成速度和类似的语音质量,但是FastSpeech仍然存在一些缺点,比如
使用一个auto-regressive的TTS模型作为teacher,训练模型非常耗费时间;
使用知识蒸馏的方式来训练模型会导致信息损失,从而对合成出的语音的音质造成影响。
在FastSpeech 2: Fast and High-Quality End-to-End Text to Speech文章中,作者针对这些问题进行了改进,作者首先摒弃了知识蒸馏的teacher-student训练,采用了直接在ground-truth上训练的方式。其次在模型中引入了更多的可以控制语音的输入,其中既包括我们在FastSpeech中提到的phoneme duration,也包括energy、pitch等新的量。作者将这个模型命名为FastSpeech2。作者在此基础之上提出了FastSpeech2s,这个模型可以直接从text生成语音而不是mel-spectrogram。实验结果证明FastSpeech2的训练速度比FastSpeech加快了3倍,FastSpeech2s有比其它模型更快的合成速度。在音质方面,FastSpeech2和2s都超过了之前auto-regressive模型。
模型的整体架构如下图所示:
整体上来说(上图(a)),FastSpeech2在encoder和decoder上采用了和FastSpeech类似的基于self-attention和1D卷积的结构。
不同的是,FastSpeech2使用了variance adaptor(上图(b))用来引入更多的输入来控制合成出的语音,正如之前提到的,这里不仅有phoneme duration也有energy和pitch,我们看到这个adaptor的结构使得它可以引入任意多的额外输入。最后,作者没有使用之前的从attention matrix中推断phoneme duration的方法,而是使用了forced alignment得到的duration作为训练的ground truth,实验结果也证明这种方法得到的duration会更加精确。
这里可能一部分的读者不清楚forced alignment是什么。其实这是一种TTS中常用的技术,用来推断音素对应的音频,比如Montreal Forced Aligner (MFA)库。
Variance Adaptor(VA)是给phoneme hidden seq加上变化信息(各种声学特征),对于TTS的one-to-many映射提供帮助。作者在这里加上了三种:duration,pitch和energy。此外像emotion、style、speaker等信息都可以加到VA上。
VA的设计如图(b)所示,GT的duration、pitch、energy一方面被用来在训练时作为condition预测mel谱,另一方面被用来训练声学特征预测器Duration Predictor(DP)、Pitch Predictor(PP)和Energy Predictor(EP)。
Duration Predictor用到了forced alignment抽出的phoneme duration作为训练目标。输入phoneme hidden seq,输出每个音素对应的预测帧数(为便于预测转换成对数域)。DP训练用的是MSE loss,GT 音素时长是通过Montreal Forced Alignment(MFA)工具从原音频中提取的。
Pitch Predictor需要语音的pitch信息作为训练目标,一般情况下会使用pitch contour(基频轮廓),不过这里作者认为pitch contour的variation很大,不好预测。因此作者使用了pitch spectrogram作为训练目标。作者首先使用continuous wavelet transform (连续小波变换,CWT) 获得pitch spectrogram,然后训练predictor去预测它。在合成语音的时候,作者使用inverse CWT (iCWT),即CWT的逆运算来将pitch spectrogram转换称pitch contour。作者进一步根据pitch F0的大小把它们映射到对数域的256个值上 ,最后把值对应的pitch embedding加在phoneme hidden state上,以此为GT target计算MSE loss。
Energy Predictor:对于每一STFT帧计算其幅度的L2范数作为能量值,然后将energy均匀量化成256个可能值,最后将值对应的embedding加到hidden state上。这里训练的时候predictor会直接预测映射之前的energy,并计算MSE loss。
作者希望实现text-to-waveform而不是text-to-mel-to-waveform的合成方式,因此扩展FastSpeech2提出了FastSpeech2s。在上一节的架构图的子图(a)中我们可以看到,FastSpeech2s直接从hidden state中生成waveform,而不使用mel-spectrogram decoder。
架构图的子图(d)给出了waveform decoder的架构,作者使用类似WaveNet的结构,其中包含了dilated卷积和gated activation。这里作者使用了WaveGAN中的对抗训练的方法来让模型隐式地学习到恢复phase information的方法。值得注意的是这里作者在训练FastSpeech2s的时候也同时训练FastSpeech2的mel-spectrogram decoder,作者认为这样可以从text中提取更多的信息。
本文介绍了FastSpeech的改进版FastSpeech2/2s,FastSpeech2改进了FastSpeech的训练方法,通过引入forced alignment以及pitch和energy信息提升了模型的训练速度和精度。FastSpeech2s进一步实现了text-to-waveform的训练方式,因此提升了合成速度。实验结果证明FastSpeech2的训练速度比FastSpeech快了3倍,另外FastSpeech2s由于不需要生成mel-spectrogram因此有更快的合成速度。
论文阅读笔记:Tacotron和Tacotron2
语音合成(三):端到端的TTS深度学习模型tacotron
Tacotron以及Tacotron2详解
端到端语音合成及其优化实践(上)
语音合成简介 Text-to-speech
语音合成技术综述
声学模型(02):Transformer based TTS
声学模型(03):Fastspeech
FastSpeech阅读笔记
FastSpeech2——快速高质量语音合成
TTS paper阅读:FastSpeech 2
一般只有元音(一些介于元音辅音中间分类不明的音暂不讨论)才会有共振峰,而元音的音质由声道的形状决定,而声道的形状又通过发音的动作来塑造(articulatory+movements)。
以下内容主要来源于不同元音辅音在声音频谱的表现是什么样子? - 王赟 Maigo的回答 - 知乎。
声音最直接的表示方式是波形,英文叫waveform。另外两种表示方式(频谱和语谱图)下文再说。波形的横轴是时间(所以波形也叫声音的时域表示),纵轴的含义并不重要,可以理解成位移(声带或者耳机膜的位置)或者压强。
当横轴的分辨率不高的时候,语音的波形看起来就是像你贴的图中一样,呈现一个个的三角形。这些三角形的轮廓叫作波形的包络(envelope)。包络的大小代表了声音的响度。一般来说,每一个音节会对应着一个三角形,因为一般地每个音节含有一个元音,而元音比辅音听起来响亮。但例外也是有的,比如:1) 像/s/这样的音,持续时间比较长,也会形成一个三角形;2) 爆破音(尤其是送气爆破音,如/p/)可能会在瞬时聚集大量能量,在波形的包络上就体现为一个脉冲。
下面这张图中上方的子图,是读单词pass /pæs/的录音。它的横坐标已经被拉开了一些,但其实这个波形是由两个“三角形”组成的。0.05秒处那个小突起是爆破音/p/,0.05秒到0.3秒是元音/æ/,0.3到0.58秒是辅音/s/。
如果你把横轴的分辨率调高,比如只观察0.02s秒甚至更短时间内的波形,你就可以看到波形的精细结构(fine structure),像上图的下面两个子图。波形的精细结构可能呈现两种情况:一种是有周期性的,比如左边那段波形(图中显示了两个周期多一点),这种波形一般是元音或者辅音中的鼻音、浊擦音以及/l/、/r/等;另一种是乱的,比如右边那段波形,这种波形一般是辅音中的清擦音。辅音中的爆破音,则往往表现为一小段静音加一个脉冲(如pass开头的/p/)。
看完了声音的时域表示,我们再来看它的频域表示——频谱(spectrum)。它是由一小段波形做傅里叶变换(Fourier transform)之后取模得到的。注意,必须是一小段波形,太长了弄出来的东西(比如你贴的右边的图)就没意义了!这样的一小段波形(通常在0.02~0.05s这样的数量级)称为一帧(frame)。下面是读的pass的波形中,以0.17s和0.4s为中心截取0.04s波形经傅里叶变换得到的频谱。频谱的横轴是频率;录音的采样率用的是16000 Hz,频谱的频率范围也是0 ~ 16000 Hz。但由于0 ~ 8000 Hz和8000 ~ 16000 Hz的频谱是对称的,所以一般只画0 ~ 8000 Hz的部分。
频谱跟波形一样,也有包络和精细结构。你把横轴压缩,看到的就是包络;把横轴拉开,看到的就是精细结构。我上面这两张图使得二者都能看到。
第一个频谱是元音/æ/的频谱,可以看到它的精细结构是有周期性的,每隔108 Hz出现一个峰。从这儿也可以看出来,语音不是一个单独的频率,而是由许多频率的简谐振动叠加而成的。第一个峰叫基音,其余的峰叫泛音。第一个峰的频率(也是相邻峰的间隔)叫作基频(fundamental frequency),也叫音高(pitch),常记作$f_0$。有时说“一个音的频率”,就是特指基频。基频的倒数叫基音周期。你再看看上面元音/æ/的波形的周期,大约是0.009 s,跟基频108 Hz吻合。频谱上每个峰的高度是不一样的,这些峰的高度之比决定了音色(timbre)。不过对于语音来说,一般没有必要精确地描写每个峰的高度,而是用“共振峰”(formant)来描述音色。共振峰指的是包络的峰。在我这个图中,忽略精细结构,可以看到0~1000 Hz形成一个比较宽的峰,1800 Hz附近形成一个比较窄的峰。共振峰的频率一般用$f_1$、$f_2$等等来表示。上图中,$f_1$是多少很难精确地读出来,但$f_2 \approx 1800Hz$。当然,在2800 Hz、3800 Hz、5000 Hz处还有第三、四、五共振峰,但它们与第一、二共振峰相比就弱了许多。除了元音以外,辅音中的鼻音、浊擦音以及/l/、/r/等也具有这种频谱,可以讨论基频和共振峰频率(不过浊擦音一般不讨论共振峰频率)。
第二个频谱是辅音/s/的频谱。可以看出它的精细结构是没有周期性的,所以就无所谓基频。一般也不提这种频谱的共振峰。清擦音的频谱一般都是这样。
我们最后来看一下声音的第三种表示方式——语谱图(spectrogram)。上面说过,频谱只能表示一小段声音。那么,如果我想观察一整段语音信号的频域特性,要怎么办呢?我们可以把一整段语音信号截成许多帧,把它们各自的频谱“竖”起来(即用纵轴表示频率),用颜色的深浅来代替频谱强度,再把所有帧的频谱横向并排起来(即用横轴表示时间),就得到了语谱图,它可以称为声音的时频域表示。下面我就偷懒,不用Matlab自己画语谱图,而用Cool Edit绘制上面“pass”的语谱图,如下:
注意横轴是时间,纵轴是频率,颜色越亮代表强度越大。可以观察一下0.17s和0.4s处,是不是跟我上面画的频谱相似?然后再试着从这张语谱图上读出元音/æ/的第二共振峰频率。
语谱图的好处是可以直观地看出共振峰频率的变化。我上面读的“pass”中只有一个单元音,如果有双元音就会非常明显了。比如下面这张我读的“eye” /aɪ/,可以非常明显地看出在元音从/a/向/ɪ/过渡的阶段(0.2 ~ 0.25s),$f_1$在降低,而$f_2$在升高。
元音与共振峰的关系已经研究得比较透彻了,简单地说:
1) 开口度越大, $ f_{1} $ 越高;
2) 舌位越靠前, $ f_{2} $ 越高;
3) 不圆唇元音的 $ f_{3} $ 比圆唇元音高。例如, $ / \mathrm{a} / $ 是开、后、不圆唇元音, 所以 $ f_{1} $ 高, $ f_{2} $ 低, $ f_{3} $ 高;/y/(即汉语拼音的ü)是闭、前、圆 唇元音, 所以 $ f_{1} $ 低, $ f_{2} $ 高, $ f_{3} $ 低。也许大家见过下图那样的元音图Q (vowel chart) , 我把 $ f_{1} $ 和 $ f_{2} $ 的变化方向标 $ Q $ 上去。
$f_3$最明显的体现其实是在英语的辅音/r/中,例如下面我读的erase /ɪ’reɪz/的语谱图,可以看到辅音/r/处(0.19s左右)$f_3$明显低,把$f_2$也压下去了。
清擦音可以根据能量集中的频段来分辨。下面是我读的/f/, /θ/, /s/, /ʃ/的语谱图。浊擦音会在清擦音的基础上有周期性的精细结构。
爆破音的爆破时间很短,在语谱图上一般较难分辨。
“两个音之间的音是什么样子”,就要分情况讨论了。
1) 如果是两个元音,那么可以在元音图上找到两个元音,取它们连线的中点。这对应着把$f_1$、$f_2$分别取平均。
2) 如果是两个清擦音,那么可以把它们的频谱取平均,这样听起来应该是个四不像(后来我做了实验,结果见这里:Mixture of Unvoiced Fricatives)。
3) /t/和/ʃ/属于不同类型的辅音,很难定义它们“之间”是什么东西。
以下内容主要来源于语音基础知识(附相关实现代码)。在不理解的地方我会加上自己的注释。
声波通过空气传播,被麦克风接收,通过采样、量化、编码转换为离散的数字信号,即波形文件。音量、音高和音色是声音的基本属性。
1)采样:原始的语音信号是连续的模拟信号,需要对语音进行采样,转化为时间轴上离散的数据。
采样后,模拟信号被等间隔地取样,这时信号在时间上就不再连续了,但在幅度上还是连续的。经过采样处理之后,模拟信号变成了离散时间信号。
采样频率是指一秒钟内对声音信号的采样次数,采样频率越高声音的还原就越真实越自然。
在当今的主流采集卡上,采样频率一般共分为 22.05KHz、44.1KHz、48KHz 三个等级,22.05KHz 只能达到 FM 广播的声音品质,44.1KHz 则是理论上的 CD 音质界限(人耳一般可以感觉到 20-20K Hz 的声音,根据香农采样定理,采样频率应该不小于最高频率的两倍,所以 40KHz 是能够将人耳听见的声音进行很好的还原的一个数值,于是 CD 公司把采样率定为 44.1KHz),48KHz 则更加精确一些。
对于高于 48KHz 的采样频率人耳已无法辨别出来了,所以在电脑上没有多少使用价值。
2)量化:进行分级量化,将信号采样的幅度划分成几个区段,把落在某区段的采样到的样品值归成一类,并给出相应的量化值。根据量化间隔是否均匀划分,又分为均匀量化和非均匀量化。
均匀量化的特点为 “大信号的信噪比大,小信号的信噪比小”。缺点为 “为了保证信噪比要求,编码位数必须足够大,但是这样导致了信道利用率低,如果减少编码位数又不能满足信噪比的要求”(根据信噪比公式,编码位数越大,信噪比越大,通信质量越好)。
通常对语音信号采用非均匀量化,基本方法是对大信号使用大的量化间隔,对小信号使用小的量化间隔。由于小信号时量化间隔变小,其相应的量化噪声功率也减小(根据量化噪声功率公式),从而使小信号时的量化信噪比增大,改善了小信号时的信噪比。
量化后,信号不仅在时间上不再连续,在幅度上也不连续了。经过量化处理之后,离散时间信号变成了数字信号。
3)编码:在量化之后信号已经变成了数字信号,需要将数字信号编码成二进制。“CD 质量” 的语音采用 44100 个样本每秒的采样率,每个样本 16 比特,这个 16 比特就是编码的位数。
采样,量化,编码的过程称为 A/D(从模拟信号到数字信号)转换,如上图 1 所示。
补充比特率的概念:比特率是指每秒传送的比特(bit)数。单位为 bps(Bit Per Second),比特率越高,传送的数据越大,音质越好。以电话为例,每秒3000点取样,每个样本是7比特,那么电话的比特率是21000。而CD是每秒44100点取样,两个声道,每个取样是13位PCM编码,所以CD的比特率是$44100213=1146600$,也就是说CD每秒的数据量大约是144KB,而一张CD的容量是74分等于4440秒,就是639360KB=640MB。
音频的能量通常指的是时域上每帧的能量,幅度的平方。在简单的语音活动检测(Voice Activity Detection,VAD)中,直接利用能量特征:能量大的音频片段是语音,能量小的音频片段是非语音(包括噪音、静音段等)。这种 VAD 的局限性比较大,正确率也不高,对噪音非常敏感。
1 | def __init__(self, input_file, sr=None, frame_len=512, n_fft=None, win_step=2 / 3, window="hamming"): |
短时能量体现的是信号在不同时刻的强弱程度。设第 n 帧语音信号的短时能量用$E_n$表示,则其计算公式为:
上式中,M 为帧长,$x_n(m)$为该帧中的样本点。
1 | def short_time_energy(self): |
单位时间内通过垂直于声波传播方向的单位面积的平均声能,称作声强,声强用 P 表示,单位为 “瓦 / 平米”。实验研究表明,人对声音的强弱感觉并不是与声强成正比,而是与其对数成正比,所以一般声强用声强级来表示:
其中,P 为声强, $P’=10e^{-12}$单位($w/m^2$)称为基本声强,声强级的常用单位是分贝 (dB)。
1 | def intensity(self): |
过零率体现的是信号过零点的次数,体现的是频率特性。
其中,N 表示帧数,M 表示每一帧中的样本点个数,sgn 为符号函数,即:
1 | def zero_crossing_rate(self): |
基音周期反映了声门相邻两次开闭之间的时间间隔,基频(fundamental frequency, F0)则是基音周期的倒数,对应着声带振动的频率,代表声音的音高,声带振动越快,基频越高。如图 2 所示,蓝色箭头指向的就是基频的位置,决定音高。它是语音激励源的一个重要特征,比如可以通过基频区分性别。一般来说,成年男性基频在 100-250Hz 左右,成年女性基频在 150-350Hz 左右,女声的音高一般比男声稍高。 人类可感知声音的频率大致在 20-20000Hz 之间,人类对于基频的感知遵循对数律,也就是说,人们会感觉 100Hz 到 200Hz 的差距,与 200Hz 到 400Hz 的差距相同。因此,音高常常用基频的对数来表示。
这部分的详细介绍可以看前面的
波形、频谱和语谱(声谱)
小节。
音高(pitch)是由声音的基频决定的,音高和基频常常混用。可以这样认为,音高(pitch)是稀疏离散化的基频(F0)。由规律振动产生的声音一般都会有基频,比如语音中的元音和浊辅音;也有些声音没有基频,比如人类通过口腔挤压气流的清辅音。在汉语中,元音有 a/e/i/o/u,浊辅音有 y/w/v,其余音素比如 b/p/q/x 等均为清辅音,在发音时,可以通过触摸喉咙感受和判断发音所属音素的种类。
1 | def pitch(self, ts_mag=0.25): |
声门处的准周期激励进入声道时会引起共振特性,产生一组共振频率,这一组共振频率称为共振峰频率或简称共振峰。共振峰包含在语音的频谱包络中,频谱极大值就是共振峰。频率最低的共振峰称为第一共振峰,对应的频率也称作基频,决定语音的 F0,其它的共振峰统称为谐波,如上图 2 所示,蓝色箭头指向频谱的第一共振峰,也就是基频的位置,决定音高;而绿框则是其它共振峰,统称为谐波。谐波是基频对应的整数次频率成分,由声带发声带动空气共振形成的,对应着声音三要素的音色。谐波的位置,相邻的距离共同形成了音色特征。谐波之间距离近听起来则偏厚粗,之间距离远听起来偏清澈。在男声变女声的时候,除了基频的移动,还需要调整谐波间的包络,距离等,否则将会丢失音色信息。
为了有一个直观的图来解释上述的理论,可以把语音波形、短时能量、声强级、过零率、音高绘制在一张图上,如下图 3 所示:
以下内容主要来源于语音基础知识(附相关实现代码)。在不理解的地方我会加上自己的注释。
在进行语音特征(如 MFCC、频谱图、声谱图等)提取之前一般要进行语音信号的预处理操作,主要包括:预加重、分帧、加窗。
语音经过说话人的口唇辐射发出,受到唇端辐射抑制,高频能量明显降低。一般来说,当语音信号的频率提高两倍时,其功率谱的幅度下降约 6dB,即语音信号的高频部分受到的抑制影响较大。比如像元音等一些因素的发音包含了较多的高频信号的成分,高频信号的丢失,可能会导致音素的共振峰并不明显,使得声学模型对这些音素的建模能力不强。预加重(pre-emphasis)是个一阶高通滤波器,可以提高信号高频部分的能量,给定时域输入信号$x[n]$,预加重之后信号为:
其中,a 是预加重系数,一般取 0.97 或 0.95。如下图 4 所示,元音音素 /aa/ 原始的频谱图(左)和经过预加重之后的频谱图(右)。
1 | def preemphasis(y, coef=0.97, zi=None, return_zf=False): |
语音信号是非平稳信号,考虑到发浊音时声带有规律振动,即基音频率在短时范围内时相对固定的,因此可以认为语音信号具有短时平稳特性,一般认为 10ms~50ms 的语音信号片段是一个准稳态过程。_短时分析_采用分帧方式,一般每帧帧长为 20ms 或 50ms。假设语音采样率为 16kHz,帧长为 20ms,则一帧有 16000×0.02=320 个样本点。
相邻两帧之间的基音有可能发生变化,如两个音节之间,或者声母向韵母过渡。为确保声学特征参数的平滑性,一般采用重叠取帧的方式,即相邻帧之间存在重叠部分。一般来说,帧长和帧移的比例为 1:4 或 1:5。
短时分析:虽然语音信号具有时变特性,但是在一个短时间范围内(一般认为在 10-30ms)其特性基本保持相对稳定,即语音具有短时平稳性。所以任何语音信号的分析和处理必须建立在 “短时” 的基础上,即进行“短时分析”。
1 | def framesig(sig,frame_len,frame_step): |
分帧相当于对语音信号加矩形窗(用矩形窗其实就是不加窗),矩形窗在时域上对信号进行截断,在边界处存在多个旁瓣,会发生频谱泄露。为了减少频谱泄露,通常对分帧之后的信号进行其它形式的加窗操作。常用的窗函数有:汉明(Hamming)窗、汉宁(Hanning)窗和布莱克曼(Blackman)窗等。 加窗主要是为了使时域信号似乎更好地满足 FFT 处理的周期性要求,减少泄漏(加窗不能消除泄漏,只能减少, 如下图 5 所示)。
什么是频谱泄露?
音频处理中,经常需要利用傅里叶变换将时域信号转换到频域,而一次快速傅里叶变换(FFT)只能处理有限长的时域信号,但语音信号通常是长的,所以需要将原始语音截断成一帧一帧长度的数据块。这个过程叫信号截断,也叫分帧。分完帧后再对每帧做 FFT,得到对应的频域信号。FFT 是离散傅里叶变换(DFT)的快速计算方式,而做 DFT 有一个先验条件:分帧得到的数据块必须是整数周期的信号,也即是每次截断得到的信号要求是周期主值序列。但做分帧时,很难满足周期截断,因此就会导致频谱泄露。一句话,频谱泄露就是分析结果中,出现了本来没有的频率分量。比如说,50Hz 的纯正弦波,本来只有一种频率分量,分析结果却包含了与 50Hz 频率相近的其它频率分量。
非周期的无限长序列,任意截取一段有限长的序列,都不能代表实际信号,分析结果当然与实际信号不一致!也就是会造成频谱泄露。而周期的无限长序列,假设截取的是正好一个或整数个信号周期的序列,这个有限长序列就可以代表原无限长序列,如果分析的方法得当的话,分析结果应该与实际信号一致!因此也就不会造成频谱泄露。
汉明窗的窗函数为: $ W_{\mathrm{ham}}[n]=0.54-0.46 \cos \left(\frac{2 \pi n}{N}-1\right) $;汉宁窗的窗函数为: $ W_{h a n}[n]=0.5\left[1-\cos \left(\frac{2 \pi n}{N}-1\right)\right] $ ,其中$n$介于0到$ \mathrm{N}-1 $ 之间,$ \mathrm{N} $ 是窗的长度。
加窗就是用一定的窗函数$ w(n) $来乘$ s(n) $, 从而形成加窗语音信号$s_{w}(n)=\mathrm{s}(\mathrm{n}) * w(\mathrm{n}) $。
1 | def framesig(sig,frame_len,frame_step,winfunc=lambda x:numpy.ones((x,))): |
以下内容主要来源于论文笔记:语音情感识别(四)语音特征之声谱图,log梅尔谱,MFCC,deltas
声音信号本是一维的时域信号,直观上很难看出频率变化规律。傅里叶变换可把它变到频域上,虽然可看出信号的频率分布,但是丢失了时域信息,无法看出频率分布随时间的变化。为了解决这个问题,很多时频分析手段应运而生,如短时傅里叶,小波,Wigner分布等都是常用的时频域分析方法。
从音频文件中读取出来的原始语音信号通常称为 raw waveform,是一个一维数组,长度是由音频长度和采样率决定,比如采样率 Fs 为 16KHz,表示一秒钟内采样 16000 个点,这个时候如果音频长度是 10 秒,那么 raw waveform 中就有 160000 个值,值的大小通常表示的是振幅。
(1)对原始信号进行分帧加窗后,可以得到很多帧,对每一帧做 FFT(快速傅里叶变换),傅里叶变换的作用是把时域信号转为频域信号,把每一帧 FFT 后的频域信号(频谱图)在时间上堆叠起来就可以得到声谱图,其直观理解可以形象地表示为以下几个图,图源见CMU 语音课程 slides。
(2)有些论文提到的 DCT(离散傅里叶变换)和 STFT(短时傅里叶变换)其实是差不多的东西。STFT 就是对一系列加窗数据做 FFT。而 DCT 跟 FFT 的关系就是:FFT 是实现 DCT 的一种快速算法。
(3)FFT 有个参数 N,表示对多少个点做 FFT,如果一帧里面的点的个数小于 N 就会 zero-padding 到 N 的长度。对一帧信号做 FFT 后会得到 N 点的复数,这个点的模值就是该频率值下的幅度特性。每个点对应一个频率点,某一点 n(n 从 1 开始)表示的频率为$F_n = (n-1)*Fs/N$,第一个点(n=1,Fn 等于 0)表示直流信号,最后一个点 N 的下一个点(n=N+1,Fn=Fs 时,实际上这个点是不存在的)表示采样频率 Fs。
(4)FFT 后我们可以得到 N 个频点,频率间隔(也叫频率分辨率或)为 Fs / N,比如,采样频率为 16000,N 为 1600,那么 FFT 后就会得到 1600 个点,频率间隔为 10Hz,FFT 得到的 1600 个值的模可以表示 1600 个频点对应的振幅。因为 FFT 具有对称性,当 N 为偶数时取 N/2+1 个点,当 N 为奇数时,取 (N+1)/2 个点,比如 N 为 512 时最后会得到 257 个值。
(5)用 python_speech_feature 库时可以看到有三种声谱图,包括振幅谱,功率谱(有些资料称为能量谱,是一个意思,功率就是单位时间的能量),log 功率谱。振幅谱就是 fft 后取绝对值。功率谱就是在振幅谱的基础上平方然后除以 N。log 功率谱就是在功率谱的基础上取 10 倍 lg,然后减去最大值。得到声谱图矩阵后可以通过 matplotlib 来画图。
(6)常用的声谱图都是 STFT 得到的,另外也有用 CQT(constant-Q transform)得到的,为了区分,将它们分别称为 STFT 声谱图和 CQT 声谱图。
梅尔频谱的英文为Mel-spectrogram。
(1)人耳听到的声音高低和实际(Hz)频率不呈线性关系,用 Mel 频率更符合人耳的听觉特性(这正是用 Mel 声谱图的一个动机,由人耳听力系统启发),即在 1000Hz 以下呈线性分布,1000Hz 以上呈对数增长,Mel 频率与 Hz 频率的关系为$f_{mel} = 2595 \cdot lg(1+\frac{f}{700Hz})$,如下图所示,图源见一个 MFCC 的介绍教程。有另一种计算方式为$f_{mel} = 1125 \cdot ln(1+\frac{f}{700Hz})$。下面给出一个计算 Mel 声谱图的例子。另,python 中可以用 librosa 调包得到梅尔声谱图。
通过实际的主观实验,科学家发现人耳对低频信号的区别更加敏感,而对高频信号的区别则不那么敏感。也就是说低频段上的两个频度和高频段上的两个频度,人们会更容易区分前者。因此我们就明白了,频域上相等距离的两对频度,对于人耳来说他们的距离不一定相等。那么,能不能调整频域的刻度,使得这个新的刻度上相等距离的两对频度,对于人耳来说也相等呢?答案是可以的,这就是梅尔刻度。
下图展示了梅尔频度-正常频度的对应关系,正如之前所说明的,低频段的部分,梅尔刻度和正常频度几乎呈线性关系,而在高频段,因为人耳的感知变弱,因此两者呈对数关系。
(2)假设现在用 10 个 Mel filterbank(一些论文会用 40 个,如果求 MFCC 一般是用 26 个然后在最后取前 13 个),为了获得 filterbanks 需要选择一个 lower 频率和 upper 频率,用 300 作为 lower,8000 作为 upper 是不错的选择。如果采样率是 8000Hz 那么 upper 频率应该限制为 4000。然后用公式把 lower 和 upper 转为 Mel 频率,我们使用上述第二个公式(ln 那条),可以得到 401.25Mel 和 2834.99Mel。
(3)因为用 10 个滤波器,所以需要 12 个点来划分出 10 个区间,在 401.25Mel 和 2834.99Mel 之间划分出 12 个点,m(i) = (401.25, 622.50, 843.75, 1065.00, 1286.25, 1507.50, 1728.74, 1949.99, 2171.24, 2392.49, 2613.74, 2834.99)。
(4)然后把这些点转回 Hz 频率,h(i) = (300, 517.33, 781.90, 1103.97, 1496.04, 1973.32, 2554.33, 3261.62, 4122.63, 5170.76, 6446.70, 8000)。
(5)把这些频率转为 fft bin,f(i) = floor( (N+1)*h(i)/Fs),N 为 FFT 长度,默认为 512,Fs 为采样频率,默认为 16000Hz,则 f(i) = (9, 16, 25, 35, 47, 63, 81, 104, 132, 165, 206, 256)。这里 256 刚好对应 512 点 FFT 的 8000Hz。
(6)然后创建滤波器,第一个滤波器从第一个点开始,在第二个点到达最高峰,第三个点跌回零。第二个滤波器从第二个点开始,在第三个点到达最大值,在第四个点跌回零。以此类推。滤波器的示意图如下图所示,图源见csdn-MFCC 计算过程。可以看到随着频率的增加,滤波器的宽度也增加。
(7)接下来给出滤波器输出的计算公式,如下所示,其中 m 从 1 到 M,M 表示滤波器数量,这里是 10。k 表示点的编号,一个 fft 内 256 个点,k 从 1 到 256,表示了 fft 中的 256 个频点(k=0 表示直流信号,算进来就是 257 个频点,为了简单起见这里省略 k=0 的情况)。
(8)最后还要乘上 fft 计算出来的能量谱,关于能量谱在前一节(线性)声谱图中已经讲过了。将滤波器的输出应用到能量谱后得到的就是梅尔谱,具体应用公式如下,其中 $|X(k)|^2$表示能量谱中第 k 个点的能量。以每个滤波器的频率范围内的输出作为权重,乘以能量谱中对应频率的对应能量,然后把这个滤波器范围内的能量加起来。举个例子,比如第一个滤波器负责的是 9 和 16 之间的那些点(在其它范围的点滤波器的输出为 0),那么只对这些点对应的频率对应的能量做加权和。
(9)这样计算后,对于一帧会得到 M 个输出。经常会在论文中看到说 40 个梅尔滤波器输出,指的就是这个(实际上前面说的梅尔滤波器输出是权重 H,但是这里的意思应该是将滤波器输出应用到声谱后得到的结果,根据上下文可以加以区分)。然后在时间上堆叠多个 “40 个梅尔滤波器输出” 就得到了梅尔尺度的声谱(梅尔谱),如果再取个 log,就是 log 梅尔谱,log-Mels。
(10)把滤波器范围内的能量加起来,可以解决一个问题,这个问题就是人耳是很难理解两个靠的很近的线性频率(就是和梅尔频率相对应的赫兹频率)之间不同。如果把一个频率区域的能量加起来,只关心在每个频率区域有多少能量,这样人耳就比较能区分,我们希望这种方式得到的(Mel)声谱图可以更加具有辨识度。最后取 log 的 motivation 也是源于人耳的听力系统,人对声音强度的感知也不是线性的,一般来说,要使声音的音量翻倍,我们需要投入 8 倍的能量,为了把能量进行压缩,所以取了 log,这样,当 x 的 log 要翻倍的话,就需要增加很多的 x。另外一个取 log 的原因是为了做倒谱分析得到 MFCC,具体细节见下面 MFCC 的介绍。
(1)MFCC,梅尔频率的倒谱系数(Mel Frequency Cepstral Coefficents),是广泛应用于语音领域的特征,在这之前常用的是线性预测系数 Linear Prediction Coefficients(LPCs)和线性预测倒谱系数(LPCCs),特别是用在 HMM 上。
(2)先说一下获得 MFCC 的步骤,首先分帧加窗,然后对每一帧做 FFT 后得到(单帧)能量谱(具体步骤见上面线性声谱图的介绍),对线性声谱图应用梅尔滤波器后然后取 log 得到 log 梅尔声谱图(具体步骤见上面梅尔声谱图的介绍),然后对 log 滤波能量(log 梅尔声谱)做 DCT,离散余弦变换(傅里叶变换的一种),然后保留第二个到第 13 个系数,得到的这 12 个系数就是 MFCC。
(3)然后再大致说说 MFCC 的含义,下图第一个图(图源见参考资料 [1])是语音的频谱图,峰值是语音的主要频率成分,这些峰值称为共振峰,共振峰携带了声音的辨识(相当于人的身份证)。把这些峰值平滑地连起来得到的曲线称为频谱包络,包络描述了携带声音辨识信息的共振峰,所以我们希望能够得到这个包络来作为语音特征。频谱由频谱包络和频谱细节组成,如下第二个图(图源见参考资料[1])所示,其中 log X[k] 代表频谱(注意图中给出的例子是赫兹谱,这里只是举例子,实际我们做的时候通常都是用梅尔谱),log H[k]代表频谱包络,log E[k]代表频谱细节。我们要做的就是从频谱中分离得到包络,这个过程也称为倒谱分析,下面就说说倒谱分析是怎么做的。
(4)要做的其实就是对频谱做 FFT,在频谱上做 FFT 这个操作称为逆 FFT,需要注意的是我们是在频谱的 log 上做的,因为这样做 FFT 后的结果 x[k]可以分解成 h[k]和 e[k]的和。我们先看下图(图源见参考资料 [1]),对包络 log H[k] 做 IFFT 的结果,可以看成 “每秒 4 个周期的正弦波”,于是我们在伪频率轴上的 4Hz 上给一个峰值,记作 h[k]。对细节 log E[k] 做 IFFT 的结果,可以看成 “每秒 100 个周期的正弦波”,于是我们在伪频率轴上的 100Hz 上给一个峰值,记作 e[k]。对频谱 log X[k] 做 IFFT 后的结果记作 x[k],这就是我们说的倒谱,它会等于 h[k]和 e[k]的叠加,如下第二个图所示。我们想要得到的就是包络对应的 h[k],而 h[k]是 x[k]的低频部分,只需要对 x[k]取低频部分就可以得到了。
(5)最后再总结一下得到 MFCC 的步骤,求线性声谱图,做梅尔滤波得到梅尔声谱图,求个 log 得到 log 梅尔谱,做倒谱分析也就是对 log X[k] 做 DCT 得到 x[k],取低频部分就可以得到倒谱向量,通常会保留第 2 个到第 13 个系数,得到 12 个系数,这 12 个系数就是常用的 MFCC。图源见参考资料 [1]。
(1)deltas 和 deltas-deltas,看到很多人翻译成一阶差分和二阶差分,也被称为微分系数和加速度系数。使用它们的原因是,MFCC 只是描述了一帧语音上的能量谱包络,但是语音信号似乎有一些动态上的信息,也就是 MFCC 随着时间的改变而改变的轨迹。有证明说计算 MFCC 轨迹并把它们加到原始特征中可以提高语音识别的表现。
(2)以下是 deltas 的一个计算公式,其中 t 表示第几帧,N 通常取 2,c 指的就是 MFCC 中的某个系数。deltas-deltas 就是在 deltas 上再计算以此 deltas。
(3)对 MFCC 中每个系数都做这样的计算,最后会得到 12 个一阶差分和 12 个二阶差分,我们通常在论文中看到的 “MFCC 以及它们的一阶差分和二阶差分” 指的就是这个。
(4)值得一提的是 deltas 和 deltas-deltas 也可以用在别的参数上来表述动态特性,有论文中是直接在 log Mels 上做一阶差分和二阶差分的,论文笔记:语音情感识别(二)声谱图 + CRNN 中 3-D Convolutional Recurrent Neural Networks with Attention Model for Speech Emotion Recognition 这篇论文就是这么做的。
1.频谱:时域信号(一维)短时傅里叶变换后的频域信号(一维)。
2.声谱图/语谱图:把一整段语音信号截成许多帧,把它们各自的频谱“竖”起来(即用纵轴表示频率),用颜色的深浅来代替频谱强度,再把所有帧的频谱横向并排起来(即用横轴表示时间),就得到了语谱图,它可以称为声音的时频域表示。
3.倒谱:也叫做倒频谱,二次谱,对数功率谱等。对声谱图取对数后,再DFT变回时域,此时不是完全意义上的时域,应叫做倒谱域。
4.MFCC:对线性声谱图应用mel滤波器后,取log,得到log梅尔声谱图,然后对log滤波能量(log梅尔声谱)做DCT离散余弦变换(傅里叶变换的一种),然后保留第2到第13个系数,得到的这12个系数就是MFCC。
附加:
1.能量谱:也叫做能量密度谱。是原信号傅里叶变化的平方。用于描述时间序列的能量随频率的分布。
2.功率谱:将频谱或时频谱(语谱)中的幅值进行平方,得到功率谱。
3.功率谱密度:定义为单位频带内的吸纳后功率。其推导公式较为复杂,但维纳-辛欣定理证明了:一段信号的功率谱等于这段信号自相关函数的傅里叶变换。
注:信号分为确定和随机,确定信号又分为能量和功率,随机信号一定是功率信号。语音信号是随机信号。
[1] CMU 语音课程 slides
[2] 一个 MFCC 的介绍教程
[3] csdn-MFCC 计算过程
[4] 博客园 - MFCC 学习笔记
cnlinxi/book-text-to-speech: A book about Text-to-Speech (TTS) in Chinese. (github.com)
声谱图,梅尔语谱,倒谱,梅尔倒谱系数
论文笔记:语音情感识别(四)语音特征之声谱图,log梅尔谱,MFCC,deltas
语音基础知识(附相关实现代码)
不同元音辅音在声音频谱的表现是什么样子? - 王赟 Maigo的回答 - 知乎
搬运工:波形、频谱和声谱的关系
语音合成基础(3)——关于梅尔频谱你想知道的都在这里
语音合成基础(1)——语音和TTS
《语音信号处理》整理
MP3的采样率和比特率
2018年深度学习在NLP领域取得了比较大的突破,最大的新闻当属Google的BERT模型横扫各大比赛的排行榜。作者认为,深度学习在NLP领域比较重点的三大突破为:Word Embedding、RNN/LSTM/GRU+Seq2Seq+Attention+Self-Attention机制和Contextual Word Embedding(Universal Sentence Embedding)。
Word Embedding解决了传统机器学习方法的特征稀疏问题,它通过把一个词映射到一个低维稠密的语义空间,从而使得相似的词可以共享上下文信息,从而提升泛化能力。而且通过无监督的训练可以获得高质量的词向量(比如Word2vec和Glove等方法),从而把这些语义知识迁移到数据较少的具体任务上。但是Word Embedding学到的是一个词的所有语义,比如bank可以是”银行”也可以是”水边。如果一定要用一个固定的向量来编码其语义,那么我们只能把这两个词的语义都编码进去,但是实际一个句子中只有一个语义是合理的,这显然是有问题的。
这时我们可以通过RNN/LSTM/GRU来编码上下文的语义,这样它能学到如果周围是money,那么bank更可能是”银行”的语义。最原始的RNN由于梯度消失和梯度爆炸等问题很难训练,后来引入了LSTM和GRU等模型来解决这个问题。最早的RNN只能用于分类、回归和序列标注等任务,通过引入两个RNN构成的Seq2Seq模型可以解决序列的变换问题。比如机器翻译、摘要、问答和对话系统都可以使用这个模型。尤其机器翻译这个任务的训练数据比较大,使用深度学习的方法的效果已经超过传统的机器学习方法,而且模型结构更加简单。到了2017年,Google提出了Transformer模型,引入了Self-Attention。Self-Attention的初衷是为了用Attention替代LSTM,从而可以更好的并行(因为LSTM的时序依赖特效很难并行),从而可以处理更大规模的语料。Transformer出来之后被广泛的用于以前被RNN/LSTM/GRU霸占的地盘,Google更是在Transformer的论文里使用”Attention is all you need”这样霸气的标题。现在Transformer已经成为Encoder/Decoder的霸主。
虽然RNN可以学到上下文的信息,但是这些上下文的语义是需要通过特定任务的标注数据使用来有监督的学习。很多任务的训练数据非常少并且获取成本很高,因此在实际任务中RNN很难学到复杂的语义关系。当然通过Multi-Task Learning,我们可以利用其它相关任务的数据。比如我们要做文本分类,我们可以利用机器翻译的训练数据,通过同时优化两个(多个)目标,让模型同时学到两个任务上的语义信息,因为这两个任务肯定是共享很多基础语义信息的,所以它的效果要比单个任务好。但即使这样,标注的数据量还是非常有限的。
因此2018年的研究热点就变成了怎么利用无监督的数据学习Contextual Word Embedding(也叫做Universal Sentence Embedding),也就是通过无监督的方法,让模型能够学到一个词在不同上下文的不同语义表示方法。当然这个想法很早就有了,比如2015年的Skip Thought Vector,但是它只使用了BookCorpus,这只有一万多本书,七千多万个句子,因此效果并没有太明显的提升。
在BERT之前比较大的进展是ELMo、ULMFiT和OpenAI GPT。尤其是OpenAI GPT,它在BERT出现之前已经横扫过各大排行榜一次了,当然Google的BERT又横扫了一次。
UMLFiT比较复杂,而且效果也不是特别好,我们暂且不提。ELMo和OpenAI GPT的思想其实非常非常简单,就是用海量的无标注数据学习语言模型,在学习语言模型的过程中自然而然的就学到了上下文的语义关系。它们都是来学习一个语言模型,前者使用的是LSTM而后者使用Transformer,在进行下游任务处理的时候也有所不同,ELMo是把它当成特征。拿分类任务来说,输入一个句子,ELMo用LSTM把它扫一次,这样就可以得到每个词的表示,这个表示是考虑上下文的,因此”He deposited his money in this bank”和”His soldiers were arrayed along the river bank”中的两个bank的向量是不同的。下游任务用这些向量来做分类,它会增加一些网络层,但是ELMo语言模型的参数是固定的。而OpenAI GPT不同,它直接用特定任务来Fine-Tuning Transformer的参数。因为用特定任务的数据来调整Transformer的参数,这样它更可能学习到与这个任务特定的上下文语义关系,因此效果也更好。
而BERT和OpenAI GPT的方法类似,也是Fine-Tuning的思路,但是它解决了OpenAI GPT(包括ELMo)单向信息流的问题,同时它的模型和语料库也更大。依赖Google强大的计算能力和工程能力,BERT横扫了OpenAI GPT。成王败寇,很少还有人记得OpenAI GPT的贡献了。但是BERT的很多思路都是沿用OpenAI GPT的,要说BERT的学术贡献,最多是利用了Mask LM(这个模型在上世纪就存在了)和Predicting Next Sentence这个Multi-task Learning而已。
我们之前学习过word2vec,其中一种模型是Skip-Gram模型,根据中心词预测周围的(context)词,这样我们可以学到词向量。那怎么学习到句子向量呢?一种很自然想法就是用一个句子预测它周围的句子,这就是Skip Thought Vector的思路。它需要有连续语义相关性的句子,比如论文中使用的书籍。一本书由很多句子组成,前后的句子是有关联的。那么我们怎么用一个句子预测另一个句子呢?这可以使用Encoder-Decoder,类似于机器翻译。
比如一本书里有3个句子”I got back home”、”I could see the cat on the steps”和”This was strange”。我们想用中间的句子”I could see the cat on the steps.”来预测前后两个句子。如下图所示,输入是句子”I could see the cat on the steps.”,输出是两个句子”I got back home.”和”This was strange.”。
图:Skip Thought Vector
我们首先用一个Encoder(比如LSTM或者GRU)把输入句子编码成一个向量。而右边是两个Decoder(我们任务前后是不对称的,因此用两个Decoder)。因为我们不需要预测(像机器翻译那样生成一个句子),所以我们只考虑Decoder的训练。Decoder的输入是”<eos> I got back home”,而Decoder的输出是”I got back home <eos>”。
经过训练之后,我们就得到了一个Encoder(Decoder不需要了)。给定一个新的句子,我们可以把它编码成一个向量。这个向量可以用于下游(down stream)的任务,比如情感分类,语义相似度计算等等。
和训练Word2Vec不同,Word2Vec只需要提供句子,而Skip Thought Vector需要文章(至少是段落)。论文使用的数据集是BookCorpus(http://yknzhu.wixsite.com/mbweb),目前网站已经不提供下载了。BookCorpus的统计信息如下图所示,有一万多本书,七千多万个句子。
图:BookCorpus统计信息
接下来我们介绍一些论文中使用的模型,注意这是2015年的论文,过去好几年了,其实我们是可以使用更新的模型。但是基本的思想还是一样的。
Encoder是一个GRU。假设句子$s_i=w_i^1…w_i^N$,t时刻的隐状态是$h_i^t$认为编码了字符串$w_i^1…w_i^t$的语义,因此$h_i^N$可以看成对整个句子语义的编码。t时刻GRU的计算公式为:
这就是标准的GRU,其中$x^t$是$w_i^t$的Embedding向量,$r^t$是重置(reset)门,$z^t$是更新(update)门,$\odot$是element-wise的乘法。Decoder是一个神经网络语言模型。
和之前我们在机器翻译里介绍的稍微有一些区别。标准Encoder-Decoder里Decoder每个时刻的输入是$x^{t-1}$和$h^{t-1}$,Decoder的初始状态设置为Encoder的输出$h_i$。而这里Decodert时刻的输入除了$x^{t-1}$和$h^{t-1}$,还有Encoder的输出$h_i$。
计算出Decoder每个时刻的隐状态$h^t$之后,我们在用一个矩阵V把它投影到词的空间,输出的是预测每个词的概率分布。注意:预测前一个句子和后一个句子是两个GRU模型,它们的参数是不共享的,但是投影矩阵V是共享的。当然输入$w^t$到Embedding $x^t$的Embedding矩阵也是共享的。和Word2Vec对比的话,V是输出向量(矩阵)而这个Embedding(这里没有起名字)是输入向量(矩阵)。
这篇论文还有一个比较重要的方法就是词汇扩展。因为BookCorpus相对于训练Word2Vec等的语料来说还是太小,很多的词都根本没有在这个语料中出现,因此直接使用的话效果肯定不好。
本文使用了词汇扩展的办法。具体来说我们可以先用海量的语料训练一个Word2Vec,这样可以把一个词映射到一个语义空间,我们把这个向量叫作$\mathcal{V}_{w2v}$。而我们之前训练的得到的输入向量也是把一个词映射到另外一个语义空间,我们记作。
我们假设它们之间存在一个线性变换。这个线性变换的参数是矩阵W,使得。那怎么求这个变换矩阵W呢?因为两个训练语料会有公共的词(通常训练word2vec的语料比skip vector大得多,从而词也多得多)。因此我们可以用这些公共的词来寻找W。寻找的依据是:遍历所有可能的W,使得$Wv_{w2v}$和$v_{rnn}$尽量接近。用数学语言描述就是:
首先训练了单向的GRU,向量的维度是2400,我们把它叫作uni-skip向量。此外还训练了bi-skip向量,它是这样得到的:首先训练1200维的uni-skip,然后句子倒过来,比如原来是”aa bb”、”cc dd”和”ee ff”,我们是用”cc dd”来预测”aa bb”以及”ee ff”,现在反过来变成”ff ee”、”dd cc”和”bb aa”。这样也可以训练一个模型,当然也就得到一个encoder(两个decoder不需要了),给定一个句子我们把它倒过来然后也编码成1200为的向量,最后把这个两个1200维的向量拼接成2400维的向量。
模型训练完成之后还需要进行词汇扩展。通过BookCorpus学习到了20,000个词,而word2vec共选择了930,911词,通过它们共同的词学习出变换矩阵W,从而使得我们的Skip Thought Vector可以处理930,911个词。
为了验证效果,本文把Sentence Embedding作为下游任务的输入特征,任务包括分类(情感分类),SNI(RTE)等。前者的输入是一个句子,而后者的输入是两个句子。
这里使用了SICK(SemEval 2014 Task 1,给定两个句子,输出它们的语义相关性1-5五个分类)和Microsoft Paraphrase Corpus(给定两个句子,判断它们是否一个意思/两分类)。
它们的输入是两个句子,输出是分类数。对于输入的两个句子,我们用Skip Thought Vector把它们编码成两个向量u和v,然后计算$u \cdot v$与$\vert u-v \vert $,然后把它们拼接起来,最后接一个logistic regression层(全连接加softmax)。
使用这么简单的分类模型的原因是想看看Sentence Embedding是否能够学习到复杂的非线性的语义关系。使用结果如下图所示。可以看到效果还是非常不错的,和(当时)最好的结果差别不大,而那些结果都是使用非常复杂的模型得到结果,而这里只使用了简单的逻辑回归模型。
图:Semantic relatedness的效果
这个任务的输入是一幅图片和一个句子,模型输出的是它们的相关性(句子是否描述了图片的内容)。句子我们可以用Skip Thought Vector编码成一个向量;而图片也可以用预训练的CNN编码成一个向量。模型细节这里不再赘述了,最终的结果如下图所示。
图:Image Retrieval的效果
这里比较了5个分类任务: 电影评论情感分类(MR), 商品评论情感分类(CR) , 主观/客观分类(SUBJ), 意见分类(MPQA)和TREC问题类型分类。结果如下图所示。
图:分类任务的效果
ELMo是Embeddings from Language Models的缩写,意思就是语言模型得到的(句子)Embedding。另外Elmo是美国儿童教育电视节目芝麻街(Sesame Street)里的小怪兽的名字。原始论文是Deep contextualized word representations,这个标题是很合适的,也就是用深度的Transformer模型来学习上下文相关的词表示。
这篇论文的想法其实非常非常简单,但是取得了非常好的效果。它的思路是用深度的双向RNN(LSTM)在大量未标注数据上训练语言模型,如下图所示。然后在实际的任务中,对于输入的句子,我们使用这个语言模型来对它处理,得到输出的向量,因此这可以看成是一种特征提取。但是和普通的Word2Vec或者GloVe的pretraining不同,ELMo得到的Embedding是有上下文的。比如我们使用Word2Vec也可以得到词”bank”的Embedding,我们可以认为这个Embedding包含了bank的语义。但是bank有很多意思,可以是银行也可以是水边,使用普通的Word2Vec作为Pretraining的Embedding,只能同时把这两种语义都编码进向量里,然后靠后面的模型比如RNN来根据上下文选择合适的语义——比如上下文有money,那么它更可能是银行;而如果上下文是river,那么更可能是水边的意思。但是RNN要学到这种上下文的关系,需要这个任务有大量相关的标注数据,这在很多时候是没有的。而ELMo的特征提取可以看成是上下文相关的,如果输入句子有money,那么它就(或者我们期望)应该能知道bank更可能的语义,从而帮我们选择更加合适的编码。
图:RNN语言模型
给定一个长度为N的句子,假设为$t_1,t_2,…,t_N$,语言模型会计算给定$t_1,…,t_{k-1}$的条件下出现$t_k$的概率:
传统的N-gram语言模型不能考虑很长的历史,因此现在的主流是使用多层双向的RNN(LSTM/GRU)来实现语言模型。在每个时刻k,RNN的第j层会输出一个隐状态,其中$j=1,2,…,L$,L是RNN的层数。最上层是,对它进行softmax之后就可以预测输出词的概率。类似的,我们可以用一个反向的RNN来计算概率:
通过这个RNN,我们可以得到$\overleftarrow{h}_{kj}^{LM}$。我们把这两个方向的RNN合并起来就得到Bi-LSTM。我们优化的损失函数是两个LSTM的交叉熵加起来是最小的:
这两个LSTM有各自的参数和,但是word embedding参数$\Theta_x$和softmax参数$\Theta_s$是共享的。
ELMo会根据不同的任务,把上面得到的双向的LSTM的不同层的隐状态组合起来。对于输入的词$t_k$,我们可以得到2L+1个向量,分别是,我们把它记作。其中是词的Embedding,它与上下文无关,而其它的是把双向的LSTM的输出拼接起来的,它们与上下文相关的。为了用于下游(downstream)的特定任务,我们会把不同层的隐状态组合起来,组合的参数是根据特定任务学习出来的,公式如下:
这里的$\gamma^{task}$是一个缩放因子,而$s_j^{task}$用于把不同层的输出加权组合出来。在实际的任务中,RNN的参数$h_{kj}^{LM}$都是固定的,可以调的参数只是$\gamma^{task}$和$s_j^{task}$。当然这里ELMo只是一个特征提取,实际任务会再加上一些其它的网络结构,那么那些参数也是一起调整的。
下图是ELMo在SQuAD、SNLI等常见任务上的效果,相对于Baseline系统都有不小的提高。
图:ELMo的效果
OpenAI GPT是来自OpenAI的论文Improving Language Understanding by Generative Pre-Training,BERT借鉴了很多它的方法。
和前面的ELMo不同,GPT得到的语言模型的参数不是固定的,它会根据特定的任务进行调整(通常是微调),这样得到的句子表示能更好的适配特定任务。它的思想其实也很简单,使用Transformer来学习一个语言模型,对句子进行无监督的Embedding,然后根据具体任务对Transformer的参数进行微调。
之前我们介绍的Transformer模型是用来做机器翻译的,它有一个Encoder和一个Decoder。这里使用的是Encoder,只不过Encoder的输出不是给Decoder使用,而是直接用它来预测下一个词,如下图所示。但是直接用Self-Attention来训练语言模型是有问题的,因为在k时刻$p(t_k \vert t_1,..,t_{k-1})$,也就是计算$t_k$的时候只能利用它之前的词(或者逆向的语言模型只能用它之后的词)。但是Transformer的Self-Attention是可以利用整个句子的信息的,这显然不行,因为你让它根据”it is a”来预测后面的词,而且还告诉它整个句子是”it is a good day”,它就可能”作弊”,直接把下一个词输出了,这样loss是零。
因此这里要借鉴Decoder的Mask技巧,通过Mask让它在编码$t_k$的时候只能利用k之前(包括k本身)的信息。具体来说,给定一个未标注的语料库$\mathcal{U}=\\{u_1,…,u_n\\}$,我们训练一个语言模型,对参数进行最大(对数)似然估计:
我们这里使用多层的Transformer来实现语言模型,具体为:
这里的$W_e$是词的Embedding Matrix,$W_p$是位置Embedding Matrix。注意这里的位置编码没有使用前面Transformer的固定编码方式,而是采用类似词的Embedding Matrix,让它自己根据任务学习出合适的位置编码。
无监督的Pretraining之后,我们还需要针对特定任务进行Fine-Tuning。我们先假设监督数据集合$\mathcal{C}$的输入x是一个词序列(后面会讲到怎么处理相似度计算或者问答这种输入有两个序列的问题)$x^1,…,x^m$,输出是一个分类的标签y,比如情感分类(Sentiment Classification)任务就是满足上述的条件。
我们把$x^1,…,x^m$输入Transformer模型,得到最上层的最后一个时刻的输出$h_l^m$,然后我们再加一个softmax层(参数为$W_y$)进行分类,最后用交叉熵损失函数计算损失,从而根据标准数据调整Transformer的参数以及softmax的参数$W_y$。这等价于最大似然估计:
正常我们应该调整参数使得$L_2$最大,但是为了提高训练速度和模型的泛化能力,我们使用Multi-task Learning,同时让它最大似然$L_1$和$L_2$:
注意,这里使用的$L_1$还是之前的语言模型的损失(似然),但是使用的数据不是前面无监督的数据$\mathcal{U}$,而是使用简单的数据$\mathcal{C}$,而且只使用其中的x,而不需要标签y。
前面讲了,我们能够处理的任务要求输入是一个序列,而输出是一个分类标签。对于有些任务,比如情感分类,这是没有问题的,但是对于相似度计算或者问答,输入是两个序列。为了能够使用GPT,我们需要一些特殊的技巧把两个输入序列变成一个输入序列。
图:处理其它任务
如图上图所示,对于输入是一个序列的任务,我们在序列前后增加两个特殊token——“start”和”extract”,分别表示开始和结束;而如果输入是两个序列,那么在它们中间增加一个特殊的token “delim”。比如Entailment,输入是Premise和Hypothesis,输出是3个分类标签中的一个。
如果是相似度计算,因为对称性,我们把它们交换顺序,然后输入两个Transformer。如果是多选题,比如给定一个问题和N个答案,那么我们可以把问题和N个答案分别输入N个Transformer。
下图是部分实验结果,相对于之前的baseline对很多任务都有提高。
图:OpenAI GPT的部分实验结果
ELMo和GPT最大的问题就是传统的语言模型是单向的——我们是根据之前的历史来预测当前词。但是我们不能利用后面的信息。比如句子”The animal didn’t cross the street because it was too tired”。我们在编码it的语义的时候需要同时利用前后的信息,因为在这个句子中,it可能指代animal也可能指代street。根据tired,我们推断它指代的是animal,因为street是不能tired。但是如果把tired改成wide,那么it就是指代street了。传统的语言模型,不管是RNN还是Transformer,它都只能利用单方向的信息。比如前向的RNN,在编码it的时候它看到了animal和street,但是它还没有看到tired,因此它不能确定it到底指代什么。如果是后向的RNN,在编码的时候它看到了tired,但是它还根本没看到animal,因此它也不能知道指代的是animal。Transformer的Self-Attention理论上是可以同时attend to到这两个词的,但是根据前面的介绍,由于我们需要用Transformer来学习语言模型,因此必须用Mask来让它看不到未来的信息,所以它也不能解决这个问题的。
注意:即使ELMo训练了双向的两个RNN,但是一个RNN只能看一个方向,因此也是无法”同时”利用前后两个方向的信息的。也许有的读者会问,我的RNN有很多层,比如第一层的正向RNN在编码it的时候编码了animal和street的语义,反向RNN编码了tired的语义,然后第二层的RNN就能同时看到这两个语义,然后判断出it指代animal。理论上是有这种可能,但是实际上很难。举个反例,理论上一个三层(一个隐层)的全连接网络能够拟合任何函数,那我们还需要更多层词的全连接网络或者CNN、RNN干什么呢?如果数据不是足够足够多,如果不对网络结构做任何约束,那么它有很多中拟合的方法,其中很多是过拟合的。但是通过对网络结构的约束,比如CNN的局部特效,RNN的时序特效,多层网络的层次结构,对它进行了很多约束,从而使得它能够更好的收敛到最佳的参数。我们研究不同的网络结构(包括resnet、dropout、batchnorm等等)都是为了对网络增加额外的(先验的)约束。
BERT来自Google的论文Pre-training of Deep Bidirectional Transformers for Language Understanding,BERT是”Bidirectional Encoder Representations from Transformers”的首字母缩写。如下图所示,BERT能够同时利用前后两个方向的信息,而ELMo和GPT只能使用单个方向的。
图:BERT vs ELMo and GPT
BERT仍然使用的是Transformer模型,那它是怎么解决语言模型只能利用一个方向的信息的问题呢?答案是它的pretraining训练的不是普通的语言模型,而是Mask语言模型。在介绍Mask语言模型之前我们先介绍BERT的输入表示。
BERT的输入表示如图下图所示。比如输入的是两个句子”my dog is cute”,”he likes playing”。后面会解释为什么需要两个句子。这里采用类似GPT的两个句子的表示方法,首先会在第一个句子的开头增加一个特殊的Token [CLS],在cute的后面增加一个[SEP]表示第一个句子结束,在##ing后面也会增加一个[SEP]。注意这里的分词会把”playing”分成”play”和”##ing”两个Token,这种把词分成更细粒度的Word Piece的方法在前面的机器翻译部分介绍过了,这是一种解决未登录词的常见办法,后面的代码部分也会简单介绍。接着对每个Token进行3个Embedding:词的Embedding;位置的Embedding和Segment的Embedding。词的Embedding大家都很熟悉了,而位置的Embedding和词类似,把一个位置(比如2)映射成一个低维稠密的向量。而Segment只有两个,要么是属于第一个句子(segment)要么属于第二个句子,不管那个句子,它都对应一个Embedding向量。同一个句子的Segment Embedding是共享的,这样它能够学习到属于不同Segment的信息。对于情感分类这样的任务,只有一个句子,因此Segment id总是0;而对于Entailment任务,输入是两个句子,因此Segment是0或者1。
BERT模型要求有一个固定的Sequence的长度,比如128。如果不够就在后面padding,否则就截取掉多余的Token,从而保证输入是一个固定长度的Token序列,后面的代码会详细的介绍。第一个Token总是特殊的[CLS],它本身没有任何语义,因此它会(必须)编码整个句子(其它词)的语义。
图:BERT的输入表示
segment embeddings示意图:
为了解决只能利用单向信息的问题,BERT使用的是Mask语言模型而不是普通的语言模型。Mask语言模型有点类似与完形填空——给定一个句子,把其中某个词遮挡起来,让人猜测可能的词。这里会随机的Mask掉15%的词,然后让BERT来预测这些Mask的词,通过调整模型的参数使得模型预测正确的概率尽可能大,这等价于交叉熵的损失函数。这样的Transformer在编码一个词的时候会(必须)参考上下文的信息。
但是这有一个问题:在Pretraining Mask LM时会出现特殊的Token [MASK],但是在后面的fine-tuning时却不会出现,这会出现Mismatch的问题。因此BERT中,如果某个Token在被选中的15%个Token里,则按照下面的方式随机的执行:
这样做的好处是,BERT并不知道[MASK]替换的是哪一个词,而且任何一个词都有可能是被替换掉的,比如它看到的apple可能是被替换的词。这样强迫模型在编码当前时刻的时候不能太依赖于当前的词,而要考虑它的上下文,甚至更加上下文进行”纠错”。比如上面的例子模型在编码apple是根据上下文my dog is应该把apple(部分)编码成hairy的语义而不是apple的语义。
在有些任务中,比如问答,前后两个句子有一定的关联关系,我们希望BERT Pretraining的模型能够学习到这种关系。因此BERT还增加了一个新的任务——预测两个句子是否有关联关系。这是一种Multi-Task Learing。BERT要求的Pretraining的数据是一个一个的”文章”,比如它使用了BookCorpus和维基百科的数据,BookCorpus是很多本书,每本书的前后句子是有关联关系的;而维基百科的文章的前后句子也是有关系的。对于这个任务,BERT会以50%的概率抽取有关联的句子(注意这里的句子实际只是联系的Token序列,不是语言学意义上的句子),另外以50%的概率随机抽取两个无关的句子,然后让BERT模型来判断这两个句子是否相关。比如下面的两个相关的句子:
1 | [CLS] the man went to [MASK] store [SEP] he bought a gallon [MASK] milk [SEP] |
下面是两个不相关的句子:1
[CLS] the man [MASK] to the store [SEP] penguin [MASK] are flight ##less birds [SEP]
BERT的Fine-Tuning如下图所示,共分为4类任务。
图:BERT的Fine-Tuning
对于普通的分类任务,输入是一个序列,如图中右上所示,所有的Token都是属于同一个Segment(Id=0),我们用第一个特殊Token [CLS]的最后一层输出接上softmax进行分类,用分类的数据来进行Fine-Tuning。
对于相似度计算等输入为两个序列的任务,过程如图左上所示。两个序列的Token对应不同的Segment(Id=0/1)。我们也是用第一个特殊Token [CLS]的最后一层输出接上softmax进行分类,然后用分类数据进行Fine-Tuning。
第三类任务是序列标注,比如命名实体识别,输入是一个句子(Token序列),除了[CLS]和[SEP]的每个时刻都会有输出的Tag,比如B-PER表示人名的开始,本章的序列标注部分已经介绍过怎么把NER变成序列标注的问题了,这里不再赘述。然后用输出的Tag来进行Fine-Tuning,过程如图右下所示。
第四类是问答类问题,比如SQuAD v1.1数据集,输入是一个问题和一段很长的包含答案的文字(Paragraph),输出在这段文字里找到问题的答案。
比如输入的问题是:1
Where do water droplets collide with ice crystals to form precipitation?
包含答案的文字是:1
... Precipitation forms as smaller droplets coalesce via collision with other rain drops or ice crystals within a cloud. ...
正确答案是”within a cloud”。
我们怎么用BERT处理这样的问题呢?我们首先把问题和Paragraph表示成一个长的序列,中间用[SEP]分开,问题对应一个Segment(id=0),包含答案的文字对于另一个Segment(id=1)。这里有一个假设,那就是答案是Paragraph里的一段连续的文字(Span)。BERT把寻找答案的问题转化成寻找这个Span的开始下标和结束下标的问题。
如上图的左下所示。对于Paragraph的第i个Token,BERT的最后一层把它编码成$T_i$,然后我们用一个向量S(这是模型的参数,需要根据训练数据调整)和它相乘(内积)计算它是开始位置的得分,因为Paragraph的每一个Token(当然WordPiece的中间,比如##ing是不可能是开始的)都有可能是开始可能,我们用softmax把它变成概率,然后选择概率最大的作为答案的开始:
类似的有一个向量T,用于计算答案结束的位置。
在GLUE评测平台上的结果如下图所示,我们可以发现BERT比之前最好的OpenAI GPT还提高了很多。
图:BERT在GLUE上的结果
在SQuAD数据集上,BERT之前最好的结果F1=89.3%,而7个BERT的ensembling能达到93.2%的F1得分。
图:BERT在SQuAD上的结果
在CoNLL-2003命名实体识别任务上,之前最好的结果是ELMo+Bi-LSTM-CRF(本书前面介绍过Bi-LSTM-CRF),F1是92.2,而BERT没有使用CRF,也没有使用Bi-LSTM,只是一个Softmax就可以达到92.8的F1得分,如果加上CRF可能还会有一些提高(这是我的猜测,论文并没有尝试)。
BERT的效果比好的原因是什么呢?从算法上说,它只有两点改动:Mask LM和预测句子关系的Multi-Task Learning。为了知道每个改动的贡献,文章做了如下的对照(Ablation)实验。
如下图所示,$BERT_{BASE}$是小参数的一个BERT参考模型;No NSP是没有预测句子关系(只有Mask LM)的BERT模型;LTR & No NSP基本等同于OpenAI GPT,它是基于Transoformer的从左到右的普通语言模型;而最后一行+BiLSTM是指在Fine-Tuning OpenAI GPT的时候多加一个双向LSTM层(通常的Fine-Tuning都是只有一个线性层)。
图:不同模型的比较
从上图可以看出,BERT比双向的OpenAI GPT好不少。
另外文章也对比了不同的参数的效果,如下图所示。
图:模型参数的比较
可以看出,模型的参数越多,效果也更好。
但是和OpenAI GPT相比,还有一点很重要的区别就是训练数据。OpenAI GPT使用的是BooksCorpus语料,总的词数800M;而BERT还增加了wiki语料,其词数是2,500M,所以BERT训练数据的总词数是3,300M。因此BERT的训练数据是OpenAI GPT的4倍多,这是非常重要的一点。我谨慎的怀疑BERT效果好的很大原因是数据量造成的,这从模型参数比较实验可以看出,参数越多效果越好,但是如果训练数据不够,参数再多也是没有用的。文章并没有给出和较大BERT模型等价参数的OpenAI GPT模型的效果,不知是忽略了还是有意为之?
本文的代码部分来自于github,而图来源于The Annotated Transformer。
1 | import os |
1 | # Some convenience helper functions used throughout the notebook |
大多数的neural sequence transduction模型都使用了encoder-decoder结构,encoder结构将一个用符号(symbols)表示的输入系列$(x_1, …, x_n)$,表示成为连续表征$\mathbf{z} = (z_1, …, z_n)$。给出$\mathbf{z}$,decoder生成输出序列$(y_1,…,y_m)$,并且一次生成一个元素。在每一步,模型都是自回归的(auto-regressive),在生成下一步时,使用先前生成的符号作为附加输入。
1 | class EncoderDecoder(nn.Module): |
Transformer总体结构如下,encoder和decoder结构都是堆叠self-attention and point-wise, fully connected layers。
Encoder由$N=6$个一模一样的层(EncoderLayer)组成。
1 | def clones(module, N): |
我们在each of the two sub-layers使用残差连接,并且后接layer normalization。
1 | class LayerNorm(nn.Module): |
因此,每一个sub-layer的输出是$\mathrm{LayerNorm}(x + \mathrm{Sublayer}(x))$。我们还添加了Dropout层。为了facilitate这些残差连接,模型中所有sub-layer和embedding layers的输出维度均是$d_{\text{model}}=512$。
1 | class SublayerConnection(nn.Module): |
每一个layer含有两个sub-layers,第一个是multi-head self-attention mechanism,第二个是simple, position-wise fully connected feed-forward network。
1 | class EncoderLayer(nn.Module): |
decoder同样由$N=6$个一模一样的层(encoder layer)组成。
1 | class Decoder(nn.Module): |
在每个encoder layer除了两个 sub-layers 外,还插入了第三个sub-layer,它在encoder stack的输出上执行multi-head attention。与encoder相同,我们在each of the two sub-layers使用残差连接,并且后接layer normalization。
1 | class DecoderLayer(nn.Module): |
我们还修改了decoder中的self-attention sub-layer,以防止它利用到后续位置的信息。This masking, combined with fact that the output embeddings are offset by one position, ensures that the predictions for position $i$ can depend only on the known outputs at positions less than $i$ .
1 | def subsequent_mask(size): |
下图展示了each tgt word(row),被允许看到的信息(column)。单词在训练过程中被遮挡,使模型关注预测下一个words。
attention函数可以被描述为 mapping a query and a set of key-value pairs to an output,其中query, keys, values, and output都是向量。output是values的加权求和,其中每个value的权重是通过query with the corresponding key的compatibility function计算得到。
我们将这种特别的attention称为“Scaled Dot-Product Attention”。它的输入由$d_k$维度的queries、keys,$d_v$维度的values组成。 We compute the dot products of the query with all keys, divide each by $\sqrt{d_k}$, and apply a softmax function to obtain the weights on the values。
实际上我们会同时在一系列的的queries上计算attention 函数,对应的会有一系列的keys $K$、values $V$。attention函数的输出为:
1 | def attention(query, key, value, mask=None, dropout=None): |
最常用的两个attention函数是additive attention和dot-product (multiplicative) attention。后者除了没有缩放$\frac{1}{\sqrt{d_k}}$,其余与我们的相同。而Additive attention computes the compatibility function using a feed-forward network with a single hidden layer. 虽然两者在理论复杂性上相似,但dot-product attention在实践中要快得多,空间效率更高,因为它可以使用高度优化的矩阵乘法代码来实现。
虽然对于较小的$d_k$值,这两种机制的性能相似,但对于较大的$d_k$值,additive attention优于dot product attention。我们怀疑较大的$d_k$值,dot product的幅度会增大,从而将Softmax函数推入其梯度极小的区域。(To illustrate why the dot products get large, assume that the components of $q$ and $k$ are independent random variables with mean $0$ and variance $1$. Then their dot product, $q \cdot k = \sum_{i=1}^{d_k} q_ik_i$, has mean $0$ and variance$d_k$.). 为了抵消这种影响,我们使用$\frac{1}{\sqrt{d_k}}$对dot products进行缩放。
Multi-head attention allows the model to jointly attend to information from different representation subspaces at different positions. 当仅有一个 attention head,平均化抑制了这一点。
Where the projections are parameter matrices $W^Q_i \in \mathbb{R}^{d_{\text{model}} \times d_k}$, $W^K_i \in \mathbb{R}^{d_{\text{model}} \times d_k}$, $W^V_i \in \mathbb{R}^{d_{\text{model}} \times d_v}$ and $W^O \in \mathbb{R}^{hd_v \times d_{\text{model}}}$。在本工作中,我们使用了$h=8$个平行attention layers, or heads。对于其中每一个,我们使用了$d_k=d_v=d_{\text{model}}/h=64$。由于each head的维度降低了,所以总的计算量与full dimensionality的single-head attention相同。
1 | class MultiHeadedAttention(nn.Module): |
Transformer以三种不同的方式使用了multi-head attention。
除了attention sub-layers,encoder and decoder中的每一层都包含一个fully connected feed-forward network(完全连接的前馈网络),该网络分别且相同地应用于每个位置。它由两个线性变换组成,中间有一个ReLU激活。
虽然在不同位置上都是线性变换,但它们在不同的层之间使用不同的参数。另一种描述方式是将其描述为核大小为1的两个卷积。input和output的维度为$d_{\text{model}}=512$,内层的维度为$d_{ff}=2048$。
1 | class PositionwiseFeedForward(nn.Module): |
与其它的sequence transduction models相似,we use learned embeddings to convert the input tokens and output tokens to vectors of dimension $d_{\text{model}}$。我们还使用常用的 linear transformation and softmax function 将 decoder output转换为 predicted next-token probabilities. 在我们的模型中,我们在two embedding layers 和pre-softmax linear transformation共享相同的权重矩阵。在embedding layers,我们multiply those weights by $\sqrt{d_{\text{model}}}$。
1 | class Embeddings(nn.Module): |
由于我们的模型不包含recurrence和卷积,为了使模型利用序列的顺序,我们必须注入一些关于tokens in the sequence的相对或绝对位置的信息。为此,我们将“positional encodings”添加到 encoder 和 decoder 堆栈底部的input embeddings中。 positional encodings具有与embeddings相同的维度$d_{\text{model}}$,因此这两个模型可以求和。positional encodings有许多选择,学习的和固定的。
在这项工作中,我们使用不同频率的正弦和余弦函数。其中$pos$表示单词在句子中的位置,$2i$ 表示偶数的维度,$2i+1$ 表示奇数维度。也就是说,位置编码的每个维度对应于一个正弦。波长形成从2π到10000⋅2π的几何级数。我们选择这个函数是因为我们假设它将允许模型更容易学习相对位置,因为对于任何固定的偏移量$k$,$PE_{pos+k}$可以表示为$PE_{pos}$的线性函数。
初次之外,我们还将dropout应用于the sums of the embeddings and the positional encodings in both the encoder and decoder stacks. 这里dropout的比例为$P_{drop}=0.1$。
1 | class PositionalEncoding(nn.Module): |
Below the positional encoding will add in a sine wave based on position. The frequency and offset of the wave is different for each dimension.
1 | def example_positional(): |
我们还试验了使用学习的positional embeddings,发现两个版本产生的结果几乎相同。我们选择正弦版本是因为它可能允许模型推广到比训练期间遇到的序列长度更长的序列长度。
1 | def make_model( |
在这里,我们执行一个forward,以生成模型的预测。我们尝试使用我们的transformer来记忆输入。正如您将看到的,由于模型尚未经过训练,因此输出是随机生成的。在下一个教程中,我们将构建训练函数,并尝试训练我们的模型记住从1到10的数字。
1 | def inference_test(): |
接下来我们来介绍训练流程,在此之前我们先介绍train a standard encoder decoder model所需的工具。首先我们定义一个batch object保存用于训练的 src and target sentences、masks。
1 | class Batch: |
接下来我们创建一个通用的训练和打分函数,来跟踪损失。我们传入一个损失函数,它还会执行参数更新。
1 | class TrainState: |
1 | def run_epoch( |
我们在标准的WMT 2014英语-德语数据集上进行了训练,该数据集由大约450万个句子对组成。Sentences were encoded using byte-pair encoding, which has a shared source-target vocabulary of about 37000 tokens. 对于英语-法语,我们使用了更大的2014年WMT英语-法语数据集,包括3600万个句子和split tokens into a 32000 word-piece vocabulary.
Sentence pairs were batched together by approximate sequence length. 每个训练批次包含一组句子对,其中包含大约25000个source tokens和25000个target tokens。
我们在一台配备8个NVIDIA P100图形处理器的机器上训练了我们的模型。对于使用本文中描述的超参数的基本模型,每个训练步骤大约需要0.4秒。我们对基础模型进行了总共100,000步或12小时的培训。对于我们的大型模型,step time是1.0秒。这些大模型接受了300,000步(3.5天)的训练。
我们使用adam作为优化器,$\beta_1=0.9$, $\beta_2=0.98$ and $\epsilon=10^{-9}$,在训练中学习率也是变化的,变化方式是:
这对应于在前面$warmup_steps$线性增加学习率,此后按与步数的平方根倒数成比例递减。这里$warmup_steps=4000$。
注意:这部分非常重要。 需要使用这种模型设置进行训练。
该模型曲线的示例,用于不同的模型大小和优化超参数。
1 | def rate(step, model_size, factor, warmup): |
1 | def example_learning_schedule(): |
在训练过程中我们使用了label smoothing,$\epsilon_{ls}=0.1$。虽然模型学会了更多的不确定,但提高了准确性和BLEU的分数。
我们使用KL div loss实现label smoothing. Instead of using a one-hot target distribution, we create a distribution that has confidence of the correct word and the rest of the smoothing mass distributed throughout the vocabulary.
1 | class LabelSmoothing(nn.Module): |
Here we can see an example of how the mass is distributed to the words based on confidence.
1 | # Example of label smoothing. |
Label smoothing actually starts to penalize the model if it gets very confident about a given choice.
1 | def loss(x, crit): |
我们可以从尝试一项简单的抄写任务开始。给定一组来自较小词汇表的随机输入symbols,目标是生成相同的symbols。
1 | def data_gen(V, batch_size, nbatches): |
1 | class SimpleLossCompute: |
为简单起见,此代码使用Greedy Decoding来预测翻译。
1 | def greedy_decode(model, src, src_mask, max_len, start_symbol): |
1 | # Train the simple copy task. |
1 | Epoch Step: 1 Loss: 3.023465 Tokens per Sec: 403.074173 |
现在,我们考虑一个使用IWSLT德语-英语翻译任务的真实世界示例。这项任务比本文考虑的WMT任务小得多,但它能说明整个流程。我们还展示了如何使用多GPU处理来实现真正的速度。
We will load the dataset using torchtext and spacy for tokenization.
1 | # Load spacy tokenizer models, download them if they haven't been |
1 | def tokenize(text, tokenizer): |
1 | def build_vocabulary(spacy_de, spacy_en): |
Batching对训练速度很重要。我们希望非常均匀的划分批次(with absolutely minimal padding)。要做到这一点,我们必须修改一下默认的torchtext batching。这段代码修改了它们的默认批处理,以确保我们搜索足够多的句子来找到紧凑的批处理。
1 | def collate_batch( |
1 | def create_dataloaders( |
1 | def train_worker( |
1 | def train_model(vocab_src, vocab_tgt, spacy_de, spacy_en, config): |
一旦经过训练,我们就可以对模型进行decode,以产生一组翻译。在这里,我们只需翻译验证集中的第一句话。这个数据集非常小,因此使用greedy search的翻译相当准确。
1 | Translation:<unk> <unk> . In my language , that means , thank you very much . |
上述代码主要介绍了Transformer自身的实现,还有四个函数我们没有实现。
我们使用了subword units库对数据进行预处理。它将把训练数据转换成如下所示的形式:▁Die ▁Protokoll datei ▁kann ▁ heimlich ▁per ▁E - Mail ▁oder ▁FTP ▁an ▁einen ▁bestimmte n ▁Empfänger ▁gesendet ▁werden .
当使用共享vocabulary的BPE时,我们可以在source / target / generator之间共享权重向量。详细信息可以阅读cite。要将此想法实现到模型中,只需执行以下操作:
1 | if False: |
详情可以看OpenNMT-py。
文章中平均了最后k个checkpoints来达到集成效果。如果我们有一堆checkpoint,我们可以在事后做这件事:
1 | def average(model, models): |
在WMT 2014英德翻译任务中,big transformer模型(表2中的Transformer (big) )的表现超过了已有的最好的模型(包括集成),BLEU的表现高上了2.0%,创造了最好的BLEU 28.4分的新纪录。表3的底部列出了该模型参数配置。在8个P100 GPU上进行了3.5天的训练。甚至我们的base model模型也超过了已有的所有模型和集成模型,而训练成本只是任何已有模型的一小部分。
在2014年WMT英法翻译任务中,我们的大模型达到了BLEU的41.0分,超过了之前已有的所有单一模型,培训成本不到以前最好模型的四分之一。用于英法翻译的Transformer (big) 的dropout 系数等于0.1,而不是0.3。
我们在这里编写的代码是base model的一个版本。完整版本可以看 (Example Models)。
使用上以小节的附加扩展,OpenNMT-py 在EN-DE WMT数据集上达到了26.9的BLEU。
1 | # Load data and model for output checks |
1 | Translation:<s> ▁Die ▁Protokoll datei ▁kann ▁ heimlich ▁per ▁E - Mail ▁oder ▁FTP ▁an ▁einen ▁bestimmte n ▁Empfänger ▁gesendet ▁werden . |
即使使用greedy decoder,翻译看起来也很好。我们可以进一步把它形象化,看看注意力的每一层都在发生什么。
1 | def mtx2df(m, max_row, max_col, row_tokens, col_tokens): |
1 | def get_encoder(model, layer): |
1 | def viz_encoder_self(): |
1 | def viz_decoder_self(): |
1 | def viz_decoder_src(): |
在《the annotated transformer》中有多个mask,这里总结一下。
整个模型中使用到的mask主要就是source mask和target mask,其各自的作用如下所示:
source长短不一而无法形成batch,因此引入了pad。将source mask传入到encoder中,让attention在计算$\mathrm{softmax}(\frac{QK^T}{\sqrt{d_k}})$时,pad位置的值不起作用。
同时这个mask还需要传入每个decoderLayer第二个multi-head attention模块中,就是防止来自encoder的key和来自decoder的query在计算多头注意力的时候算了target中的词和source中pad的权重
annotated-transformer
the annotated transformer中的关于mask的问题 - lumino的文章 - 知乎
首先下载翻译模型:
1 | mkdir -p model |
解压后的文件如下:
1 | ./model |
然后调用翻译模型:
1 | from fairseq.models.transformer import TransformerModel |
得到的结果是:
1 | Hallo Welt ! |
那么,这个过程都干了哪些事呢?我们对此进行了详细的分析。
在分析之前,我们先介绍一下BPE算法。以下内容来源于NMT Tutorial 3扩展e第2部分. Subword
按照布隆菲尔德的理论,词被认为是人类语言中能自行独立存在的最小单位,是“最小自由形式”。因此,对西方语言做NLP时,以词为基石是一个很自然的想法。
但是将某个语言的词穷举出来是不太现实的。首先,名词、动词、形容词、副词这四种属于开放词类,总会有新的词加入进来。其次,网络用语会创造出更多新词,或者为某个词给出不规则的变形。最后,以德语为代表的语言通常会将几个基本词组合起来,形成一个复合词,例如Abwasserbehandlungsanlage “污水处理厂”可以被细分为Abwasser、behandlungs和Anlage三个部分。
即便是存在某个语言能获得其完整词表,词表的数量也会非常庞大,使得模型复杂度很高,训练起来很难。对于以德语、西班牙语、俄语为代表的屈折语,也会存在类似的问题(例如西班牙语动词可能有80种变化)。
因此,在机器翻译等任务中,从训练语料构造词表时,通常会过滤掉出现频率很低的单词,并将这些单词统一标记为UNK(Unknown)。根据Zipf定律,这种做法能筛掉很多不常见词,简化模型结构,而且可以起到部分防止过拟合的作用。此外,模型上线做推断时,也有很大概率会遇到在训练语料里没见过的词,这些词也会被标为UNK。所有不在词表里被标记为UNK的词,通常被称作集外词(Out Of Vocabulary,OOV)或者未登录词。
对未登录词的处理是机器翻译领域里一个十分重要的问题。sennrich2016认为,对于某些未登录词的翻译可能是”透明“的,包括
因此,将词拆分为更细粒度的subword,可以有助于处理OOV问题。另外传统tokenization方法不利于模型学习词缀之间的关系。E.g. 模型学到的“old”, “older”, and “oldest”之间的关系无法泛化到“smart”, “smarter”, and “smartest”。
由此,sennrich2016文章还同时指出使用一种称为“比特对编码”(Byte Pair Encoding——BPE)的算法可以将词拆分为更细粒度的subword。但是BPE对单词的划分是纯基于统计的,得到的subword所蕴含的词素,或者说形态学信息,并不明显。除此BPE之外,Morfessor是一种基于形态学的分词器,它使用的是无监督学习的方法,能达到不错的准确率。最后,2016年FAIR提出的一种基于subword的词嵌入表示方法fastText。但是本文只关注BPE算法,其余可以参考文章NMT Tutorial 3扩展e第2部分. Subword。
除去subword方法以外,还可以将词拆成字符,为每个字符训练一个字符向量。这种方法很直观,也很有效,不过无需太费笔墨来描述。关于字符向量的优秀工作,可以参考Bojanowski2017的“相关工作”部分。
BPE算法[gage1994]的本质实际上是一种数据压缩算法。数据压缩的一般做法都是将常见比特串替换为更短的表示方法,而BPE也不例外。更具体地说,BPE是找出最常出现的相邻字节对,将其替换成一个在原始数据里没有出现的字节,一直循环下去,直到找不到最常出现的字节对或者所有字节都用光了为止。后期使用时需要一个替换表来重建原始数据。例如,对”lwlwlwlwrr”使用BPE算法,会先把lw替换为a,得到”aaaarr”,然后把”aa”替换为”b”,得到”bbrr”。此时所有相邻字节对”bb”、”br”、”rr”的出现次数相等,迭代结束,输出替换表{“b” -> “aa”, “a” -> “lw”}。
停止符”</w>”的意义在于表示subword是词后缀。举例来说:”st”字词不加”</w>”可以出现在词首如”st ar”,加了”</w>”表明改字词位于词尾,如”wide st</w>”,二者意义截然不同。
每次合并后词表可能出现3种变化:
实际上,随着合并的次数增加,词表大小通常先增加后减小。
例子
输入:
1 | {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w e s t </w>': 6, 'w i d e s t </w>': 3} |
Iter 1, 最高频连续字节对”e”和”s”出现了6+3=9次,合并成”es”。输出:
1 | {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3} |
Iter 2, 最高频连续字节对”es”和”t”出现了6+3=9次, 合并成”est”。输出:
1 | {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est </w>': 6, 'w i d est </w>': 3} |
Iter 3, 以此类推,最高频连续字节对为”est”和”</w>” 输出:
1 | {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3} |
……
Iter n, 继续迭代直到达到预设的subword词表大小或下一个最高频的字节对出现频率为1。
BPE算法的核心学习过程可以写做如下Python代码。
1 | import re, collections |
在之前的算法中,我们已经得到了subword的词表(即常说的code
文件),且该词表已经按照频率从高到低进行排序了。那么我们就可以对单词进行编码(下文的subword-nmt
小节中,利用得到的code.file
对./en.txt
进行编码得到result1.txt
就利用了当前要介绍的编码过程)。
以单词“where”为例,首先按照字符拆分开,然后查找code
文件,逐对合并,优先合并频率靠前的字符对。85 319 9 15
表示在该字符对在code
文件中的频率排名。
根据我自己的实验,
e</w>
可以直接合并,所以这里的频率排名直接是1,即使code
文件中无e </w>
。
如果仍然有子字符串没被替换但所有token都已迭代完毕,则有两种做法,一种是将剩余的子词替换为特殊token,如
s a
在词表中,但是sa i
和i d</w>
不在词表里,encode
只能得到('sa', 'i', 'd')
,那么输出会是sa@@ i@@ d
。s a
和i d
在词表中,但是sa i
和i d</w>
不在词表里,那么输出仍然是sa@@ i@@ d
。s a
和i d</w>
在词表中,但是sa id</w>
不在词表里,那么输出是sa@@ id
。s a
,i d</w>
和sa id</w>
在词表中,那么输出是said
。sa id</w>
在词表中,那么输出是s@@ a@@ i@@ d
。i d</w>
和sa id</w>
在词表中,那么输出是s@@ a@@ id
。编码的计算量很大。 在实践中,我们可以pre-tokenize所有单词,并在词典中保存单词tokenize的结果。
将所有的tokens拼在一起,如果有@@
符号则去除(下文的后处理
小节中self.remove_bpe
函数,就利用了当前小节要介绍的解码过程)。
例子:
1 | # 编码序列 |
安装subword-nmt
1 | pip install subword-nmt |
先准备一个语料库。例如:链接:https://pan.baidu.com/s/1BAWDeAw5QYXS7xCrLIBIAw,提取码:kfy9
生成codevocabulary和:
1 | subword-nmt learn-joint-bpe-and-vocab -i ./en.txt -o ./code.file --write-vocabulary voc.txt |
说明:
其他参数说明:
1 | usage: subword-nmt learn-joint-bpe-and-vocab [-h] --input PATH [PATH ...] |
我们可以看一下生成的code.file和voc.txt。
code.file:
1 | #version: 0.2 |
voc.txt部分内容:
1 | ··· |
这里需要注意的是,code.file
文件一共有10001行,而voc.txt
文件一共有8760行,并且voc.txt
含有一部分带有@@
的行。
那么这里的code.file
和voc.txt
有什么关系呢?我们继续进行探索。
安装完subword-nmt之后,我们可以在终端输入subword-nmt -h
,得到内容如下:
1 | (base) PS E:\Working\learn_bpe> subword-nmt -h |
也就是说,subword-nmt
有learn-bpe,apply-bpe,get-vocab,learn-joint-bpe-and-vocab
方法,继续输入subword-nmt learn-bpe -h
可以查看子函数的用法。
详细的探索这几个函数的用法之后,可以发现如下结论。
learn-joint-bpe-and-vocab
其实是三条指令的合体。1 | subword-nmt learn-joint-bpe-and-vocab -i ./en.txt -o ./code.file --write-vocabulary voc.txt |
get-vocab
函数会对文件中出现的单词以及对应的频率进行统计,得到voc.txt
文件,该过程不需要code.file
文件。
code.file
和voc.txt
关系是:首先利用learn-bpe
从en.txt
文件中学习bpe分词规则,然后利用该规则对en.txt
编码,统计编码之后文件的词语和词频得到voc.txt
文件。所以两者并不是意义对应的关系,而且哪个文件行数更多也不一定。
使用bpe编码
在使用learn-bpe功能得到code后,可以使用apply-bpe来对语料进行编码。值得注意的是,这里解码时并不需要用到voc.txt
1 | subword-nmt apply-bpe -i ./en.test.txt -c ./code.file -o result.txt |
说明:
-i
后面是输入的待解码文件名-c
后面跟着learn-bpe步骤得到的code文件-o
结果输出文件我们可以查看结果,就会自动根据bpe生成的code文件对语料进行分割。
1 | beijing , 1 mar ( xinhua ) -- tian feng@@ shan , former heilongjiang governor who is 5@@ 9 years old , was appointed minister of land and resources today . |
解码
那么我们的文件怎么恢复到bpe编码之前的结果呢?
只需要执行下面指令即可。
1 | sed -r 's/(@@ )|(@@ ?$)//g' result.txt |
我们恢复之后的结果是:
1 | beijing , 1 mar ( xinhua ) -- tian fengshan , former heilongjiang governor who is 59 years old , was appointed minister of land and resources today . |
可以用命令pip install subword-nmt
安装包subword-nmt
以后,可以使用如下代码得到BPE的分词结果,以及将BPE的分词方法用到测试语料上。
1 | from subword_nmt import apply_bpe, learn_bpe |
subword可以平衡词汇量和对未知词的覆盖。 极端的情况下,我们只能使用26个token(即字符)来表示所有英语单词。一般情况,建议使用16k或32k子词足以取得良好的效果,Facebook RoBERTa甚至建立的多达50k的词表。
补充完毕BPE算法的原理之后,我们开始对该源码进行分析。首先来看模型加载部分。
模型加载的核心函数为fairseq/hub_utils.py: from_pretrained
函数。在该函数的会调用checkpoint_utils.load_model_ensemble_and_task
函数。该函数不仅加载了模型权重,而且会初始化task。我们重点关注这个初始化过程。初始化该task时,默认会初始化为translation
任务。
然后跳入函数fairseq/tasks/translation.py
中,可以看到在setup_task
函数中,会读取model/wmt16.en-de.joined-dict.transformer/dict.en.txt
和model/wmt16.en-de.joined-dict.transformer/dict.de.txt
文件,然后放到fairseq.tasks.translation.TranslationTask
的src_dict
和tgt_dict
中。另外值得注意的是fairseq.data.dictionary.Dictionary
的实例,在实例化该类的时候,会在最前面加上bos="<s>", pad="<pad>", eos="</s>", unk="<unk>"
,因此虽然这两个txt文件中都有32764行(两个文件内容一模一样),最终都会有32768行,与翻译模型的输出维度一致。
加载模型并初始化task之后,from_pretrained
函数接着实例化了hub_utils.GeneratorHubInterface
。我们接着看该类在实例化的时候会做些什么。
从下图可以看到该初始化函数依次做了:从task中创建src_dict
和tgt_dict
属性(与fairseq.tasks.translation.TranslationTask
的src_dict
和tgt_dict
一致),然后加载了align_dict、tokenizer、bpe
。
我们这里重点关注一下bpe
的初始化过程,单步调试进入到fairseq/registry.py
文件后,可以发现fairseq支持的所有bpe有:dict_keys(['bytes', 'gpt2', 'hf_byte_bpe', 'bert', 'characters', 'fastbpe', 'byte_bpe', 'sentencepiece', 'subword_nmt'])
。我们这里在初始化模型时传入了bpe='subword_nmt
参数,所以我们重点关注一下subword_nmt
的初始化方式。
该初始化过程的详细过程在fairseq/data/encoders/subword_nmt_bpe.py
文件的SubwordNMTBPE
类的__init__
函数中。从下图中可以看出,该函数会读取args.bpe_codes
对应的文件,也就是'./model/wmt16.en-de.joined-dict.transformer/bpecodes'
文件,用于实例化subword_nmt.apply_bpe.BPE
得到对应self.bpe
,同时SubwordNMTBPE
类还有对应的encode
和decode
函数。
介绍完BPE的初始化,我们接着回到hub_utils.GeneratorHubInterface
类中,此时的self.bpe
的类型为fairseq.data.encoders.subword_nmt_bpe.SubwordNMTBPE
。
由此,翻译模型的模型加载部分已经介绍完了。
从上面的调用关系来看,翻译模型进行推理的函数是translate
。我们调试进入该函数,发现该函数位于/root/anaconda3/lib/python3.8/site-packages/fairseq/hub_utils.py
文件中。核心代码如下:
可以看到,翻译时需要经过三个关键步骤:encode
、generate
和decode
。这里我们先关注预处理和后处理步骤。关键代码如下:
可以看出来预处理主要流程为分词->BPE->binarize
,后处理的主要步骤是string->去除bpe->去分词
。
我们接着来看预处理过程。在使用该模型的时候,并没有用到分词,而是直接使用了BPE的方式,所以我们跳过self.tokenize
函数,首先来看self.apply_bpe
函数。该函数会调用SubwordNMTBPE.encode of <fairseq.data.encoders.subword_nmt_bpe.SubwordNMTBPE>
,我们这里先不管这个函数干了啥,先介绍它的输入输出。其输入为:'Hello world!'
,输出为'H@@ ello world@@ !'
。
接着我们来看self.binarize
,它的输入是'H@@ ello world@@ !'
,输出是tensor([ 190, 7016, 29382, 88, 2])
。该函数会调用Dictionary.encode_line of <fairseq.data.dictionary.Dictionary>
。查询前面的src_dict
,将字符串映射到唯一ID上(ID简单理解为model/wmt16.en-de.joined-dict.transformer/dict.en.txt
中的行数+4)。
介绍完预处理流程,我们来看下网络结构,网络结构如下(因为该网络结构很长,所以只摘出来关键部分)。
1 | GeneratorHubInterface( |
总结概括一下该结构,如下。
1 | TransformerEncoder( |
也就是说,在该模型中,使用了torch.nn.Embedding层对输入进行了Embedding并学习。
接着我们看下模型推理部分——generate
函数。
该函数首先会调用FairseqTask.build_generator of <fairseq.tasks.translation.TranslationTask>
函数,并传入gen_args
参数(该参数中包含了beam
)。在该函数会执行search_strategy = search.BeamSearch(self.target_dictionary)
函数实例化BeamSearch(使用到了model/wmt16.en-de.joined-dict.transformer/dict.de.txt
),并与模型一块放到SequenceGenerator
中进行实例化,而实际进行推理时也是调用的SequenceGenerator.generate of SequenceGenerator
,同时进行模型推理+BeamSearch过程。
具体细节我们先不关注,先说下输入输出。其输入为
经过推理之后,输出结果为(下面5个结果的tokens是不一样的,这里显示不出来):
最后,我们来看下后处理流程。后处理的对应的代码是[self.decode(hypos[0]["tokens"]) for hypos in batched_hypos]
。也就是将tensor([12006, 165, 488, 88, 2], device='cuda:0')
输入到self.decode
函数中。该函数的主要流程是string->去除bpe->去分词
。
我们先来看self.string
函数,该函数与self.binarize
函数相反,它会调用Dictionary.string of <fairseq.data.dictionary.Dictionary>
,查询前面的tgt_dict
,将ID映射回字符串(ID简单理解为model/wmt16.en-de.joined-dict.transformer/dict.de.txt
中的行数+4)。它的输入为tensor([12006, 165, 488, 88, 2], device='cuda:0')
,输出为'Hall@@ o Welt !'
。
接着来看self.remove_bpe
函数,它与self.apply_bpe
函数作用相反,该函数会调用SubwordNMTBPE.decode of <fairseq.data.encoders.subword_nmt_bpe.SubwordNMTBPE>
,我们这里先不管这个函数干了啥,先介绍它的输入输出。其输入为:'Hall@@ o Welt !'
,输出为'Hallo Welt !'
。
同样的,最后,该过程并没有调用self.detokenize
,这里先不管。
下文主要来源于WMT14 en-de翻译数据集预处理步骤
fairseq提供了一份wmt14英德数翻译据集的预处理脚本,简单结合其代码分析一下其处理步骤:
1、下载mosesdecoder。mosesdecoder的使用文档在这里
1 | echo 'Cloning Moses github repository (for tokenization scripts)...' |
2、下载subword nmt。这个开源库是用于构造bpecodes及其字典的。
1 | echo 'Cloning Subword NMT repository (for BPE pre-processing)...' |
3、
1 | SCRIPTS=mosesdecoder/scripts # 定义SCRIPTS变量,指向mosesdecoder的脚本文件夹 |
4、
1 | # 指定语料来源,其中包括了训练、验证、测试语料 |
5、
1 | # This will make the dataset compatible to the one used in "Convolutional Sequence to Sequence Learning" |
6、
1 | src=en # 源语言为英文 |
7、
1 | for ((i=0;i<${#URLS[@]};++i)); do # 迭代每一个URLS |
执行完毕之后,$OUTDIR
文件夹存放的内容有:
1 | ./wmt17_en_de |
8、重点来了
1 | echo "pre-processing train data..." # 预处理训练语料 |
执行完毕之后,得到的文件是:
1 | ./wmt17_en_de |
预处理完毕之后,test.en
的其中一条语句为They are not even 100 metres apart : On Tuesday , the new B 33 pedestrian lights in Dorfparkplatz in Gutach became operational - within view of the existing Town Hall traffic lights .
。可以看出来,标点符号已经和字母分开了。
9、
1 | echo "splitting train and valid..." # 划分训练集和验证集 |
执行完毕之后,得到的文件结构是:
1 | ./wmt17_en_de |
10、
1 | TRAIN=$tmp/train.de-en # 训练语料(包含src和tgt) |
在执行learn_bpe.py
的时候,刚开始的速度特别慢,但是速度会越来越快,最终得到code
文件。
执行完毕之后,得到的文件结构是:
1 | ./wmt17_en_de |
11、
1 | perl $CLEAN -ratio 1.5 $tmp/bpe.train $src $tgt $prep/train 1 250 # 按照长度对训练语料和验证语料进行clean,只保留前250个token(cutoff 1-250),并将结果输出到output文件夹中 |
中间输出结果:
1 | zhaodali@ubuntua:wmt14$ perl $CLEAN -ratio 1.5 $tmp/bpe.train $src $tgt $prep/train 1 250 |
执行完毕之后,得到的文件结构是:
1 | ./wmt17_en_de |
12、
1 | for L in $src $tgt; do |
执行完毕之后,得到的文件结构是:
1 | ./wmt17_en_de |
执行完上述指令之后,我们需要继续将数据Binarize。并且统计词频,得到vocabulary文件。
1 | cd ../ |
中间输出为:
1 | 2022-04-29 19:38:39 | INFO | fairseq_cli.preprocess | Namespace(align_suffix=None, alignfile=None, all_gather_list_size=16384, amp=False, amp_batch_retries=2, amp_init_scale=128, amp_scale_window=None, azureml_logging=False, bf16=False, bpe=None, cpu=False, criterion='cross_entropy', dataset_impl='mmap', destdir='data-bin/wmt17_en_de', dict_only=False, empty_cache_freq=0, fp16=False, fp16_init_scale=128, fp16_no_flatten_grads=False, fp16_scale_tolerance=0.0, fp16_scale_window=None, joined_dictionary=False, log_file=None, log_format=None, log_interval=100, lr_scheduler='fixed', memory_efficient_bf16=False, memory_efficient_fp16=False, min_loss_scale=0.0001, model_parallel_size=1, no_progress_bar=False, nwordssrc=-1, nwordstgt=-1, on_cpu_convert_precision=False, only_source=False, optimizer=None, padding_factor=8, plasma_path='/tmp/plasma', profile=False, quantization_config_path=None, reset_logging=False, scoring='bleu', seed=1, simul_type=None, source_lang='en', srcdict=None, suppress_crashes=False, target_lang='de', task='translation', tensorboard_logdir=None, testpref='wmt14//test', tgtdict=None, threshold_loss_scale=None, thresholdsrc=0, thresholdtgt=0, tokenizer=None, tpu=False, trainpref='wmt14//train', use_plasma_view=False, user_dir=None, validpref='wmt14//valid', wandb_project=None, workers=20) |
执行完毕之后,可以得到的文件结构如下。
1 | . |
得到的data-bin
文件夹就是我们处理完之后的结果,可以直接用来训练和测试。因为它其中的文件已经使用bpe编码了,所以不需要code
文件,但是仍然需要dict.de.txt
和dict.en.txt
用于字符与ID之间的转换。
若直接测试句子的话,仍然需要code
文件对该句子进行编码,然后需要dict.de.txt
和dict.en.txt
用于字符与ID之间的转换。
另外需要注意的是,因为joined_dictionary=False
,所以dict.de.txt
与dict.en.txt
文件内容是不一样的。
joined_dictionary:源端和目标端使用同一个词表,对于相似语言(如英语和西班牙语)来说,有很多的单词是相同的,使用同一个词表可以降低词表和参数的总规模。
所以官方教程在训练时用的--share-decoder-input-output-embed
参数。而我看另外一个dict.de.txt
与dict.en.txt
文件内容一致的,训练时用了--share-all-embeddings
参数。
可以看这里: when you specify —share-all-embeddings then the embedding matrices for encoder input, decoder input and decoder output are all shared. when you specify —share-decoder-input-output-embed, then the matrices for decoder input and output are shared, but encoder has its own embeddings.
补充一下,当--share-decoder-input-output-embed
时,实际对应的代码如下(fairseq/models/transformer/transformer_decoder.py
文件中的build_output_projection
函数):
1 | elif self.share_input_output_embed: |
NMT Tutorial 3扩展e第2部分. Subword
深入理解NLP Subword算法:BPE、WordPiece、ULM
moses(mosesdecoder)数据预处理&BPE分词&moses用法总结
机器翻译 bpe——bytes-pair-encoding以及开源项目subword-nmt快速入门
Byte Pair Encoding
有必要了解的Subword算法模型
bpe分词算法的原理
BPE 算法原理及使用指南【深入浅出】
BPE 算法详解
WMT14 en-de翻译数据集预处理步骤