first commit

This commit is contained in:
张乾 2024-10-15 23:13:09 +08:00
parent bbc9aed40c
commit 201a5889b1
142 changed files with 21536 additions and 2 deletions

View File

@ -41,7 +41,7 @@ foreach ($lines as $line) {
$line = str_replace(' ', '%20', $line);
$curlUrl = $url. $line;
$response = file_get_contents($curlUrl);
mkdir($folderName, 0777, true);
preg_match_all('/<a class="menu-item" id="([^"]*)" href="([^"]*)">([^<]*)<\/a>/', $response, $matches);
@ -74,7 +74,7 @@ foreach ($lines as $line) {
file_put_contents($fileName, $text);
sleep(10);
sleep(5);
// preg_match_all('/<p>([^<]*)<\/p>/', $fileContents, $fileMatches);
}

View File

@ -0,0 +1,84 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 Go 为开发者的需求设计,带你实现高效工作
你好,我是飞雪无情,在技术领域从业近 10 年,目前在一家互联网公司担任技术总监,负责技术管理和架构设计。
2014 年,我因为 Docker 接触了 Go 语言,其简洁的语法、高效的开发效率和语言层面上的并发支持深深地吸引了我。经过不断地学习和实践,我对 Go 语言有了更深入的了解,不久后,便带领团队转型 Go 语言开发,提升了团队开发效率和系统性能,降低了用人成本。
在带领团队转型 Go 语言的过程中,我不断把自己学习 Go 语言的经验沉淀成文章,方便大家利用碎片时间学习,于是“飞雪无情”的公众号和知乎号就诞生了。现在,我已经发布了 200 多篇相关内容,在帮助数万名朋友有效学习 Go 的同时,还有幸拿到了知乎 Go 语言专题的最高赞。
Go 语言为开发者的需求而设计
K8s、Docker、etcd 这类耳熟能详的工具,就是用 Go 语言开发的,而且很多大公司(如腾讯、字节跳动等)都在把原来 C/C++、Python、PHP 的技术栈迁往 Go 语言。
在我看来Go 作为一门高效率的工业化语言备受推崇,这与其语言本身的优势有直接的关系:
语法简洁,相比其他语言更容易上手,开发效率更高;
自带垃圾回收GC不用再手动申请释放内存能够有效避免 Bug提高性能
语言层面的并发支持,让你很容易开发出高性能的程序;
提供的标准库强大,第三方库也足够丰富,可以拿来即用,提高开发效率;
可通过静态编译直接生成一个可执行文件,运行时不依赖其他库,部署方便,可伸缩能力强;
提供跨平台支持,很容易编译出跨各个系统平台直接运行的程序。
对比其他语言Go 的优势也显著。比如 Java 虽然具备垃圾回收功能,但它是解释型语言,需要安装 JVM 虚拟机才能运行C 语言虽然不用解释,可以直接编译运行,但是它不具备垃圾回收功能,需要开发者自己管理内存的申请和释放,容易出问题。而 Go 语言具备了两者的优势。
如今微服务和云原生已经成为一种趋势,而 Go 作为一款高性能的编译型语言,最适合承载落地微服务的实现 ,又容易生成跨平台的可执行文件,相比其他编程语言更容易部署在 Docker 容器中,实现灵活的自动伸缩服务。
总体来看Go 语言的整体设计理念就是以软件工程为目的的,也就是说它不是为了编程语言本身多么强大而设计,而是为了开发者更好地研发、管理软件工程,一切都是为了开发者着想。
如果你是有 1~3 年经验的其他语言开发者(如 Python、PHP、C/C++Go 的学习会比较容易因为编程语言的很多概念相通。而如果你是有基本计算机知识但无开发经验的小白Go 也适合尽早学习,吃透它有助于加深你对编程语言的理解,也更有职业竞争力。
而在我与 Go 语言学习者进行交流,以及面试的过程中,也发现了一些典型问题,可概括为如下三点:
第一,学习者所学知识过于零碎,缺乏系统性,并且不是太深入,导致写不出高效的程序,也难以在面试中胜出。比如,我面试时常问字符串拼接的效率问题,这个问题会牵涉到 + 加号运算符、buffer 拼接、build 拼接、并发安全等知识点,但应聘者通常只能答出最浅显的内容,缺乏对语言逻辑的深层思考。
第二,很多入门者已有其他语言基础,很难转换语言思维模式,而且 Go 的设计者还做了很多相比其他语言的改进和创新。作为从 Java 转到 Go 语言的过来人我非常理解这种情况比如对于错误的处理Java 语言使用 Exception而 Go 语言则通过函数返回 error这会让人很不习惯。
第三,没有开源的、适合练手的项目。
在过去分享 Go 语言知识的过程中,我融入了应对上述问题的方法并得到好评,比如有用户称“你的文章给我拨云见日的感觉!”“通过你的文章终于懂 context 的用法了!”……这些正向评价更坚定了我分享内容的信心。
于是在经过不断地思考、整理后,我希望设计更有系统性、也更通俗易懂的一门专栏。我的目标是通过这门课程帮助你少走弯路,比其他人更快一步提升职场竞争力。
这门课的亮点和设计思路
系统性设计:从基础知识、底层原理到实战,让你不仅可以学会使用,还能从语言自身的逻辑、框架层面分析问题,并做到能上手项目。这样当出现问题时,你可以不再盲目地搜索知识点。
案例实操:我设计了很多便于运用知识点的代码示例,还特意站在学习者的视角,演示了一些容易出 Bug 的场景,帮你避雷。我还引入了很多生活化的场景,比如用枪响后才能赛跑的例子演示 sync.Cond 的使用,帮你加深印象,缓解语言学习的枯燥感。
贴近实际:我所策划的内容来源于众多学习者的反馈,在不断地交流中,我总结了他们问题的共性和不同,并有针对性地融入专栏。
那我是怎么划分这门课的呢?
作为初学者,不管你是否有编程经验,都需要先学习 Go 语言的基本语法,然后我会在此基础上再向你介绍 Go 语言的核心特性——并发,这也是 Go 最自豪的功能。其基于协程的并发,比我们平时使用的线程并发更轻量,可以随意地在一台普通的电脑上启动成百上千个协程,成本非常低。
掌握了基本知识后,我们来通过底层分析深入理解原理。我会结合源码,并对比其他语言的同类知识,带你理解 Go 的设计思路和底层语言逻辑。
此时你可能还有一些疑惑,比如不知道如何把知识与实际工作结合起来,所以就需要 Go 语言工程质量管理方面的知识了。而最后,我会用两个实战帮你快速上手项目,巩固知识。
所以,我根据这个思路将这门课划分成 5 个模块:
模块一Go 语言快速入门:我挑选了变量、常量等数据类型、函数和方法、结构体和接口等知识点介绍,这部分内容相对简洁,但已经足够你掌握 Go 的基本程序结构。
模块二Go 语言高效并发:主要介绍 goroutine、channel、同步原语等知识让你对 Go 语言层面的并发支持有更深入的理解,并且可以编写自己的 Go 并发程序设计。最后还会有一节课专门介绍常用的并发模式,可以拿来即用,更好地控制并发。
模块三Go 语言深入理解Go 语言底层原理的讲解和高级功能的介绍,比如 slice 的底层是怎样的,为什么这么高效等。这个模块也是我特意设计的,我在初学编程时,也有只学习如何使用,而不想研究底层原理的情况,导致工作遇到障碍后又不得不回头恶补,后来发现这是初学者的通病。但理解了底层原理后,你才能灵活编写程序、高效应对问题。
模块四Go 语言工程管理:学习一门语言,不光要掌握它本身的知识,还要会模块管理、性能优化等周边技能,因为这些技能可以帮助你更好地进行多人协作,提高开发效率,写出更高质量的代码。你可以在这个模块学到如何测试 Go 语言以提高代码质量、如何做好性能优化、如何使用第三方库提高自己项目的开发效率等。
模块五Go 语言实战Go 语言更适合的场景就是网络服务和并发,通过开发 HTTP 服务和 RPC 服务这两个实战,可以把前四个模块的知识运用起来,快速上手。
作者寄语
我一直不厌其烦地跟团队小伙伴说Go 语言是一门现代编程语言,相比其他编程语言,它对我们开发者有更好的用户体验,因为它的目的就是让我们更专注于自己业务的实现,提高开发效率。与此同时,当下的云原生是一种趋势, Go 语言非常适合部署在这种环境中,越早学习越有竞争力。
此外,我在上文中也反复强调了学习底层原理的重要性。编程语言有很多共通之处(比如概念、关键字、特性语法等),吃透后再学习其他的编程语言会简单得多,原因在于你理解了语言本身。所以在学习 Go 语言的过程中,我希望你多想、多练,深入理解,融会贯通。
现在,跟我一起踏上 Go 语言学习之旅吧Lets Go

View File

@ -0,0 +1,211 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 基础入门:编写你的第一个 Go 语言程序
从这节课开始,我会带你走进 Go 语言的世界。我会用通俗易懂的语言,介绍 Go 语言的各个知识点,让你可以从零开始逐步学习,再深入它的世界。不管你以前是否接触过 Go 语言,都可以从这个专栏中受益。
现在让我以一个经典的例子“Hello World”来带你入门 Go 语言,了解它是如何运行起来的。
Hello, 世界
如果你学过 C 语言,对这个经典的例子应该不会陌生。通过它,我先带你大概了解一下 Go 语言的一些核心理念,让你对 Go 语言代码有个整体的印象。如下所示:
ch01/main.go
package main
import "fmt"
func main() {
fmt.Println("Hello, 世界")
}
这五行代码就构成了一个完整的 Go 程序,是不是非常简单?现在我运行这段代码,看看输出的结果,方法是打开终端输入以下命令,然后回车。
$ go run ch01/main.go
Hello, 世界
其中 go run ch01/main.go 是我输入的命令回车后看到的“Hello, 世界”是 Go 程序输出的结果。
代码中的 go 是一个 Go 语言开发工具包提供的命令,它和你平时常用的 ls 命令一样都是可执行的命令。它可以帮助你运行 Go 语言代码,并进行编译,生成可执行的二进制文件等。
run 在这里是 go 命令的子命令,表示要运行 Go 语言代码的意思。最后的 ch01/main.go 就是我写的 Go 语言代码文件了。也就是说,整个 go run ch01/main.go 表示要运行 ch01/main.go 里的 Go 语言代码。
程序结构分析
要让一个 Go 语言程序成功运行起来,只需要 package main 和 main 函数这两个核心部分, package main 代表的是一个可运行的应用程序,而 main 函数则是这个应用程序的主入口。
在“Hello, 世界”这个简单的示例中,包含了一个 Go 语言程序运行的最基本的核心结构。我们以此为例,来逐一介绍程序的结构,了解 Go 语言的核心概念。
第一行的 package main 代表当前的 ch01/main.go 文件属于哪个包,其中 package 是 Go 语言声明包的关键字main 是要声明的包名。在 Go 语言中 main 包是一个特殊的包,代表你的 Go 语言项目是一个可运行的应用程序,而不是一个被其他项目引用的库。
第二行的 import “fmt” 是导入一个 fmt 包,其中 import 是 Go 语言的关键字,表示导入包的意思,这里我导入的是 fmt 包,导入的目的是要使用它,下面会继续讲到。
第三行的 func main() 是定义了一个函数,其中 func 是 Go 语言的关键字表示要定义一个函数或者方法的意思main 是函数名,() 空括号表示这个 main 函数不接受任何参数。在 Go 语言中 main 函数是一个特殊的函数,它代表整个程序的入口,也就是程序在运行的时候,会先调用 main 函数,然后通过 main 函数再调用其他函数,达到实现项目业务需求的目的。
第四行的 fmt.Println(“Hello, 世界”) 是通过 fmt 包的 Println 函数打印“Hello, 世界”这段文本。其中 fmt 是刚刚导入的包要想使用一个包必须先导入。Println 函数是属于包 fmt 的函数这里我需要它打印输出一段文本也就是“Hello, 世界”。
第五行的大括号 } 表示 main 函数体的结束。现在整个代码片段已经分析完了运行就可以看到“Hello, 世界”结果的输出。
从以上分析来看Go 语言的代码是非常简洁、完整的核心程序,只需要 package、import、func main 这些核心概念就可以实现。 在后面的课时中,我还会讲如何使用变量,如何自定义函数等,这里先略过不讲,我们先来看看 Go 语言的开发环境是如何搭建的,这样才能运行上面的 Go 语言代码,让整个程序跑起来。
Go 语言环境搭建
要想搭建 Go 语言开发环境,需要先下载 Go 语言开发包。你可以从官网 https://golang.org/dl/ 和 https://golang.google.cn/dl/ 下载(第一个链接是国外的官网,第二个是国内的官网,如果第一个访问不了,可以从第二个下载)。
下载时可以根据自己的操作系统选择相应的开发包,比如 Window、MacOS 或是 Linux 等,如下图所示:
Windows MSI 下安装
MSI 安装的方式比较简单,在 Windows 系统上推荐使用这种方式。现在的操作系统基本上都是 64 位的,所以选择 64 位的 go1.15.windows-amd64.msi 下载即可,如果操作系统是 32 位的,选择 go1.15.windows-386.msi 进行下载。
下载后双击该 MSI 安装文件按照提示一步步地安装即可。在默认情况下Go 语言开发工具包会被安装到 c:\Go 目录,你也可以在安装过程中选择自己想要安装的目录。
假设安装到 c:\Go 目录,安装程序会自动把 c:\Go\bin 添加到你的 PATH 环境变量中,如果没有的话,你可以通过系统 -> 控制面板 -> 高级 -> 环境变量选项来手动添加。
Linux 下安装
Linux 系统同样有 32 位和 64 位,你可以根据你的 Linux 操作系统选择相应的压缩包,它们分别是 go1.15.linux-386.tar.gz 和 go1.15.linux-amd64.tar.gz。
下载成功后,需要先进行解压,假设你下载的是 go1.15.linux-amd64.tar.gz在终端通过如下命令即可解压
sudo tar -C /usr/local -xzf go1.15.linux-amd64.tar.gz
输入后回车,然后输入你的电脑密码,即可解压到 /usr/local 目录,然后把 /usr/local/go/bin 添加到 PATH 环境变量中,就可以使用 Go 语言开发工具包了。
把下面这段添加到 /etc/profile 或者 $HOME/.profile 文件中,保存后退出即可成功添加环境变量。
export PATH=$PATH:/usr/local/go/bin
macOS 下安装
如果你的操作系统是 macOS可以采用 PKG 安装包。下载 go1.15.darwin-amd64.pkg 后,双击按照提示安装即可。安装成功后,路径 /usr/local/go/bin 应该已经被添加到了 PATH 环境变量中,如果没有的话,你可以手动添加,和上面 Linux 的方式一样,把如下内容添加到 /etc/profile 或者 $HOME/.profile 文件保存即可。
export PATH=$PATH:/usr/local/go/bin
安装测试
以上都安装成功后,你可以打开终端或者命令提示符,输入 go version 来验证 Go 语言开发工具包是否安装成功。如果成功的话,会打印出 Go 语言的版本和系统信息,如下所示:
$ go version
go version go1.15 darwin/amd64
环境变量设置
Go 语言开发工具包安装好之后,它的开发环境还没有完全搭建完成,因为还有两个重要的环境变量没有设置,它们分别是 GOPATH 和 GOBIN。
GOPATH代表 Go 语言项目的工作目录,在 Go Module 模式之前非常重要,现在基本上用来存放使用 go get 命令获取的项目。
GOBIN代表 Go 编译生成的程序的安装目录,比如通过 go install 命令,会把生成的 Go 程序安装到 GOBIN 目录下,以供你在终端使用。
假设工作目录为 /Users/flysnow/go你需要把 GOPATH 环境变量设置为 /Users/flysnow/go把 GOBIN 环境变量设置为 $GOPATH/bin。
在 Linux 和 macOS 下,把以下内容添加到 /etc/profile 或者 $HOME/.profile 文件保存即可。
export GOPATH=/Users/flysnow/go
export GOBIN=$GOPATH/bin
在 Windows 操作系统中,则通过控制面板 -> 高级 -> 环境变量选项添加这两个环境变量即可。
项目结构
采用 Go Module 的方式,可以在任何位置创建你的 Go 语言项目。在整个专栏中,我都会使用这种方式演示 Go 语言示例,现在你先对 Go Module 项目结构有一个大概了解,后面的课时我会详细地介绍 Go Module。
假设你的项目位置是 /Users/flysnow/git/gotour打开终端输入如下命令切换到该目录下
$ cd /Users/flysnow/git/gotour
然后再执行如下命令创建一个 Go Module 项目:
$ go mod init
执行成功后,会生成一个 go.mod 文件。然后在当前目录下创建一个 main.go 文件,这样整个项目目录结构是:
gotour
├── go.mod
├── lib
└── main.go
其中 main.go 是整个项目的入口文件,里面有 main 函数。lib 目录是项目的子模块,根据项目需求可以新建很多个目录作为子模块,也可以继续嵌套为子模块的子模块。
编译发布
完成了你的项目后,可以编译生成可执行文件,也可以把它发布到 $GOBIN 目录以供在终端使用。以“Hello 世界”为例,在项目根目录输入以下命令,即可编译一个可执行文件。
$ go build ./ch01/main.go
回车执行后会在当前目录生成 main 可执行文件,现在,我们来测试下它是否可用。
$ ./main
Hello, 世界
如果成功打印出“Hello, 世界”,证明程序成功生成。
以上生成的可执行文件在当前目录,也可以把它安装到 $GOBIN 目录或者任意位置,如下所示:
$ go install ./ch01/main.go
使用 go install 命令即可,现在你在任意时刻打开终端,输入 main 回车都会打印出“Hello, 世界”,是不是很方便!
跨平台编译
Go 语言开发工具包的另一强大功能就是可以跨平台编译。什么是跨平台编译呢?就是你在 macOS 开发,可以编译 Linux、Window 等平台上的可执行程序,这样你开发的程序,就可以在这些平台上运行。也就是说,你可以选择喜欢的操作系统做开发,并跨平台编译成需要发布平台的可执行程序即可。
Go 语言通过两个环境变量来控制跨平台编译,它们分别是 GOOS 和 GOARCH 。
GOOS代表要编译的目标操作系统常见的有 Linux、Windows、Darwin 等。
GOARCH代表要编译的目标处理器架构常见的有 386、AMD64、ARM64 等。
这样通过组合不同的 GOOS 和 GOARCH就可以编译出不同的可执行程序。比如我现在的操作系统是 macOS AMD64 的,我想编译出 Linux AMD64 的可执行程序,只需要执行 go build 命令即可,如以下代码所示:
$ GOOS=linux GOARCH=amd64 go build ./ch01/main.go
关于 GOOS 和 GOARCH 更多的组合,参考官方文档的 \(GOOS and \)GOARCH 这一节即可。
Go 编辑器推荐
好的编辑器可以提高开发的效率,这里我推荐两款目前最流行的编辑器。
第一款是 Visual Studio Code + Go 扩展插件,可以让你非常高效地开发,通过官方网站 https://code.visualstudio.com/ 下载使用。
第二款是老牌 IDE 公司 JetBrains 推出的 Goland所有插件已经全部集成更容易上手并且功能强大新手老手都适合你可以通过官方网站 https://www.jetbrains.com/go/ 下载使用。
总结
这节课中你学到了如何写第一个 Go 语言程序,并且搭建好了 Go 语言开发环境,创建好了 Go 语言项目,同时也下载好了 IDE 严阵以待,那么现在我就给你留个小作业:
改编示例“Hello 世界”的代码,打印出自己的名字。

View File

@ -0,0 +1,391 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 数据类型:你必须掌握的数据类型有哪些?
上节课的思考题是打印出自己的名字这个作业比较简单属于文本的替换你只需要把我示例中的”Hello 世界”修改成自己的名字即可,比如以我的名字为例,替换为“飞雪无情”。
经过上一节课的学习,你已经对 Go 语言的程序结构有了初步了解也准备好了相应的开发环境。但是一个完整的项目需要更复杂的逻辑不是简单的“Hello 世界”可相比的。这些逻辑通过变量、常量、类型、函数方法、接口、结构体组成,这节课我就将带你认识它们,让你的 Go 语言程序变得更加生动。
变量声明
变量代表可变的数据类型,也就是说,它在程序执行的过程中可能会被一次甚至多次修改。
在 Go 语言中,通过 var 声明语句来定义一个变量,定义的时候需要指定这个变量的类型,然后再为它起个名字,并且设置好变量的初始值。所以 var 声明一个变量的格式如下:
var 变量名 类型 = 表达式
现在我通过一个示例来演示如何定义一个变量,并且设置它的初始值:
ch02/main.go
package main
import "fmt"
func main() {
var i int = 10
fmt.Println(i)
}
观察上面例子中 main 函数的内容,其中 var i int = 10 就是定义一个类型为 int整数、变量名为 i 的变量,它的初始值为 10
这里为了运行程序,我加了一行 fmt.Println(i),你在上节课中就见到过它,表示打印出变量 i 的值。
这样做一方面是因为 Go 语言中定义的变量必须使用,否则无法编译通过,这也是 Go 语言比较好的特性,防止定义了变量不使用,导致浪费内存的情况;另一方面,在运行程序的时候可以查看变量 i 的结果。
通过输入 go run ch02/main.go 命令回车运行,即可看到如下结果:
$ go run ch02/main.go
10
打印的结果是10和变量的初始值一样。
因为 Go 语言具有类型推导功能,所以也可以不去刻意地指定变量的类型,而是让 Go 语言自己推导,比如变量 i 也可以用如下的方式声明:
var i = 10
这样变量 i 的类型默认是 int 类型。
你也可以一次声明多个变量,把要声明的多个变量放到一个括号中即可,如下面的代码所示:
var (
j int= 0
k int= 1
)
同理因为类型推导,以上多个变量声明也可以用以下代码的方式书写:
var (
j = 0
k = 1
)
这样就更简洁了。
其实不止 int 类型,我后面介绍的 float64、bool、string 等基础类型都可以被自动推导,也就是可以省略定义类型。
演示项目目录结构
为了让你更好地理解我演示的例子,这里我给出演示项目的目录结构,以后的所有课时都会按照这个目录进行演示。
我的演示项目结构如下所示:
gotour
├── ch01
│ └── main.go
├── ch02
│ └── main.go
└── go.mod
其中 gotour 是演示项目的根目录,所有 Go 语言命令都会在这里执行,比如 go run。
ch01、ch02 这些目录是按照课时命名的,每一讲都有对应的目录,便于查找相应的源代码。具体的 Go 语言源代码会存放到对应的课时目录中。
基础类型
任何一门语言都有对应的基础类型,这些基础类型和现实中的事物一一对应,比如整型对应着 1、2、3、100 这些整数,浮点型对应着 1.1、3.4 这些小数等。Go 语言也不例外,它也有自己丰富的基础类型,常用的有:整型、浮点数、布尔型和字符串,下面我就为你详细介绍。
整型
在 Go 语言中,整型分为:
有符号整型:如 int、int8、int16、int32 和 int64。
无符号整型:如 uint、uint8、uint16、uint32 和 uint64。
它们的差别在于,有符号整型表示的数值可以为负数、零和正数,而无符号整型只能为零和正数。
除了有用“位”bit大小表示的整型外还有 int 和 uint 这两个没有具体 bit 大小的整型,它们的大小可能是 32bit也可能是 64bit和硬件设备 CPU 有关。
在整型中,如果能确定 int 的 bit 就选择比较明确的 int 类型,因为这会让你的程序具备很好的移植性。
在 Go 语言中,还有一种字节类型 byte它其实等价于 uint8 类型,可以理解为 uint8 类型的别名,用于定义一个字节,所以字节 byte 类型也属于整型。
浮点数
浮点数就代表现实中的小数。Go 语言提供了两种精度的浮点数,分别是 float32 和 float64。项目中最常用的是 float64因为它的精度高浮点计算的结果相比 float32 误差会更小。
下面的代码示例定义了两个变量 f32 和 f64它们的类型分别为 float32 和 float64。
ch02/main.go
var f32 float32 = 2.2
var f64 float64 = 10.3456
fmt.Println("f32 is",f32,",f64 is",f64)
运行这段程序,会看到如下结果:
$ go run ch02/main.go
f32 is 2.2 ,f64 is 10.3456
特别注意:在演示示例的时候,我会尽可能地贴出演示需要的核心代码,也就是说,会省略 package 和 main 函数。如果没有特别说明它们都是放在main函数中的可以直接运行。
布尔型
一个布尔型的值只有两种true 和 false它们代表现实中的“是”和“否”。它们的值会经常被用于一些判断中比如 if 语句以后的课时会详细介绍等。Go 语言中的布尔型使用关键字 bool 定义。
下面的代码声明了两个变量,你可以自己运行,看看打印输出的结果。
ch02/main.go
var bf bool =false
var bt bool = true
fmt.Println("bf is",bf,",bt is",bt)
布尔值可以用于一元操作符 !,表示逻辑非的意思,也可以用于二元操作符 &&、||,它们分别表示逻辑和、逻辑或。
字符串
Go 语言中的字符串可以表示为任意的数据,比如以下代码,在 Go 语言中,字符串通过类型 string 声明:
ch02/main.go
var s1 string = "Hello"
var s2 string = "世界"
fmt.Println("s1 is",s1,",s2 is",s2)
运行程序就可以看到打印的字符串结果。
在 Go 语言中,可以通过操作符 + 把字符串连接起来,得到一个新的字符串,比如将上面的 s1 和 s2 连接起来,如下所示:
ch02/main.go
fmt.Println("s1+s2=",s1+s2)
由于 s1 表示字符串“Hello”s2 表示字符串“世界”,在终端输入 go run ch02/main.go 后就可以打印出它们连接起来的结果“Hello世界”如以下代码所示
s1+s2= Hello世界
字符串也可以通过 += 运算符操作,你自己可以试试 s1+=s2 会得到什么新的字符串。
零值
零值其实就是一个变量的默认值,在 Go 语言中,如果我们声明了一个变量,但是没有对其进行初始化,那么 Go 语言会自动初始化其值为对应类型的零值。比如数字类的零值是 0布尔型的零值是 false字符串的零值是 “” 空字符串等。
通过下面的代码示例,就可以验证这些基础类型的零值:
ch02/main.go
var zi int
var zf float64
var zb bool
var zs string
fmt.Println(zi,zf,zb,zs)
变量
变量简短声明
有没有发现,上面我们演示的示例都有一个 var 关键字但是这样写代码很烦琐。借助类型推导Go 语言提供了变量的简短声明 :=,结构如下:
变量名:=表达式
借助 Go 语言简短声明功能,变量声明就会非常简洁,比如以上示例中的变量,可以通过如下代码简短声明:
i:=10
bf=false
s1:="Hello"
在实际的项目实战中,如果你能为声明的变量初始化,那么就选择简短声明方式,这种方式也是使用最多的。
指针
在 Go 语言中,指针对应的是变量在内存中的存储位置,也就说指针的值就是变量的内存地址。通过 & 可以获取一个变量的地址,也就是指针。
在以下的代码中pi 就是指向变量 i 的指针。要想获得指针 pi 指向的变量值,通过*pi这个表达式即可。尝试运行这段程序会看到输出结果和变量 i 的值一样。
pi:=&i
fmt.Println(*pi)
赋值
在讲变量的时候,我说过变量是可以修改的,那么怎么修改呢?这就是赋值语句要做的事情。最常用也是最简单的赋值语句就是 =,如下代码所示:
i = 20
fmt.Println("i的新值是",i)
这样变量 i 就被修改了,它的新值是 20。
常量
一门编程语言有变量就有常量Go 语言也不例外。在程序中,常量的值是指在编译期就确定好的,一旦确定好之后就不能被修改,这样就可以防止在运行期被恶意篡改。
常量的定义
常量的定义和变量类似,只不过它的关键字是 const。
下面的示例定义了一个常量 name它的值是“飞雪无情”。因为 Go 语言可以类型推导,所以在常量声明时也可以省略类型。
ch02/main.go
const name = "飞雪无情"
在 Go 语言中,只允许布尔型、字符串、数字类型这些基础类型作为常量。
iota
iota 是一个常量生成器,它可以用来初始化相似规则的常量,避免重复的初始化。假设我们要定义 one、two、three 和 four 四个常量,对应的值分别是 1、2、3 和 4如果不使用 iota则需要按照如下代码的方式定义
const(
one = 1
two = 2
three =3
four =4
)
以上声明都要初始化,会比较烦琐,因为这些常量是有规律的(连续的数字),所以可以使用 iota 进行声明,如下所示:
const(
one = iota+1
two
three
four
)
fmt.Println(one,two,three,four)
你自己可以运行程序,会发现打印的值和上面初始化的一样,也是 1、2、3、4。
iota 的初始值是 0它的能力就是在每一个有常量声明的行后面 +1下面我来分解上面的常量
one=(0)+1这时候 iota 的值为 0经过计算后one 的值为 1。
two=(0+1)+1这时候 iota 的值会 +1变成了 1经过计算后two 的值为 2。
three=(0+1+1)+1这时候 iota 的值会再 +1变成了 2经过计算后three 的值为 3。
four=(0+1+1+1)+1这时候 iota 的值会继续再 +1变成了 3经过计算后four 的值为 4。
如果你定义更多的常量,就依次类推,其中 () 内的表达式,表示 iota 自身 +1 的过程。
字符串
字符串是 Go 语言中常用的类型,在前面的基础类型小节中已经有过基本的介绍。这一小结会为你更详细地介绍字符串的使用。
字符串和数字互转
Go 语言是强类型的语言也就是说不同类型的变量是无法相互使用和计算的这也是为了保证Go 程序的健壮性,所以不同类型的变量在进行赋值或者计算前,需要先进行类型转换。涉及类型转换的知识点非常多,这里我先介绍这些基础类型之间的转换,更复杂的会在后面的课时介绍。
以字符串和数字互转这种最常见的情况为例,如下面的代码所示:
ch02/main.go
i2s:=strconv.Itoa(i)
s2i,err:=strconv.Atoi(i2s)
fmt.Println(i2s,s2i,err)
通过包 strconv 的 Itoa 函数可以把一个 int 类型转为 stringAtoi 函数则用来把 string 转为 int。
同理对于浮点数、布尔型Go 语言提供了 strconv.ParseFloat、strconv.ParseBool、strconv.FormatFloat 和 strconv.FormatBool 进行互转,你可以自己试试。
对于数字类型之间,可以通过强制转换的方式,如以下代码所示:
i2f:=float64(i)
f2i:=int(f64)
fmt.Println(i2f,f2i)
这种使用方式比简单,采用“类型(要转换的变量)”格式即可。采用强制转换的方式转换数字类型,可能会丢失一些精度,比如浮点型转为整型时,小数点部分会全部丢失,你可以自己运行上述示例,验证结果。
把变量转换为相应的类型后,就可以对相同类型的变量进行各种表达式运算和赋值了。
Strings 包
讲到基础类型,尤其是字符串,不得不提 Go SDK 为我们提供的一个标准包 strings。它是用于处理字符串的工具包里面有很多常用的函数帮助我们对字符串进行操作比如查找字符串、去除字符串的空格、拆分字符串、判断字符串是否有某个前缀或者后缀等。掌握好它有利于我们的高效编程。
以下代码是我写的关于 strings 包的一些例子你自己可以根据strings 文档自己写一些示例,多练习熟悉它们。
ch02/main.go
//判断s1的前缀是否是H
fmt.Println(strings.HasPrefix(s1,"H"))
//在s1中查找字符串o
fmt.Println(strings.Index(s1,"o"))
//把s1全部转为大写
fmt.Println(strings.ToUpper(s1))
总结
本节课我讲解了变量、常量的声明、初始化,以及变量的简短声明,同时介绍了常用的基础类型、数字和字符串的转换以及 strings 工具包的使用,有了这些,你就可以写出功能更强大的程序。
在基础类型中,还有一个没有介绍的基础类型——复数,它不常用,就留给你来探索。这里给你一个提示:复数是用 complex 这个内置函数创建的。
本节课的思考题是如何在一个字符串中查找某个字符串是否存在提示一下Go 语言自带的 strings 包里有现成的函数哦。

View File

@ -0,0 +1,277 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 控制结构if、for、switch 逻辑语句的那些事儿
在上节课中我留了一个思考题,在一个字符串中查找另外一个字符串是否存在,这个其实是字符串查找的功能,假如我需要在“飞雪无情”这个字符串中查找“飞雪”,可以这么做:
i:=strings.Index("飞雪无情","飞雪")
这就是 Go 语言标准库为我们提供的常用函数,以供我们使用,减少开发。
这节课我们继续讲解 Go 语言今天的内容是Go 语言代码逻辑的控制。
流程控制语句用于控制程序的执行顺序,这样你的程序就具备了逻辑结构。一般流程控制语句需要和各种条件结合使用,比如用于条件判断的 if用于选择的 switch用于循环的 for 等。这一节课,我会为你详细介绍,通过示例演示它们的使用方式。
if 条件语句
if 语句是条件语句,它根据布尔值的表达式来决定选择哪个分支执行:如果表达式的值为 true则 if 分支被执行;如果表达式的值为 false则 else 分支被执行。下面,我们来看一个 if 条件语句示例:
ch03/main.go
func main() {
i:=10
if i >10 {
fmt.Println("i>10")
} else {
fmt.Println("i<=10")
}
}
这是一个非常简单的 if……else 条件语句,当 i>10 为 true 的时候if 分支被执行,否则就执行 else 分支,你自己可以运行这段代码,验证打印结果。
关于 if 条件语句的使用有一些规则:
if 后面的条件表达式不需要使用 (),这和有些编程语言不一样,也更体现 Go 语言的简洁;
每个条件分支if 或者 else中的大括号是必须的哪怕大括号里只有一行代码如示例
if 紧跟的大括号 { 不能独占一行else 前的大括号 } 也不能独占一行,否则会编译不通过;
在 if……else 条件语句中还可以增加多个 else if增加更多的条件分支。
通过 go run ch03/main.go 运行下面的这段代码,会看到输出了 55 && i<=10 成立,该分支被执行。
ch03/main.go
func main() {
i:=6
if i >10 {
fmt.Println("i>10")
} else if i>5 && i<=10 {
fmt.Println("5<i<=10")
} else {
fmt.Println("i<=5")
}
}
你可以通过修改 i 的初始值,来验证其他分支的执行情况。
你还可以增加更多的 else if以增加更多的条件分支不过这种方式不被推荐因为代码可读性差多个条件分支可以使用我后面讲到的 switch 代替,使代码更简洁。
和其他编程语言不同,在 Go 语言的 if 语句中,可以有一个简单的表达式语句,并将该语句和条件语句使用分号 ; 分开。同样是以上的示例,我使用这种方式对其改造,如下面代码所示:
ch03/main.go
func main() {
if i:=6; i >10 {
fmt.Println("i>10")
} else if i>5 && i<=10 {
fmt.Println("5<i<=10")
} else {
fmt.Println("i<=5")
}
}
在 if 关键字之后i>10 条件语句之前,通过分号 ; 分隔被初始化的 i:=6。这个简单语句主要用来在 if 条件判断之前做一些初始化工作,可以发现输出结果是一样的。
通过 if 简单语句声明的变量,只能在整个 if……else if……else 条件语句中使用,比如以上示例中的变量 i。
switch 选择语句
if 条件语句比较适合分支较少的情况,如果有很多分支的话,选择 switch 会更方便,比如以上示例,使用 switch 改造后的代码如下:
ch03/main.go
switch i:=6;{
case i>10:
fmt.Println("i>10")
case i>5 && i<=10:
fmt.Println("5<i<=10")
default:
fmt.Println("i<=5")
}
switch 语句同样也可以用一个简单的语句来做初始化,同样也是用分号 ; 分隔。每一个 case 就是一个分支,分支条件为 true 该分支才会执行,而且 case 分支后的条件表达式也不用小括号 () 包裹。
在 Go 语言中switch 的 case 从上到下逐一进行判断,一旦满足条件,立即执行对应的分支并返回,其余分支不再做判断。也就是说 Go 语言的 switch 在默认情况下case 最后自带 break。这和其他编程语言不一样比如 C 语言在 case 分支里必须要有明确的 break 才能退出一个 case。Go 语言的这种设计就是为了防止忘记写 break 时,下一个 case 被执行。
那么如果你真的有需要,的确需要执行下一个紧跟的 case 怎么办呢Go 语言也考虑到了,提供了 fallthrough 关键字。现在看个例子,如下面的代码所示:
ch03/main.go
switch j:=1;j {
case 1:
fallthrough
case 2:
fmt.Println("1")
default:
fmt.Println("没有匹配")
}
以上示例运行会输出 1如果省略 case 1: 后面的 fallthrough则不会有任何输出。
不知道你是否可以发现,和上一个例子对比,这个例子的 switch 后面是有表达式的,也就是输入了 ;j而上一个例子的 switch 后只有一个用于初始化的简单语句。
当 switch 之后有表达式时case 后的值就要和这个表达式的结果类型相同,比如这里的 j 是 int 类型,那么 case 后就只能使用 int 类型,如示例中的 case 1、case 2。如果是其他类型比如使用 case “a” ,会提示类型不匹配,无法编译通过。
而对于 switch 后省略表达式的情况,整个 switch 结构就和 if……else 条件语句等同了。
switch 后的表达式也没有太多限制,是一个合法的表达式即可,也不用一定要求是常量或者整数。你甚至可以像如下代码一样,直接把比较表达式放在 switch 之后:
ch03/main.go
switch 2>1 {
case true:
fmt.Println("2>1")
case false:
fmt.Println("2<=1")
}
可见 Go 语言的 switch 语句非常强大且灵活。
for 循环语句
当需要计算 1 到 100 的数字之和时,如果用代码将一个个数字加起来,会非常复杂,可读性也不好,这就体现出循环语句的存在价值了。
下面是一个经典的 for 循环示例,从这个示例中,我们可以分析出 for 循环由三部分组成,其中,需要使用两个 ; 分隔,如下所示:
ch03/main.go
sum:=0
for i:=1;i<=100;i++ {
sum+=i
}
fmt.Println("the sum is",sum)
其中:
第一部分是一个简单语句,一般用于 for 循环的初始化,比如这里声明了一个变量,并对 i:=1 初始化;
第二部分是 for 循环的条件,也就是说,它表示 for 循环什么时候结束。这里的条件是 i<=100
第三部分是更新语句,一般用于更新循环的变量,比如这里 i++,这样才能达到递增循环的目的。
需要特别留意的是Go 语言里的 for 循环非常强大,以上介绍的三部分组成都不是必须的,可以被省略,下面我就来为你演示,省略以上三部分后的效果。
如果你以前学过其他编程语言,可能会见到 while 这样的循环语句,在 Go 语言中没有 while 循环,但是可以通过 for 达到 while 的效果,如以下代码所示:
ch03/main.go
sum:=0
i:=1
for i<=100 {
sum+=i
i++
}
fmt.Println("the sum is",sum)
这个示例和上面的 for 示例的效果是一样的,但是这里的 for 后只有 i<=100 这一个条件语句,也就是说,它达到了 while 的效果。
在 Go 语言中,同样支持使用 continue、break 控制 for 循环:
continue 可以跳出本次循环,继续执行下一个循环。
break 可以跳出整个 for 循环,哪怕 for 循环没有执行完,也会强制终止。
现在我对上面计算 100 以内整数和的示例再进行修改,演示 break 的用法,如以下代码:
ch03/main.go
sum:=0
i:=1
for {
sum+=i
i++
if i>100 {
break
}
}
fmt.Println("the sum is",sum)
这个示例使用的是没有任何条件的 for 循环,也称为 for 无限循环。此外,使用 break 退出无限循环,条件是 i>100。
总结
这节课主要讲解 if、for 和 switch 这样的控制语句的基本用法,使用它们,你可以更好地控制程序的逻辑结构,达到业务需求的目的。
这节课的思考题是:任意举个例子,练习 for 循环 continue 的使用。
Go 语言提供的控制语句非常强大,本节课我并没有全部介绍,比如 switch 选择语句中的类型选择for 循环语句中的 for range 等高级能力。这些高级能力我会在后面的课程中逐一介绍,接下来要讲的集合类型,就会详细地为你演示如何使用 for range 遍历集合,记得来听课!

View File

@ -0,0 +1,423 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 集合类型:如何正确使用 array、slice 和 map
上节课的思考题是练习使用 for 循环中的 continue通过上节课的学习你已经了解 continue 是跳出本次循环的意思,现在我就以计算 100 以内的偶数之和为例,演示 continue 的用法:
sum := 0
for i:=1; i<100; i++{
if i%2!=0 {
continue
}
sum+=i
}
fmt.Println("the sum is",sum)
这个示例的关键在于:如果 i 不是偶数,就会用 continue 跳出本次循环,继续下个循环;如果是偶数,则继续执行 sum+=i然后继续循环这样就达到了只计算 100 以内偶数之和的目的。
下面我们开始本节课的学习,我将介绍 Go 语言的集合类型。
在实际需求中,我们会有很多同一类型的元素放在一起的场景,这就是集合,例如 100 个数字10 个字符串等。在 Go 语言中数组array、切片slice、映射map这些都是集合类型用于存放同一类元素。虽然都是集合但用处又不太一样这节课我就为你详细地介绍。
Array数组
数组存放的是固定长度、相同类型的数据,而且这些存放的元素是连续的。所存放的数据类型没有限制,可以是整型、字符串甚至自定义。
数组声明
要声明一个数组非常简单,语法和第二课时介绍的声明基础类型是一样的。
在下面的代码示例中,我声明了一个字符串数组,长度是 5所以其类型定义为 [5]string其中大括号中的元素用于初始化数组。此外在类型名前加 [] 中括号,并设置好长度,就可以通过它来推测数组的类型。
注意:[5]string 和 [4]string 不是同一种类型,也就是说长度也是数组类型的一部分。
ch04/main.go
array:=[5]string{"a","b","c","d","e"}
数组在内存中都是连续存放的,下面通过一幅图片形象地展示数组在内存中如何存放:
可以看到数组的每个元素都是连续存放的每一个元素都有一个下标Index。下标从 0 开始,比如第一个元素 a 对应的下标是 0第二个元素 b 对应的下标是 1。以此类推通过 array+[下标] 的方式,我们可以快速地定位元素。
如下面代码所示,运行它,可以看到输出打印的结果是 c也就是数组 array 的第三个元素:
ch04/main.go
func main() {
array:=[5]string{"a","b","c","d","e"}
fmt.Println(array[2])
}
在定义数组的时候,数组的长度可以省略,这个时候 Go 语言会自动根据大括号 {} 中元素的个数推导出长度,所以以上示例也可以像下面这样声明:
array:=[...]string{"a","b","c","d","e"}
以上省略数组长度的声明只适用于所有元素都被初始化的数组,如果是只针对特定索引元素初始化的情况,就不适合了,如下示例:
array1:=[5]string{1:"b",3:"d"}
示例中的「1:“b”,3:“d”」的意思表示初始化索引 1 的值为 b初始化索引 3 的值为 d整个数组的长度为 5。如果我省略长度 5那么整个数组的长度只有 4显然不符合我们定义数组的初衷。
此外,没有初始化的索引,其默认值都是数组类型的零值,也就是 string 类型的零值 “” 空字符串。
除了使用 [] 操作符根据索引快速定位数组的元素外,还可以通过 for 循环打印所有的数组元素,如下面的代码所示:
ch04/main.go
for i:=0;i<5;i++{
fmt.Printf("数组索引:%d,对应值:%s\n", i, array[i])
}
数组循环
使用传统的 for 循环遍历数组,输出对应的索引和对应的值,这种方式很烦琐,一般不使用,大部分情况下,我们使用的是 for range 这种 Go 语言的新型循环,如下面的代码所示:
for i,v:=range array{
fmt.Printf("数组索引:%d,对应值:%s\n", i, v)
}
这种方式和传统 for 循环的结果是一样的。对于数组range 表达式返回两个结果:
第一个是数组的索引;
第二个是数组的值。
在上面的示例中,把返回的两个结果分别赋值给 i 和 v 这两个变量,就可以使用它们了。
相比传统的 for 循环for range 要更简洁,如果返回的值用不到,可以使用 _ 下划线丢弃,如下面的代码所示:
for _,v:=range array{
fmt.Printf("对应值:%s\n", v)
}
数组的索引通过 _ 就被丢弃了,只使用数组的值 v 即可。
Slice切片
切片和数组类似,可以把它理解为动态数组。切片是基于数组实现的,它的底层就是一个数组。对数组任意分隔,就可以得到一个切片。现在我们通过一个例子来更好地理解它,同样还是基于上述例子的 array。
基于数组生成切片
下面代码中的 array[2:5] 就是获取一个切片的操作,它包含从数组 array 的索引 2 开始到索引 5 结束的元素:
array:=[5]string{"a","b","c","d","e"}
slice:=array[2:5]
fmt.Println(slice)
注意:这里是包含索引 2但是不包含索引 5 的元素,即在 : 右边的数字不会被包含。
ch04/main.go
//基于数组生成切片包含索引start但是不包含索引end
slice:=array[start:end]
所以 array[2:5] 获取到的是 c、d、e 这三个元素,然后这三个元素作为一个切片赋值给变量 slice。
切片和数组一样,也可以通过索引定位元素。这里以新获取的 slice 切片为例slice[0] 的值为 cslice[1] 的值为 d。
有没有发现,在数组 array 中,元素 c 的索引其实是 2但是对数组切片后在新生成的切片 slice 中,它的索引是 0这就是切片。虽然切片底层用的也是 array 数组,但是经过切片后,切片的索引范围改变了。
通过下图可以看出,切片是一个具备三个字段的数据结构,分别是指向数组的指针 data长度 len 和容量 cap
这里有一些小技巧,切片表达式 array[start:end] 中的 start 和 end 索引都是可以省略的,如果省略 start那么 start 的值默认为 0如果省略 end那么 end 的默认值为数组的长度。如下面的示例:
array[:4] 等价于 array[0:4]。
array[1:] 等价于 array[1:5]。
array[:] 等价于 array[0:5]。
切片修改
切片的值也可以被修改,这里也同时可以证明切片的底层是数组。
对切片相应的索引元素赋值就是修改,在下面的代码中,把切片 slice 索引 1 的值修改为 f然后打印输出数组 array
slice:=array[2:5]
slice[1] ="f"
fmt.Println(array)
可以看到如下结果:
[a b c f e]
数组对应的值已经被修改为 f所以这也证明了基于数组的切片使用的底层数组还是原来的数组一旦修改切片的元素值那么底层数组对应的值也会被修改。
切片声明
除了可以从一个数组得到切片外,还可以声明切片,比较简单的是使用 make 函数。
下面的代码是声明了一个元素类型为 string 的切片,长度是 4make 函数还可以传入一个容量参数:
slice1:=make([]string,4)
在下面的例子中,指定了新创建的切片 []string 容量为 8
slice1:=make([]string,4,8)
这里需要注意的是,切片的容量不能比切片的长度小。
切片的长度你已经知道了,就是切片内元素的个数。那么容量是什么呢?其实就是切片的空间。
上面的示例说明Go 语言在内存上划分了一块容量为 8 的内容空间(容量为 8但是只有 4 个内存空间才有元素(长度为 4其他的内存空间处于空闲状态当通过 append 函数往切片中追加元素的时候,会追加到空闲的内存上,当切片的长度要超过容量的时候,会进行扩容。
切片不仅可以通过 make 函数声明,也可以通过字面量的方式声明和初始化,如下所示:
slice1:=[]string{"a","b","c","d","e"}
fmt.Println(len(slice1),cap(slice1))
可以注意到,切片和数组的字面量初始化方式,差别就是中括号 [] 里的长度。此外,通过字面量初始化的切片,长度和容量相同。
Append
我们可以通过内置的 append 函数对一个切片追加元素,返回新切片,如下面的代码所示:
//追加一个元素
slice2:=append(slice1,"f")
//多加多个元素
slice2:=append(slice1,"f","g")
//追加另一个切片
slice2:=append(slice1,slice...)
append 函数可以有以上三种操作你可以根据自己的实际需求进行选择append 会自动处理切片容量不足需要扩容的问题。
小技巧:在创建新切片的时候,最好要让新切片的长度和容量一样,这样在追加操作的时候就会生成新的底层数组,从而和原有数组分离,就不会因为共用底层数组导致修改内容的时候影响多个切片。
切片元素循环
切片的循环和数组一模一样,常用的也是 for range 方式,这里就不再进行举例,当作练习题留给你。
在 Go 语言开发中,切片是使用最多的,尤其是作为函数的参数时,相比数组,通常会优先选择切片,因为它高效,内存占用小。
Map映射
在 Go 语言中map 是一个无序的 K-V 键值对集合,结构为 map[K]V。其中 K 对应 KeyV 对应 Value。map 中所有的 Key 必须具有相同的类型Value 也同样,但 Key 和 Value 的类型可以不同。此外Key 的类型必须支持 == 比较运算符,这样才可以判断它是否存在,并保证 Key 的唯一。
Map 声明初始化
创建一个 map 可以通过内置的 make 函数,如下面的代码所示:
nameAgeMap:=make(map[string]int)
它的 Key 类型为 stringValue 类型为 int。有了创建好的 map 变量,就可以对它进行操作了。
在下面的示例中我添加了一个键值对Key 为飞雪无情Value 为 20如果 Key 已经存在,则更新 Key 对应的 Value
nameAgeMap["飞雪无情"] = 20
除了可以通过 make 函数创建 map 外,还可以通过字面量的方式。同样是上面的示例,我们用字面量的方式做如下操作:
nameAgeMap:=map[string]int{"飞雪无情":20}
在创建 map 的同时添加键值对,如果不想添加键值对,使用空大括号 {} 即可,要注意的是,大括号一定不能省略。
Map 获取和删除
map 的操作和切片、数组差不多,都是通过 [] 操作符,只不过数组切片的 [] 中是索引,而 map 的 [] 中是 Key如下面的代码所示
//添加键值对或者更新对应 Key 的 Value
nameAgeMap["飞雪无情"] = 20
//获取指定 Key 对应的 Value
age:=nameAgeMap["飞雪无情"]
Go 语言的 map 可以获取不存在的 K-V 键值对,如果 Key 不存在,返回的 Value 是该类型的零值,比如 int 的零值就是 0。所以很多时候我们需要先判断 map 中的 Key 是否存在。
map 的 [] 操作符可以返回两个值:
第一个值是对应的 Value
第二个值标记该 Key 是否存在,如果存在,它的值为 true。
我们通过下面的代码进行演示:
ch04/main.go
nameAgeMap:=make(map[string]int)
nameAgeMap["飞雪无情"] = 20
age,ok:=nameAgeMap["飞雪无情1"]
if ok {
fmt.Println(age)
}
在示例中age 是返回的 Valueok 用来标记该 Key 是否存在,如果存在则打印 age。
如果要删除 map 中的键值对,使用内置的 delete 函数即可,比如要删除 nameAgeMap 中 Key 为飞雪无情的键值对。我们用下面的代码进行演示:
delete(nameAgeMap,"飞雪无情")
delete 有两个参数:第一个参数是 map第二个参数是要删除键值对的 Key。
遍历 Map
map 是一个键值对集合,它同样可以被遍历,在 Go 语言中map 的遍历使用 for range 循环。
对于 mapfor range 返回两个值:
第一个是 map 的 Key
第二个是 map 的 Value。
我们用下面的代码进行演示:
ch04/main.go
//测试 for range
nameAgeMap["飞雪无情"] = 20
nameAgeMap["飞雪无情1"] = 21
nameAgeMap["飞雪无情2"] = 22
for k,v:=range nameAgeMap{
fmt.Println("Key is",k,",Value is",v)
}
需要注意的是 map 的遍历是无序的,也就是说你每次遍历,键值对的顺序可能会不一样。如果想按顺序遍历,可以先获取所有的 Key并对 Key 排序,然后根据排序好的 Key 获取对应的 Value。这里我不再进行演示你可以当作练习题。
小技巧for range map 的时候,也可以使用一个值返回。使用一个返回值的时候,这个返回值默认是 map 的 Key。
Map 的大小
和数组切片不一样map 是没有容量的,它只有长度,也就是 map 的大小(键值对的个数)。要获取 map 的大小,使用内置的 len 函数即可,如下代码所示:
fmt.Println(len(nameAgeMap))
String 和 []byte
字符串 string 也是一个不可变的字节序列,所以可以直接转为字节切片 []byte如下面的代码所示
ch04/main.go
s:="Hello飞雪无情"
bs:=[]byte(s)
string 不止可以直接转为 []byte还可以使用 [] 操作符获取指定索引的字节值,如以下示例:
ch04/main.go
s:="Hello飞雪无情"
bs:=[]byte(s)
fmt.Println(bs)
fmt.Println(s[0],s[1],s[15])
你可能会有疑惑,在这个示例中,字符串 s 里的字母和中文加起来不是 9 个字符吗?怎么可以使用 s[15] 超过 9 的索引呢?其实恰恰就是因为字符串是字节序列,每一个索引对应的是一个字节,而在 UTF8 编码下,一个汉字对应三个字节,所以字符串 s 的长度其实是 17。
运行下面的代码,就可以看到打印的结果是 17。
fmt.Println(len(s))
如果你想把一个汉字当成一个长度计算,可以使用 utf8.RuneCountInString 函数。运行下面的代码,可以看到打印结果是 9也就是 9 个 unicodeutf8字符和我们看到的字符的个数一致。
fmt.Println(utf8.RuneCountInString(s))
而使用 for range 对字符串进行循环时,也恰好是按照 unicode 字符进行循环的,所以对于字符串 s 来说,循环了 9 次。
在下面示例的代码中i 是索引r 是 unicode 字符对应的 unicode 码点,这也说明了 for range 循环在处理字符串的时候,会自动地隐式解码 unicode 字符串。
ch04/main.go
for i,r:=range s{
fmt.Println(i,r)
}
总结
这节课到这里就要结束了,在这节课里我讲解了数组、切片和映射的声明和使用,有了这些集合类型,你就可以把你需要的某一类数据放到集合类型中了,比如获取用户列表、商品列表等。
数组、切片还可以分为二维和多维,比如二维字节切片就是 [][]byte三维就是 [][][]byte因为不常用所以本节课中没有详细介绍你可以结合我讲的一维 []byte 切片自己尝试练习,这也是本节课要给你留的思考题,创建一个二维数组并使用它。
此外,如果 map 的 Key 的类型是整型,并且集合中的元素比较少,应该尽量选择切片,因为效率更高。在实际的项目开发中,数组并不常用,尤其是在函数间作为参数传递的时候,用得最多的是切片,它更灵活,并且内存占用少。

View File

@ -0,0 +1,444 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 函数和方法Go 语言中的函数和方法到底有什么不同?
上一讲的思考题是创建一个二维数组并使用。上节课,我主要介绍了一维数组,其实二维数组也很简单,仿照一维数组即可,如下面的代码所示:
aa:=[3][3]int{}
aa[0][0] =1
aa[0][1] =2
aa[0][2] =3
aa[1][0] =4
aa[1][1] =5
aa[1][2] =6
aa[2][0] =7
aa[2][1] =8
aa[2][2] =9
fmt.Println(aa)
相信你也完成了,现在学习我们本节课要讲的函数和方法。
函数和方法是我们迈向代码复用、多人协作开发的第一步。通过函数,可以把开发任务分解成一个个小的单元,这些小单元可以被其他单元复用,进而提高开发效率、降低代码重合度。再加上现成的函数已经被充分测试和使用过,所以其他函数在使用这个函数时也更安全,比你自己重新写一个相似功能的函数 Bug 率更低。
这节课,我会详细讲解 Go 语言的函数和方法,了解它们的声明、使用和不同。虽然在 Go 语言中有函数和方法两种概念,但它们的相似度非常高,只是所属的对象不同。我们先从函数开始了解。
函数
函数初探
在前面的四节课中,你已经见到了 Go 语言中一个非常重要的函数main 函数,它是一个 Go 语言程序的入口函数,我在演示代码示例的时候,会一遍遍地使用它。
下面的示例就是一个 main 函数:
func main() {
}
它由以下几部分构成:
任何一个函数的定义,都有一个 func 关键字,用于声明一个函数,就像使用 var 关键字声明一个变量一样;
然后紧跟的 main 是函数的名字,命名符合 Go 语言的规范即可,比如不能以数字开头;
main 函数名字后面的一对括号 () 是不能省略的,括号里可以定义函数使用的参数,这里的 main 函数没有参数,所以是空括号 ()
括号 () 后还可以有函数的返回值,因为 main 函数没有返回值,所以这里没有定义;
最后就是大括号 {} 函数体了,你可以在函数体里书写代码,写该函数自己的业务逻辑。
函数声明
经过上一小节的介绍,相信你已经对 Go 语言函数的构成有一个比较清晰的了解了,现在让我们一起总结出函数的声明格式,如下面的代码所示:
func funcName(params) result {
body
}
这就是一个函数的签名定义,它包含以下几个部分:
关键字 func
函数名字 funcName
函数的参数 params用来定义形参的变量名和类型可以有一个参数也可以有多个也可以没有
result 是返回的函数值,用于定义返回值的类型,如果没有返回值,省略即可,也可以有多个返回值;
body 就是函数体,可以在这里写函数的代码逻辑。
现在,我们一起根据上面的函数声明格式,自定义一个函数,如下所示:
ch05/main.go
func sum(a int,b int) int{
return a+b
}
这是一个计算两数之和的函数,函数的名字是 sum它有两个参数 a、b参数的类型都是 int。sum 函数的返回值也是 int 类型,函数体部分就是把 a 和 b 相加,然后通过 return 关键字返回,如果函数没有返回值,可以不用使用 return 关键字。
终于可以声明自己的函数了,恭喜你迈出了一大步!
函数中形参的定义和我们定义变量是一样的,都是变量名称在前,变量类型在后,只不过在函数里,变量名称叫作参数名称,也就是函数的形参,形参只能在该函数体内使用。函数形参的值由调用者提供,这个值也称为函数的实参,现在我们传递实参给 sum 函数,演示函数的调用,如下面的代码所示:
ch05/main.go
func main() {
result:=sum(1,2)
fmt.Println(result)
}
我们自定义的 sum 函数,在 main 函数中直接调用,调用的时候需要提供真实的参数,也就是实参 1 和 2。
函数的返回值被赋值给变量 result然后把这个结果打印出来。你可以自己运行一下能看到结果是 3这样我们就通过函数 sum 达到了两数相加的目的,如果其他业务逻辑也需要两数相加,那么就可以直接使用这个 sum 函数,不用再定义了。
在以上函数定义中a 和 b 形参的类型是一样的,这个时候我们可以省略其中一个类型的声明,如下所示:
func sum(a, b int) int {
return a + b
}
像这样使用逗号分隔变量,后面统一使用 int 类型,这和变量的声明是一样的,多个相同类型的变量都可以这么声明。
多值返回
同有的编程语言不一样Go 语言的函数可以返回多个值,也就是多值返回。在 Go 语言的标准库中,你可以看到很多这样的函数:第一个值返回函数的结果,第二个值返回函数出错的信息,这种就是多值返回的经典应用。
对于 sum 函数,假设我们不允许提供的实参是负数,可以这样改造:在实参是负数的时候,通过多值返回,返回函数的错误信息,如下面的代码所示:
ch05/main.go
func sum(a, b int) (int,error){
if a<0 || b<0 {
return 0,errors.New("a或者b不能是负数")
}
return a + b,nil
}
这里需要注意的是,如果函数有多个返回值,返回值部分的类型定义需要使用小括号括起来,也就是 (int,error)。这代表函数 sum 有两个返回值,第一个是 int 类型,第二个是 error 类型,我们在函数体中使用 return 返回结果的时候,也要符合这个类型顺序。
在函数体中,可以使用 return 返回多个值,返回的多个值通过逗号分隔即可,返回多个值的类型顺序要和函数声明的返回类型顺序一致,比如下面的例子:
return 0,errors.New("a或者b不能是负数")
返回的第一个值 0 是 int 类型,第二个值是 error 类型,和函数定义的返回类型完全一致。
定义好了多值返回的函数,现在我们用如下代码尝试调用:
ch05/main.go
func main() {
result,err := sum(1, 2)
if err!=nil {
fmt.Println(err)
}else {
fmt.Println(result)
}
}
函数有多值返回的时候,需要有多个变量接收它的值,示例中使用 result 和 err 变量,使用逗号分开。
如果有的函数的返回值不需要,可以使用下划线 _ 丢弃它,这种方式我在 for range 循环那节课里也使用过,如下所示:
result,_ := sum(1, 2)
这样即可忽略函数 sum 返回的错误信息,也不用再做判断。
提示:这里使用的 error 是 Go 语言内置的一个接口,用于表示程序的错误信息,后续课程我会详细介绍。
命名返回参数
不止函数的参数可以有变量名称,函数的返回值也可以,也就是说你可以为每个返回值都起一个名字,这个名字可以像参数一样在函数体内使用。
现在我们继续对 sum 函数的例子进行改造,为其返回值命名,如下面的代码所示:
ch05/main.go
func sum(a, b int) (sum int,err error){
if a<0 || b<0 {
return 0,errors.New("a或者b不能是负数")
}
sum=a+b
err=nil
return
}
返回值的命名和参数、变量都是一样的,名称在前,类型在后。以上示例中,命名的两个返回值名称,一个是 sum一个是 err这样就可以在函数体中使用它们了。
通过下面示例中的这种方式直接为命名返回参数赋值,也就等于函数有了返回值,所以就可以忽略 return 的返回值了,也就是说,示例中只有一个 returnreturn 后没有要返回的值。
sum=a+b
err=nil
通过命名返回参数的赋值方式,和直接使用 return 返回值的方式结果是一样的,所以调用以上 sum 函数,返回的结果也一样。
虽然 Go 语言支持函数返回值命名,但是并不是太常用,根据自己的需求情况,酌情选择是否对函数返回值命名。
可变参数
可变参数,就是函数的参数数量是可变的,比如最常见的 fmt.Println 函数。
同样一个函数,可以不传参数,也可以传递一个参数,也可以两个参数,也可以是多个等等,这种函数就是具有可变参数的函数,如下所示:
fmt.Println()
fmt.Println("飞雪")
fmt.Println("飞雪","无情")
下面所演示的是 Println 函数的声明,从中可以看到,定义可变参数,只要在参数类型前加三个点 … 即可:
func Println(a ...interface{}) (n int, err error)
现在我们也可以定义自己的可变参数的函数了。还是以 sum 函数为例,在下面的代码中,我通过可变参数的方式,计算调用者传递的所有实参的和:
ch05/main.go
func sum1(params ...int) int {
sum := 0
for _, i := range params {
sum += i
}
return sum
}
为了便于和 sum 函数区分,我定义了函数 sum1该函数的参数是一个可变参数然后通过 for range 循环来计算这些参数之和。
讲到这里,相信你也看明白了,可变参数的类型其实就是切片,比如示例中 params 参数的类型是 []int所以可以使用 for range 进行循环。
函数有了可变参数,就可以灵活地进行使用了。
如下面的调用者示例,传递几个参数都可以,非常方便,也更灵活:
ch05/main.go
fmt.Println(sum1(1,2))
fmt.Println(sum1(1,2,3))
fmt.Println(sum1(1,2,3,4))
这里需要注意,如果你定义的函数中既有普通参数,又有可变参数,那么可变参数一定要放在参数列表的最后一个,比如 sum1(tip string,params …int) params 可变参数一定要放在最末尾。
包级函数
不管是自定义的函数 sum、sum1还是我们使用到的函数 Println都会从属于一个包也就是 package。sum 函数属于 main 包Println 函数属于 fmt 包。
同一个包中的函数哪怕是私有的(函数名称首字母小写)也可以被调用。如果不同包的函数要被调用,那么函数的作用域必须是公有的,也就是函数名称的首字母要大写,比如 Println。
在后面的包、作用域和模块化的课程中我会详细讲解,这里可以先记住:
函数名称首字母小写代表私有函数,只有在同一个包中才可以被调用;
函数名称首字母大写代表公有函数,不同的包也可以调用;
任何一个函数都会从属于一个包。
小提示Go 语言没有用 public、private 这样的修饰符来修饰函数是公有还是私有,而是通过函数名称的大小写来代表,这样省略了烦琐的修饰符,更简洁。
匿名函数和闭包
顾名思义,匿名函数就是没有名字的函数,这是它和正常函数的主要区别。
在下面的示例中,变量 sum2 所对应的值就是一个匿名函数。需要注意的是,这里的 sum2 只是一个函数类型的变量,并不是函数的名字。
ch05/main.go
func main() {
sum2 := func(a, b int) int {
return a + b
}
fmt.Println(sum2(1, 2))
}
通过 sum2我们可以对匿名函数进行调用以上示例算出的结果是 3和使用正常的函数一样。
有了匿名函数,就可以在函数中再定义函数(函数嵌套),定义的这个匿名函数,也可以称为内部函数。更重要的是,在函数内定义的内部函数,可以使用外部函数的变量等,这种方式也称为闭包。
我们用下面的代码进行演示:
ch05/main.go
func main() {
cl:=colsure()
fmt.Println(cl())
fmt.Println(cl())
fmt.Println(cl())
}
func colsure() func() int {
i:=0
return func() int {
i++
return i
}
}
运行这个代码,你会看到输出打印的结果是:
1
2
3
这都得益于匿名函数闭包的能力,让我们自定义的 colsure 函数,可以返回一个匿名函数,并且持有外部函数 colsure 的变量 i。因而在 main 函数中,每调用一次 cl()i 的值就会加 1。
小提示:在 Go 语言中,函数也是一种类型,它也可以被用来声明函数类型的变量、参数或者作为另一个函数的返回值类型。
方法
不同于函数的方法
在 Go 语言中,方法和函数是两个概念,但又非常相似,不同点在于方法必须要有一个接收者,这个接收者是一个类型,这样方法就和这个类型绑定在一起,称为这个类型的方法。
在下面的示例中type Age uint 表示定义一个新类型 Age该类型等价于 uint可以理解为类型 uint 的重命名。其中 type 是 Go 语言关键字,表示定义一个类型,在结构体和接口的课程中我会详细介绍。
ch05/main.go
type Age uint
func (age Age) String(){
fmt.Println("the age is",age)
}
示例中方法 String() 就是类型 Age 的方法,类型 Age 是方法 String() 的接收者。
和函数不同,定义方法时会在关键字 func 和方法名 String 之间加一个接收者 (age Age) ,接收者使用小括号包围。
接收者的定义和普通变量、函数参数等一样,前面是变量名,后面是接收者类型。
现在方法 String() 就和类型 Age 绑定在一起了String() 是类型 Age 的方法。
定义了接收者的方法后,就可以通过点操作符调用方法,如下面的代码所示:
ch05/main.go
func main() {
age:=Age(25)
age.String()
}
运行这段代码,可以看到如下输出:
the age is 25
接收者就是函数和方法的最大不同,此外,上面所讲到的函数具备的能力,方法也都具备。
提示:因为 25 也是 unit 类型unit 类型等价于我定义的 Age 类型,所以 25 可以强制转换为 Age 类型。
值类型接收者和指针类型接收者
方法的接收者除了可以是值类型(比如上一小节的示例),也可以是指针类型。
定义的方法的接收者类型是指针,所以我们对指针的修改是有效的,如果不是指针,修改就没有效果,如下所示:
ch05/main.go
func (age *Age) Modify(){
*age = Age(30)
}
调用一次 Modify 方法后,再调用 String 方法查看结果,会发现已经变成了 30说明基于指针的修改有效如下所示
age:=Age(25)
age.String()
age.Modify()
age.String()
提示:在调用方法的时候,传递的接收者本质上都是副本,只不过一个是这个值副本,一是指向这个值指针的副本。指针具有指向原有值的特性,所以修改了指针指向的值,也就修改了原有的值。我们可以简单地理解为值接收者使用的是值的副本来调用方法,而指针接收者使用实际的值来调用方法。
示例中调用指针接收者方法的时候,使用的是一个值类型的变量,并不是一个指针类型,其实这里使用指针变量调用也是可以的,如下面的代码所示:
(&age).Modify()
这就是 Go 语言编译器帮我们自动做的事情:
如果使用一个值类型变量调用指针类型接收者的方法Go 语言编译器会自动帮我们取指针调用,以满足指针接收者的要求。
同样的原理如果使用一个指针类型变量调用值类型接收者的方法Go 语言编译器会自动帮我们解引用调用,以满足值类型接收者的要求。
总之方法的调用者既可以是值也可以是指针不用太关注这些Go 语言会帮我们自动转义,大大提高开发效率,同时避免因不小心造成的 Bug。
不管是使用值类型接收者,还是指针类型接收者,要先确定你的需求:在对类型进行操作的时候是要改变当前接收者的值,还是要创建一个新值进行返回?这些就可以决定使用哪种接收者。
总结
在 Go 语言中,虽然存在函数和方法两个概念,但是它们基本相同,不同的是所属的对象。函数属于一个包,方法属于一个类型,所以方法也可以简单地理解为和一个类型关联的函数。
不管是函数还是方法,它们都是代码复用的第一步,也是代码职责分离的基础。掌握好函数和方法,可以让你写出职责清晰、任务明确、可复用的代码,提高开发效率、降低 Bug 率。
本节课给你留的思考题是:方法是否可以作为表达式赋值给一个变量?如果可以的话,如何通过这个变量调用方法?

View File

@ -0,0 +1,433 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 struct 和 interface结构体与接口都实现了哪些功能
上节课我留了一个思考题:方法是否可以赋值给一个变量?如果可以,要怎么调用它呢?答案是完全可以,方法赋值给变量称为方法表达式,如下面的代码所示:
age:=Age(25)
//方法赋值给变量,方法表达式
sm:=Age.String
//通过变量要传一个接收者进行调用也就是age
sm(age)
我们知道,方法 String 其实是没有参数的,但是通过方法表达式赋值给变量 sm 后,在调用的时候,必须要传一个接收者,这样 sm 才知道怎么调用。
小提示:不管方法是否有参数,通过方法表达式调用,第一个参数必须是接收者,然后才是方法自身的参数。
下面开始我们今天的课程。之前讲到的类型如整型、字符串等只能描述单一的对象,如果是聚合对象,就无法描述了,比如一个人具备的名字、年龄和性别等信息。因为人作为对象是一个聚合对象,要想描述它需要使用这节课要讲的结构体。
结构体
结构体定义
结构体是一种聚合类型,里面可以包含任意类型的值,这些值就是我们定义的结构体的成员,也称为字段。在 Go 语言中,要自定义一个结构体,需要使用 type+struct 关键字组合。
在下面的例子中,我自定义了一个结构体类型,名称为 person表示一个人。这个 person 结构体有两个字段name 代表这个人的名字age 代表这个人的年龄。
ch06/main.go
type person struct {
name string
age uint
}
在定义结构体时,字段的声明方法和平时声明一个变量是一样的,都是变量名在前,类型在后,只不过在结构体中,变量名称为成员名或字段名。
结构体的成员字段并不是必需的,也可以一个字段都没有,这种结构体成为空结构体。
根据以上信息,我们可以总结出结构体定义的表达式,如下面的代码所示:
type structName struct{
fieldName typeName
....
....
}
其中:
type 和 struct 是 Go 语言的关键字,二者组合就代表要定义一个新的结构体类型。
structName 是结构体类型的名字。
fieldName 是结构体的字段名,而 typeName 是对应的字段类型。
字段可以是零个、一个或者多个。
小提示:结构体也是一种类型,所以以后自定义的结构体,我会称为某结构体或某类型,两者是一个意思。比如 person 结构体和 person 类型其实是一个意思。
定义好结构体后就可以使用了,因为它是一个聚合类型,所以比普通的类型可以携带更多数据。
结构体声明使用
结构体类型和普通的字符串、整型一样,也可以使用同样的方式声明和初始化。
在下面的例子中,我声明了一个 person 类型的变量 p因为没有对变量 p 初始化,所以默认会使用结构体里字段的零值。
var p person
当然在声明一个结构体变量的时候,也可以通过结构体字面量的方式初始化,如下面的代码所示:
p:=person{"飞雪无情",30}
采用简短声明法,同时采用字面量初始化的方式,把结构体变量 p 的 name 初始化为“飞雪无情”age 初始化为 30以逗号分隔。
声明了一个结构体变量后就可以使用了,下面我们运行以下代码,验证 name 和 age 的值是否和初始化的一样。
fmt.Println(p.name,p.age)
在 Go 语言中,访问一个结构体的字段和调用一个类型的方法一样,都是使用点操作符“.”。
采用字面量初始化结构体时,初始化值的顺序很重要,必须和字段定义的顺序一致。
在 person 这个结构体中,第一个字段是 string 类型的 name第二个字段是 uint 类型的 age所以在初始化的时候初始化值的类型顺序必须一一对应才能编译通过。也就是说在示例 {“飞雪无情”,30} 中,表示 name 的字符串飞雪无情必须在前,表示年龄的数字 30 必须在后。
那么是否可以不按照顺序初始化呢?当然可以,只不过需要指出字段名称,如下所示:
p:=person{age:30,name:"飞雪无情"}
其中,第一位我放了整型的 age也可以编译通过因为采用了明确的 field:value 方式进行指定,这样 Go 语言编译器会清晰地知道你要初始化哪个字段的值。
有没有发现,这种方式和 map 类型的初始化很像都是采用冒号分隔。Go 语言尽可能地重用操作,不发明新的表达式,便于我们记忆和使用。
当然你也可以只初始化字段 age字段 name 使用默认的零值,如下面的代码所示,仍然可以编译通过。
p:=person{age:30}
字段结构体
结构体的字段可以是任意类型,也包括自定义的结构体类型,比如下面的代码:
ch06/main.go
type person struct {
name string
age uint
addr address
}
type address struct {
province string
city string
}
在这个示例中我定义了两个结构体person 表示人address 表示地址。在结构体 person 中,有一个 address 类型的字段 addr这就是自定义的结构体。
通过这种方式,用代码描述现实中的实体会更匹配,复用程度也更高。对于嵌套结构体字段的结构体,其初始化和正常的结构体大同小异,只需要根据字段对应的类型初始化即可,如下面的代码所示:
ch06/main.go
p:=person{
age:30,
name:"飞雪无情",
addr:address{
province: "北京",
city: "北京",
},
}
如果需要访问结构体最里层的 province 字段的值,同样也可以使用点操作符,只不过需要使用两个点,如下面的代码所示:
ch06/main.go
fmt.Println(p.addr.province)
第一个点获取 addr第二个点获取 addr 的 province。
接口
接口的定义
接口是和调用方的一种约定,它是一个高度抽象的类型,不用和具体的实现细节绑定在一起。接口要做的是定义好约定,告诉调用方自己可以做什么,但不用知道它的内部实现,这和我们见到的具体的类型如 int、map、slice 等不一样。
接口的定义和结构体稍微有些差别,虽然都以 type 关键字开始,但接口的关键字是 interface表示自定义的类型是一个接口。也就是说 Stringer 是一个接口,它有一个方法 String() string整体如下面的代码所示
src/fmt/print.go
type Stringer interface {
String() string
}
提示Stringer 是 Go SDK 的一个接口,属于 fmt 包。
针对 Stringer 接口来说,它会告诉调用者可以通过它的 String() 方法获取一个字符串,这就是接口的约定。至于这个字符串怎么获得的,长什么样,接口不关心,调用者也不用关心,因为这些是由接口实现者来做的。
接口的实现
接口的实现者必须是一个具体的类型,继续以 person 结构体为例,让它来实现 Stringer 接口,如下代码所示:
ch06/main.go
func (p person) String() string{
return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}
给结构体类型 person 定义一个方法,这个方法和接口里方法的签名(名称、参数和返回值)一样,这样结构体 person 就实现了 Stringer 接口。
注意:如果一个接口有多个方法,那么需要实现接口的每个方法才算是实现了这个接口。
实现了 Stringer 接口后就可以使用了。首先我先来定义一个可以打印 Stringer 接口的函数,如下所示:
ch06/main.go
func printString(s fmt.Stringer){
fmt.Println(s.String())
}
这个被定义的函数 printString它接收一个 Stringer 接口类型的参数,然后打印出 Stringer 接口的 String 方法返回的字符串。
printString 这个函数的优势就在于它是面向接口编程的,只要一个类型实现了 Stringer 接口,都可以打印出对应的字符串,而不用管具体的类型实现。
因为 person 实现了 Stringer 接口,所以变量 p 可以作为函数 printString 的参数,可以用如下方式打印:
printString(p)
结果为:
the name is 飞雪无情,age is 30
现在让结构体 address 也实现 Stringer 接口,如下面的代码所示:
ch06/main.go
func (addr address) String() string{
return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}
因为结构体 address 也实现了 Stringer 接口,所以 printString 函数不用做任何改变,可以直接被使用,打印出地址,如下所示:
printString(p.addr)
//输出the addr is 北京北京
这就是面向接口的好处,只要定义和调用双方满足约定,就可以使用,而不用管具体实现。接口的实现者也可以更好的升级重构,而不会有任何影响,因为接口约定没有变。
值接收者和指针接收者
我们已经知道,如果要实现一个接口,必须实现这个接口提供的所有方法,而且在上节课讲解方法的时候,我们也知道定义一个方法,有值类型接收者和指针类型接收者两种。二者都可以调用方法,因为 Go 语言编译器自动做了转换,所以值类型接收者和指针类型接收者是等价的。但是在接口的实现中,值类型接收者和指针类型接收者不一样,下面我会详细分析二者的区别。
在上一小节中,已经验证了结构体类型实现了 Stringer 接口,那么结构体对应的指针是否也实现了该接口呢?我通过下面这个代码进行测试:
printString(&p)
测试后会发现,把变量 p 的指针作为实参传给 printString 函数也是可以的,编译运行都正常。这就证明了以值类型接收者实现接口的时候,不管是类型本身,还是该类型的指针类型,都实现了该接口。
示例中值接收者p person实现了 Stringer 接口,那么类型 person 和它的指针类型*person就都实现了 Stringer 接口。
现在,我把接收者改成指针类型,如下代码所示:
func (p *person) String() string{
return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}
修改成指针类型接收者后会发现,示例中这行 printString(p) 代码编译不通过,提示如下错误:
./main.go:17:13: cannot use p (type person) as type fmt.Stringer in argument to printString:
person does not implement fmt.Stringer (String method has pointer receiver)
意思就是类型 person 没有实现 Stringer 接口。这就证明了以指针类型接收者实现接口的时候,只有对应的指针类型才被认为实现了该接口。
我用如下表格为你总结这两种接收者类型的接口实现规则:
可以这样解读:
当值类型作为接收者时person 类型和*person类型都实现了该接口。
当指针类型作为接收者时,只有*person类型实现了该接口。
可以发现,实现接口的类型都有*person这也表明指针类型比较万能不管哪一种接收者它都能实现该接口。
工厂函数
工厂函数一般用于创建自定义的结构体,便于使用者调用,我们还是以 person 类型为例,用如下代码进行定义:
func NewPerson(name string) *person {
return &person{name:name}
}
我定义了一个工厂函数 NewPerson它接收一个 string 类型的参数,用于表示这个人的名字,同时返回一个*person。
通过工厂函数创建自定义结构体的方式,可以让调用者不用太关注结构体内部的字段,只需要给工厂函数传参就可以了。
用下面的代码,即可创建一个*person 类型的变量 p1
p1:=NewPerson("张三")
工厂函数也可以用来创建一个接口,它的好处就是可以隐藏内部具体类型的实现,让调用者只需关注接口的使用即可。
现在我以 errors.New 这个 Go 语言自带的工厂函数为例,演示如何通过工厂函数创建一个接口,并隐藏其内部实现,如下代码所示:
errors/errors.go
//工厂函数返回一个error接口其实具体实现是*errorString
func New(text string) error {
return &errorString{text}
}
//结构体内部一个字段s存储错误信息
type errorString struct {
s string
}
//用于实现error接口
func (e *errorString) Error() string {
return e.s
}
其中errorString 是一个结构体类型,它实现了 error 接口,所以可以通过 New 工厂函数,创建一个 *errorString 类型,通过接口 error 返回。
这就是面向接口的编程,假设重构代码,哪怕换一个其他结构体实现 error 接口,对调用者也没有影响,因为接口没变。
继承和组合
在 Go 语言中没有继承的概念所以结构、接口之间也没有父子关系Go 语言提倡的是组合,利用组合达到代码复用的目的,这也更灵活。
我同样以 Go 语言 io 标准包自带的接口为例,讲解类型的组合(也可以称之为嵌套),如下代码所示:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
//ReadWriter是Reader和Writer的组合
type ReadWriter interface {
Reader
Writer
}
ReadWriter 接口就是 Reader 和 Writer 的组合组合后ReadWriter 接口具有 Reader 和 Writer 中的所有方法,这样新接口 ReadWriter 就不用定义自己的方法了,组合 Reader 和 Writer 的就可以了。
不止接口可以组合,结构体也可以组合,现在把 address 结构体组合到结构体 person 中,而不是当成一个字段,如下所示:
ch06/main.go
type person struct {
name string
age uint
address
}
直接把结构体类型放进来,就是组合,不需要字段名。组合后,被组合的 address 称为内部类型person 称为外部类型。修改了 person 结构体后,声明和使用也需要一起修改,如下所示:
p:=person{
age:30,
name:"飞雪无情",
address:address{
province: "北京",
city: "北京",
},
}
//像使用自己的字段一样,直接使用
fmt.Println(p.province)
因为 person 组合了 address所以 address 的字段就像 person 自己的一样,可以直接使用。
类型组合后,外部类型不仅可以使用内部类型的字段,也可以使用内部类型的方法,就像使用自己的方法一样。如果外部类型定义了和内部类型同样的方法,那么外部类型的会覆盖内部类型,这就是方法的覆写。关于方法的覆写,这里不再进行举例,你可以自己试一下。
小提示:方法覆写不会影响内部类型的方法实现。
类型断言
有了接口和实现接口的类型,就会有类型断言。类型断言用来判断一个接口的值是否是实现该接口的某个具体类型。
还是以我们上面小节的示例演示,我们先来回忆一下它们,如下所示:
func (p *person) String() string{
return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}
func (addr address) String() string{
return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}
可以看到,*person 和 address 都实现了接口 Stringer然后我通过下面的示例讲解类型断言
var s fmt.Stringer
s = p1
p2:=s.(*person)
fmt.Println(p2)
如上所示,接口变量 s 称为接口 fmt.Stringer 的值,它被 p1 赋值。然后使用类型断言表达式 s.(*person),尝试返回一个 p2。如果接口的值 s 是一个*person那么类型断言正确可以正常返回 p2。如果接口的值 s 不是一个 *person那么在运行时就会抛出异常程序终止运行。
小提示:这里返回的 p2 已经是 *person 类型了,也就是在类型断言的时候,同时完成了类型转换。
在上面的示例中,因为 s 的确是一个 *person所以不会异常可以正常返回 p2。但是如果我再添加如下代码对 s 进行 address 类型断言,就会出现一些问题:
a:=s.(address)
fmt.Println(a)
这个代码在编译的时候不会有问题,因为 address 实现了接口 Stringer但是在运行的时候会抛出如下异常信息
panic: interface conversion: fmt.Stringer is *main.person, not main.address
这显然不符合我们的初衷我们本来想判断一个接口的值是否是某个具体类型但不能因为判断失败就导致程序异常。考虑到这点Go 语言为我们提供了类型断言的多值返回,如下所示:
a,ok:=s.(address)
if ok {
fmt.Println(a)
}else {
fmt.Println("s不是一个address")
}
类型断言返回的第二个值“ok”就是断言是否成功的标志如果为 true 则成功,否则失败。
总结
这节课虽然只讲了结构体和接口,但是所涉及的知识点很多,整节课比较长,希望你可以耐心地学完。
结构体是对现实世界的描述,接口是对某一类行为的规范和抽象。通过它们,我们可以实现代码的抽象和复用,同时可以面向接口编程,把具体实现细节隐藏起来,让写出来的代码更灵活,适应能力也更强。

View File

@ -0,0 +1,341 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 错误处理:如何通过 error、deferred、panic 等处理错误?
上节课我为你讲解了结构体和接口,并留了一个小作业,让你自己练习实现有两个方法的接口。现在我就以“人既会走也会跑”为例进行讲解。
首先定义一个接口 WalkRun它有两个方法 Walk 和 Run如下面的代码所示
type WalkRun interface {
Walk()
Run()
}
现在就可以让结构体 person 实现这个接口了,如下所示:
func (p *person) Walk(){
fmt.Printf("%s能走\n",p.name)
}
func (p *person) Run(){
fmt.Printf("%s能跑\n",p.name)
}
关键点在于,让接口的每个方法都实现,也就实现了这个接口。
提示:%s 是占位符,和 p.name 对应,也就是 p.name 的值,具体可以参考 fmt.Printf 函数的文档。
下面进行本节课的讲解。这节课我会带你学习 Go 语言的错误和异常,在我们编写程序的时候,可能会遇到一些问题,该怎么处理它们呢?
错误
在 Go 语言中,错误是可以预期的,并且不是非常严重,不会影响程序的运行。对于这类问题,可以用返回错误给调用者的方法,让调用者自己决定如何处理。
error 接口
在 Go 语言中,错误是通过内置的 error 接口表示的。它非常简单,只有一个 Error 方法用来返回具体的错误信息,如下面的代码所示:
type error interface {
Error() string
}
在下面的代码中,我演示了一个字符串转整数的例子:
ch07/main.go
func main() {
i,err:=strconv.Atoi("a")
if err!=nil {
fmt.Println(err)
}else {
fmt.Println(i)
}
}
这里我故意使用了字符串 “a”尝试把它转为整数。我们知道 “a” 是无法转为数字的,所以运行这段程序,会打印出如下错误信息:
strconv.Atoi: parsing "a": invalid syntax
这个错误信息就是通过接口 error 返回的。我们来看关于函数 strconv.Atoi 的定义,如下所示:
func Atoi(s string) (int, error)
一般而言error 接口用于当方法或者函数执行遇到错误时进行返回,而且是第二个返回值。通过这种方式,可以让调用者自己根据错误信息决定如何进行下一步处理。
小提示:因为方法和函数基本上差不多,区别只在于有无接收者,所以以后当我称方法或函数,表达的是一个意思,不会把这两个名字都写出来。
error 工厂函数
除了可以使用其他函数,自己定义的函数也可以返回错误信息给调用者,如下面的代码所示:
ch07/main.go
func add(a,b int) (int,error){
if a<0 || b<0 {
return 0,errors.New("a或者b不能为负数")
}else {
return a+b,nil
}
}
add 函数会在 a 或者 b 任何一个为负数的情况下,返回一个错误信息,如果 a、b 都不为负数,错误信息部分会返回 nil这也是常见的做法。所以调用者可以通过错误信息是否为 nil 进行判断。
下面的 add 函数示例,是使用 errors.New 这个工厂函数生成的错误信息,它接收一个字符串参数,返回一个 error 接口,这些在上节课的结构体和接口部分有过详细介绍,不再赘述。
ch07/main.go
sum,err:=add(-1,2)
if err!=nil {
fmt.Println(err)
}else {
fmt.Println(sum)
}
自定义 error
你可能会想,上面采用工厂返回错误信息的方式只能传递一个字符串,也就是携带的信息只有字符串,如果想要携带更多信息(比如错误码信息)该怎么办呢?这个时候就需要自定义 error 。
自定义 error 其实就是先自定义一个新类型,比如结构体,然后让这个类型实现 error 接口,如下面的代码所示:
ch07/main.go
type commonError struct {
errorCode int //错误码
errorMsg string //错误信息
}
func (ce *commonError) Error() string{
return ce.errorMsg
}
有了自定义的 error就可以使用它携带更多的信息现在我改造上面的例子返回刚刚自定义的 commonError如下所示
ch07/main.go
return 0, &commonError{
errorCode: 1,
errorMsg: "a或者b不能为负数"}
我通过字面量的方式创建一个 *commonError 返回,其中 errorCode 值为 1errorMsg 值为 “a 或者 b 不能为负数”。
error 断言
有了自定义的 error并且携带了更多的错误信息后就可以使用这些信息了。你需要先把返回的 error 接口转换为自定义的错误类型,用到的知识是上节课的类型断言。
下面代码中的 err.(*commonError) 就是类型断言在 error 接口上的应用,也可以称为 error 断言。
ch07/main.go
sum, err := add(-1, 2)
if cm,ok:=err.(*commonError);ok{
fmt.Println("错误代码为:",cm.errorCode,",错误信息为:",cm.errorMsg)
} else {
fmt.Println(sum)
}
如果返回的 ok 为 true说明 error 断言成功,正确返回了 *commonError 类型的变量 cm所以就可以像示例中一样使用变量 cm 的 errorCode 和 errorMsg 字段信息了。
错误嵌套
Error Wrapping
error 接口虽然比较简洁,但是功能也比较弱。想象一下,假如我们有这样的需求:基于一个存在的 error 再生成一个 error需要怎么做呢这就是错误嵌套。
这种需求是存在的,比如调用一个函数,返回了一个错误信息 error在不想丢失这个 error 的情况下,又想添加一些额外信息返回新的 error。这时候我们首先想到的应该是自定义一个 struct如下面的代码所示
type MyError struct {
err error
msg string
}
这个结构体有两个字段,其中 error 类型的 err 字段用于存放已存在的 errorstring 类型的 msg 字段用于存放新的错误信息,这种方式就是 error 的嵌套。
现在让 MyError 这个 struct 实现 error 接口,然后在初始化 MyError 的时候传递存在的 error 和新的错误信息,如下面的代码所示:
func (e *MyError) Error() string {
return e.err.Error() + e.msg
}
func main() {
//err是一个存在的错误可以从另外一个函数返回
newErr := MyError{err, "数据上传问题"}
}
这种方式可以满足我们的需求,但是非常烦琐,因为既要定义新的类型还要实现 error 接口。所以从 Go 语言 1.13 版本开始Go 标准库新增了 Error Wrapping 功能,让我们可以基于一个存在的 error 生成新的 error并且可以保留原 error 信息,如下面的代码所示:
ch07/main.go
e := errors.New("原始错误e")
w := fmt.Errorf("Wrap了一个错误:%w", e)
fmt.Println(w)
Go 语言没有提供 Wrap 函数,而是扩展了 fmt.Errorf 函数,然后加了一个 %w通过这种方式便可以生成 wrapping error。
errors.Unwrap 函数
既然 error 可以包裹嵌套生成一个新的 error那么也可以被解开即通过 errors.Unwrap 函数得到被嵌套的 error。
Go 语言提供了 errors.Unwrap 用于获取被嵌套的 error比如以上例子中的错误变量 w ,就可以对它进行 unwrap获取被嵌套的原始错误 e。
下面我们运行以下代码:
fmt.Println(errors.Unwrap(w))
可以看到这样的信息,即“原始错误 e”。
原始错误e
errors.Is 函数
有了 Error Wrapping 后,你会发现原来用的判断两个 error 是不是同一个 error 的方法失效了,比如 Go 语言标准库经常用到的如下代码中的方式:
if err == os.ErrExist
为什么会出现这种情况呢?由于 Go 语言的 Error Wrapping 功能,令人不知道返回的 err 是否被嵌套,又嵌套了几层?
于是 Go 语言为我们提供了 errors.Is 函数,用来判断两个 error 是否是同一个,如下所示:
func Is(err, target error) bool
以上就是errors.Is 函数的定义,可以解释为:
如果 err 和 target 是同一个,那么返回 true。
如果 err 是一个 wrapping errortarget 也包含在这个嵌套 error 链中的话,也返回 true。
可以简单地概括为,两个 error 相等或 err 包含 target 的情况下返回 true其余返回 false。我们可以用上面的示例判断错误 w 中是否包含错误 e试试运行下面的代码来看打印的结果是不是 true。
fmt.Println(errors.Is(w,e))
errors.As 函数
同样的原因,有了 error 嵌套后error 断言也不能用了,因为你不知道一个 error 是否被嵌套,又嵌套了几层。所以 Go 语言为解决这个问题提供了 errors.As 函数,比如前面 error 断言的例子,可以使用 errors.As 函数重写,效果是一样的,如下面的代码所示:
ch07/main.go
var cm *commonError
if errors.As(err,&cm){
fmt.Println("错误代码为:",cm.errorCode,",错误信息为:",cm.errorMsg)
} else {
fmt.Println(sum)
}
所以在 Go 语言提供的 Error Wrapping 能力下,我们写的代码要尽可能地使用 Is、As 这些函数做判断和转换。
Deferred 函数
在一个自定义函数中,你打开了一个文件,然后需要关闭它以释放资源。不管你的代码执行了多少分支,是否出现了错误,文件是一定要关闭的,这样才能保证资源的释放。
如果这个事情由开发人员来做随着业务逻辑的复杂会变得非常麻烦而且还有可能会忘记关闭。基于这种情况Go 语言为我们提供了 defer 函数,可以保证文件关闭后一定会被执行,不管你自定义的函数出现异常还是错误。
下面的代码是 Go 语言标准包 ioutil 中的 ReadFile 函数,它需要打开一个文件,然后通过 defer 关键字确保在 ReadFile 函数执行结束后f.Close() 方法被执行,这样文件的资源才一定会释放。
func ReadFile(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
//省略无关代码
return readAll(f, n)
}
defer 关键字用于修饰一个函数或者方法,使得该函数或者方法在返回前才会执行,也就说被延迟,但又可以保证一定会执行。
以上面的 ReadFile 函数为例,被 defer 修饰的 f.Close 方法延迟执行,也就是说会先执行 readAll(f, n),然后在整个 ReadFile 函数 return 之前执行 f.Close 方法。
defer 语句常被用于成对的操作,如文件的打开和关闭,加锁和释放锁,连接的建立和断开等。不管多么复杂的操作,都可以保证资源被正确地释放。
Panic 异常
Go 语言是一门静态的强类型语言,很多问题都尽可能地在编译时捕获,但是有一些只能在运行时检查,比如数组越界访问、不相同的类型强制转换等,这类运行时的问题会引起 panic 异常。
除了运行时可以产生 panic 外,我们自己也可以抛出 panic 异常。假设我需要连接 MySQL 数据库,可以写一个连接 MySQL 的函数connectMySQL如下面的代码所示
ch07/main.go
func connectMySQL(ip,username,password string){
if ip =="" {
panic("ip不能为空")
}
//省略其他代码
}
在 connectMySQL 函数中,如果 ip 为空会直接抛出 panic 异常。这种逻辑是正确的,因为数据库无法连接成功的话,整个程序运行起来也没有意义,所以就抛出 panic 终止程序的运行。
panic 是 Go 语言内置的函数,可以接受 interface{} 类型的参数,也就是任何类型的值都可以传递给 panic 函数,如下所示:
func panic(v interface{})
小提示interface{} 是空接口的意思,在 Go 语言中代表任意类型。
panic 异常是一种非常严重的情况,会让程序中断运行,使程序崩溃,所以如果是不影响程序运行的错误,不要使用 panic使用普通错误 error 即可。
Recover 捕获 Panic 异常
通常情况下,我们不对 panic 异常做任何处理,因为既然它是影响程序运行的异常,就让它直接崩溃即可。但是也的确有一些特例,比如在程序崩溃前做一些资源释放的处理,这时候就需要从 panic 异常中恢复,才能完成处理。
在 Go 语言中,可以通过内置的 recover 函数恢复 panic 异常。因为在程序 panic 异常崩溃的时候,只有被 defer 修饰的函数才能被执行,所以 recover 函数要结合 defer 关键字使用才能生效。
下面的示例是通过 defer 关键字 + 匿名函数 + recover 函数从 panic 异常中恢复的方式。
ch07/main.go
func main() {
defer func() {
if p:=recover();p!=nil{
fmt.Println(p)
}
}()
connectMySQL("","root","123456")
}
运行这个代码,可以看到如下的打印输出,这证明 recover 函数成功捕获了 panic 异常。
ip 不能为空
通过这个输出的结果也可以发现recover 函数返回的值就是通过 panic 函数传递的参数值。
总结
这节课主要讲了 Go 语言的错误处理机制,包括 error、defer、panic 等。在 error、panic 这两种错误机制中Go 语言更提倡 error 这种轻量错误,而不是 panic。

View File

@ -0,0 +1,304 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 并发基础Goroutines 和 Channels 的声明与使用
在本节课开始之前,我们先一起回忆上节课的思考题:是否可以有多个 defer如果可以的话它们的执行顺序是怎么样的
对于这道题,可以直接采用写代码测试的方式,如下所示:
func moreDefer(){
defer fmt.Println("First defer")
defer fmt.Println("Second defer")
defer fmt.Println("Three defer")
fmt.Println("函数自身代码")
}
func main(){
moreDefer()
}
我定义了 moreDefer 函数,函数里有三个 defer 语句,然后在 main 函数里调用它。运行这段程序可以看到如下内容输出:
函数自身代码
Three defer
Second defer
First defer
通过以上示例可以证明:
在一个方法或者函数中,可以有多个 defer 语句;
多个 defer 语句的执行顺序依照后进先出的原则。
defer 有一个调用栈,越早定义越靠近栈的底部,越晚定义越靠近栈的顶部,在执行这些 defer 语句的时候,会先从栈顶弹出一个 defer 然后执行它,也就是我们示例中的结果。
下面我们开始本节课的学习。本节课是 Go 语言的重点——协程和通道,它们是 Go 语言并发的基础,我会从这两个基础概念开始,带你逐步深入 Go 语言的并发。
什么是并发
前面的课程中,我所写的代码都按照顺序执行,也就是上一句代码执行完,才会执行下一句,这样的代码逻辑简单,也符合我们的阅读习惯。
但这样是不够的,因为计算机很强大,如果只让它干完一件事情再干另外一件事情就太浪费了。比如一款音乐软件,使用它听音乐的时候还想让它下载歌曲,同一时刻做了两件事,在编程中,这就是并发,并发可以让你编写的程序在同一时刻做多几件事情。
进程和线程
讲并发就绕不开线程,不过在介绍线程之前,我先为你介绍什么是进程。
进程
在操作系统中,进程是一个非常重要的概念。当你启动一个软件(比如浏览器)的时候,操作系统会为这个软件创建一个进程,这个进程是该软件的工作空间,它包含了软件运行所需的所有资源,比如内存空间、文件句柄,还有下面要讲的线程等。下面的图片就是我的电脑上运行的进程:
(电脑运行的进程)
那么线程是什么呢?
线程
线程是进程的执行空间,一个进程可以有多个线程,线程被操作系统调度执行,比如下载一个文件,发送一个消息等。这种多个线程被操作系统同时调度执行的情况,就是多线程的并发。
一个程序启动,就会有对应的进程被创建,同时进程也会启动一个线程,这个线程叫作主线程。如果主线程结束,那么整个程序就退出了。有了主线程,就可以从主线里启动很多其他线程,也就有了多线程的并发。
协程Goroutine
Go 语言中没有线程的概念,只有协程,也称为 goroutine。相比线程来说协程更加轻量一个程序可以随意启动成千上万个 goroutine。
goroutine 被 Go runtime 所调度这一点和线程不一样。也就是说Go 语言的并发是由 Go 自己所调度的,自己决定同时执行多少个 goroutine什么时候执行哪几个。这些对于我们开发者来说完全透明只需要在编码的时候告诉 Go 语言要启动几个 goroutine至于如何调度执行我们不用关心。
要启动一个 goroutine 非常简单Go 语言为我们提供了 go 关键字,相比其他编程语言简化了很多,如下面的代码所示:
ch08/main.go
func main() {
go fmt.Println("飞雪无情")
fmt.Println("我是 main goroutine")
time.Sleep(time.Second)
}
这样就启动了一个 goroutine用来调用 fmt.Println 函数,打印“飞雪无情”。所以这段代码里有两个 goroutine一个是 main 函数启动的 main goroutine一个是我自己通过 go 关键字启动的 goroutine。
从示例中可以总结出 go 关键字的语法,如下所示:
go function()
go 关键字后跟一个方法或者函数的调用,就可以启动一个 goroutine让方法在这个新启动的 goroutine 中运行。运行以上示例,可以看到如下输出:
我是 main goroutine
飞雪无情
从输出结果也可以看出程序是并发的go 关键字启动的 goroutine 并不阻塞 main goroutine 的执行,所以我们才会看到如上打印结果。
小提示:示例中的 time.Sleep(time.Second) 表示等待一秒,这里是让 main goroutine 等一秒,不然 main goroutine 执行完毕程序就退出了,也就看不到启动的新 goroutine 中“飞雪无情”的打印结果了。
Channel
那么如果启动了多个 goroutine它们之间该如何通信呢这就是 Go 语言提供的 channel通道要解决的问题。
声明一个 channel
在 Go 语言中,声明一个 channel 非常简单,使用内置的 make 函数即可,如下所示:
ch:=make(chan string)
其中 chan 是一个关键字,表示是 channel 类型。后面的 string 表示 channel 里的数据是 string 类型。通过 channel 的声明也可以看到chan 是一个集合类型。
定义好 chan 后就可以使用了,一个 chan 的操作只有两种:发送和接收。
接收:获取 chan 中的值,操作符为 <- chan
发送:向 chan 发送值,把值放在 chan 中,操作符为 chan <-
小技巧:这里注意发送和接收的操作符,都是 <- 只不过位置不同接收的 <- 操作符在 chan 的左侧发送的 <- 操作符在 chan 的右侧
现在我把上个示例改造下,使用 chan 来代替 time.Sleep 函数的等待工作,如下面的代码所示:
ch08/main.go
func main() {
ch:=make(chan string)
go func() {
fmt.Println("飞雪无情")
ch <- "goroutine 完成"
}()
fmt.Println("我是 main goroutine")
v:=<-ch
fmt.Println("接收到的chan中的值为",v)
}
运行这个示例,可以发现程序并没有退出,可以看到”飞雪无情”的输出结果,达到了 time.Sleep 函数的效果,如下所示:
我是 main goroutine
飞雪无情
接收到的chan中的值为 goroutine 完成
可以这样理解:在上面的示例中,我们在新启动的 goroutine 中向 chan 类型的变量 ch 发送值;在 main goroutine 中,从变量 ch 接收值;如果 ch 中没有值,则阻塞等待到 ch 中有值可以接收为止。
相信你应该明白为什么程序不会在新的 goroutine 完成之前退出了,因为通过 make 创建的 chan 中没有值,而 main goroutine 又想从 chan 中获取值,获取不到就一直等待,等到另一个 goroutine 向 chan 发送值为止。
channel 有点像在两个 goroutine 之间架设的管道,一个 goroutine 可以往这个管道里发送数据,另外一个可以从这个管道里取数据,有点类似于我们说的队列。
无缓冲 channel
上面的示例中,使用 make 创建的 chan 就是一个无缓冲 channel它的容量是 0不能存储任何数据。所以无缓冲 channel 只起到传输数据的作用,数据并不会在 channel 中做任何停留。这也意味着,无缓冲 channel 的发送和接收操作是同时进行的,它也可以称为同步 channel。
有缓冲 channel
有缓冲 channel 类似一个可阻塞的队列,内部的元素先进先出。通过 make 函数的第二个参数可以指定 channel 容量的大小,进而创建一个有缓冲 channel如下面的代码所示
cacheCh:=make(chan int,5)
我创建了一个容量为 5 的 channel内部的元素类型是 int也就是说这个 channel 内部最多可以存放 5 个类型为 int 的元素,如下图所示:
(有缓冲 channel
一个有缓冲 channel 具备以下特点:
有缓冲 channel 的内部有一个缓冲队列;
发送操作是向队列的尾部插入元素,如果队列已满,则阻塞等待,直到另一个 goroutine 执行,接收操作释放队列的空间;
接收操作是从队列的头部获取元素并把它从队列中删除,如果队列为空,则阻塞等待,直到另一个 goroutine 执行,发送操作插入新的元素。
因为有缓冲 channel 类似一个队列,可以获取它的容量和里面元素的个数。如下面的代码所示:
ch08/main.go
cacheCh:=make(chan int,5)
cacheCh <- 2
cacheCh <- 3
fmt.Println("cacheCh容量为:",cap(cacheCh),",元素个数为:",len(cacheCh))
其中,通过内置函数 cap 可以获取 channel 的容量,也就是最大能存放多少个元素,通过内置函数 len 可以获取 channel 中元素的个数。
小提示:无缓冲 channel 其实就是一个容量大小为 0 的 channel。比如 make(chan int,0)。
关闭 channel
channel 还可以使用内置函数 close 关闭,如下面的代码所示:
close(cacheCh)
如果一个 channel 被关闭了,就不能向里面发送数据了,如果发送的话,会引起 painc 异常。但是还可以接收 channel 里的数据,如果 channel 里没有数据的话,接收的数据是元素类型的零值。
单向 channel
有时候,我们有一些特殊的业务需求,比如限制一个 channel 只可以接收但是不能发送,或者限制一个 channel 只能发送但不能接收,这种 channel 称为单向 channel。
单向 channel 的声明也很简单,只需要在声明的时候带上 <- 操作符即可如下面的代码所示
onlySend := make(chan<- int)
onlyReceive:=make(<-chan int)
注意,声明单向 channel <- 操作符的位置和上面讲到的发送和接收操作是一样的
在函数或者方法的参数中,使用单向 channel 的较多,这样可以防止一些操作影响了 channel。
下面示例中的 counter 函数,它的参数 out 是一个只能发送的 channel所以在 counter 函数体内使用参数 out 时,只能对其进行发送操作,如果执行接收操作,则程序不能编译通过。
func counter(out chan<- int) {
//函数内容使用变量out只能进行发送操作
}
select+channel 示例
假设要从网上下载一个文件,我启动了 3 个 goroutine 进行下载,并把结果发送到 3 个 channel 中。其中,哪个先下载好,就会使用哪个 channel 的结果。
在这种情况下,如果我们尝试获取第一个 channel 的结果,程序就会被阻塞,无法获取剩下两个 channel 的结果,也无法判断哪个先下载好。这个时候就需要用到多路复用操作了,在 Go 语言中,通过 select 语句可以实现多路复用,其语句格式如下:
select {
case i1 = <-c1:
//todo
case c2 <- i2:
//todo
default:
// default todo
}
整体结构和 switch 非常像,都有 case 和 default只不过 select 的 case 是一个个可以操作的 channel。
小提示多路复用可以简单地理解为N 个 channel 中,任意一个 channel 有数据产生select 都可以监听到,然后执行相应的分支,接收数据并处理。
有了 select 语句,就可以实现下载的例子了。如下面的代码所示:
ch08/main.go
func main() {
//声明三个存放结果的channel
firstCh := make(chan string)
secondCh := make(chan string)
threeCh := make(chan string)
//同时开启3个goroutine下载
go func() {
firstCh <- downloadFile("firstCh")
}()
go func() {
secondCh <- downloadFile("secondCh")
}()
go func() {
threeCh <- downloadFile("threeCh")
}()
//开始select多路复用哪个channel能获取到值
//就说明哪个最先下载好,就用哪个。
select {
case filePath := <-firstCh:
fmt.Println(filePath)
case filePath := <-secondCh:
fmt.Println(filePath)
case filePath := <-threeCh:
fmt.Println(filePath)
}
}
func downloadFile(chanName string) string {
//模拟下载文件,可以自己随机time.Sleep点时间试试
time.Sleep(time.Second)
return chanName+":filePath"
}
如果这些 case 中有一个可以执行select 语句会选择该 case 执行,如果同时有多个 case 可以被执行,则随机选择一个,这样每个 case 都有平等的被执行的机会。如果一个 select 没有任何 case那么它会一直等待下去。
总结
在这节课中,我为你介绍了如何通过 go 关键字启动一个 goroutine以及如何通过 channel 实现 goroutine 间的数据传递,这些都是 Go 语言并发的基础,理解它们可以更好地掌握并发。
在 Go 语言中,提倡通过通信来共享内存,而不是通过共享内存来通信,其实就是提倡通过 channel 发送接收消息的方式进行数据传递,而不是通过修改同一个变量。所以在数据流动、传递的场景中要优先使用 channel它是并发安全的性能也不错。

View File

@ -0,0 +1,362 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 同步原语sync 包让你对并发控制得心应手
上节课留了一个思考题channel 为什么是并发安全的呢?是因为 channel 内部使用了互斥锁来保证并发的安全,这节课,我将为你介绍互斥锁的使用。
在 Go 语言中,不仅有 channel 这类比较易用且高级的同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步机制。通过它们,我们可以更加灵活地控制数据的同步和多协程的并发,下面我为你逐一讲解。
资源竞争
在一个 goroutine 中,如果分配的内存没有被其他 goroutine 访问,只在该 goroutine 中被使用,那么不存在资源竞争的问题。
但如果同一块内存被多个 goroutine 同时访问,就会产生不知道谁先访问也无法预料最后结果的情况。这就是资源竞争,这块内存可以称为共享的资源。
我们通过下面的示例来进一步地了解:
ch09/main.go
//共享的资源
var sum = 0
func main() {
//开启100个协程让sum+10
for i := 0; i < 100; i++ {
go add(10)
}
//防止提前退出
time.Sleep(2 * time.Second)
fmt.Println("和为:",sum)
}
func add(i int) {
sum += i
}
示例中,你期待的结果可能是“和为 1000”但当运行程序后可能如预期所示但也可能是 990 或者 980。导致这种情况的核心原因是资源 sum 不是并发安全的,因为同时会有多个协程交叉执行 sum+=i产生不可预料的结果。
既然已经知道了原因,解决的办法也就有了,只需要确保同时只有一个协程执行 sum+=i 操作即可。要达到该目的,可以使用 sync.Mutex 互斥锁。
小技巧:使用 go build、go run、go test 这些 Go 语言工具链提供的命令时,添加 -race 标识可以帮你检查 Go 语言代码是否存在资源竞争。
同步原语
sync.Mutex
互斥锁,顾名思义,指的是在同一时刻只有一个协程执行某段代码,其他协程都要等待该协程执行完毕后才能继续执行。
在下面的示例中,我声明了一个互斥锁 mutex然后修改 add 函数,对 sum+=i 这段代码加锁保护。这样这段访问共享资源的代码片段就并发安全了,可以得到正确的结果。
ch09/main.go
var(
sum int
mutex sync.Mutex
)
func add(i int) {
mutex.Lock()
sum += i
mutex.Unlock()
}
小提示:以上被加锁保护的 sum+=i 代码片段又称为临界区。在同步的程序设计中,临界区段指的是一个访问共享资源的程序片段,而这些共享资源又有无法同时被多个协程访问的特性。 当有协程进入临界区段时,其他协程必须等待,这样就保证了临界区的并发安全。
互斥锁的使用非常简单,它只有两个方法 Lock 和 Unlock代表加锁和解锁。当一个协程获得 Mutex 锁后,其他协程只能等到 Mutex 锁释放后才能再次获得锁。
Mutex 的 Lock 和 Unlock 方法总是成对出现,而且要确保 Lock 获得锁后,一定执行 UnLock 释放锁,所以在函数或者方法中会采用 defer 语句释放锁,如下面的代码所示:
func add(i int) {
mutex.Lock()
defer mutex.Unlock()
sum += i
}
这样可以确保锁一定会被释放,不会被遗忘。
sync.RWMutex
在 sync.Mutex 小节中,我对共享资源 sum 的加法操作进行了加锁,这样可以保证在修改 sum 值的时候是并发安全的。如果读取操作也采用多个协程呢?如下面的代码所示:
ch09/main.go
func main() {
for i := 0; i < 100; i++ {
go add(10)
}
for i:=0; i<10;i++ {
go fmt.Println("和为:",readSum())
}
time.Sleep(2 * time.Second)
}
//增加了一个读取sum的函数便于演示并发
func readSum() int {
b:=sum
return b
}
这个示例开启了 10 个协程,它们同时读取 sum 的值。因为 readSum 函数并没有任何加锁控制,所以它不是并发安全的,即一个 goroutine 正在执行 sum+=i 操作的时候,另一个 goroutine 可能正在执行 b:=sum 操作,这就会导致读取的 num 值是一个过期的值,结果不可预期。
如果要解决以上资源竞争的问题,可以使用互斥锁 sync.Mutex如下面的代码所示
ch09/main.go
func readSum() int {
mutex.Lock()
defer mutex.Unlock()
b:=sum
return b
}
因为 add 和 readSum 函数使用的是同一个 sync.Mutex所以它们的操作是互斥的也就是一个 goroutine 进行修改操作 sum+=i 的时候,另一个 gouroutine 读取 sum 的操作 b:=sum 会等待,直到修改操作执行完毕。
现在我们解决了多个 goroutine 同时读写的资源竞争问题,但是又遇到另外一个问题——性能。因为每次读写共享资源都要加锁,所以性能低下,这该怎么解决呢?
现在我们分析读写这个特殊场景,有以下几种情况:
写的时候不能同时读,因为这个时候读取的话可能读到脏数据(不正确的数据);
读的时候不能同时写,因为也可能产生不可预料的结果;
读的时候可以同时读,因为数据不会改变,所以不管多少个 goroutine 读都是并发安全的。
所以就可以通过读写锁 sync.RWMutex 来优化这段代码,提升性能。现在我将以上示例改为读写锁,来实现我们想要的结果,如下所示:
ch09/main.go
var mutex sync.RWMutex
func readSum() int {
//只获取读锁
mutex.RLock()
defer mutex.RUnlock()
b:=sum
return b
}
对比互斥锁的示例,读写锁的改动有两处:
把锁的声明换成读写锁 sync.RWMutex。
把函数 readSum 读取数据的代码换成读锁,也就是 RLock 和 RUnlock。
这样性能就会有很大的提升,因为多个 goroutine 可以同时读数据,不再相互等待。
sync.WaitGroup
在以上示例中,相信你注意到了这段 time.Sleep(2 * time.Second) 代码,这是为了防止主函数 main 返回使用,一旦 main 函数返回了,程序也就退出了。
因为我们不知道 100 个执行 add 的协程和 10 个执行 readSum 的协程什么时候完全执行完毕,所以设置了一个比较长的等待时间,也就是两秒。
小提示:一个函数或者方法的返回 (return) 也就意味着当前函数执行完毕。
所以存在一个问题,如果这 110 个协程在两秒内执行完毕main 函数本该提前返回,但是偏偏要等两秒才能返回,会产生性能问题。
如果这 110 个协程执行的时间超过两秒,因为设置的等待时间只有两秒,程序就会提前返回,导致有协程没有执行完毕,产生不可预知的结果。
那么有没有办法解决这个问题呢?也就是说有没有办法监听所有协程的执行,一旦全部执行完毕,程序马上退出,这样既可保证所有协程执行完毕,又可以及时退出节省时间,提升性能。你第一时间应该会想到上节课讲到的 channel。没错channel 的确可以解决这个问题不过非常复杂Go 语言为我们提供了更简洁的解决办法,它就是 sync.WaitGroup。
在使用 sync.WaitGroup 改造示例之前,我先把 main 函数中的代码进行重构,抽取成一个函数 run这样可以更好地理解如下所示
ch09/main.go
func main() {
run()
}
func run(){
for i := 0; i < 100; i++ {
go add(10)
}
for i:=0; i<10;i++ {
go fmt.Println("和为:",readSum())
}
time.Sleep(2 * time.Second)
}
这样执行读写的 110 个协程代码逻辑就都放在了 run 函数中,在 main 函数中直接调用 run 函数即可。现在只需通过 sync.WaitGroup 对 run 函数进行改造,让其恰好执行完毕,如下所示:
ch09/main.go
func run(){
var wg sync.WaitGroup
//因为要监控110个协程所以设置计数器为110
wg.Add(110)
for i := 0; i < 100; i++ {
go func() {
//计数器值减1
defer wg.Done()
add(10)
}()
}
for i:=0; i<10;i++ {
go func() {
//计数器值减1
defer wg.Done()
fmt.Println("和为:",readSum())
}()
}
//一直等待只要计数器值为0
wg.Wait()
}
sync.WaitGroup 的使用比较简单,一共分为三步:
声明一个 sync.WaitGroup然后通过 Add 方法设置计数器的值,需要跟踪多少个协程就设置多少,这里是 110
在每个协程执行完毕时调用 Done 方法,让计数器减 1告诉 sync.WaitGroup 该协程已经执行完毕;
最后调用 Wait 方法一直等待,直到计数器值为 0也就是所有跟踪的协程都执行完毕。
通过 sync.WaitGroup 可以很好地跟踪协程。在协程执行完毕后,整个 run 函数才能执行完毕,时间不多不少,正好是协程执行的时间。
sync.WaitGroup 适合协调多个协程共同做一件事情的场景,比如下载一个文件,假设使用 10 个协程,每个协程下载文件的 110 大小,只有 10 个协程都下载好了整个文件才算是下载好了。这就是我们经常听到的多线程下载,通过多个线程共同做一件事情,显著提高效率。
小提示:其实你也可以把 Go 语言中的协程理解为平常说的线程,从用户体验上也并无不可,但是从技术实现上,你知道他们是不一样的就可以了。
sync.Once
在实际的工作中,你可能会有这样的需求:让代码只执行一次,哪怕是在高并发的情况下,比如创建一个单例。
针对这种情形Go 语言为我们提供了 sync.Once 来保证代码只执行一次,如下所示:
ch09/main.go
func main() {
doOnce()
}
func doOnce() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}
//用于等待协程执行完毕
done := make(chan bool)
//启动10个协程执行once.Do(onceBody)
for i := 0; i < 10; i++ {
go func() {
//把要执行的函数(方法)作为参数传给once.Do方法即可
once.Do(onceBody)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
这是 Go 语言自带的一个示例,虽然启动了 10 个协程来执行 onceBody 函数,但是因为用了 once.Do 方法,所以函数 onceBody 只会被执行一次。也就是说在高并发的情况下sync.Once 也会保证 onceBody 函数只执行一次。
sync.Once 适用于创建某个对象的单例、只加载一次的资源等只执行一次的场景。
sync.Cond
在 Go 语言中sync.WaitGroup 用于最终完成的场景,关键点在于一定要等待所有协程都执行完毕。
而 sync.Cond 可以用于发号施令,一声令下所有协程都可以开始执行,关键点在于协程开始的时候是等待的,要等待 sync.Cond 唤醒才能执行。
sync.Cond 从字面意思看是条件变量,它具有阻塞协程和唤醒协程的功能,所以可以在满足一定条件的情况下唤醒协程,但条件变量只是它的一种使用场景。
下面我以 10 个人赛跑为例来演示 sync.Cond 的用法。在这个示例中有一个裁判,裁判要先等这 10 个人准备就绪,然后一声发令枪响,这 10 个人就可以开始跑了,如下所示:
//10个人赛跑1个裁判发号施令
func race(){
cond :=sync.NewCond(&sync.Mutex{})
var wg sync.WaitGroup
wg.Add(11)
for i:=0;i<10; i++ {
go func(num int) {
defer wg.Done()
fmt.Println(num,"号已经就位")
cond.L.Lock()
cond.Wait()//等待发令枪响
fmt.Println(num,"号开始跑……")
cond.L.Unlock()
}(i)
}
//等待所有goroutine都进入wait状态
time.Sleep(2*time.Second)
go func() {
defer wg.Done()
fmt.Println("裁判已经就位,准备发令枪")
fmt.Println("比赛开始,大家准备跑")
cond.Broadcast()//发令枪响
}()
//防止函数提前返回退出
wg.Wait()
}
以上示例中有注释说明,已经很好理解,我这里再大概讲解一下步骤:
通过 sync.NewCond 函数生成一个 *sync.Cond用于阻塞和唤醒协程
然后启动 10 个协程模拟 10 个人,准备就位后调用 cond.Wait() 方法阻塞当前协程等待发令枪响,这里需要注意的是调用 cond.Wait() 方法时要加锁;
time.Sleep 用于等待所有人都进入 wait 阻塞状态,这样裁判才能调用 cond.Broadcast() 发号施令;
裁判准备完毕后,就可以调用 cond.Broadcast() 通知所有人开始跑了。
sync.Cond 有三个方法,它们分别是:
Wait阻塞当前协程直到被其他协程调用 Broadcast 或者 Signal 方法唤醒,使用的时候需要加锁,使用 sync.Cond 中的锁即可,也就是 L 字段。
Signal唤醒一个等待时间最长的协程。
Broadcast唤醒所有等待的协程。
注意:在调用 Signal 或者 Broadcast 之前,要确保目标协程处于 Wait 阻塞状态,不然会出现死锁问题。
如果你以前学过 Java会发现 sync.Cond 和 Java 的等待唤醒机制很像,它的三个方法 Wait、Signal、Broadcast 就分别对应 Java 中的 wait、notify、notifyAll。
总结
这节课主要讲解 Go 语言的同步原语使用通过它们可以更灵活地控制多协程的并发。从使用上讲Go 语言还是更推荐 channel 这种更高级别的并发控制方式,因为它更简洁,也更容易理解和使用。
当然本节课讲的这些比较基础的同步原语也很有用。同步原语通常用于更复杂的并发控制,如果追求更灵活的控制方式和性能,你可以使用它们。
本节课到这里就要结束了sync 包里还有一个同步原语我没有讲,它就是 sync.Map。sync.Map 的使用和内置的 map 类型一样,只不过它是并发安全的,所以这节课的作业就是练习使用 sync.Map。

View File

@ -0,0 +1,380 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 Context你必须掌握的多线程并发控制神器
在上一节课中我留了一个作业,也就是让你自己练习使用 sync.Map相信你已经做出来了。现在我为你讲解 sync.Map 的方法。
Store存储一对 key-value 值。
Load根据 key 获取对应的 value 值,并且可以判断 key 是否存在。
LoadOrStore如果 key 对应的 value 存在,则返回该 value如果不存在存储相应的 value。
Delete删除一个 key-value 键值对。
Range循环迭代 sync.Map效果与 for range 一样。
相信有了这些方法的介绍,你对 sync.Map 会有更深入的理解。下面开始今天的课程:如何通过 Context 更好地控制并发。
协程如何退出
一个协程启动后,大部分情况需要等待里面的代码执行完毕,然后协程会自行退出。但是如果有一种情景,需要让协程提前退出怎么办呢?在下面的代码中,我做了一个监控狗用来监控程序:
ch10/main.go
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
watchDog("【监控狗1】")
}()
wg.Wait()
}
func watchDog(name string){
//开启for select循环一直后台监控
for{
select {
default:
fmt.Println(name,"正在监控……")
}
time.Sleep(1*time.Second)
}
}
我通过 watchDog 函数实现了一个监控狗,它会一直在后台运行,每隔一秒就会打印”监控狗正在监控……”的文字。
如果需要让监控狗停止监控、退出程序,一个办法是定义一个全局变量,其他地方可以通过修改这个变量发出停止监控狗的通知。然后在协程中先检查这个变量,如果发现被通知关闭就停止监控,退出当前协程。
但是这种方法需要通过加锁来保证多协程下并发的安全,基于这个思路,有个升级版的方案:用 select+channel 做检测,如下面的代码所示:
ch10/main.go
func main() {
var wg sync.WaitGroup
wg.Add(1)
stopCh := make(chan bool) //用来停止监控狗
go func() {
defer wg.Done()
watchDog(stopCh,"【监控狗1】")
}()
time.Sleep(5 * time.Second) //先让监控狗监控5秒
stopCh <- true //发停止指令
wg.Wait()
}
func watchDog(stopCh chan bool,name string){
//开启for select循环一直后台监控
for{
select {
case <-stopCh:
fmt.Println(name,"停止指令已收到,马上停止")
return
default:
fmt.Println(name,"正在监控……")
}
time.Sleep(1*time.Second)
}
}
这个示例是使用 select+channel 的方式改造的 watchDog 函数,实现了通过 channel 发送指令让监控狗停止,进而达到协程退出的目的。以上示例主要有两处修改,具体如下:
为 watchDog 函数增加 stopCh 参数,用于接收停止指令;
在 main 函数中,声明用于停止的 stopCh传递给 watchDog 函数,然后通过 stopCh<-true 发送停止指令让协程退出
初识 Context
以上示例是 select+channel 比较经典的使用场景,这里也顺便复习了 select 的知识。
通过 select+channel 让协程退出的方式比较优雅,但是如果我们希望做到同时取消很多个协程呢?如果是定时取消协程又该怎么办?这时候 select+channel 的局限性就凸现出来了,即使定义了多个 channel 解决问题,代码逻辑也会非常复杂、难以维护。
要解决这种复杂的协程问题,必须有一种可以跟踪协程的方案,只有跟踪到每个协程,才能更好地控制它们,这种方案就是 Go 语言标准库为我们提供的 Context也是这节课的主角。
现在我通过 Context 重写上面的示例,实现让监控狗停止的功能,如下所示:
ch10/main.go
func main() {
var wg sync.WaitGroup
wg.Add(1)
ctx,stop:=context.WithCancel(context.Background())
go func() {
defer wg.Done()
watchDog(ctx,"【监控狗1】")
}()
time.Sleep(5 * time.Second) //先让监控狗监控5秒
stop() //发停止指令
wg.Wait()
}
func watchDog(ctx context.Context,name string) {
//开启for select循环一直后台监控
for {
select {
case <-ctx.Done():
fmt.Println(name,"停止指令已收到,马上停止")
return
default:
fmt.Println(name,"正在监控……")
}
time.Sleep(1 * time.Second)
}
}
相比 select+channel 的方案Context 方案主要有 4 个改动点。
watchDog 的 stopCh 参数换成了 ctx类型为 context.Context。
原来的 case <-stopCh 改为 case <-ctx.Done()用于判断是否停止
使用 context.WithCancel(context.Background()) 函数生成一个可以取消的 Context用于发送停止指令。这里的 context.Background() 用于生成一个空 Context一般作为整个 Context 树的根节点。
原来的 stopCh <- true 停止指令改为 context.WithCancel 函数返回的取消函数 stop()
可以看到,这和修改前的整体代码结构一样,只不过从 channel 换成了 Context。以上示例只是 Context 的一种使用场景,它的能力不止于此,现在我来介绍什么是 Context。
什么是 Context
一个任务会有很多个协程协作完成,一次 HTTP 请求也会触发很多个协程的启动,而这些协程有可能会启动更多的子协程,并且无法预知有多少层协程、每一层有多少个协程。
如果因为某些原因导致任务终止了HTTP 请求取消了,那么它们启动的协程怎么办?该如何取消呢?因为取消这些协程可以节约内存,提升性能,同时避免不可预料的 Bug。
Context 就是用来简化解决这些问题的并且是并发安全的。Context 是一个接口,它具备手动、定时、超时发出取消信号、传值等功能,主要用于控制多个协程之间的协作,尤其是取消操作。一旦取消指令下达,那么被 Context 跟踪的这些协程都会收到取消信号,就可以做清理和退出操作。
Context 接口只有四个方法,下面进行详细介绍,在开发中你会经常使用它们,你可以结合下面的代码来看。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Deadline 方法可以获取设置的截止时间,第一个返回值 deadline 是截止时间到了这个时间点Context 会自动发起取消请求,第二个返回值 ok 代表是否设置了截止时间。
Done 方法返回一个只读的 channel类型为 struct{}。在协程中,如果该方法返回的 chan 可以读取,则意味着 Context 已经发起了取消信号。通过 Done 方法收到这个信号后,就可以做清理操作,然后退出协程,释放资源。
Err 方法返回取消的错误原因,即因为什么原因 Context 被取消。
Value 方法获取该 Context 上绑定的值,是一个键值对,所以要通过一个 key 才可以获取对应的值。
Context 接口的四个方法中最常用的就是 Done 方法,它返回一个只读的 channel用于接收取消信号。当 Context 取消的时候,会关闭这个只读 channel也就等于发出了取消信号。
Context 树
我们不需要自己实现 Context 接口Go 语言提供了函数可以帮助我们生成不同的 Context通过这些函数可以生成一颗 Context 树,这样 Context 才可以关联起来,父 Context 发出取消信号的时候,子 Context 也会发出,这样就可以控制不同层级的协程退出。
从使用功能上分,有四种实现好的 Context。
空 Context不可取消没有截止时间主要用于 Context 树的根节点。
可取消的 Context用于发出取消信号当取消的时候它的子 Context 也会取消。
可定时取消的 Context多了一个定时的功能。
值 Context用于存储一个 key-value 键值对。
从下图 Context 的衍生树可以看到,最顶部的是空 Context它作为整棵 Context 树的根节点,在 Go 语言中,可以通过 context.Background() 获取一个根节点 Context。
(四种 Context 的衍生树)
有了根节点 Context 后,这颗 Context 树要怎么生成呢?需要使用 Go 语言提供的四个函数。
WithCancel(parent Context):生成一个可取消的 Context。
WithDeadline(parent Context, d time.Time):生成一个可定时取消的 Context参数 d 为定时取消的具体时间。
WithTimeout(parent Context, timeout time.Duration):生成一个可超时取消的 Context参数 timeout 用于设置多久后取消
WithValue(parent Context, key, val interface{}):生成一个可携带 key-value 键值对的 Context。
以上四个生成 Context 的函数中,前三个都属于可取消的 Context它们是一类函数最后一个是值 Context用于存储一个 key-value 键值对。
使用 Context 取消多个协程
取消多个协程也比较简单,把 Context 作为参数传递给协程即可,还是以监控狗为例,如下所示:
ch10/main.go
wg.Add(3)
go func() {
defer wg.Done()
watchDog(ctx,"【监控狗2】")
}()
go func() {
defer wg.Done()
watchDog(ctx,"【监控狗3】")
}()
示例中增加了两个监控狗,也就是增加了两个协程,这样一个 Context 就同时控制了三个协程,一旦 Context 发出取消信号,这三个协程都会取消退出。
以上示例中的 Context 没有子 Context如果一个 Context 有子 Context在该 Context 取消时会发生什么呢?下面通过一幅图说明:
Context 取消)
可以看到,当节点 Ctx2 取消时,它的子节点 Ctx4、Ctx5 都会被取消,如果还有子节点的子节点,也会被取消。也就是说根节点为 Ctx2 的所有节点都会被取消,其他节点如 Ctx1、Ctx3 和 Ctx6 则不会。
Context 传值
Context 不仅可以取消,还可以传值,通过这个能力,可以把 Context 存储的值供其他协程使用。我通过下面的代码来说明:
ch10/main.go
func main() {
wg.Add(4) //记得这里要改为4原来是3因为要多启动一个协程
//省略其他无关代码
valCtx:=context.WithValue(ctx,"userId",2)
go func() {
defer wg.Done()
getUser(valCtx)
}()
//省略其他无关代码
}
func getUser(ctx context.Context){
for {
select {
case <-ctx.Done():
fmt.Println("【获取用户】","协程退出")
return
default:
userId:=ctx.Value("userId")
fmt.Println("【获取用户】","用户ID为",userId)
time.Sleep(1 * time.Second)
}
}
}
这个示例是和上面的示例放在一起运行的,所以我省略了上面实例的重复代码。其中,通过 context.WithValue 函数存储一个 userId 为 2 的键值对,就可以在 getUser 函数中通过 ctx.Value(“userId”) 方法把对应的值取出来,达到传值的目的。
Context 使用原则
Context 是一种非常好的工具,使用它可以很方便地控制取消多个协程。在 Go 语言标准库中也使用了它们,比如 net/http 中使用 Context 取消网络的请求。
要更好地使用 Context有一些使用原则需要尽可能地遵守。
Context 不要放在结构体中,要以参数的方式传递。
Context 作为函数的参数时,要放在第一位,也就是第一个参数。
要使用 context.Background 函数生成根节点的 Context也就是最顶层的 Context。
Context 传值要传递必须的值,而且要尽可能地少,不要什么都传。
Context 多协程安全,可以在多个协程中放心使用。
以上原则是规范类的Go 语言的编译器并不会做这些检查,要靠自己遵守。
总结
Context 通过 With 系列函数生成 Context 树,把相关的 Context 关联起来,这样就可以统一进行控制。一声令下,关联的 Context 都会发出取消信号,使用这些 Context 的协程就可以收到取消信号,然后清理退出。你在定义函数的时候,如果想让外部给你的函数发取消信号,就可以为这个函数增加一个 Context 参数,让外部的调用者可以通过 Context 进行控制,比如下载一个文件超时退出的需求。

View File

@ -0,0 +1,487 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 并发模式Go 语言中即学即用的高效并发模式
上节课我为你讲解了如何通过 Context 更好地控制多个协程,课程最后的思考题是:如何通过 Context 实现日志跟踪?
要想跟踪一个用户的请求,必须有一个唯一的 ID 来标识这次请求调用了哪些函数、执行了哪些代码,然后通过这个唯一的 ID 把日志信息串联起来。这样就形成了一个日志轨迹,也就实现了用户的跟踪,于是思路就有了。
在用户请求的入口点生成 TraceID。
通过 context.WithValue 保存 TraceID。
然后这个保存着 TraceID 的 Context 就可以作为参数在各个协程或者函数间传递。
在需要记录日志的地方,通过 Context 的 Value 方法获取保存的 TraceID然后把它和其他日志信息记录下来。
这样具备同样 TraceID 的日志就可以被串联起来,达到日志跟踪的目的。
以上思路实现的核心是 Context 的传值功能。
目前我们已熟练掌握了 goroutine、channel、sync 包的同步原语,这些都是并发编程比较基础的元素。而这节课要介绍的是如何用这些基础元素组成并发模式,帮助我们更好地编写并发程序。
for select 循环模式
for select 循环模式非常常见,在前面的课程中也使用过,它一般和 channel 组合完成任务,代码格式如下:
for { //for无限循环或者for range循环
select {
//通过一个channel控制
}
}
这是一种 for 循环 +select 多路复用的并发模式,哪个 case 满足就执行哪个,直到满足一定的条件退出 for 循环(比如发送退出信号)。
从具体实现上讲for select 循环有两种模式,一种是上节课监控狗例子中的无限循环模式,只有收到终止指令才会退出,如下所示:
for {
select {
case <-done:
return
default:
//执行具体的任务
}
}
这种模式会一直执行 default 语句中的任务,直到 done 这个 channel 被关闭为止。
第二种模式是 for range select 有限循环,一般用于把可以迭代的内容发送到 channel 上,如下所示:
for _,s:=range []int{}{
select {
case <-done:
return
case resultCh <- s:
}
}
这种模式也会有一个 done channel用于退出当前的 for 循环,而另外一个 resultCh channel 用于接收 for range 循环的值,这些值通过 resultCh 可以传送给其他的调用者。
select timeout 模式
假如需要访问服务器获取数据,因为网络的不同响应时间不一样,为保证程序的质量,不可能一直等待网络返回,所以需要设置一个超时时间,这时候就可以使用 select timeout 模式,如下所示:
ch11/main.go
func main() {
result := make(chan string)
go func() {
//模拟网络访问
time.Sleep(8 * time.Second)
result <- "服务端结果"
}()
select {
case v := <-result:
fmt.Println(v)
case <-time.After(5 * time.Second):
fmt.Println("网络访问超时了")
}
}
select timeout 模式的核心在于通过 time.After 函数设置一个超时时间,防止因为异常造成 select 语句的无限等待。
小提示:如果可以使用 Context 的 WithTimeout 函数超时取消,要优先使用。
Pipeline 模式
Pipeline 模式也称为流水线模式,模拟的就是现实世界中的流水线生产。以手机组装为例,整条生产流水线可能有成百上千道工序,每道工序只负责自己的事情,最终经过一道道工序组装,就完成了一部手机的生产。
从技术上看,每一道工序的输出,就是下一道工序的输入,在工序之间传递的东西就是数据,这种模式称为流水线模式,而传递的数据称为数据流。
(流水线模式)
通过以上流水线模式示意图,可以看到从最开始的生产,经过工序 1、2、3、4 到最终成品,这就是一条比较形象的流水线,也就是 Pipeline。
现在我以组装手机为例,讲解流水线模式的使用。假设一条组装手机的流水线有 3 道工序,分别是配件采购、配件组装、打包成品,如图所示:
(手机组装流水线)
从以上示意图中可以看到,采购的配件通过 channel 传递给工序 2 进行组装,然后再通过 channel 传递给工序 3 打包成品。相对工序 2 来说,工序 1 是生产者,工序 3 是消费者。相对工序 1 来说,工序 2 是消费者。相对工序 3 来说,工序 2 是生产者。
我用下面的几组代码进行演示:
ch11/main.go
//工序1采购
func buy(n int) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for i := 1; i <= n; i++ {
out <- fmt.Sprint("配件", i)
}
}()
return out
}
首先我们定义一个采购函数 buy它有一个参数 n可以设置要采购多少套配件。采购代码的实现逻辑是通过 for 循环产生配件,然后放到 channel 类型的变量 out 里,最后返回这个 out调用者就可以从 out 中获得配件。
有了采购好的配件,就可以开始组装了,如下面的代码所示:
ch11/main.go
//工序2组装
func build(in <-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for c := range in {
out <- "组装(" + c + ")"
}
}()
return out
}
组装函数 build 有一个 channel 类型的参数 in用于接收配件进行组装组装后的手机放到 channel 类型的变量 out 中返回。
有了组装好的手机,就可以放在精美的包装盒中售卖了,而包装的操作是工序 3 完成的,对应的函数是 pack如下所示
ch11/main.go
//工序3打包
func pack(in <-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for c := range in {
out <- "打包(" + c + ")"
}
}()
return out
}
函数 pack 的代码实现和组装函数 build 基本相同,这里不再赘述。
流水线上的三道工序都完成后,就可以通过一个组织者把三道工序组织在一起,形成一条完整的手机组装流水线,这个组织者可以是我们常用的 main 函数,如下面的代码所示:
ch11/main.go
func main() {
coms := buy(10) //采购10套配件
phones := build(coms) //组装10部手机
packs := pack(phones) //打包它们以便售卖
//输出测试,看看效果
for p := range packs {
fmt.Println(p)
}
}
按照流水线工序进行调用,最终把手机打包以便售卖,过程如下所示:
打包(组装(配件1))
打包(组装(配件2))
打包(组装(配件3))
打包(组装(配件4))
打包(组装(配件5))
打包(组装(配件6))
打包(组装(配件7))
打包(组装(配件8))
打包(组装(配件9))
打包(组装(配件10))
从上述例子中,我们可以总结出一个流水线模式的构成:
流水线由一道道工序构成,每道工序通过 channel 把数据传递到下一个工序;
每道工序一般会对应一个函数,函数里有协程和 channel协程一般用于处理数据并把它放入 channel 中,整个函数会返回这个 channel 以供下一道工序使用;
最终要有一个组织者(示例中的 main 函数)把这些工序串起来,这样就形成了一个完整的流水线,对于数据来说就是数据流。
扇出和扇入模式
手机流水线经过一段时间的运转,组织者发现产能提不上去,经过调研分析,发现瓶颈在工序 2 配件组装。工序 2 过慢,导致上游工序 1 配件采购速度不得不降下来,下游工序 3 没太多事情做,不得不闲下来,这就是整条流水线产能低下的原因。
为了提升手机产能,组织者决定对工序 2 增加两班人手。人手增加后,整条流水线的示意图如下所示:
(改进后的流水线)
从改造后的流水线示意图可以看到,工序 2 共有工序 2-1、工序 2-2、工序 2-3 三班人手,工序 1 采购的配件会被工序 2 的三班人手同时组装这三班人手组装好的手机会同时传给merge 组件汇聚,然后再传给工序 3 打包成品。在这个流程中,会产生两种模式:扇出和扇入。
示意图中红色的部分是扇出,对于工序 1 来说,它同时为工序 2 的三班人手传递数据(采购配件)。以工序 1 为中点,三条传递数据的线发散出去,就像一把打开的扇子一样,所以叫扇出。
示意图中蓝色的部分是扇入,对于 merge 组件来说,它同时接收工序 2 三班人手传递的数据(组装的手机)进行汇聚,然后传给工序 3。以 merge 组件为中点,三条传递数据的线汇聚到 merge 组件,也像一把打开的扇子一样,所以叫扇入。
小提示:扇出和扇入都像一把打开的扇子,因为数据传递的方向不同,所以叫法也不一样,扇出的数据流向是发散传递出去,是输出流;扇入的数据流向是汇聚进来,是输入流。
已经理解了扇出扇入的原理,就可以开始改造流水线了。这次改造中,三道工序的实现函数 buy、build、pack 都保持不变,只需要增加一个 merge 函数即可,如下面的代码所示:
ch11/main.go
//扇入函数组件把多个chanel中的数据发送到一个channel中
func merge(ins ...<-chan string) <-chan string {
var wg sync.WaitGroup
out := make(chan string)
//把一个channel中的数据发送到out中
p:=func(in <-chan string) {
defer wg.Done()
for c := range in {
out <- c
}
}
wg.Add(len(ins))
//扇入需要启动多个goroutine用于处于多个channel中的数据
for _,cs:=range ins{
go p(cs)
}
//等待所有输入的数据ins处理完再关闭输出out
go func() {
wg.Wait()
close(out)
}()
return out
}
新增的 merge 函数的核心逻辑就是对输入的每个 channel 使用单独的协程处理,并将每个协程处理的结果都发送到变量 out 中,达到扇入的目的。总结起来就是通过多个协程并发,把多个 channel 合成一个。
在整条手机组装流水线中merge 函数非常小,而且和业务无关,不能当作一道工序,所以我把它叫作组件。该 merge 组件是可以复用的,流水线中的任何工序需要扇入的时候,都可以使用 merge 组件。
小提示:这次的改造新增了 merge 函数,其他函数保持不变,符合开闭原则。开闭原则规定“软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的”。
有了可以复用的 merge 组件,现在来看流水线的组织者 main 函数是如何使用扇出和扇入并发模式的,如下所示:
ch11/main.go
func main() {
coms := buy(100) //采购100套配件
//三班人同时组装100部手机
phones1 := build(coms)
phones2 := build(coms)
phones3 := build(coms)
//汇聚三个channel成一个
phones := merge(phones1,phones2,phones3)
packs := pack(phones) //打包它们以便售卖
//输出测试,看看效果
for p := range packs {
fmt.Println(p)
}
}
这个示例采购了 100 套配件,也就是开始增加产能了。于是同时调用三次 build 函数,也就是为工序 2 增加人手,这里是三班人手同时组装配件,然后通过 merge 函数这个可复用的组件将三个 channel 汇聚为一个,然后传给 pack 函数打包。
这样通过扇出和扇入模式,整条流水线就被扩充好了,大大提升了生产效率。因为已经有了通用的扇入组件 merge所以整条流水中任何需要扇出、扇入提高性能的工序都可以复用 merge 组件做扇入,并且不用做任何修改。
Futures 模式
Pipeline 流水线模式中的工序是相互依赖的,上一道工序做完,下一道工序才能开始。但是在我们的实际需求中,也有大量的任务之间相互独立、没有依赖,所以为了提高性能,这些独立的任务就可以并发执行。
举个例子,比如我打算自己做顿火锅吃,那么就需要洗菜、烧水。洗菜、烧水这两个步骤相互之间没有依赖关系,是独立的,那么就可以同时做,但是最后做火锅这个步骤就需要洗好菜、烧好水之后才能进行。这个做火锅的场景就适用 Futures 模式。
Futures 模式可以理解为未来模式,主协程不用等待子协程返回的结果,可以先去做其他事情,等未来需要子协程结果的时候再来取,如果子协程还没有返回结果,就一直等待。我用下面的代码进行演示:
ch11/main.go
//洗菜
func washVegetables() <-chan string {
vegetables := make(chan string)
go func() {
time.Sleep(5 * time.Second)
vegetables <- "洗好的菜"
}()
return vegetables
}
//烧水
func boilWater() <-chan string {
water := make(chan string)
go func() {
time.Sleep(5 * time.Second)
water <- "烧开的水"
}()
return water
}
洗菜和烧水这两个相互独立的任务可以一起做,所以示例中通过开启协程的方式,实现同时做的功能。当任务完成后,结果会通过 channel 返回。
小提示:示例中的等待 5 秒用来描述洗菜和烧火的耗时。
在启动两个子协程同时去洗菜和烧水的时候,主协程就可以去干点其他事情(示例中是眯一会),等睡醒了,要做火锅的时候,就需要洗好的菜和烧好的水这两个结果了。我用下面的代码进行演示:
ch11/main.go
func main() {
vegetablesCh := washVegetables() //洗菜
waterCh := boilWater() //烧水
fmt.Println("已经安排洗菜和烧水了,我先眯一会")
time.Sleep(2 * time.Second)
fmt.Println("要做火锅了,看看菜和水好了吗")
vegetables := <-vegetablesCh
water := <-waterCh
fmt.Println("准备好了,可以做火锅了:",vegetables,water)
}
Futures 模式下的协程和普通协程最大的区别是可以返回结果,而这个结果会在未来的某个时间点使用。所以在未来获取这个结果的操作必须是一个阻塞的操作,要一直等到获取结果为止。
如果你的大任务可以拆解为一个个独立并发执行的小任务,并且可以通过这些小任务的结果得出最终大任务的结果,就可以使用 Futures 模式。
总结
并发模式和设计模式很相似,都是对现实场景的抽象封装,以便提供一个统一的解决方案。但和设计模式不同的是,并发模式更专注于异步和并发。

View File

@ -0,0 +1,221 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 指针详解:在什么情况下应该使用指针?
这节课起我将带你学习本专栏的第三模块:深入理解 Go 语言。这部分主要会为你讲解 Go 语言的高级特性,以及 Go 语言一些特性功能的底层原理。通过这部分的学习,你不光可以更好地使用 Go 语言,还会更深入地理解 Go 语言,比如理解你所使用的 slice 底层是如何实现的等。
什么是指针
我们都知道程序运行时的数据是存放在内存中的,而内存会被抽象为一系列具有连续编号的存储空间,那么每一个存储在内存中的数据都会有一个编号,这个编号就是内存地址。有了这个内存地址就可以找到这个内存中存储的数据,而内存地址可以被赋值给一个指针。
小提示:内存地址通常为 16 进制的数字表示,比如 0x45b876。
可以总结为:在编程语言中,指针是一种数据类型,用来存储一个内存地址,该地址指向存储在该内存中的对象。这个对象可以是字符串、整数、函数或者你自定义的结构体。
小技巧:你也可以简单地把指针理解为内存地址。
举个通俗的例子,每本书中都有目录,目录上会有相应章节的页码,你可以把页码理解为一系列的内存地址,通过页码你可以快速地定位到具体的章节(也就是说,通过内存地址可以快速地找到存储的数据)。
指针的声明和定义
在 Go 语言中,获取一个变量的指针非常容易,使用取地址符 & 就可以,比如下面的例子:
ch12/main.go
func main() {
name:="飞雪无情"
nameP:=&name//取地址
fmt.Println("name变量的值为:",name)
fmt.Println("name变量的内存地址为:",nameP)
}
我在示例中定义了一个 string 类型的变量 name它的值为”飞雪无情”然后通过取地址符 & 获取变量 name 的内存地址,并赋值给指针变量 nameP该指针变量的类型为 *string。运行以上示例你可以看到如下打印结果
name变量的值为: 飞雪无情
name变量的内存地址为: 0xc000010200
这一串 0xc000010200 就是内存地址,这个内存地址可以赋值给指针变量 nameP。
指针类型非常廉价,只占用 4 个或者 8 个字节的内存大小。
以上示例中 nameP 指针的类型是 *string用于指向 string 类型的数据。在 Go 语言中使用类型名称前加 * 的方式,即可表示一个对应的指针类型。比如 int 类型的指针类型是 *intfloat64 类型的指针类型是 *float64自定义结构体 A 的指针类型是 *A。总之指针类型就是在对应的类型前加 * 号。
下面我通过一个图让你更好地理解普通类型变量、指针类型变量、内存地址、内存等之间的关系。
(指针变量、内存地址指向示意图)
上图就是我刚举的例子所对应的示意图,从图中可以看到普通变量 name 的值“飞雪无情”被放到内存地址为 0xc000010200 的内存块中。指针类型变量也是变量,它也需要一块内存用来存储值,这块内存对应的地址就是 0xc00000e028存储的值是 0xc000010200。相信你已经看到关键点了指针变量 nameP 的值正好是普通变量 name 的内存地址,所以就建立指向关系。
小提示:指针变量的值就是它所指向数据的内存地址,普通变量的值就是我们具体存放的数据。
不同的指针类型是无法相互赋值的,比如你不能对一个 string 类型的变量取地址然后赋值给 *int指针类型编译器会提示你 Cannot use &name (type *string) as type *int in assignment。
此外,除了可以通过简短声明的方式声明一个指针类型的变量外,也可以使用 var 关键字声明,如下面示例中的 var intP *int 就声明了一个 *int 类型的变量 intP。
var intP *int
intP = &name //指针类型不同,无法赋值
可以看到指针变量也和普通的变量一样,既可以通过 var 关键字定义,也可以通过简短声明定义。
小提示:通过 var 声明的指针变量是不能直接赋值和取值的,因为这时候它仅仅是个变量,还没有对应的内存地址,它的值是 nil。
和普通类型不一样的是,指针类型还可以通过内置的 new 函数来声明,如下所示:
intP1:=new(int)
内置的 new 函数有一个参数,可以传递类型给它。它会返回对应的指针类型,比如上述示例中会返回一个 *int 类型的 intP1。
指针的操作
在 Go 语言中指针的操作无非是两种:一种是获取指针指向的值,一种是修改指针指向的值。
首先介绍如何获取,我用下面的代码进行演示:
nameV:=*nameP
fmt.Println("nameP指针指向的值为:",nameV)
可以看到,要获取指针指向的值,只需要在指针变量前加 * 号即可,获得的变量 nameV 的值就是“飞雪无情”,方法比较简单。
修改指针指向的值也非常简单,比如下面的例子:
*nameP = "公众号:飞雪无情" //修改指针指向的值
fmt.Println("nameP指针指向的值为:",*nameP)
fmt.Println("name变量的值为:",name)
对 *nameP 赋值等于修改了指针 nameP 指向的值。运行程序你将看到如下打印输出:
nameP指针指向的值为: 公众号:飞雪无情
name变量的值为: 公众号:飞雪无情
通过打印结果可以看到,不光 nameP 指针指向的值被改变了,变量 name 的值也被改变了,这就是指针的作用。因为变量 name 存储数据的内存就是指针 nameP 指向的内存,这块内存被 nameP 修改后,变量 name 的值也被修改了。
我们已经知道,通过 var 关键字直接定义的指针变量是不能进行赋值操作的,因为它的值为 nil也就是还没有指向的内存地址。比如下面的示例
var intP *int
*intP =10
运行的时候会提示 invalid memory address or nil pointer dereference。这时候该怎么办呢其实只需要通过 new 函数给它分配一块内存就可以了,如下所示:
var intP *int = new(int)
//更推荐简短声明法,这里是为了演示
//intP:=new(int)
指针参数
假如有一个函数 modifyAge想要用来修改年龄如下面的代码所示。但运行它你会看到 age 的值并没有被修改,还是 18并没有变成 20。
age:=18
modifyAge(age)
fmt.Println("age的值为:",age)
func modifyAge(age int) {
age = 20
}
导致这种结果的原因是 modifyAge 中的 age 只是实参 age 的一份拷贝,所以修改它不会改变实参 age 的值。
如果要达到修改年龄的目的,就需要使用指针,现在对刚刚的示例进行改造,如下所示:
age:=18
modifyAge(&age)
fmt.Println("age的值为:",age)
func modifyAge(age *int) {
*age = 20
}
也就是说,当你需要在函数中通过形参改变实参的值时,需要使用指针类型的参数。
指针接收者
指针的接收者在[“第 6 讲| struct 和 interface结构体与接口都实现了哪些功能”]中有详细介绍,你可以再复习一下。对于是否使用指针类型作为接收者,有以下几点参考:
如果接收者类型是 map、slice、channel 这类引用类型,不使用指针;
如果需要修改接收者,那么需要使用指针;
如果接收者是比较大的类型,可以考虑使用指针,因为内存拷贝廉价,所以效率高。
所以对于是否使用指针类型作为接收者,还需要你根据实际情况考虑。
什么情况下使用指针
从以上指针的详细分析中,我们可以总结出指针的两大好处:
可以修改指向数据的值;
在变量赋值,参数传值的时候可以节省内存。
不过 Go 语言作为一种高级语言,在指针的使用上还是比较克制的。它在设计的时候就对指针进行了诸多限制,比如指针不能进行运行,也不能获取常量的指针。所以在思考是否使用时,我们也要保持克制的心态。
我根据实战经验总结了以下几点使用指针的建议,供你参考:
不要对 map、slice、channel 这类引用类型使用指针;
如果需要修改方法接收者内部的数据或者状态时,需要使用指针;
如果需要修改参数的值或者内部数据时,也需要使用指针类型的参数;
如果是比较大的结构体,每次参数传递或者调用方法都要内存拷贝,内存占用多,这时候可以考虑使用指针;
像 int、bool 这样的小数据类型没必要使用指针;
如果需要并发安全,则尽可能地不要使用指针,使用指针一定要保证并发安全;
指针最好不要嵌套,也就是不要使用一个指向指针的指针,虽然 Go 语言允许这么做,但是这会使你的代码变得异常复杂。
总结
为了使编程变得更简单,指针在高级的语言中被逐渐淡化,但是它也的确有自己的优势:修改数据的值和节省内存。所以在 Go 语言的开发中我们要尽可能地使用值类型,而不是指针类型,因为值类型可以使你的开发变得更简单,并且也是并发安全的。如果你想使用指针类型,就要参考我上面讲到的使用指针的条件,看是否满足,要在满足和必须的情况下才使用指针。
这节课到这里就要结束了,在这节课的最后同样给你留个思考题:指向接口的指针是否实现了该接口?为什么?思考后可以自己写代码验证下。

View File

@ -0,0 +1,320 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 参数传递:值、引用及指针之间的区别?
上节课我留了一个思考题,关于指向接口的指针的思考。在[“第 6 讲| struct 和 interface结构体与接口都实现了哪些功能”]中,你已经知道了如何实现一个接口,并且也知道如果值接收者实现了接口,那么值的指针也就实现了该接口。现在我们再一起来复习一下接口实现的知识,然后再解答关于指向接口的指针的思考题。
在下面的代码中,值类型 address 作为接收者实现了接口 fmt.Stringer那么它的指针类型 *address 也就实现了接口 fmt.Stringer。
ch13/main.go
type address struct {
province string
city string
}
func (addr address) String() string{
return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}
在下面的代码示例中,我定义了值类型的变量 add然后把它和它的指针 &add 都作为参数传给函数 printString发现都是可以的并且代码可以成功运行。这也证明了当值类型作为接收者实现了某接口时它的指针类型也同样实现了该接口。
ch13/main.go
func main() {
add := address{province: "北京", city: "北京"}
printString(add)
printString(&add)
}
func printString(s fmt.Stringer) {
fmt.Println(s.String())
}
基于以上结论,我们继续分析,看是否可以定义一个指向接口的指针。如下所示:
ch13/main.go
var si fmt.Stringer =address{province: "上海",city: "上海"}
printString(si)
sip:=&si
printString(sip)
在这个示例中,因为类型 address 已经实现了接口 fmt.Stringer所以它的值可以被赋予变量 si而且 si 也可以作为参数传递给函数 printString。
接着你可以使用 sip:=&si 这样的操作获得一个指向接口的指针,这是没有问题的。不过最终你无法把指向接口的指针 sip 作为参数传递给函数 printStringGo 语言的编译器会提示你如下错误信息:
./main.go:42:13: cannot use sip (type *fmt.Stringer) as type fmt.Stringer in argument to printString:
*fmt.Stringer is pointer to interface, not interface
于是可以总结为:虽然指向具体类型的指针可以实现一个接口,但是指向接口的指针永远不可能实现该接口。
所以你几乎从不需要一个指向接口的指针,把它忘掉吧,不要让它在你的代码中出现。
通过这个思考题,相信你也对 Go 语言的值类型、引用类型和指针等概念有了一定的了解,但可能也存在一些迷惑。这节课我将更深入地分析这些概念。
修改参数
假设你定义了一个函数,并在函数里对参数进行修改,想让调用者可以通过参数获取你最新修改的值。我仍然以前面课程用到的 person 结构体举例,如下所示:
ch13/main.go
func main() {
p:=person{name: "张三",age: 18}
modifyPerson(p)
fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p person) {
p.name = "李四"
p.age = 20
}
type person struct {
name string
age int
}
在这个示例中,我期望通过 modifyPerson 函数把参数 p 中的 name 修改为李四,把 age 修改为 20 。代码没有错误,但是运行一下,你会看到如下打印输出:
person name: 张三 ,age: 18
怎么还是张三与 18 呢?我换成指针参数试试,因为在上节课中我们已经知道可以通过指针修改指向的对象数据,如下所示:
modifyPerson(&p)
func modifyPerson(p *person) {
p.name = "李四"
p.age = 20
}
这些代码用于满足指针参数的修改,把接收的参数改为指针参数,以及在调用 modifyPerson 函数时,通过&取地址符传递一个指针。现在再运行程序,就可以看到期望的输出了,如下所示:
person name: 李四 ,age: 20
值类型
在上面的小节中,我定义的普通变量 p 是 person 类型的。在 Go 语言中person 是一个值类型,而 &p 获取的指针是 *person 类型的,即指针类型。那么为什么值类型在参数传递中无法修改呢?这也要从内存讲起。
在上节课中,我们已经知道变量的值是存储在内存中的,而内存都有一个编号,称为内存地址。所以要想修改内存中的数据,就要找到这个内存地址。现在,我来对比值类型变量在函数内外的内存地址,如下所示:
ch13/main.go
func main() {
p:=person{name: "张三",age: 18}
fmt.Printf("main函数p的内存地址为%p\n",&p)
modifyPerson(p)
fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p person) {
fmt.Printf("modifyPerson函数p的内存地址为%p\n",&p)
p.name = "李四"
p.age = 20
}
其中,我把原来的示例代码做了更改,分别打印出在 main 函数中变量 p 的内存地址,以及在 modifyPerson 函数中参数 p 的内存地址。运行以上程序,可以看到如下结果:
main函数p的内存地址为0xc0000a6020
modifyPerson函数p的内存地址为0xc0000a6040
person name: 张三 ,age: 18
你会发现它们的内存地址都不一样,这就意味着,在 modifyPerson 函数中修改的参数 p 和 main 函数中的变量 p 不是同一个,这也是我们在 modifyPerson 函数中修改参数 p但是在 main 函数中打印后发现并没有修改的原因。
导致这种结果的原因是 Go 语言中的函数传参都是值传递。 值传递指的是传递原来数据的一份拷贝,而不是原来的数据本身。
main 函数调用 modifyPerson 函数传参内存示意图)
以 modifyPerson 函数来说,在调用 modifyPerson 函数传递变量 p 的时候Go 语言会拷贝一个 p 放在一个新的内存中,这样新的 p 的内存地址就和原来不一样了,但是里面的 name 和 age 是一样的,还是张三和 18。这就是副本的意思变量里的数据一样但是存放的内存地址不一样。
除了 struct 外,还有浮点型、整型、字符串、布尔、数组,这些都是值类型。
指针类型
指针类型的变量保存的值就是数据对应的内存地址,所以在函数参数传递是传值的原则下,拷贝的值也是内存地址。现在对以上示例稍做修改,修改后的代码如下:
func main() {
p:=person{name: "张三",age: 18}
fmt.Printf("main函数p的内存地址为%p\n",&p
modifyPerson(&p)
fmt.Println("person name:",p.name,",age:",p.age)
}
func modifyPerson(p *person) {
fmt.Printf("modifyPerson函数p的内存地址为%p\n",p)
p.name = "李四"
p.age = 20
}
运行这个示例,你会发现打印出的内存地址一致,并且数据也被修改成功了,如下所示:
main函数p的内存地址为0xc0000a6020
modifyPerson函数p的内存地址为0xc0000a6020
person name: 李四 ,age: 20
所以指针类型的参数是永远可以修改原数据的,因为在参数传递时,传递的是内存地址。
小提示:值传递的是指针,也是内存地址。通过内存地址可以找到原数据的那块内存,所以修改它也就等于修改了原数据。
引用类型
下面要介绍的是引用类型,包括 map 和 chan。
map
对于上面的例子,假如我不使用自定义的 person 结构体和指针,能不能用 map 达到修改的目的呢?
下面我来试验一下,如下所示:
ch13/main.go
func main() {
m:=make(map[string]int)
m["飞雪无情"] = 18
fmt.Println("飞雪无情的年龄为",m["飞雪无情"])
modifyMap(m)
fmt.Println("飞雪无情的年龄为",m["飞雪无情"])
}
func modifyMap(p map[string]int) {
p["飞雪无情"] =20
}
我定义了一个 map[string]int 类型的变量 m存储一个 Key 为飞雪无情、Value 为 18 的键值对,然后把这个变量 m 传递给函数 modifyMap。modifyMap 函数所做的事情就是把对应的值修改为 20。现在运行这段代码通过打印输出来看是否修改成功结果如下所示
飞雪无情的年龄为 18
飞雪无情的年龄为 20
确实修改成功了。你是不是有不少疑惑?没有使用指针,只是用了 map 类型的参数,按照 Go 语言值传递的原则modifyMap 函数中的 map 是一个副本,怎么会修改成功呢?
要想解答这个问题,就要从 make 这个 Go 语言内建的函数说起。在 Go 语言中,任何创建 map 的代码(不管是字面量还是 make 函数)最终调用的都是 runtime.makemap 函数。
小提示:用字面量或者 make 函数的方式创建 map并转换成 makemap 函数的调用,这个转换是 Go 语言编译器自动帮我们做的。
从下面的代码可以看到makemap 函数返回的是一个 *hmap 类型,也就是说返回的是一个指针,所以我们创建的 map 其实就是一个 *hmap。
src/runtime/map.go
// makemap implements Go map creation for make(map[k]v, hint).
func makemap(t *maptype, hint int, h *hmap) *hmap{
//省略无关代码
}
因为 Go 语言的 map 类型本质上就是 *hmap所以根据替换的原则我刚刚定义的 modifyMap(p map) 函数其实就是 modifyMap(p *hmap)。这是不是和上一小节讲的指针类型的参数调用一样了?这也是通过 map 类型的参数可以修改原始数据的原因,因为它本质上就是个指针。
为了进一步验证创建的 map 就是一个指针,我修改上述示例,打印 map 类型的变量和参数对应的内存地址,如下面的代码所示:
func main(){
//省略其他没有修改的代码
fmt.Printf("main函数m的内存地址为%p\n",m)
}
func modifyMap(p map[string]int) {
fmt.Printf("modifyMap函数p的内存地址为%p\n",p)
//省略其他没有修改的代码
}
例子中的两句打印代码是新增的,其他代码没有修改,这里就不再贴出来了。运行修改后的程序,你可以看到如下输出:
飞雪无情的年龄为 18
main函数m的内存地址为0xc000060180
modifyMap函数p的内存地址为0xc000060180
飞雪无情的年龄为 20
从输出结果可以看到,它们的内存地址一模一样,所以才可以修改原始数据,得到年龄是 20 的结果。而且我在打印指针的时候,直接使用的是变量 m 和 p并没有用到取地址符 &,这是因为它们本来就是指针,所以就没有必要再使用 & 取地址了。
所以在这里Go 语言通过 make 函数或字面量的包装为我们省去了指针的操作,让我们可以更容易地使用 map。其实就是语法糖这是编程界的老传统了。
注意:这里的 map 可以理解为引用类型,但是它本质上是个指针,只是可以叫作引用类型而已。在参数传递时,它还是值传递,并不是其他编程语言中所谓的引用传递。
chan
还记得我们在 Go 语言并发模块中学的 channel 吗?它也可以理解为引用类型,而它本质上也是个指针。
通过下面的源代码可以看到,所创建的 chan 其实是个 *hchan所以它在参数传递中也和 map 一样。
func makechan(t *chantype, size int64) *hchan {
//省略无关代码
}
严格来说Go 语言没有引用类型,但是我们可以把 map、chan 称为引用类型,这样便于理解。除了 map、chan 之外Go 语言中的函数、接口、slice 切片都可以称为引用类型。
小提示:指针类型也可以理解为是一种引用类型。
类型的零值
在 Go 语言中,定义变量要么通过声明、要么通过 make 和 new 函数,不一样的是 make 和 new 函数属于显式声明并初始化。如果我们声明的变量没有显式声明初始化,那么该变量的默认值就是对应类型的零值。
从下面的表格可以看到,可以称为引用类型的零值都是 nil。
(各种类型的零值)
总结
在 Go 语言中,函数的参数传递只有值传递,而且传递的实参都是原始数据的一份拷贝。如果拷贝的内容是值类型的,那么在函数中就无法修改原始数据;如果拷贝的内容是指针(或者可以理解为引用类型 map、chan 等),那么就可以在函数中修改原始数据。
所以我们在创建一个函数的时候,要根据自己的真实需求决定参数的类型,以便更好地服务于我们的业务。
这节课中,我讲解 chan 的时候没有举例,你自己可以自定义一个有 chan 参数的函数,作为练习题。
下节课我将介绍“内存分配new 还是 make什么情况下该用谁”记得来听课

View File

@ -0,0 +1,316 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 内存分配new 还是 make什么情况下该用谁
程序的运行都需要内存,比如像变量的创建、函数的调用、数据的计算等。所以在需要内存的时候就要申请内存,进行内存分配。在 C/C++ 这类语言中,内存是由开发者自己管理的,需要主动申请和释放,而在 Go 语言中则是由该语言自己管理的开发者不用做太多干涉只需要声明变量Go 语言就会根据变量的类型自动分配相应的内存。
Go 语言程序所管理的虚拟内存空间会被分为两部分:堆内存和栈内存。栈内存主要由 Go 语言来管理,开发者无法干涉太多,堆内存才是我们开发者发挥能力的舞台,因为程序的数据大部分分配在堆内存上,一个程序的大部分内存占用也是在堆内存上。
小提示:我们常说的 Go 语言的内存垃圾回收是针对堆内存的垃圾回收。
变量的声明、初始化就涉及内存的分配,比如声明变量会用到 var 关键字,如果要对变量初始化,就会用到 = 赋值运算符。除此之外还可以使用内置函数 new 和 make这两个函数你在前面的课程中已经见过它们的功能非常相似但你可能还是比较迷惑所以这节课我会基于内存分配进而引出内置函数 new 和 make为你讲解他们的不同以及使用场景。
变量
一个数据类型,在声明初始化后都会赋值给一个变量,变量存储了程序运行所需的数据。
变量的声明
和前面课程讲的一样,如果要单纯声明一个变量,可以通过 var 关键字,如下所示:
var s string
该示例只是声明了一个变量 s类型为 string并没有对它进行初始化所以它的值为 string 的零值,也就是 ““(空字符串)。
上节课你已经知道 string 其实是个值类型,现在我们来声明一个指针类型的变量试试,如下所示:
var sp *string
发现也是可以的,但是它同样没有被初始化,所以它的值是 *string 类型的零值,也就是 nil。
变量的赋值
变量可以通过 = 运算符赋值,也就是修改变量的值。如果在声明一个变量的时候就给这个变量赋值,这种操作就称为变量的初始化。如果要对一个变量初始化,可以有三种办法。
声明时直接初始化,比如 var s string = “飞雪无情”。
声明后再进行初始化,比如 s=“飞雪无情”(假设已经声明变量 s
使用 := 简单声明,比如 s:=“飞雪无情”。
小提示:变量的初始化也是一种赋值,只不过它发生在变量声明的时候,时机最靠前。也就是说,当你获得这个变量时,它就已经被赋值了。
现在我们就对上面示例中的变量 s 进行赋值,示例代码如下:
ch14/main.go
func main() {
var s string
s = "张三"
fmt.Println(s)
}
运行以上代码,可以正常打印出张三,说明值类型的变量没有初始化时,直接赋值是没有问题的。那么对于指针类型的变量呢?
在下面的示例代码中,我声明了一个指针类型的变量 sp然后把该变量的值修改为“飞雪无情”。
ch14/main.go
func main() {
var sp *string
*sp = "飞雪无情"
fmt.Println(*sp)
}
运行这些代码,你会看到如下错误信息:
panic: runtime error: invalid memory address or nil pointer dereference
这是因为指针类型的变量如果没有分配内存,就默认是零值 nil它没有指向的内存所以无法使用强行使用就会得到以上 nil 指针错误。
而对于值类型来说,即使只声明一个变量,没有对其初始化,该变量也会有分配好的内存。
在下面的示例中,我声明了一个变量 s并没有对其初始化但是可以通过 &s 获取它的内存地址。这其实是 Go 语言帮我们做的,可以直接使用。
func main() {
var s string
fmt.Printf("%p\n",&s)
}
还记得我们在讲并发的时候,使用 var wg sync.WaitGroup 声明的变量 wg 吗?现在你应该知道为什么不进行初始化也可以直接使用了吧?因为 sync.WaitGroup 是一个 struct 结构体是一个值类型Go 语言自动分配了内存,所以可以直接使用,不会报 nil 异常。
于是可以得到结论:如果要对一个变量赋值,这个变量必须有对应的分配好的内存,这样才可以对这块内存操作,完成赋值的目的。
小提示:其实不止赋值操作,对于指针变量,如果没有分配内存,取值操作一样会报 nil 异常,因为没有可以操作的内存。
所以一个变量必须要经过声明、内存分配才能赋值才可以在声明的时候进行初始化。指针类型在声明的时候Go 语言并没有自动分配内存,所以不能对其进行赋值操作,这和值类型不一样。
小提示map 和 chan 也一样,因为它们本质上也是指针类型。
new 函数
既然我们已经知道了声明的指针变量默认是没有分配内存的,那么给它分配一块就可以了。于是就需要今天的主角之一 new 函数出场了,对于上面的例子,可以使用 new 函数进行如下改造:
ch14/main.go
func main() {
var sp *string
sp = new(string)//关键点
*sp = "飞雪无情"
fmt.Println(*sp)
}
以上代码的关键点在于通过内置的 new 函数生成了一个 *string并赋值给了变量 sp。现在再运行程序就正常了。
内置函数 new 的作用是什么呢?可以通过它的源代码定义分析,如下所示:
// The new built-in function allocates memory. The first argument is a type,
// not a value, and the value returned is a pointer to a newly
// allocated zero value of that type.
func new(Type) *Type
它的作用就是根据传入的类型申请一块内存,然后返回指向这块内存的指针,指针指向的数据就是该类型的零值。
比如传入的类型是 string那么返回的就是 string 指针,这个 string 指针指向的数据就是空字符串,如下所示:
sp1 = new(string)
fmt.Println(*sp1)//打印空字符串,也就是string的零值。
通过 new 函数分配内存并返回指向该内存的指针后,就可以通过该指针对这块内存进行赋值、取值等操作。
变量初始化
当声明了一些类型的变量时,这些变量的零值并不能满足我们的要求,这时就需要在变量声明的同时进行赋值(修改变量的值),这个过程称为变量的初始化。
下面的示例就是 string 类型的变量初始化,因为它的零值(空字符串)不能满足需要,所以需要在声明的时候就初始化为“飞雪无情”。
var s string = "飞雪无情"
s1:="飞雪无情"
不止基础类型可以通过以上这种字面量的方式进行初始化,复合类型也可以,比如之前课程示例中的 person 结构体,如下所示:
type person struct {
name string
age int
}
func main() {
//字面量初始化
p:=person{name: "张三",age: 18}
}
该示例代码就是在声明这个 p 变量的时候,把它的 name 初始化为张三age 初始化为 18。
指针变量初始化
在上个小节中,你已经知道了 new 函数可以申请内存并返回一个指向该内存的指针,但是这块内存中数据的值默认是该类型的零值,在一些情况下并不满足业务需求。比如我想得到一个 *person 类型的指针,并且它的 name 是飞雪无情、age 是 20但是 new 函数只有一个类型参数,并没有初始化值的参数,此时该怎么办呢?
要达到这个目的,你可以自定义一个函数,对指针变量进行初始化,如下所示:
ch14/main.go
func NewPerson() *person{
p:=new(person)
p.name = "飞雪无情"
p.age = 20
return p
}
还记得前面课程讲的工厂函数吗?这个代码示例中的 NewPerson 函数就是工厂函数,除了使用 new 函数创建一个 person 指针外,还对它进行了赋值,也就是初始化。这样 NewPerson 函数的使用者就会得到一个 name 为飞雪无情、age 为 20 的 *person 类型的指针,通过 NewPerson 函数做一层包装把内存分配new 函数)和初始化(赋值)都完成了。
下面的代码就是使用 NewPerson 函数的示例,它通过打印 *pp 指向的数据值,来验证 name 是否是飞雪无情age 是否是 20。
pp:=NewPerson()
fmt.Println("name为",pp.name,",age为",pp.age)
为了让自定义的工厂函数 NewPerson 更加通用,我把它改造一下,让它可以接受 name 和 age 参数,如下所示:
ch14/main.go
pp:=NewPerson("飞雪无情",20)
func NewPerson(name string,age int) *person{
p:=new(person)
p.name = name
p.age = age
return p
}
这些代码的效果和刚刚的示例一样,但是 NewPerson 函数更通用,因为你可以传递不同的参数,构建出不同的 *person 变量。
make 函数
铺垫了这么多,终于讲到今天的第二个主角 make 函数了。在上节课中你已经知道,在使用 make 函数创建 map 的时候,其实调用的是 makemap 函数,如下所示:
src/runtime/map.go
// makemap implements Go map creation for make(map[k]v, hint).
func makemap(t *maptype, hint int, h *hmap) *hmap{
//省略无关代码
}
makemap 函数返回的是 *hmap 类型,而 hmap 是一个结构体,它的定义如下面的代码所示:
src/runtime/map.go
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
可以看到,我们平时使用的 map 关键字其实非常复杂,它包含 map 的大小 count、存储桶 buckets 等。要想使用这样的 hmap不是简单地通过 new 函数返回一个 *hmap 就可以,还需要对其进行初始化,这就是 make 函数要做的事情,如下所示:
m:=make(map[string]int,10)
是不是发现 make 函数和上一小节中自定义的 NewPerson 函数很像?其实 make 函数就是 map 类型的工厂函数,它可以根据传递它的 K-V 键值对类型,创建不同类型的 map同时可以初始化 map 的大小。
小提示make 函数不只是 map 类型的工厂函数,还是 chan、slice 的工厂函数。它同时可以用于 slice、chan 和 map 这三种类型的初始化。
总结
通过这节课的讲解,相信你已经理解了函数 new 和 make 的区别,现在我再来总结一下。
new 函数只用于分配内存并且把内存清零也就是返回一个指向对应类型零值的指针。new 函数一般用于需要显式地返回指针的情况,不是太常用。
make 函数只用于 slice、chan 和 map 这三种内置类型的创建和初始化,因为这三种类型的结构比较复杂,比如 slice 要提前初始化好内部元素的类型slice 的长度和容量等,这样才可以更好地使用它们。
在这节课的最后,给你留一个练习题:使用 make 函数创建 slice并且使用不同的长度和容量作为参数看看它们的效果。
下节课我将介绍“运行时反射:字符串和结构体之间如何转换?”记得来听课!

View File

@ -0,0 +1,697 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 运行时反射:字符串和结构体之间如何转换?
我们在开发中会接触很多字符串和结构体之间的转换,尤其是在调用 API 的时候,你需要把 API 返回的 JSON 字符串转换为 struct 结构体,便于操作。那么一个 JSON 字符串是如何转换为 struct 结构体的呢?这就需要用到反射的知识,这节课我会基于字符串和结构体之间的转换,一步步地为你揭开 Go 语言运行时反射的面纱。
反射是什么?
和 Java 语言一样Go 语言也有运行时反射,这为我们提供了一种可以在运行时操作任意类型对象的能力。比如查看一个接口变量的具体类型、看看一个结构体有多少字段、修改某个字段的值等。
Go 语言是静态编译类语言比如在定义一个变量的时候已经知道了它是什么类型那么为什么还需要反射呢这是因为有些事情只有在运行时才知道。比如你定义了一个函数它有一个interface{}类型的参数,这也就意味着调用者可以传递任何类型的参数给这个函数。在这种情况下,如果你想知道调用者传递的是什么类型的参数,就需要用到反射。如果你想知道一个结构体有哪些字段和方法,也需要反射。
还是以我常用的函数 fmt.Println 为例,如下所示:
src/fmt/print.go
func Println(a ...interface{}) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
例子中 fmt.Println 的源代码有一个可变参数,类型为 interface{},这意味着你可以传递零个或者多个任意类型参数给它,都能被正确打印。
reflect.Value 和 reflect.Type
在 Go 语言的反射定义中,任何接口都由两部分组成:接口的具体类型,以及具体类型对应的值。比如 var i int = 3因为 interface{} 可以表示任何类型,所以变量 i 可以转为 interface{}。你可以把变量 i 当成一个接口,那么这个变量在 Go 反射中的表示就是 。其中 Value 为变量的值,即 3而 Type 为变量的类型,即 int。
小提示interface{} 是空接口,可以表示任何类型,也就是说你可以把任何类型转换为空接口,它通常用于反射、类型断言,以减少重复代码,简化编程。
在 Go 反射中,标准库为我们提供了两种类型 reflect.Value 和 reflect.Type 来分别表示变量的值和类型,并且提供了两个函数 reflect.ValueOf 和 reflect.TypeOf 分别获取任意对象的 reflect.Value 和 reflect.Type。
我用下面的代码进行演示:
ch15/main.go
func main() {
i:=3
iv:=reflect.ValueOf(i)
it:=reflect.TypeOf(i)
fmt.Println(iv,it)//3 int
}
代码定义了一个 int 类型的变量 i它的值为 3然后通过 reflect.ValueOf 和 reflect.TypeOf 函数就可以获得变量 i 对应的 reflect.Value 和 reflect.Type。通过 fmt.Println 函数打印后,可以看到结果是 3 int这也可以证明 reflect.Value 表示的是变量的值reflect.Type 表示的是变量的类型。
reflect.Value
reflect.Value 可以通过函数 reflect.ValueOf 获得,下面我将为你介绍它的结构和用法。
结构体定义
在 Go 语言中reflect.Value 被定义为一个 struct 结构体,它的定义如下面的代码所示:
type Value struct {
typ *rtype
ptr unsafe.Pointer
flag
}
我们发现 reflect.Value 结构体的字段都是私有的,也就是说,我们只能使用 reflect.Value 的方法。现在看看它有哪些常用方法,如下所示:
//针对具体类型的系列方法
//以下是用于获取对应的值
Bool
Bytes
Complex
Float
Int
String
Uint
CanSet //是否可以修改对应的值
以下是用于修改对应的值
Set
SetBool
SetBytes
SetComplex
SetFloat
SetInt
SetString
Elem //获取指针指向的值,一般用于修改对应的值
//以下Field系列方法用于获取struct类型中的字段
Field
FieldByIndex
FieldByName
FieldByNameFunc
Interface //获取对应的原始类型
IsNil //值是否为nil
IsZero //值是否是零值
Kind //获取对应的类型类别比如Array、Slice、Map等
//获取对应的方法
Method
MethodByName
NumField //获取struct类型中字段的数量
NumMethod//类型上方法集的数量
Type//获取对应的reflect.Type
看着比较多,其实就三类:一类用于获取和修改对应的值;一类和 struct 类型的字段有关,用于获取对应的字段;一类和类型上的方法集有关,用于获取对应的方法。
下面我通过几个例子讲解如何使用它们。
获取原始类型
在上面的例子中,我通过 reflect.ValueOf 函数把任意类型的对象转为一个 reflect.Value而如果想逆向转回来也可以reflect.Value 为我们提供了 Inteface 方法,如下面的代码所示:
ch15/main.go
func main() {
i:=3
//int to reflect.Value
iv:=reflect.ValueOf(i)
//reflect.Value to int
i1:=iv.Interface().(int)
fmt.Println(i1)
}
这是 reflect.Value 和 int 类型互转,换成其他类型也可以。
修改对应的值
已经定义的变量可以通过反射在运行时修改,比如上面的示例 i=3修改为 4如下所示
ch15/main.go
func main() {
i:=3
ipv:=reflect.ValueOf(&i)
ipv.Elem().SetInt(4)
fmt.Println(i)
}
这样就通过反射修改了一个变量。因为 reflect.ValueOf 函数返回的是一份值的拷贝,所以我们要传入变量的指针才可以。 因为传递的是一个指针,所以需要调用 Elem 方法找到这个指针指向的值,这样才能修改。 最后我们就可以使用 SetInt 方法修改值了。
要修改一个变量的值,有几个关键点:传递指针(可寻址),通过 Elem 方法获取指向的值才可以保证值可以被修改reflect.Value 为我们提供了 CanSet 方法判断是否可以修改该变量。
那么如何修改 struct 结构体字段的值呢?参考变量的修改方式,可总结出以下步骤:
传递一个 struct 结构体的指针,获取对应的 reflect.Value
通过 Elem 方法获取指针指向的值;
通过 Field 方法获取要修改的字段;
通过 Set 系列方法修改成对应的值。
运行下面的代码,你会发现变量 p 中的 Name 字段已经被修改为张三了。
ch15/main.go
func main() {
p:=person{Name: "飞雪无情",Age: 20}
ppv:=reflect.ValueOf(&p)
ppv.Elem().Field(0).SetString("张三")
fmt.Println(p)
}
type person struct {
Name string
Age int
}
最后再来总结一下通过反射修改一个值的规则。
可被寻址,通俗地讲就是要向 reflect.ValueOf 函数传递一个指针作为参数。
如果要修改 struct 结构体字段值的话,该字段需要是可导出的,而不是私有的,也就是该字段的首字母为大写。
记得使用 Elem 方法获得指针指向的值,这样才能调用 Set 系列方法进行修改。
记住以上规则,你就可以在程序运行时通过反射修改一个变量或字段的值。
获取对应的底层类型
底层类型是什么意思呢?其实对应的主要是基础类型,比如接口、结构体、指针……因为我们可以通过 type 关键字声明很多新的类型。比如在上面的例子中,变量 p 的实际类型是 person但是 person 对应的底层类型是 struct 这个结构体类型,而 &p 对应的则是指针类型。我们来通过下面的代码进行验证:
ch15/main.go
func main() {
p:=person{Name: "飞雪无情",Age: 20}
ppv:=reflect.ValueOf(&p)
fmt.Println(ppv.Kind())
pv:=reflect.ValueOf(p)
fmt.Println(pv.Kind())
}
运行以上代码,可以看到如下打印输出:
ptr
struct
Kind 方法返回一个 Kind 类型的值,它是一个常量,有以下可供使用的值:
type Kind uint
const (
Invalid Kind = iota
Bool
Int
Int8
Int16
Int32
Int64
Uint
Uint8
Uint16
Uint32
Uint64
Uintptr
Float32
Float64
Complex64
Complex128
Array
Chan
Func
Interface
Map
Ptr
Slice
String
Struct
UnsafePointer
)
从以上源代码定义的 Kind 常量列表可以看到,已经包含了 Go 语言的所有底层类型。
reflect.Type
reflect.Value 可以用于与值有关的操作中,而如果是和变量类型本身有关的操作,则最好使用 reflect.Type比如要获取结构体对应的字段名称或方法。
要反射获取一个变量的 reflect.Type可以通过函数 reflect.TypeOf。
接口定义
和 reflect.Value 不同reflect.Type 是一个接口,而不是一个结构体,所以也只能使用它的方法。
以下是我列出来的 reflect.Type 接口常用的方法。从这个列表来看,大部分都和 reflect.Value 的方法功能相同。
type Type interface {
Implements(u Type) bool
AssignableTo(u Type) bool
ConvertibleTo(u Type) bool
Comparable() bool
//以下这些方法和Value结构体的功能相同
Kind() Kind
Method(int) Method
MethodByName(string) (Method, bool)
NumMethod() int
Elem() Type
Field(i int) StructField
FieldByIndex(index []int) StructField
FieldByName(name string) (StructField, bool)
FieldByNameFunc(match func(string) bool) (StructField, bool)
NumField() int
}
其中几个特有的方法如下:
Implements 方法用于判断是否实现了接口 u
AssignableTo 方法用于判断是否可以赋值给类型 u其实就是是否可以使用 =,即赋值运算符;
ConvertibleTo 方法用于判断是否可以转换成类型 u其实就是是否可以进行类型转换
Comparable 方法用于判断该类型是否是可比较的,其实就是是否可以使用关系运算符进行比较。
我同样会通过一些示例来讲解 reflect.Type 的使用。
遍历结构体的字段和方法
我还是采用上面示例中的 person 结构体进行演示,不过需要修改一下,为它增加一个方法 String如下所示
func (p person) String() string{
return fmt.Sprintf("Name is %s,Age is %d",p.Name,p.Age)
}
新增一个 String 方法,返回对应的字符串信息,这样 person 这个 struct 结构体也实现了 fmt.Stringer 接口。
你可以通过 NumField 方法获取结构体字段的数量,然后使用 for 循环,通过 Field 方法就可以遍历结构体的字段,并打印出字段名称。同理,遍历结构体的方法也是同样的思路,代码也类似,如下所示:
ch15/main.go
func main() {
p:=person{Name: "飞雪无情",Age: 20}
pt:=reflect.TypeOf(p)
//遍历person的字段
for i:=0;i<pt.NumField();i++{
fmt.Println("字段:",pt.Field(i).Name)
}
//遍历person的方法
for i:=0;i<pt.NumMethod();i++{
fmt.Println("方法:",pt.Method(i).Name)
}
}
运行这个代码,可以看到如下结果:
字段: Name
字段: Age
方法: String
这正好和我在结构体 person 中定义的一致,说明遍历成功。
小技巧:你可以通过 FieldByName 方法获取指定的字段,也可以通过 MethodByName 方法获取指定的方法,这在需要获取某个特定的字段或者方法时非常高效,而不是使用遍历。
是否实现某接口
通过 reflect.Type 还可以判断是否实现了某接口。我还是以 person 结构体为例,判断它是否实现了接口 fmt.Stringer 和 io.Writer如下面的代码所示
func main() {
p:=person{Name: "飞雪无情",Age: 20}
pt:=reflect.TypeOf(p)
stringerType:=reflect.TypeOf((*fmt.Stringer)(nil)).Elem()
writerType:=reflect.TypeOf((*io.Writer)(nil)).Elem()
fmt.Println("是否实现了fmt.Stringer",pt.Implements(stringerType))
fmt.Println("是否实现了io.Writer",pt.Implements(writerType))
}
小提示:尽可能通过类型断言的方式判断是否实现了某接口,而不是通过反射。
这个示例通过 Implements 方法来判断是否实现了 fmt.Stringer 和 io.Writer 接口,运行它,你可以看到如下结果:
是否实现了fmt.Stringer true
是否实现了io.Writer false
因为结构体 person 只实现了 fmt.Stringer 接口,没有实现 io.Writer 接口,所以和验证的结果一致。
字符串和结构体互转
在字符串和结构体互转的场景中,使用最多的就是 JSON 和 struct 互转。在这个小节中,我会用 JSON 和 struct 讲解 struct tag 这一功能的使用。
JSON 和 Struct 互转
Go 语言的标准库有一个 json 包,通过它可以把 JSON 字符串转为一个 struct 结构体,也可以把一个 struct 结构体转为一个 json 字符串。下面我还是以 person 这个结构体为例,讲解 JSON 和 struct 的相互转换。如下面的代码所示:
func main() {
p:=person{Name: "飞雪无情",Age: 20}
//struct to json
jsonB,err:=json.Marshal(p)
if err==nil {
fmt.Println(string(jsonB))
}
//json to struct
respJSON:="{\"Name\":\"李四\",\"Age\":40}"
json.Unmarshal([]byte(respJSON),&p)
fmt.Println(p)
}
这个示例是我使用 Go 语言提供的 json 标准包做的演示。通过 json.Marshal 函数,你可以把一个 struct 转为 JSON 字符串。通过 json.Unmarshal 函数,你可以把一个 JSON 字符串转为 struct。
运行以上代码,你会看到如下结果输出:
{"Name":"飞雪无情","Age":20}
Name is 李四,Age is 40
仔细观察以上打印出的 JSON 字符串,你会发现 JSON 字符串的 Key 和 struct 结构体的字段名称一样,比如示例中的 Name 和 Age。那么是否可以改变它们呢比如改成小写的 name 和 age并且字段的名称还是大写的 Name 和 Age。当然可以要达到这个目的就需要用到 struct tag 的功能了。
Struct Tag
顾名思义struct tag 是一个添加在 struct 字段上的标记,使用它进行辅助,可以完成一些额外的操作,比如 json 和 struct 互转。在上面的示例中,如果想把输出的 json 字符串的 Key 改为小写的 name 和 age可以通过为 struct 字段添加 tag 的方式,示例代码如下:
type person struct {
Name string `json:"name"`
Age int `json:"age"`
}
为 struct 字段添加 tag 的方法很简单,只需要在字段后面通过反引号把一个键值对包住即可,比如以上示例中的 json:"name"。其中冒号前的 json 是一个 Key可以通过这个 Key 获取冒号后对应的 name。
小提示json 作为 Key是 Go 语言自带的 json 包解析 JSON 的一种约定,它会通过 json 这个 Key 找到对应的值,用于 JSON 的 Key 值。
我们已经通过 struct tag 指定了可以使用 name 和 age 作为 json 的 Key代码就可以修改成如下所示
respJSON:="{\"name\":\"李四\",\"age\":40}"
没错JSON 字符串也可以使用小写的 name 和 age 了。现在再运行这段代码,你会看到如下结果:
{"name":"张三","age":20}
Name is 李四,Age is 40
输出的 JSON 字符串的 Key 是小写的 name 和 age并且小写的 name 和 age JSON 字符串也可以转为 person 结构体。
相信你已经发现struct tag 是整个 JSON 和 struct 互转的关键,这个 tag 就像是我们为 struct 字段起的别名,那么 json 包是如何获得这个 tag 的呢?这就需要反射了。我们来看下面的代码:
//遍历person字段中key为json的tag
for i:=0;i<pt.NumField();i++{
sf:=pt.Field(i)
fmt.Printf("字段%s上,json tag为%s\n",sf.Name,sf.Tag.Get("json"))
}
要想获得字段上的 tag就要先反射获得对应的字段我们可以通过 Field 方法做到。该方法返回一个 StructField 结构体,它有一个字段是 Tag存有字段的所有 tag。示例中要获得 Key 为 json 的 tag所以只需要调用 sf.Tag.Get(“json”) 即可。
结构体的字段可以有多个 tag用于不同的场景比如 json 转换、bson 转换、orm 解析等。如果有多个 tag要使用空格分隔。采用不同的 Key 可以获得不同的 tag如下面的代码所示
//遍历person字段中key为json、bson的tag
for i:=0;i<pt.NumField();i++{
sf:=pt.Field(i)
fmt.Printf("字段%s上,json tag为%s\n",sf.Name,sf.Tag.Get("json"))
fmt.Printf("字段%s上,bson tag为%s\n",sf.Name,sf.Tag.Get("bson"))
}
type person struct {
Name string `json:"name" bson:"b_name"`
Age int `json:"age" bson:"b_name"`
}
运行代码,你可以看到如下结果:
字段Name上,key为json的tag为name
字段Name上,key为bson的tag为b_name
字段Age上,key为json的tag为age
字段Age上,key为bson的tag为b_name
可以看到,通过不同的 Key使用 Get 方法就可以获得自定义的不同的 tag。
实现 Struct 转 JSON
相信你已经理解了什么是 struct tag下面我再通过一个 struct 转 json 的例子演示它的使用:
func main() {
p:=person{Name: "飞雪无情",Age: 20}
pv:=reflect.ValueOf(p)
pt:=reflect.TypeOf(p)
//自己实现的struct to json
jsonBuilder:=strings.Builder{}
jsonBuilder.WriteString("{")
num:=pt.NumField()
for i:=0;i<num;i++{
jsonTag:=pt.Field(i).Tag.Get("json") //获取json tag
jsonBuilder.WriteString("\""+jsonTag+"\"")
jsonBuilder.WriteString(":")
//获取字段的值
jsonBuilder.WriteString(fmt.Sprintf("\"%v\"",pv.Field(i)))
if i<num-1{
jsonBuilder.WriteString(",")
}
}
jsonBuilder.WriteString("}")
fmt.Println(jsonBuilder.String())//打印json字符串
}
这是一个比较简单的 struct 转 json 示例,但是已经可以很好地演示 struct 的使用。在上述示例中,自定义的 jsonBuilder 负责 json 字符串的拼接,通过 for 循环把每一个字段拼接成 json 字符串。运行以上代码,你可以看到如下打印结果:
{"name":"飞雪无情","age":"20"}
json 字符串的转换只是 struct tag 的一个应用场景,你完全可以把 struct tag 当成结构体中字段的元数据配置,使用它来做想做的任何事情,比如 orm 映射、xml 转换、生成 swagger 文档等。
反射定律
反射是计算机语言中程序检视其自身结构的一种方法它属于元编程的一种形式。反射灵活、强大但也存在不安全。它可以绕过编译器的很多静态检查如果过多使用便会造成混乱。为了帮助开发者更好地理解反射Go 语言的作者在博客上总结了反射的三大定律。
任何接口值 interface{} 都可以反射出反射对象,也就是 reflect.Value 和 reflect.Type通过函数 reflect.ValueOf 和 reflect.TypeOf 获得。
反射对象也可以还原为 interface{} 变量,也就是第 1 条定律的可逆性,通过 reflect.Value 结构体的 Interface 方法获得。
要修改反射的对象,该值必须可设置,也就是可寻址,参考上节课修改变量的值那一节的内容理解。
小提示:任何类型的变量都可以转换为空接口 intferface{},所以第 1 条定律中函数 reflect.ValueOf 和 reflect.TypeOf 的参数就是 interface{},表示可以把任何类型的变量转换为反射对象。在第 2 条定律中reflect.Value 结构体的 Interface 方法返回的值也是 interface{},表示可以把反射对象还原为对应的类型变量。
一旦你理解了这三大定律,就可以更好地理解和使用 Go 语言反射。
总结
在反射中reflect.Value 对应的是变量的值,如果你需要进行和变量的值有关的操作,应该优先使用 reflect.Value比如获取变量的值、修改变量的值等。reflect.Type 对应的是变量的类型,如果你需要进行和变量的类型本身有关的操作,应该优先使用 reflect.Type比如获取结构体内的字段、类型拥有的方法集等。
此外我要再次强调:反射虽然很强大,可以简化编程、减少重复代码,但是过度使用会让你的代码变得复杂混乱。所以除非非常必要,否则尽可能少地使用它们。
这节课的作业是:自己写代码运行通过反射调用结构体的方法。
下节课我将介绍“非类型安全:让你既爱又恨的 unsafe”记得来听课

View File

@ -0,0 +1,248 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 非类型安全:让你既爱又恨的 unsafe
上节课我留了一个小作业,让你练习一下如何使用反射调用一个方法,下面我来进行讲解。
还是以 person 这个结构体类型为例。我为它增加一个方法 Print功能是打印一段文本示例代码如下
func (p person) Print(prefix string){
fmt.Printf("%s:Name is %s,Age is %d\n",prefix,p.Name,p.Age)
}
然后就可以通过反射调用 Print 方法了,示例代码如下:
func main() {
p:=person{Name: "飞雪无情",Age: 20}
pv:=reflect.ValueOf(p)
//反射调用person的Print方法
mPrint:=pv.MethodByName("Print")
args:=[]reflect.Value{reflect.ValueOf("登录")}
mPrint.Call(args)
}
从示例中可以看到,要想通过反射调用一个方法,首先要通过 MethodByName 方法找到相应的方法。因为 Print 方法需要参数,所以需要声明参数,它的类型是 []reflect.Value也就是示例中的 args 变量,最后就可以通过 Call 方法反射调用 Print 方法了。其中记得要把 args 作为参数传递给 Call 方法。
运行以上代码,可以看到如下结果:
登录:Name is 飞雪无情,Age is 20
从打印的结果可以看到,和我们直接调用 Print 方法是一样的结果,这也证明了通过反射调用 Print 方法是可行的。
下面我们继续深入 Go 的世界,这节课会介绍 Go 语言自带的 unsafe 包的高级用法。
顾名思义unsafe 是不安全的。Go 将其定义为这个包名,也是为了让我们尽可能地不使用它。不过虽然不安全,它也有优势,那就是可以绕过 Go 的内存安全机制,直接对内存进行读写。所以有时候出于性能需要,还是会冒险使用它来对内存进行操作。
指针类型转换
Go 的设计者为了编写方便、提高效率且降低复杂度将其设计成一门强类型的静态语言。强类型意味着一旦定义了类型就不能改变静态意味着类型检查在运行前就做了。同时出于安全考虑Go 语言是不允许两个指针类型进行转换的。
我们一般使用 *T 作为一个指针类型,表示一个指向类型 T 变量的指针。为了安全的考虑,两个不同的指针类型不能相互转换,比如 *int 不能转为 *float64。
我们来看下面的代码:
func main() {
i:= 10
ip:=&i
var fp *float64 = (*float64)(ip)
fmt.Println(fp)
}
这个代码在编译的时候,会提示 *cannot convert ip (type * int) to type * float64*,也就是不能进行强制转型。那如果还是需要转换呢?这就需要使用 unsafe 包里的 Pointer 了。下面我先为你介绍 unsafe.Pointer 是什么,然后再介绍如何转换。
unsafe.Pointer
unsafe.Pointer 是一种特殊意义的指针,可以表示任意类型的地址,类似 C 语言里的 void* 指针,是全能型的。
正常情况下,*int 无法转换为 *float64但是通过 unsafe.Pointer 做中转就可以了。在下面的示例中,我通过 unsafe.Pointer 把 *int 转换为 *float64并且对新的 *float64 进行 3 倍的乘法操作,你会发现原来变量 i 的值也被改变了,变为 30。
ch16/main.go
func main() {
i:= 10
ip:=&i
var fp *float64 = (*float64)(unsafe.Pointer(ip))
*fp = *fp * 3
fmt.Println(i)
}
这个例子没有任何实际意义,但是说明了通过 unsafe.Pointer 这个万能的指针,我们可以在 *T 之间做任何转换。那么 unsafe.Pointer 到底是什么?为什么其他类型的指针可以转换为 unsafe.Pointer 呢?这就要看 unsafe.Pointer 的源代码定义了,如下所示:
// ArbitraryType is here for the purposes of documentation
// only and is not actually part of the unsafe package.
// It represents the type of an arbitrary Go expression.
type ArbitraryType int
type Pointer *ArbitraryType
按 Go 语言官方的注释ArbitraryType 可以表示任何类型(这里的 ArbitraryType 仅仅是文档需要,不用太关注它本身,只要记住可以表示任何类型即可)。 而 unsafe.Pointer 又是 *ArbitraryType也就是说 unsafe.Pointer 是任何类型的指针,也就是一个通用型的指针,足以表示任何内存地址。
uintptr 指针类型
uintptr 也是一种指针类型,它足够大,可以表示任何指针。它的类型定义如下所示:
// uintptr is an integer type that is large enough
// to hold the bit pattern of any pointer.
type uintptr uintptr
既然已经有了 unsafe.Pointer为什么还要设计 uintptr 类型呢?这是因为 unsafe.Pointer 不能进行运算,比如不支持 +(加号)运算符操作,但是 uintptr 可以。通过它,可以对指针偏移进行计算,这样就可以访问特定的内存,达到对特定内存读写的目的,这是真正内存级别的操作。
在下面的代码中,我以通过指针偏移修改 struct 结构体内的字段为例,演示 uintptr 的用法。
func main() {
p :=new(person)
//Name是person的第一个字段不用偏移即可通过指针修改
pName:=(*string)(unsafe.Pointer(p))
*pName="飞雪无情"
//Age并不是person的第一个字段所以需要进行偏移这样才能正确定位到Age字段这块内存才可以正确的修改
pAge:=(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(p))+unsafe.Offsetof(p.Age)))
*pAge = 20
fmt.Println(*p)
}
type person struct {
Name string
Age int
}
这个示例不是通过直接访问相应字段的方式对 person 结构体字段赋值,而是通过指针偏移找到相应的内存,然后对内存操作进行赋值。
下面我详细介绍操作步骤。
先使用 new 函数声明一个 *person 类型的指针变量 p。
然后把 *person 类型的指针变量 p 通过 unsafe.Pointer转换为 *string 类型的指针变量 pName。
因为 person 这个结构体的第一个字段就是 string 类型的 Name所以 pName 这个指针就指向 Name 字段(偏移为 0对 pName 进行修改其实就是修改字段 Name 的值。
因为 Age 字段不是 person 的第一个字段,要修改它必须要进行指针偏移运算。所以需要先把指针变量 p 通过 unsafe.Pointer 转换为 uintptr这样才能进行地址运算。既然要进行指针偏移那么要偏移多少呢这个偏移量可以通过函数 unsafe.Offsetof 计算出来,该函数返回的是一个 uintptr 类型的偏移量,有了这个偏移量就可以通过 + 号运算符获得正确的 Age 字段的内存地址了,也就是通过 unsafe.Pointer 转换后的 *int 类型的指针变量 pAge。
然后需要注意的是,如果要进行指针运算,要先通过 unsafe.Pointer 转换为 uintptr 类型的指针。指针运算完毕后,还要通过 unsafe.Pointer 转换为真实的指针类型(比如示例中的 *int 类型),这样可以对这块内存进行赋值或取值操作。
有了指向字段 Age 的指针变量 pAge就可以对其进行赋值操作修改字段 Age 的值了。
运行以上示例,你可以看到如下结果:
{飞雪无情 20}
这个示例主要是为了讲解 uintptr 指针运算,所以一个结构体字段的赋值才会写得这么复杂,如果按照正常的编码,以上示例代码会和下面的代码结果一样。
func main() {
p :=new(person)
p.Name = "飞雪无情"
p.Age = 20
fmt.Println(*p)
}
指针运算的核心在于它操作的是一个个内存地址,通过内存地址的增减,就可以指向一块块不同的内存并对其进行操作,而且不必知道这块内存被起了什么名字(变量名)。
指针转换规则
你已经知道 Go 语言中存在三种类型的指针,它们分别是:常用的 *T、unsafe.Pointer 及 uintptr。通过以上示例讲解可以总结出这三者的转换规则
任何类型的 *T 都可以转换为 unsafe.Pointer
unsafe.Pointer 也可以转换为任何类型的 *T
unsafe.Pointer 可以转换为 uintptr
uintptr 也可以转换为 unsafe.Pointer。
(指针转换示意图)
可以发现unsafe.Pointer 主要用于指针类型的转换而且是各个指针类型转换的桥梁。uintptr 主要用于指针运算,尤其是通过偏移量定位不同的内存。
unsafe.Sizeof
Sizeof 函数可以返回一个类型所占用的内存大小,这个大小只与类型有关,和类型对应的变量存储的内容大小无关,比如 bool 型占用一个字节、int8 也占用一个字节。
通过 Sizeof 函数你可以查看任何类型(比如字符串、切片、整型)占用的内存大小,示例代码如下:
fmt.Println(unsafe.Sizeof(true))
fmt.Println(unsafe.Sizeof(int8(0)))
fmt.Println(unsafe.Sizeof(int16(10)))
fmt.Println(unsafe.Sizeof(int32(10000000)))
fmt.Println(unsafe.Sizeof(int64(10000000000000)))
fmt.Println(unsafe.Sizeof(int(10000000000000000)))
fmt.Println(unsafe.Sizeof(string("飞雪无情")))
fmt.Println(unsafe.Sizeof([]string{"飞雪u无情","张三"}))
对于整型来说,占用的字节数意味着这个类型存储数字范围的大小,比如 int8 占用一个字节,也就是 8bit所以它可以存储的大小范围是 -128~~127也就是 2^(n-1) 到 2^(n-1)1。其中 n 表示 bitint8 表示 8bitint16 表示 16bit以此类推。
对于和平台有关的 int 类型,要看平台是 32 位还是 64 位,会取最大的。比如我自己测试以上输出,会发现 int 和 int64 的大小是一样的,因为我用的是 64 位平台的电脑。
小提示:一个 struct 结构体的内存占用大小,等于它包含的字段类型内存占用大小之和。
总结
unsafe 包里最常用的就是 Pointer 指针,通过它可以让你在 *T、uintptr 及 Pointer 三者间转换,从而实现自己的需求,比如零内存拷贝或通过 uintptr 进行指针运算,这些都可以提高程序效率。
unsafe 包里的功能虽然不安全,但的确很香,比如指针运算、类型转换等,都可以帮助我们提高性能。不过我还是建议尽可能地不使用,因为它可以绕开 Go 语言编译器的检查,可能会因为你的操作失误而出现问题。当然如果是需要提高性能的必要操作,还是可以使用,比如 []byte 转 string就可以通过 unsafe.Pointer 实现零内存拷贝,下节课我会详细讲解。
unsafe 包还有一个函数我这节课没有讲,它是 Alignof功能就是函数名字字面的意思比较简单你可以自己练习使用一下这也是这节课的思考题。记得来听下节课哦

View File

@ -0,0 +1,364 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 SliceHeaderslice 如何高效处理数据?
在[第 4 讲|集合类型:如何正确使用 array、slice 和 map]中,你已经学习了 slice切片并且知道如何使用。这节课我会详细介绍 slice 的原理,带你学习它的底层设计。
数组
在讲 slice 的原理之前我先来介绍一下数组。几乎所有的编程语言里都存在数组Go 也不例外。那么为什么 Go 语言除了数组之外又设计了 slice 呢?要想解答这个问题,我们先来了解数组的局限性。
在下面的示例中a1、a2 是两个定义好的数组,但是它们的类型不一样。变量 a1 的类型是 [1]string变量 a2 的类型是 [2]string也就是说数组的大小属于数组类型的一部分只有数组内部元素类型和大小一致时这两个数组才是同一类型。
a1:=[1]string{"飞雪无情"}
a2:=[2]string{"飞雪无情"}
可以总结为,一个数组由两部分构成:数组的大小和数组内的元素类型。
//数组结构伪代码表示
array{
len
item type
}
比如变量 a1 的大小是 1内部元素的类型是 string也就是说 a1 最多只能存储 1 个类型为 string 的元素。而 a2 的大小是 2内部元素的类型也是 string所以 a2 最多可以存储 2 个类型为 string 的元素。一旦一个数组被声明,它的大小和内部元素的类型就不能改变,你不能随意地向数组添加任意多个元素。这是数组的第一个限制。
既然数组的大小是固定的,如果需要使用数组存储大量的数据,就需要提前指定一个合适的大小,比如 10 万,代码如下所示:
a10:=[100000]string{"飞雪无情"}
这样虽然可以解决问题,但又带来了另外的问题,那就是内存占用。因为在 Go 语言中,函数间的传参是值传递的,数组作为参数在各个函数之间被传递的时候,同样的内容就会被一遍遍地复制,这就会造成大量的内存浪费,这是数组的第二个限制。
虽然数组有限制,但是它是 Go 非常重要的底层数据结构,比如 slice 切片的底层数据就存储在数组中。
slice 切片
你已经知道数组虽然也不错但是在操作上有不少限制为了解决这些限制Go 语言创造了 slice也就是切片。切片是对数组的抽象和封装它的底层是一个数组存储所有的元素但是它可以动态地添加元素容量不足时还可以自动扩容你完全可以把切片理解为动态数组。在 Go 语言中,除了明确需要指定长度大小的类型需要数组来完成,大多数情况下都是使用切片的。
动态扩容
通过内置的 append 方法,你可以向一个切片中追加任意多个元素,所以这就可以解决数组的第一个限制。
在下面的示例中,我通过内置的 append 函数为切片 ss 添加了两个字符串,然后返回一个新的切片赋值给 ss。
func main() {
ss:=[]string{"飞雪无情","张三"}
ss=append(ss,"李四","王五")
fmt.Println(ss)
}
现在运行这段代码,会看到如下打印结果:
[飞雪无情 张三 李四 王五]
当通过 append 追加元素时如果切片的容量不够append 函数会自动扩容。比如上面的例子,我打印出使用 append 前后的切片长度和容量,代码如下:
func main() {
ss:=[]string{"飞雪无情","张三"}
fmt.Println("切片ss长度为",len(ss),",容量为",cap(ss))
ss=append(ss,"李四","王五")
fmt.Println("切片ss长度为",len(ss),",容量为",cap(ss))
fmt.Println(ss)
}
其中,我通过内置的 len 函数获取切片的长度,通过 cap 函数获取切片的容量。运行这段代码,可以看到打印结果如下:
切片ss长度为 2 ,容量为 2
切片ss长度为 4 ,容量为 4
[飞雪无情 张三 李四 王五]
在调用 append 之前,容量是 2调用之后容量是 4说明自动扩容了。
小提示append 自动扩容的原理是新创建一个底层数组,把原来切片内的元素拷贝到新数组中,然后再返回一个指向新数组的切片。
数据结构
在 Go 语言中,切片其实是一个结构体,它的定义如下所示:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
SliceHeader 是切片在运行时的表现形式,它有三个字段 Data、Len 和 Cap。
Data 用来指向存储切片元素的数组。
Len 代表切片的长度。
Cap 代表切片的容量。
通过这三个字段,就可以把一个数组抽象成一个切片,便于更好的操作,所以不同切片对应的底层 Data 指向的可能是同一个数组。现在通过一个示例来证明,代码如下:
func main() {
a1:=[2]string{"飞雪无情","张三"}
s1:=a1[0:1]
s2:=a1[:]
//打印出s1和s2的Data值是一样的
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s2)).Data)
}
用上节课学习的 unsafe.Pointer 把它们转换为 *reflect.SliceHeader 指针,就可以打印出 Data 的值,打印结果如下所示:
824634150744
824634150744
你会发现它们是一样的,也就是这两个切片共用一个数组,所以我们在切片赋值、重新进行切片操作时,使用的还是同一个数组,没有复制原来的元素。这样可以减少内存的占用,提高效率。
注意:多个切片共用一个底层数组虽然可以减少内存占用,但是如果有一个切片修改内部的元素,其他切片也会受影响。所以在切片作为参数在函数间传递的时候要小心,尽可能不要修改原切片内的元素。
切片的本质是 SliceHeader又因为函数的参数是值传递所以传递的是 SliceHeader 的副本,而不是底层数组的副本。这时候切片的优势就体现出来了,因为 SliceHeader 的副本内存占用非常少,即使是一个非常大的切片(底层数组有很多元素),也顶多占用 24 个字节的内存,这就解决了大数组在传参时内存浪费的问题。
小提示SliceHeader 三个字段的类型分别是 uintptr、int 和 int在 64 位的机器上,这三个字段最多也就是 int64 类型,一个 int64 占 8 个字节,三个 int64 占 24 个字节内存。
要获取切片数据结构的三个字段的值,也可以不使用 SliceHeader而是完全自定义一个结构体只要字段和 SliceHeader 一样就可以了。
比如在下面的示例中,通过 unsfe.Pointer 转换成自定义的 *slice 指针,同样可以获取三个字段对应的值,你甚至可以把字段的名称改为 d、l 和 c也可以达到目的。
sh1:=(*slice)(unsafe.Pointer(&s1))
fmt.Println(sh1.Data,sh1.Len,sh1.Cap)
type slice struct {
Data uintptr
Len int
Cap int
}
小提示:我们还是尽可能地用 SliceHeader因为这是 Go 语言提供的标准,可以保持统一,便于理解。
高效的原因
如果从集合类型的角度考虑,数组、切片和 map 都是集合类型,因为它们都可以存放元素,但是数组和切片的取值和赋值操作要更高效,因为它们是连续的内存操作,通过索引就可以快速地找到元素存储的地址。
小提示:当然 map 的价值也非常大,因为它的 Key 可以是很多类型,比如 int、int64、string 等,但是数组和切片的索引只能是整数。
进一步对比,在数组和切片中,切片又是高效的,因为它在赋值、函数传参的时候,并不会把所有的元素都复制一遍,而只是复制 SliceHeader 的三个字段就可以了,共用的还是同一个底层数组。
在下面的示例中,我定义了两个函数 arrayF 和 sliceF分别打印传入的数组和切片底层对应的数组指针。
func main() {
a1:=[2]string{"飞雪无情","张三"}
fmt.Printf("函数main数组指针%p\n",&a1)
arrayF(a1)
s1:=a1[0:1]
fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)
sliceF(s1)
}
func arrayF(a [2]string){
fmt.Printf("函数arrayF数组指针%p\n",&a)
}
func sliceF(s []string){
fmt.Printf("函数sliceF Data%d\n",(*reflect.SliceHeader)(unsafe.Pointer(&s)).Data)
}
然后我在 main 函数里调用它们,运行程序会打印如下结果:
函数main数组指针0xc0000a6020
函数arrayF数组指针0xc0000a6040
824634400800
函数sliceF Data824634400800
你会发现,同一个数组在 main 函数中的指针和在 arrayF 函数中的指针是不一样的,这说明数组在传参的时候被复制了,又产生了一个新数组。而 slice 切片的底层 Data 是一样的,这说明不管是在 main 函数还是 sliceF 函数中,这两个切片共用的还是同一个底层数组,底层数组并没有被复制。
小提示:切片的高效还体现在 for range 循环中,因为循环得到的临时变量也是个值拷贝,所以在遍历大的数组时,切片的效率更高。
切片基于指针的封装是它效率高的根本原因,因为可以减少内存的占用,以及减少内存复制时的时间消耗。
string 和 []byte 互转
下面我通过 string 和 []byte 相互强制转换的例子,进一步帮你理解 slice 高效的原因。
比如我把一个 []byte 转为一个 string 字符串,然后再转换回来,示例代码如下:
s:="飞雪无情"
b:=[]byte(s)
s3:=string(b)
fmt.Println(s,string(b),s3)
在这个示例中,变量 s 是一个 string 字符串,它可以通过 []byte(s) 被强制转换为 []byte 类型的变量 b又可以通过 string(b) 强制转换为 string 类型的变量 s3。打印它们三个变量的值都是
“飞雪无情”。
Go 语言通过先分配一个内存再复制内容的方式,实现 string 和 []byte 之间的强制转换。现在我通过 string 和 []byte 指向的真实内容的内存地址,来验证强制转换是采用重新分配内存的方式。如下面的代码所示:
s:="飞雪无情"
fmt.Printf("s的内存地址%d\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
b:=[]byte(s)
fmt.Printf("b的内存地址%d\n",(*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)
s3:=string(b)
fmt.Printf("s3的内存地址%d\n", (*reflect.StringHeader)(unsafe.Pointer(&s3)).Data)
运行它们,你会发现打印出的内存地址都不一样,这说明虽然内容相同,但已经不是同一个字符串了,因为内存地址不同。
小提示:你可以通过查看 runtime.stringtoslicebyte 和 runtime.slicebytetostring 这两个函数的源代码,了解关于 string 和 []byte 类型互转的具体实现。
通过以上的示例代码,你已经知道了 SliceHeader 是什么。其实 StringHeader 和 SliceHeader 一样代表的是字符串在程序运行时的真实结构StringHeader 的定义如下所示:
// StringHeader is the runtime representation of a string.
type StringHeader struct {
Data uintptr
Len int
}
也就是说,在程序运行的时候,字符串和切片本质上就是 StringHeader 和 SliceHeader。这两个结构体都有一个 Data 字段,用于存放指向真实内容的指针。所以我们打印出 Data 这个字段的值,就可以判断 string 和 []byte 强制转换后是不是重新分配了内存。
现在你已经知道了 []byte(s) 和 string(b) 这种强制转换会重新拷贝一份字符串,如果字符串非常大,由于内存开销大,对于有高性能要求的程序来说,这种方式就无法满足了,需要进行性能优化。
如何优化呢?既然是因为内存分配导致内存开销大,那么优化的思路应该是在不重新申请内存的情况下实现类型转换。
仔细观察 StringHeader 和 SliceHeader 这两个结构体,会发现它们的前两个字段一模一样,那么 []byte 转 string就等于通过 unsafe.Pointer 把 *SliceHeader 转为 *StringHeader也就是 *[]byte 转 *string原理和我上面讲的把切片转换成一个自定义的 slice 结构体类似。
在下面的示例中s4 和 s3 的内容是一样的。不一样的是 s4 没有申请新内存(零拷贝),它和变量 b 使用的是同一块内存,因为它们的底层 Data 字段值相同,这样就节约了内存,也达到了 []byte 转 string 的目的。
s:="飞雪无情"
b:=[]byte(s)
//s3:=string(b)
s4:=*(*string)(unsafe.Pointer(&b))
SliceHeader 有 Data、Len、Cap 三个字段StringHeader 有 Data、Len 两个字段,所以 *SliceHeader 通过 unsafe.Pointer 转为 *StringHeader 的时候没有问题,因为 *SliceHeader 可以提供 *StringHeader 所需的 Data 和 Len 字段的值。但是反过来却不行了,因为 *StringHeader 缺少 *SliceHeader 所需的 Cap 字段,需要我们自己补上一个默认值。
在下面的示例中b1 和 b 的内容是一样的,不一样的是 b1 没有申请新内存,而是和变量 s 使用同一块内存,因为它们底层的 Data 字段相同,所以也节约了内存。
s:="飞雪无情"
//b:=[]byte(s)
sh:=(*reflect.SliceHeader)(unsafe.Pointer(&s))
sh.Cap = sh.Len
b1:=*(*[]byte)(unsafe.Pointer(sh))
注意:通过 unsafe.Pointer 把 string 转为 []byte 后,不能对 []byte 修改,比如不可以进行 b1[0]=12 这种操作,会报异常,导致程序崩溃。这是因为在 Go 语言中 string 内存是只读的。
通过 unsafe.Pointer 进行类型转换,避免内存拷贝提升性能的方法在 Go 语言标准库中也有使用,比如 strings.Builder 这个结构体,它内部有 buf 字段存储内容,在通过 String 方法把 []byte 类型的 buf 转为 string 的时候,就使用 unsafe.Pointer 提高了效率,代码如下:
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
string 和 []byte 的互转就是一个很好的利用 SliceHeader 结构体的示例,通过它可以实现零拷贝的类型转换,提升了效率,避免了内存浪费。
总结
通过 slice 切片的分析,相信你可以更深地感受 Go 的魅力,它把底层的指针、数组等进行封装,提供一个切片的概念给开发者,这样既可以方便使用、提高开发效率,又可以提高程序的性能。
Go 语言设计切片的思路非常有借鉴意义,你也可以使用 uintptr 或者 slice 类型的字段来提升性能,就像 Go 语言 SliceHeader 里的 Data uintptr 字段一样。
在这节课的最后,给你留一个思考题:你还可以找到哪些通过 unsafe.Pointer、uintptr 提升性能的例子呢?欢迎留言讨论。
下节课我们将进入工程管理模块首先学习“质量保证Go 语言如何通过测试保证质量?”记得来听课!

View File

@ -0,0 +1,434 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 质量保证Go 语言如何通过测试保证质量?
从这节课开始,我会带你学习本专栏的第四模块:工程管理。现在项目的开发都不是一个人可以完成的,需要多人进行协作,那么在多人协作中如何保证代码的质量,你写的代码如何被其他人使用,如何优化代码的性能等, 就是第四模块的内容。
这一讲首先来学习 Go 语言的单元测试和基准测试。
单元测试
在开发完一个功能后,你可能会直接把代码合并到代码库,用于上线或供其他人使用。但这样是不对的,因为你还没有对所写的代码进行测试。没有经过测试的代码逻辑可能会存在问题:如果强行合并到代码库,可能影响其他人的开发;如果强行上线,可能导致线上 Bug、影响用户使用。
什么是单元测试
顾名思义,单元测试强调的是对单元进行测试。在开发中,一个单元可以是一个函数、一个模块等。一般情况下,你要测试的单元应该是一个完整的最小单元,比如 Go 语言的函数。这样的话,当每个最小单元都被验证通过,那么整个模块、甚至整个程序就都可以被验证通过。
单元测试由开发者自己编写,也就是谁改动了代码,谁就要编写相应的单元测试代码以验证本次改动的正确性。
Go 语言的单元测试
虽然每种编程语言里单元测试的概念是一样的但它们对单元测试的设计不一样。Go 语言也有自己的单元测试规范,下面我会通过一个完整的示例为你讲解,这个例子就是经典的斐波那契数列。
斐波那契数列是一个经典的黄金分隔数列:它的第 0 项是 0第 1 项是 1从第 2 项开始每一项都等于前两项之和。所以它的数列是0、1、1、2、3、5、8、13、21……
说明:为了便于总结后面的函数方程式,我这里特意写的从第 0 项开始,其实现实中没有第 0 项。
根据以上规律,可以总结出它的函数方程式。
F(0)=0
F(1)=1
F(n)=F(n - 1)+F(n - 2)
有了函数方程式,再编写一个 Go 语言函数来计算斐波那契数列就比较简单了,代码如下:
ch18/main.go
func Fibonacci(n int) int {
if n < 0 {
return 0
}
if n == 0 {
return 0
}
if n == 1 {
return 1
}
return Fibonacci(n-1) + Fibonacci(n-2)
}
也就是通过递归的方式实现了斐波那契数列的计算。
Fibonacci 函数已经编写好了,可以供其他开发者使用,不过在使用之前,需要先对它进行单元测试。你需要新建一个 go 文件用于存放单元测试代码。刚刚编写的 Fibonacci 函数在*ch18/main.go*文件中,那么对 Fibonacci 函数进行单元测试的代码需要放在*ch18/main_test.go*中**测试代码如下:
ch18/main_test.go
func TestFibonacci(t *testing.T) {
//预先定义的一组斐波那契数列作为测试用例
fsMap := map[int]int{}
fsMap[0] = 0
fsMap[1] = 1
fsMap[2] = 1
fsMap[3] = 2
fsMap[4] = 3
fsMap[5] = 5
fsMap[6] = 8
fsMap[7] = 13
fsMap[8] = 21
fsMap[9] = 34
for k, v := range fsMap {
fib := Fibonacci(k)
if v == fib {
t.Logf("结果正确:n为%d,值为%d", k, fib)
} else {
t.Errorf("结果错误:期望%d,但是计算的值是%d", v, fib)
}
}
}
在这个单元测试中,我通过 map 预定义了一组测试用例,然后通过 Fibonacci 函数计算结果。同预定义的结果进行比较,如果相等,则说明 Fibonacci 函数计算正确,不相等则说明计算错误。
然后即可运行如下命令,进行单元测试:
➜ go test -v ./ch18
这行命令会运行 ch18 目录下的所有单元测试,因为我只写了一个单元测试,所以可以看到结果如下所示:
➜ go test -v ./ch18
=== RUN TestFibonacci
main_test.go:21: 结果正确:n为0,值为0
main_test.go:21: 结果正确:n为1,值为1
main_test.go:21: 结果正确:n为6,值为8
main_test.go:21: 结果正确:n为8,值为21
main_test.go:21: 结果正确:n为9,值为34
main_test.go:21: 结果正确:n为2,值为1
main_test.go:21: 结果正确:n为3,值为2
main_test.go:21: 结果正确:n为4,值为3
main_test.go:21: 结果正确:n为5,值为5
main_test.go:21: 结果正确:n为7,值为13
--- PASS: TestFibonacci (0.00s)
PASS
ok gotour/ch18 (cached)
在打印的测试结果中,你可以看到 PASS 标记,说明单元测试通过,而且还可以看到我在单元测试中写的日志。
这就是一个完整的 Go 语言单元测试用例,它是在 Go 语言提供的测试框架下完成的。Go 语言测试框架可以让我们很容易地进行单元测试,但是需要遵循五点规则。
含有单元测试代码的 go 文件必须以 _test.go 结尾Go 语言测试工具只认符合这个规则的文件。
单元测试文件名 _test.go 前面的部分最好是被测试的函数所在的 go 文件的文件名,比如以上示例中单元测试文件叫 main_test.go因为测试的 Fibonacci 函数在 main.go 文件里。
单元测试的函数名必须以 Test 开头,是可导出的、公开的函数。
测试函数的签名必须接收一个指向 testing.T 类型的指针,并且不能返回任何值。
函数名最好是 Test + 要测试的函数名,比如例子中是 TestFibonacci表示测试的是 Fibonacci 这个函数。
遵循以上规则,你就可以很容易地编写单元测试了。单元测试的重点在于熟悉业务代码的逻辑、场景等,以便尽可能地全面测试,保障代码质量。
单元测试覆盖率
以上示例中的 Fibonacci 函数是否被全面地测试了呢?这就需要用单元测试覆盖率进行检测了。
Go 语言提供了非常方便的命令来查看单元测试覆盖率。还是以 Fibonacci 函数的单元测试为例,通过一行命令即可查看它的单元测试覆盖率。
➜ go test -v --coverprofile=ch18.cover ./ch18
这行命令包括 coverprofile 这个 Flag它可以得到一个单元测试覆盖率文件运行这行命令还可以同时看到测试覆盖率。Fibonacci 函数的测试覆盖率如下:
PASS
coverage: 85.7% of statements
ok gotour/ch18 0.367s coverage: 85.7% of statements
可以看到,测试覆盖率为 85.7%。从这个数字来看Fibonacci 函数应该没有被全面地测试,这时候就需要查看详细的单元测试覆盖率报告了。
运行如下命令,可以得到一个 HTML 格式的单元测试覆盖率报告:
➜ go tool cover -html=ch18.cover -o=ch18.html
命令运行后,会在当前目录下生成一个 ch18.html 文件,使用浏览器打开它,可以看到图中的内容:
单元测试覆盖率报告
红色标记的部分是没有测试到的,绿色标记的部分是已经测试到的。这就是单元测试覆盖率报告的好处,通过它你可以很容易地检测自己写的单元测试是否完全覆盖。
根据报告,我再修改一下单元测试,把没有覆盖的代码逻辑覆盖到,代码如下:
fsMap[-1] = 0
也就是说,由于图中 n 的部分显示为红色,表示没有测试到,所以我们需要再添加一组测试用例,用于测试 n 的情况。现在再运行这个单元测试,查看它的单元测试覆盖率,就会发现已经是 100% 了。
基准测试
除了需要保证我们编写的代码的逻辑正确外,有时候还有性能要求。那么如何衡量代码的性能呢?这就需要基准测试了。
什么是基准测试
基准测试Benchmark是一项用于测量和评估软件性能指标的方法主要用于评估你写的代码的性能。
Go 语言的基准测试
Go 语言的基准测试和单元测试规则基本一样,只是测试函数的命名规则不一样。现在还以 Fibonacci 函数为例,演示 Go 语言基准测试的使用。
Fibonacci 函数的基准测试代码如下:
ch18/main_test.go
func BenchmarkFibonacci(b *testing.B){
for i:=0;i<b.N;i++{
Fibonacci(10)
}
}
这是一个非常简单的 Go 语言基准测试示例,它和单元测试的不同点如下:
基准测试函数必须以 Benchmark 开头,必须是可导出的;
函数的签名必须接收一个指向 testing.B 类型的指针,并且不能返回任何值;
最后的 for 循环很重要,被测试的代码要放到循环里;
b.N 是基准测试框架提供的,表示循环的次数,因为需要反复调用测试的代码,才可以评估性能。
写好了基准测试,就可以通过如下命令来测试 Fibonacci 函数的性能:
➜ go test -bench=. ./ch18
goos: darwin
goarch: amd64
pkg: gotour/ch18
BenchmarkFibonacci-8 3461616 343 ns/op
PASS
ok gotour/ch18 2.230s
运行基准测试也要使用 go test 命令,不过要加上 -bench 这个 Flag它接受一个表达式作为参数以匹配基准测试的函数”.“表示运行所有基准测试。
下面着重解释输出的结果。看到函数后面的 -8 了吗?这个表示运行基准测试时对应的 GOMAXPROCS 的值。接着的 3461616 表示运行 for 循环的次数,也就是调用被测试代码的次数,最后的 343 ns/op 表示每次需要花费 343 纳秒。
基准测试的时间默认是 1 秒,也就是 1 秒调用 3461616 次、每次调用花费 343 纳秒。如果想让测试运行的时间更长,可以通过 -benchtime 指定,比如 3 秒,代码如下所示:
go test -bench=. -benchtime=3s ./ch18
计时方法
进行基准测试之前会做一些准备,比如构建测试数据等,这些准备也需要消耗时间,所以需要把这部分时间排除在外。这就需要通过 ResetTimer 方法重置计时器,示例代码如下:
func BenchmarkFibonacci(b *testing.B) {
n := 10
b.ResetTimer() //重置计时器
for i := 0; i < b.N; i++ {
Fibonacci(n)
}
}
这样可以避免因为准备数据耗时造成的干扰。
除了 ResetTimer 方法外,还有 StartTimer 和 StopTimer 方法,帮你灵活地控制什么时候开始计时、什么时候停止计时。
内存统计
在基准测试时,还可以统计每次操作分配内存的次数,以及每次操作分配的字节数,这两个指标可以作为优化代码的参考。要开启内存统计也比较简单,代码如下,即通过 ReportAllocs() 方法:
func BenchmarkFibonacci(b *testing.B) {
n := 10
b.ReportAllocs() //开启内存统计
b.ResetTimer() //重置计时器
for i := 0; i < b.N; i++ {
Fibonacci(n)
}
}
现在再运行这个基准测试,就可以看到如下结果:
➜ go test -bench=. ./ch18
goos: darwin
goarch: amd64
pkg: gotour/ch18
BenchmarkFibonacci-8 2486265 486 ns/op 0 B/op 0 allocs/op
PASS
ok gotour/ch18 2.533s
可以看到相比原来的基准测试多了两个指标,分别是 0 B/op 和 0 allocs/op。前者表示每次操作分配了多少字节的内存后者表示每次操作分配内存的次数。这两个指标可以作为代码优化的参考尽可能地越小越好。
小提示:以上两个指标是否越小越好?这是不一定的,因为有时候代码实现需要空间换时间,所以要根据自己的具体业务而定,做到在满足业务的情况下越小越好。
并发基准测试
除了普通的基准测试外Go 语言还支持并发基准测试,你可以测试在多个 goroutine 并发下代码的性能。还是以 Fibonacci 为例,它的并发基准测试代码如下:
func BenchmarkFibonacciRunParallel(b *testing.B) {
n := 10
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Fibonacci(n)
}
})
}
可以看到Go 语言通过 RunParallel 方法运行并发基准测试。RunParallel 方法会创建多个 goroutine并将 b.N 分配给这些 goroutine 执行。
基准测试实战
相信你已经理解了 Go 语言的基准测试,也学会了如何使用,现在我以一个实战帮你复习。
还是以 Fibonacci 函数为例,通过前面小节的基准测试,会发现它并没有分配新的内存,也就是说 Fibonacci 函数慢并不是因为内存,排除掉这个原因,就可以归结为所写的算法问题了。
在递归运算中,一定会有重复计算,这是影响递归的主要因素。解决重复计算可以使用缓存,把已经计算好的结果保存起来,就可以重复使用了。
基于这个思路,我将 Fibonacci 函数的代码进行如下修改:
//缓存已经计算的结果
var cache = map[int]int{}
func Fibonacci(n int) int {
if v, ok := cache[n]; ok {
return v
}
result := 0
switch {
case n < 0:
result = 0
case n == 0:
result = 0
case n == 1:
result = 1
default:
result = Fibonacci(n-1) + Fibonacci(n-2)
}
cache[n] = result
return result
}
这组代码的核心在于采用一个 map 将已经计算好的结果缓存、便于重新使用。改造后,我再来运行基准测试,看看刚刚优化的效果,如下所示:
BenchmarkFibonacci-8 97823403 11.7 ns/op
可以看到,结果为 11.7 纳秒,相比优化前的 343 纳秒,性能足足提高了 28 倍。
总结
单元测试是保证代码质量的好方法,但单元测试也不是万能的,使用它可以降低 Bug 率,但也不要完全依赖。除了单元测试外,还可以辅以 Code Review、人工测试等手段更好地保证代码质量。
在这节课的最后给你留个练习题:在运行 go test 命令时,使用 -benchmem 这个 Flag 进行内存统计。
下一讲我将介绍“性能优化Go 语言如何进行代码检查和优化?”记得来听课!

View File

@ -0,0 +1,430 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 性能优化Go 语言如何进行代码检查和优化?
在上节课中,我为你留了一个小作业:在运行 go test 命令时,使用 -benchmem 这个 Flag 进行内存统计。该作业的答案比较简单,命令如下所示:
➜ go test -bench=. -benchmem ./ch18
运行这一命令就可以查看内存统计的结果了。这种通过 -benchmem 查看内存的方法适用于所有的基准测试用例。
今天要讲的内容是 Go 语言的代码检查和优化,下面我们开始本讲内容的讲解。
在项目开发中,保证代码质量和性能的手段不只有单元测试和基准测试,还有代码规范检查和性能优化。
代码规范检查是对单元测试的一种补充,它可以从非业务的层面检查你的代码是否还有优化的空间,比如变量是否被使用、是否是死代码等等。
性能优化是通过基准测试来衡量的,这样我们才知道优化部分是否真的提升了程序的性能。
代码规范检查
什么是代码规范检查
代码规范检查,顾名思义,是从 Go 语言层面出发,依据 Go 语言的规范,对你写的代码进行的静态扫描检查,这种检查和你的业务无关。
比如你定义了个常量,从未使用过,虽然对代码运行并没有造成什么影响,但是这个常量是可以删除的,代码如下所示:
ch19/main.go
const name = "飞雪无情"
func main() {
}
示例中的常量 name 其实并没有使用,所以为了节省内存你可以删除它,这种未使用常量的情况就可以通过代码规范检查检测出来。
再比如,你调用了一个函数,该函数返回了一个 error但是你并没有对该 error 做判断,这种情况下,程序也可以正常编译运行。但是代码写得不严谨,因为返回的 error 被我们忽略了。代码如下所示:
ch19/main.go
func main() {
os.Mkdir("tmp",0666)
}
示例代码中Mkdir 函数是有返回 error 的,但是你并没有对返回的 error 做判断,这种情况下,哪怕创建目录失败,你也不知道,因为错误被你忽略了。如果你使用代码规范检查,这类潜在的问题也会被检测出来。
以上两个例子可以帮你理解什么是代码规范检查、它有什么用。除了这两种情况,还有拼写问题、死代码、代码简化检测、命名中带下划线、冗余代码等,都可以使用代码规范检查检测出来。
golangci-lint
要想对代码进行检查,则需要对代码进行扫描,静态分析写的代码是否存在规范问题。
小提示:静态代码分析是不会运行代码的。
可用于 Go 语言代码分析的工具有很多,比如 golint、gofmt、misspell 等,如果一一引用配置,就会比较烦琐,所以通常我们不会单独地使用它们,而是使用 golangci-lint。
golangci-lint 是一个集成工具,它集成了很多静态代码分析工具,便于我们使用。通过配置这一工具,我们可以很灵活地启用需要的代码规范检查。
如果要使用 golangci-lint首先需要安装。因为 golangci-lint 本身就是 Go 语言编写的,所以我们可以从源代码安装它,打开终端,输入如下命令即可安装。
➜ go get github.com/golangci/golangci-lint/cmd/[email protected]
使用这一命令安装的是 v1.32.2 版本的 golangci-lint安装完成后在终端输入如下命令检测是否安装成功。
➜ golangci-lint version
golangci-lint has version v1.32.2
小提示:在 MacOS 下也可以使用 brew 来安装 golangci-lint。
好了,安装成功 golangci-lint 后,就可以使用它进行代码检查了,我以上面示例中的常量 name 和 Mkdir 函数为例,演示 golangci-lint 的使用。在终端输入如下命令回车:
➜ golangci-lint run ch19/
这一示例表示要检测目录中 ch19 下的代码,运行后可以看到如下输出结果。
ch19/main.go:5:7: `name` is unused (deadcode)
const name = "飞雪无情"
^
ch19/main.go:8:10: Error return value of `os.Mkdir` is not checked (errcheck)
os.Mkdir("tmp",0666)
通过代码检测结果可以看到,我上一小节提到的两个代码规范问题都被检测出来了。检测出问题后,你就可以修复它们,让代码更加符合规范。
golangci-lint 配置
golangci-lint 的配置比较灵活,比如你可以自定义要启用哪些 linter。golangci-lint 默认启用的 linter包括这些
deadcode - 死代码检查
errcheck - 返回错误是否使用检查
gosimple - 检查代码是否可以简化
govet - 代码可疑检查,比如格式化字符串和类型不一致
ineffassign - 检查是否有未使用的代码
staticcheck - 静态分析检查
structcheck - 查找未使用的结构体字段
typecheck - 类型检查
unused - 未使用代码检查
varcheck - 未使用的全局变量和常量检查
小提示golangci-lint 支持的更多 linter可以在终端中输入 golangci-lint linters 命令查看,并且可以看到每个 linter 的说明。
如果要修改默认启用的 linter就需要对 golangci-lint 进行配置。即在项目根目录下新建一个名字为 .golangci.yml 的文件,这就是 golangci-lint 的配置文件。在运行代码规范检查的时候golangci-lint 会自动使用它。假设我只启用 unused 检查,可以这样配置:
.golangci.yml
linters:
disable-all: true
enable:
- unused
在团队多人协作开发中,有一个固定的 golangci-lint 版本是非常重要的,这样大家就可以基于同样的标准检查代码。要配置 golangci-lint 使用的版本也比较简单,在配置文件中添加如下代码即可:
service:
golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly
此外,你还可以针对每个启用的 linter 进行配置,比如要设置拼写检测的语言为 US可以使用如下代码设置
linters-settings:
misspell:
locale: US
golangci-lint 的配置比较多,你自己可以灵活配置。关于 golangci-lint 的更多配置可以参考官方文档,这里我给出一个常用的配置,代码如下:
.golangci.yml
linters-settings:
golint:
min-confidence: 0
misspell:
locale: US
linters:
disable-all: true
enable:
- typecheck
- goimports
- misspell
- govet
- golint
- ineffassign
- gosimple
- deadcode
- structcheck
- unused
- errcheck
service:
golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly
集成 golangci-lint 到 CI
代码检查一定要集成到 CI 流程中效果才会更好这样开发者提交代码的时候CI 就会自动检查代码,及时发现问题并进行修正。
不管你是使用 Jenkins还是 Gitlab CI或者 Github Action都可以通过Makefile的方式运行 golangci-lint。现在我在项目根目录下创建一个 Makefile 文件,并添加如下代码:
Makefile
getdeps:
@mkdir -p ${GOPATH}/bin
@which golangci-lint 1>/dev/null || (echo "Installing golangci-lint" && go get github.com/golangci/golangci-lint/cmd/[email protected])
lint:
@echo "Running $@ check"
@GO111MODULE=on ${GOPATH}/bin/golangci-lint cache clean
@GO111MODULE=on ${GOPATH}/bin/golangci-lint run --timeout=5m --config ./.golangci.yml
verifiers: getdeps lint
小提示:关于 Makefile 的知识可以网上搜索学习一下,比较简单,这里不再进行讲述。
好了,现在你就可以把如下命令添加到你的 CI 中了,它可以帮你自动安装 golangci-lint并检查你的代码。
make verifiers
性能优化
性能优化的目的是让程序更好、更快地运行,但是它不是必要的,这一点一定要记住。所以在程序开始的时候,你不必刻意追求性能优化,先大胆地写你的代码就好了,写正确的代码是性能优化的前提。
堆分配还是栈
在比较古老的 C 语言中,内存分配是手动申请的,内存释放也需要手动完成。
手动控制有一个很大的好处就是你需要多少就申请多少,可以最大化地利用内存;
但是这种方式也有一个明显的缺点,就是如果忘记释放内存,就会导致内存泄漏。
所以为了让程序员更好地专注于业务代码的实现Go 语言增加了垃圾回收机制,自动地回收不再使用的内存。
Go 语言有两部分内存空间:栈内存和堆内存。
栈内存由编译器自动分配和释放,开发者无法控制。栈内存一般存储函数中的局部变量、参数等,函数创建的时候,这些内存会被自动创建;函数返回的时候,这些内存会被自动释放。
堆内存的生命周期比栈内存要长,如果函数返回的值还会在其他地方使用,那么这个值就会被编译器自动分配到堆上。堆内存相比栈内存来说,不能自动被编译器释放,只能通过垃圾回收器才能释放,所以栈内存效率会很高。
逃逸分析
既然栈内存的效率更高,肯定是优先使用栈内存。那么 Go 语言是如何判断一个变量应该分配到堆上还是栈上的呢?这就需要逃逸分析了。下面我通过一个示例来讲解逃逸分析,代码如下:
ch19/main.go
func newString() *string{
s:=new(string)
*s = "飞雪无情"
return s
}
在这个示例中:
通过 new 函数申请了一块内存;
然后把它赋值给了指针变量 s
最后通过 return 关键字返回。
小提示:以上 newString 函数是没有意义的,这里只是为了方便演示。
现在我通过逃逸分析来看下是否发生了逃逸,命令如下:
➜ go build -gcflags="-m -l" ./ch19/main.go
# command-line-arguments
ch19/main.go:16:8: new(string) escapes to heap
在这一命令中,-m 表示打印出逃逸分析信息,-l 表示禁止内联,可以更好地观察逃逸。从以上输出结果可以看到,发生了逃逸,也就是说指针作为函数返回值的时候,一定会发生逃逸。
逃逸到堆内存的变量不能马上被回收,只能通过垃圾回收标记清除,增加了垃圾回收的压力,所以要尽可能地避免逃逸,让变量分配在栈内存上,这样函数返回时就可以回收资源,提升效率。
下面我对 newString 函数进行了避免逃逸的优化,优化后的函数代码如下:
ch19/main.go
func newString() string{
s:=new(string)
*s = "飞雪无情"
return *s
}
再次通过命令查看以上代码的逃逸分析,命令如下:
➜ go build -gcflags="-m -l" ./ch19/main.go
# command-line-arguments
ch19/main.go:14:8: new(string) does not escape
通过分析结果可以看到,虽然还是声明了指针变量 s但是函数返回的并不是指针所以没有发生逃逸。
这就是关于指针作为函数返回逃逸的例子,那么是不是不使用指针就不会发生逃逸了呢?下面看个例子,代码如下:
fmt.Println("飞雪无情")
同样运行逃逸分析,你会看到如下结果:
➜ go build -gcflags="-m -l" ./ch19/main.go
# command-line-arguments
ch19/main.go:13:13: ... argument does not escape
ch19/main.go:13:14: "飞雪无情" escapes to heap
ch19/main.go:17:8: new(string) does not escape
观察这一结果,你会发现「飞雪无情」这个字符串逃逸到了堆上,这是因为「飞雪无情」这个字符串被已经逃逸的指针变量引用,所以它也跟着逃逸了,引用代码如下:
func (p *pp) printArg(arg interface{}, verb rune) {
p.arg = arg
//省略其他无关代码
}
所以被已经逃逸的指针引用的变量也会发生逃逸。
Go 语言中有 3 个比较特殊的类型,它们是 slice、map 和 chan被这三种类型引用的指针也会发生逃逸看个这样的例子
ch19/main.go
func main() {
m:=map[int]*string{}
s:="飞雪无情"
m[0] = &s
}
同样运行逃逸分析,你看到的结果是:
➜ gotour go build -gcflags="-m -l" ./ch19/main.go
# command-line-arguments
ch19/main.go:16:2: moved to heap: s
ch19/main.go:15:20: map[int]*string literal does not escape
从这一结果可以看到,变量 m 没有逃逸,反而被变量 m 引用的变量 s 逃逸到了堆上。所以被map、slice 和 chan 这三种类型引用的指针一定会发生逃逸的。
逃逸分析是判断变量是分配在堆上还是栈上的一种方法,在实际的项目中要尽可能避免逃逸,这样就不会被 GC 拖慢速度,从而提升效率。
小技巧:从逃逸分析来看,指针虽然可以减少内存的拷贝,但它同样会引起逃逸,所以要根据实际情况选择是否使用指针。
优化技巧
通过前面小节的介绍,相信你已经了解了栈内存和堆内存,以及变量什么时候会逃逸,那么在优化的时候思路就比较清晰了,因为都是基于以上原理进行的。下面我总结几个优化的小技巧:
第 1 个需要介绍的技巧是尽可能避免逃逸,因为栈内存效率更高,还不用 GC。比如小对象的传参array 要比 slice 效果好。
如果避免不了逃逸,还是在堆上分配了内存,那么对于频繁的内存申请操作,我们要学会重用内存,比如使用 sync.Pool这是第 2 个技巧。
第 3 个技巧就是选用合适的算法,达到高性能的目的,比如空间换时间。
小提示:性能优化的时候,要结合基准测试,来验证自己的优化是否有提升。
以上是基于 GO 语言的内存管理机制总结出的 3 个方向的技巧,基于这 3 个大方向基本上可以优化出你想要的效果。除此之外,还有一些小技巧,比如要尽可能避免使用锁、并发加锁的范围要尽可能小、使用 StringBuilder 做 string 和 [ ] byte 之间的转换、defer 嵌套不要太多等等。
最后推荐一个 Go 语言自带的性能剖析的工具 pprof通过它你可以查看 CPU 分析、内存分析、阻塞分析、互斥锁分析,它的使用不是太复杂,你可以搜索下它的使用教程,这里就不展开介绍。
总结
这节课主要介绍了代码规范检查和性能优化两部分内容,其中代码规范检查是从工具使用的角度讲解,而性能优化可能涉及的点太多,所以是从原理的角度讲解,你明白了原理,就能更好地优化你的代码。
我认为是否进行性能优化取决于两点:业务需求和自我驱动。所以不要刻意地去做性能优化,尤其是不要提前做,先保证代码正确并上线,然后再根据业务需要,决定是否进行优化以及花多少时间优化。自我驱动其实是一种编码能力的体现,比如有经验的开发者在编码的时候,潜意识地就避免了逃逸,减少了内存拷贝,在高并发的场景中设计了低延迟的架构。
最后给你留个作业,把 golangci-lint 引入自己的项目吧,相信你的付出会有回报的。
下一讲我将介绍“协作开发:模块化管理为什么能够提升研发效能”,记得来听课!

View File

@ -0,0 +1,258 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 协作开发:模块化管理为什么能够提升研发效能?
任何业务,都是从简单向复杂演进的。而在业务演进的过程中,技术是从单体向多模块、多服务演进的。技术的这种演进方式的核心目的是复用代码、提高效率,这一讲,我会为你介绍 Go 语言是如何通过模块化的管理,提升开发效率的。
Go 语言中的包
什么是包
在业务非常简单的时候,你甚至可以把代码写到一个 Go 文件中。但随着业务逐渐复杂,你会发现,如果代码都放在一个 Go 文件中,会变得难以维护,这时候你就需要抽取代码,把相同业务的代码放在一个目录中。在 Go 语言中,这个目录叫作包。
在 Go 语言中一个包是通过package 关键字定义的最常见的就是main 包,它的定义如下所示:
package main
此外,前面章节演示示例经常使用到的 fmt 包,也是通过 package 关键字声明的。
一个包就是一个独立的空间,你可以在这个包里定义函数、结构体等。这时,我们认为这些函数、结构体是属于这个包的。
使用包
如果你想使用一个包里的函数或者结构体,就需要先导入这个包,才能使用,比如常用的 fmt包代码示例如下所示。
package main
import "fmt"
func main() {
fmt.Println("先导入fmt包才能使用")
}
要导入一个包,需要使用 import 关键字;如果需要同时导入多个包,则可以使用小括号,示例代码如下所示。
import (
"fmt"
"os"
)
从以上示例可以看到,该示例导入了 fmt 和 os 这两个包,使用了小括号,每一行写了一个要导入的包。
作用域
讲到了包之间的导入和使用,就不得不提作用域这个概念,因为只有满足作用域的函数才可以被调用。
在Java 语言中,通过 public、private 这些修饰符修饰一个类的作用域;
但是在Go 语言中,并没有这样的作用域修饰符,它是通过首字母是否大写来区分的,这同时也体现了 Go 语言的简洁。
如上述示例中 fmt 包中的Println 函数:
它的首字母就是大写的 P所以该函数才可以在 main 包中使用;
如果 Println 函数的首字母是小写的 p那么它只能在 fmt 包中被使用,不能跨包使用。
这里我为你总结下 Go 语言的作用域:
Go 语言中,所有的定义,比如函数、变量、结构体等,如果首字母是大写,那么就可以被其他包使用;
反之,如果首字母是小写的,就只能在同一个包内使用。
自定义包
你也可以自定义自己的包,通过包的方式把相同业务、相同职责的代码放在一起。比如你有一个 util 包,用于存放一些常用的工具函数,项目结构如下所示:
ch20
├── main.go
└── util
└── string.go
在 Go 语言中,一个包对应一个文件夹,上面的项目结构示例也验证了这一点。在这个示例中,有一个 util 文件夹,它里面有一个 string.go 文件,这个 Go 语言文件就属于 util 包,它的包定义如下所示:
ch20/util/string.go
package util
可以看到Go 语言中的包是代码的一种组织形式,通过包把相同业务或者相同职责的代码放在一起。通过包对代码进行归类,便于代码维护以及被其他包调用,提高团队协作效率。
init 函数
除了 main 这个特殊的函数外Go 语言还有一个特殊的函数——init通过它可以实现包级别的一些初始化操作。
init 函数没有返回值,也没有参数,它先于 main 函数执行,代码如下所示:
func init() {
fmt.Println("init in main.go ")
}
一个包中可以有多个 init 函数,但是它们的执行顺序并不确定,所以如果你定义了多个 init 函数的话,要确保它们是相互独立的,一定不要有顺序上的依赖。
那么 init 函数作用是什么呢? 其实就是在导入一个包时,可以对这个包做一些必要的初始化操作,比如数据库连接和一些数据的检查,确保我们可以正确地使用这个包。
Go 语言中的模块
如果包是比较低级的代码组织形式的话,那么模块就是更高级别的,在 Go 语言中,一个模块可以包含很多个包,所以模块是相关的包的集合。
在 Go 语言中:
一个模块通常是一个项目,比如这个专栏实例中使用的 gotour 项目;
也可以是一个框架,比如常用的 Web 框架 gin。
go mod
Go 语言为我们提供了 go mod 命令来创建一个模块(项目),比如要创建一个 gotour 模块,你可以通过如下命令实现:
➜ go mod init gotour
go: creating new go.mod: module gotour
运行这一命令后,你会看到已经创建好一个名字为 gotour 的文件夹,里面有一个 go.mod 文件,它里面的内容如下所示:
module gotour
go 1.15
第一句是该项目的模块名,也就是 gotour
第二句表示要编译该模块至少需要Go 1.15 版本的 SDK。
小提示:模块名最好是以自己的域名开头,比如 flysnow.org/gotour这样就可以很大程度上保证模块名的唯一不至于和其他模块重名。
使用第三方模块
模块化为什么可以提高开发效率最重要的原因就是复用了现有的模块Go 语言也不例外。比如你可以把项目中的公共代码抽取为一个模块,这样就可以供其他项目使用,不用再重复开发;同理,在 Github 上也有很多开源的 Go 语言项目,它们都是一个个独立的模块,也可以被我们直接使用,提高我们的开发效率,比如 Web 框架 gin-gonic/gin。
众所周知,在使用第三方模块之前,需要先设置下 Go 代理,也就是 GOPROXY这样我们就可以获取到第三方模块了。
在这里我推荐 goproxy.io 这个代理,非常好用,速度也很快。要使用这个代理,需要进行如下代码设置:
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.io,direct
打开终端,输入这一命令回车即可设置成功。
在实际的项目开发中,除了第三方模块外,还有我们自己开发的模块,放在了公司的 GitLab上这时候就要把公司 Git 代码库的域名排除在 Go PROXY 之外,为此 Go 语言提供了GOPRIVATE 这个环境变量帮助我们达到目的。通过如下命令即可设置 GOPRIVATE
# 设置不走 proxy 的私有仓库,多个用逗号相隔(可选)
go env -w GOPRIVATE=*.corp.example.com
以上域名只是一个示例,实际使用时你要改成自己公司私有仓库的域名。
一切都准备好就可以使用第三方的模块了,假设我们要使用 Gin 这个 Web 框架,首先需要安装它,通过如下命令即可安装 Gin 这个 Web 框架:
go get -u github.com/gin-gonic/gin
安装成功后,就可以像 Go 语言的标准包一样,通过 import 命令导入你的代码中使用它,代码如下所示:
package main
import (
"fmt"
"github.com/gin-gonic/gin"
)
func main() {
fmt.Println("先导入fmt包才能使用")
r := gin.Default()
r.Run()
}
以上代码现在还无法编译通过,因为还没有同步 Gin 这个模块的依赖也就是没有把它添加到go.mod 文件中。通过如下命令可以添加缺失的模块:
go mod tidy
运行这一命令,就可以把缺失的模块添加进来,同时它也可以移除不再需要的模块。这时你再查看 go.mod 文件,会发现内容已经变成了这样:
module gotour
go 1.15
require (
github.com/gin-gonic/gin v1.6.3
github.com/golang/protobuf v1.4.2 // indirect
github.com/google/go-cmp v0.5.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/stretchr/testify v1.6.1 // indirect
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
)
所以我们不用手动去修改 go.mod 文件,通过 Go 语言的工具链比如 go mod tidy 命令,就可以帮助我们自动地维护、自动地添加或者修改 go.mod 的内容。
总结
在 Go 语言中,包是同一目录中,编译在一起的源文件的集合。包里面含有函数、类型、变量和常量,不同包之间的调用,必须要首字母大写才可以。
而模块又是相关的包的集合,它里面包含了很多为了实现该模块的包,并且还可以通过模块的方式,把已经完成的模块提供给其他项目(模块)使用,达到了代码复用、研发效率提高的目的。
所以对于你的项目(模块)来说,它具有模块 ➡ 包 ➡ 函数类型这样三层结构,同一个模块中,可以通过包组织代码,达到代码复用的目的;在不同模块中,就需要通过模块的引入,达到这个目的。
编程界有个谚语:不要重复造轮子,使用现成的轮子,可以提高开发效率,降低 Bug 率。Go 语言提供的模块、包这些能力,就可以很好地让我们使用现有的轮子,在多人协作开发中,更好地提高工作效率。
最后,为你留个作业:基于模块化拆分你所做的项目,提取一些公共的模块,以供更多项目使用。相信这样你们的开发效率会大大提升的。

View File

@ -0,0 +1,454 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 网络编程Go 语言如何玩转 RESTful API 服务?
从这一讲开始,我将带你学习本专栏的第五模块,在这个模块中,你将学到我们项目中最常用的编码操作,也就是编写 RESTful API 和 RPC 服务。在实际开发项目中,你编写的这些服务可以被其他服务使用,这样就组成了微服务的架构;也可以被前端调用,这样就可以前后端分离。
今天我就先来为你介绍什么是 RESTful API以及 Go 语言是如何玩转 RESTful API 的。
什么是 RESTful API
RESTful API 是一套规范,它可以规范我们如何对服务器上的资源进行操作。在了解 RESTful API 之前,我先为你介绍下 HTTP Method因为 RESTful API 和它是密不可分的。
说起 HTTP Method最常见的就是POST和GET其实最早在 HTTP 0.9 版本中只有一个GET方法该方法是一个幂等方法用于获取服务器上的资源也就是我们在浏览器中直接输入网址回车请求的方法。
在 HTTP 1.0 版本中又增加了HEAD和POST方法其中常用的是 POST 方法,一般用于给服务端提交一个资源,导致服务器的资源发生变化。
随着网络越来越复杂,发现这两个方法是不够用的,就继续新增了方法。所以在 HTTP1.1 版本的时候,一口气增加到了 9 个,新增的方法有 HEAD、OPTIONS、PUT、DELETE、TRACE、PATCH 和 CONNECT。下面我为你一一介绍它们的作用。
GET 方法可请求一个指定资源的表示形式,使用 GET 的请求应该只被用于获取数据。
HEAD 方法用于请求一个与 GET 请求的响应相同的响应,但没有响应体。
POST 方法用于将实体提交到指定的资源,通常导致服务器上的状态变化或副作用。
PUT 方法用于请求有效载荷替换目标资源的所有当前表示。
DELETE 方法用于删除指定的资源。
CONNECT 方法用于建立一个到由目标资源标识的服务器的隧道。
OPTIONS 方法用于描述目标资源的通信选项。
TRACE 方法用于沿着到目标资源的路径执行一个消息环回测试。
PATCH 方法用于对资源应用部分修改。
从以上每个方法的介绍可以看到HTTP 规范针对每个方法都给出了明确的定义,所以我们使用的时候也要尽可能地遵循这些定义,这样我们在开发中才可以更好地协作。
理解了这些 HTTP 方法,就可以更好地理解 RESTful API 规范了,因为 RESTful API 规范就是基于这些 HTTP 方法规范我们对服务器资源的操作,同时规范了 URL 的样式和 HTTP Status Code。
在 RESTful API 中,使用的主要是以下五种 HTTP 方法:
GET表示读取服务器上的资源
POST表示在服务器上创建资源
PUT表示更新或者替换服务器上的资源
DELETE表示删除服务器上的资源
PATCH表示更新 / 修改资源的一部分。
以上 HTTP 方法在 RESTful API 规范中是一个操作,操作的就是服务器的资源,服务器的资源通过特定的 URL 表示。
现在我们通过一些示例让你更好地理解 RESTful API如下所示
HTTP GET https://www.flysnow.org/users
HTTP GET https://www.flysnow.org/users/123
以上是两个 GET 方法的示例:
第一个表示获取所有用户的信息;
第二个表示获取 ID 为 123 用户的信息。
下面再看一个 POST 方法的示例,如下所示:
HTTP POST https://www.flysnow.org/users
这个示例表示创建一个用户,通过 POST 方法给服务器提供创建这个用户所需的全部信息。
注意:这里 users 是个复数。
现在你已经知道了如何创建一个用户,那么如果要更新某个特定的用户怎么做呢?其实也非常简单,示例代码如下所示:
HTTP PUT https://www.flysnow.org/users/123
这表示要更新 / 替换 ID 为 123 的这个用户,在更新的时候,会通过 PUT 方法提供更新这个用户需要的全部用户信息。这里 PUT 方法和 POST 方法不太一样的是,从 URL 上看PUT 方法操作的是单个资源,比如这里 ID 为 123 的用户。
小提示:如果要更新一个用户的部分信息,使用 PATCH 方法更恰当。
看到这里,相信你已经知道了如何删除一个用户,示例代码如下所示:
HTTP DELETE https://www.flysnow.org/users/123
DELETE 方法的使用和 PUT 方法一样,也是操作单个资源,这里是删除 ID 为 123 的这个用户。
一个简单的 RESTful API
相信你已经非常了解什么是 RESTful API 了,现在开始,我会带你通过一个使用 Golang 实现 RESTful API 风格的示例,加深 RESTful API 的理解。
Go 语言的一个很大的优势,就是可以很容易地开发出网络后台服务,而且性能快、效率高。在开发后端 HTTP 网络应用服务的时候,我们需要处理很多 HTTP 的请求访问比如常见的RESTful API 服务,就要处理很多 HTTP 请求然后把处理的信息返回给使用者。对于这类需求Golang 提供了内置的 net/http 包帮我们处理这些 HTTP 请求,让我们可以比较方便地开发一个 HTTP 服务。
下面我们来看一个简单的 HTTP 服务的 Go 语言实现,代码如下所示:
ch21/main.go
func main() {
http.HandleFunc("/users",handleUsers)
http.ListenAndServe(":8080", nil)
}
func handleUsers(w http.ResponseWriter, r *http.Request){
fmt.Fprintln(w,"ID:1,Name:张三")
fmt.Fprintln(w,"ID:2,Name:李四")
fmt.Fprintln(w,"ID:3,Name:王五")
}
这个示例运行后,你在浏览器中输入 http://localhost:8080/users, 就可以看到如下内容信息:
ID:1,Name:张三
ID:2,Name:李四
ID:3,Name:王五
也就是获取所有的用户信息,但是这并不是一个 RESTful API因为使用者不仅可以通过 HTTP GET 方法获得所有的用户信息,还可以通过 POST、DELETE、PUT 等 HTTP 方法获得所有的用户信息,这显然不符合 RESTful API 的规范。
现在我对以上示例进行修改,使它符合 RESTful API 的规范,修改后的示例代码如下所示:
ch20/main.go
func handleUsers(w http.ResponseWriter, r *http.Request){
switch r.Method {
case "GET":
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w,"ID:1,Name:张三")
fmt.Fprintln(w,"ID:2,Name:李四")
fmt.Fprintln(w,"ID:3,Name:王五")
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w,"not found")
}
}
这里我只修改了 handleUsers 函数,在该函数中增加了只在使用 GET 方法时,才获得所有用户的信息,其他情况返回 not found。
现在再运行这个示例,会发现只能通过 HTTP GET 方法进行访问了,使用其他方法会提示 not found。
RESTful JSON API
在项目中最常见的是使用 JSON 格式传输信息,也就是我们提供的 RESTful API 要返回 JSON 内容给使用者。
同样用上面的示例,我把它改造成可以返回 JSON 内容的方式,示例代码如下所示:
ch20/main.go
//数据源类似MySQL中的数据
var users = []User{
{ID: 1,Name: "张三"},
{ID: 2,Name: "李四"},
{ID: 3,Name: "王五"},
}
func handleUsers(w http.ResponseWriter, r *http.Request){
switch r.Method {
case "GET":
users,err:=json.Marshal(users)
if err!=nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w,"{\"message\": \""+err.Error()+"\"}")
}else {
w.WriteHeader(http.StatusOK)
w.Write(users)
}
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w,"{\"message\": \"not found\"}")
}
}
//用户
type User struct {
ID int
Name string
}
从以上代码可以看到,这次的改造主要是新建了一个 User 结构体,并且使用 users 这个切片存储所有的用户,然后在 handleUsers 函数中把它转化为一个 JSON 数组返回。这样,就实现了基于 JSON 数据格式的 RESTful API。
运行这个示例,在浏览器中输入 http://localhost:8080/users可以看到如下信息
[{"ID":1,"Name":"张三"},{"ID":2,"Name":"李四"},{"ID":3,"Name":"王五"}]
这已经是 JSON 格式的用户信息,包含了所有用户。
Gin 框架
虽然 Go 语言自带的 net/http 包,可以比较容易地创建 HTTP 服务,但是它也有很多不足:
不能单独地对请求方法POST、GET 等)注册特定的处理函数;
不支持 Path 变量参数;
不能自动对 Path 进行校准;
性能一般;
扩展性不足;
……
基于以上这些不足,出现了很多 Golang Web 框架,如 MuxGin、Fiber 等,今天我要为你介绍的就是这款使用最多的 Gin 框架。
引入 Gin 框架
Gin 框架是一个在 Github 上开源的 Web 框架,封装了很多 Web 开发需要的通用功能,并且性能也非常高,可以让我们很容易地写出 RESTful API。
Gin 框架其实是一个模块,也就是 Go Mod所以采用 Go Mod 的方法引入即可。我在第 18讲的时候详细介绍过如何引入第三方的模块这里再复习一下。
首先需要下载安装 Gin 框架,安装代码如下:
$ go get -u github.com/gin-gonic/gin
然后就可以在 Go 语言代码中导入使用了,导入代码如下:
import "github.com/gin-gonic/gin"
通过以上安装和导入这两个步骤,就可以在你的 Go 语言项目中使用 Gin 框架了。
使用 Gin 框架
现在,已经引入了 Gin 框架,下面我就是用 Gin 框架重写上面的示例,修改的代码如下所示:
ch21/main.go
func main() {
r:=gin.Default()
r.GET("/users", listUser)
r.Run(":8080")
}
func listUser(c *gin.Context) {
c.JSON(200,users)
}
相比 net/http 包Gin 框架的代码非常简单,通过它的 GET 方法就可以创建一个只处理 HTTP GET 方法的服务,而且输出 JSON 格式的数据也非常简单,使用 c.JSON 方法即可。
最后通过 Run 方法启动 HTTP 服务,监听在 8080 端口。现在运行这个 Gin 示例,在浏览器中输入 http://localhost:8080/users看到的信息和通过 net/http 包实现的效果是一样的。
获取特定的用户
现在你已经掌握了如何使用 Gin 框架创建一个简单的 RESTful API并且可以返回所有的用户信息那么如何获取特定用户的信息呢
我们知道,如果要获得特定用户的信息,需要使用的是 GET 方法,并且 URL 格式如下所示:
http://localhost:8080/users/2
以上示例中的 2 是用户的 ID也就是通过 ID 来获取特定的用户。
下面我通过 Gin 框架 Path 路径参数来实现这个功能,示例代码如下:
ch21/main.go
func main() {
//省略没有改动的代码
r.GET("/users/:id", getUser)
}
func getUser(c *gin.Context) {
id := c.Param("id")
var user User
found := false
//类似于数据库的SQL查询
for _, u := range users {
if strings.EqualFold(id, strconv.Itoa(u.ID)) {
user = u
found = true
break
}
}
if found {
c.JSON(200, user)
} else {
c.JSON(404, gin.H{
"message": "用户不存在",
})
}
}
在 Gin 框架中,路径中使用冒号表示 Path 路径参数,比如示例中的 :id然后在 getUser 函数中可以通过 c.Param(“id”) 获取需要查询用户的 ID 值。
小提示Param 方法的参数要和 Path 路径参数中的一致,比如示例中都是 ID。
现在运行这个示例,通过浏览器访问 http://localhost:8080/users/2就可以获得 ID 为 2 的用户,输出信息如下所示:
{"ID":2,"Name":"李四"}
可以看到,已经正确的获取到了 ID 为 2 的用户,他的名字叫李四。
假如我们访问一个不存在的 ID会得到什么结果呢比如 99示例如下所示
➜ curl http://localhost:8080/users/99
{"message":"用户不存在"}%
从以上示例输出可以看到,返回了『用户不存在』的信息,和我们代码中处理的逻辑一样。
新增一个用户
现在你已经可以使用 Gin 获取所有用户,还可以获取特定的用户,那么你也应该知道如何新增一个用户了,现在我通过 Gin 实现如何新增一个用户,看和你想的方案是否相似。
根据 RESTful API 规范,实现新增使用的是 POST 方法,并且 URL 的格式为 http://localhost:8080/users ,向这个 URL 发送数据,就可以新增一个用户,然后返回创建的用户信息。
现在我使用 Gin 框架实现新增一个用户,示例代码如下:
func main() {
//省略没有改动的代码
r.POST("/users", createUser)
}
func createUser(c *gin.Context) {
name := c.DefaultPostForm("name", "")
if name != "" {
u := User{ID: len(users) + 1, Name: name}
users = append(users, u)
c.JSON(http.StatusCreated,u)
} else {
c.JSON(http.StatusOK, gin.H{
"message": "请输入用户名称",
})
}
}
以上新增用户的主要逻辑是获取客户端上传的 name 值,然后生成一个 User 用户,最后把它存储到 users 集合中,达到新增用户的目的。
在这个示例中,使用 POST 方法来新增用户,所以只能通过 POST 方法才能新增用户成功。
现在运行这个示例,然后通过如下命令发送一个新增用户的请求,查看结果:
➜ curl -X POST -d 'name=飞雪' http://localhost:8080/users
{"ID":4,"Name":"飞雪"}
可以看到新增用户成功,并且返回了新增的用户,还有分配的 ID。
总结
Go 语言已经给我们提供了比较强大的 SDK让我们可以很容易地开发网络服务的应用而借助第三方的 Web 框架,可以让这件事情更容易、更高效。比如这篇文章介绍的 Gin 框架,就可以很容易让我们开发出 RESTful API更多关于 Gin 框架的使用可以参考 Golang Gin 实战系列文章。
在我们做项目开发的时候,要善于借助已经有的轮子,让自己的开发更有效率,也更容易实现。
在我们做项目开发的时候,会有增、删、改和查,现在增和查你已经学会了,那么就给你留 2 个作业,任选其中 1 个即可,它们是:
修改一个用户的名字;
删除一个用户。
下一讲,也就是本专栏的最后一讲,我将为你介绍如何使用 Go 语言实现 RPC 服务,记得来听课哦。

View File

@ -0,0 +1,587 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 网络编程Go 语言如何通过 RPC 实现跨平台服务?
在上一讲中,我为你讲解了 RESTful API 的规范以及实现,并且留了两个作业,它们分别是删除和修改用户,现在我为你讲解这两个作业。
删除一个用户比较简单,它的 API 格式和获取一个用户一样,但是 HTTP 方法换成了DELETE。删除一个用户的示例代码如下所示
ch21/main.go
func main() {
//省略没有修改的代码
r.DELETE("/users/:id", deleteUser)
}
func deleteUser(c *gin.Context) {
id := c.Param("id")
i := -1
//类似于数据库的SQL查询
for index, u := range users {
if strings.EqualFold(id, strconv.Itoa(u.ID)) {
i = index
break
}
}
if i >= 0 {
users = append(users[:i], users[i+1:]...)
c.JSON(http.StatusNoContent, "")
} else {
c.JSON(http.StatusNotFound, gin.H{
"message": "用户不存在",
})
}
}
这个示例的逻辑就是注册 DELETE 方法达到删除用户的目的。删除用户的逻辑是通过ID 查询:
如果可以找到要删除的用户,记录索引并跳出循环,然后根据索引删除该用户;
如果找不到要删除的用户,则返回 404。
实现了删除用户的逻辑后,相信你已经会修改一个用户的名字了,因为它和删除一个用户非常像,实现代码如下所示:
func main() {
//省略没有修改的代码
r.PATCH("/users/:id",updateUserName)
}
func updateUserName(c *gin.Context) {
id := c.Param("id")
i := -1
//类似于数据库的SQL查询
for index, u := range users {
if strings.EqualFold(id, strconv.Itoa(u.ID)) {
i = index
break
}
}
if i >= 0 {
users[i].Name = c.DefaultPostForm("name",users[i].Name)
c.JSON(http.StatusOK, users[i])
} else {
c.JSON(http.StatusNotFound, gin.H{
"message": "用户不存在",
})
}
}
整体代码逻辑和删除的差不多的,只不过这里使用的是 PATCH方法。
什么是RPC 服务
RPC也就是远程过程调用是分布式系统中不同节点调用的方式进程间通信属于 C/S 模式。RPC 由客户端发起,调用服务端的方法进行通信,然后服务端把结果返回给客户端。
RPC的核心有两个通信协议和序列化。在 HTTP 2 之前,一般采用自定义 TCP 协议的方式进行通信HTTP 2 出来后也有采用该协议的比如流行的gRPC。
序列化和反序列化是一种把传输内容编码和解码的方式,常见的编解码方式有 JSON、Protobuf 等。
在大多数 RPC的架构设计中都有Client、Client Stub、Server、Server Stub这四个组件Client 和 Server 之间通过 Socket 进行通信。RPC 架构如下图所示:
(图片来自于 Google 搜索)
下面我为你总结下 RPC 调用的流程:
客户端Client调用客户端存根Client Stub同时把参数传给客户端存根
客户端存根将参数打包编码,并通过系统调用发送到服务端;
客户端本地系统发送信息到服务器;
服务器系统将信息发送到服务端存根Server Stub
服务端存根解析信息,也就是解码;
服务端存根调用真正的服务端程序Sever
服务端Server处理后通过同样的方式把结果再返回给客户端Client
RPC 调用常用于大型项目,也就是我们现在常说的微服务,而且还会包含服务注册、治理、监控等功能,是一套完整的体系。
Go 语言 RPC 简单入门
RPC这么流行Go 语言当然不会错过,在 Go SDK 中,已经内置了 net/rpc 包来帮助开发者实现 RPC。简单来说net/rpc 包提供了通过网络访问服务端对象方法的能力。
现在我通过一个加法运算来演示 RPC的使用它的服务端代码如下所示
ch22/server/math_service.go
package server
type MathService struct {
}
type Args struct {
A, B int
}
func (m *MathService) Add(args Args, reply *int) error {
*reply = args.A + args.B
return nil
}
在以上代码中:
定义了MathService用于表示一个远程服务对象
Args 结构体用于表示参数;
Add 这个方法实现了加法的功能,加法的结果通过 replay这个指针变量返回。
有了这个定义好的服务对象就可以把它注册到暴露的服务列表中以供其他客户端使用了。在Go 语言中要注册一个一个RPC 服务对象还是比较简单的,通过 RegisterName 方法即可,示例代码如下所示:
ch22/server_main.go
package main
import (
"gotour/ch22/server"
"log"
"net"
"net/rpc"
)
func main() {
rpc.RegisterName("MathService",new(server.MathService))
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
rpc.Accept(l)
}
以上示例代码中,通过 RegisterName 函数注册了一个服务对象,该函数接收两个参数:
服务名称MathService
具体的服务对象也就是我刚刚定义好的MathService 这个结构体。
然后通过 net.Listen 函数建立一个TCP 链接,在 1234 端口进行监听,最后通过 rpc.Accept 函数在该 TCP 链接上提供 MathService 这个 RPC 服务。现在客户端就可以看到MathService这个服务以及它的Add 方法了。
任何一个框架都有自己的规则net/rpc 这个 Go 语言提供的RPC 框架也不例外。要想把一个对象注册为 RPC 服务,可以让客户端远程访问,那么该对象(类型)的方法必须满足如下条件:
方法的类型是可导出的(公开的);
方法本身也是可导出的;
方法必须有 2 个参数,并且参数类型是可导出或者内建的;
方法必须返回一个 error 类型。
总结下来,该方法的格式如下所示:
func (t *T) MethodName(argType T1, replyType *T2) error
这里面的 T1、T2都是可以被 encoding/gob 序列化的。
第一个参数 argType 是调用者(客户端)提供的;
第二个参数 replyType是返回给调用者结果必须是指针类型。
有了提供好的RPC 服务,现在再来看下客户端如何调用,它的代码如下所示:
ch22/client_main.go
package main
import (
"fmt"
"gotour/ch22/server"
"log"
"net/rpc"
)
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
args := server.Args{A:7,B:8}
var reply int
err = client.Call("MathService.Add", args, &reply)
if err != nil {
log.Fatal("MathService.Add error:", err)
}
fmt.Printf("MathService.Add: %d+%d=%d", args.A, args.B, reply)
}
在以上实例代码中,首先通过 rpc.Dial 函数建立 TCP 链接,需要注意的是这里的 IP、端口要和RPC 服务提供的一致,确保可以建立 RCP 链接。
TCP 链接建立成功后就需要准备远程方法需要的参数也就是示例中的args 和 reply。参数准备好之后就可以通过 Call 方法调用远程的RPC 服务了。Call 方法有 3 个参数,它们的作用分别如下所示:
调用的远程方法的名字这里是MathService.Add点前面的部分是注册的服务的名称点后面的部分是该服务的方法
客户端为了调用远程方法提供的参数示例中是args
为了接收远程方法返回的结果,必须是一个指针,也就是示例中的& replay这样客户端就可以获得服务端返回的结果了。
服务端和客户端的代码都写好了,现在就可以运行它们,测试 RPC调用的效果了。
首先运行服务端的代码,提供 RPC 服务,运行命令如下所示:
➜ go run ch22/server_main.go
然后运行客户端代码,测试调用 RPC的结果运行命令如下所示
➜ go run ch22/client_main.go
如果你看到了 MathService.Add: 7+8=15的结果那么恭喜你你完成了一个完整的RPC 调用。
基于 HTTP的RPC
RPC 除了可以通过 TCP 协议调用之外还可以通过HTTP 协议进行调用而且内置的net/rpc 包已经支持,现在我修改以上示例代码,支持 HTTP 协议的调用,服务端代码如下所示:
ch22/server_main.go
func main() {
rpc.RegisterName("MathService", new(server.MathService))
rpc.HandleHTTP()//新增的
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
http.Serve(l, nil)//换成http的服务
}
以上是服务端代码的修改,只需修改两处,我已经在代码中标注出来了,很容易理解。
服务端修改的代码不算多,客户端修改的代码就更少了,只需要修改一处即可,修改的部分如下所示:
ch22/client_main.go
func main() {
client, err := rpc.DialHTTP("tcp", "localhost:1234")
//省略了其他没有修改的代码
}
从以上代码可以看到,只需要把建立链接的方法从 Dial 换成 DialHTTP 即可。
现在分别运行服务端和客户端代码就可以看到输出的结果了和上面使用TCP 链接时是一样的。
此外Go 语言 net/rpc 包提供的 HTTP 协议的 RPC 还有一个调试的 URL运行服务端代码后在浏览器中输入 http://localhost:1234/debug/rpc 回车即可看到服务端注册的RPC 服务,以及每个服务的方法,如下图所示:
如上图所示,注册的 RPC 服务、方法的签名、已经被调用的次数都可以看到。
JSON RPC 跨平台通信
以上我实现的RPC 服务是基于 gob 编码的这种编码在跨语言调用的时候比较困难而当前在微服务架构中RPC 服务的实现者和调用者都可能是不同的编程语言,因此我们实现的 RPC 服务要支持多语言的调用。
基于 TCP 的 JSON RPC
实现跨语言 RPC 服务的核心在于选择一个通用的编码这样大多数语言都支持比如常用的JSON。在 Go 语言中,实现一个 JSON RPC 服务非常简单,只需要使用 net/rpc/jsonrpc 包即可。
同样以上面的示例为例,我把它改造成支持 JSON的RPC 服务,服务端代码如下所示:
ch22/server_main.go
func main() {
rpc.RegisterName("MathService", new(server.MathService))
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
for {
conn, err := l.Accept()
if err != nil {
log.Println("jsonrpc.Serve: accept:", err.Error())
return
}
//json rpc
go jsonrpc.ServeConn(conn)
}
}
从以上代码可以看到,相比 gob 编码的RPC 服务JSON 的 RPC 服务是把链接交给了jsonrpc.ServeConn这个函数处理达到了基于 JSON 进行 RPC 调用的目的。
JSON RPC 的客户端代码也非常少,只需要修改一处,修改的部分如下所示:
ch22/client_main.go
func main() {
client, err := jsonrpc.Dial("tcp", "localhost:1234")
//省略了其他没有修改的代码
}
从以上代码可以看到,只需要把建立链接的 Dial方法换成 jsonrpc 包中的即可。
以上是使用 Go 语言作为客户端调用 RPC 服务的示例,其他编程语言也是类似的,只需要遵守 JSON-RPC 规范即可。
基于 HTTP的JSON RPC
相比基于 TCP 调用的RPC 来说,使用 HTTP肯定会更方便也更通用。Go 语言内置的jsonrpc 并没有实现基于 HTTP的传输所以就需要自己来实现这里我参考 gob 编码的HTTP RPC 实现方式,来实现基于 HTTP的JSON RPC 服务。
还是上面的示例,我改造下让其支持 HTTP 协议RPC 服务端代码如下所示:
ch22/server_main.go
func main() {
rpc.RegisterName("MathService", new(server.MathService))
//注册一个path用于提供基于http的json rpc服务
http.HandleFunc(rpc.DefaultRPCPath, func(rw http.ResponseWriter, r *http.Request) {
conn, _, err := rw.(http.Hijacker).Hijack()
if err != nil {
log.Print("rpc hijacking ", r.RemoteAddr, ": ", err.Error())
return
}
var connected = "200 Connected to JSON RPC"
io.WriteString(conn, "HTTP/1.0 "+connected+"\n\n")
jsonrpc.ServeConn(conn)
})
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
http.Serve(l, nil)//换成http的服务
}
以上代码的实现基于 HTTP 协议的核心,即使用 http.HandleFunc 注册了一个 path对外提供基于 HTTP 的 JSON RPC 服务。在这个 HTTP 服务的实现中通过Hijack方法劫持链接然后转交给 jsonrpc 处理,这样就实现了基于 HTTP 协议的 JSON RPC 服务。
实现了服务端的代码后,现在开始实现客户端调用,它的代码如下所示:
func main() {
client, err := DialHTTP("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
args := server.Args{A:7,B:8}
var reply int
err = client.Call("MathService.Add", args, &reply)
if err != nil {
log.Fatal("MathService.Add error:", err)
}
fmt.Printf("MathService.Add: %d+%d=%d", args.A, args.B, reply)
}
// DialHTTP connects to an HTTP RPC server at the specified network address
// listening on the default HTTP RPC path.
func DialHTTP(network, address string) (*rpc.Client, error) {
return DialHTTPPath(network, address, rpc.DefaultRPCPath)
}
// DialHTTPPath connects to an HTTP RPC server
// at the specified network address and path.
func DialHTTPPath(network, address, path string) (*rpc.Client, error) {
var err error
conn, err := net.Dial(network, address)
if err != nil {
return nil, err
}
io.WriteString(conn, "GET "+path+" HTTP/1.0\n\n")
// Require successful HTTP response
// before switching to RPC protocol.
resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "GET"})
connected := "200 Connected to JSON RPC"
if err == nil && resp.Status == connected {
return jsonrpc.NewClient(conn), nil
}
if err == nil {
err = errors.New("unexpected HTTP response: " + resp.Status)
}
conn.Close()
return nil, &net.OpError{
Op: "dial-http",
Net: network + " " + address,
Addr: nil,
Err: err,
}
}
以上这段代码的核心在于通过建立好的TCP 链接,发送 HTTP 请求调用远程的HTTP JSON RPC 服务,这里使用的是 HTTP GET 方法。
分别运行服务端和客户端就可以看到正确的HTTP JSON RPC 调用结果了。
总结
这一讲基于 Go 语言自带的RPC 框架,讲解了 RPC 服务的实现以及调用。通过这一讲的学习相信你可以很好地了解什么是 RPC 服务,基于 TCP 和 HTTP 实现的RPC 服务有什么不同,它们是如何实现的等等。
不过在实际的项目开发中使用Go 语言自带的 RPC 框架并不多,但是这里我还是以自带的框架为例进行讲解,这样可以更好地理解 RPC 的使用以及实现原理。如果你可以很好地掌握它们,那么你使用第三方的 RPC 框架也可以很快上手。
在实际的项目中比较常用的是Google的gRPC 框架它是通过Protobuf 序列化的,是基于 HTTP/2 协议的二进制传输,并且支持很多编程语言,效率也比较高。关于 gRPC的使用可以看官网的文档入门是很容易的。

View File

@ -0,0 +1,63 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 结束语 你的 Go 语言成长之路
我们从 Go 语言的基础知识,到底层原理,再到实战,相信你已经学会了如何使用 Go 语言,并可以上手做项目了。这一路走来,非常感谢你对学习的坚持,以及对我的支持。
在本专栏的最后,我会和你聊下 Go 语言的前景,以及对于你学习 Go 语言编程和在今后职业发展方面,我的一些建议。
Go 语言的发展前景
随着这几年 Dokcer、K8s 的普及,云原生的概念也越来越火,而 Go 语言恰恰就是为云而生的编程语言,所以在云原生的时代,它就具备了天生的优势:易于学习、天然的并发、高效的网络支持、跨平台的二进制文件编译等。
CNCF云原生计算基金会对云原生的定义是
应用容器化;
面向微服务架构;
应用支持容器的编排调度。
我们可以看到,对于这三点有代表性的 Docker、K8s 以及 istio 都是采用 Go 语言编写的,所以 Go 语言在云原生中发挥了极大的优势。
在涉及网络通信、对象存储、协议等领域的工作中Go 语言所展现出的优势要比 Python、C /C++ 更大,所以诸如字节跳动、腾讯等很多大厂都在拥抱 Go 语言的开发,甚至很多公司在业务这一层也采用 Go 语言来开发微服务,从而提高开发和运行效率。
总体来说,对 Go 语言的前景我还是比较看好的,所以本专栏是你 Go 语言学习的敲门砖,接下来我建议你可以对这一语言进行更加系统和全面的学习。
Go 语言学习建议
关于 Go 语言的学习,我建议从官方文档和官方作者著作的书开始,这样你可以看到“原汁原味”的讲解。其实不只 Go 语言,任何一门语言都应该是这样,官方的内容是比较权威的。
基于官方文档入门后,你就可以参考一些第三方大牛写的相关书籍了。阅读不同人写的 Go 语言书籍,你可以融会贯通,更好地理解 Go 语言的知识点。比如在其他书上看不懂的内容,换一本你可能就看懂了。
阅读书籍还有一个好处是让你的学习具备系统性,而非零散的。现在大部分的我们都选择碎片化学习,其实通过碎片化的时间,系统地学习才是正确的方式。
不管是通过书籍、官网文档,还是视频、专栏的学习,我们都要结合示例进行练习,不能只用眼睛看,这样的学习效率很低,一定要将代码动手写出来,这样你对知识的理解程度和只看是完全不一样的,在这个过程中你可以通过写加深记忆、通过调试加深理解、通过结果验证你的知识。
有了这些基础后,就可以看一些实战类的书籍、文章和视频了,这样你不只是学会了 Go 语言,还能用 Go 语言做项目,了解如何编码、分库、微服务、自动化部署等。
不管是学习 Go 语言还是其他编程语言,都要阅读源代码,通过阅读源代码了解底层的实现原理,以及学习他人优秀的代码设计,进而提升自己在 Go 语言上的技术能力。
当然一个工程师“源于代码”,但不能“止于代码”。
不止于编程语言
无论你是想走技术专家路线,还是技术管理路线,要想更多地发挥自己的价值,必然是要带人的,因为一个人再怎么努力、技术如何厉害,也比不上多人团队的协作。
所以,当你工作 3 年具备骨干的能力后,就要开始尝试带人、做导师了,把自己学习编程的经验教给新人,让他们少走弯路,同时也能锻炼自己带人的能力,协调更多的人一起做事情。
这样当你有 5 年、7 年,甚至以上工作经验的时候,你的团队会越来越壮大,在团队中你所发挥的价值也越来越大;而在个人方面,你也可以做架构设计、技术难点攻关等更有价值的事情。
关于技术编程人员的成长,我有过一次分享。我把成长经历分为 9 个阶段,每一个阶段需要哪些技术,如何提升自己的段位,都有详细的介绍,你可以在《技术编程人员成长的 9 个段位》中查看。
总结
具备自我驱动力,以及学习能力的人,在职场中的竞争力都不会太差。
希望这个专栏可以很好地帮到你,让你学到 Go 语言的知识,让你在职场中更具备竞争力。

View File

@ -0,0 +1,137 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 用好A_B测试你得这么学
你好我是博伟。欢迎和我一起学习A/B测试。
可能你对我还不是很熟悉我先来做个自我介绍。我目前呢在美国的互联网大厂FLAG工作是一名资深数据科学家。在过去的7年多时间里我一直在做A/B测试、机器学习建模、大数据分析的相关工作。
在从事A/B测试的经历中我参与到从设计测试、实施测试到最后分析测试结果给出业务指导的全过程后来逐步在团队中主导A/B测试领域的相关工作开发与A/B测试相关的数据产品还和工程团队合作来改进提升内部的A/B测试平台通过持续的A/B测试为公司的新业务带来上百万用户的增长。经过多年的经验积累现在也为数据分析、营销和产品团队提供数十场A/B测试的讲座和上百次的咨询给他们讲解A/B测试的最佳实践以及避坑经验。
在我多年的数据分析实践中我越来越觉得A/B测试是促进业务持续增长的最实用、最有效的方式。
不过我也发现在这些不同的数据分析方法中A/B测试也是最容易用错的方法。
究其原因是因为A/B测试是一种实践性很强的方法学校的教学中往往没有相关的课程。你可能会在统计课上学到过它的理论基础——假设检验但是还是太过理论不知道该怎么应用。A/B测试的难点就在于如果你只有理论基础而没有实践经验那么实践过程由于业务场景千变万化可能就会有各种各样潜在的陷阱在等着你。只有兼顾了理论基础和实践经验才能得出值得信赖的测试结果。
也因此我非常希望能够系统地梳理和总结下自己在硅谷成熟科技公司学到的知识经验并分享出来。在你即将学习的这门课程中我会先带你建立起一个做A/B测试的框架让你在应对不同业务场景时都能通过框架来按图索骥灵活运用。
不过在讲具体的学习方法之前我想先和你聊一聊A/B测试到底可以帮我们解决什么问题
为什么想要获得持续的业务增长就必须学习A/B测试
在大数据时代,每个公司都在说数据驱动产品和业务的快速迭代,这当然没有错。但是,有很多人都认为,数据驱动就是做几次数据分析,产生一些报表,并没有把数据放在公司的业务决策流程中。
这是一个非常严重的误区。
多年的专业经验告诉我看一个公司或者团队是不是真正做到了数据驱动就要看它的决策流程中有没有A/B测试这一环节。
为什么这么说呢,我们先来了解下决策流程,也就是产品/业务迭代的流程。
你可以看到,产品/业务迭代的流程大概分为3步
具体的业务问题催生出迭代的想法,比如出现业务问题后,团队会提出具体的迭代方案;
团队论证方案的可行性和效果;
论证完成后,具体实施迭代方案。
很明显,只要论证环节结束了,就要开始进行迭代了。所以,做好充分而正确的论证,就是至关重要的环节。
这也很容易理解,你想,如果刚刚有了迭代的想法,不去论证就直接实施,就很难达到预期,甚至会产生负面效果。
这就好比一个刚刚研制成功的药品,不经过临床实验就直接推入市场,去治疗病人,那承担的风险是非常高的。因为这样不仅可能无法治愈病人,甚至还可能会产生严重的副作用。这么一想,你是不是就体会到论证的重要性了?
而A/B测试就是保证这个关键环节不出现问题的最佳方案。因为它不仅可以让我们清楚地知道产品/迭代方案到底有没有效果,能产生多大效果,还可以在结果不如预期时,快刀斩乱麻,有理有据地放弃这个想法。
这样既能大大节省公司的成本,又能加快想法迭代的速度。如果在花费了大量时间和资源实施想法后,还收不到预期的效果,那就得不偿失了。
所以只有在决策流程中加入A/B测试这个环节根据值得信赖的测试结果而不是所谓的经验来做业务和产品决策时才是真正的数据驱动决策。
这其实也是所有公司都会面临一个问题业务增长从来都不是一步到位的那么如何保持业务的持续增长呢A/B测试在提升业务和产品迭代上真的很管用能持续带来营收和用户的增长。
无论是美国硅谷的FLAG还是中国的BAT每年都会进行成千上万次的线上A/B测试参与测试的用户超百万事实上大部分用户是在不知情的情况下被参与的。即使是一些初创公司或者是像沃尔玛、美国航空这样的传统企业也会通过小规模的A/B测试来优化提升业务。
以必应Bing搜索为例A/B测试每个月都能帮助他们发掘数十个提升收益的方法每个搜索的收益一年可以提升10%~25%这些AB测试带来的改进和其他提升用户满意度的努力是必应搜索的盈利提升以及其美国市场份额从2009年刚成立时的8%上升到2017年的23%的主要原因。
讲到这里你可能会比较好奇这些公司用A/B测试来解决什么具体的业务问题呢你看下面我给你总结的表格就了解啦。
正因为发现了A/B测试在产品迭代、算法优化、市场营销等领域的巨大作用越来越多的公司开始使用A/B测试这方面的人才的需求量也越来越大。无论是偏技术的数据科学家、数据分析师还是偏业务和产品的市场营销分析师、产品经理以及增长黑客都需要在工作中掌握和应用A/B测试。而且从我多年做面试官的经验来看A/B测试也是这些职位面试中必考的一块内容重要程度可想而知。
看到这里你可能已经非常想要学习A/B测试了先别着急。我发现很多人对A/B测试是既熟悉又陌生。
说熟悉是因为A/B测试的基本概念很好理解它就是指科学中的控制变量实验。说陌生是因为A/B测试涉及到千变万化的业务场景、不同的数据以及在实施过程中的多种琐碎环节也存在着太多的误区。
理解A/B测试的原理很简单想用好却很难
为什么这么说呢?我们直接看几个真实的案例吧。
我经常和营销、产品团队一起合作A/B测试他们一般会提出一些A/B测试的想法比如想要提升某款App的推送效果希望能通过改变推送中的不同因素来提升推送的点击率。
刚开始他们的很多想法完全不适合A/B测试。比如说实验组和对照组相比他们会想到同时改变推送的标题和内容或者同时改变推送的内容和时间等等这就违反了控制变量实验中实验组和对照组只能有一个因素不同的原则。因为当我们同时变化多个因素时即使最后得到了显著的测试结果也没有办法确定到底是哪个因素造成的。
这就是基础不扎实导致的。这是一个非常严重的问题,因为不清楚原理,就很容易在设计实验和分析实验结果中采取错误的方法。
你可能会问那我掌握好理论基础是不是做A/B测试就没问题啦
当然不是。A/B测试是一种实践性很强的方法。你可能会在统计课上学到过它的理论基础——假设检验但是怎么在实际业务场景中应用呢这就是学习A/B测试的难点。
理论上的东西是死的但A/B测试的应用场景和相关的数据却是千变万化的在实施A/B测试中会遇到各种各样的数据问题或者工程Bug要是一不小心哪怕忽视了很小的一个点就会有各种各样的陷阱在等着你实验结果就会变得不准确之前的所有功夫就白费了。
我再跟你分享一个小例子。
某个专门测试App推送的平台有一类流程是比较发推送有没有效果。对照组不发推送实验组发推送。
在正式发送前,该平台还会做一个过滤,过滤掉那些不符合推送的用户,比如用户是未成年人,或者用户手机设备太旧不支持推送,等等。但是由于只有实验组会发推送,对照组并不会发推送,所以平台只在实验组实施了过滤机制:
但是,仔细想想,这个流程会使实验组和对照组有两个不同:有无推送和有无过滤。第一个不同是在实验设计中,但是第二个不同就纯粹是流程中加进来的,是偏差,会造成实验结果的不准确。
正确的流程如下图。对照组即使最后不发推送,也要经过和实验组同样的过滤,这样才能保证实验的准确性:
你看这么一个细小的问题就可能会导致整个A/B测试失败。
这门课程是如何设计的?
所以为了让你快速且扎实地掌握A/B测试这门手艺我结合我的从业经验从统计原理、基本流程和进阶实战三个层面为你梳理出了一条学习A/B测试的最佳路径。
第一模块是“统计篇”。
想要做好A/B测试统计原理的学习肯定是不能漏掉的。统计学知识纷繁复杂但做A/B测试其实不需要掌握全部。所以我精选了与A/B测试密切相关的统计理论主要讲解A/B测试的理论基础-假设检验以及A/B测试指标的统计属性这两块知识让你有针对性地学习理论知识真正打好做A/B测试的理论基础。
即使你没有很好的统计学基础也可以在这个模块快速掌握A/B测试的统计学基础完全不用担心。
第二模块是“基础篇”。
在这个模块我梳理了做A/B测试的几个关键步骤包括确定目标和假设、确定指标、选取实验单位、计算所需样本大小以及分析测试结果。我会在讲解流程的同时也告诉你背后的原理帮助你在实际应用时能举一反三。
第三模块是“进阶篇”。
想要让做A/B测试的技能更上一层楼掌握了关键流程还不够。你还需要能够识别那些在实际业务场景中潜在的坑并且要有相应的解决方法。
除此之外你应该知道A/B测试并不是万能的所以我会专门拿出一节课来给你讲解A/B测试的适用范围及替代方法。
如果你是想面试A/B测试相关职位呢也不用担心我会花两节课带你掌握面试中的常见考点及应对方法。
最后,我还会通过实战,带你亲自制作一个实用的样本量计算器,来解决网上工具参差不齐、适用范围有限等问题。
A/B测试其实并不难因为它并不需要你掌握非常高深的计算机算法或者高等数学所以说理解基本的统计知识就足够了。不过想要把A/B测试做好肯定是有难度的。它的难度就在于如果不遵循科学化的流程那么在实践过程中就可能会出现各种状况和问题。
所以我也希望你能在学习这门课程的时候边学边实践在实践中学习、总结前人的经验把A/B测试慢慢变成你在工作中的一项核心竞争力。
最后,今天是开篇,你可以在评论区写下你对这门课的期待,或者你的学习计划,让我们一起见证彼此的成长吧!

View File

@ -0,0 +1,199 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 统计基础(上):系统掌握指标的统计属性
你好,我是博伟。
在学习、解决技术问题的时候我们都知道有这么一句话“知其然知其所以然”。那么A/B测试的“所以然”是什么呢在我看来就是A/B测试背后的计算原理知道A/B测试为什么要这么设计最佳实践中为什么要选择这样的指标、那样的检验方法。
那说到A/B测试背后的计算原理我们首先得知道A/B测试的理论基础是假设检验Hypothesis Testing。可以说假设检验贯穿了A/B测试从实验设计到分析测试结果的整个流程。
如果要一句话解释“假设检验”的话就是选取一种合适的检验方法去验证在A/B测试中我们提出的假设是否正确。现在你只要知道“假设检验”中最重要也最核心的是“检验”就可以了因为选取哪种检验方法取决于指标的统计属性。
也就是说理解指标的统计属性是我们掌握假设检验和A/B测试的前提也是“知其所以然”的第一步。
而至于深入理解并用好“假设检验”的任务,我们就留着下一讲去完成吧。
指标的统计属性,指的是什么?
在实际业务中,我们常用的指标其实就是两类:
均值类的指标,比如用户的平均使用时长、平均购买金额、平均购买频率,等等。
概率类的指标,比如用户点击的概率(点击率)、转化的概率(转化率)、购买的概率(购买率),等等。
很明显这些指标都是用来表征用户行为的。而用户的行为是非常随机的这也就意味着这些指标是由一系列随机事件组成的变量也就是统计学中的随机变量Random Variable
“随机”就代表着可以取不同的数值。比如一款社交App每天的使用时间对轻度用户来说可能不到1小时而对重度用户来说可能是4、5小时以上。那么问题来了在统计学中怎么表征呢
没错我们可以用概率分布Probability Distribution来表征随机变量取不同值的概率和范围。所以A/B测试指标的统计属性其实就是要看这些指标到底服从什么概率分布。
在这里,我可以先告诉你结论:在数量足够大时,均值类指标服从正态分布;概率类指标本质上服从二项分布,但当数量足够大时,也服从正态分布。
看到这两个结论你可能会有很多问题:
什么是正态分布?什么是二项分布?
“数量足够大”具体是需要多大的数量?
概率类指标,为什么可以既服从二项分布又服从正态分布?
不要着急,我这就来一一为你解答。
正态分布Normal Distribution
正态分布是A/B测试的指标中最主要的分布是计算样本量大小和分析测试结果的前提。
在统计上如果一个随机变量x的概率密度函数Probability Density Function
\[-
f(x)=\\frac{1}{\\sigma \\sqrt{2 \\pi}} e^{-\\frac{1}{2}\\left(\\frac{x-\\mu}{\\sigma}\\right)^{2}}-
\]\[-
\\begin{aligned}-
\\mu &=\\frac{x\_{1}+x\_{2}+\\cdots+x\_{n}}{n} \\\\\\-
\\sigma &=\\sqrt{\\frac{\\sum\_{i}^{n}\\left(x\_{i}-\\mu\\right)^{2}}{n}}-
\\end{aligned}-
\]那么x就服从正态分布。
其中 μ为x的平均值Meanσ为x的标准差Standard Deviationn为随机变量x的个数xi为第i个x的值。
随机变量x服从正态分布时的直方图Histogram如下
直方图是表征随机变量分布的图表其中横轴为x可能的取值纵轴为每个值出现的概率。通过直方图你可以看到距离平均值μ越近的值出现的概率越高。
除了平均值μ,你还能在直方图和概率密度函数中看到另一个非常重要的参数:标准差σ。σ通过计算每个随机变量的值和平均值μ的差值,来表征随机变量的离散程度(偏离平均值的程度)。
接下来,我们就来看看标准差σ是怎么影响随机变量的分布的。
为了方便理解我们用Python做一个简单的模拟选取服从正态分布的随机变量x其平均值μ=0分别把x的标准差σ设置为1.0、2.0、3.0、4.0 然后分别做出直方图。对应的Python代码和直方图如下
from scipy.stats import norm
import numpy as np
import matplotlib.pyplot as plt
## 构建图表
fig, ax = plt.subplots()
x = np.linspace(-10,10,100)
sigma = [1.0, 2.0, 3.0, 4.0]
for s in sigma:
ax.plot(x, norm.pdf(x,scale=s), label='σ=%.1f' % s)
## 加图例
ax.set_xlabel('x')
ax.set_ylabel('Density')
ax.set_title('Normal Distribution')
ax.legend(loc='best', frameon=True)
ax.set_ylim(0,0.45)
ax.grid(True)
通过这个直方图去看标准差σ对随机变量分布的影响是不是就更直观了σ越大x偏离平均值μ的程度越大x的取值范围越广波动性越大直方图越向两边分散。
咱们再举个生活中的例子来理解标准差。在一次期末考试中有A和B两个班的平均分都是85分。其中A班的成绩范围在70~100分通过计算得到成绩的标准差是5分B的成绩范围在50~100分计算得到的成绩标准差是10分。你看A班的成绩分布范围比较小集中在85分左右所以标准差也就更小。
说到标准差你应该还会想到另一个用来表征随机变量离散程度的概念就是方差Variance。其实方差就是标准差的平方。所以标准差σ和方差在表征离散程度上其实是可以互换的。
有了方差和标准差我们就可以描述业务指标的离散程度了但要计算出业务指标的波动范围我会在第4讲展开具体的计算方法我们还差一步。这一步就是z分数。
要解释z分数就要引出一种特殊的正态分布也就是标准正态分布Standard Normal Distribution其实就是平均值μ=0、标准差σ=1的正态分布。
标准正态分布的直方图如下所示:
这里的横轴就是z分数Z Score也叫做标准分数Standard Score
\[-
\\mathrm{z} \\text { score }=\\frac{x-\\mu}{\\sigma}-
\]事实上任何一个正态分布都可以通过标准化Standardization变成标准正态分布。而标准化的过程就是按照上面这个公式把随机变量x变为z分数。不同z分数的值代表x的不同取值偏离平均值μ多少个标准差σ。比如当z分数等于1时说明该值偏离平均值1个标准差σ
我们再用一个社交App业务指标的例子来强化下对正态分布的理解。
现在有一个社交App我们想要了解用户日均使用时间t的概率分布。根据现有的数据1万个用户在一个月内每天使用App的时间我们做出了一个直方图
可以看出这1万个用户的日均使用时间t大约在3-5小时这个范围而且是近似正态分布的钟形曲线说明t的分布也可以近似为正态分布。
中心极限定理Central Limit Theorem
这其实是均值类变量的特性:当样本量足够大时,均值类变量会趋近于正态分布。这背后的理论基础,就是中心极限定理。
中心极限定理的数学证明和推理过程十分复杂,但不用害怕,我们只要能理解它的大致原理就可以了:不管随机变量的概率分布是什么,只要取样时的样本量足够大,那么这些样本的平均值的分布就会趋近于正态分布。
那么,这个足够大的样本量到底是多大呢?
统计上约定俗成的是样本量大于30就属于足够大了。在现在的大数据时代我们的样本量一般都能轻松超过30这个阈值所以均值类指标可以近似为正态分布。
到这里,“数量足够大”具体是需要多大的数量,以及什么是正态分布,这两个问题我们就都明白了。接下来,我们再学习下什么是二项分布,之后我们就可以理解为什么概率类指标可以既服从二项分布又服从正态分布了。
二项分布Binomial Distribution
业务中的概率类指标,具体到用户行为时,结果只有两种:要么发生,要么不发生。比如点击率,就是用来表征用户在线上点击特定内容的概率,一个用户要么点击,要么不点击,不会有第三种结果发生。
这种只有两种结果的事件叫做二元事件Binary Event。二元事件在生活中很常见比如掷硬币时只会出现正面或者反面这两种结果所以统计学中有专门有一个描述二元事件概率分布的术语也就是二项分布Binomial Distribution
这里我们还是结合着社交App的例子来学习下二元分布。
这款社交App在网上投放了广告来吸引人们点击广告从而下载App。现在我们想通过数据看看App下载率的分布情况
下载率 = 通过广告下载App的用户数量 / 看到广告的用户数量。
因为单个二元事件的结果只能是发生或者不发生发生的概率要么是100%要么是0%,所以我们要分析下载率就必须把数据进行一定程度的聚合。这里,我们就以分钟为单位来举例,先计算每分钟的下载率,再看它们的概率分布。
我们有一个月的用户及下载数据一个月一共有43200分钟60*24*30因为我们关注的是每分钟的下载率所以一共有43200个数据点。通过数据分析发现每分钟平均有10个人会看到广告下载率集中分布在0-30%之间。
下图是每分钟下载率的概率分布:
你可能会说概率在某种程度上也是平均值可以把这里的下载率理解为“看到广告的用户的平均下载量”那我们已经有43200个数据点了样本量远远大于30但为什么下载率的分布没有像中心极限定理说的那样趋近于正态分布呢
这是因为在二项分布中中心极限定理说的样本量指的是计算概率的样本量。在社交App的例子中概率的样本量是10因为平均每分钟有10人看到广告还没有达到中心极限定理中说的30这个阈值。所以我们现在要提高这个样本量才能使下载率的分布趋近正态分布。
提高样本量的方法也很简单可以计算每小时的下载率。因为每小时平均有600人看到广告这样我们的样本量就从10提高到了600。下图是每小时下载率的概率分布
现在再看这张直方图每小时下载率的分布是不是就趋近于正态分布了图中下载率的平均值大约为10%。
在二项分布中有一个从实践中总结出的经验公式min(np,n(1-p)) >= 5。其中n为样本大小p为概率的平均值。
这个公式的意思是说np或者n(1-p)中相对较小的一方大于等于5只有二项分布符合这个公式时才可以近似于正态分布。这是中心极限定理在二项分布中的变体。
在我们的例子中计算每分钟下载率的概率分布时np=10*10%=1小于5所以不能近似成正态分布计算每小时下载率的概率分布时np=600*10%=60大于等于5所以可以近似成正态分布。
我们可以利用这个公式来快速判断概率类指标是不是可以近似成正态分布。不过你也可以想象在实践中的A/B测试由于样本量比较大一般都会符合以上公式的。
小结
今天这节课我们主要学习了A/B测试和假设检验的前提也就是指标的统计属性。我给你总结成了一个定理、两个分布和三个概念
一个定理:中心极限定理。
两个分布:正态分布和二项分布。
三个概念方差标准差和z分数。
生活中随机变量的分布有很多种,今天我重点给你介绍了正态分布和二项分布,它们分别对应的是最普遍的两类业务指标:均值类和概率类。
而且你要知道有了中心极限定理我们就可以把业务中的大部分指标都近似成正态分布了。这一点非常重要因为A/B测试中的很多重要步骤比如计算样本量大小和分析测试结果都是以指标为正态分布为前提的。
同时你还可以用通过方差和标准差来了解业务指标的离散程度再结合z分数就可以计算出业务指标的波动范围了。只有理解了指标的波动范围才能够帮助我们得到更加准确的测试结果。
在下节课中我们继续学习A/B测试的统计基础也就是假设检验及其相关的统计概念。
思考题
我在刚开始接触概率类指标的二项分布时对于其如何能近似成正态分布很迷惑大家可以在这里聊一聊在学习A/B测试的统计过程中有什么难理解的地方以及是如何解决的
欢迎在留言区写下你的思考和想法,我们可以一起交流讨论。如果你觉得有所收获,欢迎你把课程分享给你的同事或朋友,一起共同进步!

View File

@ -0,0 +1,235 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 统计基础深入理解A_B测试中的假设检验
你好,我是博伟。
在上节课学习A/B测试指标的统计属性时我用一句话给你简单解释了下假设检验选取一种合适的检验方法去验证在A/B测试中我们提出的假设是否正确。
这句话其实很抽象,所以今天这一讲,我们就具体展开下,看看假设检验是什么,以及如何利用假设检验来做出推断。
假设检验(Hypothesis Testing)是什么?
假设检验,顾名思义,就是要检验我们提出的假设是不是正确的,在事实上能否成立。
在统计中我们很难获取总体数据Population。不过我们可以取得样本数据Sample然后根据样本数据的情况产生对总体数据的假设。所以我们所说的假设检验其实就是检测通过样本数据产生的假设在总体数据即事实上是否成立。
在A/B测试的语境中假设一般是指关于实验组和对照组指标的大小的推断。
为了更加形象地帮你理解假设检验,这节课我就从一个推荐系统的案例出发,从中抽象出假设检验的基本原理和相关概念,让你在实践中学习理论,同时把理论应用到实践中去。
新闻App中的推荐系统是重要的组成部分可以根据用户过往的浏览记录来推荐用户喜欢的内容。最近工程团队改进了推荐系统的算法就想通过A/B测试来验证改进的效果。
实验组中使用新算法,对照组中使用旧算法,然后通过点击率来表征算法的效果:推荐效果越好,点击率越高。那么,我们提出的假设就是:实验组(新算法)的点击率比对照组(旧算法)的点击率高。
你可能会有些疑惑,我们提出的“假设”,和假设检验中的“假设”是相同的吗?
其实不完全相同。
假设检验中的“假设”是什么?
为什么这么说呢因为在假设检验中的“假设”是一对零假设Null Hypothesis和备择假设Alternative Hypothesis它们是完全相反的。在A/B测试的语境下零假设指的是实验组和对照组的指标是相同的备择假设指的是实验组和对照组的指标是不同的。
为了更好地理解零假设和备择假设,我们可以回到推荐系统的案例中,把最开始提出的假设转化成假设检验中的零假设和备择假设:
零假设是,实验组和对照组的点击率是相同的。
备择假设是,实验组和对照组的点击率是不同的。
你可能会问,我们最开始提出的假设不是“实验组的点击率比对照组的点击率高”吗?为什么备择假设中仅仅说两组的点击率不同,却没说谁大谁小呢?
要回答这个问题我们就得先了解单尾检验One-tailed Test和双尾检验Two-tailed Test这两个概念。
单尾检验又叫单边检验One-sided Test它不仅在假设中说明了两个比较对象不同并且还明确了谁大谁小比如实验组的指标比对照组的指标大。
双尾检验又叫双边检验Two-sided Test指的是仅仅在假设中说明了两个比较对象不同但是并没有明确谁大谁小。
回到推荐系统案例中的最初假设,我们已经明确了实验组的点击率比对照组的高,那就应该选用单尾检验。但是,我们的备择假设却变成了两组的点击率不同,这是双尾检验的假设,为什么呢?
这就是理论和实践的不同之处也是为什么我们觉得A/B测试的理论好掌握但实践总出问题的原因。这里我先告诉你结论再给你说明为什么。结论是在A/B测试的实践中更推荐使用双尾检验。
更推荐你使用双尾检验的原因,主要有两个。
第一个原因是,双尾检验可以让数据自身在决策中发挥更大的作用。
我们在实践中使用A/B测试就是希望能够通过数据来驱动决策。我们要尽量减少在使用数据前产生的任何主观想法来干扰数据发挥作用。所以双尾检验这种不需要我们明确谁大谁小的检验更能发挥数据的作用。
第二个原因是,双尾检验可以帮助我们全面考虑变化带来的正、负面结果。
在实践中,我们期望改变可以使指标朝着好的方向变化,但是万一指标实际的变化与期望的正好相反呢?这就可以体现双尾检验的优势了。双尾检验可以同时照顾到正面和负面的结果,更接近多变的现实情况。但是单尾检验只会适用于其中一种,而且通常是我们期望的正面效果。
所以正因为我们选择双尾测试,在备择假设中我们才只说了两组不同,并没有说谁大谁小。
假设检验中的“检验”都有哪些,该怎么选取?
现在,我们知道了假设检验中的“假设”包括零假设和备择假设两种,那么“检验”都包括什么呢?
其实检验有很多种单尾检验和双尾检验是从“假设”的角度来分类的。除此之外常见的“检验”还可以根据比较样本的个数进行分类包括单样本检验One-Sample Test、 双样本检验Two-Sample Test和配对检验Paired Test。那么问题来了在测试中到底该选择哪种检验方法呢
答案是在A/B测试中使用双样本检验。
其中的原因其实很简单,我给你解释下它们各自的适用范围,你就知道了。
当两组样本数据进行比较时就用双样本检验。比如A/B测试中实验组和对照组的比较。
当一组样本数据和一个具体数值进行比较时就用单样本检验。比如我想比较极客时间用户的日均使用时间有没有达到15分钟这个时候我就可以把一组样本数据抽样所得的极客时间用户的每日使用时间和一个具体数值15来进行比较。
当比较同一组样本数据发生变化前和发生变化后时就用配对检验。比如我现在随机抽取1000个极客时间的用户给他们“全场专栏一律1折”这个优惠然后在这1000个人中我们会比较他们在收到优惠前一个月的日均使用时间和收到优惠后一个月的日均使用时间。
看到这里你可能会问我还听说过T检验T Test和Z检验Z Test那这两个检验在A/B测试中该怎么选择呢
选择T检验还是Z检验主要看样本量的大小和是否知道总体方差Population Variance
当我们不知道总体方差时使用T检验。
当我们已知总体方差且样本量大于30时使用Z检验。
我还给你画了张图,你一看就明白了。
那么这些理论具体到A/B测试实践中一个经验就是均值类指标一般用T检验概率类指标一般用Z检验比例检验
为什么会有这样的经验呢?
因为上节课我讲了样本量大的情况下均值类指标是正态分布正态分布的总体方差的计算需要知道总体中各个数据的值这在现实中几乎做不到因为我们能获取的只是样本数据。所以总体方差不可知选用T检验。
而概率类指标是二项分布二项分布的总体方差的计算不需要知道总体中各个数据的值可以通过样本数据求得总体方差。而且现实中A/B测试的样本量一般都远大于30所以选用Z检验。这里的比例检验Proportion Test)是专指用于检验概率类指标的Z检验。
讲了这么多检验我现在来总结一下对于A/B测试来说要选用双尾、双样本的比例检验概率类指标或T检验均值类指标
再次回到我们的案例中来,由于点击率为概率类指标,所以这里选用双尾、双样本的比例检验。
如何利用假设检验做出推断?
选取了正确的假设和检验方法接下来就要检验我们的假设是不是正确了这在A/B测试中就是分析测试结果这一步啦。
A/B测试可能出现的结果
假设检验会推断出两种结果:
接受零假设,拒绝备择假设,也就是说实验组和对照组的指标是相同的。
接受备择假设,拒绝零假设,也就是说实验组和对照组的指标是不同的。
但是请注意,这两个结果只是假设检验根据样本数据,通过一系列统计计算推断出的结果,并不代表事实情况(总体数据情况)。如果考虑到事实情况的话,结合假设检验的推断结果会有四种可能:
可以看出,只有当假设检验推断的情况和事实完全相符时,推断才正确,否则就会出现两类错误。
第一类错误Type I Error)统计上的定义是拒绝了事实上是正确的零假设。在A/B测试中零假设是两组的指标是相同的当假设检验推断出两组指标不同但事实上两组指标相同时就是第一类错误。我们把两组指标不同称作阳性Positive。所以第一类错误又叫假阳性False Positive
发生第一类错误的概率用α表示也被称为显著水平Significance Level。“显著”是指错误发生的概率大统计上把发生率小于5%的事件称为小概率事件代表这类事件不容易发生。因此显著水平一般也为5%。
第二类错误Type II Error)统计上的定义是接受了事实上是错误的零假设。在A/B测试中当假设检验推断出两组指标相同但事实上两组指标是不同时就是第二类错误。我们把两组指标相同称作阴性Negative所以第二类错误又叫假阴性False Negative。发生第二类错误的概率用β表示统计上一般定义为20%。
这两种错误的概念读起来可能比较拗口,也不太容易理解,那么我就举一个新冠病毒核酸检测的例子来给你具体解释一下。
我们在这里的零假设是:被测试者是健康的,没有携带新冠病毒。
把携带新冠病毒作为阳性,没有携带作为阴性。如果一个健康的人去检测,结果检测结果说此人携带新冠病毒,这就犯了第一类错误,拒绝了事实上正确的零假设,是假阳性。如果一个新冠肺炎患者去检测,结果检测结果说此人没有携带新冠病毒,这就犯了第二类错误,接受了事实上错误的零假设,是假阴性。
现在我们了解了假设检验推断的可能结果,那么,如何通过假设检验得到测试结果呢?
实践中常用的有两种方法P值P Value法和置信区间Confidence Interval法。
P值法
在统计上P值就是当零假设成立时我们所观测到的样本数据出现的概率。在A/B测试的语境下P值就是当对照组和实验组指标事实上是相同时在A/B测试中用样本数据所观测到的“实验组和对照组指标不同”出现的概率。
如果我们在A/B测试中观测到“实验组和对照组指标不同”的概率P值很小比如小于5%,是个小概率事件,虽然这在零假设成立时不太可能发生,但是确实被我们观测到了,所以肯定是我们的零假设出了问题。那么,这个时候就应该拒绝零假设,接受备择假设,即两组指标是不同的。
与此相反的是当我们在A/B测试中观测到“实验组和对照组指标不同”的概率P值很大比如70%,那么在零假设成立时,我们观测到这个事件还是很有可能的。所以这个时候我们接受零假设,拒绝备择假设,即两组指标是相同的。
在统计中我们会用P值和显著水平α进行比较又因为α一般取5%所以就用P值和5%进行比较,就可以得出假设检验的结果了:
当P值小于5%时,我们拒绝零假设,接受备择假设,得出两组指标是不同的结论,又叫做结果显著。
当P值大于5%时,我们接受零假设,拒绝备择假设,得出两组指标是相同的结论,又叫做结果不显著。
至于P值具体的计算我推荐你用工具来完成比如Python或者R
比例检验可以用Python的proportions_ztest函数、R的prop.test函数。
T检验可以用Python的ttest_ind函数、R的t.test函数。
置信区间法
置信区间是一个范围一般前面会跟着一个百分数最常见的是95%的置信区间。这是什么意思呢在统计上对于一个随机变量来说有95%的概率包含总体平均值Population mean的范围就叫做95%的置信区间。
置信区间的统计定义其实不是特别好懂其实你可以直接把它理解为随机变量的波动范围95%的置信区间就是包含了整个波动范围的95%的区间。
A/B测试本质上就是要判断对照组和实验组的指标是否相等那怎么判断呢答案就是计算实验组和对照组指标的差值δ。因为指标是随机变量所以它们的差值δ也会是随机变量具有一定的波动性。
这就意味着我们就要计算出δ的置信区间然后看看这个置信区间是否包括0。如果包括0的话则说明δ有可能为0意味着两组指标有可能相同如果不包括0则说明两组指标不同。
至于置信区间的具体的计算我也推荐你使用Python或者R等工具完成
比例检验可以使用Python的proportion_confint函数、R的prop.test函数。
T检验可以使用Python的tconfint_diff函数、R的t.test函数。
现在回到推荐系统的案例中我会分别用P值法和置信区间法来根据A/B测试的结果进行判断。
实验组新推荐算法样本量为43578其中有2440个点击点击率为5.6%。
对照组旧推荐算法样本量为43524其中有2089个点击点击率为4.8%。
这时候我用R中的比例检验函数prop.test来计算P值和置信区间。
prop.test(x = c(2440, 2089), n = c(43578, 43524), alternative = "two.sided", conf.level = 0.95)
得到了如下结果:
可以得出P值=\(1.167 e^{-7}\) 远远小于5%且接近于0所以我们拒绝零假设接受备择假设并且推断出实验组和对照组指标显著不同。
同时我们也可以得出两组指标差值δ的95%置信区间为[0.005,0.011]不包含0也可以推断出两组指标显著不同。
小结
今天这节课我们针对A/B测试的理论基础—假设检验学习了假设、检验以及相关的统计概念。你只要记住以下两个知识点就可以了。
第一对于A/B测试来说要选用双尾、双样本的比例检验概率类指标或T检验均值类指标。这决定了你在计算分析A/B测试结果时如何选取检验的参数所以很重要。
第二在A/B测试实践中计算样本量大小、指标波动性和分析测试结果的时候会用到这些统计概念。
计算样本量大小时,会用到: 第一类/第二类错误及其概率α和β。
计算指标波动性时,会用到:方差和置信区间。
分析A/B测试结果时会用到各类检验、置信区间、P值。
本节课中的关于假设检验的概念和知识点比较琐碎,为了方便你日后理解记忆,我也给你准备了下面的导图:
到这里我们的统计篇就告一段落了现在你应该已经掌握了A/B测试所需的基本统计知识啦。其实前两节的内容比较偏理论会不太好理解。不过理论知识的学习如果只是填鸭式地讲效果可能并不好。那该怎么掌握这些理论知识呢在我这些年做A/B测试的实践中发现要想真正把理论知识理解透化为己用还是需要自己多思考多实践。等你有了一些实战后自然就能自己体悟到理论学习的好处了。而且这时候再回过头来看理论就会非常容易看懂。
所以在今天的内容中如果有哪些地方你还不能理解那也没关系不要给自己设置心理障碍可以先放一放。之后的课程中我都会运用今天讲到的理论去解决在A/B测试中遇到的问题。你可以在学习的过程中不断回顾这些理论或者发挥主观能动性多查阅一些资料。等你学完整个课程再回头看这两节理论知识一定会发现理论原来如此简单。
那么接下来我们就进入“基础篇”模块去详细学习A/B测试的主要流程吧
思考题
这节课涉及的统计概念都是虽然经常听到,但是难理解的,你们在学习统计中有没有对这些概念的理解有独特的心得?可以拿出来分享给大家。
欢迎在留言区写下你的思考和想法,我们可以一起交流讨论。如果你觉得有所收获,欢迎你把课程分享给你的同事或朋友,一起共同进步!

View File

@ -0,0 +1,224 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 确定指标:指标这么多,到底如何来选择?
你好,我是博伟。
上节课,我们学习了确定评价指标的几种方法,包括量化产品/业务不同阶段的目标,采取定量+定性的方法,或者借鉴行业内其他公司的经验等。你也发现了,这些方法的局限性在于只能选出单个评价指标,而且也没有考虑到评价指标的波动性对结果准确度的影响。
今天我们会更进一步去看看在实际的复杂业务场景中确定评价指标的方法以及计算指标的波动性的方法。然后我们再看看为了确保A/B测试结果的可靠性应该如何去确定护栏指标。
综合多个指标,建立总体评价标准
在实际的业务需求中,有时会出现多个目标,同一目标也可能有多个都很重要的评价指标,需要我们把它们都综合起来考虑。对于单个指标,我们可以用上一讲的方法来确定;但如果要综合考虑多个指标时,又要如何考虑呢?
我们先看一个例子。
亚马逊和用户沟通的一个重要渠道就是电子邮件,它有一个专门给用户发送电子邮件的平台,通过两种方式来精准定位用户:
基于用户的历史购买数据来构建用户的个人喜好,通过推荐算法来发邮件给用户做推荐;
亚马逊的编辑团队会人工精选出推荐产品,通过电子邮件发送给用户。
确定了精准用户以后,亚马逊还面临一个问题:要用什么指标来衡量电子邮件的效果呢?
你可能会想到,给用户发送邮件是为了让他们购买,所以邮件产生的收入可以作为评价指标。
实际上,亚马逊最初就是这么做的:他们确定的假设是通过多发电子邮件来增加额外的收入,评价指标就是邮件产生的收入。
那么这个时候一个假想的A/B测试就被设计了出来。
在实验组,我们给用户发邮件。
在对照组,我们不给用户发邮件。
结果可想而知。对照组没有收到任何邮件,也就不会有邮件产生的收入。而在实验组的用户,由于收到很多邮件,所以产生了不少收入。
出现这个结果的根本原因是,这个指标本身是单调递增的。也就是说,发的电子邮件越多,点击的用户也会越多,从邮件中获得的收入也会越多。所以,想要有更多的收入,就要发更多的邮件。
但现实情况是用户收到的邮件多到一定程度后他们就会觉得是垃圾邮件被骚扰了结果就是影响了用户体验用户选择退订Unsubscribe邮件。而用户一旦退订以后就再也接收不到来自亚马逊的邮件了。
把邮件产生的收入作为评价指标,可以说只是用来优化短期的收入,并没有考虑到长期的用户价值。用户一旦因为被骚扰而退订,亚马逊就失去了在未来给他们发邮件做营销的机会了。所以,邮件产生的收入并不适合作为评价指标,我们需要综合考虑发邮件所带来的好处和潜在的损失,结合多个指标,构建一个总体评价标准 Overall Evaluation Criteria简称OEC
那具体怎么做呢?我们可以给每个实验/对照组计算OEC
\[-
\\mathrm{OEC}=\\frac{\\left(\\Sigma\_{i}{ Revenue-S\*Unsubscribe\\\_lifetime\\\_loss}\\right)} {n}-
\]我来具体解释下这个公式。
i代表每一个用户。
S代表每组退订的人数。
Unsubscribe_lifetime_loss ,代表用户退订邮件带来的预计的损失。
n代表每组的样本大小。
当亚马逊实施了这个OEC之后惊讶地发现有一半以上电子邮件的OEC都是负的这就说明多发邮件并不总是能带来正的收益。
当亚马逊发现退订会造成这么大的长期损失以后,就改进了它的退订页面:从退订所有的亚马逊邮件到退订某一个类别的邮件。比如可以选择只退订亚马逊图书,从而避免了全部退订,也减少了长期的潜在损失。
通过刚刚的分析我们可以看到当要考察的事物包含多个方面时只有综合各方面的指标才能把握总体的好坏。这也是使用OEC最明显的一个好处。最常见的一类OEC就是亚马逊的这种结合变化带来的潜在收益和损失的OEC。需要注意的是这里的“损失”还有可能是护栏指标也就是说OEC有可能会包含护栏指标。
另外使用OEC的另一个好处就是可以避免多重检验问题Multiple Testing Problem。如果我们不把不同的指标加权结合起来分析而是单独比较它们就会出现多重检验的问题导致A/B测试的结果不准确。多重检验问题是A/B测试中一个非常常见的误区我在进阶篇中会具体讲解。
解决了单一评价指标不能应对复杂A/B测试的场景的问题后我们继续学习评价指标的最后一个要点波动性。在实际业务场景中评价指标的值会因各种因素的影响而发生波动。如果忽视了这一点就很有可能得出错误的测试结论。
如何衡量评价指标的波动性?
还记得我们上节课所学的音乐App要“增加自动播放功能”的例子吗
假如这个音乐App没有自动播放功能之前每个月的用户续订率的波动范围是[65%-70%]。我们在A/B测试中发现实验组有自动播放功能的续订率69%确实比对照组没有自动播放功能的续订率66%要高。
那么这个结果是可信的吗达到A/B测试的目的了吗答案显然是否定的。
虽然实验组的数据要比对照组的好,但是这些数据都在正常的波动范围内。所以,增加自动播放功能和提升续订率之间的因果关系,在这次实验中就没有被建立起来,因为这两组指标的差异也可能只是正常的波动而已。但是,如果我们事先不知道评价指标的波动性和正常波动范围,就很有可能建立错误的因果关系。
那么,如何才能知道评价指标的这个正常波动范围呢?
在统计学里面,指标的波动性通常用其平均值和平均值的标准差来表示,一个指标平均值的标准差越大,说明其波动性越大。这里面要注意,变量平均值的标准差又叫做标准误差**Standard Error****。关于标准差的概念你可以再回顾下第1节课的统计学基础。
评价指标的正常波动范围,就是置信区间。那具体该怎么计算呢?
在实践中,计算波动范围一般有统计公式和实践经验两类方法。
第一,根据统计公式来计算。
在统计学中,一般是用以下公式构建置信区间的:
置信区间 = 样本均值Sample Mean ± Z分数*标准误差
根据中心极限定理当样本量足够大时大部分情况下数据服从正态分布所以这里选用z分数。在一般情况下我们选用95%的置信区间对应的z分数为1.96。
为了给你形象地展示置信区间我们在这里假设指标的样本均值为50、标准误差为0.1服从正态分布那么该指标的95%的置信区间为 [50-1.96*0.1 50+1.96*0.1] = [49.8, 50.2]。
你可能注意到了,我在用上面这个公式计算置信区间,假设了一个标准误差。但实际情况上,标准误差是需要我们来计算的。而且,计算标准误差是非常关键的一步。
对于简单的指标,主要是概率类和均值类,我们可以用统计公式来计算标准误差。
概率类的指标,常见的有用户点击的概率(点击率)、转化的概率(转化率)、购买的概率(购买率),等等。
这类指标在统计上通常服从二项分布在样本量足够大的情况下也可以近似为正态分布关于二项分布和正态分布你可以回顾下第1节课的相关内容
所以,概率指标的标准误差,我们可以通过下面这个公式计算:-
$\(-
\\text { Standard Error }=\\sqrt{\\frac{p(1-p)}{n}}-
\)$
其中p代表事件发生的概率。
均值类的指标,常见的有用户的平均使用时长、平均购买金额、平均购买频率,等等。根据中心极限定理,这类指标通常也是正态分布。
所以,均值类指标的标准误差,我们可以这样计算:
\[-
\\text {Standard Error}=\\sqrt{\\frac{s^{2}}{\\mathrm{n}}}=\\sqrt{\\frac{\\sum\_{i}^{n}\\left(x\_{i}-\\bar{x}\\right)^{2}}{n(n-1)}}-
\]其中s代表样本的标准差
n=样本大小,
\(x\_{i}\)=第i个用户的使用时长或者购买金额等
\(\\bar{x}\)= 用户的平均使用时长或者购买金额等。
第二,根据实践经验来确定。
在实际应用中,有些复杂的指标可能不符合正态分布,或者我们根本不知道它们是什么分布,就很难甚至是没办法找到相应的统计公式去计算了。这时候,要得到评价指标的波动范围,我们需要结合实践经验来估算。
1.A/A测试
我们可以跑多个不同样本大小的A/A测试然后分别计算每个样本的指标大小计算出来后再把这些指标从小到大排列起来并且去除最小2.5% 和最大2.5%的值剩下的就是95%的置信区间。
2.Bootstrapping算法
我们可以先跑一个样本很大的A/A测试然后在这个大样本中进行随机可置换抽样Random Sample with Replacement 抽取不同大小的样本来分别计算指标。然后采用和A/A测试一样的流程把这些指标从小到大排列起来并且去除最小2.5% 和最大2.5%的值得到95%的置信区间。
在实际应用中Bootstrapping会更流行些因为只需要跑一次A/A测试既节省时间也节省资源。
不过要注意的是即使对于那些简单的、符合正态分布的、可以用统计方法直接计算方差的指标如果有条件、有时间的话我也推荐你同时用统计公式和Bootstrapping两种方法分别计算方差。如果两者的结果差距较大就需要再去跑更多的A/A测试所以从两方面验证得到的结果会更保险。
到这里评价指标的选取方法以及波动性这个易错点我们就都学习完了。接下来我们进入到选取指标的最后一部分内容如何选取护栏指标为A/B测试提供质量保障。
护栏指标
A/B测试往往改变的是业务中的某一部分指标评价指标所以我们很容易只关注短期的改变却失去了对业务的大局观比如长期的盈利能力/用户体验的掌控或者统计上合理性的检查。因此在实践中我会推荐每个A/B测试都要有相应的护栏指标。
接下来,我们就从业务品质和统计品质这两个维度,来学习如何选取护栏指标。这里我先用一张图,帮你总结下:
业务品质层面
在业务层面的护栏指标是在保证用户体验的同时兼顾盈利能力和用户的参与度。所以我们通常会用到的护栏指标主要是三个网络延迟Latency、闪退率Crash Rate和人均指标。
网络延迟
网页加载时间、App响应时间等都是表征网络延迟的护栏指标。增加产品功能可能会增加网页或App的响应时间而且用户可以敏感地察觉出来。这个时候就需要在A/B测试中加入表征网络延迟的护栏指标确保在增加产品功能的同时尽可能减少对用户体验的影响 (一般是通过优化底层代码)。
闪退率
对于不同的应用程序App来说不管是在个人电脑端还是在移动端都有可能因为CPU、内存或者其他原因出现闪退导致程序突然关闭。
说到这儿我想和你分享一件趣事。我在用MS Word写这节课的内容时就出现了软件闪退。关键是我当时还没有保存心想几个小时的努力不就白费了嘛特别心灰意冷。万幸的是MS Word有自动保存功能。
你看,闪退发生的概率虽然不大,但是会严重影响用户体验。所以,在测试应用程序的新功能时,尤其是针对一些大的改动,闪退率就是一个比较好的护栏指标。
人均指标
人均指标可以从两个角度来考虑:
收入角度,比如人均花费、人均利润等。
用户参与度,比如人均使用时长、人均使用频率等。
这两个角度一般都是实际业务中追求的目标收入角度代表了产品的盈利能力用户参与度代表了用户的满意程度。但是在具体的A/B测试中我们往往会只关注产品的被测试部分的功能忽视了对大局的把握。
举个例子。应用商店优化了推荐算法后推荐的内容更贴近用户的喜好提高了用户对推荐内容的点击率。我们关注的评价指标点击率提高了是不是皆大欢喜呢不是的因为我们分析后发现这个新算法推荐内容中的免费App的比例增加了使得人均花费降低了进而影响到了应用商店的总体收入。
这个时候,我们可以把人均收入作为护栏指标,去继续优化推荐算法。
统计品质层面
统计方面主要是尽可能多地消除偏差,使实验组和对照组尽可能相似,比如检测两组样本量的比例,以及检测两组中特征的分布是否相似。
造成偏差的原因有很多可能是随机分组的算法出现了Bug也可能是样本不够大还有可能是触发实验条件的数据出现了延迟不过更多的是具体实施中的工程问题造成的。这些偏差都会影响我们获得准确的实验结果而护栏指标就是我们发现这些偏差的利器
1.实验/对照组样本大小的比例
在设计A/B测试的时候我们就会预先分配好实验组和对照组通常是把样本等分。也就是说实验组和对照组样本大小的比例预期是1:1=1。但有的时候当实验结束后却发现两者的比例并不等于1甚至也没有很接近1。这就说明这个实验在具体实施的过程中出现了问题导致实验组和对照组出现了偏差。
2.实验/对照组中特征的分布
A/B 测试中一般采取随机分组来保证两组实验对象是相似的从而达到控制其他变量、只变化我们关心的唯一变量即A/B测试中的原因的目的。
比如说,如果以用户作为实验单位的话,那么,在试验结束后去分析两组数据时,两组中用户的年龄、性别、地点等基本信息的分布应该是大体一致的,这样才能消除偏差。否则,实验本身就是有问题的,得出的结果也不是可信赖的。
小结
今天,我们学习了复杂业务场景下如何选取评价指标、评价指标的波动性这个易错点,以及如何选取护栏指标。
有多个指标出现的情况下我们可以把它们结合在一起建立总体评价标准也就是OEC。这里面需要注意的一点是不同指标的单位、大小可能不在一个尺度上需要先要对各个指标进行归一化Normalization处理使它们的取值都在一定的范围内比如[0,1] 之后再进行结合,从而剔除指标单位/大小的影响。
评价指标的正常波动范围就是置信区间。计算置信区间是一个重点对于分布比较复杂的指标我推荐用bootstrapping来计算对于概率类或者均值类的这些符合二项分布或者正态分布的指标建议同时用统计公式和Bootstrapping两种方法来计算。
在实践中选取护栏指标的时候,我们主要是从业务品质和统计品质这两个维度来考虑。可以选择的护栏指标有,网络延迟、闪退率、人均指标、实验/对照组样本大小的比例和实验/对照组中特征的分布等。
思考题
你之前在工作中接触过的A/B测试都会有相应的护栏指标吗如果有的话是什么具体的指标呢这些护栏指标的作用又是什么呢
欢迎在留言区写下你的思考和想法,我们可以一起交流讨论。如果你觉得有所收获,欢迎你把课程分享给你的同事或朋友,一起共同进步!

View File

@ -0,0 +1,164 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 选取实验单位:什么样的实验单位是合适的?
你好,我是博伟。
上节课我们确定了实验的目标、假设以及各类指标那么今天我们就来讲一讲A/B测试的第三步如何选取合适的实验单位。
前面我提到A/B测试的本质就是控制变量实验。既然是实验那就要有实验单位。毕竟只有确定了实验单位我们才能在这个单位层面进行合理的样本分配Assignment从而决定哪些样本在实验组Treatment/Test Group哪些样本在对照组Control Group
谈到实验单位,你可能会问,这有什么难理解的,实验单位不就是用户吗?
其实,这是一个非常常见的认知误区。除了测试系统的表现外,在绝大部分情况下,准确地说,实验单位都是用户的行为。因为我们在产品、营销、业务上所做的调整,本质上都是为了观察用户的行为是否会有相应的变化。
那么问题就来了,很多单位都可以表征用户的行为。那到底是以用户为单位,以用户的每次浏览、访问为单位,还是以用户浏览的每个页面为单位呢?
这节课,我们就来学习下常用的实验单位有哪些,以及实践中选择实验单位的三大原则。
实验单位有哪些?
虽然可以表征用户行为的实验单位有很多,但综合来看,我们可以从用户层面、访问层面和页面层面这三个维度来分别学习。
用户层面User Level
用户层面是指,把单个的用户作为最小单位,也就是以用户为单位来划分实验组和对照组。
那么具体到数据中用户层面都包括什么呢其实主要是4种ID。
第一种ID是用户ID也就是用户注册、登录时的用户名、手机号、电子邮箱等等。
这类ID包含个人信息它的特点就是稳定不会随着操作系统和平台的变化而变化。用户ID和真实的用户一般是一一对应的关系也是代表用户的最准确的ID。
第二种ID是匿名ID一般是用户浏览网页时的Cookies。
Cookies是用户浏览网页时随机生成的并不需要用户注册、登录。需要注意的是用户使用的iOS和安卓操作系统也会随机生成Cookies但是这些Cookies仅限于该操作系统内部和用户浏览时使用的设备或者浏览器有很大关系。所以综合来看Cookies一般不包含个人信息而且可以被抹除因此准确度不如用户ID高。
第三种ID是设备ID。它是和设备绑定的一旦出厂就不可改变。设备ID虽然不会被抹除但是如果用户和家人、朋友共享上网设备的话它就不能区分用户了。所以设备ID的准确度也低于用户ID。
第四种ID是IP地址它和实际的地理位置以及使用的网络都有关系。
同一个用户即使用同一个设备在不同的地方上网IP地址也是不同的。同时在一些大的互联网提供商中很多用户往往共享一个IP地址。所以IP地址的准确度是最差的一般只有在用户ID、匿名ID和设备ID都得不到的情况下才考虑使用IP地址。
这就是用户层面的4个实验单位它们的准确度从高到低的顺序是
用户ID > 匿名IDCookies/设备ID > IP地址。
为什么我要强调这4种ID类型的准确度呢这是因为实验单位的准确度越高A/B测试结果的准确度才会越高。
因此当我们确定了选择用户层面的实验单位时如果数据中有用户ID就优先选择用户ID如果数据中没有用户ID比如用户出于对隐私的考虑没有注册和登录或者是测试网页的功能无需用户注册和登录那么就可以选用匿名ID或者设备ID当这些ID都没有时再选择准确度最低的IP地址。
访问层面Visit/Session Level
访问层面是指把用户的每次访问作为一个最小单位。
当我们访问网站或者App的时候都会有后台系统来记录我们的每次访问动作。那么我们怎么定义一次访问的开始和结束呢
访问的开始很好理解就是进入到这个网站或者App的那一瞬间。但难点就在于怎么定义一次访问的结束。在一次访问中我们可能会点开不同的页面上下左右滑动一番然后退出也有可能只是访问了一下没有啥操作甚至都没有退出就进入了其他的页面或者App。
因此考虑到用户访问的复杂性通常情况下如果用户在某个网站、App连续30分钟之内没有任何动作系统就认定这次访问已经结束了。
如果一个用户经常访问的话就会有很多个不同的访问ID。那在进行A/B测试的时候如果以访问层面作为实验单位就可能会出现一个用户既在实验组又在对照组的问题。
比如我今天和昨天都访问了极客时间App相当于我有两个访问ID如果以访问ID作为实验单位的话我就有可能同时出现在对照组和实验组当中。
页面层面Page Level
页面层面指的是把每一个新的页面浏览Pageview作为最小单位。
这里有一个关键词“新的”它指的是即使是相同的页面如果它们被相同的人在不同的时间浏览也会被算作不同的页面。举个例子我先浏览了极客时间的首页然后点进一个专栏最后又回到了首页。那么如果以页面浏览ID作为实验单位的话这两个首页的页面浏览ID就有可能一个被分配到实验组一个被分配到对照组。
到这里,我们就可以对比着理解下这三个层面了。
访问层面和页面层面的单位比较适合变化不易被用户察觉的A/B测试比如测试算法的改进、不同广告的效果等等如果变化是容易被用户察觉的那么建议你选择用户层面的单位。
从用户层面到访问层面再到页面层面,实验单位颗粒度越来越细,相应地可以从中获得更多的样本量。原因也很简单,一个用户可以有多个访问,而一个访问又可以包含多个页面浏览。
看到这儿你可能觉得信息量有些大这么多单位具体操作时到底怎么选呢不用担心下面我就通过一个“视频App增加产品功能来提升用户留存率”的具体案例来带你一步步地选出合适的实验单位。
一个案例:如何选择实验单位?
某视频App最近收到了不少用户反馈其中很大一部分用户希望在没有网络或者网络不好的情况下也能看视频。于是产品经理希望增加“离线下载”的功能来提高用户的留存率。
现在产品经理要通过A/B测试来看看增加“离线下载”的功能是否真的能提升留存。那应该怎么选取实验单位呢
如果把用户层面的ID作为实验单位的话即把每个用户作为最小单位来分组由于收集样本的时间比较紧迫可能收集到的样本量就不够。因此我们要去寻找颗粒度更细的实验单位来产生更大的样本量。所以我们可以选择访问层面或者页面层面作为实验单位。
数据分析师通过查看发现数据中有访问ID但没有pageview ID所以这里选择访问层面把每一次访问作为最小单位来分组因为一个用户可以产生多次访问。
这样一来样本量是足够了,但是我们分析计算实验结果之后发现,实验组的用户的留存率不仅没有上升,反而低于对照组。
这就很奇怪了,难道是因为“离线下载”功能导致用户体验变差了吗?这不是和之前用户反馈的结果相反了吗?
于是,我们再次对这些用户进行采访调研,得到的结论确实是用户体验确实变差了,但并不是因为用户不喜欢新增加的功能。那么问题究竟出在哪儿了呢?
其实,这里的问题就在于选择了不恰当的实验单位。在刚才的实验中,我们把每一次访问作为最小单位来分实验组和对照组,就造成了同一个用户因为有多个访问而被分到了不同的组。
所以,用户在实验组时可以使用新功能,但是被分到对照组时就会发现没有新功能,让用户很困惑。就好比,昨天你还在用一个很好用的功能今天突然消失了,是不是很沮丧呢?
所以,当业务的变化是用户可以察觉的时候,我建议你一定要选择用户层面作为实验单位。
在这种情况下如果样本量不足那就要和业务去沟通明确样本量不足需要更多的时间做测试而不是选取颗粒度更小的单位。如果不能说服业务方增加测试时间的话我们就要通过其他方法来弥补样本量不足会给实验造成的影响比如增加这次A/B测试使用的流量在总流量中的比例选用波动性方差更小的评价指标等方法我会在第9节课和你讲这些方法
回过头我们再看看这个案例,是不是可以提炼些选取实验单位的经验和坑点呢?没错儿,我将其归纳为了三个原则:
保证用户体验的连贯性。
实验单位应与评价指标的单位保持一致。
样本数量要尽可能多。
掌握了这三条原则,你就能根据实际情况去选择最佳的实验单位啦!
确定实验单位的三大原则
1.保证用户体验的连贯性
保证用户获得最好的体验几乎是所有产品的目标之一用户体验的连贯性尤其重要视频App的例子告诉我们如果A/B测试中的变化是用户可以察觉到的那么实验单位就要选择用户层面。
否则,同一个用户同时出现在实验组和对照组,就会体验到不同的功能、得到不同的体验。这种体验的不连贯性,就会给用户带来困惑和沮丧,很容易导致用户流失。
2.实验单位要和评价指标的单位保持一致
为什么这么说呢?我们还得从统计学上入手去理解。
A/B测试的一个前提是实验单位相互独立且分布相同的Independent and identically distributed简称IID。如果两个单位不一致就会违反相互独立这一前提破坏了A/B测试的理论基础从而导致实验结果不准确。
举个例子。如果用A/B测试来检测音乐App推送新专辑的效果评价指标为用户的新专辑收听率收听新专辑的用户数量/收到推送的用户数量),这里评价指标是建立在用户层面上的,那么实验单位一般也要为用户。
假如我们把实验单位变为新专辑页面层面由于每个用户可以多次浏览该页面所以对于同一个用户的多次页面浏览每次页面浏览其实并不是独立的IID的假设前提就被破坏了那么实验结果也就变得不准确了。
所以在选择实验单位时你一定要记住A/B测试中的实验单位应与评价指标的单位保持一致。
3.样本数量要尽可能多
在A/B测试中样本数量越多实验结果就越准确。但增加样本量的方法有很多我们绝对不能因为要获得更多的样本量就选择颗粒度更细的实验单位而不考虑前面两个原则。
所以我们选取实验单位的第三个原则就是:在保证用户体验连贯性、实验单位和评价指标的单位一致的前提下,可以尽可能地选择颗粒度更细的实验单位来增加样本量。
那么现在三个原则就讲完啦,我来给你总结下:前两个原则是一定要考虑和满足的,第三个原则则是锦上添花,有条件的情况下可以考虑。
小结
这节课,我详细讲解了实践中常用的实验单位及其适用范围,也结合我的实际经验,给你总结了选取不同单位时需要考量的主要因素,让你真正理解并掌握背后的逻辑,从而帮助你在将来的实践中做出正确的判断。
我还给你总结了一个简化版的决策图,便于你回顾和记忆:
在实践中,我们要考虑的最重要的两点就是:用户体验的连贯性、实验单位和评价指标单位的一致性。毕竟用户是上帝,维持好的用户体验适用于所有的业务/产品。所以针对用户可见的变化比如UI的改进大部分的实验都是把用户作为最小的实验单位用户ID/匿名ID/设备ID同时也把用户作为评价指标的单位。
如果你想要更多的样本量同时A/B测试的变化是用户不易察觉到的比如推荐算法的提升可以用比用户颗粒度更细的访问或者页面作为实验单位。与此同时也要让评价指标与实验单位保持一致。
思考题
你平时做A/B测试时是不是都以用户为单位的学完了这节课以后你可以再回想一下有些A/B测试是不是可以用其他单位为什么
欢迎在留言区写下你的思考和想法,我们一起交流讨论。如果你有所收获,也欢迎你把今天的内容分享给你的朋友,一起共同进步!

View File

@ -0,0 +1,232 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 选择实验样本量:样本量越多越好吗?
你好,我是博伟。
前面聊了很多A/B测试的准备工作我们确定了目标和指标也选取了实验单位那么现在可以正式开始测试了吗?
先别着急,我们还需要解决正式测试前的最后一个问题:到底多少样本量是合适的呢?
打破误区:样本量并不是越多越好
如果我问你做A/B测试时多少样本量合适你的第一反应肯定是那当然是越多越好啊。样本量越多实验结果才会越准确嘛
从统计理论上来说,确实是这样。因为样本量越大,样本所具有的代表性才越强。但在实际业务中,样本量其实是越少越好。
为什么会这样说呢?我来带你分析一下。
要弄明白这个问题你首先要知道A/B需要做多长时间我给你一个公式A/B测试所需的时间 = 总样本量 / 每天可以得到的样本量。
你看,从公式就能看出来,样本量越小,意味着实验所进行的时间越短。在实际业务场景中,时间往往是最宝贵的资源,毕竟,快速迭代贵在一个“快”字。
另外我们做A/B测试的目的就是为了验证某种改变是否可以提升产品、业务当然也可能出现某种改变会对产品、业务造成损害的情况所以这就有一定的试错成本。那么实验范围越小样本量越小试错成本就会越低。
你看实践和理论上对样本量的需求其实是一对矛盾。所以我们就要在统计理论和实际业务场景这两者中间做一个平衡在A/B测试中既要保证样本量足够大又要把实验控制在尽可能短的时间内。
那么,样本量到底该怎么确定呢?
你可能会说网上有很多计算样本量的网站我用这些网站来计算出合适的样本量难道不可以吗这当然也是一种方法但你有没有想过这些网上的计算器真的适用于所有的A/B测试吗如果不适用的话应该怎么计算呢
事实上,我们只有掌握了样本量计算背后的原理,才能正确地计算出样本量。
所以,这节课,我会先带你熟悉统计学上的理论基础,再带你进行实际的计算,让你学会计算不同评价指标类型所需的样本量大小。最后,我再通过一个案例来给你串讲下,帮助你掌握今天的内容。
样本量计算背后的原理
这里咱们开门见山,我先把样本量的计算公式贴出来,然后再来详细讲解:
\[-
\\mathrm{n}=\\frac{\\left(Z\_{1-\\frac{\\alpha}{2}}+Z\_{1-\\beta}\\right)^{2}}{\\left(\\frac{\\delta}{\\sigma\_{\\text {pooled}}}\\right)^{2}}=\\frac{\\left(Z\_{1-\\frac{\\alpha}{2}}+Z\_{\\text {power}}\\right)^{2}}{\\left(\\frac{\\delta}{\\sigma\_{\\text {pooled}}}\\right)^{2}}-
\]其中:
\(Z\_{1-\\frac{\\alpha}{2}}\) 为 \(\\left(1-\\frac{\\alpha}{2}\\right)\) 对应的 \(Z\) Score。 \(Z\_{\\text {Power}}\) 为 Power 对应的 Z Score。-
\(\\delta\) 为实验组和对照组评价指标的差值。-
\(\\sigma\_{\\text {pooled}}^{2}\) 为实验组和对照组的综合方差Pooled Variance
从公式中我们可以看出来样本量主要由α、Power、δ和\(\\sigma\_{\\text {pooled}}^{2}\)决定。我们要调整样本量的大小就靠这4个因素了下面我们就来具体聊聊每个因素怎样影响样本量n的。
这四个因素里,α、δ和 \(\\sigma\_{\\text {pooled}}^{2}\)我在前几节课已经讲过了所以在聊每个因素是如何影响样本量n这个问题之前我先来给你介绍下Power到底是什么。
如何理解Power?
Power又被称作Statistical Power。在第二节讲统计基础时我讲解过第二类错误率βType II Error。在统计理论中Power = 1β。
Power的本质是概率在A/B测试中如果实验组和对照组的指标事实上是不同的Power指的就是通过A/B测试探测到两者不同的概率。
可能这么说还是有些抽象不过没关系Power确实是比较难理解的统计概念我刚开始接触时也是一头雾水。所以我再举个例子来帮助你理解Power。
某社交App的用户注册率偏低产品经理想要通过优化用户注册流程来提高用户注册率。用户注册率在这里的定义是完成注册的用户的总数 / 开始注册的用户的总数 * 100%
那么现在我们就可以用A/B测试来验证这种优化是否真的能提高用户注册率。
我们先把用户分为对照组和实验组,其中:
对照组是正常的用户注册流程,输入个人基本信息——短信/邮箱验证——注册成功。
实验组是,在正常的用户注册流程中,还加入了微信、微博等第三方账号登录的功能,用户可以通过第三方账号一键注册登录。
相信不用我说,你也能猜到,实验组用户的注册率肯定比对照组的要高,因为实验组帮用户省去了繁琐的注册操作。这就说明,在事实上这两组用户的注册率是不同的。
那么现在如果A/B测试有80%的Power就意味着这个A/B测试有80%的概率可以准确地检测到这两组用户注册率的不同得出统计显著的结果。换句话说这个A/B测试有20%的概率会错误地认为这两组用户的注册率是相同的。
可见Power越大说明A/B测试越能够准确地检测出实验组与对照组的不同如果两组事实上是不同的
我再给你打个比方。你可以把A/B测试看作是探测空中飞行物的雷达。那么专门探测小型无人机的雷达的灵敏度就要比专门探测大型客机的雷达的灵敏度高。因为探测物越小就越需要灵敏度更高的雷达。在这里雷达的灵敏度就相当于A/B测试的PowerPower越大就越能探测到两组的不同。
所以啊你把Power看成A/B测试的灵敏度就可以了。
四个因素和样本量n的关系
认识完Power那现在就让我们来看下α、Power、δ和\(\\sigma\_{\\text {pooled}}^{2}\)这四个因素和样本量n的关系。
1.显著水平Significance Levelα
显著水平和样本量成反比显著水平越小样本量就越大。这个也不难理解。因为显著水平又被称为第一类错误率Type I Errorα想要第一类错误率越小结果越精确就需要更大的样本量。
2.Power 1 β)
Power和样本量成正比Power越大样本量就越大。Power越大就说明第二类错误率Type II Errorβ越小。和第一类错误类似想要第二类错误率越小结果越精确就需要更大的样本量。
3.实验组和对照组的综合方差\(\\sigma\_{\\text {pooled}}^{2}\)
方差和样本量成正比:方差越大,样本量就越大。
前面讲过,方差是用来表征评价指标的波动性的,方差越大,说明评价指标的波动范围越大,也越不稳定,那就更需要更多的样本来进行实验,从而得到准确的结果。
4.实验组和对照组评价指标的差值δ
差值和样本量成反比差值越小样本量就越大。因为实验组和对照组评价指标的差值越小越不容易被A/B测试检测到所以我们需要提高Power也就是说需要更多的样本量来保证准确度。
实践中该怎么计算样本量?
在实践中绝大部分的A/B测试都会遵循统计中的惯例把显著水平设置为默认的5%把Power设置为默认的80%。这样的话我们就确定了公式中的Z分数而且四个因素也确定了两个α、Power。那么样本量大小就主要取决于剩下的两个因素实验组和对照组的综合方差\(\\sigma\_{\\text {pooled}}^{2}\),以及两组评价指标的差值δ。因此样本量计算的公式可以简化为:-
$\(-
\\mathrm{n} \\approx \\frac{8 \\sigma\_{p o o l e d}^{2}}{\\delta^{2}}-
\)$
现在,我们就可以用这个简化版的公式来估算样本量大小了。
其中方差是数据本身的属性代表了数据的波动性而两组间评价指标的差值则和A/B测试中的变量以及变量对评价指标的影响有关。
以上公式其实是在两组评价指标的综合方差为 \(\\sigma\_{\\text {pooled}}^{2}\)两组评价指标的差值为δ的情况下要使A/B测试结果达到统计显著性的最小样本量。
注意,这里重点强调“最小”二字。理论上样本量越大越好,上不封顶,但实践中样本量越小越好,那我们就需要在这两者间找一个平衡。所以由公式计算得到的样本量,其实是平衡二者后的最优样本量。
样本量计算出来了,接下来就要分对照组和实验组了,那这里就涉及到一个问题,实验组和对照组的样本量应该如何分配?在这个问题中,其实存在一个很常见的误解。那么接下来,我就带你来好好分析一下样本量分配这个问题。
实验组和对照组的样本量应保持相等
如果A/B测试的实验组和对照组样本量相等即为50%/50%均分,那么我们的总样本量(实验组样本量和对照组样本量之和)为:-
你可能会问,实验组和对照组的样本量必须要相等吗?
虽然两组的样本量不相等在理论上是可行的,实践中也可以如此操作,但是我强烈不建议你这样做。下面听我来仔细分析。
一个常见的误解是如果实验组的样本量大一些对照组的样本量小一些比如按照80%/20%分配),就能更快地获得统计上显著的结果。其实现实正好相反:两组不均分的话反而会延长测试的时间。
为什么会这样呢?因为我们计算的达到统计显著性的最小样本量,是以每组为单位的,并不是以总体为单位。也就是说,在非均分的情况下,只有相对较小组的样本量达到最小样本量,实验结果才有可能显著,并不是说实验组越大越好,因为瓶颈是在样本量较小的对照组上。
相对于50%/50%的均分,非均分会出现两种结果,这两种结果均对业务不利。
准确度降低。如果保持相同的测试时间不变那么对照组样本量就会变小测试的Power也会变小测试结果的准确度就会降低
延长测试时间。如果保持对照组的样本量不变,那么就需要通过延长测试时间来收集更多的样本。
所以只有两组均分才能使两组的样本量均达到最大并且使总样本量发挥最大使用效率从而保证A/B测试更快更准确地进行。
你可能会问这个样本量的估算是在A/B测试前进行的但我还没有做这个实验怎么知道两组间评价指标的差值δ呢
估算实验组和对照组评价指标的差值δ
这里呢,我们当然不会事先知道实验结束后的结果,不过可以通过下面的两种方法估算出两组评价指标的差值δ。
第一种方法是从收益和成本的角度进行估算。
业务/产品上的任何变化都会产生相应的成本,包括但不限于人力成本、时间成本、维护成本、机会成本,那么变化带来的总收益能否抵消掉成本,达到净收益为正呢?
举个例子我们现在想要通过优化注册流程来增加某App的用户注册率。假设优化流程的成本大约是3万元主要是人力和时间成本优化前的注册率为60%每天开始注册的人数为100人每个新用户平均花费10元。如果优化后的注册率提升为70%这样一年下来就多了3.65万元70%-60%*100*10*365的收入这样的话一年之内的净收益就为正的这就说明此次优化流程不仅回本而且还带来了利润也就证明10%的差值是一个理想的提升。
当然,我们进行相应的改变肯定是希望获得净收益,所以一般我们会算出当收支平衡时差值为 \(\\delta\_{\\text {收支平衡}}\),我们希望差值\(\\delta \\geq \\delta\_{\\text {收支平衡 }}\)。在这个例子中, \(\\delta\_{\\text {收支平衡}}\)= 8.2% (30000/10/100/365)所以我们希望的差值δ至少为8.2%。
第二种方法是如果收益和成本不好估算的话我们可以从历史数据中寻找蛛丝马迹根据我在第4节课介绍的计算指标波动性的方法算出这些评价指标的平均值和波动范围从而估算一个大概的差值。
比如说我们的评价指标是点击率通过历史数据算出点击率的平均值为5%,波动范围是[3.5%, 6.5%]那么我们对实验组评价指标的期望值就是至少要大于这个波动范围比如7%那么这时δ就等于2%7%5%)。
计算实验组和对照组的综合方差\(\\sigma\_{\\text {pooled}}^{2}\)
至于两组综合方差\(\\sigma\_{\\text {pooled}}^{2}\)的计算,主要是选取历史数据,根据不同的评价指标的类型,来选择相应的统计方法进行计算。评价指标的类型主要分为概率类和均值类这两种。
概率类指标在统计上通常是二项分布,综合方差为:-
$\(-
\\sigma\_{\\text {pooled}}^{2}=p\_{\\text {test}}\\left(1-p\_{\\text {test}}\\right)+p\_{\\text {control}}\\left(1-p\_{\\text {control}}\\right)-
\)$
其中,\(p\_{\\text {control}}\)为对照组中事件发生的概率也就是没有A/B测试变化的情况一般可以通过历史数据计算得出\(p\_{\\text {test}}=p\_{\\text {control}}+\\delta\),得出的是期望的实验组中事件发生的概率。
均值类指标通常是正态分布,在样本量大的情况下,根据中心极限定理,综合方差为:-
$\(-
\\sigma\_{p o o l e d}^{2}=\\frac{2 \* \\sum\_{i}^{n}\\left(x\_{i}-\\bar{x}\\right)^{2}}{n-1}-
\)$-
其中:
n为所取历史数据样本的大小。
\(x\_{i}\)为所取历史数据样本中第i个用户的使用时长/购买金额等。
\(\\bar{x}\)为所取历史数据样本中用户的平均使用时长/购买金额等。
好了,到这里,这节课的核心内容就全部讲完了。不过为了帮助你更好地掌握这些公式原理和计算方式,现在我就用优化注册流程来增加用户注册率的这个例子,来给你串一下该怎么计算样本大小。
案例串讲
我们可以根据前面介绍总样本量的公式来计算样本量:
首先我们来计算实验组和对照组之间评价指标的差值δ。在前面某App优化用户注册率的案例中可以看到我们从成本和收益的角度估算出\(\\delta\_{\\text {收支平衡}}\)=8.2%。
其次,我们来计算\(\\sigma\_{\\text {pooled}}^{2}\)。根据历史数据我们算出注册率大约为60%\(p\_{\\text {control}}\)),结合前面算出的\(\\delta\_{\\text {收支平衡}}\)=8.2%这时就可以把流程改变后的注册率定为68.2% 然后再根据概率类指标的计算公式求出\(\\sigma\_{\\text {pooled}}^{2}\) = 60%*(1-60%) + 68.2%*(1-68.2%)=0.46。
最后我们在A/B测试中把实验组和对照组进行50%/50%均分,利用公式最终求得样本总量为:
这样我们就求得每组样本量至少要有548完成了样本量的计算。
还记得开头我提到的网上各种各样的A/B测试的样本量计算器吗比如这款。如果你仔细研究这些计算器就会发现这些计算器几乎全部是让你输入以下4个参数
原始转化率 \(p\_{\\text {control}}\)Baseline Conversion Rate
最小可检测提升δMinimum Detectable Lift或者优化版本转化率\(p\_{\\text {test}}\) 。
置信水平 (1-αConfident Level或者显著水平αSignificance Level
Statistical Power1-β)。
细心的你可能已经发现:上面这些参数都是计算概率类指标要用的参数,所以现在网上的这些样本量计算器只能计算概率类的指标,并不能计算均值类的指标,所以我们在使用时一定要注意要求输入的参数是什么,才能根据不同类型的指标选择正确的计算方法。对于均值类指标,现在网上还没有比较好的样本量计算器,在这种情况下我建议你通过公式来计算。
为了方便大家日后计算A/B测试中各类指标的样本量我会在专栏的最后一节课教大家用R做一个既可以计算概率类指标还可以计算均值类指标的线上样本量计算器敬请期待
小结
这节课我们主要学习了怎么确定A/B测试所需的样本量大小了解了背后的理论基础我给你总结了影响样本量的四个因素其中向上箭头表示增大向下箭头表示减小。
这里我想要再强调一下这节课介绍的计算A/B测试样本量的方法是测试前对样本量的估计值是为了让A/B测试结果达到统计显著性的最小样本量所以只要最终的实际样本量大于最小样本量就行。当然如果业务条件允许的话样本量自然是越多越好。
最后我想说的是当我们用网上的A/B测试样本量计算器时要注意输入的参数是什么因为绝大部分的计算器都是让用户输入转化率只能计算概率类的指标所以当计算概率类指标时我们可以用网上的计算器但如果是其他类的指标如均值类的话不能用网上的计算器还是得靠你自己利用公式计算测试所需的最小样本量或者跟着我在专栏的最后一起做一个既包含概率类指标又包含均值类指标的线上样本量计算器。
思考题
你有用过网上的A/B测试样本量计算器吗有没有想过为什么网上大部分的样本量计算器只能算概率类的指标而不能计算均值类指标呢
欢迎在评论区留言、讨论,也欢迎点击“请朋友读”,把今天的内容分享给你的同事、好友,和他一起学习、成长。好,感谢你的收听,我们下节课再见。

View File

@ -0,0 +1,224 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 分析测试结果:你得到的测试结果真的靠谱吗?
你好,我是博伟。
经过前面的确定目标和假设、确定指标、选取实验单位、计算所需样本大小后我们终于来到了A/B测试的最后一站分析测试结果。
在正式开始之前我想先问你一个问题拿到测试结果之后就可以马上进行分析了吗肯定不行。因为只有确定测试结果值得信赖之后才可以进行分析。其实分析A/B测试结果并不难难的是如何得出值得信赖的结果从而给业务以正确的指导。
为什么这么说呢接下来我就通过一个音乐App要提高用户升级率的例子和你先拆解下导致测试结果不可靠的因素有哪些然后再看看具体该怎么分析。
案例导入
通常情况下音乐App有两种盈利模式一种是提供免费音乐但是会在App中加广告通过广告赚钱一种是让用户付费订阅App享受高品质的免广告音乐。
我们的这款音乐App是两种盈利模式都有但是从长期盈利效果和用户体验来看采用用户付费订阅的模式会更胜一筹。因此我们计划在双十一前后针对App里的免费用户做一次促销吸引他们付费。
现在有这么两条广告语为了通过A/B测试验证哪条更有效将其分别放到实验组和对照组
对照组广告语:千万曲库免广告无限畅听,用户升级,免费试用半年!
实验组广告语即日起到11月15日用户升级免费试用半年
现在我们来完成A/B测试的整体设计方案。
确定目标:使更多的免费用户升级成为付费用户。
提出假设:通过在广告语中加入倒计时这种增加紧迫感的信息,能够提升免费用户的升级率。
确定实验单位免费用户的用户ID。
实验组/对照组随机分配50%/50%。
评价指标:用户升级率 = 点击广告升级的用户数 / 看到广告的用户数。
评价指标的波动范围:[1.86%2.14%]。
设计好了A/B测试的框架实施了A/B测试后我们就可以等待分析测试结果了。那什么时候可以查看测试结果停止A/B测试呢这是保证测试结果可信赖要解决的第一个问题。
什么时候可以查看测试结果?
还记得我们上节课,在计算测试要达到显著性结果所需的最小样本量时,用到的一个公式吗?
A/B测试所需的时间 = 总样本量 / 每天可以得到的样本量。
结合这个公式再根据App中每天免费用户的流量我们可以计算出这个测试在理论上需要跑10天。
其实这个公式只是理论上推导具体到A/B测试的实践中我们要确定测试时间除了考虑样本量的大小外还要考虑指标周期性变化的因素。
如果指标具有强烈的周期性变化,比如周中和周末的变化很大,那么这时候的测试时间要包含至少一个周期的时间,以排除指标周期性变化的影响。
在音乐App这个案例中我们通过历史数据发现在周末升级的用户要比周中多。这就说明用户升级率这个评价指标会以每周为单位形成周期性的变化所以我们的测试至少要跑7天。而我们通过最小样本量已经算出了本次测试需要跑10天包含了一个周期所以我们可以放心地把测试时间定为10天。
我再多补充一句,如果计算出的测试时间小于一个周期的时间,那么最好也按照一个周期来算,这样做更为保险。
不过啊,在测试实际进行的过程中,有可能出现这样一种情况:在预计时间之前,评价指标出现了显著不同。这时候你就要小心了,如果提前结束测试,就会前功尽弃。我来给你具体解释下。
假设负责这个测试的数据分析师是第一次做A/B测试所以特别激动兴奋每天都在观测实验计算测试结果。在实验进行到第6天的时候样本量还没有达到预期他发现实验组和对照组的评价指标出现了显著的不同。这位数据分析师就在想测试结果在预计时间之前达到了统计显著这个实验是不是提前成功了呢
答案当然是否定的。
一方面因为样本量是不断变化的所以每次观测到的测试其实都可以算作新的实验。根据统计上的惯例A/B测试一般有5%的第一类错误率α也就是说每重复测试100次平均就会得到5次错误的统计显著性的结果。
这就意味着如果我们观测的次数变多的话那么观测到错误的统计显著结果的概率就会大大提升这是多重检验问题Multiple Testing Issue的一种体现。关于多重检验问题我会在第9节课中详细讲解。
另一方面提前观测到统计显著的结果这就意味着样本量并没有达到事先估算的最小样本量那么这个所谓的“统计显著的结果”就极有可能是错误的假阳性False Positive。“假阳性”是指两组事实上是相同的而测试结果错误地认为两组显著不同。
因此这位数据分析师还不能提前结束这次测试,仍然需要继续观测实验。
但如果测试已经跑到了第10天样本量也达到了之前计算的量那是不是就可以开始分析A/B测试的结果了呢
答案依旧是不行。
俗话说心急吃不了热豆腐为了确保实验在具体实施过程中按照我们预先设计的进行保证中途不出现Bug那么在正式分析实验结果前我们还要进行测试的合理性检验Sanity Check从而保证实验结果的准确性。
在第3和第4节课我们学过为了确保在具体实施过程中不会出现破坏统计合理性的Bug我们可以用护栏指标来保证统计品质。这时我们可以使用实验/对照组样本大小的比例和实验/对照组中特征的分布这两个护栏指标。这是保证测试结果可信赖,我们要关注的第二个问题。
保障统计品质的合理性检验
检验实验/对照组样本量的比例
我们预设的是实验组和对照组的样本量各占总样本量的50%,现在我们来看看实验过程中有没有发生什么变化。
各组样本量占总样本量的比例也是概率也是符合二项分布的所以具体的操作方法参见第4节课指标波动性的相关内容
首先根据二项分布的公式\(\\sqrt{\\frac{p(1-p)}{n}}\)算出标准误差。
然后以0.550%为中心构建95%的置信区间。
最后,确认实际的两组样本量的比例是否在置信区间内。
如果总的比例在置信区间内的话就说明即使总的比例不完全等于50%/50%,也是非常接近,属于正常波动,两组样本量大小就符合预期。否则,就说明实验有问题。那该如何确认和解决潜在问题呢?
回到我们的A/B测试上来我们实验组的样本量315256对照组的样本量为315174。通过公式我们求得标准误差为-
计算出来的结果是0.06%我们构建了95%的置信区间[50%-1.96*0.06%, 50%+1.96*0.06%] = [49.88%,50.12%],也就是两组占比的波动范围,然后算出总体的实验组/对照组的样本量比例=50.01%/49.99%。
可以看到,两组占比均在置信区间内,属于正常波动。也就是说,两组样本量符合均分的预期,成功通过了实验/对照组样本量的比例这个合理性检验。那我们接下来就可以进行实验/对照组中特征的分布这个合理性检验了。
检验实验/对照组中特征的分布
A/B测试中实验组和对照组的数据要相似才具有可比性。这里的相似我们可以通过比较两组的特征分布来判断。
常用的特征包括用户的年龄、性别、地点等基本信息或者可能影响评价指标的特征。比如在音乐App这个案例中我们还可以查看用户平时的活跃程度。如果这些特征在两组中分布比例相差较大则说明实验有问题。
一旦从合理性检验中发现了问题就不要急着分析实验结果了实验结果大概率是不准确的。我们要做的就是找到出现问题的原因解决问题并重新实施改进后的A/B测试。
找原因的方法主要有以下两种:
和工程师一起从实施的流程方面进行检查看看是不是具体实施层面上两组有偏差或者bug。
从不同的维度来分析现有的数据看看是不是某一个特定维度存在偏差。常用的维度有时间、操作系统、设备类型等。比如从操作系统维度去看两组中iOS和Android的用户的比例是否存在偏差如果是的话那说明原因和操作系统有关。
通过数据分析发现这两组数据中重要特征的分布基本一致说明两组数据是相似的。这就意味着我们已经通过了合理性检验接下来我们就可以分析A/B测试的结果了。
最后,我还想跟你强调一下,这两个合理性检验是都要进行的,这是保障实验质量的关键。这两种检验如果没有通过的话都会使实验结果不准确,具体来说,实验/对照组样本量的比例和实验设计不相同时会出现样本比例不匹配问题Sample Ratio Mismatch实验/对照组的特征分布不相似则会导致辛普森悖论问题Simpson Paradox这两类问题我们会在第11节课中重点讲解。
如何分析A/B测试的结果
其实分析A/B测试的结果主要就是对比实验组和对照的评价指标是否有显著不同。那怎么理解“显著”呢其实“显著”就是要排除偶然随机性的因素通过统计的方法来证明两者的不同是事实存在的而不是由于波动性造成的巧合。
那具体怎么做呢?
首先我们可以用统计中的假设检验Hypothesis Testing计算出相关的统计量然后再来分析测试的结果。最常用的统计量是用P值P value和置信区间(Confidence Interval)这两种统计量。
你可能会说假设检验中有各种各样的检验Test我应该选取什么检验来计算P值和置信区间呢这里我们不需要理解这些检验的复杂理论解释只要熟悉实践中常用的3种检验方法的使用场景就可以了
Z检验Z Test
当评价指标为概率类指标时比如转化率注册率等等一般选用Z检验在A/B测试中有时又被称为比例检验Proportion Test来计算出相应的P值和置信区间。
T检验T Test
当评价指标为均值类指标时比如人均使用时间人均使用频率等等且在大样本量下可以近似成正态分布时一般选用T 检验来计算相应的P值和置信区间。
Bootstrapping
当评价指标的分布比较复杂在大样本量下也不能近似成正态分布时比如70%用户的使用时间OEC等一般采用Bootstrapping的方法从P值或者置信区间的定义来计算P值和置信区间具体方法请参见第三节课指标波动性的相关内容
现在我们已经拿到了如下的测试结果:
实验组样本量为315256升级的用户为7566升级率为2.4%。
对照组样本量为315174升级的用户为6303升级率为2.0%。
因为评价指标的波动范围是[1.86%,2.14%]所以我们可以得出实验组的升级率2.4%并不属于正常范围,很有可能显著不同于对照组。
接下来我们就可以通过P值法和置信区间法来分析这个测试结果验证我们的假设是否正确。
P值法
首先我们可以采取P值法借助一些计算工具常见有Python、R还有网上的一些在线工具比如这个网站都可以计算P值。具体选择哪个工具根据自己的喜好来就可以。我个人比较喜欢选用R来计算
results <- prop.test(x = c(7566, 6303), n = c(315256, 315174))
因为用户升级率这个评价指标属于概率类指标所以我们选择了专门针对概率类指标的函数prop.test。
通过计算我们可以得到P值 < \(2.2 e^{-16}\)
根据统计惯例一般我们会把测试的显著水平Significance Levelα定为5%统计上的约定俗成再把计算出来的P值和5%相比。当P值小于5%时说明两组指标具有显著的不同。当P值大于5%时,说明两组指标没有显著的不同。如果你对这块概念还不是很清楚,可以回顾下第二节课中假设检验的内容。
从上面的结果可以看出P值远远小于5%且接近于0说明两组指标具有显著的不同这就意味着实验组的广告语确实能提升免费用户的升级率。
置信区间法
在第三节课介绍指标时我们学习了该怎样构建置信区间。现在我们要比较实验组和对照组的评价指标是否显著不同也就是看两者的差值是不是为0。这时候我们就要构建两组指标差值\(\\left(p\_{\\text {test}}-p\_{\\text {control}}\\right)\)的置信区间了。
置信区间的具体计算我们也可以借助Python和R等软件当然你也可以使用我在第二讲时介绍过的具体函数这里我们还是用R的prop.test这个函数。
其实当我们在上面用这个函数计算P值时R也顺便把95%的置信区间算出来了:-
由图可见95%的置信区间为[0.0033, 0.0047]。
接下来我们需要比较一下两组指标是否有统计显著的不同也就是要看看这个置信区间是否包括0。
我们知道数值在置信区间内均为正常波动如果置信区间包括0的话就说明两组指标的差值也有可能为0两组指标是相等的。而如果置信区间不包括0的话就说明两组指标的差值不为0两组指标是显著不同的。
显然,[0.0033, 0.0047]这个置信区间是不包括0的也就是说我们的测试结果是统计显著的。那对应到业务上与对照组的广告语千万曲库免广告无限畅听用户升级免费试用半年相比带有紧迫感的实验组广告语实验组广告语即日起到11月15日用户升级免费试用半年能吸引更多用户升级也就验证了我们最开始的假设是成立的。
学到这里我们发现无论是P值法还是置信区间法都可以用来分析A/B测试结果是否具有统计显著性。那么在实际应用中该如何选择呢两者有什么差别吗
其实,在大部分情况下这两种方法是通用的,只要选择一种就可以。但如果需要考虑实施变化后的收益和成本的关系时,我们就要选择置信区间法了。
因为要考虑收益和成本的关系时除了满足结果在统计上是显著的两组指标不相同差值的置信区间不包括0还不够更要让结果在业务上也是显著的两组指标不仅要不相等而且其差值δ >= \(\\delta\_{\\text {收支平衡}}\),并且差值的置信区间的范围都要比\(\\delta\_{\\text {收支平衡}}\)大)。
小结
这节课我们主要讲解了A/B测试中如何分析结果根据实践经验我给你总结了3个要点
切莫心急,一定要等到达到足够样本量时再分析测试结果。
分析结果前一定要做合理性检验来确保测试的质量否则一旦实施过程中出现Bug就会功亏一篑。
一定要根据指标和数据的特点,选择正确的分析方法来得出可以驱动业务的结论。
数据领域有一句名言“Garbage in, garbage out”意思就是“放进去的是垃圾产出的还是垃圾”。这句话放在A/B测试中同样适用如果A/B测试没有设置好或者虽然计划得很好但要是在实施过程中出现了问题也会得到错误的结果和结论从而给业务带来难以估量的损失。
所以前面我们用4节课来讲怎么设置实验今天又花了很多篇幅来介绍确保结果是可信赖的都是在给“分析测试结果”做铺垫。
好了今天这个音乐App的测试得到了显著的结果皆大欢喜。但是如果结果不显著又该怎么办呢
关于这个问题我们在第9节课再来好好讨论
思考题
你觉得分析结果前的合理性检验还可以参考哪些护栏指标呢?为什么?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,欢迎你把这一讲分享给你的朋友,邀请他一起学习。

View File

@ -0,0 +1,158 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 案例串讲从0开始搭建一个规范的A_B测试框架
你好,我是博伟。
经过前面几节课的学习相信你不仅掌握了做A/B测试的统计原理还知道了一个规范的A/B测试的流程是什么样的以及关键步骤中有哪些需要注意的地方。
今天这节课的内容整体来说不会太难主要是用一个音乐App提升留存率的案例来串讲一下我们学过的统计知识以及做A/B测试的几个核心步骤。
在学习这节课的过程中,一方面,如果你还有一些没有完全搞懂的内容,可以再针对性地复习下,查漏补缺;另一方面,之前几节课的内容容量都比较大,今天的案例串讲相当于帮助你理清思路,清空大脑,然后再有效地去吸收进阶篇的知识。
好了那我就通过下面音乐App这个案例来带你走一遍流程。
从业务问题出发确定A/B测试的目标和假设
咱们今天案例里的产品是一款音乐App用户只要每月付费就可以免广告畅听千万首音乐。当然除了最基本的播放音乐功能产品经理还给这款App设计了很多便利的功能比如用户可以把喜欢的音乐加入收藏夹可以创建不同的歌单还可以离线下载以便随时随地畅听自己喜欢的音乐等等。
数据科学家通过数据分析也发现,使用这些便利功能的用户往往有着高于平均水平的续订率,说明这些便利功能确实有助于提升用户留存。但是也有一个问题一直困扰着团队:这些功能虽然方便实用,有助于优化用户的听歌体验,但是使用率却一直不高。使用率不高,从长期来看,势必会影响用户留存。
团队通过用户调研才发现其中的原因。
由于App的页面设计崇尚简洁这些功能一般就存放在每首歌曲的功能列表中而用户往往需要点击两次才能使用第一次先点击功能列表第二次再点击具体的产品功能。一方面很多用户尤其是新用户并没有发现这些功能。另一方面点击两次才能使用用户体验并不好慢慢地也就退订了。
那么,我们现在的目标就非常明确了:增加用户对产品功能的使用率。
如何增加这个使用率呢?你可能会说,把每个功能都直接显示出来,让用户一目了然,不就可以提高它们的使用率了嘛!产品经理刚开始就想到了这一点,但是后来发现功能太多,全部直接显示出来,会让歌曲界面看起来非常杂乱,会让用户体验更糟糕。
既然产品交互界面的改动被否定了,那么我们可不可以主动告知用户这些功能怎么使用呢?
比如说,在新用户刚注册登录后就告知他们每个功能的用法。不过这个想法很快也被产品经理否定了,毕竟新用户刚登录时并不会用到所有功能。这很好理解,因为没有需求嘛,新用户在看到这些功能时肯定也没有什么反应,所以新用户在第一次登录时一般都会跳过产品功能介绍。
之前的A/B测试也验证了这一点。只有在用户有使用这个功能的需求时再告知他们才最有效果。
于是团队的假设就是:在用户有需求时,通过弹窗的形式告知用户相关使用功能,以此提升相关功能的使用率。这样的话,既能避免对每一个新用户的打扰,又能满足有需求的用户,两全其美。
确定A/B测试的评价指标
确定了目标和假设之后,就可以开始定义评价指标了。
团队准备先拿“把喜欢的音乐加入收藏夹”这个功能来做一个A/B测试验证以上的假设是否成立。
因为要在用户有需求的时候再告知用户所以我们就需要一个条件来触发这个告知。那么我们的首要任务就是确定触发条件只有当用户从来没有用过这个功能如果用户知道这个功能的话我们就没有必要告知了并且播放同一首歌曲达到x次时以此来判断用户对某首歌曲的喜爱程度我们才会给用户发送弹窗通知。
经过数据科学家的数据分析最终确定了x的最优值为4所以该功能的弹窗的最终触发条件为
该用户从来没用过“把喜欢的音乐加入收藏夹”这个功能。
该用户已经对某首歌听了4次当播放第5次时触发弹窗。
需要说明的是,因为弹窗是为了要告知用户,不需要重复提醒,所以每个符合触发条件的用户也只能收到一次,不能多次触发。
在这个A/B测试中把用户随机分为实验组和对照组每组50%。
在实验组中,如果用户满足了触发条件,系统就会发送弹窗提醒(如上图)。
在对照组中,用户不会收到弹窗提醒,不管是否符合触发条件。
确定了目标和假设,现在我们来具体定义下评价指标:
“把喜欢的音乐加入收藏夹”功能的使用率 = 使用了“把喜欢的音乐加入收藏夹”的用户总数 / 实验中的用户总数。
很明显,这是一个概率类的指标,也就是说在实验中的这些用户,使用了“把喜欢的音乐加入收藏夹”这个功能的概率有多少。不过,为了使我们的评价指标更加具体,也方便之后的计算,所以这里我们要搞清楚两个问题。
第一个问题,如何定义“实验中的用户”?
鉴于用户只有满足了条件才会触发弹窗,并不是所有在实验中的人都会受到影响,所以测试时不能用所有被分配在实验中的用户,因为这样就会引入没有受到影响的用户(那些被分配在实验中但是却没有满足触发条件的用户),从而降低测试的准确性。所以一定要注意,这里的“实验中的用户”应该是符合触发条件的用户(下图中虚线部分)。
在实验组中就是触发弹窗的用户,在对照组中则为符合触发条件的用户(因为对照组中的用户不管符合不符合触发条件都不会触发弹窗)。-
-
第二个问题,如何确定用户从触发弹窗开始到最终使用功能的时间窗口期呢?
因为本次A/B测试是要检测弹窗是否会对相关功能的使用率有所提升而且每个用户触发弹窗的时间不同所以需要事先规定一个统一的时间窗口期来衡量比如触发后x天之内的使用率这样统一化是为了使指标更加清晰准确。
因为弹窗告知在这里具有及时性及时性也就是说在用户有需求时所以如果用户是受到弹窗的影响才使用相关功能时肯定会在看到弹窗不久后就使用了。我们这里就把x设为1即触发后1天内的使用率。
搞清楚了这两个问题,我们就可以把评价指标最终定义为:-
“把喜欢的音乐加入收藏夹”功能的使用率 = 在符合触发条件后1天之内使用了“把喜欢的音乐加入收藏夹”的用户总数 / 实验中的符合触发条件的用户总数
光确定评价指标的具体定义还不够,为了更了解咱们的评价指标,得出准确的实验结果,我们还要从统计的角度来看下这个指标的波动性如何。
通过对历史数据的回溯性分析得到了用户在符合触发条件后一天之内使用相关功能的平均概率为2.0%通过统计公式最后求得该指标95%的置信区间为[1.82%2.18%]。这就说明如果测试结束后两组评价指标的值均落入这个波动范围内,则说明两组无显著不同,属于正常波动范围。
选取实验对象的单位
确定了A/B测试的评价指标后接下来我们要确定下实验对象的单位了。
因为本次实验的弹窗是用户可见的变化而且评价指标是以用户为单位所以我们选择用户作为最小实验对象单位具体来说可以选用用户ID因为这些用户必须登录后才能享受音乐服务。
计算所需的样本大小和实验所需时间
我们继续往下走就该计算实验所需的样本量了。这里我们需要先确定4个统计量
显著水平Significance Levelα
Power 1 β)。
实验组和对照组的综合方差 \(\\sigma\_{\\text {pooled}}^{2}\)。
实验组和对照组评价指标的差值δ。
一般A/B测试中显著水平默认为5%Power默认为80% 我们的案例也遵循这样的原则。至于两组评价指标之间的差值根据我们之前算出的波动性两者的差值要在0.18%以上才算是统计显著的变化那么我们就取0.2%。至于综合方差,因为是概率类的指标,我们就可以用统计公式直接算出。
确定了这些统计量后我们算出实验组和对照组各需要至少8.07万个符合触发条件的用户一共需要16.14万用户。而数据分析显示每天符合触发条件的新用户大约为1.7万人所以本次实验大约需要10天时间完成。
那么当我们完成了对整个A/B测试的设计工作后现在就让测试跑起来收集数据等到样本量达到预期时就开始分析测试结果。
分析测试结果
经过了一周多的等待我们的样本量终于达标可以来分析最终的结果啦。不过在分析结果前我们还要确保A/B测试在具体实施过程中符合我们最初的设计保证测试的质量品质这时候就要做合理性检验。
我们用最常见的护栏指标来做检验。
实验/对照组样本大小的比例是否为50%/50%。
实验/对照组中特征的分布是否相似。
经过分析发现本次A/B测试完全通过了这两项护栏指标的合理性检验说明试验实施的正如预期。
那么,现在我们就开始正式分析实验结果了。
实验组样本量为80723符合触发条件一天之内使用功能的用户为3124使用率为3.87%。
对照组样本量为80689符合触发条件一天之内使用功能的用户为1598使用率为1.98%。-
根据结果我们得到的P值接近于0而且远远小于5%同时我们计算出两组评价指标差值的95%的置信区间为[1.72%2.05%]不包括0说明这两组的使用率显著不同事实上实验组的使用率几乎等于对照组的两倍证明了在用户需要时的弹窗提醒确实有效果
得到这个振奋人心的结果后团队决定把“把喜欢的音乐加入收藏夹”功能的弹窗提醒推广到所有符合触发条件的用户同时也计划对其他功能的弹窗做类似的A/B测试来验证它们的效果。如果一切进行顺利的话就将这些弹窗全部推广长期来看肯定会增加用户的留存率
小结
通过这个案例串讲你肯定对做A/B测试的关键步骤有了更具体、更深层次的认识了。
那么基础篇的内容到这里也就结束了。接下来我们就会进入到进阶篇的学习。
在进阶篇我会给你讲解更多偏经验和方法论的知识。针对做A/B测试时经常出现的一些问题我会给你讲解它们的成因给出解决办法。针对面试中常出现的一些考点我会结合我做面试官的经验来给你一些解题思路。
最后我还想强调一下学习这件事本来就是反复和持续的。进阶篇的内容会和基础篇有不少联系。所以在学习进阶篇的课程时我也希望你能够不断温习、思考之前学过的知识。待课程结束再回头看基础篇这些内容相信你会有一种“蓦然回首原来A/B测试如此简单”的畅快感和收获感。
思考题
回忆你之前做过或者经历过的A/B测试它们是否有这些基本的流程步骤如果缺少的话是缺少哪些步骤为什么如果还有其他步骤也和我分享一下吧。
如果你学完今天的案例串讲对A/B测试的流程、步骤有了更清晰的认识欢迎你点击“请朋友读”把今天的内容分享给你的同事、好友大家一起学习、成长。好感谢你的收听我们进阶篇的课程再见。

View File

@ -0,0 +1,202 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 测试结果不显著,要怎么改善?
你好,我是博伟。
通过“基础篇”的学习你已经掌握了A/B测试的整体流程那就可以参照这些流程设计一次A/B测试了。不过在具体实施的过程中你会因为业务的复杂性、没有严格遵守规范的流程或者数据本身的属性等不可避免地遇到一些问题。
没错儿这就是我在开篇词中和你说的A/B测试的实践性非常强你需要能够识别那些潜在的坑并找到相应的解决方法。所以在接下来的三节课里我会带你去看看我积累的经验曾经踩过的坑让你在实践时能提前规避少出错。
今天我们就先从一个很痛的问题开始吧。在第7节课我们学习如何得到可信赖的测试结果以及如何分析测试结果时非常顺利地得出了对照组和实验组指标显著不同的结果。不知道你脑海中会不会一直萦绕着这么一个问题我也是按照这个流程来设计A/B测试的啊为什么我的实验结果不显著呢我应该据此得出“两组指标事实上是相同的”结论吗
今天这节课,我们就来深入剖析“测试结果如何不显著怎么办”这个大家经常遇到的问题。
为什么会出现“实验结果不显著”?
首先我们要搞清楚,为什么会出现“实验结果不显著”?有两方面原因。
A/B测试中的变化确实没有效果所以两组的指标在事实上是相同的。
A/B测试中的变化有效果所以两组的指标在事实上是不同的。但是由于变化的程度很小测试的灵敏度也就是Power不足所以并没有检测到两组指标的不同。
如果是第一种原因,就证明这个变化对产品/业务优化没有效果。那我们要考虑放弃这个变化,或者去测试新的变化。
如果是第二种原因那我们可以从A/B测试的角度进行一些优化和调整。具体来说就是通过提高Power来提高A/B测试检测到实验结果不同的概率。在第6节课我讲过了Power越大越能够准确地检测出实验组与对照组的不同。所以当我们提高了Power之后如果仍然发现测试结果不显著这样才能得出“两组指标事实上是相同的”的结论。
有什么方法可以提高Power呢
我们再来回顾下第6节课讲到的样本量估算公式-
\(\\mathrm{n}=\\frac{\\left(Z\_{1-\\frac{\\alpha}{2}}+Z\_{1-\\beta}\\right)^{2}}{\\left(\\frac{\\delta}{\\sigma\_{\\text {pooled}}}\\right)^{2}}=\\frac{\\left(Z\_{1-\\frac{\\alpha}{2}}+Z\_{\\text {power}}\\right)^{2}}{\\left(\\frac{\\delta}{\\sigma\_{\\text {pooled}}}\\right)^{2}}\)-
其中:-
\(Z\_{1-\\frac{\\alpha}{2}}\) 为 \(\\left(1-\\frac{\\alpha}{2}\\right)\) 对应的 \(Z\) Score。-
\(Z\_{\\text {Power}}\) 为 Power 对应的 \(Z\) Score。-
\(\\delta\) 为实验组和对照组评价指标的差值。-
\(\\sigma\_{\\text {pooled}}^{2}\) 为实验组和对照组的综合方差 (Pooled Variance)。
在公式里我们找出影响Power的因素也就是样本量和方差。其中
样本量和Power成正比。即通过增大样本量就可以提高Power。
方差和Power成反比。即通过减小方差就可以提高Power。
具体来说实践中在有条件获得更大样本量的情况下可以选择增大样本量的方法来提高Power相对简单易操作。如果受流量或时间限制没有条件获得更多的样本量此时可以通过减小方差来提高Power。
接下来我就分别从增大样本量和减小方差这两个维度来讲解6种提高Power的具体方法。
如何通过增加样本量来提高Power
实践中,用来增加样本量的方法主要有三种:延长测试时间,增加测试使用流量在总流量中的占比,以及多个测试共用同一个对照组。
延长测试时间
对于延长测试时间你肯定不陌生我在第6节课讲样本量估算时就讲过。每天产生的可以测试的流量是固定的那么测试时间越长样本量也就越大。所以在条件允许的情况下可以延长测试的时间。
增加测试使用流量在总流量中的占比
假设某个产品每天有1万流量如果我要做A/B测试并不会用100%的流量一般会用总流量的一部分比如10%,也就是测试使用流量在总流量中的占比。
为什么不使用全部流量呢?
一方面A/B测试有试错成本虽然出现的概率较低但是我们在测试中做出的任何改变都有可能对业务造成损害。所以使用的流量越少试错成本越低也就越保险。
另一方面,在大数据时代,对于互联网巨头来说,由于本身就拥有巨大的流量,那么产品本身做出的任何比较明显的改变,都有可能成为新闻。
比如要测试是否要增加一个新功能时公司并不想在测试阶段就把这个新功能泄露给用户以免给用户造成困扰。所以它们一般会先使用很小比例的流量来做A/B测试比如1%确定得到显著结果后再把A/B测试中的变化慢慢推广到100%的流量。
所以,在保持测试时间不变的情况下,还可以通过增加测试使用流量在总流量中的占比,来达到增加样本量的目的。
多个测试共用同一个对照组
有时我们会在同一个产品上同时跑多个A/B测试比如我们想要提升推送的点击率就会在原推送的基础上改变推送的标题、推送的内容、推送的时间、推送的受众等等。
对于这四个不同的影响因素事实上改变每一个因素都是一个独立的A/B测试。那理论上我们就需要设计4个实验需要有4个实验组和4个对照组。
假设我们现在的可用流量一共是8万那么每组就有1万流量。但是你会发现这样流量的利用率太低了因为每个实验的对照组其实都是一样的原推送。但如果我们把4个对照组合并成一个这样的话就变成了4个实验组和1个对照组每组就有1.6万流量。
你看在同一个基础上想同时验证多个变化也就是跑多个A/B测试有相同的对照组的时候我们可以把对照组合并减少分组数量这样每组的样本量也会增加。这种测试又叫做A/B/n测试。
总结来说,在实践中:
如果时间允许,最常用的是延长测试时间的方法,因为操作起来最简单。
如果时间不充足的,可以优先选择增加测试使用流量在总流量中的占比,因为可以节省时间。
当有多个测试同时进行,而且对照组又相同的情况下,就可以把多个对照组合并成一个。
通过增加样本量来提高Power是实践中最常见的方法但是业务场景千变万化虽然不常见但有时候确实没有办法获得更多的样本比如时间紧迫同时已经使用了100%的总流量结果还是不显著那这个时候就要通过减少方差来提高Power了。
如何通过减小方差来提高Power
实践中常用的减少方差的方法也有三种:减小指标的方差,倾向评分匹配,以及在触发阶段计算指标。
减小指标的方差
减小指标的方差有两种方式。
第一种方式保持原指标不变通过剔除离群值Outlier的方法来减小方差。
如果我们通过指标的直方图发现实验的指标分布中有很明显的离群值就可以通过设定封顶阈值Capping Threshold的方法把离群值剔除掉。
比如可以根据指标的分布只选取95%的取值范围然后把剩下的5%的离群值剔除掉。常见的指标比如电商中的人均花费或者音乐App中的人均收听时间由于会有些极少热衷于线上购物的用户花费居多或者音乐发烧友一直在听歌那么这些极少部分的用户就可能变成离群值从而增加方差。
第二种方式:选用方差较小的指标。
取值范围窄的指标比取值范围广的指标方差要小。比如点击者量比点击量的方差小(因为一个点击者可以产生多个点击,点击比点击者多,取值范围广);购买率比人均花费的方差小(因为购买率是表征买或不买的二元事件,只有两个取值,人均花费则可以是任何数量的金钱,理论上有无限的取值范围);收听率比人均收听时间的方差小;等等。
可以看到,对于表征类似的行为(比如买买买,听音乐,看视频,等等),概率类指标要比均值类指标的方差小。所以在满足业务需求的情况下,如果我们想要减少方差,就可以把均值类的指标转化成表征相似行为的概率类指标,也就是修改原定指标,选用取值范围窄的指标。
倾向评分匹配Propensity Score Matching
倾向评分匹配简称PSM是因果推断的一种方法目的是解决实验组和对照组分布不均匀的问题。
你一定还记得我们在第7节课中学习过分析结果前要进行合理性检验那么它和PSM是什么关系呢
我来总结下。如果说合理性检验是帮我们确定两组分布是否相似的方法那么PSM就是帮我们找出两组中相似部分的回溯性分析方法。简单来说两组的各个特征越相似就说明两组的方差越小。
PSM的基本原理就是把一组中的数据点找到在另一组和它们相似的数据点进行一对一的匹配这个相似性是通过比较每个数据点的倾向评分Propensity Score得到的。如果不理解倾向评分也没关系你只需要知道这一点就够了倾向评分越接近说明两个数据点越相似。这里的一个数据点指的就是A/B测试中的一个实验单位。
PSM的具体做法如下。
首先,把我们要匹配的两组中每个数据点的各个特征(比如用户的性别,年龄,地理位置,使用产品/服务的特征等放进一个逻辑回归Logistics Regression中。
然后算出每个数据点的倾向评分然后再用诸如最邻近Nearest Neighbor等方法进行匹配。
最后我们只需要比较匹配后的两组相似的部分即可。
PSM的原理有些复杂我放了一些资料链接你可以查看。不过幸运的是在主要的编程语言Python和R中都有相应的库比如Python中的pymatch和R中的Matching让我们的实施变得相对容易些。
在倾向评分匹配这个部分你只需要记住一个结论就可以了那就是PSM能够有效地减少两组的方差。通过比较倾向评分匹配后的两组的相似部分我们可以来查看结果是否显著。
在触发阶段计算指标
在A/B测试中我们把实验单位进行随机分组的这个过程叫做分配Assignment 同时你要知道在有些A/B测试中比如在第8节课的案例中我们要测试的变化是需要满足一定条件才能触发的。
所以从分配和触发的关系来看A/B测试可以分为两种。
变化不需要条件触发。所有用户在被分配到实验组后就都可以体验到A/B测试中的变化。
变化需要条件触发。在被分配到实验组的所有用户中只有满足一定条件的用户才会触发A/B测试中的变化。
实践中大部分的A/B测试都属于第一种而且也比较好理解。
但是要注意了我们这里讲的减小方差的方法只适用于第二种A/B测试简单来说就是在计算指标时只考虑每组符合触发条件黄圆圈的用户而不是考虑每组中的所有用户绿圆圈。这个不是很好理解我来举例说明下。
还记得第8节课中我们讲到可以利用弹窗的形式来告知用户“把喜欢的音乐加入收藏夹”新功能的A/B测试吗在A/B测试的设计中并不是在实验组的所有用户都会收到弹窗提醒的。
所以为了避免打扰到不相关的用户,把弹窗发送给需要这个功能的用户,我们事先规定了触发弹窗的规则:
该用户从来没用过“把喜欢的音乐加入收藏夹”这个功能。
该用户已经对某首歌听了4次当播放第5次时触发弹窗。
那么当我们计算案例中的评价指标时,各组中“把喜欢的音乐加入收藏夹”功能的使用率 = 各组中使用了“把喜欢的音乐加入收藏夹”的用户总数 / 实验中各组的用户总数。
这里的分母“实验中各组的用户总数”就只算满足弹窗触发规则的用户,而不是算所有被分配到实验中各组的用户,这就是在触发阶段计算指标。
这里要注意的是,在对照组也会有用户满足弹窗触发规则的,只不过因为他们在对照组,所以即使他们满足了弹窗触发规则,我们也不会给这些用户发弹窗,我们还是要统计这些人因为要用他们做分母来计算评价指标。
这里对数据埋点熟悉的小伙伴可能要问了:这些符合弹窗触发规则的对照组用户并没有触发弹窗,在数据中并没有相关的记录,我怎么在数据中去记录他们呢?
在工程实现上其实是有一个小技巧的:对于对照组的用户,如果他们符合触发规则,我们就给他们发送一个只有一个像素点的看不见的隐形弹窗,这样的话我们在数据中会有相关记录,方便之后的指标计算,同时又保证了对照组不会受到弹窗的影响。
通过把评价指标的分母变为满足弹窗触发规则的用户,在计算指标时就会排除掉数据中的噪音(那些被分配到实验中但是没有触发弹窗的用户),从而减小方差。
这种需要触发的A/B测试多出现在有固定的用户使用路径的业务中比如电商。在电商中用户一般有较明确的多层级的使用路径进入网站/App —> 浏览商品列表 —> 查看具体商品 —> 加入购物车 —> 购买。
在电商的A/B测试中一般是在用户进入网站/App时就被分配到实验组或者对照组如果我们测试之后的环节比如在“购物车”页面测试新功能这时候只有进入到“购物车”页面的用户才能触发A/B测试中的变化。
总体而言通过减少方差来提高Power的情况不多常见的是通过增加样本量来提高Power。如果真的遇到这种情况那么比较简单快速的方法就是减小指标的方差。当然如果有条件的话我还是推荐更加科学的倾向评分匹配PSM。那么对于在A/B测试中的变化存在触发的情况下就一定要在触发阶段计算指标。
小结
为了解决A/B测试结果不显著的问题这节课我们主要讲解了提高A/B测试Power的6种方法我把它们从原理上分成了两大类
你可以根据我对每种方法的介绍,以及在什么情况下选用哪种方法,灵活应用。
如果在尝试过这些方法后重新跑实验得出的测试结果还不显著那就说明两组的指标事实上是相同的我们就要放弃这个A/B测试中的变化用其他的变化来优化业务和产品。
最后再强调一下做出一个能真正提升业务的改变并不容易。从美国FLAG这些大厂披露出来的实验数据来看A/B测试得到真正显著的结果并最终实施变化的概率还不到三分之一。
所以也不要气馁,虽然不是每次实验都会有显著的结果,但是你可以从每次实验中学到新的知识(比如变化到底对业务有没有效果),沉淀新的方法论,还能从中发现业务、数据或者工程上潜在的一些问题。这些个人技能上的成长、业务流程上的完善,都是非常宝贵的。
思考题
在某次A/B测试中你是不是也遇到过没能得到显著结果的情况你当时是怎么处理的有没有从实验中获得一些宝贵的经验
欢迎在评论区留言、讨论,也欢迎点击“请朋友读”,把今天的内容分享给你的同事、好友,和他一起学习、成长。好,感谢你的收听,我们下节课再见。

View File

@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 常见误区及解决方法(上):多重检验问题和学习效应
你好,我是博伟。
上节课我们讲了一个在做A/B测试时普遍存在的一个问题那么接下来我就根据自己这些年做A/B测试的经验精选了一些在实际业务中会经常遭遇的误区主要是多重检验问题、学习效应、辛普森悖论和实验/对照组的独立性这四大误区。
这四个误区,其实也可以被看作在实际业务中经常出现的几个问题。不过我在题目中之所以强调说这是误区,是因为你很可能会在这些问题的理解上产生一些偏差。
所以接下来我在讲这两节课时,会按照“问题阐述—问题解析—总结引申—课后思考”的范式来给你讲。也就是说,我会先带你深入剖析问题的成因,然后再举例分析这些问题在实践中的表现形式,最后给出对应的解决方法。
毕竟,在搞清楚问题原理的前提下,再学习问题的表现形式和解决方法,不仅你的学习效果会事半功倍,而且在实际应用时,你也能根据变化多端的业务场景,随机应变,灵活运用。
多重检验问题Multiple Testing Problem
多重检验问题又叫多重测试问题或多重比较问题Multiple Comparison Problem指的是当同时比较多个检验时第一类错误率α就会增大而结果的准确性就会受到影响这个问题。我在基础篇讲A/B测试流程时就多次提到过它比如第4节课讲OEC的好处时还有第7节课讲什么时间才能查看测试结果时。
多重检验为什么会是一个问题?
要搞清楚多重检验为什么会是一个问题我们还得先从第一类错误率α又叫假阳性率显著水平是测试前的预设值一般为5%说起。我在第2节课也讲过第一类错误率指的就是当事实上两组指标是相同的时候假设检验推断出两组指标不同的概率或者说由于偶然得到显著结果的概率。而且它在统计上的约定俗成是5%。
5%看上去是个小概率事件但是如果我们同时比较20个检验测试你可以先思考一下如果每个检验出现第一类错误的概率是5%那么在这20个检验中至少出现一个第一类错误的概率是多少呢
要直接求出这个事件的概率不太容易我们可以先求出这个事件发生情况的反面也就是在这20个检验中完全没有出现第一类错误的概率然后再用100%减去这个反面事件的概率。
这里我们用PA来表示出现事件A的概率。P每个检验出现第一类错误=5%那么P每个检验不出现第一类错误 = 1-5%=95%所以P20个检验中完全没有第一类错误= 95%的20次方。
这样我们就可以求得这个概率:-
-
这里的 P至少出现一个第一类错误的概率又叫做 FWER Family-wise Error Rate。-
通过计算得出来的概率是64%。这就意味着当同时比较20个检验时在这20个结果中至少出现一个第一类错误的概率是64%。看看,这是不是个很大的概率了呢?事实上,随着检验次数的增加,这个概率会越来越大,你看看下面这个图就明白了。
图中的蓝线和橙线分别表示当α=5%和1%时FWER的变化情况。根据这个图我们可以得出两个结论
随着检验次数的增加FWER也就是出现第一类错误的概率会显著升高。
α越小时FWER会越小上升的速度也越慢。
第一个结论讲的就是多重检验带来的问题。第二个结论其实为我们提供了一种潜在的解决方法:降低α。
这就意味着当我们同时比较多个检验时就增加了得到第一类错误的概率FWER这就变成了一个潜在的多重检验问题。
什么时候会遇到多重检验问题?
你可能会说我平时都是一个测试一个测试去跑不会同时跑多个测试是不是就不会遇到这个问题了呢其实不是的实践中出现多重检验问题比你想象的要普遍得多它在实践中主要以4种形式出现。
第一种形式当A/B测试有不止一个实验组时。
当我们想要改变不止一个变量且样本量充足时,我们可以不必等测试完一个变量后再去测试下一个,而是可以同时测试这些变量,把它们分在不同的实验组当中。
每个实验组只变化一个变量在分析结果时分别用每个实验组和共同的对照组进行比较这种测试方法也叫做A/B/n测试。比如我想要改变广告来提升其效果那么想要改变的变量包括内容、背景颜色、字体大小等等这个时候我就要有相对应的3个实验组然后把它们分别和对照组进行比较。
这就相当于同时进行了3个检验就会出现多重检验问题。
第二种形式当A/B测试有不止一个评价指标时。
这个很好理解,因为我们分析测试结果,其实就是比较实验组和对照组的评价指标。如果有多个评价指标的话,就会进行多次检验,产生多重检验问题。
第三种形式当你在分析A/B测试结果按照不同的维度去做细分分析Segmentation Analysis时。
当我们分析测试结果时,根据业务需求,有时我们并不满足于只把实验组和对照组进行总体比较。
比如对于一个跨国公司来说很多A/B测试会在全球多个国家同时进行这时候如果我们想要看A/B测试中的变化对于各个国家的具体影响时就会以国家为维度来做细分的分析会分别比较单个国家中的两组指标大小那么此时分析每个国家的测试结果就是一个检验多个国家则是多个检验。
第四种形式当A/B测试在进行过程中你不断去查看实验结果时。
这种情况我在第7节课中提到过因为当测试还在进行中所以每次查看的测试都和上一次的不一样每查看一次结果都算是一次检验这样也会产生多重检验问题。
了解了多重检验问题在实践中的表现形式,那么在实践中该如何解决它呢?
如何解决多重检验问题?
首先我要提前说明的是接下来我介绍的解决方法只适用于前3种表现形式。对于第4种表现形式的解决办法我已经在第7节课介绍了那就是不要在A/B测试还在进行时就过早地去查看结果一定要等样本量达到要求后再去计算结果所以这里就不再赘述。
鉴于多重检验问题的普遍性,在统计上有很多学者提出了自己的解决方法,大致分为两类:
保持每个检验的P值不变调整α
保持α不变调整每个检验的P值。
为什么会做这两种调整呢?
在第2节课我们介绍了用P值来判断假设检验的结果是否显著时是用检验中计算出的P值和α进行比较的。当P值<α时我们才说结果显著
所以我们要么调整α要么调整P值。前面我也说了降低α是一种解决办法最常用的调整α的方法是Bonferroni校正Bonferroni Correction其实很简单就是把α变成α/n。
其中n是检验的个数。比如α=5%那当我们比较20个检验时校正之后的α=5%/20 = 0.25%此时的FWER =\(1-(1-0.25 \\%)^{20}\) = 4.88% ,和我们最初设定的α=5%差不多。
Bonferroni校正由于操作简单在A/B测试的实践中十分流行但是这种方法只是调整了α对于不同的P值都采取了一刀切的办法所以显得有些保守检测次数较少时还可以适用。
根据实践经验在检测次数较大时比如上百次这种情况在A/B测试中出现的情况一般是做不同维度的细分分析时比如对于跨国公司来说有时会有上百个markets Bonferroni校正会显著增加第二类错误率β这时候一个比较好的解决办法就是去调整P值常用的方法就是通过控制FDRFalse Discovery Rate来实现。
控制FDR的原理比较复杂我就不展开讲了你只需要记住它指的是一类方法其中最常用的是BH法Benjamini-Hochberg Procedure就行了。BH法会考虑到每个P值的大小然后做不同程度的调整。大致的调整方法就是把各个检验计算出的P值从小到大排序然后根据排序来分别调整不同的P值最后再用调整后的P值和α进行比较。
实践中我们一般会借助像Python这样的工具来计算Python中的multipletests函数很强大里面有各种校正多重检验的方法其中就包括我们今天讲的Bonferroni校正和BH法我们使用时只需要把不同的P值输入选取校正方法这个函数就会给我们输出校正后的P值。
这里我总结一下虽然Bonferroni校正十分简单但由于过于严格和保守所以在实践中我会更推荐使用BH法来矫正P值。
聊完了多重检验问题我们再聊一下A/B测试中另一个常见问题——学习效应。
学习效应(Learning Effect)
当我们想通过A/B测试检验非常明显的变化时比如改变网站或者产品的交互界面和功能那些网站或者产品的老客户往往适应了之前的交互界面和功能而新的交互界面和功能对他们来说需要一段时间来适应和学习。所以往往老用户在学习适应阶段的行为会跟平时有些不同这就是学习效应。
学习效应在实践中有哪些表现形式?
根据不同的改变,老用户在学习适应期的反应也不同,一般分为两种。
第一种是积极的反应一般也叫做新奇效应Novelty Effect指的是老用户对于变化有很强的好奇心愿意去尝试。
比如把点击按钮的颜色,由之前的冷色调变成了非常艳丽的大红色,在短期内可能会使诸如点击率之类的指标提升,但是当用户适应了新的大红色后,长期的指标也可能回归到之前的水平。
第二种是消极的反应一般也叫做改变厌恶Change Aversion。指的是老用户对于变化比较困惑甚至产生抵触心理。
比如你经常光顾的电商网站,之前的加入购物车功能是在屏幕的左上方,但是交互界面改变后加入购物车的位置变到了屏幕的右下方,这个时候你可能就需要在屏幕上找一阵子才能找到,甚至找了一圈没找到,因为烦躁就关掉了页面,那么这时候短期的指标就会下降。
可以想象这些在学习适应期的不同反应一般是短期的长期来看这些短期反应也是会慢慢消退的。但是要注意的是这些短期的学习效应确实会给A/B测试的结果带来干扰使结果变得过于好或者过于差。那么我们如何来及时发现学习效应从而剔除学习效应带来的干扰呢
学习效应该如何检测?
在实践中,主要有两种方法可以用来检测学习效应。
第一种方法是表征实验组的指标随着时间(以天为单位)的变化情况。
在没有学习效应的情况下,实验组的指标随着时间的变化是相对稳定的。
但是当有学习效应时,因为学习效应是短期的,长期来看慢慢会消退,那么实验组(有变化的组)的指标就会有一个随着时间慢慢变化的过程,直到稳定。
如果是新奇效应,实验组的指标可能会由刚开始的迅速提升,到随着时间慢慢降低。
如果是改变厌恶,实验组的指标可能会由刚开始的迅速降低,到随着时间慢慢回升。
当然我们在使用这个方法时需要注意:随着时间表征实验组的指标变化,但并不是让你每天去比较实验组和对照组的大小。如果每天都去比较,就会出现我们刚才讲的多重检验的问题。一定要记住,只有达到样本量之后才可以去比较两组大小,分析测试结果。
第二种方法是只比较实验组和对照组中的新用户。
学习效应是老用户为了学习适应新的变化产生的,所以对于新用户,也就是在实验期间才第一次登录的用户来说,并不存在“学习适应新的变化”这个问题,那么我们可以先在两组找出新用户(如果是随机分组的话,两组中新用户的比例应该是相似的),然后只在两组的新用户中分别计算我们的指标,最后再比较这两个指标。
如果我们在新用户的比较中没有得出显著结果(在新用户样本量充足的情况下),但是在总体的比较中得出了显著结果,那就说明这个变化对于新用户没有影响,但是对于老用户有影响,那么大概率是出现了学习效应。
在实践中我们可以用以上方法检测出学习效应,不过要想真正排除学习效应的影响,得到准确的实验结果,还是要延长测试时间,等到实验组的学习效应消退再来比较两组的结果。
小结
今天这节课我们重点讲解了A/B测试中两个常见的实验误区多重检验问题和学习效应。我把这两个问题出现的原理、在实践中的多种表现形式以及相应的解决方法都给你详细讲解了。
不过我还想特别强调一下多重检验问题。多重检验问题的表现形式多种多样所以在A/B测试中尤为常见。我在刚接触A/B测试时就已经知道了这个问题的存在不过当时了解到的是它会在A/B/n测试中出现但后来才发现原来在做细分分析时也会出现多重检验的问题。
幸好这个问题发现得及时,才没有让整个测试功亏一篑。现在再去复盘,主要还是因为当时只知道多重检验问题的存在,了解其中一两个表现形式。但对于为什么会出现多重检验问题,什么时候可能会出现多重检验问题,我都不清楚,所以在问题出现新的表现形式时就没有及时识别出来。
这也是我想要跟你强调的,知道为什么会出现这个问题,并且发现问题,和解决问题同样重要。
思考题
结合自己的经验想一想过去有没有在A/B测试中遇到多重检验问题和学习效应以及当时是如何处理的呢
欢迎在评论区写下你学习本节课的收获和深度思考,如果今天的内容能帮你解答了一些困惑问题,也欢迎点击“请朋友读”,和他一起学习、成长。感谢你的收听,我们下节课再见。

View File

@ -0,0 +1,161 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 常见误区及解决方法辛普森悖论和实验组_对照组的独立性
你好我是博伟。这节课我们继续来学习A/B测试中的常见误区和解决方法。
今天我们要解决的问题,是辛普森悖论和实验/对照组的独立性。这两个问题在A/B测试的实践中也是常客。
对于辛普森悖论呢由于遇到的次数太多以至于我每次做A/B测试结果的细分分析时都要先检查该细分领域在两组的比例是否符合两组整体的比例来确保实验结果的准确。
对于实验/对照组独立性被破坏这个问题我在早期做营销预算固定的A/B测试时也经常遇到但是它的表现形式其实非常多变各个业务类型中都有它的身影所以就需要有针对性地进行分析。
听了我的经历你可能还是不太明白这两个问题到底是什么它们对A/B测试有什么影响不用担心今天我就会为你深度剖析带你在实践中去识别它们并解决它们
辛普森悖论
听到“辛普森悖论”这个概念你可能会有点迷茫不知道它具体说的是什么问题。所以我还是先用音乐App来举个例子告诉你辛普森悖论是什么以及它在A/B测试中到底指的是什么。
一款音乐App优化了新用户的注册流程并且希望通过A/B测试在北京、上海这两个主要的市场来验证优化注册后的转化率是否有所提升。
实验组:使用优化后的注册流程。
对照组:使用原有的注册流程。
在设计好了实验之后实验组和对照组的样本量均分。等跑完实验获得了足够的实验样本之后结果得到实验组的转化率为1.44% 对照组的转化率为2.02%。
这就很让人意外了,为什么实验组(使用优化后的注册流程)的转化率,反而比对照组(使用原有的注册流程)的转化率要低呢?
而且更让人意外的是,当你在分别分析北京和上海这两个市场时,会发现它们的实验组转化率都比对照组的要高。
在确认了数据和计算没有错误后,你的本能反应可能会是:同样的数据,我在进行总体分析和细分分析时得出的结果却完全相反,这怎么可能呢!
这就是在数学理论中很有名的辛普森悖论,它是指当多组数据内部组成分布不均匀时,从总体上比较多组数据和分别在每个细分领域中比较多组数据可能会得出相反的结论。在数学上,它的形式要更加抽象:即使 \(\\frac{a}{b}<\\frac{A}{B}, \\frac{c}{d}<\\frac{C}{D}\)那么\(\\frac{a+c}{b+d}>\\frac{A+C}{B+D}\)也是可能成立的。
真是奇怪了这个有些反直觉的现象在数学上竟然是完全可以成立的。而这种不可思议的现象也出现在了A/B测试中。
究其原因,在这个例子当中,其实是因为实验组和对照组虽然在总体上实现了我们在设计实验时要求的样本量均分。但是在北京和上海这两个细分市场中却分布不均匀,没有实现样本量均分。-
-
关于细分分析我也再补充一下。在A/B测试的实践中我们一般根据市场不同的城市、国家等设备类型手机、桌面、平板等用户的互动或者花费程度轻度、中度、重度等来进行细分分析。
好了还是回到我们的案例当中。如果你对基础篇的内容掌握得足够扎实就会发现我们在第7节课中讲分析结果前的合理性检验时其中有一项就是检验实验/对照组中特征的分布,意思是说要检验两组中的特征分布是否相似,是否符合实验设计要求的比例分布。
所以,咱们今天讲的辛普森悖论,实际上就是由于实验中两组在不同细分领域中的分布不均造成的。
当然在音乐App这个例子中只有两个细分领域。如果是多个细分领域比如要在全国几十个大城市进行A/B测试那么只要是实验组和对照组在任何一个细分领域的分布与实验设计的不相符时都有可能出现辛普森悖论。
所以既然辛普森悖论是我们做A/B测试时需要规避的问题/现象,那有没有合适的解决方案呢?
其实,解决一个问题的最好方法就是减少或者避免它的产生。就像我刚才说的,辛普森悖论是两组在不同细分领域中的分布不均造成的。想想看,如果我们在分析测试结果前做好了合理性检验,那出现辛普森悖论的几率就会大大减小。
不过,如果在分析结果前我们没有做好合理性检验,那还有别的办法可以解决吗?
当然有,不过会比较麻烦。如果我们在进行总体分析和细分分析时发现了辛普森悖论,最好的解决办法就是重新跑实验,看看两组在不同细分领域的分布不均会不会消失。
如果分布不均的情况还是没有消失,那就说明这很可能不是偶然事件。这个时候就要检查看看是不是工程或者实施层面出现了问题,由此造成了分布的不均匀。如果是工程层面出现了问题,那就要有针对性地去解决。
当然如果时间比较紧迫,没有时间重新跑实验和检查问题的原因,那么就以细分领域的结果为准,因为总体结果出现了辛普森悖论会变得不准确。不过这里因为是比较细分领域,会出现多重检验问题,要用我们上节课讲的方式做相应的处理。
好啦,现在你对辛普森悖论的理解肯定比之前更深刻了,那么我们接下来聊聊实验组和对照组的独立性这个问题。
实验组和对照组要相互独立
首先要说明的是A/B测试有一个前提*实验组和对照组的实验单位是要相互独立的,意思是说测试中各组实验单位的行为仅受本组体验的影响,不能受其他组的影响*。这个前提又叫做Stable Unit Treatment Value Assumption SUTVA
针对实验组与对照组保持独立的问题可能很多人都会觉得这有什么好说的都分成两个组了肯定是各自独立的啊在实践中还真不是这样的。我们在做A/B测试时经常会在不知不觉中因为实践中碰到了一些业务场景导致检验两组的独立性被破坏了而这就会破坏实验结果的准确性。
这还是因为A/B测试的本质是因果推断所以只有在实验组和对照组相互独立互不干扰的情况下如果测试结果有显著的不同那么才能把这个显著不同归因成实验组相对于对照组的变化否则就很难建立准确的因果关系。
这么说你可能理解得还不够深刻下面我就结合具体的业务场景来看下在A/B测试中两组实验单位的独立性都是如何被破坏的。
破坏两组独立性的表现形式有哪些?
在A/B测试中两组的独立性被破坏主要表现在社交网络/通讯,共享经济以及共享资源这三类业务上,下面就让我来为你一一讲解。
第一类业务是社交网络/通讯类业务。
这类业务主要是用户之间的交流和信息交换典型代表包括微信、微博、领英Linkedin、语音/视频通讯、电子邮件,等等。
在这类业务中会存在网络效应。网络效应也就是网络中相邻的各个节点会相互影响。如果节点A在实验组而它相邻的节点B在对照组这时候两者就不是独立的。
我举个例子来帮助你理解。某社交App改进了信息流的推荐算法通过推荐给用户更相关的内容来增加用户的互动现在呢我们想通过A/B测试来检测算法改进的效果。
对照组:使用旧算法。
实验组:使用改进后的新算法。
评价指标:用户的平均使用时间。
这样我们就可以做个假设实验组的用户A体验到了改进后的新算法看到了更多喜欢的内容就花了更多的时间在这个App上同时也在App中分享了更多有趣的内容和朋友有了更多的互动。而他的好友用户B恰巧在对照组那么当B看到A分享的内容和互动后可能也会花更多的时间在App中浏览并且参与到和A的互动当中即使B并没有体验到改进后的算法。
这就是一个典型的A/B测试中网络效应的例子。在实验组的用户A会因为A/B测试中的变化而改变使用行为并且这个行为上的改变会通过网络效应传递给在对照组的好友B从而改变了用户B的使用行为这就使得对照组也间接受到了实验组中新算法的影响。
这显然违背了我们在对照组给用户旧算法体验的实验设计,所以测试结果很可能是两组的指标都升高,造成结果不准确。
第二类业务是共享经济类业务。
共享经济类业务一般是双边市场Two-Sided Market即公司只提供交易平台供给方和需求方均是用户。典型代表包括淘宝、滴滴、Uber、共享单车、共享租赁、爱彼迎(Airbnb),等等。在这类业务中,由于供需关系是动态平衡的,一方的变化必然会引起另一方的变化,从而造成实验中两组相互影响。
比如说我们在用A/B测试验证不同的优化是否有效时往往只能一次验证一个优化。如果我们用A/B测试检验一个需求侧的优化就要在需求侧分成实验组和对照组这样实验组由于受到了优化就会导致需求增加。那么在供给一定的情况下更多的供给流向了实验组就会造成对照组的供给减少对照组的用户体验会变得更差从而进一步打击对照组的需求。
我给你举个例子假设某共享打车服务优化了用户在App中的打车流程现在我们要通过A/B测试来验证这个优化是否有效果。
这里实验组依然是使用优化流程对照组则使用旧流程。实验组的用户因为流程的优化打车更加方便吸引了更多的司机。而由于司机的数量是稳定的这就会导致可供对照组选择的司机减少对照组的用户更难打到车用户体验变差那么通过A/B测试得出的流程优化的效果相比较对照组就会被高估。
第三类业务是共享资源类业务。
有些共享资源类业务有固定的资源或者预算,最常见的就是广告营销了。
在营销预算固定的情况下我们用A/B测试来验证不同广告的效果。如果发现我们在实验组改进后的广告效果更好点击率更高那么这就会造成对照组的广告预算减少从而影响到对照组的广告效果。因为线上的广告大部分是按点击次数付费的所以这时候实验组广告花的钱就越多在营销预算固定的情况下就会抢占对照组的预算。以此来看通过A/B测试得出的实验组的广告效果就会被高估。
如何避免破坏两组的独立性?
那么从我刚才讲的三类业务中你也能看出来在实际业务场景中由于违反两组实验单位独立性的表现形式和原因有很多所以也会有不同的方法来解决不过总的原则就是通过不同形式的分离来排除两组之间的干扰。具体而言主要有以下4种分离方法
第一种方法是从地理上进行分离。
这类方法主要适用于受到地理位置影响的线下服务,比如共享出行和共享租赁,这种本地化的服务一般不同的地域之间不会有干扰,这时候就可以按照不同的市场来分类。
最常用的是从城市这个维度进行分类。比如把北京的用户作为实验组,把上海的用户作为对照组,这样就可以排除两组间的干扰。需要注意的是,这里选取的不同市场要尽量相似,具有可比较性。我所说的相似,包括但不限于:该项业务在当地的发展情况,当地的经济状况,人口分布情况等等。
第二种方法是从资源上进行分离。
这类方法主要适用于由于共享资源造成的两组之间的干扰。具体操作就是A/B测试中每组的资源分配比例要和每组样本量的比例一致。比如在做广告营销中如果通过A/B测试比较不同组的广告的效果那么每组分配的广告预算的比例要和每组的样本量比例相等比如两组样本量均分时广告预算也要均分这样两组之间的广告预算才能互不干扰。
第三种方法是从时间上进行分离。
这类方法主要适用于不易被用户察觉的变化上,比如算法的改进。这类方法的原理就是实验组和对照组都是同一组用户,在一段时间内实施变化,给他们实验组的体验,然后在另一段时间内不实施变化,给他们对照组的体验。
需要注意的是,这个时间段的单位可以是分钟、小时或者天,这样的话因为在同一时间内所有的实验单位都属于同一组,也就不存在不同组之间的干扰了。不过用这种方法时要特别注意用户的行为可能会在每天的不同时段,或者周中/周末有所波动。如果有周期性波动的话,就要在比较时尽量在不同周期的同一个阶段进行比较,比如只把周中和周中比较,周末和周末比较,但是不能把周中和周末比较。
第四种方法是通过聚类Clustering的方法进行分离。
这种方法主要适应于社交网络类业务。社交网络中用户之间的连接其实也不是均匀的有远近亲疏那就可以通过模型的方法根据不同用户之间交流的程度来分离出不同集群cluster每个cluster都会有不同的联系很紧密的用户我们可以把每个cluster作为实验单位随机分组这样就能从一定程度上减少不同组之间的干扰。
这种方法比较复杂实施难度大需要数据模型和工程团队的支持有兴趣的话可以参考Google和Linkedin的经验。
小结
在这节课,我具体讲解了辛普森悖论和实验/对照组的独立性这两个常见的实验误区,以及在实践中的常用解决办法。相信通过这节课的学习,你能够在实践中及时发现并解决它们。
另外啊,我把这两节误区系列的内容呢也总结成了一张图,放在了文稿里,你可以保存下来,方便之后的复习与巩固。-
到这里我们的常见实验误区系列就告一段落了通过这两节课的学习你也体会到了我在讲课时不断说的真实业务场景的复杂多变潜在的各种各样的坑这些误区和问题其实都是通过一次次A/B测试去积累试错得出的所以就更加体现出了实践在A/B测试中的重要性。
如果你能在A/B测试中及时识别和解决这些常见的潜在的误区不仅能为公司挽回潜在的损失获得持续的增长也是区别你和A/B测试新手的重要标志。
思考题
结合自己的经验想一想过去有没有在A/B测试中遇到辛普森悖论和实验/对照组的独立性被破坏的情况?以及当时是如何处理的呢?
欢迎把你的思考和收获分享在留言区,我们一起学习、讨论。如果这两节的误区系列帮你解答了一些疑难问题,也欢迎你把课程分享给你的同事、好友。我们下节课再见。

View File

@ -0,0 +1,132 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 什么情况下不适合做A_B测试
你好,我是博伟。
我们知道A/B测试是帮助公司实现持续增长的利器。然而没有任何一种方法能解决所有的问题让我们一劳永逸。A/B测试也是如此。
A/B测试可以解决大部分因果推断的问题。但在有些因果推断的业务场景下A/B测试就不适用了。这个时候我们就需要另辟蹊径换一种思路和方法来解决问题。
所以今天这节课我们就来学习A/B测试在什么情况下不适用如果不适用的话有哪些相应的解决方法。
A/B测试在什么情况下不适用
在实践中主要有3种情况下A/B测试不适用
当没有办法控制想要测试的变量时
A/B测试是控制变量实验它的一个前提就是我们必须可以控制想要测试的变量的变化这样才能人为地给实验组和对照组的实验单位不同的用户体验。但是在有些情况下我们就没有办法控制变量的变化。
你可能会有疑问,有这样的变量吗?
当然是有的,主要是用户个人的选择。我们能够控制的变量其实都是在产品和业务端,但是对于用户个人的选择,我们其实是没有办法、也不可能去控制的。毕竟用户都是有自由意志的,所以我们所有的营销方法都是努力去说服用户,但最终选择权还是在用户手里。
比如我们想要了解用户从QQ音乐换到网易云音乐后使用情况的变化那更换音乐App就是我们想要测试的变量。需要注意的是我们无法帮助用户决定是否要更换音乐App的行为因此我们也没有办法做到真正的随机分组。
你可能会说我们可以通过营销给用户优惠甚至付费让用户去更换音乐App这在实践上是可行的但是在实验中就会产生新的偏差。因为对于外界激励不同的用户会有不同的反应我们可能只研究了对外界激励有反应的用户而忽略了对外界激励没有反应的用户。这样得到的实验结果是不准确的。
当有重大事件发布时
重大事件的发布,主要指的是新产品/业务的发布,或者涉及产品形象的一些改变,比如商标/代言人的改变我们往往是不能进行A/B测试的。因为凡是重大事件的发布会都想要让尽可能多的用户知道并且也花了大量营销的钱。在当下这个信息流通极度发达的互联网时代不存在我公开发布了一个新品只有一小部分用户知道这种情况即使是中小企业也是如此。
比如苹果公司每年的新品发布会并不会、也不可能事先去做大规模的用户A/B测试来看看新品的效果如何然后再决定是否要发布。
再比如,一个公司如果想要改变自己的商标,就不能事先把用户进行分组,让实验组的用户接触新商标,对照组的用户接触旧商标。因为商标是一个公司或者产品的形象,你想想看,如果把用户进行分组,就会出现同一个产品同时有多个商标在市场流通的情况,那就会对用户造成困惑,而且也不利于产品形象的打造。
当用户数量很少时
这个其实很好理解如果我们没有一定的流量能让我们在短时间内达到所需要的样本量的情况下那么A/B测试也就不再适用了不过这种情况其实在大数据的互联网行业中比较少见这里我们就不展开讲解了。
当A/B测试不适用时有哪些替代方法
当A/B测试不适用时我们通常会选用非实验的因果推断方法和用户研究两类方法来替代让你在想做因果推断却又不能进行A/B测试时有新的思路和方法。
倾向评分匹配Propensity Score Matching
非实验的因果推断方法又叫观察性研究这其中最常用的就是倾向评分匹配Propensity Score Matching简称PSM。我在第9节课已经介绍了PSM它的本质就是在历史数据中通过模型的方法人为地而不是像实验那样随机地构建出相似的实验组和对照组最后再对两组进行比较。
这里我会通过一个音乐App的案例来详细讲解下用PSM替代A/B测试时是怎么在因果推断中应用的。
这款音乐App是付费订阅模式有两种订阅方式
个人订阅每月10块钱只能供一个人使用。
家庭订阅每月20块钱最多可以5人同时使用。
此外不管是个人订阅还是家庭订阅只要是新用户都会有3个月的免费试用期。
数据分析师通过大量的数据分析发现,家庭订阅比个人订阅用户的长期留存率(即续订率)更高。仔细想想其实也很好理解,家庭订阅可以和他人分享,所以每个订阅中的用户会更多一些,一般不止一个。而订阅中的用户越多,就越不容易取消这个订阅,所以长期留存率会越高。
于是这位数据分析师就根据这个分析发现,向营销经理推荐:可以向个人订阅的用户发广告,宣传家庭订阅的好处,鼓励他们升级到家庭订阅。
不过营销经理却提出了不同的意见:选择家庭订阅的用户和选择个人订阅的用户,在本质上就是不同的。比如他们的用户画像、使用行为等,都存在很大差异。也就是说,并不是升级本身导致了用户留存的提高,而是由于他们本来就是不同的用户,所以留存才不同。
为了验证营销经理的想法,数据分析师详细地分析了两种订阅方式的用户画像和使用行为,发现果然如营销经理所说,从个人订阅升级到家庭订阅的用户和没有升级的用户差别很大,比如升级的用户平均年龄更大,使用的时间更长等等。-
看到这里,你大概已经知道了,个人订阅升级到家庭订阅是否会提升用户留存率,其实是一个因果推断的问题。
数据分析师的观点是“从个人订阅升级到家庭订阅”这个原因,可以导致“用户留存提升”这个结果。
但是营销经理的意见是影响用户留存的因素有很多,在用户升级这个情境下并不能排除其他因素,因为升级是用户自己的选择,那么很有可能升级和不升级的用户本来就是两类不同的人,所以在其他因素不相似的情况下就不能只比较升级这一个因素。
两者的观点看起来都很合理,那我们该通过什么方法来验证谁对谁错呢?
验证因果推断的最好方法当然是做A/B测试了但是在这个业务情景下由于是否升级这个变化因素是用户的自主选择我们并不能控制所以就并不能做随机分配的实验。那么这个时候非实验的因果推断方法PSM就可以派上用场啦。具体方法如下。
首先,我们从历史数据中选取在同一个时间范围内开始个人订阅的试用期用户。
在三个月试用期结束后还在付费的用户中有的依旧是个人订阅有的则升级成了家庭订阅。而在这自然形成的两类用户中我们通过PSM的方法对用户的画像和使用行为等因素进行匹配在没有升级的用户中选出和升级用户相似的用户然后在这些相似用户中比较长期的用户留存-
-
接着进行完PSM后呢我们再来比较下个人订阅和家庭订阅各自的用户画像和使用行为。-
-
从数据中我们可以发现经过PSM处理后的没有升级的用户和升级的用户在各个特征上都已经非常相似了那么这个时候我们就可以进行比较了。当我们比较时因为已经控制了其他特征相似两组只有“是否升级”这一项不同所以如果用户留存有变化那就说明是升级这个变化因素造成的。
最后我们来看一下最终的比较结果。下图中的纵轴是用户留存率横轴是从试用期开始时的月份因为试用期是3个月且试用期内不存在续费问题所以留存率就是100% 那我们就从第4个月开始算用户留存率。-
-
从图中可以看到如果我们不做PSM的话就像最开始数据分析师发现的那样个人订阅升级到家庭订阅能够使一年的留存率提升28% 但这是在没有剔除其他因素的情况下所以28%这个结果就不够准确(营销经理的观点)。
那么经过PSM处理后我们得到了和升级用户相似的非升级用户结果发现升级确实能提升用户留存不过只能提高13%那就说明只有13%的用户留存率的提升可以归因于用户升级。
这里我们通过PSM在剔除了其他因素的影响之后模拟出了一个控制变量实验从而确定了个人订阅升级到家庭订阅对用户留存所带来的准确影响。
用户研究
用户研究适用于A/B测试无法进行时比如新产品/业务发布前的测评,我们就可以通过直接或间接的方式,和用户交流沟通来获取信息,从而判断相应的变化会对用户产生什么影响。
用户研究的方法有很多种我们今天主要来聊一聊常用的几种深度用户体验研究Deep User Experience Research焦点小组Focus Group和调查问卷Survey
深度用户体验研究,指的是通过选取几个潜在用户进行深度的信息提取,比如通过用户眼球的运动来追踪用户的选择过程的眼动研究,或者用户自己记录的日记研究。
眼动研究能让我们了解到用户的正常使用流程是什么样的,在哪些阶段会有卡顿或者退出。
日记研究通过用户自己记录的使用情况和意向,来了解他们的反馈。
焦点小组是有引导的小组讨论,由主持人把潜在的用户组织起来,引导大家讨论不同的话题,然后根据大家在讨论中发表的不同意见,综合得出反馈意见。从小组讨论这个形式就可以看出,每次焦点小组能够组织的用户一般要比深度用户体验研究的用户数量要多,但是比调查问卷的用户数量要少。
调查问卷就是通过事先设计好想要了解的问题,可以是选择题或者开放式的问题,比如对新品/新业务的想法和感受。然后把这些问题做成问卷发放给潜在的用户。交流方式可以是面对面、线上或者是电话等等,然后根据不同用户的回答结果,统计出大致的反馈结果。-
-
从图中可以看出,从深度的用户体验研究,到焦点小组,再到调查问卷,虽然参与的用户越来越多,但是团队从每个用户身上获得的信息深度会越来越浅,所以选择何种方法也取决于你能招募到多少潜在用户,有没有相应的条件与设备(比如眼动研究需要眼动仪来完成),还有想要得到的信息深度。
小结
今天这节课我们讲解了A/B测试的局限性通过案例介绍了非实验的因果推断方法-倾向评分匹配PSM也带你简单了解了用户研究的相关方法。实践中出现较多的还是我们没有办法控制的用户选择这种变量主要会用到PSM这种非实验的因果推断方法。那么用户研究在实践中不仅可以用于新产品/业务的测评还可以用于产生新指标的想法比如我在第3节课中讲到的定性+定量相结合的方法来确定指标)。
那么从开头到今天这节课呢我们的专栏讲解了A/B测试的统计原理标准的流程以及实践中各种常见问题及解决方法。说到应用这些经验和方法论呢工作场景自然是最佳场所不过还有另一个实践的好机会那就是在面试中。
那么在接下来的两节课我就会带你去过一遍面试中常考的A/B测试问题。同时我也建议你先梳理下自己面试时曾被问到的那些问题以及自己当时自己是怎么回答的。这样我们在学习后面两讲内容的时候也会更有针对性。
思考题
结合自己的经验想一想你有没有见到过或经历过想进行因果推断相关的分析但是A/B测试却不适用的情况详细说一说原因和结果。
欢迎你把对本节课的思考和想法分享在留言区,我会在第一时间给你反馈。

View File

@ -0,0 +1,177 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 融会贯通A_B测试面试必知必会
你好,我是博伟。
在接下来的两节课呢我们换换脑子来聊一个相对轻松点的话题与A/B测试相关的面试应用。
近几年随着A/B测试在互联网、电商、广告等各个行业的广泛应用已经成为数据、产品、增长等相关职位面试的一个重要组成部分。所以我就根据自己多年做面试官的经验帮你总结了常见的A/B测试相关面试考点一方面我会通过典型真题来讲解面试思路另一方面也会把我在面试中的一些沉淀与思考分享出来。
另外我还想强调的是,这两节课虽然是在讲面试题,但其实也是在以另一种方式考查你对所学知识的灵活运用。面试中考察的不仅是你对知识的掌握,更关注你在工作场景中要怎么运用。所以希望你能通过这两节面试课的学习,既能学会拆解题目,提高面试能力,同时也能把我们学过的知识融会贯通。
面试题目是无穷的,但考点是有限的。我把相关的考点总结成了一张图,方便你着重复习。接下来我们就开始正式的面试讲解吧。-
面试应用一
某共享出行公司改进了司机使用App的用户界面希望能给司机更好的用户体验在提高司机使用App频率的同时也能提高司机的收入。那么问题就是请你设计一个A/B测试来验证新的司机App是否比旧的司机App体验要好。
考点:
A/B测试的流程。
实验组/对照组的独立性。
解题思路:
很多同学遇到这个面试题首先想到的就是串一遍A/B测试的流程于是就按照以下流程开始回答。
确定目标和假设 —> 确定指标(说出评价指标和潜在的护栏指标)—> 确定实验单位—> 随机分组(一般为均分) —> 确定样本量(这里注意,强调需要已知哪些统计量来确定样本量)—> 实施测试 —> 合理性检验(要说出具体的检验都有哪些)—> 分析结果注意说明P值法和置信区间法的判断标准
如果你只回答了设计流程这一点可能仅仅是个及格分因为题中还设置了至少1个隐藏的坑点这也恰恰就能拉开你与其他面试者的距离。
首先要注意在回答流程时一定要结合题目的具体内容展开讲解否则就是照本宣科会给面试官留下不能活学活用的印象。如果你不知道怎么回答比较好可以参照我在第8节课串讲案例的思路和方法。
不过这道题最大的坑点还不在这儿你需要再细心点儿。仔细看“共享出行”这个具体情境如果你对第11节课中两组独立性这个知识点掌握得足够牢固就会发现面试官在这道题中想考查你的绝不是串讲一遍流程这么简单。
面试现场也是工作的实际场景,那你就需要具体情境具体分析,洞察出设计实验时需要保持实验组和对照组的独立性。
我们来通过一个例子深度剖析一下。
假设我们选取在上海使用该共享出行的司机把他们随机分成实验组和对照组每组各占50%。其中在实验组司机使用新的司机App对照组则使用旧的司机App。
我们先来看实验组: 如果新App的确提升了司机的用户体验司机的使用频率提高这意味着实验组的订单量就会增加。因为订单总量需求是一定这样就会导致对照组的订单量减少。
与此相反的是如果新App降低了司机的用户体验司机使用App的频率降低那么实验组的订单量就会减少对照组的订单量则会增加。
这时你就会发现实验组和对照组不是独立的而是相互影响的。这就违背了A/B测试中实验组和对照组必须是相互独立的前提假设从而导致实验结果不准确。
在题目中的场景下比较好的解决方法是在不同的城市进行测试我们找到两个相似的城市A和B相似的目的是使两组具有可比性比如业务在当地的发展程度、经济发展程度、人们的出行习惯等实验组是城市A中的司机使用新App 对照组是城市B中的司机使用旧App。这样的话两组就不会相互影响。
所以针对这道题,完整且正确的回答方式应该是:先指出两组独立性被破坏的问题,通过举例分析说明两组是相互影响的;然后提出你的解决方案;最后结合实际情境串讲流程。
其实啊,如果你是个高手,就应该看出题中还有一个隐藏的考点:学习效应。
因为题目中是测试新的用户界面,所以还可能会有老用户的学习效应:新奇效应或改变厌恶。关于这个考点,你在这里简单提及,说明识别及解决方法即可,不需要长篇大论再进行展开。因为这道题考察的核心重点依旧是两组的独立性和设计流程,但是如果你能留心到潜在的学习效应问题这个坑,这就相当于你在优秀的回答之外,还给了面试官一个惊喜,证明你有填坑的能力。
面试应用二
在过去的实践中你有没有经历过这种情况A/B测试虽然得到了显著的结果比如P值小于5%),但最终还是决定不在业务/产品中实施测试中的变化。原因是什么呢?请举例说明。
考点实施A/B测试中的变化要考虑的因素
解题思路:
这道题很简短,乍看上去会觉得很容易,往往这个时候你就要小心谨慎了。仔细想想,面试官想通过这道题来考查什么知识点呢?考察你的什么能力呢?
在知识点上面试官主要考查的是在实践中实施A/B测试中的变化时需要考虑的因素有哪些。
这个问题其实是非常直接的,你很容易知道面试官在考察什么知识点。不过我想强调的是这类问题在面试中还有很多的变体,你需要在不同的变体中识别出本质问题。
核心问题:面试官会从结论出发(最终没有实施变化),问你可能会有哪些原因。
变体1面试官会给你测试结果的数据数据中的P值虽然小于5%但是十分接近5%比如4%。说明两组变量间的不同其实非常小,对实际业务的影响十分有限。
变体2面试官会直接问你实施A/B测试中变化的成本是什么。
无论怎么变化,归根结底都是一句话:结果是统计显著的,但是业务并不显著,因此在实践中没有实施变化。
在实践中统计上的显著结果只是最终实施变化的原因之一另一个方面还要考虑到实施变化的成本和收益。收益的话我们可以根据显著结果的差值来估算但是就成本而言我们需要考虑的因素是多方面的就像我在第7节课中讲的需要估算业务上的显著性。
所以在回答这类题目时,结合案例围绕着以上这些成本展开讲解,提出结果是统计显著,但是业务上不显著,所以最后才没有在实践中实施变化。
具体来说,在实践中实施变化主要有以下几种成本。
人力成本
指的是要实施变化的相关人员的时间成本,比如工程师需要花时间去实施具体的变化,编写相关代码。产品经理需要花时间去收集整理新的要求,组织相关会议,编写文档。如果变化会引起用户困惑的话,那么客服人员还要花时间去给用户答疑解惑。
机会成本
在实践中,时间和资源在业务/产品的不断迭代当中是永远不够用的请你想象一个场景在新版本上线前如果同时有A和B两个变化都具有统计显著性P值均小于5%),但我们的时间和资源有限,在上线前只能实施一个变化,那这个时候肯定会选择对业务影响较大的变化。
那你就会问了当这两个的P值都小于5%时,我该怎么比较哪个变化对业务产生的影响更大呢?
具体来说有两种方法。
第一种方法就是估算变化带来的业务影响。这种方法适用于不同变化有着不同的评价指标,或者不同的受众范围。
比如变化A使转化率提升了2%每年可以多带来10万的新用户。变化B使留存率提高了0.5%每年可以多留住5万的现有用户。此时我们就要衡量增加10万新用户和留住5万现有用户的价值哪个更大比如可以通过数据分析或者建模的方式确定新用户和现有用户的平均价值
当然这也和所处阶段的业务目标有很大关系,你需要看当时的业务重点是拉新还是留存。一旦我们量化估算出变化带来的业务影响,就可以决定该优先实施哪个变化了。
第二种方法是计算效应值Effect Size。这种方法适用于变化相似且评价指标相同时。
比如改进推荐算法的实验大都以点击率作为评价指标。那么现在有新算法A和新算法B和老算法相比都有提升那么这时就要计算每个实验的效应值
-
效应值在统计中是用来表示指标变化的幅度的,效应值越大,就说明两组指标越不同。
如果我们计算得到的新算法A的效应值比新算法B的大就说明A的改进效果幅度更大影响也更大那就可以决定优先实施A变化了。
计算效应值其实也是估算变化带来的影响,不过因为这些变化都有相同的评价指标,所以我们只需要算出效应值来进行比较即可。
代码成本
实施变化一般需要代码的改动,这种改动会潜在地增加代码出错的概率,同时随着代码库越来越复杂,也会增加未来代码改动的成本。
面试应用三
我们对公司网站进行了改版想要以此来提升用户参与度。通过A/B测试发现新版本的用户参与度确实显著提升了所以我们随后就对所有用户显示了新版网站。但是过了一段时间后用户参与度又回到了之前的水平。假设这个A/B测试本身没有技术上或者统计计算上的问题。你觉得导致这种情况的原因会是什么呢又该怎么解决呢
考点:学习效应
解题思路:
这道题在知识点上的难度并不高,主要考察的是学习效应的问题。不过你要是只回答了这一个原因,这其实是大多数面试者都容易想到的,也仅仅只是一个合格的分数。
我先把自己更推荐的回答方式写出来,然后再带你仔细分析这道面试题。
比较推荐的回答方式是:先列举导致这种情况可能的原因有哪些,再结合题目的具体场景进行一一排除,最后得出自己的结论,给出解决方法。
为什么要这么回答呢?主要是因为相比较仅仅回答一个原因,或者直接给出解决方法,这种回答方式更能体现你对问题的全面理解。我在之前的课程中也强调过,知道为什么会出现这个问题,并发现问题,有时候甚至比解决问题还要重要。所以面试官在这里重点想要考察的,就是你对出现问题的原因的探究。
变化实施后的实际效果和A/B测试的结果不一致其中的原因有很多种最常见的原因主要是两个
实施A/B测试中出现的技术Bug。
在计算测试结果时出现错误(比如还没到足够的样本量就去计算结果)。
接下来我们进行一一排除。
首先题目中明确说了技术上和统计计算上都没有问题那接下来就要排除A/B测试常见的误区。
其次,由于题目中的场景并不是社交网络或者像共享经济的双边市场,实验组和对照组不会相互干扰,所以也不存在实验/对照组独立性被破坏的情况。
接着,从测试本身的设置和对结果的描述来看,没有细分分析或者多个实验组,又不会有多重假设问题或者辛普森悖论。
最后,对于网站不同版本这种问题,其实最常见的问题是学习效应,就像我刚才分析的那样,把其他常见的原因都排除了,那么其实考察的知识点就是学习效应。考察学习效应的面试题形式有很多种,有的会直接问你学习效应,有的就会像是本题中,给你一个具体的场景,让你判断。
根据题目描述的情况,应该是学习效应中的新奇效应:用户刚开始对于新版本很好奇,所以参与度会上升。但随着时间的推移,慢慢又会回归到正常平均水平。
至于如何识别和解决学习效应如果你还不能顺利回答出来那就得再回去复习第10节课的内容了。
所以你看,在面试中,面试官考察你的不仅是知识点,更重要的是你对问题的发散理解,以及思考问题的方式。
小结
在这节课里我主要讲了3道面试题通过我的详细分析你也能够发现拆解题目是一项很重要的能力。
很多人在面试前都会去刷题,刷题固然重要,但是在面试这种高压场景下,可能回出现大脑短暂空白的情况。其实面试题目也是有套路的,就像搏击中的双方,你需要猜测对方可能会出什么招式,如果你能在对方出招前反应出他的下一步动作,哪怕是一秒钟,就有机会制胜对方。所以相对于海量刷题,学会拆解题目就显得更重要了。
相信你通过今天的学习对于A/B测试相关面试的形式和考点有了初步的了解你一定还意犹未尽没关系我们下节课接着来剖析典型面试题及考点。
思考题
你有遇到过什么有意思的A/B测试的面试题吗或者是有什么好的面试经验吗欢迎分享出来我们一起探讨。
欢迎分享出来,我们一起交流、探讨。也欢迎你把本节课推荐给你的朋友,一起进步、成长。

View File

@ -0,0 +1,139 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 举一反三A_B测试面试必知必会
你好,我是博伟。
今天这节面试课,在学习的过程中你会发现考察的知识点都已经掌握得差不多了。不过我想要强调的是,知识是你业务精进的基础,也是面试时考察的一个重要方面。但更为关键的,是你能够把知识举一反三,知道在不同的场景中如何应用,这也正是把知识转化为解决问题的能力,是你的面试竞争力。
好了,那我们就趁热打铁,继续来讲面试这个主题,帮你夯实基础,做到面试不慌!
面试应用一
假设你现在负责跑一个A/B测试根据样本量计算测试需要跑2周。但是业务上的同事会每天关注测试结果一周之后就观察到显著结果了这时候他觉得既然结果已经显著了就想让你停止测试然后实施测试中的变化。由于业务上的同事对统计不是很熟悉所以你该怎么用直白的语言来给他解释现在还不能停止实验呢
考点:
多重检验问题
统计原理的通俗解释
解题思路:
其实这道题我在第7节讲分析测试结果时就给出了一个类似的实践背景由于在样本量还没有达到规定前不断查看结果就会造成多重检验问题。而一旦出现多重检验我们之前花费的功夫就会功亏一篑。所以在这道题中你需要首先指出多重检验问题接着说明出现的原因以及可能造成的具体后果得到假阳性的概率增大实验结果不准确
你也能看出来如果只是考多重检验问题那就太简单了。斟酌一下题目中的问题就能知道面试官想要考察的是其实是你的表达与理解能力也就是说你该怎么用通俗直白的语言来给业务同事解释复杂难懂的统计概念和原理。在实际工作中很多时候需要和没有统计背景的同事去沟通交流A/B测试的相关内容所以面试官也非常喜欢考察面试者这方面的能力。
不仅如此,这其实也是在变相考察面试者是不是真正内化了相关统计知识。毕竟如果只是死记硬背概念,肯定是不能在实践中灵活运用这些原理的,更别说再把这些原理用直白的语言去讲给没有统计背景的人听。
你可能会问,通俗直白的语言到底是什么呢?其实也很简单,就是说人话。我的经验就是“一个避免,两个多用”。
一个避免指的是尽量避免使用统计术语P值、第一类错误、假设检验等
一方面,专业统计术语会加大你们沟通的时间成本和沟通障碍。业务同事是不懂这些术语的,当你用专业术语去向他说明时,你就需要花更多的时间来解释术语,不仅对方难以理解,而且你们的沟通目的也没有达到。
另一方面,仔细想想,你为什么要去给业务同事解释呢?主要就是为了告诉对方,现在还不能停止实验。所以啊,说清楚为什么不能在此时停止实验就可以了,术语能少则少。
两个多用指的是多打比方、多举例子。尤其是通过日常生活中的事物来打比方、举例子,这会是非常好的一种方式。
比如A/B测试其实是比较两组的表现既然有比较那就有好坏输赢的概念。那你就可以选择生活中任何有好坏输赢结果但是每次发生结果都有可能不同的事件来打比方。
我比较喜欢拿体育比赛来打比方比如篮球就和A/B测试非常类似。每场NBA篮球比赛都会有事先规定的时间48分钟。而且篮球比赛的结果是以比赛结束后的最终结果为准。如果在比赛结束前的任何时间查看比分任何一方都有可能领先但是我们并不会以比赛中间的结果作为最终结果。
同理回到A/B测试当中来如果我们还没有到达规定的时间看到显著的结果就宣布实验已经完成从而停止实验这就和在比赛中看到一方领先就宣布领先的一方获胜、比赛结束是一样的道理。
再回到我们的面试场景中多重检验问题在工作中其实是很常见的。尤其是在业务上的同事没有很强的统计背景的情况下可能只是依靠P值来做决定不会考虑样本量是否充足这个前提所以用通俗的语言来解释这些统计原理尤为重要。
面试应用二
某产品现在想改变商标,所以想衡量新商标对业务的影响,该如何做?
考点:-
A/B测试的适用范围及替代方法
解题思路:
如果你对第12节课讲“什么情况下不适合用A/B测试”的知识足够熟悉就知道这里并不能用A/B测试来衡量商标改变的影响性。我讲过“当有重大事件发布时”是不适合去做A/B测试的商标即是其中之一。毕竟商标代表了产品和公司的形象如果一个产品有多个商标同时在市场流通就会给用户带来困扰从而会对产品形象有不利的影响。
还记得A/B测试的两种替代方法吗分别是非实验的因果推断方法和用户研究。不过啊在这个情境下非实验的因果推断方法也行不通因为这个商标是全新的并没有历史的相关数据。所以用户研究就是我们最终选定的方法。
在这个案例中,我们只需要收集用户对新商标的看法如何,所以就需要的样本尽可能大一些,这样意见才有代表性,但是并不会涉及到用户体验等很有深度的问题。那么我们就可以选用调查问卷的方式来收集用户反馈,从而给我们一些方向性的指导。让我们知道相较于现有的商标,用户对新商标偏正面反馈,还是更偏负面反馈。
如果从调查问卷中得到总体正面的反馈后,团队决定在市场中废除现有商标,推出新商标。这时候就可以来衡量更换商标后的影响,相对于比较推出新商标前后产品的北极星指标的变化来计算出差值去推断出新商标影响,一个更加准确的方法是建立模型。
我们可以用历史数据建立起对北极星指标的时间序列模型,用推出新商标前的数据去训练这个模型,它也可以预测出没有新商标的北极星指标的走势,然后我们可以把模型的预测数据和推出新商标后的实际数据进行比较,从两者的差值来推断出新商标的影响。
总结一下这道题的答题思路即说明题中场景下A/B测试不适用及其原因然后再给出用户研究和模型的办法来作为替代解决方法。
面试应用三
某社交网站准备给用户推荐好友在首页的右上角推出“你可能认识的人”这个新功能怎么设计A/B测试才能真正衡量这个功能底层的推荐算法的效果呢假设这里没有网络效应。
考点:-
A/B测试分组设计
解题思路:
当拿到题目一看到社交网站,你会立马想到网络效应,但是读完题发现这里假设没有网络效应。
你可能会想想要推出一个新功能而且还不考虑网络效应那肯定就是常规的A/B测试设计了呗。所以就把用户随机均分成两组对照组的用户没有“你可能认识的人”这个新功能实验组的用户有这个新功能。最后比较两组的指标来确定推荐新功能的推荐算法的效果如何。
你看,这没有什么难的!如果真的这么想,那你就在不知不觉中掉进面试官给你设的坑了。
我们再仔细读题中的场景描述,就会发现这个新功能是在页面的右上角,这意味着增加这个新功能还涉及到用户交互界面的改变。
如果按照我们刚才所说的实验分组进行设计,把实验组和对照组相比,其实是既增加了推荐算法,又改变了交互界面,是同时改变了两个因素。以此来看,即使实验组的指标相对于对照组有所提升,我们也无法确定究竟是哪个因素在起作用。
所以这道题的关键点就是如何分离这两个潜在的影响因素。在实践中,解决的方法一般是设计多个实验组,每个实验组只改变一个因素,同时共用一个对照组,也就是改变前的状态。
是不是觉得这个方法有点熟悉呢没错儿这就是我在第9节课中提到的A/B/n测试。不过这个案例的情况比较特殊因为要增加推荐算法的话肯定会改变交互界面也就是说其中一个因素必须依赖另一个因素不能单独存在。
但是如果反过来想,其实改变交互界面并不一定要增加推荐算法,所以我们可以把各个分组设计成递进关系:
对照组:改变前的原始版本。
实验组A: 增加“你可能认识的人”这个新功能, 其中推荐的内容随机产生。
实验组B: 增加“你可能认识的人”这个新功能, 其中推荐的内容由推荐算法产生。
我们可以发现实验组A相对于对照组只是改变了交互界面因为它的推荐内容是随机产生的。而实验组B相对于实验组A则是只增加了推荐算法而二者的交互界面是相同的。这样我们就可以通过比较对照组和实验组A来衡量改变交互界面是否有影响比较实验组A和实验组B来判断新功能的底层推荐算法是否有效果。
面试应用四
某社交平台开发出了一个新的交互界面希望能增加用户的点赞次数。团队通过把一部分用户随机分组进行A/B测试发现用了新界面的实验组的用户平均点赞次数比对照组高出了5%结果也是显著的。那么如果把新界面推广给所有用户你认为用户的平均点赞次数会提升多少呢是大于5%还是小于5%?为什么呢?在这个案例中,我们假设没有学习效应的影响。
考点:-
网络效应
解题思路:
看到“社交平台”就要想到“网络效应”,经过前面的学习,你应该对这一点形成肌肉记忆。
这道题其实难度不大,考察的是网络效应及其形成原因。不过我想通过这道题,一方面让你清楚网络效应的具体场景,另一方面,也想让你知道在有网络效应的影响下,社交平台开发新交互界面后的真实提升效果和实验结果之间的关系。
在没有学习效应的情况下,因为是社交平台,存在网络效应,所以随机分组并不能保证实验组和对照组的独立性,意味着两组的独立性被破坏了。
具体而言如果实验组的用户A因为用了的新界面点赞了一个内容那么这个被点赞的内容也会被A的好友在对照组的B看到B也有可能点赞这个内容。所以这个新界面改动既影响了实验组还会通过网络效应影响对照组即实验组的用户平均点赞次数提升对照组的也会提升。
以此来看这里的5%的提升其实是受到网络效应影响后的结果真实的提升效果应该会更大即只有实验组的指标提升而对照组的指标不变即大于5%。
所以当我们把这个新的交互界面推广到所有用户也就是在没有对照组的情况下那么和旧版本相比真实的提升效果应该是大于5%的。
小结
我们两节课的A/B测试的面试之旅到这里也就告一段落了。你应该也能发现这些常见的考点我们在前面的课程中都有讲解过只要你认真学习了专栏的内容是不会有太大的问题的。
在最后呢我还想强调一点。我们在这两节课讲的面试题大都是题目中直接提到A/B测试的在面试中A/B测试的考查形式是多种多样的。有时题目中并没有明确提到A/B测试但是A/B测试是这些题目中答案的有机组成部分比如让你衡量产品新功能的好坏是不是应该推进这个产品变化这种问题你的答案中肯定会有要如何定义目标和指标去表征新功能的影响如何设计A/B测试去验证新功能是否有效。总之只要让你进行因果推断需要量化改变带来的影响时A/B测试都是你的好帮手
思考题
这里呢我们开动脑筋如果让你用直白通俗的语言不用统计上的定义不引用其他术语解释A/B测试的相关术语的话你会怎么解释呢选取一两个尝试着解释下。
欢迎把你的解释分享在评论区,我们一起交流、讨论。同时如果你有所收获,也欢迎你把这节面试课分享给你有需要的朋友。

View File

@ -0,0 +1,269 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 用R_Shiny教你制作一个样本量计算器
你好,我是博伟。
A/B测试前的样本量计算是设计实验时不可或缺的一步。在第6节讲样本量计算时我提到了现在网上的样本量计算器参差不齐的问题并且网上大部分的计算器都是只能计算概率类指标不能计算均值类指标在实际业务应用时十分局限。
鉴于这些问题加上我在实践中也想要更加快速且正确地计算样本量提高工作效率于是就从统计理论出发去钻研A/B测试样本量计算的原理让自己能够识别和掌握正确的计算方法。
后来渐渐发现身边的同事和朋友在做A/B测试时也有这方面的需求于是我就把样本量的计算方法工具化做成App放在了网上。
所以我今天教你的就是把样本量计算工具化的详细过程——我会带你制作一个可以发布在网上的实时计算的A/B测试样本量计算器。
实战指南
既然是制作App我们还是需要进行一些简单的编程包括前端和后端使用R语言及其前端库Shiny。不过你也不用担心制作这款App不需要你掌握前端的JavaScript、HTML、CSS也不需要你会在后端如何搭建数据库。你只需要掌握以下3点就足够了。
A/B测试样本量计算的原理。关于原理重点学习咱们这门课”统计篇”的两节课和基础篇的第6节课即可。
最基本的编程知识。我指的是通用的编程,不细指特定的语言。包括变量赋值、基本的数据类型(字符串,数字等),这些最基础的编程知识,即使不是专业的编程人员,也是大部分互联网从业者都会知道和掌握的,难度不大。
R和Shiny的基本语法。如果你对R和Shiny很熟悉的话那就可以直接跳过这一点。如果你之前没有接触过R和Shiny也没关系这些语法也是可以快速学习和掌握的。我在这里给你一些拓展资料供你参考学习。
如何安装R和Rstudio
R的基本语法只需看R Tutorial这部分即可
Shiny教程
如果你没有时间和精力学习R和Shiny的知识也别担心我会把我的代码开源贴出来你可以结合代码和本节课的内容学习理解。
相信如果你是跟我从头开始认真学习这门课的话现在你肯定已经掌握了第一点A/B测试样本量计算的原理。至于第二点最基本的编程知识相信作为互联网人的你已经掌握或者有概念性的认知了。那么我们今天就重点讲解下如何把这两点结合起来制作一个简单方便的样本量计算器在教你制作的同时呢我也会穿插讲解R和Shiny的相关知识还有一些实战案例。
在讲解前呢,我先把我的代码库和样本量计算器贴出来,供作参考:
代码库
样本量计算器App
首先如果你点开GitHub上的代码库就会发现主要的文件有两个server.R和ui.R。这是Shiny App的标准文件结构其实从文件名就能看出它们大概的功能
server.R负责后端逻辑的文件比如我们这次的样本量计算的逻辑都在server.R当中。
ui.R负责前端用户交互界面的你的App做得好不好看全靠它了。
接着,你点开我贴出来的样本量计算器链接就会发现,它已经按照指标类型分成了概率类和均值类:
那么我今天就按照这两类指标来分别进行讲解。
制作过程
概率类指标
从概率类指标的样本量计算的逻辑参看第6节课上来看我们需要函数power.prop.test。下面这段代码L31-35是在server.R文件中的具体实施
number_prop_test <-reactive({ceiling(power.prop.test(
p1=input$avgRR_prop_test/100,
p2=input$avgRR_prop_test/100*(1+input$lift_prop_test/100),
sig.level=1-numsif_prop_test(),
power=0.8)[[1]])
})
函数的输入参数这里,我们需要输入以下四项信息:
两组的指标p1和p2。
显著水平sig.level。
Power。
单双尾检验。
我们来对照实际的前端交互界面来看下应该怎么输入:-
两组的指标p1和p2
在这里我会让用户输入原始指标也就是p1和最小可检测提升。注意这里的“提升”是相对提升=p2-p1/p1而不是绝对提升=p2-p1注意和均值类指标的绝对提升进行区别。通过这两个参数就可以算出p2了。
这是根据我平时实践中的实际使用情况来设置的因为一般是有原始指标和想要获得的提升当然你也可以根据自己的需求直接让用户输入p1和p2。
显著水平sig.level
在这里我会让用户输入置信水平1-α),而不是显著水平α,这也是根据实践中的习惯调整的。
Power和单双尾检验
我把Power和单双尾检验都设定成了默认值是不需要用户改变的。因为很多用我制作的计算器的用户他们的统计背景并不强所以我就把Power隐藏了起来并且设定为默认的80%,这样就可以减少他们的困惑。
至于单双尾检验我在第2节课中也讲了A/B测试中更推荐使用双尾检验所以我也把它设定为默认的双尾检验从代码可以看到并没有涉及这个参数那是因为函数本身对这个参数的默认值就为“two.sided”即“双尾”
如果你还记得第6节讲的样本量计算公式的话就会知道影响样本量的因素有显著水平α、Power、两组间的差值δ和两组的综合方差\(\\sigma\_{\\text {pooled}}^{2}\)。
你可能会有疑问为什么不让用户直接输入以上4个影响因素呢而是让用户输入现在交互界面上的参数呢
这其实是在帮用户省事,通过在实践中最常出现的参数,来帮助用户计算综合方差。
通过把函数的输入参数和这些影响因素对比之后你就会发现其实通过函数的输入参数完全可以确定这4个影响因素从而求得样本量。
输入完这四项信息之后,就可以开始运行了。
如果你仔细比较server.R和ui.R这两个文件就会发现整个App中两个文件通过以下方式运行的
整个App是通过ui.R这个文件接收到用户的输入值然后把这些输入值存到input函数中。
接着呢server.R再读取input进行计算再把结果存储到output函数中返回给ui.R。
最后ui.R再把这些结果显示在用户的交互界面上。
这里再来举个例子说明下我刚才讲的整个过程。
首先在ui.R中我们需要用户输入原始指标avgRR_prop_testL11-12
numericInput("avgRR_prop_test", label = "原始指标", value = 5, min = 0, step=1)
最小可检测相对提升lift_prop_testL18-19
numericInput("lift_prop_test", label = "最小可检测相对提升", value = 5,min = 0, step=1)
置信水平sif_prop_testL42-44:
radioButtons("sif_prop_test", label = "置信水平",
choices = list("80%","85%","90%","95%"),
selected = "95%",inline=T)
那么这些用户输入的参数呢最后都通过input这个函数传递到server.R文件当中去进行样本量计算L31-35
number_prop_test <-reactive({ceiling(power.prop.test(p1=input$avgRR_prop_test/100,
p2=input$avgRR_prop_test/100*(1+input$lift_prop_test/100),
sig.level=1-numsif_prop_test(),
power=0.8)[[1]])
})
当计算完成后再把结果存在output函数中L44-51
output$resulttext1_prop_test <- renderText({
"每组的样本量为 "
})
output$resultvalue1_prop_test<-renderText({
tryIE(number_prop_test())
})
最后output函数再把结果传递给ui.R供前端显示L57-63
tabPanel("结果",
br(),
textOutput("resulttext1_prop_test"),
verbatimTextOutput("resultvalue1_prop_test"),
textOutput("resulttext2_prop_test"),
verbatimTextOutput("resultvalue2_prop_test")
)
这里要注意的一点是因为通过power.prop.test函数计算出来的样本量是单组的如果需要求总样本量时就要用单组样本量乘上分组数量。这里的分组数量也是需要用户手动输入的。
同时你可能也注意到了我在这款App中加入了大量的解释性语言对于每个需要用户输入的参数都进行了解释说明这样做其实也是吸取了实践中的用户反馈。就像我刚才说的很多用户的统计背景并不强对于这些统计量并不熟悉所以我们要尽可能地将这些输入参数解释清楚减少用户使用时的困惑。
均值类指标
从均值类指标的样本量计算的逻辑上参看第6节课来看我们需要函数power.t.test。下面这段代码L105-109是在server.R文件中的具体实施
number_t_test <-
reactive({ceiling(power.t.test(delta=input$lift_t_test,
sd=input$sd_t_test,
sig.level=1-numsif_t_test(),
power=0.8)[[1]])
})
从这段代码我们会发现,和概率类指标相比,函数要求的输入参数有所变化,变成了:
标准差sd。
最小可检测差值delta这里是两组指标的绝对差值
显著水平sig.level。
Power。
还有单双尾检验。
这是因为均值类指标的标准差(方差)并不能仅仅通过两组指标的值来计算,而是需要知道每个数据点来计算。
我们先看标准差的计算公式:-
-
所以标准差需要用户根据以上公式在数据中计算得出。
最小可检测差值delta是用户根据实际业务情况自定的显著水平一般为95%。
Power和单双尾检验这两项我们还沿用概率类指标的设定去设置默认值Power为80%的双尾检测。
均值类指标代码的其他部分和概率类指标均类似,这里就不再展开举例了,具体的内容,代码里也写得十分清楚了。
应用场景和使用案例
实践中使用样本量计算的应用场景主要分两类:
已知单位时间的流量,求测试时间。
已知测试时间,求单位时间的流量。
所以你会发现在App交互界面右下角区域的结果板块就有关于测试时间和测试流量的选项。
下面我来举一个例子来分别说明这两种情况。
假设我们现在做A/B测试的指标为下载率原始指标为5%最小可检测的相对提升为10%置信水平为95%一共有一个实验组和一个对照组通过咱们的样本量计算器求得的每组样本量为31234总样本量为62468。
在单位时间测试可用流量固定的情况下,求测试时间
这种场景是比较常见的。我们假设以周为单位每周可用的测试流量约为10000。输入参数后计算得出一共需要6到7周的时间才能达到充足的样本量
在测试时间固定的情况下,求单位时间内的流量
这种场景适用于时间紧急的情况比如一周之内要出结果一周有7天那么输入参数后计算得出我们每天的测试流量至少要有8924。
知道每天需要的流量后,我们就可以根据这个数字去调整我们的测试流量占总流量的比例了。
最后我要说明一点,虽然我举的是一个概率类指标的例子,但这两个使用场景对于均值类指标是同样适用的。
使用案例
在使用案例这个版块,我会针对概率类指标和均值类指标各举一个例子,来说明具体情况下应该如何输入不同的参数。
先看概率类指标的案例。
再看均值类指标的案例。-
如何把Shiny App发布在网上
现在我们完成了server.R和ui.R在下图中点击右上角的“Run App”即可在本地打开我们的App。-
-
但是如果你想把App发布在网上还是得借助ShinyApps.io它是专门发布 Shiny App的平台你需要在上面注册一个免费账户具体过程呢也不难你可以查看这里的教程。
小结
那么到这里呢关于制作A/B测试样本量计算器的讲解就结束了相信你通过本节课的学习结合着我提供的代码和App已经能成功制作出自己的样本量计算器啦。
不过有一点也需要再说明一下。虽然样本量计算的逻辑是固定的但是对于用户交互前端的话在保证基本功能的前提下你可以根据自己的喜好来设计的这里我贴出来Shiny前端的案例集和常用语句供你参考。
思考题
其实样本量计算可以在各种编程语言中实施这里我选用R和Shiny的原因是它们使用起来相对简单些。那如果让你用Python来实施的话对于概率类和均值类指标样本量的计算你会选择哪些函数呢
如果你在制作样本量计算器的过程中有遇到什么问题,或者有什么经验,欢迎在留言区和我分享,也欢迎你把课程分享给你的朋友、同事。

View File

@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐 试验意识改变决策模式,推动业务增长
你好我是凯悦。很荣幸能为博伟老师的专栏写篇加餐写这篇文章一方面跟我学习A/B测试的经历有关。另一方面作为极客时间的产品经理我们团队的试验意识也经历了一个从0到1的过程。
一年半前我开始自学A/B测试当时在网上找了很多文章和课程来学习。但有用的资料较少质量也参差不齐讲得也不够透彻所以我花了很长时间来判断资料的正确与否也因此踩了很多坑。
所以在博伟老师这个专栏上线之后,我每周追更,越是往后学习兴趣越浓,心想如果在我学习初期就遇到这个专栏,那是多美好的事。
这篇加餐中我把我们团队从引入、应用A/B测试到建立起试验意识的整个过程分享给正在学习的你。
试验意识改变决策模式,推动业务增长
极客时间不是从产品初期就开始使用A/B测试的而是经历了纠偏、引入、应用、总结四个阶段最终形成了较强的试验意识。
纠偏改变对A/B测试的错误认识建立正确认识。
引入将A/B测试的方法和工具引入到决策过程中而非拍脑袋决定。
应用用A/B测试解决一个个实际问题。
总结:复盘经验,形成试验意识。
经历了四个阶段的发展,我们建立了完整的试验流程,形成了试验意识,关键点有两个:
每当遇到产品决策问题时第一时间想到A/B测试。
长期坚持使用A/B测试。
这里我着重想说明一下我们在试验意识上的纠偏。正是意识上的纠偏,让我们改变了决策模式,将依据经验决策的单一决策模式切换为依据经验+试验意识的系统决策模式,持续推动业务增长。
意识纠偏
曾经以为A/B测试就是设置两个版本分别让两组用户使用转化率高的胜出然后就可以发布上线了。
但事实真是如此吗这样做决策科学吗如果A/B测试如此简单那为什么还是有很多互联网公司没有使用呢
先举个例子一个详情页版本A转化率是1.76%版本B转化率是2.07%。如果你是产品经理你会选用哪个版本呢?
假如再有版本C转化率是2.76%呢再有版本D转化率是11.76%呢?
按照“哪个版本转化率高就上线哪个版本”的决策模式我们应该立即上线版本D。过去我也是这么认为的但实际上是错误地理解了A/B测试。
在我刚才举的例子中,存在三个问题:
第一试验只是抽取了一部分用户得出了结论不是全部用户那么当全部用户都使用版本D时转化率还会是11.76%吗?
第二版本B、C和D的转化率分别是2.07%、2.76%和11.76%相对于版本A的提升分别是0.31%、1%和10%。是差异越大我们上线这个版本的信心指数就会越高吗显然不是还需要考虑0.31%、1%和10%的提升是实际存在的,还是试验误差导致的?
第三当差异多大时我们才能下判断呢换句话说如果上线版本B它是否确实能带来转化率的提升呢实际的提升会是多少呢
由于这三个问题缺少数据支持所以无法回答因此就没法做出是上线版本A还是上线版本B的决策。我们还需要收集更多的信息来回答这三个问题。
回答这三个问题就涉及对科学A/B测试的理解。什么是科学、规范的A/B测试呢博伟老师的专栏已经给出了答案。A/B测试并没有想象中的简单它是一项科学试验涉及到抽样、显著性检验、软件工程、心理学等方方面面。重点要关注试验过程是否科学严谨试验结果是否可信依据这样的A/B测试结果做决策才真正的能推动业务的发展。
引入A/B测试
为什么要引入A/B测试呢极客时间用户早已破百万需要实现从野蛮生长到精耕细作的阶段跨越用户增长、数据决策都离不开A/B测试这个工具。它能够在不进行较大改变的情况下使用小部分流量进行试验验证假设得出结论达到优化产品、促进用户留存和活跃的目的。
引入过程中我们采取了三方面的行动。
第一系统学习A/B测试。开始学习时找了大量的资料量虽然多但大部分千篇一律。不过经过不断的学习我们还是总结出了自己的试验流程并尝试应用。当然中间也踩了很多坑进入了不少误区。
所以当编辑同学策划《A/B测试从0到1》这个专栏时我们就发现这个专栏非常实用初学者或进阶者学习过程中遇到的问题不清楚的细节以及需要避免的“坑”博伟老师都有详细的讲解。
第二自建分流系统。学习了理论知识之后就要给研发同学提需求做工具了。我们自建了分流系统然后将整个A/B测试流程跑通这样才能真正地帮助到决策者做判断。
第三将A/B测试纳入产品迭代的流程。现在在做重要产品的迭代前都会做多个版本进行A/B测试这已经成为了团队的共识。
引入并建立了A/B测试观念和意识后接下来就需要动手实践了。博伟老师在专栏中也多次讲过A/B测试的实践性非常强需要在实际业务场景中不断迭代、精进。下面我就通过两个实际案例来看看极客时间是如何从0到1利用A/B测试验证假设以及进行产品迭代的。
A/B测试实践应用
极客时间有多个重要业务指标其中转化率和复购率两项指标尤为重要。所以我就选择了具有代表性的两个案例来讲解。案例一我们通过A/B测试检验了一个提升复购率的假设。案例二利用A/B测试选出高转化率的详情页。两个案例都说明了试验和试验意识的必要性。
案例一:醒目的优惠券样式可以提高复购率吗?
案例背景
运营同学想提高完成首单用户的复购率,于是提出想法:在用户完成首单后,让优惠券的展示更加醒目,以促进用户使用。但是这个想法却不被产品经理认可。主要有以下几方面的原因:
首先,现有版本已经有了优惠券展示模块。
其次,整体优惠券使用率不高,而且分析历史数据得知优惠券对促进用户再次购买的效果并不理想。
最后,也是最重要的一点,现有版本有“分享有赏”功能,用户将课程以海报形式分享到朋友圈,其好友通过该海报购买后,该用户能够得到返现。通过这种形式也能促成复购,还有拉新效果。
运营同学和产品经理各有理由,所以在双方互相不能说服的情况下,我们就决定用 A/B测试来解决这一问题而试验结果也让大家颇感意外。
试验设计
现有方案是用户完成首单后,系统弹出弹窗,用户可以选择使用优惠券购课或者分享给其他用户获得现金奖励。运营同学提出假设,认为以更醒目的样式展示大额优惠券可以提高复购率,试验的假设就可以表述为“醒目的优惠券能促进用户立即使用优惠券,进而增加复购的概率”。
这里需要说明的是,用户完成首单后,系统会自动将优惠券发送给用户,不需要用户手动领取。
于是产生了实验组的UI样式
接下来就是按照A/B测试的规范流程来设计试验了
明确目标和假设。目标是增加复购,零假设是实验组复购率与控制组没有差异。
确定指标。用复购率作为衡量指标,同时考虑新用户数和营收。(复购率=已支付订单数大于等于两单的用户数/已支付订单数等于一单的用户数)
确定试验单位。使用uid作为试验单位。
确定样本量。我们将实验组与控制组的差值设置为0.6%。这个差值也有其他叫法比如最小可检测效应、实际显著性。算出来最少需要8074个样本。
实施测试
经过对历史数据的分析,用户分享率和领取优惠券的领取率没有明显的周期性变化,因此按照样本量与流量确定了试验时长。
做好准备后,开发同学开始使用自建的分流系统,上线测试。
结果分析
进入试验的用户有17652人在功效80%置信度95%时置信区间不收敛并且P值大于0.05,不拒绝原假设。我们又试验了一段时间,发现依然如此。因此判断实验组并不比原版本效果好。
使用R语言的prop.test函数计算结果如下图-
试验结果汇总如下表所示:-
-
试验过程中我们还收集了另外两个指标:-
-
通过辅助指标,我们发现原版本能带来更多的用户,且用户更有动力分享促进用户购买。并且经过分析,排除了“大部分新用户是由少数几个老用户的分享带来的”这种情况。
做出决策
从试验数据来看置信区间包含“0”值意味着实验组比控制组的转化率有可能增加0.098%也有可能降低0.733%。
此外在拉新能力上原版本是实验组的5倍成交金额上前者是后者的3.6倍。差别之大令我们感到意外幸好有试验的意识先通过A/B测试对idea做了检验如果拍脑袋决策直接采纳这个建议那会给公司带来损失。
基于以上两个原因我们决定继续使用原版本。
案例思考
该案例中采取了“大胆假设小心求证”的决策方式当提出了“通过醒目的优惠券设计刺激复购”的idea时产品经理第一时间想到用A/B测试的方法来验证想法是否可行。既不臆断拒绝也不盲目接受。而是试验意识驱动采用A/B测试方法收集数据分析数据科学决策。这也就是我在文章开头所说的试验意识的第一个关键点当涉及产品变化的决策时首先想到A/B测试。
案例二:选出高转化率的详情页
有了前车之鉴我们在产品迭代时也开始养成肌肉记忆不断使用A/B测试。
案例背景
APP的课程详情页需要版本迭代。产品经理思考通过强化促销价格能否提升详情页的转化率
试验设计
设计了两种UI样式如下图-
确定指标。用转化率作为衡量指标。
确定试验单位。使用uid作为试验单位。
确定样本量。我们将实验组与控制组的差值设置为1.5%计算后大概需要样本量1.7万。因为我们流量较大按照原定分流计划1-2天的时间就能达到最小样本量。由于用户在周末活跃数据会骤降为了覆盖一个用户活跃周期同时为了尽量避免新奇效应我们适量缩小试验流量占总流量的比例将试验时长设置为一周。
实施测试。做好准备后,开发同学上线测试。
结果分析
为避免“学习效应”,上线试验后,我们持续监测每天的指标;各项指标的变化都很稳定,符合预期,排除了“学习效应”。
试验结果如下:
-
进入试验的用户有23686人在功效80%置信度95%时置信区间不收敛p值大于0.05不拒绝原假设,两个版本没有显著差异。
此时,陷入僵局,试验结果不显著,增加样本量降低方差都没有改变结果。如何决策呢?
做出决策
由于置信区间不收敛无法根据试验结果决定使用哪个版本。因此需要考虑其他因素做决策。APP整体风格简洁明快没有大色块设计而且醒目的“大色块”并没有带来转化率的提升却将页面分割成上下两个部分。
基于UI样式的考虑我们决定使用版本A。
案例思考
试验结果有时会与直觉相左。通过严格试验得出的数据能有效反应用户的真实情况,数据驱动的前提是有数据,有数据的前提是有意识的做试验并收集数据。
很多试验的结果并不能给出明确的决策依据也需要产品经理主观决策这并不意味着试验没有作用试验的作用是将能够用试验验证错误的idea全部排除且证据充分将无法用试验解决的问题交给“专家系统”来决策权即依据负责人或团队的经验决策。
总结
今天的核心内容到这里就讲完了,我总结了团队在优化决策模式、推动业务增长过程中积攒的一些经验。
A/B测试方法是经过验证的最佳实践Best Practice要将试验意识写入我们的心智模式每当遇到增长问题、决策问题时第一时间想到“A/B测试可能是一个好的解决方法”这是试验意识的第一个关键点。
试验意识的第二个关键点是A/B测试需要长期坚持要形成循环而不仅仅是闭环。如果说从发现问题到试验结果上线再到效果回归是一个闭环的话那么还需要在发现问题前加一个动词“持续”“持续发现问题”这就让试验意识形成了循环在循环中形成持续向上的趋势。这个意识的重要性不在于一次两次试验有效还是无效而是能让我们在决策前先用试验验证并长期这样做形成习惯。
试验意识的建立,让我们的决策模式不再局限于依赖经验和直觉。试验意识加经验的决策模式成为我们的决策系统,由于这个系统有概率优势,虽然单次决策有时有效有时无效,但长期来看每一次微小进步的叠加效果就能驱动业务的整体增长,而其中的经验必将带来惊艳的效果。

View File

@ -0,0 +1,47 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
导读 科学、规范的A_B测试流程是什么样的
你好,我是博伟。
前面两节课啊我们花了很大力气去学习做A/B测试的理论前提这也是为了让你夯实理论基础。不过啊除非你是统计科班出身否则我都会推荐你在学习实战的时候呢也要不断温习统计篇的内容把理论与实践结合起来。如果觉得有必要也可以把我在统计篇讲的统计概念和理论延伸开来通过查看相关统计专业书籍来加深理解。
学完了统计理论接下来就要开始设计实现做A/B测试了。不过在我总结A/B测试的流程之前呢我要简单介绍下在实践中做A/B测试的准备工作主要有两部分数据和测试平台。
一方面我们要有数据包括用户在我们产品和业务中的各种行为营销广告的表现效果等等以便用来构建指标。因为A/B测试是建立在数据上的分析方法正如“巧妇难为无米之炊”没有数据的话我们就不能通过A/B测试来比较谁好谁坏。
一般来说,只要是公司的数据基础架构做得好,埋点埋得到位的话,基本的常用指标都是可以满足的。
如果说我们要进行的A/B测试的指标比较新、比较特别或者数据库没有很全面没有现成的数据可以用来计算相应的指标那么可以和数据团队进行协商看能不能在现有的数据中找出可以替代的指标计算方法。
如果找不到相近的替代指标,那么就要和数据工程团队协商,看能不能构建这个数据,可能需要新的埋点,或者从第三方获得。
另一方面呢我们要有合适的测试平台来帮助我们具体实施A/B测试。可以是公司内部工程团队搭建的平台也可以是第三方提供的平台。对于这些平台我们在做A/B测试之前都是需要事先熟悉的以便可以在平台上面设置和实施新的A/B测试。
当然在做A/B测试时数据库和测试平台是要通过API等方式有机结合起来的这样我们在测试平台上设置和实施的A/B测试才能通过数据来计算相应的指标。
以上的准备工作并不是每次A/B测试都要做的更多的是第一次做A/B测试时才需要去做的准备所以更像是A/B测试的基础设施。以下的流程才是我们每次去做A/B测试都要经历的我把它们总结在一张图中你可以看一下。
以上就是一个规范的做A/B测试的流程了。你看啊A/B测试的实践性很强但大体就是这么几步。在这门课里我会着重讲最核心的5个部分也就是确定目标和假设、确定指标、确定实验单位、估算样本量以及分析测试结果。
在整个流程中,除了随机分组的具体实施细节和具体实施测试外,其余环节我都会逐个讲解。你可能会问,为什么不能把全部环节讲解一遍呢?
其实啊我会侧重讲解A/B测试的基本原理实践中的具体流程还有实践中遇到的常见问题及解决办法这些都是偏经验和方法论的内容。不管你在哪家公司处在哪个行业用什么平台去实施A/B测试这些经验和方法论都是通用的学完之后你就可以应用到实践中。
至于随机分组的不同随机算法以及实施测试所用的平台这些更偏工程实施的细节公司不同平台不同那么实施A/B测试时也会有很大的差别。比如A/B测试的平台大公司一般会自己开发内部的测试平台中小型公司则会利用第三方的测试平台。
所以啊基础篇这几节课呢我也希望你能在学习的同时能够跟自己的工作联系起来。如果你在工作中做过A/B测试但是觉得流程没有很系统化你就可以把平时做的A/B测试和基础篇的流程进行参照对比看看还有哪些不足的地方。同时通过学习基础篇也会让你知道为什么会有这些流程它们背后的原理是什么让你加深对流程的理解应用起来更加得心应手。
如果你还没做过A/B测试也没关系。我会结合实际案例来给你深入讲解。如果有条件学习完之后你就可以尝试做自己的第一个A/B测试啦
最后我还要说明一点。A/B测试的前提是数据这里牵涉到一个公司的数据架构和埋点策略更多的是工程和数据库建设的问题不是我们A/B测试的重点。所以在接下来讲课的时候我就假设我们已经能够追踪A/B测试所需要的数据了至于如何追踪这些数据如何埋点这种工程实施的细节我们这里就不展开讨论了。
好啦了解了这些就让我们正式开始A/B测试的旅程吧

View File

@ -0,0 +1,69 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 实践是检验真理的唯一标准
你好,我是博伟。
在过去的这些年里我一直在和A/B测试打交道研究的时间越久越能体会出其中蕴含的深意。所以在设计课程大纲时结束语这一讲的标题我毫不犹豫地选择了“实践是检验真理的唯一标准”。这句话很朴素却是我这么多年和A/B测试朝夕相处的真实体会。
首先,我想和你聊聊为什么我在课程中会反复强调实践的重要性。
A/B测试本身就是一种偏经验和方法论的工具。掌握了我在课程中讲的这些统计原理、规范流程后也不能保证你在实践中如鱼得水游刃有余。毕竟听到的经验和方法想要深刻理解还是要拿到实际业务场景中反复试炼才能不断迭代和完善。
即使是我讲过的一些常见误区和问题以及一些隐形的坑点由于业务环境的千差万别你在实践时大概率还是会遇到而且还会遇到其他的坑。不过不要害怕因为有些“弯路”非走不可正是在和“弯路”的博弈中你才能摸透A/B测试中的招式和套路精进业务。
A/B测试带给公司和团队的不光是持续提升的结果更重要的是实验意识。团队中的成员提出一个想法究竟是突发奇想还是真正可靠的呢能不能有效落地实施呢我们完全可以把这个想法通过A/B测试放在实际场景中检验最后得出具有说服力的结论。正所谓大胆猜想小心求证不断调整快速更迭。
“实践”并不是件容易的事儿,真正去做的时候,还需要主动、耐心、有勇气。
与A/B测试相关的项目都可以主动参与。不管你是亲自做测试还是观摩整个过程这都是在学习积累。主动提出业务需求主动尝试只要去实践肯定就有收获。
对“失败”多点耐心。这里的失败我是打引号的你可能会觉得做完A/B测试后只要没有把A/B测试中的变化在产品或业务中实施就算“失败”不过我想告诉你的是从实践数据来看一大半的A/B测试中的变化最终都没有最终实施。在做测试的时候我们肯定希望结果是显著的但实际上不显著的概率比较大。这就是期望与现实的落差。那这就算是失败吗
在A/B测试领域显然不是这样的。每一次“失败”的测试对我们来说都是宝贵的经验你可能会从中发现从而改进测试设置、工程实施等方面的问题。哪怕测试结果真的不显著也没关系因为这也能帮我们排除不同的想法减少给业务带来的潜在损失从而让我们快速迭代到下一个想法中去。
要敢于提出自己的想法。我已经记不清有多少次我和同事、领导意见不一致时A/B测试就成了我们解决问题的法宝。长此以往也帮助我们在团队中形成了一种实验的氛围大家越来越敢提出与别人不一样的想法和意见而不是管理人员的一言堂。
你看,“实践是检验真理的唯一标准”中包含的朴素智慧,一旦和我们当下的生活、工作结合起来,是不是就有更生动、更丰富的理解了呢?这也正是我平时爱好历史的原因,前人的智慧总能历久弥新。
不过在今天课程结束之际我还想给你分享更多我自己学习A/B测试的故事聊一聊我的学习心得希望能带给你一些启发和勉励。
第一个心得,搭建自己的知识框架,能让你的学习效率更高。
就拿我自己来说吧。我呢,其实并不是科班出身的数据从业者,所以想要在这一陌生领域有立足之地,术业有专攻,就要付出更多的努力。
举个小例子。为了搞清楚中心极限定理、P值、Power这些难懂的统计概念我把各种版本的统计课本学了不下20遍。为了全面掌握数据科学方面的知识就利用业余时间学习了将近10门与数据科学相关的网课。系统化的学习给我打下了实践的坚实基础实践中再遇到其他问题我就知道怎么去搜索资料、寻找解决方法而不会无从下手。
所以这也是我想要做这门课的初衷。根据我多年积累的经验系统总结A/B测试领域的经验和方法帮助想要学习这个领域的人搭建一个知识框架让你能够在短时间内获得非线性的突破。
第二个心得,学习要有目的,并且要把学到的知识及时应用到实践中,学以致用。
在学校期间的学习大都是为了学习而学习,工作后的学习就不同了,有目的的学习更能达到一举多得的效果。
我刚才谈到自己曾把统计课本学习了不下20遍这只是相对系统的学习遍数如果算上实际翻看的次数那远不止百次了。因为我这个人呢记性一般纯知识性的东西一旦不常用就很容易忘记。所以统计书对我来说就像小学学习语文时的《现代汉语词典》一样平时用到了就去查形成肌肉动作。
所以如果你学完这个课程有些内容没能完全消化没有关系我希望你能把它当做你在A/B测试上的工具书遇到棘手的问题就来翻看、学习有问题也欢迎继续留言我也会时不时地回复你的问题。
还想分享一个我做专栏的心得:文字输出是检验输入质量的重要标准。
在实践中丰富和完善的知识要怎么检验?文字输出就是一个很好的方式。这也是我在做专栏这几个月保持连续输出的一个重要心得。
这几年我会经常带学生做项目或者做讲座,但文字输出和“讲”是不一样的。脑海中把一个问题想清楚了,也能给别人讲出来,但要落到笔上,就得斟酌每一个细枝末节:全文是不是有逻辑、用词是不是够精准、例子是不是够恰当等等。这对文字表达、专业逻辑都是不小的磨练。
做专栏的文字输出,跟我平时写文章也不一样。写专栏文章,我需要去掉那些学术派的语言,调整粗糙的表达,力求用最简单明了的语言去讲清楚一个问题,同时还要考虑读者的阅读习惯等等。所以反复打磨的不仅是文字内容,还有对问题的周密思考。当然了,这对精力、意志力也都是考验。
我和A/B测试已经亲密相处了7年多对我来说它不仅是工作中的一种增长方法在深入体会它的精妙之后它代表的“实验意识”更成了我生活中的重要理念和原则。
当生活中偶遇迷茫找不准方向,或者面对未知的不确定心有焦虑时,我不会畏首畏尾,更不会退缩,而是大胆地去尝试。在考虑到可能的结果后,勇于试错。因为不亲自经历,我可能永远也不知道这件事对自己来说是好是坏。
就像过去的2020年注定是个特别的年份突如其来的疫情打乱了很多人在学习、生活和工作上的节奏。在这种生存与挑战、安稳与不确定的摇摆之间不如把心里的思绪和想法投放到实践中从而突破自己内心的围城。
这也是我最后想与你共勉的:唯有步履不停,人生路上才能遇到更多的惊喜。
最后的最后,我也为你准备了调查问卷,题目不多,希望你可以花两分钟填一下。十分期待能听到你的反馈,说说你对这门课程的想法和建议。

View File

@ -0,0 +1,63 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
086 Twitter的广告点击率预估模型
在上一篇文章的分享里我们了解了LinkedIn这家公司是怎么来做最基本的广告预估的。LinkedIn的广告预估模型分为三部分这里面的核心思想是直接对“冷启动”和“热启动”进行建模外加和EE策略Exploit & Explore结合在一起从而提升了效果。
今天我们就结合论文《Twitter时间轴上的广告点击率预估》Click-through Prediction for Advertising in Twitter Timeline[1]来看看Twitter的广告预估到底是怎么做的。
Twitter的广告预估
我们前面提到过最基本的广告形态分类,可以分为“搜索广告”和“展示广告”。当计算广告在互联网上出现以后,这两种广告形态就迅速被很多网站和服务商所采纳。
在最近的10年里随着社交媒体的发展希望在社交媒体的用户信息流里投放广告的需求逐渐增强。我们之前谈到的Facebook的案例其实也是往用户的信息流中插入广告。很多类似的社交媒体都争先恐后地开始进行相似的项目这一类广告经常被称为社交广告。
社交广告的特点是,需要根据用户的社交圈子以及这些社交圈所产生的内容,而动态产生广告的内容。广告商和社交媒体平台都相信,不管是在投放的精准度上,还是在相关性上,社交广告都有极大的可能要强过搜索广告和展示广告。毕竟,在社交媒体上,用户有相当多的信息,例如年龄、性别,甚至在哪里工作、在哪里上学等,这些信息都有助于广告商的精准投放。而用户自己在社交媒体上追踪的各种信息,又可以让广告商清晰地知道用户的喜好。
Twitter的工程师们在这篇论文里介绍的也是在信息流里投放的社交广告。只不过Twitter的工程师们认为我们之前分享的Facebook的解决方案并没有真正考虑往信息流里插入广告的难点也就是广告的排序依然把广告的排序问题当做分类问题也就是用对数几率回归Logistic Regression来解决。
另外Twitter的工程师们认为社交广告比类似Google的搜索广告更具挑战性。因为在社交信息流里用户所看到的信息都是随时变化的比如用户在Twitter中可能随时有新的信息进入到信息流中因此信息流的上下文会随时发生变化。那么如果要投放和上下文相关的广告这种变化无疑会带来前所未有的挑战。
利用排序学习来对广告排序
既然Twitter的工程师们认为信息流广告的建模最重要的就是借鉴排序学习的办法。那么我们就来看一看他们是怎么利用排序学习来为信息流社交广告建模的。
首先排序学习中最基本的就是“单点法”Pointwise排序学习。回顾一下单点法其实就是把排序学习的任务转化为分类问题。其实典型的就是直接利用“支持向量机”SVM或者对数几率回归模型。
第二种比较常用的排序学习的方法就是“配对法”Pairwise排序学习。通俗地讲配对法排序学习的核心就是学习哪些广告需要排到哪些广告之前。这种二元关系是根据一组一组的配对来体现的。学习的算法主要是看能否正确学习这些配对的关系从而实现整个排序正确的目的。对于配对法排序我们依然可以使用对数几率回归。只是这个时候我们针对的正负示例变成了某个广告比某个广告排名靠前或者靠后。
值得一提的是,通过配对法学习排序学习,对于一般的搜索结果来说,得到最后的排序结果以后就可以了。而对于广告来说,我们还需要对点击率进行准确的预测。这个我们之前提到过。于是在这篇文章中专门提到了如何从配对结果到点击率的预测。
具体来说,原理其实很简单,根据配对法学习排序完成以后的广告之间顺序是绝对的,但是绝对的数值可能是不太精确的。这里进行校准的目的是根据配对法产生的预测值,再去尽可能准确地转换为实际的点击率的数值。一般来说,这里就可以再使用一次对数几率回归。也就是说,这个模型的唯一特性就是配对法产生的预测数值,然后模型的目的是去估计或者说是预测最后的实际数值。这种使用一个回归模型来进行校准的方法,也用在比如要将支持向量机的结果转换成概率结果这一应用上。
虽然从原理上讲,先有一个配对模型进行排序,然后再有一个校准模型对模型的绝对估计值进行重新校正,这是很自然的。但是在实际的工业级应用中,这意味着需要训练两个模型,那无疑就变成了比较繁复的步骤。
所以,在这篇文章里,作者们想到了一种结合的办法,那就是结合单点法和配对法。具体来说,就是直接把两者的目标函数串联在一起。这样做的好处是,可以直接用现在已经有的训练方法,而且同时解决了排序和更加准确预测点击率的问题。我们回顾一下,单点法的特性是尽可能准确地预测每一个广告的点击率,也就是刚才提到的校准的这一个步骤所需要干的事情。这种直接串联的好处是,只需要学习一个模型就可以做到既考虑了排序,又考虑了预测的绝对精准度的问题。
在机器学习应用中,串联多个目标函数是经常使用的一种技术。其目的和作用也就和这个串联的想法一样,就是希望针对多个不同的目标进行优化。一般来说,这里面的核心是,多个串联的目标函数需要共享模型参数才能形成有关联的总的大的目标函数;如果没有共享参数,那就仅仅是一种形式上的串联。
模型的实验
在这篇文章里作者们也是用了Facebook提出的“归一化的交叉熵”简称NE的概念以及业界比较常用的AUC来针对模型进行线下评价。
在线下实验中配对法以及单点法和配对法结合的混合方法都在AUC上要超过单点法本身。这非常容易理解。只是配对法针对单点法在NE上的表现要差很多。这和我们刚才所说的没有对点击率进行估计有很大关系。这一点也在实验中得到了证实。
在在线实验中单点法相对于以前的自然排序点击率好了将近14%而混合法则好了26%左右。可以说效果非常明显。
总结
今天我为你介绍了Twitter广告点击率预估的核心算法。一起来回顾下要点第一我们讲了Twitter认为社交广告的难点是要解决广告的排序问题第二我们聊了如何利用排序学习来为点击率预估进行效果提升。
最后给你留一个思考题为什么Twitter不尝试使用树模型来对点击率进行提升呢
参考文献
Cheng Li, Yue Lu, Qiaozhu Mei, Dong Wang, and Sandeep Pandey. Click-through Prediction for Advertising in Twitter Timeline. Proceedings of the 21th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining (KDD 15). ACM, New York, NY, USA, 1959-1968, 2015.

View File

@ -0,0 +1,69 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
087 阿里巴巴的广告点击率预估模型
今天,我们继续来进行点击率预估的案例分析,结合三篇核心论文,来看一看阿里巴巴的广告预估又有哪些值得我们学习的地方。
多段线性模型
我们之前介绍了多个公司关于点击率或者转化率预估的案例。从这些案例中,你可能已经发现有两个非常重要的特征需要机器学习模型来处理。
第一,就是数据中呈现的非线性化的关系。也就是说,我们的模型必须在某一个地方考虑到特性之间的非线性表征,以及对于目标标签的非线性关系。
第二,就是数据的不均衡以及数据的稀疏性。有很多广告商是新广告商,很多广告是新广告。在这样的情况下,我们就必须要处理“冷启动”和“热启动”这两种局面。
在《从广告点击率预估的大规模数据中学习多段线性模型》Learning Piece-wise Linear Models from Large Scale Data for Ad Click Prediction[1]这篇文章中作者们提出了一种多段线性模型来解决我们刚刚说的这两个问题这个模型简称为LS-PLM Large Scale Piecewise Linear Model )。
LS-PLM的核心思路其实非常直观。既然数据在整个空间里可能呈现非线性的关系那么我们是否能够把整个空间分割成较小的区域使得每个区域内依然可以使用线性模型来逼近这个区域内的数据点呢其实在统计学习中这种模型常常被叫作“混合模型”。在很多机器学习教科书中都会讲授的一种混合模型是“高斯混合模型”Gaussian Mixture Model
LS-PLM在这篇论文的实际应用中基本上可以被理解成为一种混合线性模型。这个模型的一个子模型叫作“分割函数”也就是模型需要学习每一个数据点到底是依赖于哪一个线性模型来进行预测的。当然这个分割是一种概率的分割。实际上每一个数据点都依赖所有的线性模型来进行预测只不过对每个模型的依赖程度不一样。对于每一个不同的线性模型来说最大的不同就是每一个模型有自己的系数。也就是说之前只有一个全局模型并且只有一组系数相比之下这里有多组系数来决定模型的预测效果。很明显对于LS-PLM来说每一个局部都是线性的但是在整体上依然是一个非线性的模型。
LS-PLM还借助了两种正则化机制。一种叫作L1正则这种正则化主要是希望模型保留尽可能少的特性从而达到对于模型特性的选择。另外模型还采用了一种L2,1正则的方法这种方法的目的也是特性选择但是希望能够把一组特性全部选择或者全部置零。
在实际的实验中作者们尝试了不同数目的数据分割从2个到36个不等。最终他们发现当数据分割为12个的时候模型的效果达到最优而之后模型效果并没有明显提升。最终推出模型的AUC比直接使用一个对数概率回归的全局模型效果要好1.4%。
广告点击率预估和图像处理的结合
我们在电商上购物,对于商品的图像会不会影响我们的点击或者购买,应该有一个直观的感受。那么在广告的点击率预估上,商品的图像特征对于模型性能上的提高到底有没有帮助呢?我们再来看一篇论文[2],在这篇文章中,阿里巴巴的工程师就尝试对这个问题进行回答。
这篇文章结合了近期好几个利用深度学习来进行图像处理和广告点击率预估的工作。首先就是所有的特性都利用一个“嵌入层”Embedding Layer把原始的特性转换成为数值特性。这种思路我们在之前介绍文本处理特别是Word2Vec的时候曾经进行了详细的讲解。而在这里不管是文本信息还是图像信息都根据自己的特点转换成为了数值特性。
这里我们要解决的一个核心问题就是用户和广告之间的匹配问题这篇论文的模型是这么处理的。首先对所有广告的ID及其图像进行单独的嵌入。然后对用户过去的喜好特别是对图像的喜好进行了另外的嵌入然后这些嵌入向量形成用户的某种“画像”。用户的画像和广告信息的嵌入被直接串联起来形成最终的特征向量。在此之上利用一个多层的神经网络来学习最后的点击率的可能性。
在深度学习建模中,这种把多种来源不同的信息通过简单的拼接,然后利用多层神经网络来进行学习的方法非常普遍和实用。
在这篇论文的介绍中除了在模型上对图像进行处理以外还有一个创新就是提出了一个叫“高级模型服务器”Advanced Model Server简称AMS的架构理念。AMS是针对深度学习模型的大计算量而专门打造的计算体系。总体来说AMS的目的是把深度学习模型中的很多基础步骤进行拆分然后把这些步骤部署到不同的服务器上从而能够把复杂的模型拆分成细小的可以互相交流的步骤。
从最终的实验结果上来看基于深度学习的模型要比对数几率回归的模型好2~3%。整体上来看,利用了图像的模型要比没有利用图像的模型都要好,哪怕是线性模型也是一样的效果。但是,这个好的程度非常之小,基本上可以忽略不计。看来如何好好利用图像的信息,依然是一个比较大的挑战。
深度兴趣网络
我们刚才介绍的这种把其他信息和图像信息进行结合的方法,最近在一篇文章[3]中有一个总结。在这篇论文中作者们提出了一种叫“深度兴趣网络”或者简称DIN的架构。
DIN依靠一种基本的模型架构那就是先把所有的特性变换成嵌入向量然后针对不同的特性进行划组一些特性得以直接进入下一轮另一些特性经过类似图像中的池化Pooling操作抽取到更加高级的特性。之后所有的特性都被简单串联起来然后再经过多层的深度神经网络的操作。
DIN在这个架构的基础上提出了一种新的“激活函数”Activation Function叫DICE目的是可以在不同的用户数据中灵活选择究竟更依赖于哪一部分数据。可以说在某种意义上这个架构非常类似深度学习中比较火热的Attention架构其目的也是要看究竟那部分数据对于最终的预测更有效果。
从最后的实验中看不管是在内部数据还是外部公开的例如MovieLens或者Amazon的数据上基于DIN的模型都比线性模型和其他的深度学习模型有显著的提高。
总结
今天我为你介绍了阿里巴巴广告点击率预估的核心算法。一起来回顾下要点:第一,我们讲了如何利用混合线性模型来引入非线性的因素从而提高预测效果。第二,我们聊了如何利用深度学习模型来对数据进行建模,谈到了图像在这里面起到的作用。
最后,给你留一个思考题,深度学习模型在点击率预估方面的最大优势是什么?又有什么劣势呢?
参考文献
Kun Gai, Xiaoqiang Zhu, Han Li, Kai Liu, Zhe Wang. Learning Piece-wise Linear Models from Large Scale Data for Ad Click Prediction. CoRR abs/1704.05194 , 2017.
Tiezheng Ge, Liqin Zhao, Guorui Zhou, Keyu Chen, Shuying Liu, Huiming Yi, Zelin Hu, Bochao Liu, Peng Sun, Haoyu Liu, Pengtao Yi, Sui Huang, Zhiqiang Zhang, Xiaoqiang Zhu, Yu Zhang, Kun Gai. Image Matters: Jointly Train Advertising CTR Model with Image Representation of Ad and User Behavior. CoRR abs/1711.06505 , 2017.
Guorui Zhou, Chengru Song, Xiaoqiang Zhu, Xiao Ma, Yanghui Yan, Xingya Dai, Han Zhu, Junqi Jin, Han Li, Kun Gai. Deep Interest Network for Click-Through Rate Prediction. CoRR abs/1706.06978 , 2017.

View File

@ -0,0 +1,72 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
088 什么是基于第二价位的广告竞拍?
在之前一段时间的分享里,我们重点讲解了广告系统中的回馈预测,也就是我们常说的点击率预测或是转化率预测的问题,和你一起分享了一些有代表性的公司对于点击率预测的技术方案。
在最早介绍计算广告系统的时候我们介绍了DSP也就是需求侧平台的基本功能。这个平台的一个很重要的作用就是决定到底投放哪个广告。我们介绍过的点击率预测可以提供对广告优劣的一种预测除此之外我们还需要一种机制来决定如何从众多的广告中进行选取这就是广告的竞价排名。
广告位竞价排名的出现有两个原因。第一发布商的广告位是有限的。不管是搜索广告还是展示广告绝大多数的发布商都以一定的比例在原生的内容例如新闻、社交媒体内容里插入一些广告位。但是这些广告位的数目是有限的特别是在优质的发布商资源里就会出现一些广告位有着很大的竞争。第二既然有竞争那么如果引入一种竞价机制的话势必有可能抬高广告的单价从而让广告中间平台例如DSP或者是发布商从中获取更高的价值。
今天,我们就来讲一讲广告位竞价的一个基本原理,特别是目前广泛使用的基于第二价位的广告竞拍。
基于第一价位的竞拍
在我们开始讨论基于第二价位的广告竞拍之前,我们首先来看一个更加自然的竞拍手段,基于第一价位的竞拍。其实,在现实生活中,基于第一价位的竞拍会显得更加普遍。
所谓基于第一价位的竞拍,指的是所有的投标方都定好自己的出价,然后一次性统一出价。在出价的过程中,所有的投标方都是看不见竞争对手的出价的,这保证了出价的诚实性。
当竞拍平台接到所有投标方的出价以后,按照出价由高到低排序,出价最高的投标方获得投标的胜利。
在广告系统中如果要采用这样的形式那么决定最后投标顺序的不再是单纯的价格而往往是一个投标价格和点击率的函数最简单的函数就是点击率乘以投标价格。这其实也可以被认为是一种“期望收入”。也就是说如果发布商或者DSP是按照广告的每一次点击来收取费用的话那么点击率乘以投标价格就是这种收入的一个数学期望值。
所以,基于第一价位竞价的广告系统,按照广告收入的期望值进行竞价排名。排名第一的广告被选为显示的广告。
这种机制在早期的互联网广告平台中曾被大量使用。但是一段时间以后,大家发现,基于第一价位竞价的竞价结果往往是“虚高”的。
这也很容易形象地解释,在大家都不知道对方出价的情况下,如果希望自己能在竞拍中胜出,势必就可能报出比较高的价格。另外一个方面,投标方并不清楚这个广告位的真实价值,大家只能在条件允许的情况下,尽量抬高价格来获取这个机会。
从某种意义上来说,这样的竞价并不利于广告商的长远发展,也打击了广告商的积极性。
基于第二价位的竞拍
就是在基于第一价位竞价的基础上,互联网广告界逐渐衍生出了一种新的竞拍方法——基于第二价位的竞拍。
当我们已经熟悉了基于第一价位的竞拍模式以后,理解基于第二价位的竞拍就比较容易了。
首先,和基于第一价位的竞拍模式一样,基于第二价位的模式也是按照广告的期望收入,也就是根据点击率和出价的乘积来进行排序。但和基于第一价位模式不一样的是,中间商或者发布商并不按照第一位的价格来收取费用,而是按照竞价排位第二位的广告商的出价来收取费用。也就是说,虽然第一名利用自己的出价赢得了排名,但是只需要付第二名所出的价格。
很多互联网广告平台采用了基于第二价位的竞拍之后,发现广告商的竞价表现整体上要比基于第一价位的时候要好。时至今日,基于第二价位的竞拍方式已经成为了互联网广告的主流竞拍模式。
那么,基于第二价位的竞拍方式究竟有什么好处呢?文末推荐一个参考文献[1],有比较详细的描述。简单来说,研究人员发现,在基于第二价位竞拍的形式下,广告商按照自己对于广告位价值的理解来竞拍是相对较优的策略。
在基于第二价位的竞拍方式的环境中,又有什么值得注意的技术难点呢?
对于广告商来说主要是希望知道在当前出价的情况下究竟有多大的概率赢得当前的竞拍。这也就是所谓的“赢的概率”这对于广告商调整自己的出价有非常重要的指导意义。对于整个出价的概率分布的一个估计有时候又叫作“竞价全景观”Bid Landscape预测。这是一个非常形象的说法因为广告商希望知道整个赢的概率随着出价变化的整个分布从而调整自己的安排。
这样的预测工作会用到一些简单的模型。比如有学者认为赢的价格服从一个“对数正态分布”Log-normal。也就是说广告商出的价格并且最终赢得竞拍的这些价格在取了对数的情况下服从一个正态分布。当然这是一个假设。但是有了这么一个假设以后我们就可以从数据中估计这个对数正态分布的参数从而能够对整个“竞价全景观”进行估计。
对于“竞价全景观”或者是赢的价格分布的估计有一个比较困难的地方,那就是,作为广告商来说,往往并不知道所有其他竞争对手的出价,以及在没有赢得竞拍的情况下,那些赢得竞拍的出价是多少。简而言之,也就是我们只观测到了一部分数据,那就是我们赢得这些广告位的出价。在这种只有一部分信息的情况下,所做的估计就会不准确。
已经有一些研究工作关注这样情况的预测。比如论文《用截尾数据预测实时招标中的赢价》Predicting winning price in real time bidding with censored data[2]就利用了一种对数几率回归来估计那些没有赢得竞拍情况下的赢的价格,然后和已知的赢的价格一起对整个“竞价全景观”进行估计,这也算是目前的一项前沿研究。
总结
今天我为你介绍了广告竞价系统中的基于第二价位的广告竞拍。
一起来回顾下要点:第一,我们讲了基于第一价位的竞价原理,就是按照广告收入的期望值进行竞价排名,排名第一的广告竞拍成功;第二,我们聊了基于第二价位的竞价原理和一些技术难点,主要是如何对整个“竞价全景观”进行估计。
最后,给你留一个思考题,既然竞价排名是按照点击率乘以价格,那如何避免下面这样一种情况呢?就是一些点击率比较低的广告商利用很高的价格占据广告位,从而让用户看到很多不相关的广告?
参考文献
Jun Wang, Weinan Zhang and Shuai Yuan. Display Advertising with Real-Time Bidding (RTB) and Behavioural Targeting. Foundations and Trends® in Information Retrieval: Vol. 11: No. 4-5, pp 297-435, 2017.
Wu, W. C.-H., Yeh, M.-Y., and Chen, M.-S. Predicting winning price in real time bidding with censored data. Proceedings of the 21st ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 13051314. ACM, 2015.

View File

@ -0,0 +1,71 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
089 广告的竞价策略是怎样的?
在上一次的分享里,我们讲了广告位竞价的一个基本原理,那就是目前广泛使用的基于第二价位的广告竞拍。简单来说,基于第二价位的广告竞拍需要利用广告点击率的估计值和竞拍的价格,把所有的竞拍广告进行排序,排名第一的广告赢得竞拍。和基于第一价位的广告竞拍不一样的是,基于第二价位的广告竞拍并不直接利用排名第一的广告的出价来对其进行收费,而是利用排名第二的价位进行收费。在这样的情况下,有理论工作和实际的数据表明,基于第二价位的广告竞拍更加符合广告商对于广告位本身真实价值的判断。
今天我们来看在基于第二价位的广告竞拍的基础上DSP或者广告商究竟该如何形成自己的竞价策略Bidding Strategy
竞价策略
为什么需要竞价策略其实这个问题主要是在“实时竞价”或简称RTB的背景下来探讨的。
我们之前提到过RTB是DSP目前流行的竞价模式也就是广告商等利用计算机程序来自动对广告竞拍进行出价。从实际的运作中来看这样的自动竞价模式要比人工竞价更加方便快捷也更加高效。然而在自动竞价的模式下我们势必需要一种指导思想来让我们的计算机程序能够随着形式的变化来进行出价。
那么在RTB中竞价策略的环境究竟是怎样的呢
首先竞价的一个重要特征就是作为一个竞标方我们并不知道其他竞标方的点击率和出价。因此我们处在一个信息不完整的竞价环境中。在这样的环境中我们只能根据自己的点击率估计和自己的出价以及过去出价的成功与否来对整个市场的形势进行判断。这就是在RTB中竞价策略的一大挑战和难点。
在这样的背景下RTB竞价策略的研究和开发集中在以下两种思路上。
一种思路是把整个竞价策略当做一种“博弈”Game从而根据博弈论中的方法来对竞价环境中各个竞标方的行为和收益进行研究比较经典的论文例如参考文献[1])。用博弈论的方法来对竞价进行研究有一个最大的特点,那就是博弈论主要是对各个竞标方行为之间的关联性进行建模,这种关联性包括他们之间的收益和他们的动机。
另外一种思路是把整个竞价策略当做是纯粹的统计决策,也就是直接对广告商的行为进行建模,而把整个竞价环境中的种种联系都当做是当前决策下的不确定因素(这种思路比较有代表性的论文是参考文献[2])。在这样的思路下,各个竞标方之间的行为关联变得不那么重要,而对于整个不确定性的建模则变得至关重要。
第一种思路,也就是利用博弈论的方法来对竞价策略进行研究主要存在于学术界。虽然从理论上来说,博弈论可能提供一种比较有说服力的解释,但是这种思路需要对整个竞价环境有非常多的假设(例如竞标方是不是理性,市场是不是充分竞争等等)。而第二种思路,仅仅需要从广告商自身的角度出发,因此在现实中,这种思路的操作性更强,从而受到工业界的青睐。
总的来说,第二种思路其实就是根据当前的输入信息,例如页面信息、广告信息、用户信息以及上下文信息等,学到一个输出价格的函数,也就是说,这个函数的输出就是在现在情况下当前广告的出价。当然,这个函数势必需要考虑各种不确定的因素。
搜索广告和展示广告的竞标
搜索广告和展示广告的竞标存在着不小的区别,因此,从技术上来讲,就发展出了一系列不同的方法。
对于搜索广告来讲,在大多数情况下,每一个出价都是针对某一个搜索关键词的。例如,一个汽车广告商可能会在一个搜索引擎里竞标针对自己车的品牌,如“大众汽车”、“奥迪汽车”相关的关键词,还可能竞标更加宽泛的关键词,如“买车”、“汽车”等。同时,这里的出价也往往是事先设置好的。
参考文献[3]是第一个利用机器学习方法对搜索广告的出价进行建模的工作。在这个工作里,每一个关键词的出价来自于一个线性函数的输出,而这个线性函数是把用户信息、关键词以及其他的页面信息当做特性,学习了一个从特性到出价的线性关系。这可以算是最早的利用线性函数来进行出价的例子了。
展示广告的竞价则面临着不同的挑战。首先在展示广告中场景中并不存在搜索关键词这种概念。因此很多广告商无法针对场景事先产生出价。这也就要求RTB的提供商要能够在不同的场景中帮助广告商进行出价。
同时相比于搜索广告针对每一个关键词的出价方式来说针对每一个页面显示机会出价的挑战则更大。理论上讲每一个页面显示机会的价格都可能有很大的不同。很多RTB都利用一种叫作CPM的收费模式也就是说一旦某一个广告位被赢得之后对于广告商来说这往往就意味着需要被收取费用。所以在展示广告的情况下如何针对当前的页面显示机会以及目前的预算剩余等等因素进行统一建模就成为一个必不可少的步骤。
竞价策略的其他问题
除了我们谈论到的基本的竞价策略以外,竞价系统还有一些其他问题需要考虑。
比如一个广告商现在有1千元的预算参与到RTB竞价中。从广告商的角度来说通常希望这1千元能够比较均匀地使用到整个广告竞价中。或者说即便不是完全均匀使用至少也不希望这笔预算被很快用完。这里面的一个原因是在每天的各个时段广告的表现情况也就是说转化率或点击率是不一样的广告商通常希望自己的广告能够在比较好的时段进行展示。而如果广告在比较好的时段还没有来临之前就已经将预算消耗殆尽那就会让广告商觉得整个流程不是很友好。
因此在广告竞价策略中还存在着一个叫“预算步调”Budget Pacing的技术也就是希望能够让广告的展示相对平缓而不至于在短时间内使用完全部的预算。这势必对于广告如何出价有着直接的影响。
另外对于平台而言虽然竞价保证了一定的竞争但是也并不是所有的展示机会都有非常充分的竞争。因此从平台的角度来说如何能够保证一定的收益就变得十分重要。在这样的情况下有的平台有一种叫作“保留价格”Reserved Price的做法用来设置一个最低的竞价价格。保留价格虽然能够来保证收益但是也可能会让广告商觉得不划算因此如何来设置这个保留价格也就成为了出价策略中的一个重要组成部分。
总结
今天我为你介绍了广告竞价的基础知识,也就是如何形成竞价策略。
一起来回顾下要点第一我们讲了RTB背景下竞价策略的两种思路第二我们介绍了搜索广告和展示广告竞价策略的不同之处第三我们简单聊了广告竞价策略的一些其他相关问题。
最后,给你留一个思考题,你觉得如何评价一个广告竞价策略的好坏呢?
参考文献
Ramakrishna Gummadi, Peter Key and Alexandre Proutiere. Repeated auctions under budget constraints: Optimal bidding strategies and equilibria. In Eighth Workshop on Ad Auctions, 2012.
Yuan, S., Wang, J., and Zhao, X. Real-time bidding for online advertising: measurement and analysis. Proceedings of the Seventh International Workshop on Data Mining for Online Advertising, page 3. ACM, 2013.
Andrei Broder, Evgeniy Gabrilovich, Vanja Josifovski, George Mavromatis, and Alex Smola. Bid generation for advanced match in sponsored search. Proceedings of the fourth ACM international conference on Web search and data mining (WSDM 11). ACM, New York, NY, USA, 515-524, 2011.

View File

@ -0,0 +1,73 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
090 如何优化广告的竞价策略?
广告的竞价排名是计算广告系统中非常重要的一个话题我们介绍了目前广泛使用的基于第二价位的广告竞拍以及在此基础上DSP或者广告商究竟该如何形成自己的竞价策略Bidding Strategy
今天,我们就来看一些具体的广告竞价策略方法。
单个广告推广计划优化
我们首先来看单个广告“推广计划”Campaign的竞价策略的优化。
在上一次的分享里我们介绍了,利用统计决策的一个重要假设就是最终的出价是一个各种输入(例如环境、用户、页面等)的函数输出。这里我们采用一个简化的假设,认为一个推广计划的出价是点击率的一个函数。在这样的情况下,我们先来理清一些概念。
第一个概念是“赢的概率”Winning Probability。这里面如果我们知道现在市场的一个价格分布以及我们的出价。那么赢的概率就是一个已知概率密度函数求概率的计算也就是通常情况下的一个积分计算。
第二个概念就是“效用”Utility。这是一个广告商关注的指标通常情况下是点击率的某种函数比如利润那就是每一次点击后的价值减去成本。
在这种情况下的成本其实主要就是出价后产生的交易价格。如果是基于第一价位的竞价,那么这个成本就是出价;如果是基于第二价位的竞价,这个成本就是超过第二价位多少还能赢得竞价的价格。
最后还有一点需要说明,那就是所有的广告推广计划都必须要在预算内,这是一个很明显的限制条件。
理清了这些基本的概念和限制条件以后,我们来看一看最一般的竞价策略。为了方便讨论,我们先假设不需要考虑预算,同时也假设,我们竞价的核心是所谓的“按照价值”的竞价。那么,在这种情况下,最优的策略其实就是按照点击率乘以点击后产生的价值来进行出价。可以说,这种策略其实是业界接纳程度最好、也是最直观的一种竞价策略。
然而,在有了预算和当前的交易流量信息的情况下,这种竞价策略就并不是最优的策略了。为什么呢?因为在有了这些限制条件的情况下,我们是否还会按照自己客观认为的广告价值来竞标就成了一个疑问。
那么,如何来应对预算和交易流量的限制呢?有没有什么优化方法? 我就结合几篇论文来跟你聊聊这个问题。
有一篇文章题目是《目标在线广告中的出价优化和库存评分》Bid Optimizing And Inventory Scoring in Targeted Online Advertising[1],这篇文章提供了一种简单的思路来应对预算和交易流量的限制优化问题。
具体来说,与其完全按照广告的价值来进行出价,不如采用这个价值乘以某个系数,而利用这个系数来动态调整目前的出价。由于是在一个已知的可能出价前面乘以一个系数,所以整个出价策略其实是一种线性变换,因此也被叫作是线性出价策略。
线性出价策略在实际操作中比较方便灵活,在这篇论文中,这种算法也取得了比较好的效果。不过遗憾的是,这种做法并没有太多的理论支持。
相比之下,另外的两个研究工作([2]和[3])则提供了一种比较通用的理论框架,可以用于不同的效用函数和损失函数。在这里,我们不展开讲这个通用框架的细节,重点介绍它的核心思路。
这个框架的整体思路是把寻找最优出价或者说是竞价函数的过程表达成为一个“有限制的最优化问题”Constrained Optimization。最优化的优化目标自然就是当前竞价流量下的收益。而最优化的限制条件就是竞价流量下的成本要等于预算。也就是说在我们期望达到预算的情况下我们需要尽可能地扩大收益这就是最优化目标的最大化这个意思。而限制条件决定了这个最大化问题的解的空间因此那些不符合条件的解就可以不考虑了。
一旦我们的问题可以用有限制的最优化问题来表达以后,整个问题的求解就变得相对比较规范化了。对于这类问题有一个标准的求解过程,就是利用“拉格朗日乘数法”,把“有限制的优化问题”转换成为“无限制的优化问题”,然后针对最后的目标函数,求导并置零从而推导出最优解的结果。这一部分的步骤是标准的高等数学微积分的内容。
这个框架最后推导出了基于第一价位和基于第二价位的最优的出价函数形式。在两种情况下,最优的出价函数都是一个基于点击率、当前竞价流量和预算的非线性函数。那么,从这个框架来看,刚才我们提到的线性竞价策略就并不是最优的。
多个广告推广计划优化
了解了单个广告推广计划的优化后很自然地多个广告推广计划的优化也是一个很重要的话题。在这方面比较经典的论文推荐你读一读《展示广告的统计套利挖掘》Statistical Arbitrage Mining for Display Advertising[4]。
从基本的思路上来讲我们需要做的是把刚才的基于单个广告推广计划的有限制优化问题给扩展到多个广告推广计划上去。除了满足各种限制条件以外比如需要满足总的预算要求论文也提出了一种基于风险控制的思路来计算每一个广告推广计划的均值和方差从而限制方差的大小来降低风险。比较遗憾的是论文提出的优化是一个基于EM算法的过程也就是说相对于单个广告推广计划来说多个广告推广计划找到的解可能并不是全局的最优解。
总结
今天我为你介绍了广告竞价的一些具体的竞价策略。
一起来回顾下要点:第一,广告竞价会有预算和交易流量的限制问题,我们介绍了单个广告推广计划的两种思路,分别是“线性出价策略”和转化为“有限制的最优化问题”;第二,我们简单聊了多个广告推广计划的思路,简单介绍了论文提出的一种基于风险控制的思路。
最后,给你留一个思考题,在广告竞价策略的诸多框架中,都基本假定我们知道了广告的点击率,这样的假设有没有问题呢?
参考文献
Perlich, C., Dalessandro, B., Hook, R., Stitelman, O., Raeder, T., and Provost, F. Bid Optimizing And Inventory Scoring in Targeted Online Advertising. Proceedings of the 18th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 804812. ACM, 2012.
Zhang, W., Yuan, S., and Wang, J. Optimal Real-Time Bidding for Display Advertising. Proceedings of the 20th ACM SIGKDD international conference on Knowledge discovery and data mining, pages 10771086. ACM, 2014.
Zhang, W., Ren, K., and Wang, J. Optimal Real-time Bidding Frameworks Discussion. arXiv preprint arXiv:1602.01007, 2016.
Zhang, W. and Wang, J. Statistical Arbitrage Mining for Display Advertising. Proceedings of the 21th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 14651474. ACM, 2015.

View File

@ -0,0 +1,59 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
091 如何控制广告预算?
我们在前面的一系列分享里讲了广告位竞价的基本原理,那就是目前广泛使用的基于第二价位的广告竞拍。也分享了广告的竞价策略,以及具体的竞价策略优化方法,比如单个广告推广计划的优化等。
今天,我们来看在广告竞价策略中一个比较重要的问题,这个问题我们在前一篇的分享里也提到过,那就是如何能够比较流畅地利用广告商的预算,而不是把广告商的钱一下子都花完。
预算步调优化
控制广告预算的第一种方法是预算步调优化Budget Pacing这个方法的目的就是在某一个时间段里均匀地分配广告商的预算。同时在每一个时段发布商所面临的受众都有可能不太一样所以对于广告商而言比较理想的状态是一个广告可以在一天的不同时段被不同的受众所看到从而达到扩大受众面的目的。
预算步调优化有两种比较常见的思路一种叫“节流”Throttling一种叫“修改出价”。
节流这种方法主要是把单位时间的支出或者是成本给控制在某一个速率内,使得预算能够被均匀地使用。这种方法往往是在我们已经介绍过的竞价模型之外运行。修改出价这个思路很直观,也就是直接修改我们的竞价,从而带来预算均匀化的结果。
关于节流思路,有一种做法[1]是把如何节流当做一种“线性优化”问题,并且是有限制的最大化问题。具体来说,对于每一个出价的请求,我们都可以做一个二元的决定,决定我们是否接受这个出价请求。当然,对于每一个出价请求,这里都有一个价值和一个成本。根据对不同出价请求的设置,我们来做优化,从而能够最大化总价值。但同时,我们需要遵守一个限制,总的成本不能超过预算。这其实就是在两种目标之间实现一个均衡,简言之,我们需要在不超过总预算的情况下达到总价值的最大化。
虽然这种算法本身能够通过我们之前介绍过的“拉格朗日乘数法”来求解,但是还存在一个根本的问题,那就是这种算法并不能实时地对整个竞价的安排进行计算和更新。因为,这种线性优化方法一般都是在线下计算好了以后再到线上运行。很明显,这种方法并不适合快速变化的竞价环境。因此,也就有一些工作[2]和[3],尝试通过节流,或者更确切地说,通过在线优化来控制预算的使用情况。
对竞价直接进行修改的相关工作也很多[4]和[5]这个思路是把控制理论中的一些思想借鉴到了对竞价的直接优化上目标是让广告商的预算能够平滑使用。这里面的控制是指什么呢主要是指我们引入一个新的模块在DSP中从而能够实时监测各种指标例如竞价赢的比率、点击率等然后利用这些数据作为一个参考点从而能够形成一种回馈信息以供控制系统来对出价进行实时的调整。
和节流的思想相对比,利用控制理论对出价进行直接优化这种思路明显要更加灵活。然而在实际的工作中,更加灵活的框架依赖于对点击率以及竞价全景观的准确预测,这其实是很困难的。在真实的情况下,利用节流的思想,也就是不去修改出价,只是在其基础上直接进行操作,则往往来得简单有效。
频率上限
在工业界还有一种经常会使用的控制预算的方法叫“频率上限”Frequency Cap。简单来说这种策略就是限制某一个或者某一种广告在某一种媒介上一段时间内出现的次数。比如是否限制一个肯德基的广告在半天之内让同一个用户看见的次数5次、10次还是20次
为什么要限制频率呢?一个因素当然是我们希望广告的预算不要在短时间内消耗完。另外,短时间内反复观看某一个广告,很可能会让用户对某一个广告或者广告商产生厌烦情绪,那么广告的有效度就会降低。这对于一些广告商来说,其实是消耗了一些资源。因此,限制广告的投放是一种策略选择,从而让广告的投放花钱少、效率高。
这种频率上限的做法在工业界非常普遍,不过比较遗憾的是,关于这样做究竟是不是有很大的效果,用户多次看到广告是否会真正产生非常大的厌烦情绪从而使得广告效果降低,有没有理论支持等问题,目前还没有比较好的研究来解决。
总结
今天我为你介绍了广告竞价中的预算步调优化和频率上限两个思路。
一起来回顾下要点:第一,预算步调优化有两种常见思路,分别是“节流”和“修改出价”;第二,频率上限是一种工业界常用的方法,但是目前这方面缺乏理论依据。
最后,给你留一个思考题:今天我们介绍了使用节流的方法来控制预算,其中一种方法是线性优化,需要在预算允许的情况下最大化广告的价值。那么,对于广告商来说,如何衡量广告的价值?
参考文献
Lee, K.-C., Jalali, A., and Dasdan, A. Real Time Bid Optimization with Smooth Budget Delivery in Online Advertising. Proceedings of the Seventh International Workshop on Data Mining for Online Advertising, page 1. ACM, 2013.
Xu, J., Lee, K.-c., Li, W., Qi, H., and Lu, Q. Smart Pacing for Effective Online Ad Campaign Optimization. Proceedings of the 21st ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 22172226. ACM, 2015.
Agarwal, D., Ghosh, S., Wei, K., and You, S. Budget Pacing for Targeted Online Advertisements at Linkedin. Proceedings of the 20th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 16131619. ACM, 2014.
Chen, Y., Berkhin, P., Anderson, B., and Devanur, N. R. Real-time Bidding Algorithms for Performance-based Display Ad Allocation. Proceedings of the 17th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 13071315. ACM, 2011.
Zhang, W., Rong, Y., Wang, J., Zhu, T., and Wang, X. Feedback Control of Real-time Display Advertising. Proceedings of the Ninth ACM International Conference on Web Search and Data Mining, pages 407416. ACM, 2016.

View File

@ -0,0 +1,69 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
092 如何设置广告竞价的底价?
在互联网广告生态系统的环境中,我们已经分享了不少关于点击率优化和竞价排名以及如何优化出价的内容,相信你对于广告的整体运作以及其中的核心算法都有了一定的了解。
我们首先来简单回顾一下发布商和广告商或者DSP之间的关系。发布商往往是类似于新闻网站、社交媒体网站和视频网站这样的内容提供方。这些网站的一个特点就是他们本身并不产生收益甚至往往是免费提供服务。因为流量巨大这些内容提供方希望能够通过在自身的网站上发布广告从而获得巨额收益。对于广告商和DSP来说则是希望能够利用发布商的巨大流量来接触更多的用户从而推销自己的服务和产品。
我们之前的很多讨论其实重点都放到了需求侧平台也就是常说的DSP方面包括点击率预估和很多调整竞价排名的方法等等。今天我们就来看一个发布商在广告竞价流程中可以参与调优的地方那就是广告竞价中的底价优化。
底价
底价,顾名思义,就是在广告的竞价中给竞拍设定一个最低价。
为什么需要这么做呢?其实在理想的状态下,一个充分竞争的,并且有着充分广告源的市场,广告的单价应该是逐渐升高的。因为广告位资源毕竟是有限的,在有充分广告源的情况下,所有的广告商为了竞争这些有限的广告位,必定是会逐渐抬高广告位的价格。而作为内容发布商,在这个过程中则可以享受到逐渐升高的广告位价值。
然而,在现实中的很多情况下,这种理想状态的竞争态势并不完全存在。比如,对于一个新闻内容提供商来说,在新闻首页顶端出现的广告位一般更能吸引眼球,这种广告位常常可以引起充分竞争,但是在新闻页面下方的广告位则很有可能无法带来充分竞争,因为这些广告位的点击率可能只有顶端广告位点击率的十分之一甚至更少。那么,对于那些无法带来充分竞争的广告位,内容发布商就有可能无法收取理想状态下的收益,甚至在一些比较极端的情况下,会以非常便宜的价格给予广告商。
也就是说,在真实的广告竞争市场中,很多时候广告位都无法得到充分竞争。除了我们刚才所说的因为广告位的位置所导致的不充分竞争以外,同一广告位在一天中的不同时段的竞争程度也是不尽相同的。另外,在搜索广告中,不同的搜索关键词也会有不同的竞争情况。
综合这些原因,对于内容发布商来说,如何保护自己的广告位价值并且保证最低收益呢?一种方法就是设置一个广告竞价的最低价格,也就是我们这里所说的底价。当我们设置了底价以后,所有的广告竞价都不会低于这个价格,也就人为地抬高了广告位的竞争水准。
既然这是一种保护广告位价值的简单做法,那么会不会带来一些其他的问题呢?答案是,当然会。一个重要的因素就是,这个底价设置得太高,会打击广告商的积极性,进一步影响广告位的竞争,从而让整个市场变得竞争不足拉低价格。而如果这个底价设置得太低,则没有起到实际的作用,广告商仍然可以利用较低的价格获得广告位,而内容发布商可能也没有获得足够的收益。
底价优化
在了解了这些关于底价的背景知识以后,我们来思考一下该如何设置底价。
在一个基于第二价位的竞价系统中,底价存在三种情况,这些情况的不同会导致发布商有不同的收益。
第一种情况底价高于竞价的最高价。很明显这个时候发布商没有收益因为所有其他的出价都低于底价也就是说底价过高。在实际的操作中这一次广告位请求可能会被重新拍卖Re-Sell
第二种情况,底价高于第二价位。因为是基于第二价位的竞价,所以已经用第一价位获取了广告位的广告商,这个时候就需要支付底价,而不是原本的第二价位的价格。这种情况下,发布商就获取了额外的收益。这个额外的收益就是底价减去之前原本的第二价位。
第三种情况,底价低于第二价位。同理,因为是基于第二价位的竞价,所以这个时候的底价并没有影响原本的第二价位,因此发布商的收益没有变化。
我们讨论了这三种情况以后,就会发现,对于发布商来说,在绝大部分情况下,第二种情况是最理想的,因为这种时候会有额外的收益。那么,如何学习到这个底价就成为了一个挑战。
这里面发布商面临的一个困难是,广告商在提交出价的时候,发布商往往是不知道这个出价的。因此,发布商需要去“猜”所有出价的分布,这无疑是一件非常困难的任务。
在比较早期的研究中[1]研究者们借用了“最优化竞拍理论”Optimal Auction Theory来研究究竟该如何设置这个出价。
最优化竞拍理论其实假设了发布商知道出价的一个概率密度函数再进一步假设这个密度函数是服从“对数正态”Log-Normal分布的然后推导出了一个最佳的底价。在有了这个假设之后就可以利用最佳的底价对广告的竞价进行管理最终在实验中显示对于某一些广告发布商的收益增加了10%以上。
一个更加近期的研究[2]则指出在实时竞价RTB的很多场景中出价的分布未必是对数正态分布整个竞价的环境中也有很多并不符合最优化竞拍理论的情况比如广告商出价未必是按照心中的价值出价而是为了赢得更多的广告位。
在这项研究中,作者们提出了一种非常直观的类似于决策树的策略,然后研究了在不同情况下发布商策略的不同所带来收益的区别。总体说来,发布商可以采用这样一种策略来调整底价:当发现底价低于最高的出价时,保持或者提高底价;当发现底价高于最高出价时,降级底价。在这种策略的指导下,发布商能够达到一种最佳的收益。
总结
今天我为你介绍了广告竞价中底价的设置。
一起来回顾下要点:第一,在真实的广告竞争市场中,很多时候广告位都无法得到充分竞争,为了保证发布商的最佳收益,需要给竞拍设置一个最低价,也就是底价;第二,如何设置底价是一个很困难的任务,以往的研究给我们提供了两种策略可以借鉴,分别是最优化竞拍理论和类似于决策树的策略。
最后,给你留一个思考题,我们应该对所有广告位设置统一的底价吗?还是不同的广告位有不同的底价呢?
参考文献
Ostrovsky, M. and Schwarz, M. Reserve Prices in Internet Advertising Auctions: A Field Experiment. Search, pages 118, 2009.
Yuan, S., Wang, J., Chen, B., Mason, P., and Seljan, S. An Empirical Study of Reserve Price Optimisation in Real-Time Bidding. Proceedings of the 20th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 18971906. ACM, 2014.

View File

@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
093 聊一聊程序化直接购买和广告期货
在周一的分享里,我们讨论了内容发布商优化自己收益策略的底价方案。如果能够把这种底价方案运用得当,就可以增加市场的竞争程度,从而人为地抬高竞价,达到增加收益的目的。
今天,我们来看关于计算广告竞价的另外两个话题:一个是程序化直接购买,另一个是广告期货。
程序化直接购买
程序化直接购买Programmatic Direct是指广告商不通过竞价的方式获取发布商的广告位。这往往意味着广告商需要和发布商直接签订合同来购买一定量的展示机会Impression
在互联网的早期,其实有很多广告合同是通过直接购买的方式进行的。这一类合同的签订通常是经过相对比较传统的模式,也就是由公司的销售人员直接进行接洽。
直接购买的广告合作合同往往是比较大的。例如,可口可乐公司需要在雅虎主页显示一类新饮料的广告。这种广告要求涵盖的人群广,并且时间也比较长。如果按照竞标的方式,这可能是要竞标上百万次甚至上千万次的展示机会。在这样的情况下,对于广告商和发布商来说,比较快捷的方式反而可能是一次性购买下这些展示机会。
以雅虎为例在很长的一段时间里广告的销售都是分为“有保证的销售”和“无保证的销售”。后者类似于今天的RTB市场而前者就是我们现在所说的直接购买。
时至今日对于顶级的内容发布商来说大家依然喜欢把最优价值的一些广告位比如较好的位置或者尺寸较大的广告留下来当做“独家”Premium广告位用于直接购买的合同。而近些年如何使用程序让直接购买更加便捷就成为了很多广告中间平台的一个重要任务。
那么对于内容发布商或者SSP供应侧平台来说需要做什么事情来推动程序化直接购买呢
首先内容发布商需要预估未来一段时间内展示机会的数量。例如在下一个小时内一共有多少展示机会。这种预估其实就是网站或者服务对流量的估计。然后把这些预估的展示机会分为两个部分一部分送入RTB用于我们之前介绍过的广告竞价排名而另一部分则用于程序化直接购买。
和传统的直接购买不同的是这时候的直接购买是程序化的因此并不需要广告商和发布商之间直接建立联系而是通过平台进行交易。从某种意义上来说这种交易和股票交易十分类似。通常情况下平台显示的是对这一批展示机会的一个统一价格。广告商以及DSP需求侧平台可以根据自己的需要直接购买这个统一价格的展示机会。一般来说这种购买可以提前几个星期甚至几个月。
一旦直接购买和通过竞价排名的方式都程序化以后,对于广告商来说,他们愿意提前直接购买广告位,因为这样购买的广告位价值低于他们的一个心理价位。而对于发布商来说,就需要权衡这两种渠道之间的收益平衡,其实在某种情况下,特别是市场竞争不完全的情况下,这也是发布商希望确保一定收益的方法,也就是在有一定折扣的情况下卖掉广告位。
在程序化直接购买方面进行研究的相关论文非常稀少[1],一个原因是这种技术的探讨往往需要比较高级的广告系统作为支撑。
广告的期权
到现在为止,我们已经讨论了广告的竞价排名以及程序化直接购买等话题,你是不是已经慢慢感受到,广告生态系统的构架和我们熟悉的另外一个领域的很多概念有着千丝万缕的联系。对,这个领域就是金融系统,特别是股票或者大宗商品的交易。
这里面的联系其实是非常直观的。第一广告和股票交易一样都有大量的交易机会。这就需要出现第三方系统和平台对于股票来说是股票交易所而对于广告来说则是广告的DSP和SSP。第二广告和股票交易一样价值和价格都有可能因为交易带来瞬息万变的差别因此越来越多的金融工具被制造出来来为这个生态系统中的种种角色进行风险控制。
比如对于RTB来说虽然这种机制为广告商和发布商创造了一种交易的模式但是这种模式中基于第二价位的竞价让广告商无法对最终的成交价进行有效控制而且对于发布商而言对于利润的把握也有一定的风险同时广告商和发布商之间也谈不上什么“忠诚度”因为相同的广告位还有可能在其他的发布商那里找到。在这种情况下“期权”Option这种金融工具就被介绍到了计算广告的环境中。
最近一段时间以来,已经有了一些零星的研究工作讨论广告期权的理论和应用([2]和[3])。当前,很多发布商是这么设置广告期权的。发布商设置一个未来某个时间点的某个或某些广告展示机会的一个提前价格。这个价格并不是展示机会的实际价格,而是一个权利。对于广告商来说,可以购买这个权利,用于得到未来的这个展示机会。当然,广告商在未来并不一定购买这个展示机会,也可以放弃这个权利。
对于广告商来说,如何参与竞拍,如何在最佳的时机去购买期权,就变成了一个复杂的优化问题。当下关于这方面的很多研究,都借用了金融领域的一些模型和算法。
总结
今天我为你介绍了在线计算广告的另外两个重要话题程序化直接购买和广告期权。到此为止我们就完整地介绍了DSP和SSP中所有有关出价和竞价的话题。
一起来回顾下要点:第一,我们从广告的历史发展中介绍了程序化直接购买的意义;第二,我们简单聊了聊广告期权存在的目的。
最后给你留一个思考题对于一个DSP来说能不能通过直接购买获得大量的展示机会然后又通过RTB竞价排名把这些机会卖出去这样做的风险是什么
参考文献
Chen, B., Yuan, S., and Wang, J. A dynamic pricing model for unifying programmatic guarantee and real-time bidding in display advertising. In Proceedings of the Eighth International Workshop on Data Mining for Online Advertising, pages 1:11:9. ACM, 2014.
Chen, B., Wang, J., Cox, I. J., and Kankanhalli, M. S. Multi-keyword multi-click advertisement option contracts for sponsored search. ACM Transactions on Intelligent Systems and Technology (TIST), 7(1):5, 2015.
Chen, B. and Wang, J. A lattice framework for pricing display advertisement options with the stochastic volatility underlying model. Electronic Commerce Research and Applications, 14(6):465479, 2015.

View File

@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
094 归因模型:如何来衡量广告的有效性
在互联网广告生态系统的环境中,我们已经分享了不少关于点击率优化和竞价排名以及如何优化出价的内容。接下来我们开始讨论一些计算广告相关的高级话题。之所以说这些是高级话题,是因为作为机器学习在计算广告的应用,这些话题往往都比较偏冷,但在现实中又特别有实用价值。
今天我们先来聊一聊归因模型,这种技术在计算广告业中被广泛使用。
什么是归因模型
归因模型Attribution Model是一种计算广告中分配“贡献”的机制。
在现代网站或者应用中,每一个用户都有可能在每一次会话中看到多个不同的广告,或者在多个不同的会话中看到相同广告的不同展示。那么,当用户点击了某个广告,或者是当用户转化以后,比如购买了某个商品或是订阅了某种服务,广告商通常希望知道究竟是哪一个广告起了更大的作用。也就是说,广告商想知道用户接收到的不同广告对这个最后的转化事件都起了什么作用,这个问题就是归因模型研究的核心。
归因模型之所以重要,是因为这里面牵涉到了广告有效性这个话题。那么,如何来衡量广告的有效性呢?
衡量广告的有效性,就需要利用归因模型,针对每一个转化来分配“贡献”。这样,对于广告商来说,就可以通过贡献值的叠加来看某一个渠道或者某一个内容发布平台的转化效果。
然而归因模型的难点在于这里面并没有完全的“基本事实”Ground Truth全部都基于一定的假设。同时归因模型直接关系到广告是否有效的计算也就关系到我们能否推行一个“公平”的市场以及能否防止其他的广告商在整个平台上进行博弈。
那么,现在各个平台普遍都在使用的是什么样的归因模型呢?下面我给你介绍几个最基本的归因模型。当然了,说这些方法是模型其实也是不够准确的,因为这些方法大多没有理论支撑,主要是基于经验或者基于传统的方法。
第一种经验方法叫“最后触碰”Last Touch
顾名思义最后触碰指的就是在转化前的最后一个广告拿走100%的贡献值。这是目前使用最广泛的归因方法,主要是因为它的简单直观。
我们之前讨论过的所有点击率或者转化率的计算都是基于这个归因方法的。一个可以去博弈“最后触碰”的方法就是让DSP需求侧平台把广告投放给那些已经对品牌或者服务产生兴趣的人从而能够以较大的概率获得用户的转化。在这个过程中广告的投放其实并没有起作用而DSP也并没有试图去转化新用户。
举一个例子如果我们已知一个用户喜欢可口可乐并且很可能在过去购买过可口可乐那么给这个用户展示最新的可口可乐促销广告就很有可能让这个用户点击广告并购买了一箱促销的可乐。但是在这个情况下我们还可以认为这个用户很有可能不需要看这个广告也会购买可乐所以这个广告其实是浪费了资源。“最后触碰”其实是鼓励了DSP采用更加保守的投放方式。
既然有“最后触碰”那肯定就有“第一次触碰”First Touch的经验方法。这种方式和“最后触碰”截然相反那就是只要一个用户最后转化了那么这个用户第一次看到的广告就获得了100%的贡献值。尽管用户可能在第一次看到这个广告后还看了其他的广告但是这些其他广告都不算数了。“第一次触碰”其实鼓励了DSP尽可能广地投放广告把广告的投放当做品牌宣传。
除了这两种比较极端但是被广泛使用的归因方法以外还有一系列的经验方法都算是这两种方法的某种平衡状态。比如一种叫“线性碰触”Linear Touch的方法是给用户在转化的道路上每一个广告都赋予一样的贡献值。还有一种叫“位置触碰”Position Based的方法其实就是“最后触碰”和“第一次触碰”的结合。另外一种经验方法“时间递减”Time Decay则是按照由远到近对所有的广告位都给一定的贡献值。离转化事件时间越近的广告获得的贡献值越多。
总之,你可以看到,这些林林总总的经验方法虽然都比较直观,但是在实践中,都有可能给一些广告商利用系统进行不公平投放的机会。
基于模型的归因方法
下面我们来看一些具备一定理论基础的归因方法,介绍一个在这方面比较早的探索研究[1]。在这个研究里作者们首先介绍了一种叫Bagged Logistic Regression的方法这个方法根据当前广告的“触碰”信息也就是用户看了什么广告来预测用户是否将会转化。在这个模型里所有的特征就是二元的用户是否观看了某个广告的信息然后标签就是用户是否转化。通过这些二元的特征学习到的系数就表达了这个广告在这个预测模型下的贡献度。当然作者们利用了Bagged的方法学习到所有的系数都是正的确保能够解释这个模型的含义。
同时,作者们还提出了一个对归因问题的概率解法,我来介绍下这个概率解法的直观思路。某一个广告对用户转化的最后作用都来自两个部分:第一部分是这个广告对用户转化的直接作用;第二个部分是当前这个广告和另外一个广告一起作用使用户转化的概率。当然,这个第二部分的联合作用需要减去这两个广告分别单独作用于用户的情况。那么,一个广告对于用户的影响,就是这两个部分概率的加和,这其实就是考虑了一阶和二阶的关系下的归因模型。
知道了归因信息之后,我们还可以把这个信息利用到广告的竞价中。直白来说,就是针对有价值的渠道进行有效的出价,而对没有效果的渠道进行控制[2]。除此以外,归因信息还可以帮助广告商来分配自己的预算,把大部分的预算用在优质的渠道中来投放广告[3]。
总结
今天我为你介绍了在线计算广告的一个高级话题:归因模型。
一起来回顾下要点:第一,归因模型是一种计算广告中分配贡献的机制,广泛使用的方法有最后触碰和第一次触碰等;第二,有一些有一定理论基础的归因方法,我们其实可以拓展归因信息的应用场景。
最后,给你留一个思考题,如何来衡量一个归因方法是否有效呢?
参考文献
Shao, X. and Li, L. Data-driven multi-touch attribution models. Proceedings of the 17th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 258264. ACM, 2011.
Xu, J., Shao, X., Ma, J., Lee, K.-c., Qi, H., and Lu, Q. Lift-based bidding in ad selection. Proceedings of the 30th AAAI Conference on Artificial Intelligence, 2016.
Geyik, S. C., Saxena, A., and Dasdan, A. Multitouch attribution based budget allocation in online advertising. Proceedings of 20th ACM SIGKDD Conference on Knowledge Discovery and Data Mining, pages 19. ACM, 2014.

View File

@ -0,0 +1,67 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
095 广告投放如何选择受众?如何扩展受众群?
从上一期的分享开始,我们来讨论计算广告相关的一些高级话题。作为机器学习在计算广告的应用,这些话题往往偏冷,但在现实中又有很大的实用价值。我们首先聊了归因模型,介绍了几种经验方法和一些基于模型的归因方法,这种模型在计算广告业中举足轻重,不过也常常被人忽视。
今天我们来看另外一个重要的话题那就是如何帮助广告商扩大受众群我们也把这种技术称为受众扩展Audience Expansion技术。
什么是受众
广告商在投放广告时有一个最根本的需求,就是希望通过广告平台接触到更多有可能被转化的受众群。所以,对于绝大多数的广告平台而言,满足广告商的这个需求就成为了一个非常重要的功能。
为了让广告商来选择受众,不少广告平台提供两种最基本的功能。
第一种方式是搜索广告的模式,也就是广告商可以选择通过某个关键词或者一系列关键词来接触到希望投放的受众。这里面其实有一个假设,那就是受众的兴趣或者意图是和关键词联系在一起的,而如果投放的广告内容和受众的兴趣以及意图相符,那么对于广告商来说,就可以假设这种情况下受众的转化率是最高的。
第二种就是通过某种选择受众群的方式来让广告商自由地选择广告投放的对象。这里最基本的方式是通过受众的“人口”Demographic信息来进行投放。典型的人口信息包括年龄、性别和地域。
不管是采用关键词还是人口信息来进行受众选择,这些方法看似简单直观,但其实也给广告商带来了不小的挑战。
首先,我们来看关于搜索关键词的难点。作为一个广告商,你怎么知道所有的跟你产品或者服务相关的关键词呢?理论上说,可能会有无穷无尽的关键词可供投放。但是关键词的投放数量也和成本有着密切的关系。所以,从现实的角度来讲,肯定是无法投放所有的关键词的。
其次,利用人口信息来选择受众,那如何来找到比较合适的人口信息呢?这里面就有很大的挑战了。广告商可以利用一些研究结果来找到对应的人口信息从而增强广告投放的效果。然而针对很多中小广告商来说,花费很大的精力和时间去研究这些不同的人口信息和广告效果之间的关系显然是不可能的。
除了我们刚才所说的这两种广告商选择受众的方式以外,现在也有不少的广告平台并不需要广告商进行“显式”的受众选择。这些服务其实就是看到了这种选择带给广告商的复杂性,与其让广告商来选择,还不如让广告平台来优化。于是,有很多广告平台提供的就是“傻瓜式”的广告服务,广告商仅需要设置预算信息,对于人群的投放则完全由广告平台来负责。
受众扩展
了解了受众的选择以后,一个很现实的问题就摆在了广告平台商的面前,如何帮助广告商来扩展已经选择了的受众群体,从而能够实现受众转化的最大化呢?
来自LinkedIn的几位作者就探讨了在社交媒体广告中受众扩展的这个问题[1]。在LinkedIn平台上广告商也就是雇主可以针对不同的群体限制条件也就是我们所说的受众来投放广告以吸引潜在的雇员和候选人。广告商在投放广告的时候可以按照雇员的职业技能比如是否会Java是否会机器学习等以及一些其他的信息例如来自哪个公司、地理位置来选择投放的受众。这和我们之前介绍的场景一样很明显即便是广告商精心选择一个看似比较有效的受众在这种情况下其实依然有很多种其他选择的可能性。
在这篇文章里,作者们介绍了这么几种受众扩展的思路。
第一种思路是和某一个广告推广计划Campaign无关的。这里主要是通过一种“类似”算法而找到类似的公司、类似的技能等等。这种扩展的好处是可以对任何广告推广进行扩展而无需积累数据。
第二种思路是广告推广相关的扩展。这里其实还是利用了“类似”算法,但是在扩展的时候是根据广告商对当前这个广告推广所选择的条件来进行选择,这样的选择自然就会和当前的广告推广更加相关。
在实际操作中LinkedIn采用了这两种思路结合的方法。先利用于推广无关的扩展方法来获取最初的一些扩展用户尽管这部分用户可能质量不高。然后当广告推广已经运行了一段时间以后再针对这个广告推广的选择进行扩展就可以找到更加高质量的扩展用户群体。
我们看到这些扩展方法都依赖于“类似”算法,这里我简单说一下这个算法的核心思想。
总体来说这个算法是针对某一个实体可以是公司、人名、地域、技能等通过搜索的方法来返回最相关的K个其他实体。作者们把这个问题看成了一个监督学习的问题。其核心就是利用了一个对数几率模型对相似的正例和负例进行学习。
那么哪些实体是正例哪些是负例呢作者们把用户频繁选择放在一起投放的实体当做了正例而把其他的实体当做负例。对于特性来说这里广泛采用了文本特性包括文本的词包表达、以及N元语法N-gram组成的特性。同时这里还利用了图相关度来推算比如两个公司在社交关系上的相关程度。然后两个实体之间的余弦相关度也作为一种特性被包含在了模型中。
在线上实验的结果中所有受众扩展的效果都比不用扩展有显著的提升。特别是在混合扩展的模式下展示机会、点击率和总的收益都提升了10%以上。这个实验结果可以用来说明受众扩展的重要性。
总结
今天我为你介绍了在线计算广告的另外一个高级话题:受众扩展。
一起来回顾下要点:第一,广告商可以通过关键词或者人口信息等方式来选择受众,不过受众选择也并不容易,有很大的挑战性;第二,我们介绍了和推广计划有关的与无关的两种受众扩展思路,以及将两种思路结合的方法,并简单介绍了两种思路都依赖的“类似”算法。
最后,给你留一个思考题,在什么情况下受众扩展可能会出现问题,如何来衡量这些问题?
参考文献
Haishan Liu, David Pardoe, Kun Liu, Manoj Thakur, Frank Cao, and Chongzhe Li. Audience Expansion for Online Social Network Advertising. Proceedings of the 22nd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining (KDD 16). ACM, New York, NY, USA, 165-174, 2016.

View File

@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
096 复盘 4 广告系统核心技术模块
今天我准备了 18 张知识卡,和你一起来对广告系统核心技术模块的内容做一个复盘。
在这个模块我们一起学习了18篇文章讨论了5大话题包括广告系统架构、知名公司的广告点击率预估模型、出价系统、预算等。通过这些点我们勾勒出了这个领域的主线。希望你能沿着这条线去做更多探索。
提示:点击知识卡,可以一键到达你最想复习的那一篇文章。
广告系统架构
广告点击率预估
出价系统
预算
高级话题
积跬步以至千里
最后,恭喜你在人工智能领域的千里之行,又往前迈出了一步。

View File

@ -0,0 +1,83 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
096 如何利用机器学习技术来检测广告欺诈?
在上一期的内容中,我们聊了如何帮助广告商扩大受众群这个话题,也就是受众扩展技术。受众扩展的目的是让广告商投放的广告能够接触到更广泛的受众,甚至有可能提高广告效果。
在计算广告高级话题的最后一篇分享同时也是整个广告模块的最后一篇分享里我想来聊一聊广告中一个非常棘手同时也是一个非常实际的问题欺诈检测Fraud Detection
什么是广告欺诈
广告欺诈是一个多大规模的问题呢?
根据一个统计数字[1]到2015年的时候就因为广告欺诈全美的市场营销和媒体业每年的耗费约为82亿美元。这个数字中大约有56%也就是46亿多美元的耗费来自于“非法流量”Invalid Traffic。我们把这个数字和全美每年596亿的广告支出进行对比就可以看出这是一个惊人的数字。当然因为各种欺诈手段层出不穷并不是所有的欺诈都能够被甄别出来。因此我们其实有理由相信真实的数字会更高。
那么,怎么来定义广告欺诈呢?什么样的行为算是广告欺诈呢?
我们这里主要讨论三种形式的广告欺诈。这三种广告欺诈模式其实对应着三种流行的广告计费模式。
第一种欺诈叫“展示欺诈”Impression Fraud也就是造假者产生虚假的竞价信息然后把这些竞价展示放到广告交易平台上去贩卖并且在广告商购买了这些展示后获利。
第二种欺诈叫“点击欺诈”Click Fraud也就是造假者在广告商产生虚假的点击行为。
第三种欺诈叫“转化欺诈”Conversion Fraud也就是造假者完成某种虚假的动作例如填写表格下载某个应用等来虚拟真实的转化事件。
在真实的场景中,这三种欺诈手段经常混合出现。例如点击欺诈和展示欺诈可能同时出现,这样就能在报表中展示一个看似合理的点击率。
广告欺诈的产生源
了解了广告欺诈的基本形式之后,我们来看一下这些欺诈产生的源头都在什么地方。因为广告产业的有利可图,产生欺诈的途径也是多种多样的,我们这里就看一些经典的形式。
首先有一种欺诈来源途径叫PPVPay-Per-View网络。
利用PPV进行欺诈的主要流程就是尝试通过购买流量然后在一些合法的展示机会中插入用户肉眼看不见的0像素的标签Tag诱导广告商让广告商以为产生了更多的合法流量。
对于这样的欺诈一般来说广告商必须去检测展示机会用户是不是看不见或者是否是由0像素产生的。然后还可以采用黑名单的方式对屡次利用PPV来进行欺诈的IP地址进行屏蔽。
另外一种欺诈手段是通过“僵尸网络”Botnets
这种方法主要是试图直接控制用户的终端电脑或者其他的移动设备从而进行很多方面的攻击。在过去僵尸网络的一大应用主要是来产生拒接服务的DDoSDistributed Denial of Service攻击和发送垃圾信息。
近年来因为其灵活性很多僵尸网络也被用于广告欺诈。僵尸网络的一大作用就是产生浏览信息而这些浏览的行为是宿主电脑的用户所无法得知的。因此对付僵尸网络的一大方法就是检测从某些IP地址或者DNS产生的流量行为是否发生了突然的根本性的变化。
第三类欺诈手段是“竞者攻击”Competitor Attack
正常的广告商设立预算参与竞价购买广告位。而竞争对手可以利用“点击欺诈”的方式产生虚假无效的点击信息,从而消耗广告商的预算。当把竞争对手的预算消耗光以后,攻击者反而可以用比较小的成本拿到这些广告位,因为竞争减少了。
另外,还有一种情况是仅仅大量调入竞争对手的广告而不点击。在这样的情况下,就容易产生非常低的点击率。而很多广告平台依赖点击率来进行排序,因此,如果点击率很低,那代价就是难以赢得竞价,通过这种方式也就间接打压了竞争对手。
欺诈检测
了解了什么是广告欺诈以及不同的广告欺诈来源之后,我们来看一看如何利用机器学习技术,来对各种不同的欺诈行为进行检测和挖掘。
首先介绍一个研究[2],作者们提出了一种技术,利用“同访问”图来分析异常的浏览行为。这里面有一个最基本的假设:对于大多数用户来说,对两个不同的网站并不具有相同的喜好程度,除非这些网站非常流行。也就是说,对于绝大多数的网站来说,其用户群体是不一样的。
如果用户和这些网站的相互关系发生了变化,那可能就是出现了一些异常的情况。当然,利用图分析的方法,就是把异常发掘当成了一种无监督学习的任务,自然也就会有无标签的困难。
还有一个研究[3]作者们提出了一种方法来分析用户到底需要花多少时间来浏览显示的像素。这个方法其实就是来检测是否是0像素的展示欺诈。作者们通过研究发现对于50%以上的像素绝大多数用户至少需要1~3秒时间来观看。于是广告商或者平台就可以用这种停留时间来作为一个最基本的检测手段。
当然,一种最普遍的做法就是把广告欺诈当做一个监督学习任务。通过产生各种格样的特性以及把过去已知的欺诈数据当做训练数据来进行学习。这种做法的难点是,欺诈数据在真实世界中毕竟是少数。于是,我们就有了数据不足以及需要训练和不平衡的分类问题。正是因为存在这些问题,欺诈检测依然是一个非常前沿的研究领域。
总结
今天我为你介绍了在线计算广告的最后一个高级话题:欺诈检测。
一起来回顾下要点第一我们讲了三种形式的广告欺诈分别是展示欺诈、点击欺诈和转化欺诈在真实场景中这三种欺诈手段经常混合出现第二产生欺诈的源头很多我们简单介绍了三种不同类型的广告欺诈来源分别是PPV网络、僵尸网络和“竞者攻击第三我们讨论了欺诈检测的一些基本思路比如利用图分析、利用停留时间的方法等等。
最后,给你留一个思考题,如何来检测转化欺诈,也就是我们怎么知道广告转化中哪些是虚假的呢?
参考文献
Interactive Advertising Bureau (2015). What is an untrustworthy supply chain costing the us digital advertising industry?
Stitelman, O., Perlich, C., Dalessandro, B., Hook, R., Raeder, T., and Provost, F. Using co-visitation networks for detecting large scale online display advertising exchange fraud. In Proceedings of the 19th ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, pages 12401248. ACM, 2013.
Zhang, W., Pan, Y., Zhou, T., and Wang, J. An empirical study on display ad impression viewability measurements. arXiv preprint arXiv:1505.05788, 2015.

View File

@ -0,0 +1,79 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
097 LDA模型的前世今生
在文本挖掘中有一项重要的工作就是分析和挖掘出文本中隐含的结构信息而不依赖任何提前标注的信息。今天我要介绍的是一个叫做LDALatent Dirichlet Allocation的模型它在过去十年里开启了一个领域叫主题模型。
从LDA提出后不少学者都利用它来分析各式各样的文档数据从新闻数据到医药文档从考古文献到政府公文。一段时间内LDA成了分析文本信息的标准工具。从最原始的LDA发展出来的各类模型变种则被应用到了多种数据类型上包括图像、音频、混合信息、推荐系统、文档检索等等各类主题模型变种层出不穷。下面我来简单剖析一下LDA这个模型聊聊它的模型描述以及训练方法等基础知识。
LDA的背景介绍
LDA的论文作者是戴维·布雷David Blei、吴恩达和迈克尔·乔丹Michael Jordan。这三位都是今天机器学习界炙手可热的人物。论文最早发表在2002年的神经信息处理系统大会Neural Information Processing Systems简称NIPS然后长文章Long Paper于2003年在机器学习顶级期刊《机器学习研究杂志》Journal of Machine Learning Research上发表。迄今为止这篇论文已经有超过1万9千次的引用数也成了机器学习史上的重要文献之一。
论文发表的时候,戴维·布雷还在加州大学伯克利分校迈克尔手下攻读博士。吴恩达当时刚刚从迈克尔手下博士毕业来到斯坦福大学任教。戴维 2004年从伯克利毕业后先到卡内基梅隆大学跟随统计学权威教授约翰·拉弗蒂John Lafferty做了两年的博士后学者然后又到东部普林斯顿大学任教职先后担任助理教授和副教授。2014年转到纽约哥伦比亚大学任统计系和计算机系的正教授。戴维在2010年获得斯隆奖Alfred P. Sloan Fellowship美国声誉极高的奖励研究人员的奖项不少诺贝尔奖获得者均在获得诺贝尔奖多年之前获得过此奖紧接着又在2011年获得总统青年科学家和工程师早期成就奖Presidential Early Career Award for Scientists and Engineers简称PECASE。目前他所有论文的引用数超过了4万9千次 。
吴恩达在斯坦福晋升到副教授后2011年到2012年在Google工作开启了谷歌大脑Google Brain的项目来训练大规模的深度学习模型是深度学习的重要人物和推动者之一。2012年他合作创建了在线学习平台Coursera可以说是打开了慕课Massive Open Online Course简称MOOC运动的大门。之后吴恩达从2014年到2017年间担任百度首席科学家并创建和运行了百度在北美的研究机构。目前他所有论文的引用数超过8万3千次。
文章的第三作者迈克尔·乔丹是机器学习界的泰斗人物。他自1998年在加州大学伯克利任教至今是美国三个科学院院士American Academy of Arts and Sciences、National Academy of Engineering以及National Academy of Sciences是诸多学术和专业组织的院士比如ACM、IEEE、AAAI、SIAM等。迈克尔可以说是桃李满天下而且其徒子徒孙也已经遍布整个机器学习领域不少都是学术权威。他的所有论文有多达12万次以上的引用量。
值得注意的是对于三位作者来说LDA论文都是他们单篇论文引用次数最多的文章。
LDA模型
要描述LDA模型就要说一下LDA模型所属的产生式模型Generative Model的背景。产生式模型是相对于判别式模型Discriminative Model而说的。这里我们假设需要建模的数据有特征信息也就是通常说的X以及标签信息也就是通常所说的Y。
判别式模型常常直接对Y的产生过程Generative Process)进行描述而对特征信息本身不予建模。这使得判别式模型天生就成为构建分类器或者回归分析的有利工具。而产生式模型则要同时对X和Y建模这使得产生式模型更适合做无标签的数据分析比如聚类。当然因为产生式模型要对比较多的信息进行建模所以一般认为对于同一个数据而言产生式模型要比判别式模型更难以学习。
一般来说产生式模型希望通过一个产生过程来帮助读者理解一个模型。注意这个产生过程本质是描述一个联合概率分布Joint Distribution的分解过程。也就是说这个过程是一个虚拟过程真实的数据往往并不是这样产生的。这样的产生过程是模型的一个假设一种描述。任何一个产生过程都可以在数学上完全等价一个联合概率分布。
LDA的产生过程描述了文档以及文档中文字的生成过程。在原始的LDA论文中作者们描述了对于每一个文档而言有这么一种生成过程
首先从一个全局的泊松Poisson参数为β的分布中生成一个文档的长度N
从一个全局的狄利克雷Dirichlet参数为α的分布中生成一个当前文档的θ
然后对于当前文档长度N的每一个字执行以下两步一是从以θ为参数的多项Multinomial分布中生成一个主题Topic的下标Indexz_n二是从以φ和z共同为参数的多项分布中产生一个字Wordw_n。
从这个描述我们可以马上得到这些重要的模型信息。第一我们有一个维度是K乘以V的主题矩阵Topic Matrix。其中每一行都是一个φ也就是某一个生成字的多项分布。当然这个主题矩阵我们在事先并不知道是需要学习得到的。另外对每一个文档而言θ是一个长度为K的向量用于描述当前文档在K个主题上的分布。产生过程告诉我们我们对于文档中的每一个字都先从这个θ向量中产生一个下标用于告诉我们现在要从主题矩阵中的哪一行去生成当前的字。
这个产生模型是原论文最初提出的,有两点值得注意。
第一,原始论文为了完整性,提出了使用一个泊松分布来描述文档的长度这一变化信息。然而,从模型的参数和隐变量的角度来说,这个假设并不影响整个模型,最终作者在文章中去除了这个信息的讨论。在主题模型的研究中,也较少有文献专注这个信息。
第二原始论文并没有在主题矩阵上放置全局的狄利克雷分布作为先验概率分布。这一缺失在后续所有的主题模型文献中得到修正。于是今天标准的LDA模型有两类狄利克雷的先验信息一类是文档主题分布的先验参数是α一类是主题矩阵的先验参数是β。
文章作者们把这个模型和当时的一系列其他模型进行了对比。比如说LDA并不是所谓的狄利克雷-多项Dirichlet-Multinomial聚类模型。这里LDA对于每个文档的每一个字都有一个主题下标。也就是说从文档聚类的角度来看LDA是没有一个文档统一的聚类标签而是每个字有一个聚类标签在这里就是主题。这也是LDA是Mixed-Membership模型的原因——每个字有可能属于不同的类别、每个文档也有可能属于不同的类别。
LDA很类似在2000年初提出的另外一类更简单的主题模型——概率隐形语义索引Probabilistic Latent Semantic Indexing简称PLSI。其实从本质上来说LDA借用了PLSI的基本架构只不过在每个文档的主题分布向量上放置了狄利克雷的先验概率以及在主题矩阵上放置了另外一个狄利克雷的先验概率。
尽管看上去这是一个非常小的改动但是这样做的结果则是LDA的参数个数并不随着文档数目的增加而增加。那么相对于PLSI来说LDA并不容易对训练数据过度拟合Overfitting
值得注意的原始文章说过度拟合主要是指对于PLSI而言文档的主题分布向量是必须需要学习的而这个向量对于LDA是可以被忽略或者说是并不需要保存的中间变量。然而在实际的应用中我们其实常常也需要这个向量的信息因此这部分对于过度拟合的讨论在后来的应用中并没有特别体现。
LDA模型的训练和结果
LDA虽然从某种意义上来说仅仅是在PLSI上增加了先验信息。然而这一个改动为整个模型的训练学习带来了非常大的挑战。应该说整个LDA的学习直到模型提出后近10年才随着随机变分推理Stochastic Variational Inference的提出以及基于别名方法Alias Method的抽样算法Sampling Method而得以真正的大规模化。一直以来LDA的训练学习都是一件很困难的事情。
不像PLSI可以依靠最大期望EM算法得以比较完美的解决传统上LDA的学习属于贝叶斯推理Bayesian Inference而在2000年代初期只有马尔科夫蒙特卡洛Markov chain Monte Carlo简称MCMC以及迈克尔·乔丹等人推崇的变分推理Variational Inference简称VI作为工具可以解决。这篇文章因为出自迈克尔的实验室当仁不让地选择了VI。比较有意思的是后续大多数LDA相关的论文都选择了MCMC为主的吉布斯Gibbs采样来作为学习算法。
VI的完整讲解无法在本文涵盖。从最高的层次上来理解VI是选取一整组简单的、可以优化的所谓变分分布Variational Distribution来逼近整个模型的后验概率分布。当然由于这组分布的选取有可能会为模型带来不小的误差。不过好处则是这样就把贝叶斯推理的问题转化成了优化问题。
从LDA的角度来讲就是要为θ以及z选取一组等价的分布只不过更加简单更不依赖其他的信息。在VI进行更新θ以及z的时候算法可以根据当前的θ以及z的最新值更新α的值这里的讨论依照原始的LDA论文忽略了β的信息。整个流程俗称变分最大期望Variational EM算法。
文章在TREC AP的文档数据中做了实验。首先作者们使用了一个叫困惑度Perplexity的评估值来衡量文档的建模有效程度这个值越低越好。LDA在好几个数据集中都明显好于PLSI以及其他更加简单的模型。从这篇文章之后主题模型的发展和对比都离不开困惑度的比较也算是开启了一个新时代。
然后作者们展示了利用LDA来做文档分类也就是利用文档主题向量来作为文档的特征从而放入分类器中加以分类。作者们展示了LDA作为文档分类特征的有力证据在数据比较少的情况下优于文本本身的特征。不过总体说来在原始的LDA论文中作者们并没有特别多地展现出LDA的所有可能性。
小结
今天我为你梳理了LDA提出的背景以及这篇论文所引领的整个领域的情况。你需要掌握的核心要点第一论文作者们目前的状态第二LDA模型本身和它的一些特点第三LDA的训练流程概况以及在原始文章中的实验结果。
最后我为你留一个思考题LDA的产生过程决定了对于一个文本而言每个字都可能来自不同的主题那么如果你希望对于某一个段落所有的文字都来自同一个主题你需要对LDA这个模型进行怎么样的修改呢

View File

@ -0,0 +1,69 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
098 LDA变种模型知多少
我们在之前的分享中曾经介绍过文本挖掘Text Mining中的重要工具LDALatent Diriclet Allocation的基本原理。在文本挖掘中有一项重要的工作就是分析和挖掘出文本中隐含的结构信息而不依赖任何提前标注Labeled的信息。也就是说我们希望能够利用文本挖掘技术来对无标签的数据进行挖掘这是典型的无监督学习。
LDA就是一个出色的无监督学习的文本挖掘模型。这个模型在过去的十年里开启了主题模型Topic Model这个领域。不少学者都利用LDA来分析各式各样的文档数据从新闻数据到医药文档从考古文献到政府公文。在一段时间内LDA成为了分析文本信息的标准工具。而从最原始的LDA发展出来的各类模型变种则被应用到了多种数据类型上包括图像、音频、混合信息、推荐系统、文档检索等等可以说各类主题模型变种层出不穷。
今天我们就结合几篇经典论文来看一看LDA的各种扩展模型。当然在介绍今天的内容之前我们首先来回顾一下LDA模型的一些基本信息。
LDA模型的回顾
LDA模型是一个典型的产生式模型Generative Model。产生式模型的一大特点就是通过一组概率语言对数据的产生过程进行描述从而对现实数据建立一个模型。注意这个产生过程的本质是描述的一个联合概率分布Joint Distribution的分解过程。也就是说这个过程是一个虚拟的过程真实的数据往往并不是这样产生的。这样的产生过程是模型的一个假设一种描述。任何一个产生过程都可以在数学上完全等价一个联合概率分布。
LDA的产生过程描述了文档以及文档中文字的产生过程。在原始的LDA论文中作者们描述了对于每一个文档而言的产生过程。
[LDA模型的前世今生]
相比于传统的文本聚类方法LDA对于每个文档的每一个字都有一个主题下标也就是说LDA是没有一个文档统一的聚类标签而是每个字有一个聚类标签在这里就是主题。
LDA模型的训练一直是一个难点。传统上LDA的学习属于贝叶斯推断Bayesian Inference而在2000年初期只有MCMC算法Markov chain Monte Carlo马尔科夫链蒙特卡洛以及 VIVariational Inference变分推断作为工具可以解决。在最初的LDA论文里作者们采用了VI后续大多数LDA相关的论文都选择了MCMC为主的吉布斯采样Gibbs Sampling来作为学习算法。
LDA的扩展
当LDA被提出以后不少学者看到了这个模型的潜力于是开始思考怎么把更多的信息融入到LDA里面去。通过我们上面的讲解你可以看到LDA只是对文档的文字信息本身进行建模。但是绝大多数的文档数据集还有很多额外的信息如何利用这些额外信息就成为了日后对LDA扩展的最重要的工作。
第一个很容易想到的需要扩展的信息就是作者信息。特别是LDA最早期的应用对于一般的文档来说比如科学文档或者新闻文档都有作者信息。很多时候我们希望借用作者在写文档时的遣词造句风格来分析作者的一些写作信息。那么如何让LDA能够分析作者的信息呢
这里我们分享一篇论文《用于作者和文档信息的作者主题模型》The author-topic model for authors and documents[1]这是最早利用额外信息到LDA模型中的扩展模型。文章提出的模型叫作“作者LDA”Author LDA。这个模型的主要思想是每篇文档都会有一些作者信息我们可以把这些作者编码成为一组下标Index。对于每一个文档来说我们首先从这组作者数组中选出一个当前的作者然后假定这个作者有一组相对应的主题。这样文档的主题就不是每个文档随机产生了而是每个作者有一套主题。这个时候我们从作者相对应的主题分布中取出当前的主题然后再到相应的语言模型中采样到当前的单词。
可以看到作者LDA和普通的LDA相比最大的不同就是主题分布不是每个文档有一个而是每个作者有一个。这个主题分布决定着当前的单词是从哪一个语言模型中采样的单词。作者LDA也采用吉布斯采样的方法学习并且通过模型的学习之后能够看得出不同作者对于文档的影响。
从作者LDA之后大家看出了一种扩展LDA的思路那就是依靠额外的信息去影响主题分布进而影响文档字句的选择。这种扩展的方法叫作“上游扩展法”Upstream。什么意思呢就是说把希望对模型有影响的信息放到主题分布的上游去主动影响主题分布的变化。这其实是概率图模型的一种基本的思路那就是把变量放到这个产生式模型的上游使得下游的变量受到影响。
那你可能要问有没有把需要依赖的变量放到下游的情况呢答案是肯定的。我们再来看一篇论文《同时进行图像分类和注释》Simultaneous image classification and annotation[2]这篇文章就发明了一种方法。具体来说文章希望利用LDA到多模数据领域Multiple Modal。也就是数据中可能有文字也可能有图像还可能有其他信息。在这样的多模数据的情况下如何让LDA能够对多种不同的数据进行建模呢
这里面的基本思路就是认为所有的这些数据都是通过主题分布产生的。也就是说,一个数据点,我们一旦知道了这个数据点内涵的主题(比如到底是关于体育的,还是关于金融的),那么我们就可以产生出和这个数据点相关的所有信息,包括文字、图像、影音等。
具体到这篇文章提出的思路那就是这组数据的图像标签以及图像所属的类别都是主题产生的。我们可以看到和之前的作者LDA的区别那就是其他信息都是放在主题变量的下游的希望通过主题变量来施加影响。
这两种模型代表了一系列丰富的关于LDA的扩展思路那就是如何把扩展的变量设置在上游或者是下游从而能够对主题信息产生影响或者是受到主题信息的影响。
除此以外LDA的另外一大扩展就是把文档放到时间的尺度上希望去分析和了解文档在时间轴上的变化。这就要看经典的论文《动态主题模型》Dynamic topic models[3]。这篇论文最后获得了ICML 2010年的最佳贡献奖。那么我们怎么修改LDA使其能够理解时间的变化呢很明显还是需要从主题分布入手因为主题分布控制了究竟什么文字会被产生出来。因此我们可以认为主题分布会随着时间的变化而变化。
在之前的模型中我们已经介绍了每个文档的主题分布其实来自一个全局的狄利克雷Diriclet 先验分布。那么我们可以认为不同时间的先验分布是不一样的而这些先验分布会随着时间变化而变化。怎么能够表达这个思想呢作者们用到了一个叫“状态空间”State-Space的模型。简而言之状态空间模型就是把不同时间点的狄利克雷分布的参数给串起来使得这些分布的参数会随着时间的变化而变化。把一堆静态的参数用状态空间模型串接起来可以说是这篇文章开创的一个新的思维。
总结
今天我为你梳理了LDA的扩展模型。LDA的扩展当然还有很多我们今天讨论了几个非常经典的扩展思路分别是基于上游、下游和时间序列的LDA扩展模型。
一起来回顾下要点第一我们回顾了LDA这个模型的核心思想第二我们聊了如何把文档的其他信息融入到LDA模型中去以及如何对时间信息进行建模。
最后给你留一个思考题如果我们希望利用LDA来对“用户对商品的喜好”进行建模应该怎么对模型进行更改呢
参考文献
Michal Rosen-Zvi, Thomas Griffiths, Mark Steyvers, and Padhraic Smyth. The author-topic model for authors and documents. Proceedings of the 20th conference on Uncertainty in artificial intelligence (UAI 04). AUAI Press, Arlington, Virginia, United States, 487-494, 2004.
C. Wang, D. Blei., and L. Fei-Fei. Simultaneous image classification and annotation. Computer Vision and Pattern Recognition, 2009.
D.Blei and J.Lafferty. Dynamic topic models. Proceedings of the 23rd International Conference on Machine Learning, 2006.

View File

@ -0,0 +1,69 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
099 针对大规模数据如何优化LDA算法
周一我们分享了LDALatent Diriclet Allocation的各种扩展模型介绍了基于上游的和下游的两种把额外信息融入到LDA模型中的方法。同时我们也讨论了在时间尺度上如何把LDA模型扩展到可以“感知”不同的时间段对于模型的影响。以LDA为代表的主题模型在过去的十年间发展出了一整套的扩展为各式各样的应用场景提供了有力的工具。
尽管LDA在模型的表达力上给研究者们提供了把各种场景和模型结合的可能性但是LDA的训练过程比较复杂而且速度也比较慢。因此如何能够把LDA真正应用到工业级的场景中对于很多人来说都是一件煞费苦心的事情。今天我们就来聊聊LDA的算法优化问题。
LDA模型训练
我们首先来回顾一下LDA模型的训练过程从高维度上为你分析一下为什么这个过程很困难。
LDA模型中最重要的未知变量就是每个单词对应的主题下标Index或者说是主题“赋值”Assignment。这个主题下标是从每个文档对应的主题分布中“采样”得来的。每个文档的主题分布本身也是一个未知的多项式分布用来表达当前这个文档的所属主题比如有多少百分比属于运动、有多少百分比属于金融等等。这个分布是从一个全局的狄利克雷Diriclet分布中产生的。狄利克雷分布在这里起到了超参数的作用其参数的取值往往也是未知的。但是我们可以根据一些经验值对其进行设置。除了每个文档的主题分布和主题赋值以外我们还需要对全局的主题语言模型进行估计。这些语言模型直接决定了各类词语出现的概率是多少。
流行的LDA训练方法有两个一个是基于吉布斯采样Gibbs Sampling的随机方法一个是基于变分推断Variational Inference的确定性方法Deterministic。这两种方法的初始形态都无法应对大型数据。这里我们来简要介绍一下这两种方法。
吉布斯采样主要是针对主题赋值进行采样最开始是完全随机的结果但是慢慢会收敛到参数的后验概率的真值。这里面比较慢的一个原因是这个收敛过程可能需要几百到几千个不等的迭代。同时吉布斯采样只能一个文档一个文档进行所有的数据结构都需要在采样的过程中进行更改。这个过程比较慢的另外一个原因是吉布斯采样的核心是如何对一个离散分布进行采样。而离散分布采样本身如果在分布的参数变化的情况下最好能够达到OKlogK这里K是主题的数目。因此从原理上来说这也是阻碍吉布斯采样能够扩展到大型数据的一个原因。
变分推断的思路则和吉布斯采样很不一样。它是把对隐含参数的估计问题变成一个确定性的优化问题,这样我们就可以利用种种优化算法来解决贝叶斯推断的问题。不过和吉布斯采样相比,变分推断存在一个问题,因为这种方法并不是解决原来的优化问题,因此新的优化问题可能并不能带来原来问题的解。同时,变分推断也需要一个文档一个文档单独处理,因此推广到大规模数据上有其局限性。
LDA的大规模优化算法
顺着我们刚才提到的问题,为了把吉布斯采样和变分推断扩大到大规模数据上,学者们有针对性地做了很多探索。我们下面就分别对这两种思路展开简要的介绍。
首先,我们来看吉布斯采样。吉布斯采样慢的一个核心就是我们刚才说的,需要从一个离散分布中采样出一个样本,在我们这个例子中也就是每个单词的主题赋值。那么,有没有什么方法让这个步骤加速呢?答案是,有的。
在KDD 2009上发表了一篇论文《应用于流文档集合的主题模型推断的高效方法》Efficient methods for topic model inference on streaming document collections[1],算是在这方面取得突出成绩的一个重要参考文献。这篇论文的主要贡献就是,对原有的采样公式进行了一个比较仔细的分析。
作者们发现,原来的吉布斯采样公式可以被分解为几个部分:和全局的语言模型有关、和文档有关以及和当前需要采样的单词有关。这是一个非常有价值的观察,之后很多加速吉布斯采样的工作基本上都采用了类似的思路,也就是试图把原始的吉布斯采样公式拆分成好几个组成部分,并且每一个部分所代表数据的变化率是不一样的。
以这篇文章提出的方法来说,全局语言模型在每个文档的采样过程中是不变的,于是这部分的计算不需要每个单词都重算。同理,只与文档相关的部分,也可以每个单词的采样过程中,只算一次,而不需要每个主题算一次。在这样一个简化了的流程里,采样速度得到了极大的提升。
在这篇文章之后通过吉布斯采样这个方法LDA的采样速度还是没有得到明确的提升直到《降低主题模型的采样复杂度》Reducing the sampling complexity of topic models[2]这篇论文的出现。这篇论文获得了KDD 2014年的最佳论文奖。文章的思想还是针对吉布斯采样的公式不过这一次拆分的方法略不一样。作者们把采样的公式拆分成了与当前文档有关系的一部分以及和当前文档没关系的全局语言模型的部分。
同时作者们提出了一个“Alias方法”Alias Method简称A算法来加速采样。这个A算法其实并不是作者们为了LDA发明的而是一个普遍的可以对离散分布采样的一个算法。A算法的核心思想是如果我们要针对一个分布进行反复采样那么就可以建立一种数据结构使得这种采样只有在第一遍的时候有一定的计算成本而后都会以O(1)的成本进行采样。这个方法极大地加速了LDA通过吉布斯采样的效率。值得一提的是在这篇论文之后很多研究者发布了一系列的后续工作。
那么在变分推断的道路上,有没有什么方法能够加速呢?答案依然是肯定的。
这方面的代表作无疑就是论文《LDA的在线学习》Online learning for Latent Dirichlet Allocation[3]。
我们回到变分推断的场景中,把一个贝叶斯推断的问题变成了优化的问题。那么,在优化的场景里,是怎么针对大规模数据的呢?
在优化的场景里特别是基于梯度Gradient的优化方法中大数据的应用往往需要SGDStochastic Gradient Descent随机梯度下降的方法。通俗地讲就是在计算梯度的时候我们不需要处理完所有的数据之后才计算一次梯度而是针对每一个文档都可以计算一次梯度的估计值。
作者们其实就是把这个思想给搬到了变分推断里。总的来说新发明出来的变分推断其实就是希望能够推演出一种类似SGD的变分方法这种方法在后来的很多论文中都有所应用。
总结
今天我为你梳理了LDA优化算法的相关知识。
一起来回顾下要点第一我们聊了聊LDA这个模型的优化算法为什么会有难度特别是针对吉布斯采样和变分推断这两种思路来说难点在哪里第二我们分享了当前加速LDA算法的两种思路主要讨论了两种思路的一些核心思想希望能够起到抛砖引玉的作用。
最后给你留一个思考题除了在算法层面希望能够加速LDA以外我们能否利用并行化对LDA进行加速呢
参考文献
Limin Yao, David Mimno, and Andrew McCallum. Efficient methods for topic model inference on streaming document collections. Proceedings of the 15th ACM SIGKDD international conference on Knowledge discovery and data mining (KDD 09). ACM, New York, NY, USA, 937-946, 2009.
Aaron Q. Li, Amr Ahmed, Sujith Ravi, and Alexander J. Smola. Reducing the sampling complexity of topic models. Proceedings of the 20th ACM SIGKDD international conference on Knowledge discovery and data mining (KDD 14). ACM, New York, NY, USA, 891-900, 2014.
Matthew D. Hoffman, David M. Blei, and Francis Bach. Online learning for Latent Dirichlet Allocation. Proceedings of the 23rd International Conference on Neural Information Processing Systems - Volume 1 (NIPS10), 2010.

View File

@ -0,0 +1,57 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
100 基础文本分析模型之一:隐语义分析
本周我们分享了文本挖掘中的一个重要工具LDALatent Diriclet Allocation这是一个出色的无监督学习的文本挖掘模型。
今天,我们沿着文本分析这一方向继续展开。我们首先回到一个最基础的问题,那就是文本分析的基础模型都有哪些,这些最早的模型对后面的发展都有哪些贡献和启发?
带着这些问题我们一起来看一个叫“隐语义分析”Latent Semantic Indexing的技术。
隐语义分析的背景
为什么需要隐语义分析呢?隐语义分析到底发挥了怎样的历史性作用呢?
对于数据挖掘而言,文本数据算是大规模数据中,研究人员最早接触到的一类数据了。长久以来,大家都有一种直观的想法,那就是在这些看似没有头绪的文字中,究竟有没有隐含着某些规律呢?我们到底能不能从文字中提取出一些更加有用的结构性的内容呢?
对于文本分析,有一类是基于“显式”的标签来进行的。也就是说,我们可以把文本分析当作是监督学习的任务来看待。这一类文本分析的一大特点,往往是针对某一种任务建立分类器,然后对不同类别的文本进行鉴别,从而达到更加深入理解文本的目的。比如,我们需要理解不同情感的文字的时候,通常情况下,我们需要有一个数据集,能够告诉我们哪些文档是“正面情绪”的,哪些是“负面情绪”的。
然而,并不是所有的文本分析任务都是建立在有数据标签的基础之上。实际上,对于绝大多数文本数据而言,我们事先是并没有标签信息的。那么,在没有标签信息的场景下,如何对文本提取关键信息就成为了研究人员长期面对的一个关键挑战。
如果我们用今天的眼光来看,隐语义分析的核心其实就是用无监督的方法从文本中提取特性,而这些特性可能会对原文本的深层关系有着更好的解释。
其实从20世纪80年代发展出来的隐语义分析一直到今天利用深度学习技术来对文本的内涵进行分析其实质都是一样的都是看如何能够用无监督的方法提取文本特性一个重要的区别当然是在提取办法的差异上。
隐语义分析
对隐语义分析的一个简单直白的解释就是:利用矩阵分解的概念对“词-文档矩阵”Term-Document Matrix进行分解。
在前面介绍推荐系统的时候,我们已经看到了矩阵分解可以认为是最直接的一种针对矩阵数据的分析方式。
那么,为什么我们需要对矩阵进行分解呢?
这里面的一个隐含的假设就是,“词-文档矩阵”是一个稀疏矩阵。什么意思意思就是从大规模的文字信息来说文字服从一个叫“幂定律”Power Law Distribution的规律。那就是绝大多数的单词仅出现很少的次数而少数的单词会出现在很多文档中。我们也可以理解成一种变形的“20/80”原理也就是20%的单词出现在80%的文档中。当然,文字的幂定理规则的一个直接结果就是“词-文档矩阵”是稀疏矩阵。这个矩阵里面有大量的零,代表很多单词都没有出现在那些文档中。
对一个稀疏矩阵我们往往假设原有的矩阵并不能真正表示数据内部的信息。也就是说我们认为可能会有一个结构存在于这个矩阵之中。而这个假设就是我们经常会在矩阵分解这个语境中提到的“低维假设”Low-rank Approximation。你不必去担心这个低维假设的本质意义我们只需要理解这个低维假设的核心就是我们可以用比较少的维度来表达原来的这个稀疏的矩阵。
试想我们拥有一个N乘M的“词-文档矩阵”也就是说我们有N个单词M个文档。在这个稀疏矩阵的数据中矩阵分解的基本思想是希望得到一个N乘以K的单词矩阵以及一个K乘以M的文档矩阵。K是一个事先指定好的参数这也是矩阵分解的一个核心问题那就是如何选择这个K。我们可以看到这种分解能够还原之前的N乘以M的“词-文档矩阵”。
那么这两个新的矩阵有什么“含义”呢人们通过对很多数据的分解以后发现单词矩阵往往能够把一些在某种语境下的单词给聚拢。比如我们会发现很多和体育相关的词会聚拢在某个维度下而很多和金融相关的词会聚拢在另外一个维度下。慢慢地大家就开始把每一个维度认定为一个“主题”。那么基于矩阵分解的隐语义分析其实就是最早的主题模型。而文档矩阵则描述了不同文档在我们K个主题下的强度。
值得注意的是我们这里为了介绍隐语义模型的实际意义而隐藏了一些实际的技术细节。从历史上看比较流行的隐语义模型其实是基于“奇异值分解”Singular Value Decomposition也就是我们常常听到的SVD分解。由于篇幅有限我们这里就不针对SVD分解展开讨论了。即便是SVD分解其核心思想依然是我们刚才讲到的分解出来的主题矩阵。
基于矩阵分解的隐语义模型也有其局限性最大的一个问题就是分解出来的矩阵本身都是实数也就是有负数和正数这也限制了我们真正用这些数来进行一些含义的推断。然而即便如此在很长的一段时间里基于SVD的隐语义模型可以说是标准的无监督文本挖掘的核心算法。
总结
今天我为你介绍了基于矩阵分解的隐语义模型的相关知识。
一起来回顾下要点:第一,我们聊了聊为什么需要隐语义模型;第二,我们聊了一下基于矩阵分解的隐语义模型的核心思想及其局限。
最后,给你留一个思考题,如果我们要限制矩阵分解的结果是非负数,我们应该怎么做呢?

View File

@ -0,0 +1,51 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
101 基础文本分析模型之二:概率隐语义分析
在上一篇的分享里我们展开了文本分析这个方向讨论了“隐语义分析”Latent Semantic Indexing这个模型。隐语义分析的核心是基于矩阵分解的代数方法。这种方法的好处自然是能够直接利用代数计算方法对文本进行分析而短板则是无法很好地解释结果。而“解释性”是很多概率模型的一大优势因此自然就有很多研究者想到是否能够把概率的语言移植到隐语义分析上。
今天我们就来分享“概率隐语义分析”Probabilistic Latent Semantic Indexing的一些基本内容。概率隐语义分析有时候又被简称为 PLSAProbability Latent Semantic Analysis
隐语义分析核心思想
上周我们介绍过隐语义分析的核心思想,首先来简要回顾一下。
隐语义分析的核心其实就是用无监督的方法从文本中提取特性,而这些特性可能会对原来文本的深层关系有着更好的解释。
简单来说,隐语义分析就是利用了“矩阵分解”的概念,从而对“词-文档矩阵”Term-Document Matrix进行分解。
概率隐语义分析
既然概率隐语义分析是利用概率的语言,那么我们就来看看概率隐语义分析是如何对文档进行建模的。
首先PLSA是对文档和里面单词的联合分布进行建模。这个文档和单词的联合分布其实就是类似隐语义分析中的那个文档和单词的矩阵。只不过在PLSA里我们不是直接对数据进行建模而是认为数据是从某个分布中产生的结果。那么对于这个联合分布该如何建模呢
一种方法就是对这个联合分布直接进行建模,但是这往往会遇到数据不足的情况,或者无法找到一个合适的已知参数的分布来直接描述我们需要建模的这个联合分布。另外一种经常使用的方法就是简化这个联合分布,从而找到我们可以建模的形式。
那么,如何简化联合分布呢?一种方法就是把一个联合分布进行分解。
一种分解分布的方法就是假定一些隐含的变量然后数据又是从这些隐含变量中产生而来。在我们现在的情况里我们从文档和单词的联合分布入手可以做出这样的假设这个文档和单词的联合分布是我们首先从文档出发产生了当前所需要的主题比如金融、运动等然后再从主题出发产生相应的单词。很明显这里的主题是我们并不知道的隐含变量是需要我们从数据中估计出来的。这就是PLSA模型的基本假设。
PLSA还有一个等价的模型描述也是对文档单词联合分布的另外一种分解那就是我们首先假设有一个主题的先验概率然后根据这个主题的分布产生了一个文档同时也产生了这个文档里面的所有单词。这种假设观点非常类似我们之前在介绍高级的主题模型时谈到的“下游方法”Down-Stream。这里文档变量和单词变量都成为了隐变量也就是主题变量的下游变量。
通过一定的代数变形,我们可以得到这两种方法其实就是等价的。
如果我们按照第一种分解方法来认识文档单词分布有一种更加通俗的解释我们其实是给每一个单词都联系了一个未知的主题变量这个主题变量是从一个文档级别的主题分布得来的实际上这是一个多项分布Multinomial Distribution然后根据这个主题变量我们又从相应的一个语言模型中抽取出了当前的单词这又是另外的一个多项分布。如果从这个角度来看待这个模型你会发现PLSA其实和LDA非常相似。
实际上从模型的根本特征上来说PLSA和LDA都是对文档单词分布的一种分解或者叫作产生解释。只不过LDA针对刚才我们所说的两个多项分布一个是每个文档的主题分布另外一个是K个语言模型都外加了先验分布使得整个模型更加符合贝叶斯统计的观点。然而在建模的核心思想上PLSA和LDA是一样的。
关于如何学习PLSA这样的隐变量模型我将会在后面的分享中和你详细讨论。
总结
今天我为你介绍了基于概率模型的隐语义模型的相关知识。
一起来回顾下要点第一我们简要回顾了隐语义模型的重要性第二我们讨论了基于概率语言的隐语义模型的核心思想以及PLSA和LDA的联系和区别。
最后给你留一个思考题PLSA的建模流程有没有什么局限性

View File

@ -0,0 +1,59 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
102 基础文本分析模型之三EM算法
周一我们分享的模型是“概率隐语义分析”Probabilistic Latent Semantic Indexing或者简称为PLSA这类模型有效地弥补了隐语义分析的不足在LDA兴起之前成为了有力的文本分析工具。
不管是PLSA还是LDA其模型的训练过程都直接或者间接地依赖一个算法这个算法叫作“期望最大化”Expectation Maximization或简称为 EM算法。实际上EM算法是针对隐参数模型Latent Variable Model最直接有效的训练方法之一。既然这些模型都需要EM算法我们今天就来谈一谈这个算法的一些核心思想。
EM和MLE的关系
EM算法深深根植于一种更加传统的统计参数方法最大似然估计Maximum Likelihood Estimation有时候简称为 MLE。绝大多数的机器学习都可以表达成为某种概率模型的MLE求解过程。
具体来说MLE是这样构造的。首先我们通过概率模型写出当前数据的“似然表达”。所谓的“似然”表达其实也就是在当前模型的参数值的情况下看整个数据出现的可能性有多少。可能性越低表明参数越无法解释当前的数据。反之如果可能性非常高则表明参数可以比较准确地解释当前的数据。因此MLE的思想其实就是找到一组参数的取值使其可以最好地解释现在的数据。
针对某一个模型写出这个MLE以后就是一个具体的式子然后看我们能否找到这个式子最大值下的参数取值。这个时候整个问题往往就已经变成了一个优化问题。从优化的角度来说那就是针对参数求导然后尝试把整个式子置零从而求出在这个时候的参数值。
对绝大多数相对比较简单的模型来说我们都可以根据这个流程求出参数的取值。比如我们熟悉的利用高斯分布来对数据进行建模其实就可以通过MLE的形式写出用高斯建模的似然表达式然后通过求解最优函数解的方式得到最佳的参数表达。而正好这个最优的参数就是样本的均值和样本的方差。
然而并不是所有的MLE表达都能够得到一个“解析解”Closed Form Solution有不少的模型甚至无法优化MLE的表达式那么这个时候我们就需要一个新的工具来求解MLE。
EM算法的提出就是为了简化那些求解相对比较困难模型的MLE解。
有一点需要说明的是EM算法并不能直接求到MLE而只能提供一种近似。多数无法直接求解的MLE问题都属于非凸Non-Convex问题。因此EM能够提供的仅仅是一个局部的最优解而不是全局的最优解。
EM算法的核心思想
理解了EM和MLE的关系后我们来看一看EM的一些核心思想。因为EM算法是技术性比较强的算法我建议你一定要亲自去推演公式从而能够真正理解算法的精髓。我们在这里主要提供一种大体的思路。
EM算法的一种解释是这样的。首先我们可以通过代数变形为每一个数据点的似然公式找到一个新的概率分布而这个概率分布是通过一个隐含变量来达到的。很明显在理论上我们可以通过把这个隐含变量积分掉来达到恢复原始的MLE公式的目的。
然而这里遇到的一个大的阻碍就是在MLE公式里面有一个求对数函数log在这个积分符号外面。这就导致整个式子无法进行操作。通俗地讲EM就是要针对这样的情况试图把这个在积分符号之外的求对数函数拿到积分符号里面。能够这么做是因为有一个不等式叫“杨森不等式”。你不需要去理解杨森不等式的细节大体上这个不等式是说函数的期望值要大于或等于先对函数的变量求期望然后再对其作用函数。
于是在这样的一个不等式的引领下我们刚才所说的积分其实就可以被看作是对某一个函数求期望值。而这个函数恰好就是模型的似然表达。通过杨森不等式我们可以把对数函数拿到积分符号里面这样当然就无法保持等号了也就是说这一步的操作不是一个等值操作。利用杨森不等式之后的式子其实是原来的式子也就是含有隐含变量的MLE式的一个“下限”Lower Bound
利用杨森不等式从而写出一个原始的MLE的下限是标准的EM算法以及一系列基于变分EMVariational EM算法的核心思想。这么做的目的其实就是把对数函数从积分的外面给拿到里面。
当我们有了这个下限之后我们就可以套用MLE的一切流程了。注意这时候我们有两组未知数。一组未知数是我们模型的参数另外一组未知数就是模型的隐含变量。于是当得到下限之后我们就需要对这两组未知数分别求导并且得到他们的最优表达。
当我们按照当前的模型参数对模型的隐含变量所对应的概率分布求解后最优的隐含变量的概率分布就等于隐含变量基于数据的后验概率。什么意思呢意思就是说如果我们把隐含变量的取值直接等于其后验概率分布就得到了当前的最优解。这个步骤常常被叫作“E步”。
在进行了E步之后我们再按照当前的隐含变量求解这个时候最佳的模型参数。这常常被认为是“M步”。一次E步一次M步则被认为是EM算法的一个迭代轮回。
EM算法貌似很神秘但如果我们理解了整个流程的精髓就可以把这个算法总结为EM算法是利用杨森不等式得到MLE的一个下限并且优化求解模型参数和模型的隐含变量的一个过程。
掌握了这个精髓我们就可以看到为什么LDA和PLSA等隐变量模型需要利用EM或者类似EM的步骤进行求解。第一这些模型的MLE都有一个对数函数在积分符号外面使得这个过程无法直接求解。第二这些模型本身就有隐含变量因此不需要额外制造新的隐含变量。
总结
今天我为你介绍了一个经常用于求解概率图模型的EM算法。
一起来回顾下要点第一我们回顾了EM算法和MLE算法的关系第二我们讨论了EM算法的核心思想。
最后给你留一个思考题EM算法在实际应用中有哪些问题呢

View File

@ -0,0 +1,57 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
103 为什么需要Word2Vec算法
至此,关于文本分析这个方向,我们已经介绍了 LDALatent Diriclet Allocation这是一个出色的无监督学习的文本挖掘模型。还有“隐语义分析”Latent Semantic Indexing其核心是基于矩阵分解的代数方法。接着我们分享了“概率隐语义分析”Probabilistic Latent Semantic Indexing这类模型有效地弥补了隐语义分析的不足成为了在LDA兴起之前的有力的文本分析工具。我们还介绍了EMExpectation Maximization算法这是针对隐参数模型最直接有效的训练方法之一。
今天我们进入文本分析的另外一个环节介绍一个最近几年兴起的重要文本模型Word2Vec。可以说这个模型对文本挖掘、自然语言处理、乃至很多其他领域比如网络结构分析Network Analysis等都有很重要的影响。
我们先来看Word2Vec的一个最基本的形式。
Word2Vec背景
了解任何一种模型或者算法都需要了解这种方法背后被提出的动机,这是一种能够拨开繁复的数学公式从而了解模型本质的方法。
那么Word2Vec的提出有什么背景呢我们从两个方面来进行解读。
首先我们之前在介绍LDA和PLSA等隐变量模型的时候就提到过这些模型的一大优势就是在文档信息没有任何监督标签的情况下能够学习到文档的“隐含特性”。这也是文档领域“表征学习”Representation Learning的重要工具。遗憾的是不管是LDA还是PLSA其实都是把文档当作“词包”Bag Of Word然后从中学习到语言的特征。
这样做当然可以产生不小的效果,不过,从自然语言处理,或者是文档建模的角度来说,人们一直都在探讨,如何能够把单词的顺序利用到学习表征里。什么意思呢?文档中很重要的信息是单词的顺序,某一个特定单词组合代表了一个词组或者是一个句子,然后句子自然也就代表着某种语义。词包的表达方式打破了所有词组顺序以及高维度的语义表达,因此长期以来被认为并不能真正学习到语言的精华。
然而,在主题模型这个大旗帜下,已经有不少学者和研究员试图把词序和语义给加入到模型中,这些尝试都没有得到很好的效果,或者模型过于复杂变得不适用。于是,大家都期待着新的工具能够解决这方面的问题。
另外一个思路也是从词包发展来的。词包本身要求把一个词表达成为一个向量。这个向量里只有一个维度是1其他的维度都是0。因为每个词都表达成为这样离散的向量因此词与词之间没有任何的重叠。既然两个离散的向量没有重叠我们自然也就无法从这个离散的词包表达来推断任何词语的高维度语义。这也是为什么大家会利用主题模型从这个离散的词包中抽取主题信息从而达到理解高维度语义的目的。
既然我们的目的是从离散的词包中获取更加丰富的信息那有没有另外的方法或者途径能够达到这个目的呢一种基本的假设是这样的如果我们能够从离散的向量里面抽取出每个词组的“连续”Continuous信息向量假设两个词有相近的意思那么这两个词的联系向量势必就会比较相近这样我们就能够通过词向量只不过是连续向量来得到词汇的高级语义信息。这个假设常常被叫作词的“分布假设”Distributed Assumption
了解了以上这两个方面后我们再来理解Word2Vec可能就比较容易明白这个模型究竟想要干什么了。
Word2Vec模型摘要
首先我们需要说明的是Word2Vec是一种语言模型主要是根据当前的语境来预测下一个单词出现的概率也就是和我们之前所说的产生式模型相似看是否能够从模型中产生单词。这和我们介绍的主题模型是不一样的在这个模型里我们并没有假定数据也就是单词是从某几个主题中产生的。
Word2Vec的核心思想是当前的单词是从周边单词的隐含表达或者说是词向量中产生的。也就是说每一个单词都依赖于上下文而这个单词的产生并不是直接依赖周围单词的离散表达而是依赖周边单词的连续表达。这个连续表达自然是事先不知道的因此这就是Word2Vec模型需要学习的未知参数。
在具体的操作上Word2Vec有两个不太一样的模型但是经常被同等程度地使用。我们这里做一个简单的介绍。
第一种模型叫作Skip-Gram或者简称SG模型。这种模型的输入是一个词输出是这个词周围的词。这样做的目的是看我们能否用当前的词来预测周围的词。要想让这个任务有很好的表现当前词的表征必须能够抓住某种语义的信息。具体来说我们就是用当前词的表征向量和所有其他词的表征向量做点积然后再重新归一。这个过程就能够保证当前词的表征向量和周围词的表征向量相似。这样也就解决了我们之前提到的如何能够把词序影响到词的表征向量中。
另外一种模型叫作Continuous-Bag-of-Word有时候简称CBOW模型。这种模型刚好和SG是相反的也就是输入是一组词汇而希望能够通过这组词汇得到中间某个词的预测。和我们刚才所说的一样这个模型也是基于我们并不知道的表征向量来达到模型学习的目的。
不管是SG还是CBOW本质上就是希望能够利用文章的上下文信息学习到连续空间的词表达这是Word2Vec所有模型的核心。
SG和CBOW在具体的应用中常常需要比较复杂的训练算法我们这里就不展开讨论了。如果你有兴趣可以进一步阅读一些论文。
总结
今天我为你介绍了Word2Vec模型的基本含义。
一起来回顾下要点第一我们介绍了Word2Vec这个模型是怎么被开发出来的它背后有哪些原理第二我们讨论了SG和CBOW这两种非常典型的Word2Vec模型。
最后给你留一个思考题和LDA相比Word2Vec好在哪里又有什么不足的地方

View File

@ -0,0 +1,63 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
104 Word2Vec算法有哪些扩展模型
从上一期的分享开始我们进入到文本分析的另外一个环节那就是介绍一个最近几年兴起的重要文本模型Word2Vec。这个模型对文本挖掘、自然语言处理等很多领域都有重要影响。我们讨论了Word2Vec模型的基本假设主要是如何从离散的词包输入获得连续的词的表达以及如何能够利用上下文从而学习到词的隐含特性。我们还聊了两个Word2Vec模型SGSkipGram模型和CBOWContinuous-Bag-of-Word模型讨论了它们都有什么特性以及如何实现。
今天我们就来看一看Word2Vec的一些扩展模型。
Word2Vec的扩展思路
在列举几个比较知名的Word2Vec扩展模型之前我们首先来看看这个模型怎么进行扩展。
首先我们来回忆一下Word2Vec的一个基本的性质那就是这是一个语言模型。而语言模型本身其实是一个离散分布模型。我们一起来想一想什么是语言模型语言模型就是针对某一个词库这里其实就是一个语言的所有单词然后在某种语境下产生下一个单词的模型。也就是说语言模型是一个产生式模型而且这个产生式模型是产生单词这一离散数据的。
既然是这样如果我们更改这个词库变成任何的离散数据那么Word2Vec这个模型依然能够输出在新词库下的离散数据。比如如果我们把词汇库从英语单词换成物品的下标那Word2Vec就变成了一个对物品的序列进行建模的工具。这其实就是扩展Word2Vec的一大思路那就是如何把Word2Vec应用到其他的离散数据上。
扩展Word2Vec的第二大思路则是从Word2Vec的另外一个特性入手上下文的语境信息。我们在之前的介绍中也讲过这个上下文信息是Word2Vec成功的一个关键因素因为这样就使得我们学习到的词向量能够表达上下文的关联所带来的语义信息。这也是传统的主题模型Topic Model例如LDA或者PLSA所不具备的。那么我们能不能对这个上下文进行更换从而使得Word2Vec能够产生完全不一样的词向量呢答案是肯定的这也是Word2Vec扩展的重要思路。
除此以外还有一个重要的分支那就是很多研究者都希望往Word2Vec里增加更多的信息比如文档本身的信息段落的信息以及其他的辅助信息。如何能够让Word2Vec对更多信息建模也是一个重要的扩展思路。
Word2Vec的三个扩展
我们要介绍的第一个扩展是由Word2Vec作者本人提出的就是把学习词向量的工作推广到句子和文章里在论文《句子和文档的分布表示》Distributed representations of sentences and documents[1]里进行了详细的阐述。这个扩展主要是解决如何能够更加“自然”地学习到比词这个单位更大的单位(比如段落或者文档)的隐含向量。
当Word2Vec被发明之后很多研究者都发现这是一个能够把离散的词表达成连续向量的利器。然而一个应用场景很快就成为了大家的拦路虎那就是Word2Vec仅仅是在词一级数据上进行建模却无法直接得到文档一级的隐含信息。
有一种做法是这样的,比如针对一个句子或者一个段落,我们就把这个句子里的词所使用的词向量加权平均,认为这个加权平均过的结果就是段落的向量了。很明显,这是一种非常不精确的处理方法。
那么这篇文章的核心则是如何能够在模型本身上进行修改从而可以学习到比词更加高一层级单元的隐含向量。具体的做法就是修改原始Word2Vec的上下文信息。我们回忆一下SG模型和CBOW模型都有一个关键的信息那就是利用上下文也就是一个句子周围的词来预测这个句子或者上下文中间的一个词。这就是Word2Vec能够利用上下文信息的原因。那么这里的修改就是让这个上下文始终都有一个特殊的字符也就是当前段落或者文章的下标从而这个下标所对应的隐含向量就是我们所要学习到的段落或者文档的向量。在这样的情况下作者们通过实验发现学到的段落向量要比单独用加权平均的效果好得多。
我们要看的第二个扩展来自论文《线大规模信息网络嵌入》LINE: Large-scale Information Network Embedding[2]就是把Word2Vec的思想扩展到了另外一种离散数据图Graph的表达上。
刚才我们提到只要是离散的数据Word2Vec都有可能被应用上。那么图的数据建模的场景是什么呢我们设想一个社交网络Social Network的数据。每一个用户都有可能和其他用户相连而两两相连的用户所组成的整个网络就是社交网络的庞大的用户信息。很明显如果我们把用户看作单词那么整个社交网络就是一个单词和单词的网络。如果我们把两个单词在这里是用户之间的连线看成是单词出现在一起的上下文那么我们其实就可以利用Word2Vec这样的模型对社交网络所表达图进行建模。这就是这篇文章里作者们利用Word2Vec对社交网络建模的核心思想。
当然,和在文档中不同,在图里面,上下文这一关系其实是比较难以定义的。因此,很多后续的工作都是关于如何更加有效地定义这一上下文关系。
最后我们结合论文《用于支持搜索中查询重写的上下文和内容感知嵌入》Context- and Content-aware Embeddings for Query Rewriting in Sponsored Search[3]来看另一个Word2Vec的扩展。这个扩展是尝试在查询关键词和用户点击的网页之间建立上下文关系使得Word2Vec模型可以学习到查询关键词以及网页的隐含向量。
这也就是我们提到的巧妙地搭建上下文关系使得模型可以学习到离散数据的隐含表达。你可能比较好奇这里的离散数据是什么呢这里有两组离散数据第一组就是每一个查询关键词这完全可以按照Word2Vec原本的定义来走第二组离散数据就是每一个网页。注意这里不是网页的内容而是某一个网页作为一个下标。那么从模型的角度上来说这里我们要做的就是利用查询关键词来预测网页出现的概率。
总结
今天我为你介绍了Word2Vec模型扩展的一些基本思路和一些实际的案例。
一起来回顾下要点第一我们讨论了Word2Vec这个模型需要扩展的思路比如从离散数据入手或者从上下文入手第二我们分享了三个比较经典的Word2Vec扩展。
最后给你留一个思考题Word2Vec能否扩展到连续数据中呢
参考文献
Quoc Le and Tomas Mikolov. Distributed representations of sentences and documents. Proceedings of the 31st International Conference on International Conference on Machine Learning - Volume 32 (ICML14), Eric P. Xing and Tony Jebara (Eds.), Vol. 32. JMLR.org II-1188-II-1196, 2014.
Jian Tang, Meng Qu, Mingzhe Wang, Ming Zhang, Jun Yan, and Qiaozhu Mei. LINE: Large-scale Information Network Embedding. Proceedings of the 24th International Conference on World Wide Web (WWW 15). International World Wide Web Conferences Steering Committee, Republic and Canton of Geneva, Switzerland, 1067-1077, 2015.
Mihajlo Grbovic, Nemanja Djuric, Vladan Radosavljevic, Fabrizio Silvestri, and Narayan Bhamidipati. Context- and Content-aware Embeddings for Query Rewriting in Sponsored Search. Proceedings of the 38th International ACM SIGIR Conference on Research and Development in Information Retrieval (SIGIR 15). ACM, New York, NY, USA, 383-392, 2015.

View File

@ -0,0 +1,45 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
105 Word2Vec算法有哪些应用
周一我们分享了三个比较有代表意义的Word2Vec的扩展模型主要有两种思路从词的上下文入手重新定义上下文或者对完全不同的离散数据进行建模。
今天我们来看一看Word2Vec在自然语言处理领域的应用。如果我们已经通过SG模型、CBOW模型或者其他的算法获得了词向量接下来我们可以把这些词向量用于什么样的任务中呢
Word2Vec的简单应用
最直接的也是最常见的Word2Vec应用就是去计算词与词之间的相似度。当我们的数据还是原始的“词包”Bag of Word这时候是没法计算词与词之间的相似度的因为每个词都被表示为某个元素为1其余元素都为0 的离散向量。按照定义两个离散向量之间的相似度都是0。因此从词包出发我们无法直接计算词与词之间的相似度这是从定义上就被限制了的。
Word2Vec就是为了跨越这个障碍而被发明的这一点我们在前面就已经提到过了。所以当我们可以用Word2Vec的词向量来表示每一个单词的时候我们就可以用“余弦相关度”Cosine Similarity来对两个词向量进行计算。余弦相关度其实就是计算两个向量的点积然后再归一化。如果针对已经归一化了的向量我们就可以直接采用点积来表达两个向量的相关度。不管是余弦相关度还是点积我们都假设计算结果的值越大两个词越相关反之则不相关。
既然我们可以计算两个词的相关度那么很多依赖相关度的任务就都能够轻松完成。比如我们希望把词进行聚类也就是说把相关的词都聚合在一起。通常的聚类算法都可以直接使用比如我们熟悉的“K均值”算法。这些算法的核心是计算两个数据点的距离就可以利用我们刚刚讲的余弦相关度来实现。
我们在谈Word2Vec扩展模型的时候曾经提到了一些扩展模型可以用于表达比词这个单位更大的文本单元比如段落和文档向量的获取。其实当时我们就提到了一种可以得到这些单元向量的简单方法那就是直接利用Word2Vec来进行加权平均。在获得了词向量之后我们就可以用一个文档里所有词的加权平均甚至是简单的叠加来达到表达文档的目的。这个时候我们也就可以利用诸如余弦相关度来计算文档之间的相关度了。
另外一个随着Word2Vec的推出而大放异彩的应用则是“词语的类比”。Word2Vec的原作者们用类比来表达这种词向量能够完成一些与众不同的任务。词向量本质上就是一个连续空间的向量因此从数学上来说这种向量其实可以进行任何“合规”的运算比如加、减、乘、除。于是作者们就利用向量的加减关系来看能否得到有意义的结果而得到的结果令人吃惊。
比如如果我们把“国王”King这个单词的向量减去“男人”Man这个向量然后加上“女人”Woman这个向量得到的结果竟然和“王后”Queen这个向量非常相近。类似的结果还有“法国”France减去“巴黎”Paris加上“伦敦”London等于“英格兰”England等。对于传统的方法来说这样的行为是无法实现的。因此Word2Vec的流行也让这种词语的类比工作变得更加普遍起来。
Word2Vec的其他使用
在自然语言处理中有一系列的任务之前都是依靠着“词包”这个输入来执行的。当我们有了Word2Vec之后这些任务都可以相对比较容易地用“词向量”来替代。
一类任务就是利用词来进行某种分类任务。比如,我们希望知道某些文档是属于什么类别,或者某些文档是不是有什么感情色彩,也就是通常所说的“基于监督学习的任务”。词向量会成为很多文本监督学习任务的重要特性。在诸多的实验结果中,得到的效果要么好于单独使用词包,要么在和词包一起使用的情况下要好于只使用词包。
在进行监督学习的任务中词向量的重要性还体现于其对深度学习架构的支持。众所周知即便是最简单的深度学习架构比如多层感知器都需要输入的数据是连续的。如果我们直接面对离散的文本数据常常需要把这些离散的文本数据学习成为连续的向量其实就是在学习Word2Vec。经过了这一层的转换之后我们往往再利用多层的神经网络结果对这些信号进行处理。
在很多实践中人们发现与其利用不同的任务来学习相应的词向量还不如直接拿在别的地方学好的词向量当做输入省却学习词向量这一个步骤而结果其实往往会有效果上的提升。这种使用词向量的方法叫作“提前训练”Pre-trained的词向量。其实不仅仅是在简单的多层感知器中甚至是在RNN等更加复杂的深度架构中也都更加频繁地利用提前训练的词向量。
总结
今天我为你介绍了Word2Vec模型在各种实际任务中的应用。
一起来回顾下要点第一我们聊了Word2Vec这个模型的一些简单应用比如如何计算词与词之间的相关度以及如何进行词的类比计算第二我们讨论了如何利用词向量进行更加复杂的自然语言任务的处理。
最后给你留一个思考题Word2Vec和主题模型提供的向量是互补的还是可以相互替换的呢

View File

@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
106 序列建模的深度学习利器RNN基础架构
前面我们介绍了一个重要的文本模型Word2Vec我们聊了这个模型的基本假设模型实现一些重要的扩展以及其在自然语言处理各个领域的应用。
接下来,我们来讨论更加复杂的基于深度学习的文本分析模型。这些模型的一大特点就是更加丰富地利用了文字的序列信息,从而能够对文本进行大规模建模。
今天,我们首先来看一看,序列建模的深度学习利器 RNNRecurrent Neural Network递归神经网络的基本架构。
文本信息中的序列数据
我们在之前介绍Word2Vec的时候讲了为什么希望能够把上下文信息给融入到模型当中去。一个非常重要的原因就是在最早的利用“词包”Bag of Word的形式下离散的词向量无法表达更多的语义信息。那么从文本的角度来讲很多研究人员都面对的困扰是如何对有序列信息的文本进行有效的建模同时对于广大文本挖掘的科研工作者来说这也是大家心中一直深信不疑的一个假设那就是对文字的深层次的理解一定是建立在对序列、对上下文的建模之中。
你可能有一个疑问,文字信息中真的有那么多序列数据吗?
其实从最简单的语义单元“句子”出发到“段落”到“章节”再到整个“文章”。这些文字的组成部分都依赖于对更小单元的序列组合。例如句子就是词语的序列段落就是句子的序列章节就是段落的序列等等。不仅是“词包假设”无法对这样的序列进行建模就算是我们之前提到的Word2Vec等一系列学习词向量或者段落向量的方法也仅仅能考虑到一部分的上下文信息。
还有更加复杂的文字序列,比如对话。人与人的对话很明显是有顺序的。两个人之间进行对话,当前所说的字句都是根据对方的回应以及整个对话的上下文所做出的选择。如果要对这样复杂的文字序列进行建模,传统的不考虑序列的模型方法是肯定不能奏效的。
那么,传统的机器学习领域,有没有能够对时序信息建模的工具或者模型呢?
传统机器学习中的序列模型
在传统的机器学习领域当然有不少模型是针对序列进行建模的。最经典的要数“隐马尔科夫模型”Hidden Markov Model有时候又简称为 HMM。在比较长的一段时间里HMM都是常用的对序列建模的缺省Default模型。我们今天的分享不是专门针对HMM模型但是对HMM的一个简单介绍有助于我们理解为什么需要RNN。
HMM的一个最基本的假设是当前的序列数据是根据一些隐含的状态产生的。具体来说HMM的架构是这样的。我们认为每个时间点都有一个对应的隐含状态。这个隐含状态只与当前这个时间点之前的时间点所对应的隐含状态有关联。更加简单的假设也是经常使用的假设则认为当前时间点的隐含状态仅仅与之前最直接相邻的一个时间点的隐含状态有关而和之前所有时间点的隐含状态都没有关系了。这类似于说今天的天气仅仅与昨天有关和昨天之前的天气状态都没有任何关系显然这是一个很强的假设。
从时间轴这个角度来说HMM是构建了一个隐含状态的一阶马尔科夫链条。这里“一阶”是指每个状态仅与当前最邻近的状态有关。当我们构建好了隐含状态以后就可以在这个基础上对数据进行建模了。
HMM假定每个时间点的数据都是从这个时间点的隐含状态产生的而时间点所对应的数据之间并不直接产生关系。也就是说我们假定产生数据的原因是隐含状态而隐含状态已经通过一个链条给串联起来了这样我们就不需要针对数据进行串联了。
HMM虽然理解起来相对比较直观但在实际应用中存在不少问题。比如这个模型的表现力有限。我们刚才说了一个普通的HMM假定了隐含状态的一阶性质使得我们不能对比较长的序列进行建模因为模型无法对其中所有的隐含状态的转换进行建模一阶无法表达这样的关系。当然HMM有一阶以上的表达但这也就带来了HMM的一个普遍的问题就是训练方法比较复杂。对于一个现实问题而言HMM的建模会相对比较复杂从而让训练方法更加繁复。这也就是为什么HMM不能适用于大规模问题的一个主要的原因。
RNN的基本架构
在HMM的基础上我们再来看一下RNN的基本思想。
首先我们需要指出的是RNN并不是一个模型的称呼而是一个框架。具体在这个框架内部根据不同的需求我们可以构造出非常不一样的模型。
第二RNN的一大优势是它根植于深度学习的大范畴中。RNN的模型都可以算是特殊的深度学习模型。所以深度学习模型的很多优化算法或者整体的计算方式也都可以无缝嫁接到RNN上。从这一点来看RNN比传统的HMM就有很大的相对优势。
通常来说一个RNN有一个输入序列X和输出序列Y这两个序列都随着时间的变化而变化。也就是说每一个时间点我们都对应这一个X和一个Y。和HMM类似的是RNN也假定X和Y都不独立发生变化而他们的变化和关系都是通过一组隐含状态来控制的。
具体来说时间T时刻的隐含状态有两个输入一个输入是时间T时刻之前所有的隐含状态一个输入是当前时刻也就是时间T时刻的输入变量X。时间T时刻的隐含状态根据这两个输入会产生一个输出这个输出就是T时刻的Y值。也就是说T时刻的输出是根据之前所有的隐含状态和这个时刻的输入决定的。
在一些简化的情况下并不是每一个时刻都有输出的信息。比如我们需要对一个句子进行分类这个时候一个输出变量只在整个句子结束的时候出现。那么在这样的情况下Y仅仅在最后的一个时刻出现。
这个RNN的参数也就是这些隐含状态。从原理上来说可以根据标准的深度学习框架的流程加以学习。
RNN的整个框架还可以看作是一个加码解码的过程。从已知的序列到中间隐含状态这是一个加码的流程而从隐含状态到最后的输出序列这是一个解码的过程。具体的RNN针对这个加码解码的过程有更加详细的分工我会在今后的分享中为你慢慢解读。
总结
今天我为你介绍了文本序列建模利器RNN的一个概况。
一起来回顾下要点第一我们讨论了为什么需要对文本的序列数据进行建模第二我们聊了聊传统机器学习模型是如何对序列进行建模的第三我们分享了RNN的基本的加码解码的框架。
最后给你留一个思考题对比HMMRNN的优势有哪些

View File

@ -0,0 +1,59 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
107 基于门机制的RNN架构LSTM与GRU
这周我们继续来讨论基于深度学习的文本分析模型。这些模型的一大特点就是更加丰富地利用了文字的序列信息从而能够对文本进行大规模的建模。在上一次的分享里我们聊了对序列建模的深度学习利器“递归神经网络”或简称RNN。我们分析了文本信息中的序列数据了解了如何对文本信息中最复杂的一部分进行建模同时还讲了在传统机器学习中非常有代表性的“隐马尔科夫模型”HMM的基本原理以及RNN和HMM的异同。
今天我们进一步展开RNN这个基本框架看一看在当下都有哪些流行的RNN模型实现。
简单的RNN模型
为了能让你对今天要进一步介绍的RNN模型有更加深入的了解我们先来回顾一下RNN的基本框架。
一个RNN通常有一个输入序列X和一个输出序列Y这两个序列都随着时间的变化而变化。也就是说每一个时间点我们都对应着一个X和一个Y。RNN假定X和Y都不独立发生变化它们的变化和关系都是通过一组隐含状态来控制的。具体来说时间T时刻的隐含状态有两个输入一个输入是时间T时刻之前的所有隐含状态一个输入是当前时刻也就是时间T时刻的输入变量X。时间T时刻的隐含状态根据这两个输入会产生一个输出这个输出就是T时刻的Y值。
那么在这样的一个框架下一个最简单的RNN模型是什么样子的呢我们需要确定两个元素。第一个元素就是在时刻T究竟如何处理过去的隐含状态和现在的输入从而得到当前时刻的隐含状态这是一个需要建模的元素。第二如何从当前的隐含状态到输出变量Y这是另外一个需要建模的元素。
最简单的RNN模型对这两个建模元素是这样选择的。通常情况下在时间T-1时刻的隐含状态是一个向量我们假设叫St-1那么这个时候我们有两种选择。
第一种选择是用一个线性模型来表达对当前时刻的隐含状态St的建模也就是把St-1和Xt当作特性串联起来然后用一个矩阵W当作是线性变换的参数。有时候我们还会加上一个“偏差项”Bias Term比如用b来表示。那么在这样的情况下当前的隐含状态可以认为是“所有过去隐含状态以及输入”的一阶线性变换结果。可以说这基本上就是最简单直观的建模选择了。
第二种选择是如何从St变换成为Y。这一步可以更加简化那就是认为St直接就是输出的变量Y。这也就是选择了隐含状态和输出变量的一种一一对应的情况。
在这个最简单的RNN模型基础上我们可以把第一个转换从线性转换变为任何的深度模型的非线性转换这就构成了更加标准的RNN模型。
LSTM与GRU模型
我们刚刚介绍的RNN模型看上去简单直观但在实际应用中这类模型有一个致命的缺陷那就是实践者们发现在现实数据面前根本没法有效地学习这类模型。什么意思呢
所有的深度学习模型都依赖一个叫作“反向传播”Back-Propagation的算法来计算参数的“梯度”从而用于优化算法。但是RNN的基本架构存在一个叫作“梯度爆炸”或者“梯度消失”的问题。对于初学者而言你不需要去细究这两种梯度异常的细节只需要知道在传统的RNN模型下这两种梯度异常都会造成优化算法的迭代无法进行从而导致我们无法学习到模型的参数这一结局。
想要在现实的数据中使用RNN我们就必须解决梯度异常这一问题。而在解决梯度异常这个问题的多种途径中有一类途径现在变得很流行那就是尝试在框架里设计“门机制”Gated Mechanism
这个门机制的由来主要是着眼于一个问题那就是在我们刚才介绍的简单的RNN模型中隐含变量从一个时间点到另一个时间点的变化是“整个向量”变换为另外的“整个向量”。研究人员发现我们可以限制这个向量的变化也就是说我们通过某种方法不是让整个向量进行复制而是让这个隐含向量的部分单元发生变化。
如果要达到这样的效果,我们就必须设计一种机制,使得这个模型知道当前需要对隐含向量的哪些单元进行复制,哪些单元不进行复制而进行变化。我们可以认为,进行复制的单元是它们被屏蔽了“进行转换”这一操作,也可以认为它们被“门”阻挡了,这就是“门机制”的来源。
从逻辑上思考如何设计“门机制”从而起到这样的作用呢一种方式就是为隐含变量引入一个伴随变量G。这个伴随变量拥有和隐含变量一样的单元个数只不过这个伴随变量的取值范围是0或者10代表不允许通过1代表可以通过。这其实就是门机制的一个简单实现。我们只需要利用这个向量和隐含向量相应单元相乘就能实现控制这些单元的目的。当然这只是一个逻辑上的门机制实际的门机制要有更多细节也更加复杂。
基于门机制的RNN架构都有哪些呢这里介绍两个比较流行的分别是LSTM和GRU。我们这里不对这些模型展开详细的讨论而是给你一个直观的介绍帮助你从宏观上把握这些模型的核心思想。
LSTM的思路是把隐含状态分为两个部分。一部分用来当作“存储单元”Memory Cells另外一部分当作“工作单元”Working Memory。存储单元用来保留信息并且用来保留梯度跨越多个时间点。这个存储单元是被一系列的门控制这些门其实是数学函数用来模拟刚才我们说的门的机制。对于每一步来说这些门都要决定到底需要多少信息继续保留到下一个时间点。
总体来说LSTM模型的细节很多也很复杂。虽然LSTM已经成为了一种典型而且成功的RNN模型但是实践者们还是觉得这个模型可以简化于是就催生了GRU模型。
GRU模型的核心思想其实就是利用两套门机制来决定隐含单元的变化。一个门用于决定哪些单元会从上一个时间点的单元里复制过来并且形成一个临时的隐含状态另外一个门则控制这个临时状态和过去状态的融合。GRU在结构上大大简化了LSTM的繁复在效果上依然能够有不错的表现。
总结
今天我为你介绍了文本序列建模利器RNN的几个实例。
一起来回顾下要点第一我们复习了RNN的基本概念和框架第二我们聊了两个带有门机制的经典的RNN模型分别是LSTM和GRU。
最后给你留一个思考题RNN需要门机制你认为到底是建模的需要还是需要解决梯度异常的问题从而能够让优化算法工作

View File

@ -0,0 +1,53 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
108 RNN在自然语言处理中有哪些应用场景
周一我们进一步展开了RNN这个基本框架讨论了几个流行的RNN模型实现从最简单的RNN模型到为什么需要“门机制”再到流行的LSTM和GRU框架的核心思想。
今天我们就来看一看RNN究竟在自然语言处理的哪些任务和场景中有所应用。
简单分类场景
我们首先来聊一种简单的分类场景。在这种场景下RNN输入一个序列的文字然后根据所有这些文字做一个决策或者叫作输出一个符号。这类应用是文本挖掘和分析中最基本的一个场景。
在绝大多数的“简单分类”任务中传统的文字表达例如“词包”Bag of Word或者“N元语法”Ngram经常都能有不错的表现。也就是说在很多这类任务中文字的顺序其实并不是很重要或者说词序并没有携带更多的语义信息。
然而实践者们发现在一些场景中如果利用RNN来对文字序列进行建模会获得额外的效果提升。比如有一类任务叫作“句子级别的情感分类”Sentence-Level Sentiment Classification这类任务常常出现在分析商品的评论文本Review这个场景。这时候我们需要对每一个句子输出至少两种感情色彩的判断褒义或者贬义正面或者负面。比如我们在分析电影评价的时候就希望知道用户在某一个句子中是否表达了对电影“喜爱”或者“不喜爱”的情绪。
面对这样句子级别的情感分析一种比较通行的利用RNN建模的方式是把每一个单词作为一个输入单元然后把一个句子当作一个序列输入到一个RNN中去RNN来维持一个隐含的状态。
对于这类应用,不是每一个隐含状态都有一个输出,而是在句子结束的时候,利用最后的隐含状态来产生输出。对于这类任务而言,输出的状态就是一个二元判断,那么我们需要利用最后的隐含状态来实现这个目的。一般来说,在深度模型的架构中,这个步骤是利用最后的隐含状态,然后经过多层感知网络,最后进行一个二元或者多元的分类。这其实是一个标准的分类问题的构建。
在有的应用中研究者们发现可以利用两个RNN建立起来的链条从而能够更进一步地提升最后的分类效果。在我们刚才描述的建模步骤里RNN把一个句子从头到尾按照正常顺序进行了输入并归纳。另外一种建模方式是利用RNN去建模句子的逆序也就是把整个句子倒过来学习到一个逆序的隐含状态。接下来我们把顺序的最后隐含状态和逆序的最后隐含状态串联起来成为最终放入分类器需要学习的特性。这种架构有时候被称作“双向模型”。
当我们从句子这个层级到文档这个层级时比如希望对文档进行情感分类仅仅利用我们刚才讲的RNN的结构就会显得有点“捉襟见肘”了。一个重要的阻碍就是RNN很难针对特别长的序列直接建模。这个时候就需要我们把整个文档拆分成比较小的单元然后针对小的单元利用RNN进行建模再把这些小单元的RNN结果当作新的输入串联起来。
在实际拆分的时候我们可以把文章分成一个一个的句子然后每个句子可以用刚才我们在句子层级的建模方式进行建模在句子的层级下还可能再把句子拆分成比如短语这样的单元。这种把一个比较大的文档进行拆分并且通过RNN对不同级别的数据进行建模的形式就叫作“层次式”HierarchicalRNN建模。
特性提取器
在更多的场景中RNN其实已经扮演了文本信息特性提取器的角色特别是在很多监督学习任务中隐含状态常常被用来当作特性处理。尤其要说明的是如果你的任务对文字的顺序有一定要求RNN往往就能成为这方面任务的利器我们这里举几个例子。
首先可以想到的一个任务就是在自然语言处理中很常见的“词类标注”Part-Of-Speech Tagging或者简称POS标注。简单来说POS标注就是针对某一个输入句子把句子里的词性进行分析和标注让大家知道哪些是动词哪些是名词哪些是形容词等等。我们可以很容易地想到在这样的标注任务中一个词到底是名词还是动词在很多的语言场景中是需要对整个句子的语境进行分析的也就是说整个句子的顺序和词序是有意义的。
针对POS标注这类任务一种已经尝试过的架构就是利用我们刚才介绍过的双向RNN来对句子进行建模。双向RNN的好处是我们可以构建的隐含信息是包含上下文的这样就更加有助于我们来分析每个词的词性。
和句子分类的任务类似的是利用双向RNN对句子进行扫描之后我们依然需要建立分类器对每一个位置上的词语进行分类。这个时候依然是同样的思路我们把当前的隐含状态当作是特性利用多层感知网络构建多类分类器从而对当前位置的词性进行决策。
除了POS标注这样的任务以外针对普通的文档分类RNN也有一定的效果。这里我们所说的文档分类一般是指类似把文档分为“艺术”、“体育”或“时政”等主题类别。人们从实践中发现在这样的通用文档分类任务中RNN和另外一类重要的深度模型“卷积神经网络”CNN结合起来使用效果最好。我们这里不展开对CNN的原理进行讲解只是从大的逻辑上为你讲一下这种分类方法的核心思路。
在计算机视觉中通常认为CNN可以抓住图像的“位置”特征。也就是说CNN非常善于挖掘一个二维数据结构中局部的很多变化特征从而能够有效形成对这些数据点的总结。那么如果我们把文档的文字排列也看作是某种情况下的一种图案CNN就可以发挥其作用来对文字的上下文进行信息提取。然后当CNN对文字的局部信息进行提取之后我们再把这些局部信息当作输入放入RNN中这样就能更好地利用RNN去对文章的高维度的特征进行建模。
总结
今天我为你介绍了文本序列建模利器RNN的几个应用场景。
一起来回顾下要点第一我们讲了用RNN对句子层级进行分类任务的处理第二我们聊了如何把RNN当作普遍使用的特性提取器来进行分类任务的训练特别是POS标签任务。
最后给你留一个思考题利用RNN提取的信息能否完整捕捉文档里的内容这一点我们怎么来判断呢

View File

@ -0,0 +1,61 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
109 对话系统之经典的对话模型
在文本分析这个重要的环节里我们已经分享了Word2Vec模型包括模型的基本假设、模型实现以及一些比较有代表意义的扩展模型。我们还讨论了基于深度学习的文本分析模型特别是对序列建模的深度学习利器RNN包括RNN的基本框架流行的RNN模型实现以及RNN在自然语言处理中的应用场景。
今天,我们要来看另外一类和文字相关的人工智能系统——对话系统的一些基础知识。
浅析对话系统
对话系统在整个人工智能领域、甚至是计算机科学领域都占据着举足轻重的地位。著名的人工智能测试,“图灵测试”,其实就是建立在某种意义的对话系统上的。在经典的图灵测试场景中,一个最主要的论述就是:看一个人和一个机器进行对话,在和这个机器系统的问答过程中,能否猜出这个系统是一个真人还是一个计算机程序系统。从这一点可以看出,即便是在计算机科学的早期,对话系统或者说是智能的对话能力,就已经成为了计算机科学家衡量智能水平的一个重要标准。
实际上从上个世纪50~60年代开始研究人员就致力于研发早期的对话系统。即便是在今天看来在一些简单的应用中早期的对话系统也表现出了惊人的“智能”。比如麻省理工大学的约瑟夫·维森鲍姆Joseph Weizenbaum教授研发了一款叫“伊丽莎”Eliza的早期对话系统。尽管这个对话系统只能对语言进行最肤浅的反馈但是在“伊丽莎”系统的使用者中有人真的产生了这个系统有智能的幻觉。这说明对于如何界定“智能”如何理解对话以及语言能力这些的确是非常深邃的计算机科学乃至哲学问题。
早期的对话系统多是基于“规则”Rule的系统。这些系统的一大特征就是并不只是真正的去“理解”对话“理解”文字而是针对某一种模式或者说是预定好的模板对对话进行简单的模仿。不过如果你认为这样基于规则的系统在今天的对话系统中毫无用武之地的话那就大错特错了。实际上通过机器学习的手段辅以规则的方式这样的系统能够在绝大多数的场景下表现出惊人的水平。很多机器学习背景的工程师在接触对话系统研发的时候其实往往有轻视规则系统的这种情况。
从基于统计学习的机器学习崛起以后,研发人员就开始希望利用自然语言处理和机器学习的一系列方法,从根本上来改变对话系统的构建方式,其中有一个核心的想法,就是真正理解对话的内容,从而达到真正的智能。在实际的应用中,真正基于机器学习的系统在很长时间里都并不能完全代替基于规则的系统,直到最近几年出现了更加复杂的基于深度学习的模型,我们也会在之后的分享中对这样的系统进行一些介绍。
对话系统的类别
从方法上,对话系统可以大致分为“基于规则的系统”和“基于机器学习的系统”。除此之外,从应用场景上,对话系统也可以分为“基于任务的对话系统”和“非任务的对话系统”。
基于任务的对话系统其实很容易理解比如我们打电话到航空公司查询订票打电话到酒店查询订房信息抑或打电话到餐厅预定晚餐等。这样的对话系统有一大特点就是我们的对话基本上都有一个明确的目的或者说我们要完成一个“任务”Task。比如对于查询机票而言通常情况下我们的任务可以是成功查询到机票信息或者成功预订了到某个目的地的机票。
对于基于任务的对话系统而言,整个对话的“范畴”是限定好的,很多任务其实都有流程或者叫作“套路”可以参考。因此,从本质上来说,基于任务的对话系统还是相对比较容易的场景。在对话系统发展的历史中,很长时间里,基于规则的系统其实就已经可以对于基于任务的对话系统提供很高质量的服务了。很多用户针对基于规则的系统来应对任务型对话系统,往往会觉得系统缺乏一定的灵活度,但其实已经可以完成任务了。实际上,即便是今天的各类智能对话系统,对于任务型对话系统的支持依然是这些智能系统的核心业务能力。
另外一类对话系统就是非任务型对话系统这类系统的一个代表就是“聊天机器人”Chatbot。聊天机器人取决于我们构建这类系统的目的可以非常接近于任务型的对话系统也可以是非常难以模仿的真正具有一定语言理解能力的系统。
典型的聊天机器人,需要对一定的知识库进行建模。比如,当用户问到今天的天气,喜马拉雅山的高度,现在美国的总统是谁等问题,聊天系统要能从某种先前存储的知识库中提取信息。这一部分的功能其实和数据库信息查询很类似。
更加复杂的模式无疑是我们不仅需要对已经有的信息进行直接的查询还需要进行“推论”Inference。这就是“智能”的某种体现往往是能对现有的数据进行简单推导。比如如果用户问这样的问题比纽约现在气温高的美国西海岸城市有哪些这时就需要理解比较词“高”的含义并能够把这个词汇的含义转换成对气温数值的比较。从这些林林总总的情况来看非任务型的对话系统更加难以建模对研发者的挑战也更加艰巨。
对话系统的基本架构
尽管不同的对话系统有不同的目的,但是从大的架构上来看,所有的对话系统都有一些基本共同的组件。
首先一个对话系统需要有一个模块对人的语音进行识别转换成计算机能理解的信号。这个模块常常叫作“自动语音识别器”Automatic Speech Recognition简称ASR。比如现在很多手机终端、或者是智能家居都有一些简单的对话系统可以根据你的指令来进行回应。
第二在通过了语音识别之后就是一个“自然语言理解器”也简称为NLU。在这个组件里我们主要是针对已经文字化了的输入进行理解比如提取文字中的关键字对文字中的信息例如数字、比较词等进行理解。
第三对话系统往往有一个叫“对话管理器”简称是DM的组件。这个组件的意义是能够管理对话的上下文从而能够对指代信息上下文的简称以及上下文的内容进行跟踪和监测。
第四在任务型的对话系统中我们还需要一个叫“任务管理器”简称是TM的模块用于管理我们需要完成的任务的状态比如预定的机票信息是否完备酒店的房间预定是不是已经完成等等。
第五我们需要从管理器的这些中间状态中产生输出的文本也简称是NLG。这个部分是需要我们能够产生语言连贯、符合语法的有意义的自然语言。
最后在一些产品中我们还需要把自然语言能够用语音的方法回馈给用户这个组件往往简称为TTS。
总结
今天我为你介绍了对话系统的一些基础的背景信息。
一起来回顾下要点:第一,我们讲了什么是对话系统,对话系统从方法论上来说有什么流派;第二,我们聊了对话系统的分类;第三,我们分析了对话系统的基本架构。
最后,给你留一个思考题,你认为,让对话系统能够真正智能的最大挑战是什么?

View File

@ -0,0 +1,63 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
110 任务型对话系统有哪些技术要点?
在上一期的分享中,我为你开启了另外一种和文字相关的人工智能系统——对话系统的一些基础知识。我重点和你分享了对话系统的由来,以及对话系统分为“任务型”和“非任务型”两种类型的概况。同时,我们也聊了聊早期的基于规则的对话系统的构建,以及这样的系统对后来各式系统的影响。最后,我为你简单介绍了对话系统的各个基本组件以及这些组件的主要目标。
在今天的分享里,我们就来看一看任务型对话系统的一些技术要点。
任务型对话系统的基本架构
首先,我们来回顾一下任务型对话系统的一些基本架构。尽管不同的对话系统有着不同的目的,但是从大的架构上来看,所有的任务型对话系统都有一些基本共同的组件。
第一个组件是“自动语音识别器”ASR这个组件是把人的语音进行识别转换成为计算机能够理解的信号。
第二个组件是“自然语言理解器”NLU。在这个组件里我们主要是针对已经文字化了的输入进行理解比如提取文字中的关键字对文字中的信息进行理解等。
第三个组件是“对话管理器”DM。这个组件的意义是能够管理对话的上下文从而能够对指代信息上下文的简称以及上下文的内容进行跟踪和监测。
第四个组件是“任务管理器”TM用于管理我们需要完成的任务的状态。
第五个组件是NLG既从管理器的这些中间状态中产生输出的文本也就是自然和连贯的语言。
最后一个组件是TTS。在一些产品中我们还需要把自然语言能够用语音的方法回馈给用户。
在我们今天的分享里因为ASR和TTS都并不是对话系统的特殊组件我们就不对这两个部分进行更加深入的探讨了。
任务型对话系统组件详解
我们先来看一下NLU这个组件。这个组件的目的是把用户输入的文字信息给转换成为任务型对话系统可以理解的内部的表征Representation形式。
我们试想一个餐馆的对话系统当用户输入了一个句子“看一下北京西单附近今晚7点左右的西餐厅”这个时候我们都需要了解哪些信息呢
首先我们需要知道这个输入的“意图”Intent。作为一个餐馆的对话系统来说我们有可能需要处理好几种不同的意图比如“订餐”的意图“查询菜品”的意图等。那么对于我们刚才的这个句子来说很有可能是一个订餐的意图。也就是说我们针对一个输入的句子来判断当前的意图而意图其实就是一个离散的输出结果。这其实就是一个多类的分类问题或者可以看作是句子的分类问题。
当我们知道了整个句子的意图之后我们就需要进一步理解这个输入句子的细节。而进一步的理解其实就是希望能够从输入的句子中获得可以“执行”Execution的信息。
当我们真实进行餐馆预定的时候餐馆的名字预定的时间用餐人数等信息就显得尤为重要。我们可能需要这样的操作能够提取出餐馆名字、预定时间、用餐人数等信息执行餐馆预定的动作并且能够在餐馆的后台系统中记录下来。于是我们需要对刚才的语句进行这样的分析。这种分析有时候也被叫作“填空”Slot Filling
“填空”其实也可以看作是一个分类问题。比如需要知道“北京西单”是一个地点要把这个地点给识别出来而且能够知道我们已经填了一个叫“地点”的空。再比如“今晚7点”也需要被识别出来让我们知道时间的空也被填好了。在这方面有很多方法有基于传统模型比如“条件随机场”Conditional Random Field简称CRF的也有基于“递归神经网络”RNN的。
经过了NLU这个组件之后我们就来到了对话系统的中枢大脑的位置就是DM这个组件。这个组件重点的是对对话进行跟踪和管理。从整个对话的角度来看DM的主要职责就是监控整个对话的状态目前到达了一个什么情况下一步还需要进行什么样的操作。
还是以刚才我们的输入句子为例通过NLU的分析我们知道已经有地点和时间两个“空”Slot被补齐了但是很明显有一些最核心的信息依然缺失比如就餐的人数订餐人的联系电话等。DM的一大作用就是对所有的“空”都进行管理并且决定下面还有哪些“空”需要填写。在传统的系统中DM大多是基于规则的不过在最近的发展中DM逐渐变成了基于分类问题的利用CRF或者RNN来对DM进行建模的也越来越多。
下一个模块就是TM。这其实是整个任务型对话系统中执行任务的部分。对于一个“订单”意图的餐馆对话系统来说当必要的“空”都已全部填齐的时候TM就会去触发当前需要进行动作比如真正对数据库进行操作从而完成订餐的流程。
在很多现在的系统中DM和TM都是结合在一起进行构建的。在此之上往往有一个叫作“协议学习”Policy Learning的步骤。总体来说协议学习的目的是让对话系统能够更加巧妙和智能地学习到如何补全所有的“空”并且能够完成模块动作。比如有没有最简化的对话方法能够让用户更加快捷地回答各种信息这都是协议学习需要考虑的方面。目前来说在协议学习方面比较热门的方法是利用深度强化学习来对DM和TM进行统一管理。
最后一个组件叫作NLG也就是希望对话系统可以产生自然和连贯的语言。比较传统的方法当然就是利用“填写模板”的形式事先生成一些语句的半成品。目前比较流行的办法是使用RNN特别是RNN中的LSTM来对NLG进行建模。
总结
今天我为你介绍了任务型对话系统的基本技术要点。
一起来回顾下要点:第一,我们复习了任务型对话系统的基本组件;第二,我们进一步聊了这些组件的一些最基础的技术要点和背后的模型思想。
最后,给你留一个思考题,任务型对话系统需要每个组件单独进行学习还是尽可能把所有组件连在一起进行训练?这两种方法的优劣在什么地方呢?

View File

@ -0,0 +1,61 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
111 聊天机器人有哪些核心技术要点?
对话系统分为“任务型”和“非任务型”两种基本类型。周一的分享里,我们讨论了任务型对话系统的一些技术要点,重点介绍了任务型对话系统的各个组件及其背后的模型支撑。
今天,我们就来看一看非任务型对话系统的主要技术要点。非任务型的对话系统有时候又会被称作是“聊天机器人”。
基于信息检索的对话系统
我们前面讲过,对话系统,特别是非任务型对话系统,也就是聊天机器人,有一个很重要的功能,就是在一个知识库的基础上和用户进行对话。这个知识库可以是海量的已经存在的人机对话,也可以是某种形式的知识信息。
比如,一个关于篮球的聊天机器人,那就需要这个系统能够访问有关篮球球队、运动员、比赛、新闻等有关篮球信息的知识库。同时,在这个对话系统运行了一段时间之后,我们就会慢慢积累很多有关篮球的对话。这些对话就成为了系统针对当前输入进行反应的基础。
针对当前的输入,利用之前已经有过的对话进行回馈,这就是基于信息检索技术的对话系统的核心假设。一种最基本的做法就是,找到和当前输入最相近的已有对话中的某一个语句,然后回复之前已经回复过的内容。
比如,当前的问话是“迈克尔·乔丹在职业生涯中一共得过多少分?”如果在过去的对话中,已经有人问过“迈克尔·乔丹为芝加哥公牛队一共得过多少分?”。那么,我们就可以根据这两句话在词组上的相似性,返回已经回答过的答案来应对当前的输入。
当然,上面这种对话系统可能会显得比较原始。但是,一旦我们把整个问题抽象成广义的搜索问题,其实就可以建立非常复杂的检索系统,来对我们究竟需要回复什么样的内容进行建模。
比如,我们可以把输入当作查询关键词,只不过从性质上来说,当前的输入语句往往要长于传统的查询关键词,但是在技术上依然可以使用各种搜索技术,例如通常的排序学习等方法都适用于这样的场景。
从理论上来讲基于检索的对话系统有很多先天的问题。比如从根本上搜索系统就是一个“无状态”Stateless的系统。特别是传统意义上的搜索系统一般没有办法对上下文进行跟踪其实从整个流程上讲这并不是真正意义上的对话当然也就谈不上是“智能”系统。
基于深度学习的对话系统
那么,如何能够让对话系统真正对状态进行管理,从而能够对上下文的信息进行回馈呢?
最近一段时间以来,基于深度学习的对话系统逐渐成为了对话系统建模的主流,就是因为这些模型都能够比较有效地对状态进行管理。
那么在这么多的深度对话系统中首当其冲的一个经典模型就是“序列到序列”Sequence To Sequence模型简称S2S模型。S2S模型认为从本质上对话系统是某种程度上的“翻译”问题也就是说我们需要把回应输入的句子这个问题看作是把某种语言的语句翻译成目标语言语句的一个过程。S2S模型也广泛应用在机器翻译的场景中。
具体来说S2S把一串输入语句的字符通过学习转换成为一个中间的状态。这其实就是一个“编码”Encode的过程。这个中间状态可以结合之前字句的中间状态从而实现对上下文进行跟踪的目的。这个部分其实就成为很多具体模型各具特色的地方。总的来说中间状态需要随着对话的演变而产生变化。然后我们需要一个“解码”Decode的过程把中间的状态转换成为最后输出的字句。
从整个流程上来说S2S其实非常像我们已经介绍过的深度序列模型例如RNN和LSTM。从实现上来说很多S2S模型其实都是直接利用RNN或者LSTM而得以实现的。因此很多深度序列模型的技术都可以直接应用到对话系统中来。
另外我们可以看到相比于基于信息检索的系统来说S2S模型并没有一个“显式”的搜索过去信息的步骤因此可以更加灵活地处理语言上的多样性以及不是完全匹配的问题。因此从实际的效果中来看S2S模型在对话系统中取得了不小的成功。
实际系统的一些问题
在实际的开发中,非任务型对话系统会有一系列的实际问题需要解决。
首先,因为是开放性的对话系统,其实并没有一个标准来衡量这些聊天机器人式的系统的好坏。究竟什么样的系统是一个好的聊天系统,依旧是一个非常具有争议的领域。
其次人们在实际的应用中发现基于深度学习的序列模型虽然常常能够给出比较“人性化”的语句回答但是很多回答都没有过多的“意义”更像是已经出现过的语句的“深层次”的“翻译”。因此在最近的一些系统中人们又开始尝试把信息检索系统和S2S模型结合起来使用。
最后,我们需要提出的就是,非任务型对话系统和任务型对话系统有时候常常需要混合使用。比如,在一个订票系统中,可能也需要掺杂一般性的对话。如何能够有效地进行两种系统的混合,肯定又是一种新的挑战。
总结
今天我为你介绍了非任务型对话系统的基本技术要点。
一起来回顾下要点第一我们讲了基于信息检索也就是搜索技术的对话系统第二我们聊了聊如何利用RNN或者是序列模型对对话系统进行建模。
最后,给你留一个思考题,你觉得在什么样的条件下,非任务型聊天机器人可以展现出真正的“人工智能”呢?

View File

@ -0,0 +1,59 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
112 什么是文档情感分类?
到目前为止,我们讲完了对话系统的基础知识。一般来说,对话系统分为“任务型”和“非任务型”这两种基本类型。针对任务型对话系统,我们重点介绍了其各个组件的任务,以及这些组件都有哪些模型给予支撑。针对非任务型对话系统,也就是“聊天机器人”,我们主要介绍了如何利用深度学习技术来对一个聊天机器人进行建模,以及非任务型对话系统所面临的挑战都有哪些。
今天我们转入文本分析的另外一个领域同时也是在实际系统中经常会使用的一个子领域那就是文本“情感分析”Sentiment Analysis。所谓情感分析就是指我们要针对一段文本来判断这段文本的文字“色彩”到底是褒义还是贬义到底是抒发了什么情感。
文本情感分析是一个非常实用的工具,比如,我们需要分析用户对于商品的评价带有什么样的情感,从而能够更好地为商品的推荐和搜索结果服务。再比如,通过文本的情感分析,我们可以了解到用户针对某一个时事的观点异同,以及观点分歧在什么地方,从而能够更加清晰地了解新闻的舆情动态。
今天我们首先从最基础的文档情感分类Document Sentiment Classification这个问题说起。
基于监督学习的文档情感分类
文档情感分类属于文本情感分析中最基本的一种任务。这种任务的假设是一段文本的作者通过这段文本是想对某一个“实体”Entity表达一种情绪。这里的实体其实包括很多种类型的对象比如可能是商品某个事件也可能是某个人物。我们这里讨论的文本单元可以是一个文档也可以是一个句子等其他的文本段落。
值得注意的是,我们在这一类任务中,限制一个文本单元只表达,或者主要表达一种情感。很明显,这种假设是比较局限的。一般来说,在实际的应用中,一个文本单元,特别是比较长的单元例如文章,则往往包含多于一种的情绪。因此,我们可以看到文档情感分类其实是一种简化了的情感分析任务。
同时,一个文本单元还可能对多个“实体”进行情感表达。比如一个用户针对某种款式相机的多个方面进行了评价,那么每一个方面都可以作为一个实体,而这种时候,用户的情感可能就更难仅以一种情感来加以概括了。
在最基本的文档情感分类的情况下我们往往把这类任务转化成为一种监督学习任务也就是说我们希望通过一个有标签的训练集学习到一个分类器Classifier或者回归模型Regression从而能够在未知的数据上预测用户的情感。
这里往往有两种形式的监督学习任务。一种是把文档分类为几种最简单的情况下是两种情感。这就是二分或者多类分类问题。另外一种则是认为文档会有一种情感但是每一种情感之间有好坏的顺序区分比如评分“好”就比“一般”要好也就是说这些评分之间有一个次序问题。那么很多时候这种问题会被归结为一种“次序回归”Ordinal Regression问题。
在明确了我们需要构建什么样的监督学习任务以后对于这些任务而言如何选取“特性”Feature就是一个很重要的工作了。诚然对于每一个具体的任务而言我们往往需要选取不同的特性但是在过去的很多实践中经过反复验证有一些特性可能会有比较好的效果。我在这里做一个简单的总结。
首先我们曾经多次提到过的“词频”Term Frequency以及更加复杂一些的TF-IDF词权重法都是经常使用的文字特性。在文档情感分类中这一类特性被认为非常有效。
另外一种使用得比较频繁的特性就是“词类”Part of Speech。词类提供了句子中每个词的成分比如哪些词是动词哪些词是名词等等。这些词性可以跟某种特定的情感有很密切的联系。
还有一种很直观的特性就是“情感词汇”。比如,我们已经知道了“好”、“不错”等词表达了正向的情感,而“差”、“不好”、“不尽人意”等词表达了负向的情感。我们可以事先收集一个这类情感词汇的集合。这个集合里的词汇可以跟最后文档的情感有很直接的联系。
最后,需要指出的是,如何开发一个合适的特性往往是文档分类的重点工作。
除了特性以外在文档情感分类这个任务中传统上经常使用的文字分类器有“朴素贝叶斯”Naïve Bayes分类器、“支持向量机”Support Vector Machines等。
基于非监督学习的文档情感分类
情感词汇已经为我们对大段文字乃至整个文档的分类有了很强的指导意义,因此,也有一些方法寻求利用非监督学习的方式来对文档进行情感分类。注意,这里所谓的非监督学习,是指我们并不显式地学习一个分类器,也就是说,不存在一个训练数据集,不需要我们提前收集数据的标签。
这一类思想的核心其实就是设计一套“打分机制”Scoring Heuristics来对整个文档做一种粗浅的判断。当然这种打分机制背后都有一种理论来支撑。
比如,有一种打分模式依靠首先识别的“词类”进行分析,特别是大量的相邻的两个词的词性,诸如“特别好”。这里,“特别”是副词,“好”是形容词,然后就可以得出在某些情况下,副词和形容词的这种搭配特别多的时候,并且在正向的情感词比较多的时候,整个文档也许就是比较偏向正向的情感。
我们需要指出的是,这种方法虽然听上去比较“山寨”,但是对于很多产品和项目来说,获取大量高质量的标签信息往往是非常耗时,甚至是不可能的,例如上百万的用户对产品的评价数据。因此,在没有训练数据的情况下,利用某种打分机制,可以通过最简单的一些情感词库开发出文档情感分类的算法,这其实也不失为一种快速迭代的方式。
总结
今天我为你介绍了一类基础的文字情感分析任务——文档情感分类的基本技术要点。
一起来回顾下要点:第一,我们讲了基于监督学习的文档情感分类任务以及这类任务下的重要特性和模型;第二,我们聊了如何在没有大规模训练数据的基础上进行非监督的文档情感分类。
最后,给你留一个思考题,如何把文档情感分类任务扩展到可以针对多种实体多种情感的分析呢?

View File

@ -0,0 +1,55 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
113 如何来提取情感实体和方面呢?
从上一篇分享开始我们转入文本分析的另外一个领域文本“情感分析”Sentiment Analysis也就是指我们要针对一段文本来判断其文字“色彩”。文本情感分析是一个非常实用的工具。我们从最基础的文档情感分类这个问题说起这个任务是把一个单独的文档给分类为某种情感。在绝大多数情况下我们可以把这个任务看作监督学习的问题。另外我们也聊了聊如何通过建立情感词来进行简单的非监督学习的步骤。
今天,我们来看文本情感分析中的另一个关键技术,情感“实体”和“方面”的提取。
“实体”和“方面”的提取
对于文本情感分析而言“实体”Entity和“方面”Aspect是两个非常重要的概念。很多情感分析的任务都是围绕着这两个概念而产生的。在谈论如何对这两个概念提取之前我们先来看看这两个概念的意义。
“实体”其实就是文本中的某一个对象,比如产品的名字、公司的名字、服务的名字、个人、事件名字等。而“方面”则是实体的某种属性和组建。
比如这么一个句子:“我买了一部三星手机,它的通话质量很不错”。在这里,“三星手机”就是一个实体,而“通话质量”则是一个方面。更进一步,“很不错”则是一个情感表达,这里是针对“三星手机”这个实体的“通话质量”这个方面。很明显,如果我们想要精准地对文本的情感进行分析,就一定得能够对实体和方面进行有效提取。
从广义的范围来说实体和方面的提取都属于“信息提取”Information Extraction的工作。这是一个非常大的任务类别用于从大量的非结构化文本中提取出有价值的信息。实体和方面的提取可以利用一般性的信息提取技术当然往往也可以利用句子中的一些特殊结构。
常用的提取技术
接下来,我们来聊一聊有哪些最直观最简单的提取技术。
第一种最简单的技术是基于“频率”Frequency的提取。在这样的技术中我们先对文本进行“词类”Part Of Speech分析分析出每个词的词性。然后主要针对句子中的“名词”计算这些“名词”出现的频率。当这些频率达到某一个阈值的时候我们就认为这些名词是一个实体或者方面。
这里的假设是在一个例如产品评论的文本集合中如果一个名词反复出现在这个集合的很多文档中那么这个名词很有可能就是一个独立的实体或者方面。为了达到更好的效果更加复杂的词频技术例如TF-IDF也经常被用在计算名词的频率上从而提取它们作为实体和方面的候选词。
另一种比较常见的针对情感分析开发的技术,就是利用句子中的一些特殊的结构从而达到信息提取的目的。
比如,回到刚才的那句话:“我买了一部三星手机,它的通话质量很不错”。在这句话中,“很不错”作为一个情感词汇,一定和某一个方面,甚至是某一个实体成对出现的。那么这个成对出现的情况就是我们可以利用的情感句子的有利特征。
比如“很不错”这个词汇,在一个描述产品情感的文档中,这个词汇很少单独出现。这类不管是褒义还是贬义的词汇出现后,在绝大多数情况下,他们都会描述一个对象。而从句法结构上来说,这个对象往往又离这个情感词汇很近,因为这个情感词需要对这个对象进行描述。因此,我们就可以利用这种配对结果,来计算这样的结构是否大量出现。
这种结构其实可以被反复利用。例如在刚才的句子中,“三星手机”这个实体,一定会和很多不同的方面反复同时出现,如“通话质量”、“操作”、“售后服务”等。我们可以利用这两种不同的配对结构,实体和方面之间的,方面和情感词之间的,更好地提取这些词汇。
刚才我们说的不管是基于词频的还是利用配对关系的方法,都可以算是无监督的学习方法。这些方法的本质,其实就是利用某种之前定义好的规则或者是某种洞察来针对文本进行提取。另外一种思维其实就是把信息提取转换成为监督学习任务。
回到例子“我买了一部三星手机,它的通话质量很不错”这句话。这句话的文本作为输入,我们需要的输出是“三星手机—实体”、“通话质量—方面”这样的标签信息。那么,一个基本的想法就是,我们其实可以针对这句话构建一些特征,然后学习出一个分类器,从而可以得到这样的标签。
值得注意的是这一类的监督学习任务和我们常见的例如分类一个文档是不是垃圾信息不一样这里我们需要输出多个标签。这种需要输出多个标签的任务特别是这些标签之间可能还有一定关系的情况往往被称作是“结构化预测”Structural Prediction任务。
在结构化预测这个领域“条件随机场”Conditional Random Field或者简称是CRF的模型是对这方面任务进行运作的一个经典模型。然而需要指出的是把实体和方面提取当作监督任务以后很明显我们就需要有一个训练集和标签这个训练集的匮乏常常成为CRF产生理想效果的瓶颈。
总结
今天,我为你介绍了一类基础的文字情感分析任务——情感“实体”和“方面”的提取。
一起来回顾下要点:第一,我们介绍了什么是情感“实体”和“方面”;第二,我们聊了目前在这个方向上比较通行的一些方法,比如基于“频率”的提取,利用句子的一些特殊结构等。
最后,给你留一个思考题,除了我们介绍的这些方法,你还能想到其他方法来提取实体和方面的关键词吗?

View File

@ -0,0 +1,69 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
114 复盘 3 自然语言处理及文本处理核心技术模块
到目前为止,我们讲完了人工智能核心技术的第三个模块——自然语言处理及文本处理核心技术。
整个模块共18期6大主题希望通过这些内容能让你对自然语言处理及文本处理核心技术有一个全面系统的认识和理解为自己进一步学习和提升打下基础。今天我准备了 18 张知识卡,和你一起来对这一模块的内容做一个复盘。
点击知识卡跳转到你最想看的那篇文章温故而知新。如不能正常跳转请先将App更新到最新版本。
LDA模型
基础文本分析
Word2Vec
基于深度学习的语言序列模型
基于深度学习的聊天对话模型
文本情感分析
积跬步以至千里
最后恭喜你在这个模块中已经阅读了37690字听了120分钟的音频获得一张新的通关卡这是一个不小的成就。在人工智能领域的千里之行我们又往前迈出了一步。

View File

@ -0,0 +1,59 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
114 文本情感分析中如何做意见总结和搜索?
在文本“情感分析”Sentiment Analysis这个领域我们首先介绍了最基础的文档情感分类这个问题。在绝大多数情况下这是一个监督学习的问题。当然我们也可以通过建立情感词库来进行简单的非监督学习的步骤。紧接着我们讨论了文本情感分析中的另一个关键技术即情感“实体”和“方面”的提取。这个任务可以说是很多情感分析的根基我们需要从无结构的文本中提取实体和方面等结构信息便于进一步的分析。我们讲了如何通过词频、挖掘配对信息以及利用监督学习来对实体和方面进行挖掘。
今天我们来看文本情感分析的另外一个主题——意见总结Opinion Summarization和意见搜索Opinion Search
意见总结
为什么“意见总结”这个任务会很重要的呢?
假如你希望在电商网站上购买一款数码相机。这个时候,你可能需要打开好几款相机的页面进行比较。对于相机的硬件指标,能够从这些页面上相对容易地直接得到,除此以外,你可能还比较关心对这些相机的评价。
在这个场景下,“意见总结”的重要性就凸显出来了。因为优秀的相机款式往往有上百甚至上千的用户评价,这些评价包括了用户对产品很多方面的评价,有褒义和贬义的情绪。如果对这些评价逐一进行浏览,很明显是一种非常低效的做法。因此,从购物网站的角度来说,如果能够为用户把这些评论进行总结,从而让用户看到总体的有代表性的评论,无疑能够帮助用户节省不少时间和精力,让用户获得更好的体验。
简单来说意见总结就是从无结构的文本中提取出来的各种情感信息的综合表达。我们这里聊的意见总结主要是指“基于方面的意见总结”Aspect-based Opinion Summarization。也就是说意见的总结主要是围绕着产品的种种方面来产生的。
概括一下,基于方面的意见总结有两个特点。第一,这样的总结主要是针对物体的实体以及对应的方面来进行的。第二,意见总结需要提供数量化的总结。什么是数量化的总结?就是总结里需要指出,持有某种意见的用户占多少百分比,又有多少百分比的用户有其他意见。很明显,这里还牵涉到如何表达和显示这些意见总结的步骤。
可以说,基于方面的意见总结成为了意见总结的主要任务。另外,基于方面的意见总结还可以与其他文本技术相结合,从而能够延展这个技术的效果。比如,总结语句的生成可以分为“句子选择”和“人工句子生成”这两种方案。
首先来说一下句子选择这个想法。句子选择的思路是,我们希望在最后的意见总结里,能够利用已有的非常有代表性的句子,这样用户看到的最后的总结会显得更加真实。那么这里有两个问题:一个问题是如何对所有的句子进行筛选;第二个问题是如果有重复多余的字句,又如何进行进一步的选择。
通常情况下,我们通过对句子打分来筛选,这个时候,一般需要设计一个打分机制,这个机制往往是看这个句子对某一个实体的方面是否进行了有情感的评价。然后,对所有句子进行聚类,这样所有评价类似的句子就可以被聚集到一起,从而能够过滤掉重复多余的字句。
那么,人工句子生成又是怎么运作的呢?首先,我们必须知道这个物品的哪些方面得到了用户的评价,而且都是什么样的评价,比如是正面评价还是负面评价。然后,把这些信息和一个语言模型,也就是语句生成器相连接,从而能够“生成”最后的总结语句。值得注意的是,这样生成的总结语句并不会出现在所有用户的原始评价中,因此也可能会对用户的最终体验有一定的影响。
除了基于方面的意见总结以外还有一些类似的但是并不完全一样的总结方案。比如有一种总结方案叫“针对性观点总结”Contrastive View Summarization。这个任务更加突出针对同一个主题的两种截然相反的观点。这种意见总结不仅可以针对商品也针对新闻事件比如某一个政策法规、选举结果等往往比较有争议的话题事件“针对性观点总结”往往会有比较好的用户体验。
意见搜索
我们可以认为“意见搜索”是建立在意见总结之上的一个任务。通常情况下,意见搜索需要完成的任务是用户输入一个主体的名字,我们需要返回和这个主体相关的意见信息,这些意见信息有可能是通过意见总结而呈现给用户的。
意见搜索的难点,或者说是和传统搜索不一样的地方主要还是在于针对意见信息的索引和检索。
第一,我们需要在索引库中找到有哪些文档和字句包含了我们所需要查询的主体。可以说,这一点和传统的搜索是非常类似的。
第二,我们需要在找到的文档和字句中检查是否包含主体的某种意见,以及其褒义或者贬义的评价。这就是有别于传统搜索的地方。在找到了所有关于某个主体的情感评价以后,我们需要设计一个评分机制从而返回最有说服力的文档,并且还需要在这些文档的基础上进行意见总结。很显然,这些步骤都是传统的搜索中并没有的。
按照上面所说的这两点,我们可以把意见搜索分为两个阶段。
第一个阶段,就是利用现有的搜索技术,比如我们介绍过的文本搜索或者基于排序学习的搜索等方法,得到最初的一个文档的备选集。然后进入下一个阶段,就是通过一个模型,针对所有的文档进行基于意见的打分。这个模型可以是简单的分类器,用于分析当前的字句和主体的意见究竟有没有关系,也可以是一个更加复杂的模型,输出当前的文档和主体的哪一个方面有关系。在这里,任何一种文本分类器都可以被利用起来。
总体来说,意见搜索可以算是对于意见分析和总结的一个综合体现。
总结
今天,我为你介绍了一类比较高级的文本情感分析技术:意见的总结和搜索。至此,我们对于文本情感分析的分享就告一段落了。
一起来回顾下要点:第一,我们讲了意见总结的的重要性,基本概念和技术;第二,我们分享了意见搜索的基本概念和两个阶段的技术。
最后,给你留一个思考题,除了常见的观点和评分以外,用户对于产品的评价一般还在意哪些信息呢?

View File

@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
115 什么是计算机视觉?
在之前的一系列分享中,我们详细讲解了人工智能核心技术中的搜索、推荐系统、计算广告以及自然语言处理和文本处理技术。从今天开始,我们来分享专栏里人工智能核心技术模块的最后一部分内容:计算机视觉技术。
可以说,计算机视觉技术是人工智能技术的核心方向,特别是深度学习技术在计算机视觉中的应用,在最近五六年的人工智能浪潮中担当了先锋者的角色。甚至可以说,如果没有深度学习技术在过去几年对计算机视觉一些核心领域的推动和促进,就很可能没有这一波的人工智能技术浪潮。
我们可以这么来看待人工智能技术,它可以说是利用计算机技术来对人的感官,例如视觉、听觉、触觉以及思维进行模拟,从而建立起逻辑推断等智能才具备的能力。其中,计算机视觉技术无疑是至关重要的,也是非常困难的。
今天我会带你先来看看究竟什么是计算机视觉,以及这个方向的研发都需要解决哪些核心问题。
计算机视觉的定义
关于计算机视觉Computer VisionCV有两种人们普遍接受的定义。
第一种定义认为计算机视觉是从数字图像Digital Images中提取信息。这些信息可以是图像中的物品识别Identification、导航系统的位置测量Space Measurement以及增强现实Augmented Reality的应用。
计算机视觉的第二种定义主要是从应用的角度出发,认为计算机视觉是为了构建可以理解数字图像内容的算法,从而有多种应用。
那到底什么是计算机视觉呢?主要解决哪些问题?我们可以拿人类视觉的主要功能来做类比,就比较容易理解了。
当人类面对一个现实中的场景时,我们有一个感官器官来收集信号,那就是“眼睛”。眼睛收集的原始信号转换为人可以处理的信息之后,这些信息就来到了“大脑”这一个人类信息处理中心,进行分析和处理。
这个过程中最主要的一个处理模块就是对信号产生“语义”Semantic解释或者进行逻辑上的理解。比如当我们看到一个公园的一角以后需要识别这个场景里的桥梁、水、树等物体并且在头脑中形成这些物体的概念。可以说这就是人类视觉系统的一个简单的框架眼睛收集信息大脑处理信息。
那么在整体的框架上计算机视觉其实就是希望模仿人类的视觉系统构架。输入依然是一个现实中的场景但是我们需要借助其他的感知仪器Sensing Device来从中获取原始信息。最常见的感知仪器包括照相机、摄像机以及现在广泛普及的手机摄像头。从这些感知仪器中获取了最初级的信息之后计算机视觉的“大脑”就是计算机。这里的“计算机”其实是指计算机算法通过算法理解原始数据构建语义信息。
这么理解起来计算机视觉技术好像挺简单的。就像1966年麻省理工大学的一个本科生想做这样一个暑期项目并且认为这个项目可以在一个暑假里解决。这或许就是计算机视觉的一个起源了。但是令人感慨的是计算机视觉绝不是可以在一个假期内解决的项目整整半个多世纪已经过去了计算机视觉依然有很多值得挑战的课题也依然还在高速发展中。
计算机视觉的领域特点
了解了计算机视觉的定义之后,我们来进一步聊一聊这个领域的一些特点。
首先,计算机视觉是一个“跨学科领域”。正如刚才所说,对人类视觉的研究给计算机视觉带来了重要的启发。那这里就涉及到生物领域的研究,包括对人的眼睛以及视觉神经的研究。一方面,我们需要感知器来从现实世界中获取信息。那么,对于感知仪器来说,设备越是精确,就越能完整地捕捉外界世界的信息。这里就涉及到物理,特别是光学的研究。另一方面,人脑是处理所有信号并且形成语义概念的器官,理解人脑的信息处理机制就会对计算机视觉的发展有重要的作用,这就涉及到脑科学和认知科学等领域。
除此以外,计算机视觉毕竟是一个和计算机结合得很紧密的学科方向。因此,要想设计高效的计算机视觉算法,就必须和计算机科学的很多其他方向结合并借鉴,例如信息检索、计算机体系结构、机器学习等。
计算机视觉的另外一个特点,就是这个领域包含了很多非常深刻的困难问题。我们说,从计算机视觉被当作一个暑假项目到现在,五十多年已经过去了,这个领域依然在蓬勃发展着。时至今日,我们依然不能说计算机视觉是一个已经被完全解决的问题。
那计算机视觉任务“难”在哪里呢我认为根本原因在于计算机视觉算法处理的输入也就是数字化了的图像信息和我们需要理解的语义信息之间存在巨大的鸿沟。举例来说一个200乘以200的RGB图像其实就是一个由12万个数字组成的矩阵但是这个矩阵可能代表一个非常复杂的图像。从数字到具体的图像中的物体再到去理解这个图像的语义这中间有很长的距离。
一直以来计算机视觉也在尝试去构造和逼近一些人类视觉系统的特点但是困难重重。比如人类视觉系统的反应很快。有实验表明从一幅普通场景的图像中人类只需要150毫秒就能够识别出里面的物体。另外人类视觉系统的复杂性还来自于对世界认知的理解。例如人可以依靠过去的记忆或者经验还可以依靠其他外界知识来对图像中的物体进行判断。这些都是计算机视觉系统难以企及的。
当然,在经历了半个世纪的研究之后,也有不少学者提出怀疑的观点,计算机视觉研究是否要对人类视觉系统进行完全的模仿呢?一种观点是,计算机视觉系统并不需要亦步亦趋地完全照搬人类视觉系统,这可能也并不是一条切实有效的道路。有一种观点认为,计算机视觉系统可以从人类视觉或者其他领域得到灵感,但是究竟应该如何搭建一个有效的系统,还是需要开辟新的研究道路。
计算机视觉的应用
计算机视觉技术的领用非常广泛可以说是深入到了普通人生活的方方面面。在这些应用中除了我们日常比较容易接触到的例如面部识别、光学字符识别OCR、电影特效、视觉搜索以外还包括最近几年飞速兴起的自动驾驶、自动无人商店、虚拟现实、增强现实等等。
可以说计算机视觉的应用任务领域众多。近几年都受到深度学习的影响,绝大多数领域都得到了高速发展,但是依然需要领域知识来构建更加有效的模型。
小结
今天我和你讲了计算机视觉技术的一个,是我们计算机视觉基础知识系列的第一篇,帮助你对计算机视觉有一个最基本的概念性的了解。
一起来回顾下要点:第一,我们聊了什么是计算机视觉;第二,我们讲了计算机视觉的特点;第三,我们简要提及了一些计算机视觉的应用。
最后,给你留一个思考题,我们说计算机视觉的核心挑战是从数字到语义的理解,那么理解图像数据有什么特殊的地方吗?

View File

@ -0,0 +1,63 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
116 掌握计算机视觉任务的基础模型和操作
今天,我们来聊一聊计算机视觉的一些最基础的操作和任务,包括像素表达、过滤器和边界探测。基于这些内容,我们一起讨论利用计算机来处理视觉问题的核心思路。很多时候,越是基础的内容就越重要,因为只有掌握了基础的思路,我们才能在今后复杂的任务中灵活应用。
像素表达
我们在上一次的分享中谈到了计算机视觉任务中一个非常重要的步骤那就是把现实世界的信号通过感知仪器Sensing Device收集起来然后在计算机系统中加以表达。那么在所有的表达中最基础的就是“像素表达”Pixel。我们这里就展开说一说这种表达的思路。
把图像信息利用像素来进行表达是一种非常直观简单的表达方式。
对于黑白图像来说图像就被转换为了0或者1的二元矩阵。这个矩阵的每一个元素就是一个像素0代表黑1则代表白。
对于灰度图像来说每一个像素或者说是矩阵的每一个元素代表灰度的“强度”Intensity从0到2550代表黑255代表白。
对于彩色的图像来说我们一般要先选择一种模型来表示不同的颜色。一种较为流行的表达方式是RGB红、绿、蓝模型。在这样的模型中任何一个彩色图像都能够转化成为RGB这三种颜色表达的叠加。具体来说就是RGB分别代表三种不同的“通道”Channel。每一种通道都是原始图像在这个通道也就是这个原始颜色下的表达。每一个通道都是一个矩阵像素表达。每一个像素代表着从0到255的值。换句话说一个彩色图像在RGB模型下是一个“张量”Tensor也就是三个矩阵叠加在一起的结果。
针对像素你需要建立一种概念那就是像素本身是对真实世界中的“采样”Sample。每一个像素是一个整数整个像素表达并不是一个“连续”Continuous表达。因此在把世界上的连续信号采样到离散像素的这一过程中难免会有失真。而不同的“分辨率”会带来失真程度不同的像素表达。
过滤器
既然已经把图像表达成为了像素也就是某种矩阵的形式那么我们就可以利用线性代数等工具在这个矩阵上进行变换从而能够对图像进行某种操作。这就是“过滤器”Filter的一个基本思想。
很多计算机视觉的操作本质上都是过滤器操作。除了把过滤器想成某种线性代数变换之外,更普遍的一种思路是把在矩阵上的操作想成某种函数的操作。因此,我们也可以认为过滤器是函数在某一个特定区间内的操作。
举一个最简单的过滤器的例子就是“移动平均”Moving Average。这个过滤器的本质就是针对每一个像素点计算它周围9个像素点的平均值。如果我们针对每一个像素进行这样的操作就会得到一个新的矩阵。然后我们把这个矩阵当作新的像素表达进行视觉化就会发现是在原有图像基础上进行了“柔化”处理。反过来如果我们需要对某一个图像进行柔化处理就需要对其进行“移动平均”过滤操作。
有了这个直观的例子,你一定能够想到,很多我们熟知的图像特效处理,其实都对应着某种过滤器操作。
这里我们提及一种比较特殊的过滤处理那就是“卷积”Convolution。这个概念我们在深度学习中经常接触到。
刚才我们说到“移动平均”这个过滤器。如果我们把图像看作是一个函数F在某一个区域的取值那么“移动平均”这个过滤器是针对函数在某一点的取值也就是某一个像素的取值通过利用同样的函数F在周围的取值从而得到一个新的计算值。
那卷积操作的思想是怎样的呢卷积是针对F在某一个点的取值除了需要利用F在周围的点以外还需要利用另外一个函数这里称作是H的取值。也就是说我们要利用H来针对F进行操作。
边界探测
除了通过过滤器对图像进行简单操作之外,还有一些图像的基本操作蕴含了计算机视觉的基本原理。我们这里也稍微做一些介绍。
例如我们通常需要了解图像的边界。有研究表明图像的边界对于人类认知图像的内涵有着特殊的意义。因此从一个完整的图像中找到不同物体的边界是一个很有现实意义的任务并且通常被称作是“边界探测”Edge Detection
那么,怎么来认识图像中的物体边界呢?我们先从直观上来想一想,在图像中,“边界”都有什么特征?一般来说,如果我们遇到了色彩、景深、照明的突然变化,或者是其他某种图像特质上的突然变化,我们就有可能遇到了边界。现在的问题是,在像素或者函数表达的情况下,如何来描述和检测这些“突然变化”?
在数学分析中我们学过描述函数值变化的概念叫“导数”或者“梯度”Gradient。梯度大小Gradient Magnitude和梯度方向Gradient Direction都包含了函数变化的重要信息。
虽然梯度从数学的角度来说刻画了函数的变化但是这对于设计一个实际的边界探测器依然是不够的。一个好的边界探测器需要真正能够探测到边界也就是要尽可能少地出现错误能够对边界进行定位Location的同时还需要尽量使边界平滑和链接。
在真实的边界探测中我们往往先让图像变得更加平滑比如利用“高斯柔化”Gaussian Blur的办法然后在这个基础上计算梯度大小和方向有了这些之后再进行一些后期处理。
小结
今天我和分享了计算机视觉的一些基本数学模型和操作。
一起来回顾下要点:第一,像素是对真实世界的采样,我们分别讲了对黑白、灰度和彩色图像的像素表达;第二,我们讲了在像素表达之上的过滤器,介绍了移动平均和卷积两种方式;第三,我们简要提及了利用函数的梯度计算来进行边界检测的任务。
最后,给你留一个思考题,从边界检测的任务中,如何知道检测到的像素是真正属于边界呢?

View File

@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
117 计算机视觉中的特征提取难在哪里?
在上一次的分享中,我们聊了计算机视觉的一些最基础的操作和任务,包括像素表达和过滤器这两个视觉问题。我们还简单介绍了边界探测这个任务,了解如何从计算机视觉的角度来对这个任务进行建模。
今天我们来看计算机视觉基础问题中的另一个核心任务那就是特征Feature提取。
特征提取的目的
在深入讨论特征提取之前,我们先来了解一下特征提取的目的,或者说是研究特征提取的必要性。
从大的方面来说,计算机视觉的一部分任务是实现对图像的智能理解。那么,理解图像的语义就是其中一个非常重要的任务。
我们提到的边界检测或者是颜色检测,虽然都是理解图像的任务,但是这些任务并不理解图像中具体的物体,比如哪里是一只动物、哪里是行人等。那么,怎样才能理解图像中具体的物体呢?或者更进一步,整个图像表达了怎样的社会关系或者说是场景关系?例如一张会议室的图像,我们不仅关心里面的陈设和人物,还关心会议室的整体气氛,以及这样的气氛是不是传递出了更复杂的人物之间的社会关系。
那么,如何实现这种更高维度的语义理解呢?这往往需要我们对底层的一些图像先进行抽象,然后再在抽象出来的特征基础上,进一步来建模。
除了我们这里提到的对图像本身的理解以外,在很多任务中,我们还需要对图像和其他信息结合起来进行理解和分析。一种常见的形式是图像和一段文字结合起来,对某一个物品或者某一个事件进行描述。例如电商网站的商品信息,一般都有精美的图片和详细的介绍,这些信息组合起来完整地描述了整个商品的信息。
这个时候,我们就要同时理解图像和文字信息。很明显,在这样的任务中,一种比较容易想到的模式是先从图像和文字中分别抽取一定的抽象特征,有了高度概括的图像特征和文字特征之后,我们再在这个基础上进行建模。
从比较小的计算机视觉的任务来说,很多时候,一个任务会涉及到两个步骤:把任务抽象为提取特征,然后转换为一个普通的机器学习任务。这个流程的第二步可以是一个监督学习任务,例如回归或者分类;也可以是一个非监督学习任务。需要注意的是,我们这里提到的两个步骤,并不一定是绝对地把建模过程当作两个完全独立的步骤,而是从逻辑上对这两个步骤进行区分。事实上,在现代的深度学习架构中,这两个步骤往往都在统一的一个架构下进行训练,从而能够得到更好的效果。
今天,我们就从传统的计算机视觉的角度,来看看特征提取有哪些难点和经典方法。
特征提取的难点及基本思路
图像数据的特征提取为什么有难度呢?原因在于图像信息本身的复杂性。
试想我们有两张人民大会堂的建筑物照片,一张是从地面拍摄的,一张是从空中拍摄的。虽然这两张照片可能在角度、色彩、位置等方面有很多的不同,但是因为这两张照片本身所描述的对象是一致的,都是人民大会堂,因此我们希望从这两个图片中提取的特征有一些相似性。也就是说,我们需要找到在诸多变化因素中不变的成分。
一个经典的思路是从局部信息Local Information入手从图像中提取相应的特征。从实际的效果来看局部特征Local Feature比全局特征更加稳固。
回到上面的例子如何构造一个能够匹配两个图片的普遍的局部特征呢过程如下第一找到一组关键的点或者是像素第二在关键点周围定义一个区域第三抽取并且归一化这个区域第四从归一化后的区域提取“局部描述子”Local Descriptor。得到局部描述子之后我们就可以利用它来进行匹配了。
从上面这个流程来看,整体的思路其实就是希望从局部找到具有代表性的特征,然后把所有因为各种因素造成的特征变化归一化掉。
当然,这个简单的流程是有一些问题的。比如,如果我们针对两幅不同的图像分别进行上述的流程,那么很有可能最后得到的关键点和局部描述子都不一样。所以我们需要一种更具普适性的方法。
其实从70年代开始就有一大部分的计算机视觉工作是在研究如何构建局部特征描述子。在这30多年的发展历程中很多研究工作者提出了不少既有理论基础又有实用价值的特征提取方法。甚至是最近的深度学习热潮从某种程度上来说也是一个重要的特征提取成果。
在这些研究成果中比较有代表性的局部描述子包括SIFTScale-invariant feature transform描述子和HOGHistogram of oriented gradient描述子。关于这两个描述子我在这里不展开介绍它们的细节因为在深度学习浪潮中大部分利用描述子来对特征进行提取的方法都被淘汰了但是这些方法的思路我们在很多类似的工作中依然可以借鉴。所以如果你有兴趣继续了解可以阅读我在文末提供的两个参考文献。
小结
今天我为你讲了计算机视觉中的又一个核心任务:特征提取。帮助你对计算机视觉的一些基本特征提取有一个了解。
一起来回顾下要点:第一,在图像理解任务中,高维度的语义理解,以及理解图像和其他信息的组合形式,都需要特征提取这个关键步骤;第二,特征提取的难点在于图像信息本身的复杂性,一个经典的思路是从局部信息入手,提取局部特征。
最后,给你留一个思考题,除了图像数据以外,还有没有其他形式的数据,也需要我们先对数据的不同形态(例如图像中的颜色、方位、角度等)进行处理,从而识别出相同的物体或者数据个体?
参考文献
David G. Lowe, “Distinctive image features from scale-invariant keypoints,” International Journal of Computer Vision, 60, 2 (2004), pp. 91-110.
N. Dalal and B. Triggs. Histograms of Oriented Gradients for Human Detection. In CVPR, pages 886-893, 2005.

View File

@ -0,0 +1,71 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
118 基于深度学习的计算机视觉技术(一):深度神经网络入门
在最近几年的人工智能发展中,深度学习技术成为了一个强劲的推动力。对于计算机视觉来讲,深度学习在过去几年重新改写了这个领域的核心方法论。时至今日,深度学习已经深入到了计算机视觉技术的方方面面,成为解决各类视觉问题的有力工具。
从今天开始,我们将介绍一系列以深度学习为背景的计算机视觉技术。那么在这个环节的第一篇分享中,我们首先来了解一下什么是深度学习。
为什么是深度学习
在了解一些深度学习技术细节之前,我们首先要来看一下为什么需要深度学习技术。
初学者经常会有一个误区那就是认为和“深度学习”相对的就是“浅层学习”Shallow Learning。这种看法也对也不对。
“对”的地方在于“深度学习”的确强调从数据或者说是特征Feature中构造多层或深度的变换从而能够得到非线性的表征Representation。显然这种效果是线性模型所达不到的。
“不对”的地方是,在所谓的“深度学习”,或者准确地讲是深度神经网络技术发展之前,就已经有了很多构造复杂非线性表征的尝试和技术。这些技术在机器学习和人工智能的发展中都起到了举足轻重的作用。
说到这里,我们就要从线性模型聊起了。从线性模型发展到非线性模型,这一步貌似理所当然,但其实这里面有一个非常重要的思路,那就是线性模型并不是不能处理数据中的非线性关系,这一点很容易被忽视。很多时候,我们其实是可以构造非线性的特征,然后利用线性模型来把所有的非线性特征给串联起来。
举个例子在网页搜索中我们经常利用类似PageRank来表征一个网页的重要性。这个模型本身就是非线性的对网页图Graph的一种表征。所以即便在此之上构建线性模型整个模型其实也是包含了非线性的表征转换。
其实,对于很多深度学习模型而言,即便进行了复杂的表征转换,在最后一层对最终的输出进行建模的时候依然是一个线性模型。所以,线性模型在非线性特征的帮助下,依然能够满足整体的非线性建模的需求。
那么,这种非线性特征外加线性模型的方法有什么问题呢?
这类方法的主要问题是如何才能系统性地找到这些非线性特征呢其实传统的机器学习中的特征工程Feature Engineering主要就是在做特征寻找这个事情也就是要消耗人力和物力去寻找这些特征。有时候找到一个好的特征可能还需要灵感和其他领域特定的知识。
那么,有没有办法让模型自身就能从现有的数据中发现这些非线性关系,从而不需要额外的特征工程呢?
其实在机器学习发展的早期,研发人员就意识到了这个方向的重要性,这样就发展出了各种各样的非线性模型。
这里面比较有代表性的模型是“决策树”Decision Tree以及在此基础上发展出来的一系列“树模型”Tree Models。我们在专栏里介绍过树模型在搜索和推荐的一些场景中都有不错的性能表现。在很大程度上树模型可以表达非线性的关系但是它的困难之处在于无法表达过于深层次的数据关系。一般来说3到4层的树已经算是比较深的结构了如果一个树模型有特别多层次还能被训练成功这样的例子是比较少见的。
除了树模型之外还有一类模型用来挖掘数据中间的隐含关系特别是非线性关系这就是“概率图模型”Probabilistic Graphical Model。例如在文本挖掘领域非常流行的LDALatent Dirichlet Allocation以及在推荐领域流行的矩阵分解Matrix Factorization都可以被看作是概率图模型中的佼佼者。
概率图模型的一大优势是可以融入众多对于数据以及所需要处理问题的直觉从而能够让模型具有一定的可解释性甚至是“因果性”Causality。但是概率图模型的最大挑战就是每一个模型都需要单独计算训练算法。也就是说算法无法做到普适通用。这就极大地限制了概率图模型在实际问题特别是大数据问题中的应用。
综合来看,我们急需一种方法,能够自动挖掘数据中的非线性关系,而且最好能够找到数据中的隐含规律,这种隐含的规律可能是非常多层次的非线性转换;并且,这种方法还需要在计算上直接通用,不同模型可以共用一个计算框架。
所有这些因素如何囊括在一个方法里呢?答案就是深度学习技术。
深度学习的特点
深度学习技术慢慢成为了主流的非线性模型。接下来我们来看一看深度学习技术的一些特点。
首先,深度学习技术是一个非常大的外延,这里面包含了很多不同的模型和模型的计算框架技术。这两者都是深度学习成功必不可少的组成部分。
深度学习中有一种最简单也是最基础的模型就是“深度神经网络”Deep Neural Networks。这种模型其实很早就已经被提出了。
从形式上来说,深度神经网络就是把多层简单的非线性操作叠加起来,希望能够发现更加复杂的非线性关系。实际上,有理论研究表明,在有足够的内部隐含变量的情况下,深度神经网络可以表达任意复杂的函数关系。也就是说,深度神经网络有希望能够对现实世界中的复杂现象进行建模。这一点对于我们刚才提到的树模型和概率图模型来说,有相当大的难度或者说几乎是不可能的。
另外,回到我们刚才说的特征工程的需求,深度神经网络的确可以减轻这方面的压力。虽然并不如很多人预期的那样,深度神经网络依然需要依赖一定的初始数据,但是已经有实验表明,通过深度神经网络学习的特征在很多时候相比于研发人员手动挖掘的特征要更加健壮。
实际上,在计算机视觉这样的领域里,利用深度神经网络来挖掘特征基本上已经完全代替了手动的特征挖掘。
深度神经网络还有一个特点就是计算的普适性。刚才我们讲到概率图模型有一个“死穴”,就是计算无法做到模型普适,或者说在不牺牲性能的前提下,计算无法做到普适性。那么这一点来说,以深度神经网络为代表的深度学习,依赖简单的梯度下降就能对非常复杂的网络进行计算。而这种计算是可以针对不同的模型的,因此这就极大地降低了深度学习在实际工程应用中的代价。
小结
今天我为你讲了基于深度学习的计算机视觉技术的第一篇分享,帮助你对深度学习有一个更加明确的认识。
一起来回顾下要点:第一,深度学习技术能够自动挖掘数据中的非线性关系;第二,在计算机视觉领域里,利用深度神经网络来挖掘特征已经基本代替了手动的特征挖掘,而且,深度神经网络还具有计算的普适性。
最后,给你留一个思考题,和树模型或者概率图模型相比较,深度学习有什么劣势吗?如果有,你认为它最大的劣势是什么?

View File

@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
119 基于深度学习的计算机视觉技术(二):基本的深度学习模型
在上一期的介绍里,我们讨论了以深度学习为背景的计算机视觉技术,重点讲解了为什么需要深度学习,特别是从传统模型的眼光来看深度学习模型的特点。
今天,我们来聊一聊应用到图像上的一些最基本的深度学习模型。
前馈神经网络
前馈神经网络Feedforward Networks应该算是最基本的神经网络架构了。这种架构是理解其他神经网络结构的基础。
我们可以从最基本的线性模型Linear Model入手来理解前馈神经网络。线性模型说的是有一组输入x然后有一个输出y我们学习到一组向量有的时候也叫作系数w来通过x预测y。这种线性模型可以算是最简单的机器学习模型了。在图像的情况下输入往往是一个向量输出也是一个向量这个时候我们需要学习的系数就从一个向量变为一个矩阵了。
那么,试想一下,如果我们把多层的线性模型进行叠加,能否得到多层的神经网络结构呢?答案是否定的。即便是多层的线性模型,只要每一层的变换是线性的,那么最后的结果一定也是线性的。因此,要想构建多层的非线性模型,每一层的变换也一定要是非线性的。
那么,如何在线性模型的基础上,我们只进行一些最小的改动,就能引入非线性的因素呢?
在这里我们引入一个叫“激活函数”Activation Function的概念。直观地理解激活函数就是在线性模型输出的基础上进行非线性变换。一个最简单的激活函数就是Sigmoid函数也就是把负无穷到正无穷的实数给映射到01这个范围内。我们经常提到的对数几率回归其实就是对这种变换的另一种称呼。
利用了Sigmoid激活函数的线性模型本质上就是在做二元分类。在神经网络发展的早期Sigmoid激活函数是一种普遍使用的非线性变换方式。遗憾的是在之后的发展中研究人员发现了Sigmoid函数在数值稳定性上存在严重的问题。
具体来说在很多机器学习的优化算法中我们都需要依赖“梯度下降”Gradient Descent的方法来优化目标函数。在前馈神经网络中梯度下降也是一种简单的优化神经网络并且学习到系数矩阵的方法。但是因为有Sigmoid函数的存在计算的梯度有可能会溢出或者归零。在这样的情况下模型就无法得到正常的学习。
为了解决Sigmoid激活函数的问题研究人员发明了“线性整流函数”Rectified Linear Unit或简称ReLu函数。和Sigmoid函数相比ReLu函数直接留下大于0的数值把小于0的数值统统归0。在ReLu函数的帮助下我们能够更容易地训练多层的前馈神经网络。
有了非线性的转换之后,前馈神经网络往往可以把多个非线性的转换给叠加在一起,形成多层的结构。有了这个多层的转换之后,最后一层往往是是把已经有的信息再映射到最终需要的输出上。这可以是一个回归问题,也可以是一个分类问题。
从我们之前提到的特征提取的角度来讲,前馈神经网络的中间层次就是利用最原始的信息来提取数据的特征,而最后一层可以当作是我们之前讲过的线性模型层。只不过和手动构造复杂特征有一个不同的地方,前馈神经网络是自动学习这样的特征。
卷积神经网络
了解了最基本的前馈神经网络之后,我们来看一看它是怎么应用到图像处理中的。我们在之前的计算机视觉基础知识中讲到过,一种最直观的表达图像数据的方法就是把图像看成矩阵数据。
比如有一个长32像素、宽32像素并且有3个颜色通道Channel或者简称为“32乘32乘3”的图像如果我们采用前馈神经网络来对这个图像进行建模需要学习的系数或者说权重是多少呢就是把这3个数乘起来一共有3072个系数需要学习。如果说这还是一种可以接受的方案的话那么一个长宽为200像素也是3个颜色通道的图片就需要12万个系数。很显然直接采用前馈神经网络来表达图想信息需要大量待学习的参数。那么我们在这里就需要有一种方法能够更加简洁地表达数据。
卷积神经网络Convolutional Neural Networks简称为CNN就是来解决这类问题的一种神经网络架构。其实CNN最初就是专门为视觉问题而提出的。那么如何利用CNN来解决这个问题呢
首先卷积神经网络试图用向量来描述一个矩阵的信息。从工具的角度来讲卷积神经网络利用两个特殊的架构来对图像数据进行总结一个叫“卷积层”Convolutional Layer一个叫“池化层”Pooling Layer
在简单理解卷积层和池化层之前我们先来看一下卷积神经网络是采用怎样的架构来处理图像的。一般来说图像的输入要先经过一个卷积层再经过一个线性整流函数然后经过一个池化层再经过一个全联通层也就是前馈神经网络最后得到一个向量的表达。需要注意的是卷积神经网络的优势就是直接处理3维数据进行3维的变换。
现在我们来看卷积层是如何工作的。卷积层直接作用在3维的输入上利用另外一个3维的“过滤器”Filter来对原始的数据进行卷积操作。例如对于一个“32乘32乘3”的图像来说我们可以使用一个“5乘5乘3”的过滤器来对其进行卷积。
在这里,我不展开来讲卷积操作,如果你有兴趣可以在网上找到卷积操作的数学定义。在这里,我们只需要理解卷积操作是利用一个小的过滤器来对原始的更大的图像进行函数变换,从而希望能够提取图像的局部特征。
在刚才这个例子中可以看到,因为有了卷积层,我们就不需要针对原始大小的图像进行表达了,而仅仅需要学习过滤器上的参数,这种方法就大大减少了参数的数目。
池化层的目的是对数据进行进一步的高度总结和概括。对于一个矩阵来说池化层针对某一个矩阵的局部采用平均值、最大值来总结这个区域的矩阵数值。例如我们有一个“4乘4”一共16个单元的矩阵如果我们针对每个“2乘2”的区域加以最大值Max Pooling池化那么我们就可以把16个单元的数据总结为“2乘2”也就是一共4个单元的数据。每个单元是原来矩阵中“2乘2”区域中的最大值。
当一个图像经过了卷积和池化等一系列的操作以后,我们就说已经提取了这个图像的关键特征。这个时候,我们往往会把数据经过基本的前馈神经网络来进一步融合,最后能够完整地总结数据信息。在前馈神经网络之后,这就又是一个线性的决策层,可以是回归,也可以是分类。
小结
今天我为你讲了基于深度学习的计算机视觉技术的第二篇分享,帮助你对深度学习的基本模型,包括前馈神经网络和卷积神经网络有一个基本的认识。
一起来回顾下要点:第一,我们从线性模型入手讨论了前馈神经网络架构,重点介绍了激活函数和线性整流函数;第二,我们讲了在前馈神经网络基础之上的卷积神经网络,核心是用向量来描述一个矩阵的信息。
最后,给你留一个思考题,卷积神经网络中池化层的必要性是什么?为什么需要总结周围的单元到一个单元?这难道不是丢失了很多信息吗?

View File

@ -0,0 +1,69 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
120 基于深度学习的计算机视觉技术(三):深度学习模型的优化
在上一讲的分享里,我们聊了应用到图像上的一些最基本的深度学习模型,主要讨论了前馈神经网络和卷积神经网络的定义,以及它们在图像处理上的应用。
今天,我们从优化的角度来讨论,如何对深度学习模型进行训练。可以说,模型优化是成功利用深度学习模型的关键步骤。
一般机器学习模型的优化
要想了解深度学习模型的优化,我们首先得来看一看一般机器学习模型的优化。先了解一些基本的步骤,我们在讨论深度学习模型优化的时候就能更容易地看清事物的本质。
在开始说模型优化之前,要说一点需要注意的问题,我觉得这一点对于初学者来说尤为重要,那就是要区分开模型、目标函数和优化过程。这三个实体相互关联而且相互影响,我们需要对每一个实体都有一个清晰的理解。
我们以线性模型作为例子,来感受下这三个实体的关系。
我们说一个模型是线性模型是指我们期望利用一组特征Feature来对一个输出反馈进行线性预测。这里的线性其实主要是指参数针对于反馈变量而言是线性的。
需要注意的是,线性模型是一个数学模型。线性模型的设置本身并没有限定这个模型的参数(也就是模型的系数)是如何得来的,也就是数学模型本身的设置和得到参数的过程往往是互相独立的。我们把得到参数的过程叫作模型训练或者简称为模型优化过程。
对于线性模型而言我们常常利用最小二乘法来构造参数学习的目标函数。在最小二乘法的目标函数下一般情况下我们既可以得到一个“解析解”Closed Form Solution也能通过例如梯度下降的方法来进行数值优化。
对模型、目标函数和优化过程这几个概念有了清晰的认识后,那具体的模型优化过程是怎样的呢?
这里,我们就总结一下一般机器学习模型,主要是简单模型的优化过程。
模型优化的第一步就是选择目标函数。总的来说简单的机器学习模型主要有两类目的回归和分类。对于回归而言我们选择最小二乘法也就是“平方损失”Squared Loss作为目标函数对于分类而言我们选择“对数几率损失”Logistic Loss。这两种损失和模型是否是线性并没有直接的关系。当然对于简单模型来说模型往往是线性的。那么当模型是线性的而目标函数又是我们刚才所说的这两类这种情况下我们找到的其实就是线性回归和对数几率回归这两大基本模型。
当我们选择好了目标函数之后,下面一个步骤一般是尝试根据目标函数寻找参数的最优解。这一个步骤我们往往需要根据参数尝试写出参数的梯度。对于简单的线性模型来说,这一步往往相对比较容易。但是有一些模型,包括深度学习模型,梯度并不是那么直观就能够得到的。这也就直接导致下面的步骤变得更加复杂。
得到梯度以后,一般来说,我们首先尝试有没有可能得到一个解析解。
有解析解,往往就意味着我们并不需要通过迭代的方法来得到一个数值优化的解。解析解往往也不是近似解,而是一个确切的答案。当然,在真实的数据中,一些理论上的解析解因为数值稳定性的因素依然无法得到。对于解析解来说,我们需要写出参数的梯度,然后尝试把等式置零,然后看是否能够解出参数的表达式。这个过程并不一定对于每一个模型都适用。
如果我们没法得到解析解,就需要另外一个方法了,那就是利用数值计算来取得一个近似解。在有了梯度信息以后,一种最普遍的数值计算方法就是梯度下降法。从原则上来说,梯度下降是求一个函数最小值的数值流程。如果你需要求一个函数最大值的流程,那就需要梯度上升。
怎样才能保证梯度下降一定能够得到最优解呢一般来说梯度下降并不能保证找到函数参数的最优解往往只能找到一个局部最优解。对于凸问题Convex Problem而言局部最优也就是全局最优。因此从理论上说梯度下降能够找到凸问题的全局最优解。当然到底多快能够找到这个最优解也就是算法的收敛速度是怎样的就又是另外一个问题了。
但是对于非凸Non Convex Problem问题而言梯度下降仅仅能够收敛到一个局部最优解这个解是否能够被接受还有待考证。
深度学习模型的优化
在这里,我们从普通的模型衍生出来,看一看深度学习模型的优化问题。
和普通模型一样,深度学习模型也需要一个目标函数来对参数进行有效学习。我们前面在介绍深度学习模型的时候提到过,很多时候,深度模型都充当了更加复杂的特征提取器的角色。而在最后一层的表达中,我们可以认为是在复杂特征后的线性模型。因此,我们依然可以使用回归(或者说平方损失),抑或分类(或者说是对数几率损失),来对不同的问题进行建模。
遗憾的是,深度模型的特点就是多层。而进行优化的流程中,第一个步骤就是梯度的计算,这一步因为模型的多层变得复杂起来。从概念上来说,我们需要得到在当前迭代下,针对模型参数的梯度,这些梯度包括每一个隐含层的参数。我们之所以能够计算深度模型,第一个重要发展就是能够计算这些梯度。
在深度学习的语境中计算梯度的方法叫作“反向传播”Back Propagation。关于如何计算反向传播网络上有很多教程我们在这里就不赘述了。你需要记住的是反向传播是为了有效快速地计算梯度。
那么,有了梯度之后,我们是不是就能够得到深度模型参数的解析解呢?
很可惜,我们无法得到一个解析解。原因是深度模型的复杂性以及其高度的非凸性。我们不仅无法得到一个解析解,也没有办法轻易得到一个全局最优解。能够采用的一种办法就是使用梯度下降来对问题进行近似求解。而如何利用梯度下降的办法来对深度模型有效求解,一直都是深度学习研究领域的一个重心。
在过去将近10年的研究中大家发现一个好的初始值往往能够让优化过程变得容易一些。而在梯度下降的过程中有一些下降方法也要好于其他的方法。一些小技巧比如Dropout批量归一化Batch Normalization已经变成了深度模型优化的标准流程之一目的就是能够有效地计算梯度而且有较好的数值稳定性。
小结
今天我为你讲了机器学习模型,包括传统模型和深度学习模型的优化流程。
一起来回顾下要点:第一,我们聊了什么是模型、目标函数和优化过程这三个概念,清晰理解这三个概念之间的关系非常重要;第二,我们从一般机器学习模型的优化过程入手,讲了深度模型的优化。
最后,给你留一个思考题,有哪些因素造成了深度模型优化的困难?

View File

@ -0,0 +1,69 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
121 计算机视觉领域的深度学习模型AlexNet
我们继续来讨论基于深度学习的计算机视觉技术。从今天开始,我们进入一个新的模块,我会结合几篇经典的论文,给你介绍几个专门为计算机视觉而提出来的深度学习模型。这些模型都在最近几年的深度学习发展中,起到了至关重要的作用。
我们这个系列要分享的第一篇论文题目是《基于深度卷积神经网络的图像网络分类》ImageNet Classification with Deep Convolutional Neural Network[1]。因为这篇文章的第一作者名字叫Alex所以文章提出的模型也经常被称为AlexNet。
那接下来我们就先介绍一下这篇论文的作者群。
第一作者就是亚力克斯·克里切夫斯基Alex Krizhevsky。发表这篇论文的时候他在多伦多大学计算机系攻读博士学位之后的2013~2017年间在谷歌任职继续从事深度学习的研究。
第二作者叫伊利亚·苏兹克维Ilya Sutskever。发表这篇论文的时候苏兹克维也在多伦多大学计算机系攻读博士学位之后到斯坦福大学跟随吴恩达做博士后研究。2013~2015年间他在谷歌担任研究科学家一职。2016年之后他参与共同创立了OpenAI并且担任研究总监这一职位。苏兹克维在深度学习方面已经发表了很多篇论文目前论文的引用数已经超过7万次。
最后一位作者是杰弗里·辛顿Geoffrey Hinton。对于辛顿我们就比较熟悉了他是多伦多大学计算机系的教授是机器学习特别是深度学习的学术权威。可以说几十年来辛顿都在以神经网络为代表的深度学习领域深耕即便是在其他学术思潮涌动的时候他都能够坚持在深度学习这一领域继续钻研这种精神让我们钦佩。
论文的主要贡献
如何来描述这篇论文的主要贡献呢?简而言之,这篇论文开启了深度学习在计算机视觉领域广泛应用的大门。通过这篇论文,我们看到了深度学习模型在重要的计算机视觉任务上取得了非常显著的效果。
具体来说在ImageNet 2012年的比赛中文章提到的模型比第二名方法的准确度要高出十多个百分点。能够达到这个效果得益于在模型训练时的一系列重要技巧。这篇论文训练了到当时为止最大的卷积神经网络而这些技巧使得训练大规模实用级别的神经网络成为可能。
论文的核心方法
要了解AlexNet的一些核心方法我们就需要简单提一下ImageNet竞赛的数据集。这个数据集在当时有大约120万张训练图片5万张验证图片和15万张测试图片。这些图片属于1000个类别。这个数据集在当时来说应该算是无可争议的大型数据集。为了能够方便地处理这些图片作者们把所有图片的分辨率都重新调整到了“256*256”。AlexNet直接在这些图片的RGB像素点上进行建模。
整个模型的架构是怎样的呢AlexNet一共包含8层网络结构5层全联通层也就是前馈神经网络。这8层网络架构总体来说是逐渐变小的一个趋势也就是说每一层提取的信息越来越呈现高度的概括性。
那么在整个架构中,这篇文章提出的模型有哪些独到之处呢?
第一AlexNet采用了“线性整流函数”ReLu来作为激活函数。虽然这个选择在今天看来可以说是非常平常甚至已经成为了神经网络建模的默认选项。但这个选择在当时还是很大胆的一种创新。这个创新带来了训练时间的大幅度减少同时还能保持甚至提升了模型性能。
第二整个模型的训练大量采用了GPU并且使用了多个GPU来进行计算。这一点就在速度上和模型的大小上彻底解放了模型的选择。以前仅仅利用单个GPU的方式没办法把所有的训练数据都放入一个GPU上。
第三作者们介绍了一种叫作“局部响应归一化”Local Response Normalization的方法来对每层之间的单元进行归一。
如何进行最有效的归一,以及这些归一化有什么作用,这些问题一直都是深度学习研究面临的重要课题。从实际的使用上来看,这种局部响应归一化的方法在几年之后让位给了其他更为主流的归一方法。但是从这一个技术要点来看,我们要想把深度学习模型真正应用到实际场景任务中,归一化是一个必不可少的组件。
第四作者们在AlexNet里面使用了所谓的“重叠池化”Overlapping Pooling这种方法。在普通的卷积神经网络中“池化”的作用是从周围的单元中总结出必要的信息。一般来说池化的过程中并不重复覆盖相同的单元。也就是说池化不会重叠。而在这篇论文中作者们发现重叠池化能够降低错误率虽然非常微量但是很重要。这个组件在之后的发展中并不多见。
除了在网络架构上的一些创新之外AlexNet的训练过程中最需要注意的是防止“过拟合”Overfitting。在很长的一段时间里我们没有办法把深度神经网络模型应用在实际场景中一个很重要的原因就是过拟合。可以说如何防止神经网络模型过拟合这个问题让研究人员伤透了脑筋。
所谓过拟合就是说模型在训练集上工作得很好但是无法“泛化”Generalization到测试集也就是没有出现过的数据上。无法泛化其实也就证明训练的模型对未知数据的预测能力很差。
这篇论文中主要提到了两种防止过拟合的方法。
第一种思路叫“数据增强”Data Augmentation。简单来说这里的思路其实就是增加“虚拟数据”来增加数据的多样性从而能够让模型更加健壮。那虚拟数据是怎么来的虚拟数据其实来源于真实数据的变形。
第二种思路就是 Dropout。这种方法在当时看显得很随便就是在训练的时候随机把一些单元置零。作者们发现在这样随机置零的过程后模型会变得更加稳定。值得一提的是Dropout已经成为了这几年深度学习的一个标配。
小结
今天我为你讲了第一篇基于深度学习的经典论文讨论了AlexNet这个模型这个模型开启了深度学习全面进军计算机视觉领域的时代。
一起来回顾下要点第一AlexNet模型在ImageNet 2012竞赛中胜出让我们看到了深度学习模型在计算机视觉领域中所取得的显著效果第二我们讨论了AlexNet模型的四大创新之处以及论文提出的两种防止过拟合的方法。
最后给你留一个思考题站在现在的立场AlexNet在当时的成功是否给你一些启发呢
参考文献
ImageNet Classification with Deep Convolutional Neural Networks, Alex Krizhevsky, Ilya Sutskever, and Geoffrey Hinton, NIPS, 2012.

View File

@ -0,0 +1,63 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
123 计算机视觉领域的深度学习模型ResNet
今天我们继续来讨论经典的深度学习模型在计算机视觉领域应用。今天和你分享的论文是《用于图像识别的深度残差学习》Deep Residual Learning for Image Recognition[1]。这篇论文获得了CVPR 2016的最佳论文在发表之后的两年间里获得了超过1万2千次的论文引用。
论文的主要贡献
我们前面介绍VGG和GoogleNet的时候就已经提到过在深度学习模型的前进道路上一个重要的研究课题就是神经网络结构究竟能够搭建多深。
这个课题要从两个方面来看:第一个是现实层面,那就是如何构建更深的网络,如何能够训练更深的网络,以及如何才能展示出更深网络的更好性能;第二个是理论层面,那就是如何真正把网络深度,或者说是层次度,以及网络的宽度和模型整体的泛化性能直接联系起来。
在很长的一段时间里,研究人员对神经网络结构有一个大胆的预测,那就是更深的网络架构能够带来更好的泛化能力。但是要想真正实现这样的结果其实并不容易,我们都会遇到哪些挑战呢?
一个长期的挑战就是模型训练时的梯度“爆炸”Exploding或者“消失”Vanishing。为了解决这个问题在深度学习研究刚刚开始的一段时间就如雨后春笋般爆发出了很多技术手段比如“线性整流函数”ReLu“批量归一化”Batch Normalization“预先训练”Pre-Training等等。
另外一个挑战是在VGG和GoogleNet的创新之后大家慢慢发现单纯加入更多的网络层次其实并不能带来性能的提升。研究人员有这样一个发现当一个模型加入到50多层后模型的性能不但没有提升反而还有下降也就是模型的准确度变差了。这样看好像模型的性能到了一个“瓶颈”。那是不是说深度模型的深度其实是有一个限度的呢
我们从GoogleNet的思路可以看出网络结构是可以加深的比如对网络结构的局部进行创新。而这篇论文就是追随GoogleNet的方法在网络结构上提出了一个新的结构叫“残差网络”Residual Network简称为 ResNet从而能够把模型的规模从几层、十几层或者几十层一直推到了上百层的结构。这就是这篇文章的最大贡献。
从模型在实际数据集中的表现效果来看ResNet的错误率只有VGG和GoogleNet的一半模型的泛化能力随着层数的增多而逐渐增加。这其实是一件非常值得深度学习学者振奋的事情因为它意味着深度学习解决了一个重要问题突破了一个瓶颈。
论文的核心方法
那这篇论文的核心思想是怎样的呢?我们一起来看。
我们先假设有一个隐含的基于输入x的函数H。这个函数可以根据x来进行复杂的变换比如多层的神经网络。然而在实际中我们并不知道这个H到底是什么样的。那么传统的解决方式就是我们需要一个函数F去逼近H。
而这篇文章提出的“残差学习”的方式就是不用F去逼近H而是去逼近H(x)减去x的差值。在机器学习中我们就把这个差值叫作“残差”也就是表明目标函数和输入之间的差距。当然我们依然无法知道函数H在实际中我们是用F去进行残差逼近。
F(x)=H(x)-x当我们把x移动到F的一边这个时候就得到了残差学习的最终形式也就是F(x)+x去逼近未知的H。
我们引用论文中的插图来看这个问题就会更加直观。图片来源https://www.cv-foundation.org/openaccess/content_cvpr_2016/papers/He_Deep_Residual_Learning_CVPR_2016_paper.pdf
在这个公式里外面的这个x往往也被称作是“捷径”Shortcuts。什么意思呢有学者发现在一个深度神经网络结构中有一些连接或者说层与层之间的关联其实是不必要的。我们关注的是什么样的输入就应当映射到什么样的输出也就是所谓的“等值映射”Identity Mapping
遗憾的是如果不对网络结构进行改进模型无法学习到这些结构。那么构建一个从输入到输出的捷径也就是说从x可以直接到H或者叫y而不用经过F(x)在必要的时候可以强迫F(x)变0。也就是说捷径或者是残差这样的网络架构在理论上可以帮助整个网络变得更加有效率我们希望算法能够找到哪些部分是可以被忽略掉的哪些部分需要保留下来。
在真实的网络架构中作者们选择了在每两层卷积网络层之间就加入一个捷径然后叠加了34层这样的架构。从效果上看在34层的时候ResNet的确依然能够降低训练错误率。于是作者们进一步尝试了50多层再到110层一直到1202层的网络。最终发现在110层的时候能够达到最优的结果。而对于这样的网络所有的参数达到了170万个。
为了训练ResNet作者们依然使用了批量归一化以及一系列初始化的技巧。值得一提的是到了这个阶段之后作者们就放弃了Dropout不再使用了。
小结
今天我为你讲了一篇经典论文提出了ResNet残差网络这个概念是继VGG和GoogleNet之后一个能够大幅度提升网络层次的深度学习模型。
一起来回顾下要点:第一,我们总结归纳了加深网络层次的思路以及遇到的挑战;第二,我们讲了讲残差网络的概念和这样做背后的思考以及在实际应用中的一些方法。
最后给你留一个思考题从AlexNet到VGG、GoogleNet再到ResNet除了网络深度加深以外模型进化过程中是否还有一些地方也让你有所感触
参考文献
Kaiming He, Xiangyu Zhang, Shaoqing Ren, Jian Sun. Deep Residual Learning for Image Recognition. The IEEE Conference on Computer Vision and Pattern Recognition (CVPR), pp. 770-778, 2016.

View File

@ -0,0 +1,62 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
124 计算机视觉高级话题(一):图像物体识别和分割
从今天开始,我们讨论几个相对比较高级的计算机视觉话题。这些话题都不是简单的分类或者回归任务,而是需要在一些现有的模型上进行改进。
我们聊的第一个话题就是图像中的物体识别Object Recognition和分割Segmentation。我们前面介绍过物体识别和分割。通俗地讲就是给定一个输入的图像我们希望模型可以分析这个图像里究竟有哪些物体并能够定位这些物体在整个图像中的位置对于图像中的每一个像素能够分析其属于哪一个物体。
这一类型任务的目的是更加仔细地理解图像中的物体,包括图片分类、对图像里面的物体位置进行分析,以及在像素级别进行分割,这无疑是一个充满挑战的任务。
R-CNN
深度模型特别是卷积神经网络CNN在AlexNet中的成功应用很大程度上开启了神经网络在图像分类问题上的应用。这之后不少学者就开始考虑把这样的思想利用到物体识别上。第一个比较成功的早期工作来自加州大学伯克利分校[1]这就是我们接下来要介绍的R-CNN模型。
首先R-CNN的输入是一个图片输出是一个“选定框”Bounding Box和对应的标签。R-CNN采用了一种直观的方法来生成选定框尽可能多地生成选定框然后来看究竟哪一个选定框对应了一个物体。
具体来说针对图像R-CNN先用不同大小的选定框来扫描并且尝试把临近的具有相似色块、类型、密度的像素都划归到一起去。然后再利用一个AlexNet的变形来对这些待定Proposal的选定框进行特征提取Feature Extraction。在模型的最后一层R-CNN加入了一个支持向量机Support Vector Machine来判断待选定框是否是某个物体。判断好了选定框以后R-CNN再运行一个线性回归来对选定框的坐标进行微调。
R-CNN虽然证明了在物体识别这样的任务中CNN的确可以超越传统的模型但整个模型由多个模块组成相对比较繁琐。
Fast R-CNN
意识到了R-CNN的问题以后一些学者开始考虑如何在这个模型上进行改进。第一个重大改进来自于R-CNN原文中的第一作者罗斯·吉尔什克Ross Girshick。吉尔什克这个时候已经来到了微软研究院他把自己改进的模型叫作Fast R-CNN[2]。
Fast R-CNN的一个重要特点就是观察到我们刚才介绍R-CNN中的第二步骤也就是每一个待定的选定框都需要进行特征提取。这里的特征提取其实就是一个神经网络往往非常消耗资源。而且很多待定的选定框有很多重叠的部分可以想象就会有很多神经网络的计算是重复多余的。
那么有没有什么办法我们可以针对一个图片仅仅运行一次神经网络但是又可以针对不同的待选定框共享呢这其实就是Fast R-CNN的核心思想。Fast R-CNN的另外一个特点就是尝试用一个神经网络架构去替代R-CNN中间的四个模块。这样两个改进的结果是怎样的呢Fast R-CNN和R-CNN相比在效果上差不多但是训练时间快了9倍以上。
Faster R-CNN和Mask R-CNN
在Fast R-CNN的技术上一群当时在微软研究院的学者们把对R-CNN的加速往前推进了一步这就是模型Faster R-CNN[3]。Faster R-CNN是在如何提出待定的选定框上做了进一步的改进使得这部分不依赖一个单独的步骤而依赖我们已经训练的CNN网络。这在速度上比Fast R-CNN又快了不少。
在Faster R-CNN的基础上Mask R-CNN不仅能够做到对图像中的物体进行判别而且还能够做到像素级的抽取[4]。前面我们在讲2017年ICCV最佳研究论文的时候介绍过这部分内容。这里我带你做一个简单的回顾。
Faster R-CNN分为两个阶段。第一个阶段是“区域提交网络”Region Proposal Network目的是从图像中提出可能存在候选矩形框。第二个阶段从这些候选框中使用“RoIPool”这个技术来提取特征从而进行标签分类和矩形框位置定位这两个任务。这两个阶段的一些特征可以共享。
区域提交网络的大体流程是什么样的?大体来说,最原始的输入图像经过经典的卷积层变换之后形成了一个图像特征层。在这个新的图像特征层上,模型使用了一个移动的小窗口来对区域进行建模。
这个移动小窗口有这么三个任务需要考虑。首先移动小窗口所覆盖的特征经过一个变换达到一个中间层,然后经过这个中间层,直接串联到两个任务,也就是物体的分类和位置的定位。其次,移动的小窗口用于提出一个候选区域,也就是矩形框。而这个矩形框也参与刚才所说的定位信息的预测。当区域提交网络“框”出了物体的大致区域和类别之后,模型再使用一个“物体检测”的网络来对物体进行最终的检测。
Mask R-CNN的第一部分完全使用Faster R-CNN所提出的区域提交网络模型对第二部分进行了更改。那Mask R-CNN的第二部分都输出什么呢不仅仅输出区域的类别和框的相对位置同时还输出具体的像素分割。和很多类似工作的区别是像素分割、类别判断、位置预测是三个独立的任务并没有互相的依赖这是作者们认为Mask R-CNN能够成功的一个重要的关键。
小结
今天我为你讲了计算机视觉高级话题之一的物体识别和分割技术。我们总结了从最早的R-CNN到加速的Fast R-CNN和更快的Faster R-CNN以及最后能够进行像素分割的Mask R-CNN。
最后,给你留一个思考题,从这一系列模型的发展中,你能总结出一些心得体会吗?
参考文献
Ross Girshick, Jeff Donahue, Trevor Darrell, Jitendra Malik. Rich Feature Hierarchies for Accurate Object Detection and Semantic Segmentation. The IEEE Conference on Computer Vision and Pattern Recognition (CVPR), pp. 580-587, 2014.
Ross Girshick. Fast R-CNN. The IEEE International Conference on Computer Vision (ICCV), pp. 1440-1448, 2015.
Shaoqing Ren, Kaiming He, Ross Girshick, and Jian Sun. Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks. Conference on Neural Information Processing Systems (NIPS), 2015.
K. He, G. Gkioxari, P. Dollar and R. Girshick. Mask R-CNN. In IEEE Transactions on Pattern Analysis and Machine Intelligence.

View File

@ -0,0 +1,67 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
125 计算机视觉高级话题(二):视觉问答
今天我们继续分享计算机视觉领域的高级话题聊一聊“视觉问答”Visual Question Answering这个话题。
我们在前面曾经提到过“问答系统”Question Answering可以说这是人工智能领域最核心的问题之一。传统的问答系统主要是针对文字而言的问题和答案都是以文字的形式表达的。当然问答所针对的内容有可能来自一个外在的知识库例如维基百科。
我们今天要讨论的视觉问答特别是“自由形式”Free-Form或者“开放形式”Open Ended的视觉问答主要指的是根据一个图片进行自由的基于自然语言的问答。例如我们可以问一个图片中是否存在一只猫或者可以问图片里的天气是不是阴天等等。
视觉问答的挑战
那么,为什么视觉问答会在最近几年里得到很多学者的关注呢?我们有必要先来分析一下视觉问答所面临的挑战。
首先,视觉问答需要对图片中的细节加以理解。例如,我们问图片中的匹萨用了哪种奶酪,那就代表着我们的系统必须能够识别匹萨中的奶酪,而这往往意味着非常微观的一些细节的物体的识别。
其次,视觉问答还需要我们对图片的上下文进行理解。例如,我们可以问图片中有几辆自行车。这个问题其实不仅需要我们对图片中的自行车进行理解,还需要能够计数,这显然是一种更加复杂的理解任务。
除此以外,我们还需要对图片中的物体进行推理。例如,我们问图片中的匹萨是不是素食匹萨。那这个问题就需要对匹萨的种类进行分类,这是一个最基本的推理。
当然,视觉问答的挑战还远远不止这些。但从这些例子我们已经可以看出,视觉问题是一个综合性的人工智能问题。
不少视觉问答的数据集除了纯粹的图片作为输入以外还有一个图片的“标题”Caption。这个图片标题往往提供了不少的信息也算是帮助研究者在一定程度上降低了任务的难度。
如果需要对视觉问答的总体情况有一个更加深入的理解,推荐你阅读我在文末列出的参考文献[1]。
视觉问答建模
接下来我们来聊一个视觉问答的基础模型[1]。这个模型需要对问题、图片以及图片标题分别进行建模,从而能够进行问答。
针对问题模型利用所有问题中的重要词进行了“词包”Bag of Words的表达并且得到了一个1030维度的输入表征。类似地针对图片标题模型也进行了词包表达得到了一个1000维度最高频词的表征。最后作者们利用了VGG网络来提取图片的特征得到了一个4096维度的图像表征。一种更加简单的方法则是先利用神经网络的隐含层针对每一种特征单独训练然后把第一层中间层给串联起来。串联起来之后这就是所有特征的一种联合的表达了。那么我们可以再经过一层隐含层学习到各个表征之间的相互关系。
文章中还讨论了另外一种模型那就是利用LSTM来把问题和图像结合到一起来最后对回答进行预测。
在这样的模型架构下回答的准确度大概在55%左右。如何来理解这个准确度呢在同样的一个数据集中如果针对所有的问题回答都是“是”Yes所达到的准确度大概是20%多。
在最初的模型被开发出来以后的几年时间里针对视觉问答的各类模型如雨后春笋般爆发式地增长。其中一个大类的模型利用了“关注”Attention机制。在深度模型中关注机制是一种相对来说复杂一些的“加权”模式。也就是说我们希望对某一些神经元或者是隐含变量更加关注一些。这个机制在视觉问答中的一种应用就是针对不同的问题我们希望让模型学习到图片的哪一部分来负责回答。
在一篇论文中[2]作者们提出了一种更加高级的“关注”机制那就是“层次同关注”Hierarchical Co-Attention
这个机制是什么样的呢?针对某一个回答,我们不仅要学习到究竟需要模型“看到”图片的某一个局部,这也就是我们刚才说到的“加权”,还需要针对问题,也就是文字,进行“加权”。这里的一个观察是,有时候一个问题中的核心其实就是几个关键词,这些关键词直接影响了回答。这就是“同关注”这一概念。
文章中还提出了另外一个概念,那就是“层次关注”,是指问题的文字,在单词、短语以及整个提问三个层次来进行建模。可以说,这种方法在语义的局部以及整体上更能找到问题的核心所在。
最后需要提及一点最近的一些研究又把视觉问答和“推理”Reasoning特别是“神经编程”Neural Programming联系起来让回答问题变成自动生成程序的某种特殊形式[3]。
小结
今天我为你讲了计算机视觉高级话题之一的视觉问答的概念。
一起来回顾下要点:第一,我们讲了视觉问答所面临的三大主要挑战;第二,我们讨论了对视觉问答进行建模的一些基本思路。
最后,给你留一个思考题,你觉得当前视觉问答的主要瓶颈是什么?
参考文献
Stanislaw Antol, Aishwarya Agrawal, Jiasen Lu, Margaret Mitchell, Dhruv Batra, C. Lawrence Zitnick, Devi Parikh. VQA: Visual Question Answering. The IEEE International Conference on Computer Vision (ICCV), pp. 2425-2433, 2015.
Jiasen Lu, Jianwei Yang, Dhruv Batra, and Devi Parikh. Hierarchical question-image co-attention for visual question answering. Proceedings of the 30th International Conference on Neural Information Processing Systems (NIPS16), Daniel D. Lee, Ulrike von Luxburg, Roman Garnett, Masashi Sugiyama, and Isabelle Guyon (Eds.). Curran Associates Inc., USA, 289-297, 2016.
Justin Johnson, Bharath Hariharan, Laurens van der Maaten, Judy Hoffman, Li Fei-Fei, C. Lawrence Zitnick, Ross B. Girshick. Inferring and Executing Programs for Visual Reasoning. ICCV 2017: 3008-3017.

View File

@ -0,0 +1,53 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
126复盘 5 计算机视觉核心技术模块
复盘 5 计算机视觉核心技术模块
今天我们来对计算机视觉核心技术模块做一个复盘。在这个模块里我们一起学习了12期内容讨论了四个话题这些话题主要围绕计算机视觉的基础知识和深度学习技术在这个领域的应用。
之所以这么安排,是因为没有深度学习技术,就不会有现在计算机视觉的发展。我们站得稍微高一点就可以看到,正是因为深度学习技术在计算机视觉中的成功应用,才有了近几年的人工智能浪潮。
提示:点击知识卡跳转到你最想看的那篇文章,温故而知新。
图像技术基础
基于深度学习的计算机视觉技术
计算机视觉领域的深度学习模型
计算机视觉高级话题
积跬步以至千里
最后,恭喜你学完了这个模块中的内容。今日记一事,明日悟一理,积久而成学。每一个收获都是一个不小的成就。

View File

@ -0,0 +1,78 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
126 计算机视觉高级话题(三):产生式模型
今天我们来讨论计算机视觉高级话题中的“产生式模型”Generative Model
我们前面讲到的计算机视觉问题绝大多数场景都是去学习一个“判别式模型”Discriminative Model也就是我们希望构建机器学习模型来完成这样的任务比如判断某一件事情或一个图片的特征或者识别图片里面的物体等这些任务都不需要回答“数据是如何产生的”这一问题。简言之针对判断类型的任务不管是回归还是分类我们都不需要对数据直接进行建模。
然而,判别式模型并不能解决所有的机器学习任务。有一些任务的核心其实需要我们针对数据进行有效的建模,这就催生了“产生式模型”。
产生式模型的一些基础概念
那么,针对数据建模的产生式模型都有哪些基本思想呢?
首先,产生式模型的一个核心思想就是模型要能够产生数据。也就是说,产生式模型不仅需要针对已有的数据建模,还需要有能力产生新的数据。产生出的这些新数据当然需要和已有的数据有一定的相似度。换句话说,新产生的数据要能够“以假乱真”。
那么,有哪些能够产生数据的工具呢?
在比较简单的模型中,概率分布其实就起了产生数据的作用。例如,在离散概率分布的世界里,如果我们知道了一个伯努利分布的参数,也就是某一个事件发生的概率,那么,从理论上来说,我们就可以模拟出这个事件的发生。
比如我们利用伯努利分布来对掷硬币产生的正反面建模。一旦我们知道了这个分布的概率是0.5或者说是50%),那么,我们从这个分布中产生的数据就可以形成和掷一枚没有偏差的硬币一样的效果。
同样的道理,如果我们利用正态(或者叫高斯)分布来针对一个连续变量建模,例如某一个地区的温度,那么一旦我们知道了这个正态分布的均值和方差这两个参数,我们就可以产生所有温度的可能值。假设温度完全服从这个正态分布,那么就可以认为这些可能值就是以后这个地区可能出现温度的真实情况。
当然,我们可以看到,简单概率分布无法对真实世界的绝大多数场景进行建模。这也不断激发研究人员来开发各种更加复杂的概率模型来对真实世界进行描述。
在过去十多年的时间里,一类机器学习思想逐渐成为主流的产生式模型,那就是概率图模型。顾名思义,概率图模型就是概率论和图论的巧妙结合,以此来对复杂的联合概率分布来进行描述。
我们今天就不针对概率图模型展开讨论了。你需要了解概率图模型的一个重要特点,那就是能够利用一个“显示的”表达式来写出这个联合概率分布,不管这个式子本身有多复杂。也就是说,概率图模型期望能够通过构建复杂的、显示的表达式来完成对真实场景的模拟。
产生式对抗网络
显然,构造一个概率图模型是一个极具挑战的任务,面对复杂的情况,我们都需要写出一个显示的表达式,或者是针对这种场景的数据来进行模拟。例如,图像和音频信息就是比较复杂的数据,很难用一个公式(不管这个公式多么复杂)来表达。
那究竟该怎么办针对这种复杂的数据研究人员提出了一种新的产生式模型这就是“产生式对抗网络”Generative Adversarial Nets简称为 GAN[1]。在过去的几年里,这种模型因其概念简单而备受青睐。
GAN的基本思想是怎样的呢
首先我们有一个数据的“产生器”Generator。这个产生器的作用是从一个我们可以控制的模型中产生数据。最终我们的期望是这个产生器能够产生和真实数据一样的数据。
其次我们有一个数据的“判别器”Discriminator其目的是区分数据究竟是真实的数据还是产生器产生的数据。
GAN的模型训练是一个迭代的过程。最开始产生器肯定无法真正产生有效的数据这个时候判别器能够很轻松地对产生的数据进行一个评判哪些是真实数据哪些不是。但是产生器会根据这个判别结果逐渐调整自己产生数据的过程慢慢地让自己产生的数据趋于真实。一直到最后判别器无法分别出数据的真伪。
GAN其实代表了这么一类模型那就是不再对数据的产生过程进行显式建模因为这个太过于困难而是想办法定义一个流程通过这个流程产生数据从而能够直接去对真实数据进行模拟。
GAN和深度学习的结合点在哪里呢就是产生器和判别器可以分别是多层的神经网络甚至可以是更加复杂的深度学习模型。这样GAN的学习过程其实也就是两个不同的各司其职的深度学习模型参数学习的过程。
在近几年的发展中基于GAN的各类模型层出不穷而且能够产生的图片质量也越来越高甚至有的真的达到了能以假乱真的程度。
就在很多人都对这类模型充满了信心的时候一些理论界的研究再次让大家对产生式模型特别是GAN的前景萌生了怀疑。GAN能够彻底解决产生式模型的所有问题吗
最新的论文[2]论证了GAN在一些限定情况下并不是对数据的整个分布进行建模。一个通俗的例子是如果我们训练了一个可以产生猫的图片的GAN那么在理想状态下这个模型是不是应该可以产生各式各样不同种类的猫的图片呢答案是经过某种训练的GAN并不能做到这一点。相反GAN只能产生有限的猫的图片。这肯定是不太理想的一种情况。
那么研究者究竟是应该修改GAN来克服这个问题还是能够找到更好的方法来产生数据目前这还是一个未知答案的研究课题。
小结
今天我为你讲了计算机视觉高级话题之一的产生式模型。
一起来回顾下要点第一我们来讲了为什么需要产生式模型和简单的基于概率分布的数据产生器第二我们讨论了基于GAN的产生模型和最新研究的GAN的一些局限。
最后,给你留一个思考题,有了能够以假乱真的产生式模型,我们可以有哪些应用呢?
欢迎你给我留言,我们一起讨论。
参考文献
Ian J. Goodfellow, Jean Pouget-Abadie, Mehdi Mirza, Bing Xu, David Warde-Farley, Sherjil Ozair, Aaron Courville, and Yoshua Bengio. Generative adversarial nets. Proceedings of the 27th International Conference on Neural Information Processing Systems - Volume 2 (NIPS14), Z. Ghahramani, M. Welling, C. Cortes, N. D. Lawrence, and K. Q. Weinberger (Eds.), Vol. 2. MIT Press, Cambridge, MA, USA, 2672-2680, 2014.
Sanjeev Arora and Yi Zhang. Do GANs learn the distribution? Some theory and empirics. ICLR. 2018

View File

@ -0,0 +1,73 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
127 数据科学家基础能力之概率统计
学习人工智能的工程师,甚至是在人工智能相关领域从业的数据科学家,往往都不重视概率统计知识的学习和培养。有人认为概率统计知识已经过时了,现在是拥抱复杂的机器学习模型的时候了。实际上,概率统计知识和数据科学家的日常工作,以及一个人工智能项目的正常运作都密切相关,概率统计知识正在人工智能中发挥着越来越重要的作用。
和机器学习一样,概率统计各个领域的知识以及研究成果浩如烟海。今天我就和你聊一聊,如何从这么繁多的信息中,掌握能够立即应用到实际问题中的概率统计知识,以及如何快速入手一些核心知识,并能触类旁通学习到更多的内容。
使用概率的语言
概率统计中的“概率”,对于学习和掌握人工智能的诸多方面都有着举足轻重的作用。这里面最重要的,恐怕要数概率论中各种分布的定义。初学者往往会觉得这部分内容过于枯燥乏味,实际上,概率论中的各种分布就像是一门语言的基本单词,掌握了这些基本的“建模语言”单词,才能在机器学习的各个领域游刃有余。
值得注意的是目前火热的深度学习模型以及在之前一段时间占领机器学习统治地位的概率图模型Probabilistic Graphical Models都依赖于概率分布作为这些框架的基本建模语言。因此能够真正掌握这些分布就显得尤为重要。
对于分布的掌握其实可以很容易。只要对少量几个分布有一定的认识后,就能够很容易地扩展开来。首先,当你遇到一个实际场景的时候,你要问自己的第一个问题是,这个场景是针对离散结果建模还是针对连续数值建模?这是一个最重要的分支决策,让你选择正确的建模工具。
当面对离散结果的时候,最需要掌握的分布其实就是三个:
伯努利分布
多项分布
泊松分布
这三种分布是其他离散分布的重要基础。对于这三种分布,记忆其实也相对容易。比如,任何时候,如果你的场景是一个二元问题(例如用户是否点击,是否购买),伯努利分布都是最直接的选择。当你遇到的场景需要有多于两种选择的时候,那自然就用多项分布。另外,文本建模常常可以看做这样一个问题,那就是在特定语境下,如何从上千甚至上万的可能性中选择出最恰当的字词,因此多项分布也广泛应用在文本建模领域。泊松分布则常常用在对可数的整数进行建模,比如一些物品的总个数。
了解应用场景和他们所对应的分布之间的联系,是掌握这些“语言”的重要环节。当你面临的问题是连续数值的时候,绝大多数情况下,你需要掌握和理解正态分布,有时候称为高斯分布。正态分布的重要性是再怎么强调都不为过的。任何你可以想到的场景,几乎都可以用正态分布来建模。由于中心极限定理的存在,在大规模数据的情况下,很多其他分布都可以用正态分布来近似或者模拟。因此,如果说学习概率知识中你只需要掌握一种分布的话,那无疑就是正态分布。
在理解概率分布的过程中还需要逐渐建立起关于“随机数”和“参数”的概念。衡量一个分布是离散还是连续指的是它产生的“随机数”是离散还是连续和这个分布的“参数”没有关系。比如伯努利分布是一个离散分布但是伯努利分布的参数则是一个介于0和1之间的实数。理解这一点常常是初学者的障碍。另外建立起参数的概念以后所有的分布就有了模型也就是分布本身和参数的估计过程两个方面。这对理解机器学习中模型和算法的分离有很直接的帮助。
当理解了这些概率最基础的语言以后,下面需要做的就是,了解贝叶斯统计中,怎么针对概率分布定义先验概率,又怎么推导后验概率。
了解贝叶斯统计不是说一定要做比较困难的贝叶斯估计,而是说,怎么利用先验概率去对复杂的现实情况进行建模。比如说,针对用户是否购买某一件商品而言,这个问题可以用一个伯努利分布来建模。假如我们又想描述男性和女性可能先天上就对这个商品有不同的偏好,这个时候,我们就可以在伯努利分布的参数上做文章。也就是说,我们可以认为男性和女性拥有不同的参数,然而这两个参数都来自一个共同的先验概率分布(也可以认为是全部人群的购买偏好)。那么这个时候,我们就建立起了一个具有先验的模型来描述数据。这个思维过程是需要初学者去琢磨和掌握的。
假设检验
如果说概率基础是一般学习人工智能技术工程师和数据科学家的薄弱环节,假设检验往往就是被彻底遗忘的角落。我接触过的很多统计背景毕业的研究生甚至博士生,都不能对假设检验完全理解吃透。实际上,假设检验是现实数据分析和数据产品得以演化的核心步骤。
对于一款数据产品特别是已经上线的产品来说能够持续地做线上A/B测试通过A/B测试检测重要的产品指标从而指导产品迭代已经成为产品成败的关键因素。这里面通过A/B测试衡量产品指标或多或少就是做某种形式的假设检验。
你期望提高产品性能那么如何理解假设检验选取合适的工具理解P值等一系列细节就至关重要这些细节决定了你辛辛苦苦使用的复杂人工智能模型算法是否有实际作用。
首先我们要熟悉假设检验的基本设定。比如我们往往把现在的系统情况比方说用户的点击率、购买率等当做零假设或者通常叫做H0。然后把我们改进的系统情况或者算法产生的结果叫做备择假设或者叫做H1。
接下来一个重要的步骤就是检验目前的实验环境看是否满足一些标准检验的假设环境比如T检验、Z检验等。这一步往往会困扰初学者甚至是有经验的数据科学家。一个非常粗略的窍门则是因为中心极限定理的存在Z检验通常是一个可以缺省使用的检验也就是说在绝大多数情况下如果我们拥有大量数据可供使用一般会选择Z检验。当然对于初学者而言最常规的也是最需要的就是掌握T检验和Z检验然后会灵活使用。
在选择了需要的检验以后就要计算相应的统计量。然后根据相应的统计量以及我们选好的检验就可以得到一系列的数值比如P值。然后利用P值以及我们预先设定的一个范围值比如经常设置的0.95或者说95%我们往往就可以确定H1是否在统计意义上和H0不同。如果H1代表着新算法、新模型也就意味着新结果比老系统、老算法有可能要好。
需要你注意的是这里说的是“有可能”而不是“一定”、“确定”。从本质上来说假设检验并不是金科玉律。假设检验本身就是一个统计推断的过程。我们在假设检验的流程中计算的其实是统计量在H0假设下的分布中出现的可能性。可能性低只能说我们观测到的现象或者数值并不支持我们的H0但这个流程并没有去验证这些现象或者数值是不是更加支持H1。
另外,即便“可能性”低,也并不代表绝对不出现。这也是初学者常常过度相信假设检验所带来的问题。比较正确的对待假设检验的态度,就是把这个流程提供的结果当做工具,与更加复杂的决策过程结合起来,从而对目前的系统、目前的产品有一个综合的分析。
值得注意的是和假设检验有关联的一个概念“置信区间”往往也很容易被忽视。尽管初看没有太大作用置信区间其实被广泛应用在推荐系统的“利用和探索”Exploitation & Exploration策略中。因此明白置信区间的概念很有益处对实际的计算有很大帮助。
因果推论
最后我想提一下因果推论Causal Inference。因果推论不是一般的统计教科书或者工程类学生接触到的统计教科书里的基本内容。然而最近几年这个领域在机器学习界获得了越来越多的关注。对于学习机器学习前沿知识的朋友来说了解因果推论十分必要。
同时对于工程产品而言并不是所有情况都能通过A/B测试来对一个希望测试的内容、模型、产品设计进行测试并在一定时间内找到合理的结果。在很多情况下是不能进行测试的。因此如何在不能进行测试的情况下还能通过数据研究得出期望的结果这就是因果推论的核心价值。基于此越来越多的互联网公司开始关注这个技术。
对于多数人工智能工程师而言,因果推论所需要的场景其实无时无刻不陪伴着我们。一个常见的情况是,我们需要用数据来训练新的模型或者算法。这里面的数据采集自目前线上的系统,比如一个新闻推荐系统。然而,现在的线上系统是有一定偏差的,例如比较偏好推荐娱乐新闻。那么,这个偏差就会被记录到数据里,我们收集的数据就侧重于娱乐新闻。那么,要想在一个有偏差的数据中,依然能够对模型和算法进行无偏差的训练和评测,就可以运用因果推论为机器学习带来的一系列工具。
小结
今天我为你讲了掌握概率统计基础知识的一些核心思路。一起来回顾下要点:第一,学习概率分布的语言对于理解、甚至是创造新的机器学习模型和算法都有着重要作用。第二,假设检验是常常被人工智能工程师和数据科学家遗忘的知识。然而,它对我们做产品开发却至关重要。第三,因果推论是一个新兴的统计和机器学习结合的领域,希望你能有所了解。
最后给你留一个思考题我们之前说到假设检验约等于我们计算统计量在H0里发生的可能性那么为什么我们不直接计算在H1里发生的可能性呢

View File

@ -0,0 +1,109 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
128 数据科学家基础能力之机器学习
想要成为合格的,或者更进一步成为优秀的人工智能工程师或数据科学家,机器学习的各种基础知识是必不可少的。然而,机器学习领域浩如烟海,各类教材和入门课程层出不穷。特别是机器学习基础需要不少的数学知识,这对于想进入这一领域的工程师而言,无疑是一个比较高的门槛。
今天,我来和你聊一聊如何学习和掌握机器学习基础知识,又如何通过核心的知识脉络快速掌握更多的机器学习算法和模型。
监督学习和无监督学习
要问机器学习主要能解决什么问题,抛开各式各样的机器学习流派和层出不穷的算法模型不谈,机器学习主要解决的是两类问题:监督学习和无监督学习。掌握机器学习,主要就是学习这两类问题,掌握解决这两类问题的基本思路。
什么是解决这两类问题的基本思路呢?基本思路,简而言之就是“套路”。放在这里的语境,那就是指:
如何把现实场景中的问题抽象成相应的数学模型,并知道在这个抽象过程中,数学模型有怎样的假设。
如何利用数学工具,对相应的数学模型参数进行求解。
如何根据实际问题提出评估方案,对应用的数学模型进行评估,看是否解决了实际问题。
这三步就是我们学习监督学习和无监督学习,乃至所有的机器学习算法的核心思路。机器学习中不同模型、不同算法都是围绕这三步来展开的,我们不妨把这个思路叫作“三步套路”。
那什么是监督学习呢监督学习是指这么一个过程我们通过外部的响应变量Response Variable来指导模型学习我们关心的任务并达到我们需要的目的。这也就是“监督学习”中“监督”两字的由来。
也就是说,监督学习的最终目标,是使模型可以更准确地对我们所需要的响应变量建模。比如,我们希望通过一系列特征来预测某个地区的房屋销售价格,希望预测电影的票房,或者希望预测用户可能购买的商品。这里的“销售价格”、“电影票房”以及“可能购买的商品”都是监督学习中的响应变量。
那什么是无监督学习呢?通常情况下,无监督学习并没有明显的响应变量。无监督学习的核心,往往是希望发现数据内部的潜在结构和规律,为我们进行下一步决断提供参考。典型的无监督学习就是希望能够利用数据特征来把数据分组,机器学习语境下叫作“聚类”。
根据不同的应用场景,聚类又有很多变种,比如认为某一个数据点属于一个类别,或者认为某一个数据点同时属于好几个类别,只是属于每个类别的概率不同等等。
无监督学习的另外一个作用是为监督学习提供更加有力的特征。通常情况下,无监督学习能够挖掘出数据内部的结构,而这些结构可能会比我们提供的数据特征更能抓住数据的本质联系,因此监督学习中往往也需要无监督学习来进行辅助。
我们简要回顾了机器学习中两大类问题的定义。在学习这两大类模型和算法的时候,有这么一个技巧,就是要不断地回归到上面提到的基本思路上去,就是这个“三步套路”,反复用这三个方面来审视当前的模型。另外,我们也可以慢慢地体会到,任何新的模型或者算法的诞生,往往都是基于旧有的模型算法,在以上三个方面中的某一个或几个方向有所创新。
监督学习的基础
监督学习的基础是三类模型:
线性模型
决策树模型
神经网络模型
掌握这三类模型就掌握了监督学习的主干。利用监督学习来解决的问题占所有机器学习或者人工智能任务的绝大多数。这里面有90%甚至更多的监督学习问题,都可以用这三类模型得到比较好的解决。
这三类监督学习模型又可以细分为处理两类问题:
分类问题
回归问题
分类问题的核心是如何利用模型来判别一个数据点的类别。这个类别一般是离散的,比如两类或者多类。回归问题的核心则是利用模型来输出一个预测的数值。这个数值一般是一个实数,是连续的。
有了这个基本的认识以后,我们利用前面的思路来看一下如何梳理监督学习的思路。这里用线性模型的回归问题来做例子。但整个思路可以推广到所有的监督学习模型。
线性回归模型Linear Regression是所有回归模型中最简单也是最核心的一个模型。我们依次来看上面所讲的“三步套路”。
首先第一步,我们需要回答的问题是,线性回归对现实场景是如何抽象的。顾名思义,线性回归认为现实场景中的响应变量(比如房价、比如票房)和数据特征之间存在线性关系。而线性回归的数学假设有两个部分:
响应变量的预测值是数据特征的线性变换。这里的参数是一组系数。而预测值是系数和数据特征的线性组合。
响应变量的预测值和真实值之间有一个误差。这个误差服从一个正态高斯分布分布的期望值是0方差是σ的平方。
有了这样的假设以后。第二步就要看线性回归模型的参数是如何求解的。这里从历史上就衍生出了很多方法。比如在教科书中一般会介绍线性回归的解析解Closed-form Solution。线性回归的解析解虽然简单优美但是在现实计算中一般不直接采用因为需要对矩阵进行逆运算而矩阵求逆运算量很大。解析解主要用于各种理论分析中。
线性回归的参数还可以用数值计算的办法比如梯度下降Gradient Descent的方法求得近似结果。然而梯度下降需要对所有的数据点进行扫描。当数据量很多的时候梯度下降会变得很慢。于是随机梯度下降Stochastic Gradient Descent算法就应运而生。随机梯度下降并不需要对所有的数据点扫描后才对参数进行更新而可以对一部分数据有时甚至是一个数据点进行更新。
从这里我们也可以看到,对于同一个模型而言,可以用不同的算法来求解模型的参数。这是机器学习的一个核心特点。
最后第三步,我们来看如何评估线性回归模型。由于线性回归是对问题的响应变量进行一个实数预测。那么,最简单的评估方式就是看这个预测值和真实值之间的绝对误差。如果对于每一个数据点我们都可以计算这么一个误差,那么对于所有的数据点而言,我们就可以计算一个平均误差。
上述对于线性回归的讨论可以扩展到监督学习的三类基本模型。这样你就可以很快掌握这些模型的特点和这些模型算法之间的联系。
无监督学习的基础
现实中绝大多数的应用场景并不需要无监督学习。然而无监督学习中很多有价值的思想非常值得初学者掌握。另外,无监督学习,特别是深度学习支持下的无监督学习,是目前机器学习乃至深度学习的前沿研究方向。所以从长远来看,了解无监督学习是非常必要的。
我们前面说到,无监督学习的主要目的就是挖掘出数据内在的联系。这里的根本问题是,不同的无监督学习方法对数据内部的结构有不同的假设。因此,无监督学习不同模型之间常常有很大的差别。在众多无监督学习模型中,聚类模型无疑是重要的代表。了解和熟悉聚类模型有助于我们了解数据的一些基本信息。
聚类模型也有很多种类。这里我们就用最常见的、非常重要的K均值算法K-means来看看如何通过前面讲过的“三步套路”来掌握其核心思路。
首先K均值算法认为数据由K个类别组成。每个类别内部的数据相距比较近而距离所有其他类别中的数据都比较遥远。这里面的数学假设需要定义数据到一个类别的距离以及距离函数本身。在K均值算法中数据到一个类别的距离被定义为到这个类别的平均点的距离。这也是K均值名字的由来。而距离函数则采用了欧几里得距离来衡量两个数据点之间的远近。
直接求解K均值的目标函数是一个NP难NP-hard的问题。于是大多数现有的方法都是用迭代的贪心算法来求解。
一直以来,对聚类问题、对无监督学习任务的评估都是机器学习的一个难点。无监督学习没有一个真正的目标,或者是我们之前提到的响应变量,因此无法真正客观地衡量模型或者算法的好坏。
对于K均值算法而言比较简单的衡量指标就是看所有类别内部的数据点的平均距离和类别两两之间的所有点的平均距离的大小。如果聚类成功则类别内部的数据点会相距较近而类别两两之间的所有点的平均距离则比较远。
以上我们通过“三步套路”的三个方面讨论了K均值算法的核心思路这种讨论方法也适用所有的聚类模型和算法。
小结
当你可以熟练使用我今天介绍的“三步套路”,去分析更多监督学习和无监督学习的模型算法以后,对于基础的内容,也就是教科书上经常讲到的内容,你就可以去看这些内容究竟是在讲解这三个方面的哪个方面。
对于绝大多数模型来说,第一部分往往是最重要的,也就是说,这个模型究竟和现实问题的联系是什么。第二部分,也就是模型的求解,取决于模型本身的复杂度和成熟度,现在很多模型往往都有现成的软件包提供求解过程。而第三部分,模型的评估则在现实生产中至关重要。牢牢把握这三个方面,来对机器学习模型算法进行讨论,是成长为成熟数据科学家必不可少的过程。
今天我为你讲了掌握机器学习基础知识的一些核心思路。一起来回顾下要点第一机器学习主要的任务有监督学习和无监督学习。这两种机器学习任务的很多模型和算法都可以用一个“三步套路”的思路来进行分析。第二我们用线性回归作为例子探讨了如何用这个“三步套路”来分析监督学习的模型和算法。第三我们用K均值聚类算法作为例子探讨了如何用“三步套路”来分析无监督学习的模型和算法。
最后,给你留一个思考题,在现实场景中,当你发现一个模型并没有很好地解决你的问题时,从这个“三步套路”的角度来看,究竟哪个方面最容易出问题?

View File

@ -0,0 +1,71 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
129 数据科学家基础能力之系统
对于初学人工智能的工程师或者数据科学家来说,在知识积累的过程中,“系统”往往是一个很容易被忽视的环节。特别是非计算机科学专业出身的朋友,一般都没有真正地建立过“系统”的概念,在今后从事人工智能的相关工作时,很可能会遇到一些障碍。
今天我想给你分享一下,作为人工智能工程师和数据科学家,需要建立的关于“系统”的最基本认知。这些认知能够帮助你把书本的理论知识和现实的应用场景快速结合起来。
理解管道Pipeline
在很多人工智能初学者的认知中,机器学习的流程是这样的。有一个已经准备好的数据集,这个数据集里面已经有了各种特征以及所对应的标签或者响应变量。这个时候,你需要做的就是利用这个数据集和一些现成的机器学习工具包,来训练一些机器学习模型。模型训练好以后,就可以计算一些已知的评估指标了,比如准确度、精度等等。
这是一般教科书和课程上介绍的标准机器学习流程,也是很多机器学习论文中的实验环境。遗憾的是,这个静态的流程并不适用于工业级的数据产品。
要支持工业级的人工智能产品,一个最基本的概念就是,你需要搭建一个管道让你的环境是动态的、闭环的。在英文的语言背景里,“管道”这个词很形象地说明了这个环境的特点。我们把数据想象成“管道”里的水,这里面的一个核心思想,就是数据从一个环节到下一个环节,源源不断。我们再把最终的产品,也就是这个管道的末端,和最开始的数据采集部分,也就是这个管道的开始端,结合起来思考,这就是一个闭合的环路。
理解数据产品的核心,就要理解它是一个闭合环路。几乎关于数据产品的一切难点、问题以及解决方案都可以从这个闭合环路中产生。从一个静态的机器学习流程到一个动态的管道似的闭合环路,这是一个质变,对整个环节上的所有步骤都有全新的要求。
我这里就拿数据集来举个例子。静态的流程中,我们不需要太过关注这个数据集的来源。甚至采集数据集的代码或者脚本都可以是一次性的,可以不具备重复使用的价值。但是这种情况在管道的环境中是不可能的。
在管道中,采集数据的可靠性和可重复性是非常重要的步骤,这就对采集数据所采用的代码有不一样的要求。这部分代码需要被反复检验,每一步都需要人工智能工程师和数据科学家进行检验。如果我们把这个例子扩展到数据管道的其他部分,就可以很清楚地看到,数据管道对于构建一个机器学习流程所带来的根本变化。
管道的另外一个重要特性是自动化,一个不能自动化的管道是不能被称为管道的。这里的自动化有两层意思,一层意思是指数据本身可以被自动采集、整理、分析,然后自动流入机器学习部分,有结果后自动输出并能被线上的系统使用;另一层意思是指,每一个环节本身都不需要人工干预,或者仅需极少数的人工,自身可以高可靠地运行。由此可见,管道的自动化对每个环节的技术选择和实现都有非常高的要求。
现代互联网公司中,每个团队,甚至成立专门的团队,一般都会针对机器学习管道开发工具平台,使管道的灵活度、自动化、可靠性有足够保障。对于初学者而言,尝试从管道的角度去理解问题,从整个系统的角度来认识产品开发过程,认识机器学习流程,才有可能设计出能真正满足线上需求的技术方案。
理解线上和线下的区别
了解了一个数据系统的闭合回路以后,很自然地,就会出现下一个问题,这也是一个核心的系统级问题,在这个管道中,哪些部分是在“线上”,哪些部分又在“线下”呢?
这里我们先来理清“线上”这个概念。“线上”往往是说,对于交互性很强的互联网产品(包括电商、搜索引擎、社交媒体等),从用户来到某一个页面,到我们为这个页面准备好所需内容(例如推荐的商品或者搜索的结果),这中间的响应时间对应的就是“线上”,这部分时间非常短暂,往往只有几百毫秒。如何在这几百毫秒的时间内进行复杂的运算就非常有讲究了。
“线下”的概念是相对于“线上”而言的。通常情况下,不能在这几百毫秒之内完成的运算,都是某种程度的“线下”运算。
理解线上和线下的区别是初学者迈向工业级应用的又一个重要的步骤。哪些计算可以放到线上,哪些可以放到线下,就成了种种机器学习架构的核心区别。
初学者还需要注意的一个问题是,线上和线下都是相对的概念。今天放在线下计算的某些部分,明天可能就会放到线上进行计算。所以,慢慢学会把握两者之间的转换之道,对于初学者进阶至关重要。
我这里举一个简单的线上和线下分割的例子。比方说,我们要构建一个检测垃圾邮件的系统。对于这样一个系统而言,哪些部分是线上,哪些部分是线下呢?
初看,我们在这里讨论的是一个比较容易的架构,但并不代表实现这个架构的难度也很小。在最简单的情况下,检测垃圾邮件需要一个二分分类器。如何训练这个分类器的参数就是一个关键。
假设我们训练一个逻辑回归二分分类器。那么,逻辑回归的参数,也就是一组线性系数究竟应该在什么环境中得到呢?很明显,训练一个逻辑回归肯定需要大量的训练数据。在有一定量(大于几千的垃圾邮件和非垃圾邮件)的训练数据时,训练逻辑回归的参数就不可能在几百毫秒内完成。在这样的思路下,训练逻辑回归就不得不放到线下来计算。一旦这个决定做出以后,一系列的模块就都必须放在线下计算了。
另外,数据的收集肯定也得放到线下,这样才能保证可以把训练数据传输到后面的管道模块中。还有特征的生成,至少是训练数据特征的生成,很自然地也就需要放在线下。
训练逻辑回归本身,刚才我们提到了,需要放在线下。而放在线下这个决定(从某种意义上来说,无所谓时间多了一点还是少了一点,总之无法满足几百毫秒的线上计算就需要放在线下),又可以让训练逻辑回归本身,采用更加复杂的二阶算法,使参数能够得到更好的收敛。
你可以看到,因为一个决定,带来了关于整个管道的一系列决定。而这些决定又影响了模型算法的选择,比如可以选用相对耗时的更复杂的算法。
那么在这个架构下,线上的部分是什么呢?首先,训练完一个模型之后,要想使用这个模型,我们必须把模型的参数存放到某个地方(也许是一个数据库或者是一个存储系统),线上的系统可以在几百毫秒的时间内马上得到这些参数。仅仅得到参数是不够的,还需要对当前的邮件进行判断。
这一步就有一些问题了。一种选择是,线上的部分拿到模型参数,然后实时动态产生这个邮件的特征,再实时计算出一个分数,并且判断是否是垃圾邮件。整个过程的这三个步骤需要在几百毫秒内进行完毕。
实际上,这里面的第二步往往比较耗时,甚至有的特征并不能在线上进行计算。比如,也许有一个特征需要查询这个邮件的来源是否可靠,这里就可能需要数据库操作,这一步也许就会非常耗时(在几百毫秒的场景中而言)。因此,动态产生特征,除非特征都非常简单,很有可能并不能完全在线上完成。
我们可以对框架进行简单的修改。所有的邮件首先输送到一个特征产生的模块中,这里并不是一个完全线上的环境,运算的需求可能超过几百毫秒,但总体只是几秒,最多十多秒。所有的特征产生以后,对邮件的判断也在这里完成,最终将邮件是否是垃圾邮件这个简单的选项保存下来。在线上的系统,也就是用户来到这个邮件系统界面的时候,我们只是从保存的结果中,直接读出一个标签,速度非常快。
如上,我们通过检测垃圾邮件系统的例子,分析了线上和线下的分割情况。现在来做一个思考,刚才描述的这个架构有什么问题吗?问题就是,线上的结果是一个事先计算好的结果,模型本身也是事先计算好的。因此,当有大量突发数据(比如一大批新的垃圾邮件)来临的时候,这个架构可能无法很快反应,更新模型。可见,如何理解线上和线下是一个需要慢慢琢磨的学习过程。
小结
今天我为你讲了数据科学家和人工智能工程师需要掌握的关于系统基础的两个核心概念。一起来回顾下要点:第一,现代数据流程不是一个静态的数据集,而是一个动态的闭环管道。 第二,理解什么计算可以放到线上,什么计算可以放到线下至关重要。
最后,给你留一个思考题,如果让你设计一个商品的推荐系统,哪些部分放到线下,哪些部分放到线上呢?

View File

@ -0,0 +1,89 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
130 数据科学家高阶能力之分析产品
人工智能工程师和数据科学家的主要工作是什么?很多人认为,他们的主要工作是利用复杂的机器学习模型和算法来解决产品中的难题。这样的认识既“对”也“不对”。“对”的地方是说,机器学习模型和算法的确是人工智能技术在产品上落地的核心步骤。“不对”的地方是说,这种认识往往片面地总结了人工智能从业人员的工作范畴。
实际上,要想真正地提出一个好的人工智能解决方案,分析产品的能力是必需的。从较高的层次来讲,就是分析一个产品目前遇到的难题是什么,为什么需要用人工智能技术去解决,哪些是可以用人工智能技术解决的,哪些不能。
今天,我就来分享一下,站在人工智能工程师和数据科学家的角度,我们如何理解并提升分析产品的能力,学会了解产品的需求。
产品需求的庖丁解牛
一个数据驱动的产品往往是一个复杂的复合体。这里面当然有很多数据、人工智能的元素,也有不少其他元素,比如设计、人机交互、商业规则、心理学等等。那么,如何在这么一个综合复杂的体系中找到人工智能技术的合适位置,以及技术究竟要扮演什么样的角色,其实是一个数据驱动型产品能否成功的核心问题。
想要对这个问题有一个比较全面的认识,我们首先需要回答这么一个问题,那就是人工智能技术到底能够给产品带来什么?
很多朋友可能觉得这个问题不言自明。人工智能技术难道不是解决产品的核心算法难题吗?
这种看法其实不够全面。人工智能技术给产品带来的其实不仅是一些核心的模型和算法,更重要的是,带给产品一项根本性的能力:数据驱动的决策过程。
什么叫作“数据驱动的决策过程”呢?我们还是要从人工智能技术的特点说起。
人工智能技术的特点有两个方面:第一,数据驱动。第二,在不确定的因素下智能决策。
数据驱动
我们先来谈谈第一个方面,数据驱动。这里其实是两个部分,“数据”和“驱动”。
一个产品要想利用人工智能,第一步,也是非常重要的一个步骤,就是要有“数据”的概念。什么是“数据”的概念?就是一个产品需要有数据收集的意识,并有数据收集的机制。然后,一个有“数据”的产品需要慢慢建立数据管道,并开始建立数据的检测系统。这些都是人工智能介入的先决条件和基础设施。
没有数据,没有流畅的数据链条,是无法构建一个健康的人工智能决策环境的。这一点说起来容易,要真正做到其实需要很扎实的技术基础。很多团队、很多产品最终无法利用人工智能技术的方方面面,一个关键原因就是在数据链条上出了各种问题。
有了数据以后,第二个环节就是“驱动”。也就是说,只有数据是不行的,还必须有一个意识,主动利用这些数据来驱动产品的发展,驱动产品方方面面的进化。这个步骤其实不仅是针对产品的决策人员,比如产品经理、项目负责人,也是针对这个产品的所有参与人员的。
参与产品的各方面人员,包括工程方面的、设计方面的、市场方面的,大家有没有意识,在有了数据链条之后,通过数据检测、数据分析不断加深对产品的认识,提出更好的想法。当产品遇到各种问题时,大家有没有一个意识,那就是先到数据中去找答案,先去看数据是不是出了什么问题,去理解数据中显示出来的内容。
如果说数据驱动的第一部分是有关“硬件”的,是数据链条的技术以及实现,那么第二部分就是有关“软件”的,是项目人员的意识和责任。
智能决策
当一个产品有了数据驱动的基础以后,下一步,就需要建立“智能决策”的理念。
“智能决策”是什么意思?
要想明白“智能决策”的意义,我们首先要来想一想“非智能决策”是什么样的。
很多传统的产品或者不是数据驱动的科技产品都是非智能决策的产物。非智能决策主要是指,不依靠数据,或者依靠很少量的数据,由产品经理或产品负责人人工地进行决策。注意,非智能决策并不一定无法带来好的产品。实际上,在历史的很长时间里,各行各业都是依靠非智能决策在进行演化。
非智能决策的一大特点是决策的主观性。通俗地讲,就是决策者依靠自我的认识,主观“拍板”决策关于产品的方方面面。因为没有一个系统的方法论,或者说是没有一整套机制给决策者相应的信息,来帮助决策者完成决策,非智能决策所带来的产品结果往往有很大的偶然性。
这种偶然性来自于决策者本人的各种能力,来自于执行者的能力。由于这样的偶然性和主观性,非智能决策的另外一个特点,或者说是结果,就是不可复制性。这是因为决策的方法和方式都不能动态地随着时间、随着数据的变化而变化。
非智能决策在什么时候会变得比较困难呢?数据量太大的时候,需要做选择的可能性太多的时候,需要做的选择本身复杂度变高的时候。这些特征也正是新时期下互联网产品或者人工智能产品的特点。因此,在将来非智能决策会越来越多地让位给智能决策。
简单来说,智能决策就是产品的决策者依据产品的特点,把一些复杂的、需要依靠大量数据、选择面太广的决策交给人工智能模型和算法,并且建立起一整套体系,利用人工智能手段依靠数据来对整个产品进行快速迭代。
如果给这种产品决策找一些典型的类比,就像现代搜索引擎技术,代替了传统的图书馆管理员的角色;现代的电子商务网站为用户推荐各类商品,代替了传统商店里的导购;智能自动驾驶汽车,代替了人类的手工驾驶。
智能决策不仅仅是某一项任务的智能化,更重要的是一种理念的提升。也就是说,一旦产品的决策中出现了有需要大量数据、有复杂选项的时候,作为产品的决策者就需要马上意识到,这部分决策任务应该逐渐从人转移到智能模型和算法上,依靠数据驱动流程来加快迭代。这一点是智能决策的关键。
我们可以接着之前的电子商务网站的例子来说明智能决策的理念。最开始的时候,也许这个网站只有一个简单的界面,可以根据用户的一些历史信息来推荐商店的商品。这个时候,智能决策的部分还仅限于推荐模块这一部分。紧接着,越来越多的用户开始使用这个网站,于是任务就变得越来越多,也越来越困难。
比如,如何设计下一版的网站界面?设计师、前端工程师、用户体验工程师、甚至产品经理都会有自己的看法。这个任务本身就很困难,怎么能让上百万的用户满意?怎么能体现出不同用户的不同选择偏好?怎么能体现出这个产品自身的美学价值?你看,这就是一个需要基于数据的复杂的决策任务。
很多团队能够意识到推荐模块需要智能决策,却意识不到“下一版的界面”问题可能也需要智能决策。其实,一旦一个问题变成智能决策问题,我们反而有章可循。
比如这个界面问题所有人的意见、想法或者创意依据一定的规则可以用一些人工智能模型和算法来表达。然后通过现代的A/B在线测试手段可以针对不同的人群展示不同的界面。随后通过数据链条来对测试进行监控和分析。
这时候,决策反而变得简单。因为我们不需要为数百万用户拿一个主意,而需要做的是为智能决策提供足够多的创意,然后由智能模型和算法以及实验流程来选择用户喜欢的界面。最后,下一版的网站,不只有一个界面,而是有几十甚至是几百种不同的界面,为百万千万的用户服务。
小结
我们之前提到了数据驱动,提到了智能决策。那么回到我们今天最开始的主题,作为人工智能工程师或者数据科学家的一个高阶技能,就是能够培养这样一种理念,对产品进行持续分析,检测产品是否遵循了数据驱动的理念,挖掘产品有哪些需求可以进行智能决策。
一旦有了这个分析产品的能力以后,我们可以发现人工智能技术将成为产品进化的驱动器和核心机制,而不仅仅是锦上添花的一种噱头。这就是对产品的一种完全革新式的思维。
同时,我这里需要指出的是,今天在这里提到的数据驱动也好、智能决策也好,你可以认为这些都不是什么新思想。但是,如何把这些思想真正地应用到产品实践中却是一件非常困难的事情。
另外一点需要注意的是,这些理念本身也不是教条,也是一个与时俱进的过程。并不是所有的产品在所有的阶段都需要做数据驱动,都需要做智能决策。我们之前提到了,有很多没有真正数据驱动的产品也依然获得了成功。所以,对产品的分析能力,其实需要你在产品的迭代过程中逐步提升。
今天我为你讲了数据科学家和人工智能工程师如何提升产品分析能力。一起来回顾下要点:第一,产品分析的能力其实就是对产品需求的一个分解,而分解之后的产品迭代很大程度上依赖于数据驱动和智能决策。 第二,我详细地阐述了什么是数据驱动,什么是智能决策,究竟怎样可以为产品带来这两项核心能力。
最后,给你留一个思考题,什么样的产品不太适合数据驱动和智能决策呢?

View File

@ -0,0 +1,71 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
131 数据科学家高阶能力之评估产品
“如果你无法衡量它你就无法改进它。”If you cant measure it you cant improve it.)这是一句你可能会经常听到的话,这句话也被应用到很多不同的场景中。那么,对于人工智能工程师和数据科学家来说,这句话其实是他们工作核心的核心。不管是模型和算法,还是产品迭代,都离不开“指标”和“评估”这两个方面。
评估一个产品的好坏,是一项说起来最容易但做起来最困难的工作。任何人,从用户到产品经理,对某一个产品都可能有自己的主观意见。然而对一个产品,特别是要面对成千上万用户的产品来说,依靠主观感觉是很难有一个完整、全面的评价的。同时,有一个成熟的产品评价体系可以成为产品不断迭代的领航标。
今天,我就来聊一聊如何评估一个数据驱动型产品,又如何从评估产品的角度来推动产品的迭代。我们需要建立层次化的评估体系,需要一个衡量产品好坏的框架。这个框架要从宏观到微观,能够对你的产品进行全方位的检测,并且这种检测能够帮助你更容易地进行决策。
产品的经济收益
你可能要问,是的,我们需要评估一个产品,但是如何找到衡量产品的这些指标呢?
比方说你要做一个社交网络的网站,怎么来制定检测指标呢?首先,你要问自己,我做这个社交网络的最终目的是什么?很明显,一个商业网站的终极目标是赚钱,也就是说,你最终的指标是你网站的经济收益。知道了这一点远远不够,你至少还需要思考两个问题。第一,如何衡量你的经济收益;第二,你能否用经济收益来直接指导你的产品构建。
我们先谈谈第一个问题。衡量经济收益看似简单其实不易。从比较大的维度上来说,你可以衡量总收入,你也可以衡量利润,你可以衡量收入的年增长率,还可以衡量季度增长率。从比较具体的维度来说,很多社交网站依靠广告收入,对广告收入的衡量本身就是一个非常复杂的问题。
总体来看,衡量经济收益,有两点值得你思考。其一,如何衡量你收入的现状。其二,如何衡量你收入的增长。今天,关于收入的指标我就不展开讨论了。
刚才讲的第二个问题就更加复杂。衡量经济收入的指标往往太过宏观,而且衡量起来有难度,因此用经济指标来指导产品的发展是很困难的。我刚才说了一些经济收益指标,比如年收入、年增长率、季度增长率,这些指标的衡量需要至少等待一个季度以上,甚至一年的时间。这些有时间间隔的指标,无法给产品的快速迭代带来很大的指导意义。
另一方面,很多产品并不直接产生经济结果。也就是说,经济收益是一个“副产品”。这个时候,如果我们只看经济收益,就无法真正指导我们构建更好的产品。比如,我刚才提到,对于一个社交网站来说,广告收入是一个“副产品”,绝大多数用户来到这个网站的主要目的不是点击广告。因此,仅仅衡量广告有可能让社交网络产品的迭代误入歧途。
层次化的评估体系
如果单从经济指标无法对产品有全面的指导作用,那怎么才能更加有效地建立评估体系呢?这里就引出下一个话题,那就是多层次的评估体系。
接下来,我就由低到高依次从五个层面来说明一下,这个层次化的评估到底是什么意思。
最低层次的评估主要围绕着产品的最小组成单元。比如我们刚才用的社交网络的例子,社交网络的各个页面上的模块就可以是最小的被评估的单元。
为什么要用这个概念呢?原因是这样的,每一个模块往往是产品的一个逻辑单元,一个最小的承载产品理念的单元。不管是工程团队还是产品团队的运作,基本上都是为这些模块而工作。因此,观察最小单元的效果对产品和工程团队都有直接的指导意义。如果团队目前对这个模块做了一些更改,那么最直接的效果就是这个模块的一些指标会发生变化。这是产品迭代的一个重要组成部分。
在这个层次,衡量模块的指标主要是模块的直接效果指标。比如,模块本身的点击率,模块本身的驻留时间,模块上一些其他的用户活跃指标等。这些都是最低层次的模块级别的指标,和产品、工程团队的运作有密切联系。
第二个层次的指标是从单个模块上升到一个页面。这个时候,就不仅需要理解单个模块的情况,还需要对整个页面上所有模块产生的功能群进行深入研究。在这个层次,产品功能群的思考可能会涉及到多个产品团队,也可能会出现模块间冲突的情况。
比如不少现代搜索引擎的搜索页面往往都有广告模块。长期的经验告诉我们,广告模块的效果和普通搜索模块的效果往往有相反关系的耦合。也就是说,普通搜索模块的效果提高了,广告模块的某些指标反而可能下降。反过来,广告模块的效果提高了,也很有可能是因为普通搜索模块的质量突然变差。因此,在有经验的产品团队面前,广告效果有意想不到的提高,可能并不意味着是件好事情。
第二个层次的指标比第一个层次变得复杂起来。不过这个层次的指标依然是可以直接测量的。比如页面的点击率,页面的驻留时间,页面上其他的用户指标等等。这些指标虽然可以直接测量,但是分析时需要对页面上的所有模块有全面了解。
前两个层次的指标主要是测量用户在某一个模块或者页面上的表现,核心是看产品的更改对用户的直接影响。而且,第一层次和第二层次的指标非常易于检测。通常情况下,如果页面和模块发生了什么问题,这些指标就能很快地反映出页面的情况。然后通过排查,我们就能快速发现问题,这也就是通常所说的,这些指标都比较“敏感”。
“敏感”指标的第一个好处是,这些指标具有非常强的指导意义,能够帮助产品团队快速认识问题并提出解决方案。“敏感”指标的第二个好处无疑就是,产品团队的绝大多数改动都能够比较容易地反应到这些指标上。因此,这是一个容易建立的、良性循环的指标体系。当然,仅有这两个层次的指标还是远远不够的,我们可以看到,这两个层次的指标和一个产品最终目标的衡量还有一定距离。
第三个层次的指标就从某一个模块、某一个页面上升到了用户这个层级主要是检测用户在一个会话Session中的表现。这个时候用户往往在一个会话中和多个模块、多个页面进行非常复杂的互动。在这个层次上我们已经很难仅凭观测就能琢磨出用户在这个会话中是否真正感觉满意。这个时候我们往往就需要建立用户模型User Model以及通过一些统计的方法建模从而实现真正理解用户行为的目的。
举一个例子如果我们构建一个电子商务网站在一个用户会话中检测用户是否购买了一些商品这些商品的总价值又是多少。这个监测指标有时候被称作GMVGross Merchandise Value也就是通常所说的网站成交金额。GMV还是比较容易计算的就是计算每个会话之后用户购买的商品价值然后对所有会话的结果求和。但是要真正理解用户会话行为对GMV的影响就是一个比较困难的任务了。
我们可以想象,即便是同一个用户,是否在一个会话中购买商品,这是一个非常复杂的决策过程。在一个会话中,用户可能会接触到搜索页面,可能会接触到各种推荐的模块,也可能会跳转某个商品的页面,还可能会跳转首页。并且,每个用户的用户轨迹不同,接触各个页面和模块的流程也是不一样的。可以肯定地说,任何一个流程中的每一个环节,都有可能对用户是否购买商品、以及购买多少价钱的商品有或多或少的影响。而如何来测量和建模这样的影响,就是第三层次指标的核心挑战。
第四个层次的指标是从一个用户会话上升到多个用户会话。这个时候我们关心的是用户较长时间的体验问题。对于一些复杂的任务用户需要多个会话才能完成。套用我们刚才举的电商GMV的例子很多用户购买比较贵重或者是一些有特定需求的商品比如婚纱往往无法在一个用户会话中完成决策。
那么这种情况下,检测指标的复杂性又进一步提高。比如说,用户可能先在电商网站上搜索了关于婚纱的信息,但在这一次会话中并没有完成交易。用户之后可能又从其他途径了解了一些更多的信息,然后又重新到电商网站开始一个新的会话。在这个会话中,用户也许重点比较了好几个婚纱,然后决定购买其中一件。这个例子还是一个比较简单的情况了。
第三和第四层次的指标有两个特点。第一相对于第一、第二层次的指标而言这些指标已经不那么“敏感”了。也就是说仅改变某一个模块甚至某一个页面是很难在短时间内改变第三特别是第四层次的指标的。从上面的例子可以看出用户的购买行为是非常复杂的仅仅因为提高了某个推荐模块是不是就能让用户多买贵的东西答案是不确定的。第二个特点就是第三和第四层次的指标依然可以用传统的A/B测试来进行观测只不过需要很仔细地设计实验。
第五个层次的指标就是用户和产品的长期指标。我们最开始提到的经济指标其实就是第五层次的指标。类似的指标还包括月活跃用户、年度活跃用户等等。这些指标有两个特点。第一这些指标往往是产品的终极目标一般极其难以撼动特别是对于成熟的产品而言。第二个特点是这些指标往往无法通过A/B测试进行衡量。也就是说我们往往无法通过常规的实验就能够观测到这些长期指标的变化这也是为什么这些指标被称为“长期”的原因。
小结
今天我为你讲了数据科学家和人工智能工程师如何评估产品的能力,这属于比较高阶的分析问题的能力。一起来回顾下要点:第一,我们如何来认识衡量产品经济收益这件事情。第二,我们很详细地阐述了什么是五个层次的评估体系,以及这个评估体系每个层次的特点。
最后给你留一个思考题如果第五个层次无法直接通过A/B测试进行观测那我们如何在平时进行A/B测试的时候就能确保是在优化第五个层次的指标也就是我们产品的终极目标呢

View File

@ -0,0 +1,77 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
132 数据科学家高阶能力之如何系统提升产品性能
人工智能工程师和数据科学家的一个核心任务,就是依靠人工智能、机器学习这样的工具来帮助产品不断提升品质,吸引更多用户,以实现既定的长期目标。这里有一个关键点,就是我们如何开发出一套方法论,让提升产品性能的过程可以“有章可循”,并成为一个系统性的流程。
初入门槛的工程师和数据科学家,容易把精力和眼光都集中在具体的算法模型上面。这固然是短期内的重要工作,但是,如何能够持续不断地为产品提供前进的动力,才是让人工智能技术有别于之前多次技术浪潮的根本因素。今天,我就来为你剖析一下,持续不断地、系统性提升产品性能的一些关键步骤。
优化长期目标
一个产品如果需要利用数据驱动的人工智能技术来提升品质,第一件事情一定不是专注于部署某一个模型或者算法。或者说,如果已经急迫地上线了第一个简单的算法,接下来最重要的事情一定是停下来,看一看我们是否已经弄明白,这个产品到底需要“优化”什么目标,是否有一个指标检测体系,来指导我们的优化过程。
我们利用人工智能技术手段一定要优化产品的长期目标,这是系统性提升产品性能的一个关键。乍一听这是一句废话,难道算法和模型还有不优化产品长期目标的时候?你心中一定有这样的疑问。其实,确定你所制定的技术方案一定能够优化产品的长期目标,是一件比较困难的事情。
设想一下这些例子。比如你为一个在线视频的网站设计推荐系统你根据很多教科书上的推荐系统案例优化某一个视频的评分Rating这是在优化这个产品的长期目标吗
比如你为一个电子商务网站设计搜索系统你根据传统的信息检索以及搜索的案例优化查询关键词和产品的相关度Relevance这是在优化这个产品的长期目标吗
再比如,你为一个新闻网站设计新闻流系统,你根据产品的基本特点,希望提高新闻的点击率,是在优化这个产品的长期目标吗?
针对上面这些问题,答案或许都是——不确定。或者说,你正在优化的可能会、也可能不会对这个产品的长期目标有影响,这就需要我们建立一个系统性的方法论,来引导我们回答这个问题。
因此,知道我们是否在优化产品的长期目标需要一个前提,那就是我们必须要建立产品的指标检测体系。在专栏的上一期内容里,我们已经介绍了五个层次的产品评估体系。对于提升产品来说,建立这些层次是关键的一步。然而,要想真正系统性地提升产品,还有一个至关重要的步骤,那就是打通这五个层次,建立一个立体的产品提升流程,从而实现优化产品的长期目标。
我们先来简单回顾一下这五个层次的指标。从最高层次说起,第五层次的指标主要是产品的长期指标,比如季度利润的增长、年利润、月活跃人数等。这些指标和产品的最终目的息息相关,却非常难直接衡量,也就是这些指标对产品的一般变化不是很敏感。
第四层次的指标主要是用户在多个会话的交互表现。第三层次的指标是指用户在单一会话的交互表现。这两个层次的指标比较容易在A/B测试的范畴内测量。这些指标能比较宏观地检测一个产品的高维度表现了解用户一般是如何和这个产品进行交互的。
第二层次是页面层级的指标,这个时候,我们观测到的基本上已经是产品团队可以直接控制的因素了。第一个层次的指标是模块级别的指标,这是产品团队直接运作的结果。
这五个层次的指标从宏观到微观,构成了一个检测的体系。如果我们要优化产品的长期目标,也就是说第五层次的指标,而我们能够直接掌握的产品决策,往往只能带来第一、第二层次指标的显著变化,这两者之间的差距如何来弥补呢?
我们前面举了好几个例子,比如视频推荐、产品搜索、新闻流产品等等。之前提到的技术方案大多数直接针对第一或者第二层次的指标,这些方案是不是能够对第五层次的指标奏效,其实是一个不确定的问题。
那么,问题的核心就变成了,如何在只能运作第一或者第二层次指标的情况下,对第三、第四甚至五层次的指标有间接的控制和影响呢?
建立层级指标之间的联系
上面我们提到了,要想持续地提高产品,最重要的就是要一直优化产品的长期目标。但是,如果我们只能控制产品的短期指标,如何才能优化产品的长期目标呢?
答案其实很简单,就是我们必须在所有层级的指标之间建立联系。这些联系因产品而异,但核心思想却是一致的。
回到之前的一个例子,那就是构建一个视频推荐系统。如果我们希望直接优化用户对视频的评分,就必需回答一个问题,能够给用户推荐打高分的视频,和产品的长期指标之间有什么联系?假设这里产品的长期指标是月活跃用户数目,那么问题就是,给用户推荐打高分的视频,和月活跃用户数目之间的联系是什么?
注意,这里说的建立联系不仅是逻辑联系,而且也是数据链联系。也就是说,我们不仅需要尽可能地在逻辑上理清,为什么推荐高分视频有利于帮助月活跃用户数的增长,还需要用数据来为这样的观点提供证据,这才是最重要的一个环节。
简单说来,我们可以这么做。首先,从所有的用户群体中找到用户样本。然后,通过数据来研究,用户的活跃程度和被推荐的视频评分之间的关系。从最高的维度上说,那就是建立一个回归问题,比如用户的月活跃程度作为响应变量,被推荐视频的评分用作一个特征变量。
当然,这个时候我们还可以引入其他的重要变量,比如性别、年龄组、地区等等,用来排除这些因素的干扰。直接研究这两者之间的关系一般来说是一个有难度的工作。比如你很可能并没有那么全面的数据,也有可能这两个变量都需要做一些变形,还可能负例太多(也就是说有大量的用户并没有因为评分的高低而改变他们的行为)等等。
如何具体地建立这个模型我今天先不讲,但有一点是可以肯定的,那就是这样做一个分析,可以很好地帮助你了解优化对象和长期目标之间的联系。
我们不仅需要了解第一层级和第五层级指标之间的关系,每一个层级之间的关系也是需要去研究的,这样才能更加全面地了解自己的产品。这一步就是把之前分散的五个层级打通的重要步骤,也就是如何建立一个立体体系的关键。
那么,如果出现了这样的情况,长期运作的第一层级指标和自己的长期目标没有联系,该怎么办呢?第一,祝贺你,你进入了真实的产品运作环境。从很多产品的长期运作经验来看,很多传统的指标特别是教科书上的指标,都和真实的长期指标有很弱的关系,甚至根本没有太大的联系。第二,这会帮你早日抛弃错误的优化目标,转向更加正确的道路。
寻找一个正确的第一、第二层级的指标,让这个指标和最后第五层次的长期目标之间有正向联系,就是能够持续不断地推动产品前进的一个重要动力。因为这个因素,产品团队才能够不断地试错,但不会失去大方向。
然而,说起来貌似很容易的事情,做起来其实是很困难的。我刚才说了,很可能有一些指标,看上去有一定的意义,但并不一定和长期目标有任何正相关。怎么才能找到恰当的指标呢?
一个简单的方法是尽可能多地记录指标,然后根据后期的实验数据和分析来确定指标之间的联系。回到刚才那个例子,就是说,我们对于一个视频网站,可以记录很多第一、第二层级的指标,有可能有上百上千个。然后我们根据数据,从这么些指标中,和最终的长期目标做回归分析,建立一些备选集。
这里需要数据、也需要经验。我们还可能发现,最终的长期目标和好多第一或者第二层级的指标都有关系,这也是很正常的。这就说明,优化长期目标是一件复杂的事情,很多短期目标和长期目标并不是只有简单的线性关系。
当确定好了第一、第二层级的指标后,那就可以开始用机器学习的手段,把指标当做算法模型的目标函数,从而重新设计算法,使其能够开始优化新的指标。这一步也需要很高的机器学习技巧和丰富的经验,因为并不是所有的指标,都能很容易地转换成机器学习可以优化的对象。
小结
今天我为你讲了,人工智能工程师和数据科学家的一个高阶能力技巧,如何才能不断提升产品的品质。一起来回顾下要点:第一,我们要专注产品的长期目标。第二,一定要建立产品短期目标和长期目标之间的关系,从而能够在直接优化短期目标的同时间接优化长期目标。
最后,给你留一个思考题,请你认真想一想,对于我们上面举例的推荐视频网站来说,有哪些第一或者第二层级的指标和用户的活跃程度有关呢?

View File

@ -0,0 +1,66 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
133 职场话题:当数据科学家遇见产品团队
我们在之前的分享中已经聊了数据科学家应该具备的基本能力,主要是希望从机器学习、统计知识、系统知识等方面给你一个完整的基本知识框架。然后我们聊了一些数据科学家的高阶能力,主要是能够通过分析产品、建立产品的评估体系以及对产品的长短期目标进行建模来系统性地提升产品性能。
今天我们就把话题从数据科学家和人工智能算法工程师自身的修养和提升,扩展到一个更大的范围,那就是在职场中必然会遇到的发展和协作问题,我们来聊聊数据科学家和产品团队的关系这个话题。
作为数据科学家或者算法专家,不知道你有没有遇到过这样的场景:
你正在开发最新的推荐算法,产品经理找到你说,希望给在北京的女性用户推荐一款红色的高跟鞋;
你正在研究如何使用最新的深度学习技术来提高搜索结果,产品的设计师告诉你,产品团队现在决定在近期做一个推广,需要在搜索结果上方展示一个很大的条幅,使得整个搜索页面往下移动了不少;
你正在给公司的广告系统设计新的模型产品的营销人员告诉你这周需要展示给用户的折扣信息广告位从以前的6个变成了3个。
相信类似的场景你应该不陌生。这也就是我们今天要探讨的问题,数据科学家如何在一个更加广阔的环境中协作。
数据科学家和产品团队的关系会出问题吗?
数据科学家和产品团队究竟有着怎样的关系呢?先理清楚这个问题,我们才能去探讨这样的关系会有怎样的互相依赖以及可能存在的问题。
在很多数据驱动的互联网公司产品团队Product Team和工程团队Engineering Team往往是实施一个具体产品的两个关键的力量。
产品团队通常情况下是产品经理领军拥有各类不同的产品负责人、设计师、UI设计师等人员对整个产品的设计、理念进行把关和掌控。
工程团队则主要是工程经理领军,各类架构师、算法工程师、前端工程师、数据库工程师等人员对整个产品的工程技术甚至运行维护进行把关和掌控。
在这个产品的图谱里,数据科学家所组成的“人工智能”团队有可能是独立于产品团队和工程团队的第三方力量,也可能是属于工程团队的一部分。这两种情况其实也略有不同。我们在这里就简化讨论一种情况,那就是数据科学家所在的团队和产品团队并不完全是一个团队的情况。
从大的格局来说,不管是什么团队,产品人员也好,工程师人员也好,都是为了产品的进步和提高出谋划策的,都是希望产品能够越做越好的。这一点毋容置疑。
然而,由于不同的团队分工以及各类人员不同的专业背景,在如何能够让产品做得越来越好这一点上可能就会存在不同的意见,甚至是严重分歧。设计人员可能认为产品下一步最大的可能性来自于更加简洁明亮的设计风格;产品营销人员可能认为用户应该会对下一场促销更感兴趣;工程师可能认为下一步需要整个团队重写一个重要框架代码,让页面渲染速度得到提升从而使得用户体验得到改善;数据科学家或者算法工程师正在考虑开发一个更加复杂的机器学习模型,来提升产品的智能响应;产品经理也许在想着如何做一个全新的手机界面,来体现一种新的用户生活体验。
这些想法也许都对产品有益,甚至都能让产品或多或少有所进步。但是,我们经常看到的是,不同背景的人员都对自己的专业很自信,有时候甚至是“过度”自信,从而只相信自己所处岗位所能发挥的作用。从数据科学家这个角度来说,因为大数据、机器学习以及其他人工智能技术手段的不断进步,可能就会导致我们过分强调算法和模型对产品带来的影响,而忽略了产品是一个非常有机的整体。
在这样的情况下,作为数据科学家或者人工智能工程师,往往会遇到我们今天开始提及的情景。一方面你在做着自己认为能让产品有最大收益的事情,而另一方面,整个产品有机整体的各个部分都在运作着,有可能会“破坏”掉你所做过的或者正在做的努力。如果这时候数据科学家以一种算法第一的心态看待产品,就会发现自己的工作非常难以展开,也会和产品的其他部门产生矛盾。
另外一种情形是,产品经理或者产品部门对机器学习或人工智能抱有不切实际的幻想,认为这是解决一切问题的灵丹妙药。于是所有和产品进步相关的想法都希望通过人工智能来得以实现,这无疑给数据科学家和工程师增加了很大的压力。
然而不管处在哪种场景中,我们都可以看到,数据科学作为一个技术工程范畴和其所从事的人,数据科学家,无疑都是在一个复杂的环境中对产品起着作用。要想充分发挥出数据科学的作用,我们必须深入理解数据科学家和产品团队的关系,从而打造一个有机的产品团队生态体,使得处于各个职能的人员都能够在一个和谐竞争的状态下对产品有所贡献。
如何把握数据科学家和产品团队的关系
既然我们聊到了数据科学家或者人工智能工程师和产品团队之间的微妙关系,那么,有没有什么方法能够让这种关系变得更加明朗,更有利于数据科学发挥出更大的作用呢?
首先有一点很重要,也是整个团队需要先明确的是,数据科学、人工智能在现阶段来说,依然是大多数产品的“奢侈品”。什么意思呢?也就是说,没有很多基础设施的建设,没有一些最基本的产品功能,没有最简单的数据链路,就不可能应用最基本的数据科学,也不可能对产品进行持续提高。正因为此,数据科学家其实应该和产品经理建立好关系,从而能够从一开始就心系整个产品的发展,能够有一颗包容的心,为产品能够快速达到这个最基本的状态出谋划策,同时也要让整个产品时刻都处于这个状态。
这里面涉及到一个“教育”和“再教育”的问题。不是所有的产品人员都对人工智能有所了解也不是所有的产品人员对数据链条的概念都有所耳闻。比方说产品的数据是通过前端的一段JavaScript代码进行数据传输的而这段代码可能和某一个产品的界面设计有紧耦合。当设计人员“突然”对现在的设计进行了更改满心希望这样的更改可以改进产品哪知道这也许反而“破坏”了这段收集数据的代码从而使得数据链条断裂而机器学习的某些代码可能就无法正常运行或者模型接收到的是垃圾数据。在传统的观念里一位设计师可能很难理解为什么自己的工作会和机器学习紧密结合。所以这就需要数据科学家和各个岗位的人员去交流、去沟通让更多的人能够理解数据产品的涵义。
其次,数据科学和人工智能让产品成为一个有机整体。我们一定要去理解产品效果的复杂性和组合性。比方说,在很多互联网产品中,通过经验我们经常能够发现,产品外观设计的改变,常常能够带来比纯算法改变好得多的效果提升,而很多营销手段又常常能够几倍地提高用户对产品的转化率,也使得产品的效果得以提升。当然了,这并不是说,夸大任何一方面就能够让产品有更大的提高。实际上,产品的最优情况往往是各个方面的一个复杂的协调平衡状态。因此,理解数据科学在整个大环境中的位置就十分重要。
最后还有一个可以去做的,那就是看如何利用人工智能和数据科学去帮助产品团队的其他人员,比如能否帮助设计师和前端找到更好的创意,能否帮助产品经理找到更好的产品迭代方法等等,让人工智能和数据科学融入到整个产品完整的图谱中,要比提高单个算法更有意义。
小结
今天我为你讲了人工智能工程师和数据科学家所面临的一个重要的职场话题,那就是如何把握和产品部门的其他人员的关系。
一起来回顾下要点:第一,我们简要剖析了数据科学家和产品团队之间可能产生问题的原因和一些经典的情况。第二,我们分析了要去更好地推动这个关系,有哪些需要注意的地方,有哪些可以做的事情。
最后,给你留一个思考题,如果营销人员告诉你一个他们的方案,但这会影响你所负责的产品算法的呈现,这个时候你会怎么做呢?

View File

@ -0,0 +1,99 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
134 职场话题:数据科学家应聘要具备哪些能力?
周一,我们探讨了在公司内部,数据科学家和产品团队的其他职能人员在协作中都会遇到哪些问题,以及如何看待数据科学家或者人工智能工程师所做的算法性工作在一个产品发展中的位置。
那么,今天我们稍微换一个方向,来讨论数据科学家和算法工程师在应聘方面的问题。一起来看看,作为数据科学家,在面试一家公司时,究竟应该怎么准备,有哪些信息是需要了解的。
希望今天的内容对正在思考进入这个行业的年轻学者、工程师有所帮助,从大的方向上为你的应聘提供一些可借鉴的内容。
数据科学家应聘的“硬”实力
对于数据科学家或者人工智能工程师来说,最核心的竞争力无疑是他们对人工智能、机器学习等技术的知识积累以及融会贯通的能力。
我们之前的一系列分享中已经提到了这些“硬”实力的大范畴,这里我做一个简单的归纳。
首先,我们需要理解和掌握一些机器学习的基本概念和理论。
第一个重点无疑就是监督学习。
什么是监督学习呢监督学习就是指我们通过外部的响应变量Response Variable来指导模型学习我们关心的任务从而达到我们需要的目的这一过程。监督学习中需要彻底掌握三个最基础的模型包括线性回归Linear Regression、对数几率回归Logistic Regression和决策树Decision Trees
怎么理解我说的“彻底掌握”呢?这里的彻底掌握有三层含义。
第一,需要了解这些模型的数学含义,能够理解这些模型的假设和解法。比如,线性回归或者对数几率回归的目标函数是什么;写好了目标函数之后,如何求解最优解的过程。对于这些核心模型,必须能够做到完全没有差错地理解。
第二,需要了解什么场景下使用这些模型是最合适的,以及怎样把一个实际问题转化成为这些模型的应用,如果不能直接转换还有什么差距。
第三,能不能写实际的代码或者伪代码来描述这些模型的算法,真正达到对这些算法的掌握。
监督学习当然不限于这三个算法,但是这三个算法是绝大多数机器学习任务在工业界应用的起点,也是学习其他算法模型的支点,可以按照这个思路去了解更多的算法。在面试中,能够对这些基本算法的理解有扎实的基本功,这一点很重要。
了解机器学习的第二个重点就是无监督学习。
无监督学习并没有明显的响应变量,其核心往往是希望发现数据内部潜在的结构和规律,从而为我们进行下一步决断提供参考。
从面试角度来说“K均值算法”往往是考察数据科学家整个无监督学习能力的一个核心点。因此对于这个算法有必要认真学习做到真正的、彻底的理解。
怎么学习呢和前面我们提到的监督学习一样也需要从编程实现和算法本身两个方面入手对K均值进行把握。在掌握了K均值之后还可以进一步去了解一些基于概率模型的聚类方法扩宽视野比如“高斯混合模型”Gaussian Mixture Model
其次,虽然机器学习和统计学习有不少的重合部分,但是对于合格的数据科学家和人工智能工程师来说,一些机器学习方向不太容易覆盖到的统计题目也是需要掌握的。
第一,我们必须去理解和掌握一些核心的概率分布,包括离散分布和连续分布。这里的重点不仅仅是能够理解概念,而且是能够使用这些概率分布去描述一个真实的场景,并且能够去对这个场景进行抽象建模。
第二,那就是要理解假设检验。这往往是被数据科学家和算法工程师彻底遗忘的一个内容。我们要熟悉假设检验的基本设定和它们背后的假设,清楚这些假设在什么情况下可以使用,如果假设被违背了的话,又需要做哪些工作去弥补。
第三那就是去学习和理解因果推断Casual Inference。这虽然不是经典的统计内容但是近年来受到越来越多的关注。很多学者和工程师正在利用因果推断来研究机器学习模型所得结果的原因。
再次,还有一个很重要的“硬”技能,就是要对系统有一个基本了解。
第一就是具备最基本的编程能力对数据结构和基础算法有一定的掌握。编程语言上近年来Python可以说受到了诸多数据相关从业人员的青睐。因为其语言的自身特点相对于其他语言而言比如C++或者JavaPython对于从业人员来说是降低了学习和掌握的难度。但另一方面我们也要意识到大多数人工智能产品是一个复杂的产品链路。整个链路上通常是需要对多个语言环境都有所了解的。因此掌握Python再学习一两个其他的语言这时候选择Java或者C++是十分必要的。另外很多公司都采用大数据环境比如Hadoop、Spark等来对数据进行整合和挖掘了解这些技术对于应聘者来常常说是一个让用人单位觉得不错的“加分项”。
第二,就是对于搭建一个人工智能系统(比如搜索系统、人脸识别系统、图像检索系统、推荐系统等)有最基本的认识。机器学习算法能够真正应用到现实的产品中去,必须要依靠一个完整的系统链路,这里面有数据链路的设计、整体系统的架构、甚至前后端的衔接等多方面的知识。考察候选人这方面的能力是查看候选人能否把算法落地的一个最简单的方式。因此,从我们准备面试的角度来说,这部分的内容往往就是初学者需要花更多时间了解和进阶的地方。
数据科学家应聘的“软”实力
前面我们聊了数据科学家应聘的“硬”技能,下面,我们再来看看候选人还需要注意和培养哪些“软”技能。
数据科学家的第一“软”技能就是如何把一个业务需求转化成机器学习设置的“翻译”能力。
什么意思呢?和纯理论学习的情况有所不同,大多数真实的业务场景都是非常复杂的。当产品经理提到一个产品构思的时候,当设计人员想到一个业务创新的时候,没有人能够告诉你,作为一个数据科学家而言,这个问题是监督学习的问题还是无监督学习问题,这个问题是可以转换成一个分类问题还是一个回归问题。有时候,你会发现好像几条路都走得通。因此,如何能够从逻辑上,从这些不同的设置所依赖的假设上来对业务场景进行分析,就成了数据科学家必不可少的一个核心能力。
分析业务场景这个“软”技能的确非常依赖工作经验。这里不仅仅是一个机器学习问题的“翻译”,还需要对整个系统搭建有所了解,因为真正合适的场景“翻译”往往是机器学习的问题设置和系统局限性的一个平衡和结合。举一个例子,一个推荐系统需要在百毫秒级给一个用户进行推荐,那么相应的方案就必然有一个计算复杂度的限制。
因此,场景的“翻译”其实是考察数据科学家和人工智能工程师的一个非常重要的步骤,也是看候选人是否真正能够学以致用的有效手段。
说到这里,你是不是会有疑问:如果我没有相关的从业经验,那如何来锻炼这种“翻译”能力呢?
其实,现在丰富的互联网产品已经为我们提供了一个无形的平台。当你在现实中看到一个真实产品的时候,比如京东的产品搜索、科大讯飞的语音识别系统等等,你设想一下,如果你是设计者,如果你是需要实现这个产品功能的数据科学家,你会怎么做?
实际上,很多面试问题,都是面试官直接询问你对某一个现成产品的设计思路,比如谷歌的面试官可能会询问你如何设计一个搜索查询关键字拼写检查组件。这个方法一方面是帮助你“开脑洞”,另一方面也是一种非常好的思维锻炼。
另外一个很重要的“软”技能就是数据科学家的沟通表达能力。
这可能会让有一些人感到意外,因为大家也许认为数据科学家和人工智能工程师完全是技术岗位,并不需要与人打交道。其实,这个理解是片面的。就像刚才提到的,数据科学家的一个重要职责就是把现实的业务场景“翻译”成机器学习的设置,那么在这个过程中,会和业务人员、其他工程师、科学家进行高频的沟通和交流。如何把你的思路、方案清晰地表达给同事和团队成员是非常重要的职责。
实际上,数据科学家不仅在公司内部承载着的这样的沟通任务,我们往往还需要在社区中做演讲、参与讲座等活动,成为社区中的一份子,都离不开沟通表达能力的磨练。
如何锻炼沟通表达能力呢?这里,我给初学者一个简单而实用的方法,那就是用一两句话来总结你的方案。你尝试用一小段话,但是不夹带任何专业术语,把你的方案说给不懂机器学习的人听。这个训练方法可以让你反复思考,直到找到一个最简洁有力的表达。
小结
今天我为你讲了人工智能工程师和数据科学家的一个重要的职场话题,那就是作为数据科学家应聘时需具备的“硬”实力和“软”实力。
一起来回顾下要点:第一,我们讨论了机器学习、统计知识和系统这三大“硬”实力。第二,我们分析了场景翻译和沟通能力这两个“软”实力。
最后,给你留一个思考题,当下深度学习框架大行其道,那么对于应聘来说,你觉得了解和掌握各种深度学习框架会让你更有优势吗?

View File

@ -0,0 +1,73 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
135 职场话题:聊聊数据科学家的职场规划
今天,我们继续来聊数据科学家或者人工智能工程师的职场话题。我们更进一步,来聊聊数据科学家的职场规划。
当然,说到职场规划,这确实是一个非常宽泛的主题。我们今天要探讨的不是数据科学家“应该”怎么发展,而是说,有哪些职业发展的“可能性”,希望能够为你规划自己的职业生涯起到一个抛砖引玉的作用。
数据科学家的“垂直发展”
数据科学家一个最直接的职场规划,就是在技术线上持续发展,逐渐成为一个技术专家。目前,不同公司对数据科学家类型,这里包括研究科学家、算法专家、人工智能工程师等职位的职业生涯设置并没有完全统一的模式。但是,数据科学家类型的职位在技术线上大体有这么几个台阶可以发展。
第一个台阶是“初级数据科学家”。
这个台阶对应很多公司入门级别的数据科学家并且大概是对应博士生毕业直接入职或者硕士生有2-3年工作经验后入职这样的情况。这个阶段的数据科学家其主要职能是在一个比较大型的产品解决方案中完成一个小的模块或者任务。当然也可以是在一个比较小型的产品解决方案中完成较大的模块或者任务。
初级数据科学家对机器学习和人工智能的掌握程度主要集中于单独的算法。因为对业务需求接触不多,在如何利用模型和算法来对整个业务提供解决方案,也就是我们之前说的“翻译”业务的能力上,存在着比较大的挑战。这也是初级数据科学家在这个阶段最需要积累和进阶的部分。
下一个台阶就是“中级数据科学家”。
这个台阶对应很多公司的“高级数据科学家”Senior Data Scientist、“主管数据科学家”Staff Data Scientist。一般来说“初级数据科学家”有1-3年工作经验之后就有机会晋升到“高级数据科学家”然后再有1-3年工作经验之后就有机会晋升到“主管数据科学家”。“主管数据科学家”平均应该有5年左右的从业经验。
对于这个台阶的数据科学家而言,已经可以承担一个比较大型的产品解决方案的绝大部分甚至全部的模块和任务。并且在团队内部,这个台阶的数据科学家已经可以指导绝大多数的初级数据科学家。同时,这个级别的数据科学家对公司的整个宏观产品线有了更多的认识,对业务需求的“翻译”能力有很大幅度的提升。在纯技术层面,“中级数据科学家”对于机器学习和人工智能算法模型的把握已经跳出了单独一个算法或者模型的层面,可以比较好地去把握一个方向,特别是有可能的新的研究方向。
最后一个台阶,我称之为“高级数据科学家”。
这个台阶对应很多公司的“资深主管数据科学家”Senior Staff Data Scientist、“主任数据科学家”Principal Data Scientist以及其他更高的职位。一般来说成为“中级数据科学家”后再有1-3年的工作经验可以晋升到这个台阶。“高级数据科学家”平均应该有5-7年的从业经验。
对于这个台阶的数据科学家而言基本上已经算是行业的专家对某一个类型或者某几个类型的产品解决方案有深刻洞察。另外一个能力就是这个台阶的数据科学家相对比较容易举一反三能够对新的产品或者新场景下的解决方案有相对快速和成熟的理解。在团队内部这个台阶的数据科学家处于整个团队的核心的位置对“中级数据科学家”和“初级数据科学家”都能够起到很好的指导作用。在纯技术层面可以针对机器学习和人工智能过去20年的大部分算法融会贯通能够带领团队对一系列新的研究方向有比较好的把握。
数据科学家的“升级发展”
数据科学家的另外一种职场规划,其实也和众多工程师的规划类似,那就是转到“管理线”或者叫“技术管理”的岗位,特别是管理和数据科学、人工智能直接相关的团队。
数据科学家对于管理职位的优势是,他们有着在这样团队中工作和运行的第一手经验和资料。这些也为数据科学家转到管理职位提供了一些先天的背景优势。
因为人工智能团队或者数据科学团队具有高度专业化和技术化的特点,没有相关技术背景的管理人员,会非常难以胜任这样的角色。主要表现在以下几个方面。
第一,这些团队往往意味着需要招聘、管理和拓展一个由硕士和博士背景为主体的团队,完全理解和体会这个人群的需求以及这种团队对于工程、技术等方面的独特需求,对于一般背景的技术管理人才来说可能会比较困难。
第二,这个技术管理职位往往需要和技术社区,特别是人工智能社区有一个积极的交互。完全没有相关技术背景,在这样的社区立足并且作为一个领导者得以发展,相对比较困难。
第三当然还是在技术方案上因为专业性过强如果技术管理人员没有背景就无法对方案进行评估然后就变成了完全的“人事经理”People Manager
除了从人工智能团队管理岗位入手以外,数据科学家还可以挑战和人工智能有关的一些管理岗位,比如数据,或者有时候叫大数据部门。这些部门和人工智能部门经常紧密合作,所以数据科学家也算是对这些部门耳濡目染,相对来说有着比较清晰的认识。
毋容置疑,数据科学家从纯技术岗位到管理岗位的转换过程中,肯定会面临不少困难。对于有志转岗的数据科学家来说,他们往往在纯技术岗位上工作得比较优秀,一些管理的机会自然出现,于是也就顺理成章地转了过去。然而,对于这些优秀的纯技术人员来说,比如“中级”或者“高级”数据科学家,真正的挑战在于,如何能够去领导一个团队去完成一个使命。一些优秀的数据科学家因为自身条件优异,往往存在大包大揽的情况,希望靠自己的能力做出比整个团队还要好的成绩,反而在管理岗上无法施展应有的水平。其实,如何做一个优秀的人工智能技术管理者,这还是一个非常有新意和挑战的话题,篇幅有限,今天就不展开了。
数据科学家的“跨界发展”
除了我们刚才说的在纯技术岗位的发展以及往管理职位发展以外,数据科学家其实还有一些横向发展的机会。
比如,最“无缝”发展的就是在工程团队或者数据分析类团队之间进行转换。因为数据科学家的工作性质,这两类团队的工作或多或少都已经包含在了数据科学家的日常工作中了。因此,数据科学家可以比较自然地转换到这些团队中。当然,这里还是需要对一些技能进行加强培训。
另外,数据科学家其实比较适合转移到产品经理岗位。在“中级数据科学家”之后,这些技术人员需要对业务、对整个产品有比较深入的理解,包括需求、数据、工程技术等,才能对一个产品提出比较合适和成熟的解决方案。另外,数据科学家还需要不断提升产品的质量水平,这里面其实就有不少产品经理的角色。因此,数据科学家算是具备成为一个产品经理的一些条件。不过,我们这里要指出的是,数据科学家的整个背景训练主要是以纯技术为主,特别是人工智能算法,因此转换到产品经理的时候,可能往往过分强调算法的力量,而忽视整个产品的其他方面。所以,即便是一个成熟的数据科学家依然需要一段时间的培养和培训,才能够转换到产品经理的角色。
小结
今天我为你讲了人工智能工程师和数据科学家的职场规划问题。一起来回顾下要点:第一,我们简单介绍了最为自然的一条发展途径,走纯技术的路子,数据科学家可以有怎样的一条道路向前发展。第二,我们分析了从技术岗位到管理岗位的一个转换,数据科学家又有什么优势。第三,我们简单讲了从数据科学家到其他类型职位一个转换的问题。
最后,给你留一个思考题,你有没有什么方法,可以知道自己比较适合什么样的职业发展规划呢?

View File

@ -0,0 +1,10 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
136 如何组建一个数据科学团队?

View File

@ -0,0 +1,97 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
137 数据科学团队养成:电话面试指南
眼下,数据科学或者人工智能团队已经成了很多数据驱动公司的标准配置团队,数据科学家或者人工智能工程师也成为了最“性感”的职业。不少公司都在想办法建立或者扩展自己的数据科学团队。那么,对于一个公司来说,究竟需要什么样的数据科学团队呢?这就成了很多公司在发展过程中都会遇到的棘手的问题。
我们在之前的一篇分享里已经剖析过,作为一个工程团队的负责人,你该如何招聘自己的数据科学家团队。在那篇分享里,我们探讨了目前人才市场上大致有两类数据科学家,一类偏数据分析,一类偏算法模型。并且我们详细探聊了聊这两类数据科学家所需的技能和在不同团队(比如大团队和小团队)中起到的作用。
今天,我们来聊一聊组建数据科学家团队所必不可少的一个步骤:电话面试。
筛选简历
在电话面试之前,有一个步骤是必不可少的,那就是筛选简历。因为人工智能和数据科学家的职业背景的原因,我来分享一下如何筛选具有博士学历,特别是计算机专业相关毕业生的简历。筛选简历的过程需要很细心,对于普通的博士毕业生,我们会快速看以下两个方面的信息。
第一,候选人是否有高水平的论文发表。关于论文发表,首先需要看的是论文档次,也就是论文是否发表在高质量的会议上或者高水平的期刊上。对于计算机专业的博士生来说,会议一般比期刊更重要。其次,我们也要看候选人的论文是专注一个问题或者一个小领域还是很多领域都有涉猎。同时,对于这些论文,要关注候选人是第几作者。然后,我们需要关注的是论文发表频率,看论文工作是否都是一年做出来的。最后,我们可以去看一看这些论文的引用数。一般来说,博士刚毕业不会有很高的论文引用量,但也不乏水平比较高的候选人,论文会有惊人的引用量。
第二,我们需要看一看候选人是否有工业界实习经历,是研究实习还是工程实习。这里面,我们可能关注的是实习的公司。而且,我们可以关注是否是同一家公司还是多个公司。如果是研究实习的话,我们还需要去看一看候选人是否有相应的论文发表。
在看了这两个因素之后我们心中对于这个候选人就有一个很基本的认识。在需要高标准的情况下一个博士毕业生需要有3-4篇第一作者的高水平论文发表在毕业的时候引用数在70-100左右然后有1-2次工业界实习经验。
除了这两个硬指标以外,我们还会关注下面这些内容:
简历里是否有一些信息不完整的部分。比如有一些明显断档的经历,没有本科学校,没有说明博士生导师;
会什么编程语言和开发工具。是否只熟悉Matlab或者R是否有开源项目贡献
是否已经有审稿经验;
是否已经有组织会议的经验。
所有这些因素都没有明显问题之后,我们已经定位到了比较靠谱的候选人(通常,只有少数人能够通过上面这轮简历筛选)。我们可以根据实际情况来调整在筛选简历这里的标准线从而让候选人能够和我们直接交流证明自己的实力。
这里再说几个比较细的准则:
博士生的论文中,非第一作者的一般不算数;
已经发表的会议论文和同一内容的期刊文章算一篇;
可以有非第一档次会议或者期刊的论文,但没有第一档次就很难说明问题;
如果有单一作者的论文,是一个比较大的问题,电话面试的时候一定要问清楚原因;
课程项目原则上也不算数(注意,这是对博士毕业生而言);
简历是LaTex生成还是Word
毕业学校和GPA一般不是很侧重要考虑的问题。
再说几个对于已经有工作经验的候选人的简历筛选要素:
如果有教职经验或者博士后经验,原则上是一个大问题,需要电话面试问清楚;
一两年左右频繁换公司是一个大问题,需要电话面试问清楚。
这里要多说一句的是,上面这些标准是对计算机相关专业比较适用的准则。而对于数学、应用数学、统计、物理等专业的人来说,可能有些标准需要重新设定(比如发表论文的标准需要降低)。总之,这里说的是一些比较大的方向,不过在把握了这些原则之后,我们就可以安排少量的候选人电话面试了。
这里我们简单说一下对于硕士阶段的候选人的简历筛选。一般来说,硕士和博士有不同的培养目标,因此上面所说的很多标准和原则对硕士毕业生并没有完全的指导意义。对于硕士毕业生来说,公司实习经验是很重要的。不排除一些优秀的硕士毕业生已经有论文发表,因此这方面也可以降低一些标准来衡量。对于硕士毕业生来说,学科项目可以作为一些参考,不过因为大多数学科项目都没有真正的应用性,我们只能从一个侧面了解这个候选人可能具备的一些技能。
电话面试
筛简历的过程之后就是电话面试了。电话面试的目的是要验证这个候选人是不是像简历里所说的那样有相应的经历。当然有一些公司在电话面试的时候也会考察候选人解决问题的能力这个内容也会经常出现在电话面试的安排中。对于科学家的职位我们一般需要1-3轮电话面试来了解下面这些信息
了解候选人简历上的基本信息,如果对简历上的内容有疑点,需要在这个阶段问清楚;
考察候选人是否具备基本的专业知识,并对相关领域有一定的见解,考察候选人是否有其他领域的知识;
考察候选人是否有基本的专业相关的编写代码能力;
初步感知候选人的表达能力。
在询问候选人简历信息的时候,以下这些内容是需要弄明白的:
对于候选人是第一作者的论文,候选人是否能够很清晰地说出这些论文所解决的问题及解决思路。在进一步的沟通里,候选人是否能够讲清楚模型细节甚至是公式细节。候选人能否把实验的目的、数据、比较算法讲清楚。当然,这需要面试官提前做好准备。同时,询问候选人其他作者在这篇论文中的贡献;
对于候选人是非第一作者的论文,询问候选人在这个工作中起到了什么作用。看候选人是否诚实可信,也可以看出候选人的学术道德水平;
对于单一作者的文章,需要候选人解释为什么这个工作没有合作人,博士生导师为什么不是合作者,这个论文的研究时间如何而来;
对于有博士后经验或者教职经验的候选人,要询问候选人是否了解工业界研究和学术界研究的区别,如果以后有机会,是否还考虑学术界教职;
对于有工作经验的候选人,要询问候选人反复换工作的原因,询问清楚候选人在项目里的具体贡献,候选人的职业规划,看职业规划和简历经历是否相吻合。对于在某一个公司待了很长时间没有升职的候选人,也需要询问一下为何在原公司里没有其他机会。
在考察候选人专业知识的时候,需要弄明白以下这些内容:
对于某一专业最基础的一些概念和知识,候选人是否能够清晰地讲解出来。这一条其实很多人很难做到,不少人能够做复杂的工作,却往往在最基础的内容上含混不清。而在一些跨领域的工作中,基础知识往往是一个科学家所能够依赖的,提供解决方案的最初的工具。所以,基础很重要;
候选人是否能诚实地说明自己懂什么,不懂什么。在广泛的领域里,科学家应该有足够的自信说自己的专长是什么,自己的局限在哪里;
候选人是否对跨领域知识一窍不通,还是略有知晓,界限在哪里;
在考察编程水平方面,虽然很多公司已经有比较完备的方案考察软件工程师,但这些题目和考察目的其实不太适合科学家,这需要公司专门针对科学家制定一些考察题目。
在上述考察候选人各个方面的过程中,一个贯穿始终的主题就是要看候选人是不是能和面试人员进行有效的沟通。当然,也要考虑到,有人可能不太适应电话面试,而在面对面的交流时则毫无问题。
小结
今天我们分析了组建一个数据科学或者人工智能工程师团队,你需要招聘什么样的数据科学家。我们重点讲了讲如何筛选博士阶段候选人的简历以及电话面试的问题,我们是从招聘的角度来讲这个问题,那么从应聘的角度来看,也希望能给你一些启发和借鉴。
最后,给你留一个思考题,如果一个候选人并没有什么论文,但是有多年的企业经验,如何来衡量这样的候选人呢?

View File

@ -0,0 +1,77 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
138 数据科学团队养成Onsite面试面面观
本周我们来聊数据科学或者人工智能团队的招聘话题。周一的分享里,我们聊了聊组建数据科学家团队所必不可少的两个步骤,筛选简历和电话面试。我们着重从招聘博士毕业生的角度对这两个环节进行了剖析,梳理了如何看简历,以及在电话面试时需要考察哪些内容。
今天我们来聊一聊电话面试后面的一个环节也就是邀请候选人到公司面试俗称Onsite面试。
从电话面试到Onsite面试
电话面试之后,如何判断是否要邀请候选人到公司来面试呢?一般来说,有这么两种情况是需要邀请候选人到公司来面试的,从而进一步判断候选人的水平。
第一候选人的简历以及其在电话面试中表现的水平很高的确是公司需要的人才。这样的候选人进入Onsite面试的通道是水到渠成的要加快速度实施公司招聘流水线的后面步骤。对于这样的候选人来说Onsite面试主要是要考察候选人有没有其他特殊情况导致其无法胜任工作。
第二,候选人的简历或电话面试中的表现存在争议。可能在好几轮的电话面试中,候选人在其中有些轮的表现要明显好于其他轮;或者候选人得到了很多好评,但是也有一些比较负面的评价。这个时候,我们采取不“一棒子打死”的态度,往往希望能够邀请候选人到公司来仔细考察。
Onsite面试
在经历了简历筛选和电话面试的流程之后,我们已经对候选人有了一个初步的了解:他(她)的背景、熟悉以及不熟悉的领域、编程能力和沟通能力。对于各方面都表现不错的候选人,我们一般就会安排到公司来进行现场面试。对于科学家岗位,现场面试一般包括下面这些环节:
一场一个小时左右的学术报告会;
和招聘经理讨论可能的项目方向;
和其他科学家、工程师讨论技术和研究问题;
在白板上展示基本的编程开发能力;
和人事讨论职位的其他问题。
学术报告会是考察候选人学术水平的一个非常重要的环节。因为简历和电话面试都无法系统地看出候选人的整个学术生涯的特征,比如是偏理论还是偏应用?是蜻蜓点水似的研究,还是专注某几个问题?这样我们能够看到候选人的整个学术生涯的清晰明确的线条。
同时,报告会还是观察候选人语言能力的好机会,看候选人是否有较强的语言组织能力,能够清晰地表达自己。这一点之所以关键是因为有一些候选人连自己的工作都讲不清楚。
另外一个需要考察的就是看候选人能否在公开场合接受各种质疑和对自己工作的挑战包括候选人是否能够承认自己工作的局限和不足是否能够礼貌且“一语中的”To-The-Point地回答技术问题。
和招聘经理讨论可能的项目方向,很多候选人显得很随意,觉得这就是闲聊。其实这也是考察候选人的一个很重要的机会。
首先,招聘经理可以说一些公司的产品或是项目,看看候选人是否有兴趣,是否能够通过一些简单的产品介绍,问出一些有科学价值的问题。会问问题,其实是一个非常重要的技能。
招聘经理也可以稍微深入地讨论一两个产品具体的现实问题看候选人能否快速说出一些解决方案或者是一些思路。在整个谈话中可以体会出候选人是否只有学术的经验而没有任何产品和产业的“感觉”Sense。有一些候选人在这个阶段会显得没法把谈话进行下去完全是倾听问不出任何问题。这就需要招聘经理仔细控制谈话来看候选人是否对新事物有好奇心是否能够跟上思路是否对新领域新问题有快速的思考。
和参加面试的科学家以及工程师讨论研究问题,主要考察的是候选人在一个类似工作的环境里能否“半”独立地完成科研解决方案的设计和实现。为什么说“半”独立,是因为这个环节里,沟通也是很重要的,很多条件、约束和限制都需要候选人和面试人员进行有效沟通来理解清楚。因此,候选人面对的并不完全是“应用题”似的独立解决问题的场景。
通常的形式是,面试人员针对某个具体的问题,询问候选人如何提供一个有效的科学解决方案。这里面需要注意下面这些环节。
1.候选人能否问出有效的问题,这些问题是不是在帮助候选人自己减少问题的不确定性,帮助候选人自己寻找答案,还是漫无目的地问各种问题。
2.候选人是不是不假思索地就提供一些思路,然后也没有认真思考,又反反复复更换思路。这是候选人没有系统思维能力的一个体现。
3.候选人的整体思维模式是怎样的?
一般说来,有两种思维模式。第一种是先提出一个可能的多步骤解决方案,然后看是否能够简化步骤,再看能否提出比较规范的数学模型;第二种思维模式是先提出比较完整的数学模型,然后根据实际情况简化,提出更加快速的算法。
这两种思维模式都是行之有效的思维方式。但是也有候选人在两者之间踌躇,一方面提不出基本的解决方案,一方面也写不出完整的数学模型来。
4.候选人能否在提出基本方案或者是数学模型之后,用自己掌握的方法把问题的细节算法写出来,并且能够分析算法的各方面特征。这考察的是候选人解决问题的连贯性和独立性。有一些候选人的确能够写出漂亮的数学模型,但是很可能完全没办法把模型算法化,写出来的程序惨不忍睹。
5.还有一个需要考察的维度就是,候选人遇到领域之外的问题,是如何思考的。有的候选人就彻底懵了,完全不能理性地提出方案。而有的候选人则会小心翼翼地利用基础知识,尝试解决问题,或者是把新领域的问题转化成自己熟悉的问题。
值得注意的是,在这个环节中表现不好的候选人,不管过去在论文、学校方面有多么优秀的经历,都要打一个大问号。事实证明,在这个阶段不那么令人满意的候选人,在现实工作中往往也很难胜任实际的工作。
对于有经验的候选人,除了重点考察能否提出优秀的解决方案外,还可以看候选人是否具有“全局观”,比如对这些问题的考量:如何设计更加有效的数据通路,没有数据怎么办,上线以后系统表现不好怎么办等。
对候选人在白板上进行基本的编程能力的测试是整个Onsite考察中的另外一个核心内容。总的说来数据科学家或者人工智能工程师的编程能力需要和普通工程师的基本相当有些时候甚至要更高。这里面除了考察基本的算法问题以外还需要考察能否对普通的机器学习算法进行编程也就是说看候选人是否真正能够把模型或者一些算法用程序实现出来。关于候选人的编程能力问题这是一个单独的话题今天我们就不在这里展开了。
有一点需要留意观察,候选人的表现是否在有压力或者劳累(毕竟一天的现场面试是很累的)的情况下有重大波动。优秀的候选人能够通过沟通来缓解自己的压力。
小结
今天我们讨论了Onsite面试总结一下要点第一我们讲了如何决定一个候选人可以从电话面试过度到Onsite面试第二我们详细梳理了Onsite面试方方面面的问题。希望这些内容能给你一些启发和借鉴。
最后给你留一个思考题Onsite面试之后如何来决定是否录用这个候选人呢是不是需要所有的人都赞同 如果不是所有人都赞同,怎么综合意见做出最后的决定呢?

View File

@ -0,0 +1,75 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
139 成为香饽饽的数据科学家,如何衡量他们的工作呢?
本周我们聊了在构建一个数据科学家团队时从筛选简历入手到电话面试再到Onsite面试这一系列的流程。从无到有建立一个数据科学家或者人工智能团队的确是一件煞费苦心的事情。
那么今天我们来聊一聊数据科学家团队管理的下一个重要的步骤那就是如何来衡量数据科学家或者人工智能工程师在团队中的业绩有时候也被称为是绩效评定Performance Review。绩效评定的种种规则必须在团队建立的初期就明确否则就会出现一些不定因素对于招聘、培训以及留住人才都有着不可估量的影响。
数据科学家的价值
如何对数据科学家团队进行绩效评定呢?这个问题的核心其实是要回答,数据科学家或者人工智能工程师究竟应该(以及实际)为你的公司或者组织带来什么核心价值?只有梳理清楚这个核心问题,才能真正建立起衡量数据科学家团队的价值体系,从而达到为公司和组织赋能的目的。
那么,数据科学家团队或者人工智能工程师团队应该为企业或组织带来什么样的价值呢?
对于这类相对来说比较抽象的问题,其实很难有一个标准答案。每一个组织或者公司都有自己一套衡量价值的方式。这里我们并不追求一个统一的答案,而是希望能够为这个问题提供一些参考。
关于这个问题,在我们前面的一些分享中,其实已经提到过,那就是数据科学团队最重要的一部分价值来自于为企业或者团队引入数据驱动的决策过程,这是数据科学团队或者人工智能团队的一个核心价值。很多企业或者组织,在没有这些团队之前是无法真正做到数据驱动、持续决策的。
也就是说,“数据驱动”和“持续决策”这两点可以看作是数据科学家团队的主要价值体现。那么,怎么衡量数据科学家团队这个问题,也就变成了如何来衡量这些团队在围绕这两方面的工作中做得怎么样。
注意,我在这里其实并没有明确提及数据科学团队和人工智能团队对产品直接带来的效果,比如点击率升高了多少,用户存留增加了多少,什么产品又上线了等等。主要是出于以下两点考虑。
第一,每一个公司、每一个组织甚至是每一个产品在这些价值上都有不一样的需求,没有一个统一的模式。
第二,如果数据科学团队为组织建立起了数据驱动的持续决策过程,那么很多产品级别的性能提高或者核心功能的实现就会成为顺理成章、水到渠成的结果。相反,如果仅仅强调某一个产品性能的提升或者某一个单点技术的突破,很可能无法真正建立有效的人工智能团队,并且团队的“战斗力”也无法真正得到最大程度的发挥。
数据科学家团队的评价误区
刚才我们从一个比较大的概念上做了一个讨论,看数据科学家团队的价值应该如何来评价。但在实际操作中,往往存在两种比较明显的误区。
第一种误区是“唯技术论”。那就是觉得人工智能团队能够快速帮助公司、组织甚至是项目很快打开突破口,希望人工智能技术能够给公司业务带来突破性的发展,从而对人工智能团队有过高的预期。
在这种思路的指导下,在前期往往可能有一个比较大的热情,能够招聘到不少的人才或者能够拉起团队开始一些不错的项目。但很快,由于急功近利的心态和不切合实际的需求,常常又让人工智能团队身陷绝境。而这个时候,最容易产生的一种情绪是走另外一个极端,那就是从“唯技术论”到“技术无用论”。在这样的背景下,产品遇到的任何困难、任何失败都有可能归因到人工智能团队上。
第二种误区是对人工智能团队或者数据科学家团队心存怀疑,本质上觉得这些团队都无法真正能够帮助到团队。因此从一开始就不信任这些团队,蹑手蹑脚,在政策和发展上限制这些团队。由于这种不信任,使得人工智能团队不能真正发挥作用,因此催生了进一步的不信任,恶性循环,最终得出这样的结论,“人工智能是花瓶,没有用”。
这两种误区的核心其实是一种行为,那就是忽略了人工智能团队需要一个“生态环境”。什么生态环境?比如产品部门、数据部门以及其他的工程部门,必须协调发展。
绝大多数数据科学团队和人工智能团队都需要依赖一个比较强有力的数据部门的支持。同时,产品上,如果人工智能的算法或者模型并不能和产品有机结合,那无论如何,都是无法真正帮助产品,为产品赋能的。我们之前也提过,其实在很多时候,人工智能都是锦上添花的部分,而产品的整体呈现才是最为重要、做需要认真思考的问题。
数据科学家的评定
有了前面这些思路作为基础,我们现在来看一看数据科学家的评定的问题。
第一,对于数据科学家或者人工智能工程师来说,我们需要看他们是否对于建立、完善、和推动“数据驱动的持续决策”这一长期任务有不间断的贡献。
具体来说,那就是数据科学家或者工程师是不是在帮助建立和推动数据驱动的链路,是不是在思考如何能够更快、更好地解决数据的问题。这里的数据包括获取数据、整理数据、分析数据以及利用数据的整个流程。我们的数据科学家或者人工智能工程师应该持续在这几个方面有所贡献。
你可能会有疑问,这不是数据工程师的责任吗?没错,这确实是数据工程师的职责。但是,如果我们的科学家并不清楚数据的情况,并不了解如何进一步推动数据链路的进步,那产品线将来肯定会出问题。
同理,数据科学家也需要在帮助“持续决策”上不断做出贡献。这里主要指的是实验的平台,以及围绕着实验平台进行决策的工具,比如图表,比如更加复杂的假设检验工具,比如因果推断的工具等等。数据科学家和人工智能工程师必须要具备这样的敏感度。
第二,那就是考察数据科学家和人工智能工程师本身的职责和专长,针对某一个产品能否提出切实可行的机器学习解决方案,能否和产品部门以及其他工程部门一起,让解决方案落地。
这一点检验的就是解决方案的落地能力。当然,这里不仅仅依赖于解决方案本身,还依赖于其他的因素,比如数据,比如产品。这里面有一部分是第一点的内容,主要是评定数据科学家或者人工智能工程师对于跨部门合作以及共同构建一个人工智能生态系统的能力。这一条的重点是评定在一个较小范围内落地解决方案的能力。
第三,那就是数据科学家和人工智能工程师必须能够不断提高自我修养,能够持续学习不断进步。
这一点,可能是在所有的工程团队和产品团队里面都比较突出的。虽然所有的团队都需要不断进步,然而人工智能这个领域实在是变化太快。因此,在这个方面,人工智能相关的工作都必须要有比较不一样的评价标准。
在一些企业中,数据科学家的持续学习主要体现为参加会议、发表论文、参与学术讨论、发表开源软件等形式。如果在一些初创公司或者是暂时没有这些能力的组织中,我们也要思考如何来评价员工是不是在积极地持续学习。
小结
今天我们分析了组建一个数据科学家或人工智能团队后,你怎样来认识这个团队的价值,怎么来评价员工的工作。
简单地做个总结:第一,我们讲了数据科学家团队对于推动“数据驱动持续决策”这一目标的作用;第二,我们梳理了面对人工智能团队上可能存在的两个误区;第三,我们简单聊了聊如何在大的方面来评定数据科学家的工作。希望这些内容能给你带来一些启发和借鉴。
最后给你留一个思考题需不需要把人工智能团队的工作和企业的KPI挂钩如果需要该怎么挂如果你觉得不需要又是什么理由呢

Some files were not shown because too many files have changed in this diff Show More