From 201a5889b195510bfcb1046948e778d500cd9924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B9=BE?= Date: Tue, 15 Oct 2024 23:13:09 +0800 Subject: [PATCH] first commit --- crawl.php | 4 +- ...需求设计,带你实现高效工作.md | 84 +++ ...:编写你的第一个Go语言程序.md | 211 ++++++ ...必须掌握的数据类型有哪些?.md | 391 ++++++++++ ...for、switch逻辑语句的那些事儿.md | 277 +++++++ ...何正确使用array、slice和map?.md | 423 +++++++++++ ...函数和方法到底有什么不同?.md | 444 +++++++++++ ...体与接口都实现了哪些功能?.md | 433 +++++++++++ ...or、deferred、panic等处理错误?.md | 341 +++++++++ ...Goroutines和Channels的声明与使用.md | 304 ++++++++ ...ync包让你对并发控制得心应手.md | 362 +++++++++ ...须掌握的多线程并发控制神器.md | 380 ++++++++++ ...言中即学即用的高效并发模式.md | 487 ++++++++++++ ...在什么情况下应该使用指针?.md | 221 ++++++ ...值、引用及指针之间的区别?.md | 320 ++++++++ ...是make?什么情况下该用谁?.md | 316 ++++++++ ...符串和结构体之间如何转换?.md | 697 ++++++++++++++++++ ...型安全:让你既爱又恨的unsafe.md | 248 +++++++ ...ader:slice如何高效处理数据?.md | 364 +++++++++ ...语言如何通过测试保证质量?.md | 434 +++++++++++ ...言如何进行代码检查和优化?.md | 430 +++++++++++ ...理为什么能够提升研发效能?.md | 258 +++++++ ...Go语言如何玩转RESTfulAPI服务?.md | 454 ++++++++++++ ...如何通过RPC实现跨平台服务?.md | 587 +++++++++++++++ .../23结束语你的Go语言成长之路.md | 63 ++ ...篇词用好A_B测试,你得这么学.md | 137 ++++ ...):系统掌握指标的统计属性.md | 199 +++++ ...深入理解A_B测试中的假设检验.md | 235 ++++++ ...标这么多,到底如何来选择?.md | 224 ++++++ ...什么样的实验单位是合适的?.md | 164 +++++ ...样本量:样本量越多越好吗?.md | 232 ++++++ ...得到的测试结果真的靠谱吗?.md | 224 ++++++ ...,搭建一个规范的A_B测试框架.md | 158 ++++ ...试结果不显著,要怎么改善?.md | 202 +++++ ...):多重检验问题和学习效应.md | 171 +++++ ...论和实验组_对照组的独立性.md | 161 ++++ ...什么情况下不适合做A_B测试?.md | 132 ++++ ...:A_B测试面试必知必会(上).md | 177 +++++ ...:A_B测试面试必知必会(下).md | 139 ++++ ...,教你制作一个样本量计算器.md | 269 +++++++ ...改变决策模式,推动业务增长.md | 198 +++++ ...的A_B测试流程,是什么样的?.md | 47 ++ ...语实践是检验真理的唯一标准.md | 69 ++ ...86Twitter的广告点击率预估模型.md | 63 ++ ...里巴巴的广告点击率预估模型.md | 69 ++ ...是基于第二价位的广告竞拍?.md | 72 ++ ...089广告的竞价策略是怎样的?.md | 71 ++ ...090如何优化广告的竞价策略?.md | 73 ++ .../091如何控制广告预算?.md | 59 ++ ...092如何设置广告竞价的底价?.md | 69 ++ ...聊程序化直接购买和广告期货.md | 65 ++ ...型:如何来衡量广告的有效性.md | 65 ++ ...选择受众?如何扩展受众群?.md | 67 ++ ...96复盘4广告系统核心技术模块.md | 65 ++ ...器学习技术来检测广告欺诈?.md | 83 +++ .../097LDA模型的前世今生.md | 79 ++ .../098LDA变种模型知多少.md | 69 ++ ...规模数据,如何优化LDA算法?.md | 69 ++ ...本分析模型之一:隐语义分析.md | 57 ++ ...析模型之二:概率隐语义分析.md | 51 ++ ...础文本分析模型之三:EM算法.md | 59 ++ .../103为什么需要Word2Vec算法?.md | 57 ++ ...4Word2Vec算法有哪些扩展模型?.md | 63 ++ .../105Word2Vec算法有哪些应用?.md | 45 ++ ...的深度学习利器:RNN基础架构.md | 65 ++ ...于门机制的RNN架构:LSTM与GRU.md | 59 ++ ...语言处理中有哪些应用场景?.md | 53 ++ ...109对话系统之经典的对话模型.md | 61 ++ ...型对话系统有哪些技术要点?.md | 63 ++ ...机器人有哪些核心技术要点?.md | 61 ++ .../112什么是文档情感分类?.md | 59 ++ ...何来提取情感实体和方面呢?.md | 55 ++ ...处理及文本处理核心技术模块.md | 69 ++ ...析中如何做意见总结和搜索?.md | 59 ++ .../115什么是计算机视觉?.md | 65 ++ ...机视觉任务的基础模型和操作.md | 63 ++ ...视觉中的特征提取难在哪里?.md | 65 ++ ...术(一):深度神经网络入门.md | 71 ++ ...(二):基本的深度学习模型.md | 65 ++ ...(三):深度学习模型的优化.md | 69 ++ ...深度学习模型(一):AlexNet.md | 69 ++ ...习模型(二):VGG&GoogleNet.md | 0 ...的深度学习模型(三):ResNet.md | 63 ++ ...(一):图像物体识别和分割.md | 62 ++ ...觉高级话题(二):视觉问答.md | 67 ++ ...盘5计算机视觉核心技术模块.md | 53 ++ ...高级话题(三):产生式模型.md | 78 ++ ...据科学家基础能力之概率统计.md | 73 ++ ...据科学家基础能力之机器学习.md | 109 +++ ...129数据科学家基础能力之系统.md | 71 ++ ...据科学家高阶能力之分析产品.md | 89 +++ ...据科学家高阶能力之评估产品.md | 71 ++ ...能力之如何系统提升产品性能.md | 77 ++ ...:当数据科学家遇见产品团队.md | 66 ++ ...科学家应聘要具备哪些能力?.md | 99 +++ ...:聊聊数据科学家的职场规划.md | 73 ++ ...如何组建一个数据科学团队?.md | 10 + ...科学团队养成:电话面试指南.md | 97 +++ ...学团队养成:Onsite面试面面观.md | 77 ++ ...家,如何衡量他们的工作呢?.md | 75 ++ ...6年,数据科学家如何培养?.md | 55 ++ ...:水平还是垂直,这是个问题.md | 59 ++ ...学家必备套路之一:搜索套路.md | 83 +++ ...学家必备套路之二:推荐套路.md | 75 ++ ...学家必备套路之三:广告套路.md | 73 ++ ...何做好人工智能项目的管理?.md | 61 ++ ...学团队必备的工程流程三部曲.md | 71 ++ ...学团队怎么选择产品和项目?.md | 69 ++ .../148曾经辉煌的雅虎研究院.md | 81 ++ ...究院:工业界研究机构的楷模.md | 63 ++ ...数据科学团队是怎么养成的?.md | 95 +++ ...聊谷歌特立独行的混合型研究.md | 68 ++ .../151精读AlphaGoZero论文.md | 65 ++ .../1522017人工智能技术发展盘点.md | 61 ++ ...习国际顶级学术会议的内容?.md | 65 ++ ...如何快速找到学习的切入点?.md | 67 ++ ...术选择,该从哪里获得灵感?.md | 53 ++ ...人都关心的人工智能热点话题.md | 149 ++++ ...近在咫尺,走进人工智能研究.md | 65 ++ ...漫道真如铁,而今迈步从头越.md | 33 + ...导读如何打造高质量的应用?.md | 93 +++ ...虑的移动开发者该如何破局?.md | 45 ++ ...上):关于“崩溃”那些事儿.md | 163 ++++ ...崩溃了,你应该如何去分析?.md | 241 ++++++ ...:4GB内存时代,再谈内存优化.md | 196 +++++ ...化这件事,应该从哪里着手?.md | 194 +++++ ...):你要掌握的卡顿分析方法.md | 258 +++++++ ...(下):如何监控应用卡顿?.md | 182 +++++ ...顿优化:卡顿现场与卡顿分析.md | 239 ++++++ ...:从启动过程看启动速度优化.md | 219 ++++++ ...):优化启动速度的进阶方法.md | 221 ++++++ ...开发工程师必备的I_O优化知识.md | 248 +++++++ ...同I_O方式的使用场景是什么?.md | 200 +++++ ...下):如何监控线上I_O操作?.md | 273 +++++++ ...常见的数据存储方法有哪些?.md | 200 +++++ ...(中):如何优化数据存储?.md | 259 +++++++ ...):数据库SQLite的使用和优化.md | 313 ++++++++ ...发工程师必备的网络优化知识.md | 197 +++++ ...多变的移动网络该如何优化?.md | 271 +++++++ ...:大数据下网络该如何监控?.md | 244 ++++++ ...从电量优化的演进看耗电分析.md | 204 +++++ ...:耗电的优化方法与线上监控.md | 245 ++++++ 142 files changed, 21536 insertions(+), 2 deletions(-) create mode 100644 专栏/22讲通关Go语言-完/00开篇词Go为开发者的需求设计,带你实现高效工作.md create mode 100644 专栏/22讲通关Go语言-完/01基础入门:编写你的第一个Go语言程序.md create mode 100644 专栏/22讲通关Go语言-完/02数据类型:你必须掌握的数据类型有哪些?.md create mode 100644 专栏/22讲通关Go语言-完/03控制结构:if、for、switch逻辑语句的那些事儿.md create mode 100644 专栏/22讲通关Go语言-完/04集合类型:如何正确使用array、slice和map?.md create mode 100644 专栏/22讲通关Go语言-完/05函数和方法:Go语言中的函数和方法到底有什么不同?.md create mode 100644 专栏/22讲通关Go语言-完/06struct和interface:结构体与接口都实现了哪些功能?.md create mode 100644 专栏/22讲通关Go语言-完/07错误处理:如何通过error、deferred、panic等处理错误?.md create mode 100644 专栏/22讲通关Go语言-完/08并发基础:Goroutines和Channels的声明与使用.md create mode 100644 专栏/22讲通关Go语言-完/09同步原语:sync包让你对并发控制得心应手.md create mode 100644 专栏/22讲通关Go语言-完/10Context:你必须掌握的多线程并发控制神器.md create mode 100644 专栏/22讲通关Go语言-完/11并发模式:Go语言中即学即用的高效并发模式.md create mode 100644 专栏/22讲通关Go语言-完/12指针详解:在什么情况下应该使用指针?.md create mode 100644 专栏/22讲通关Go语言-完/13参数传递:值、引用及指针之间的区别?.md create mode 100644 专栏/22讲通关Go语言-完/14内存分配:new还是make?什么情况下该用谁?.md create mode 100644 专栏/22讲通关Go语言-完/15运行时反射:字符串和结构体之间如何转换?.md create mode 100644 专栏/22讲通关Go语言-完/16非类型安全:让你既爱又恨的unsafe.md create mode 100644 专栏/22讲通关Go语言-完/17SliceHeader:slice如何高效处理数据?.md create mode 100644 专栏/22讲通关Go语言-完/18质量保证:Go语言如何通过测试保证质量?.md create mode 100644 专栏/22讲通关Go语言-完/19性能优化:Go语言如何进行代码检查和优化?.md create mode 100644 专栏/22讲通关Go语言-完/20协作开发:模块化管理为什么能够提升研发效能?.md create mode 100644 专栏/22讲通关Go语言-完/21网络编程:Go语言如何玩转RESTfulAPI服务?.md create mode 100644 专栏/22讲通关Go语言-完/22网络编程:Go语言如何通过RPC实现跨平台服务?.md create mode 100644 专栏/22讲通关Go语言-完/23结束语你的Go语言成长之路.md create mode 100644 专栏/AB测试从0到1/00开篇词用好A_B测试,你得这么学.md create mode 100644 专栏/AB测试从0到1/01统计基础(上):系统掌握指标的统计属性.md create mode 100644 专栏/AB测试从0到1/02统计基础(下):深入理解A_B测试中的假设检验.md create mode 100644 专栏/AB测试从0到1/04确定指标:指标这么多,到底如何来选择?.md create mode 100644 专栏/AB测试从0到1/05选取实验单位:什么样的实验单位是合适的?.md create mode 100644 专栏/AB测试从0到1/06选择实验样本量:样本量越多越好吗?.md create mode 100644 专栏/AB测试从0到1/07分析测试结果:你得到的测试结果真的靠谱吗?.md create mode 100644 专栏/AB测试从0到1/08案例串讲:从0开始,搭建一个规范的A_B测试框架.md create mode 100644 专栏/AB测试从0到1/09测试结果不显著,要怎么改善?.md create mode 100644 专栏/AB测试从0到1/10常见误区及解决方法(上):多重检验问题和学习效应.md create mode 100644 专栏/AB测试从0到1/11常见误区及解决方法(下):辛普森悖论和实验组_对照组的独立性.md create mode 100644 专栏/AB测试从0到1/12什么情况下不适合做A_B测试?.md create mode 100644 专栏/AB测试从0到1/13融会贯通:A_B测试面试必知必会(上).md create mode 100644 专栏/AB测试从0到1/14举一反三:A_B测试面试必知必会(下).md create mode 100644 专栏/AB测试从0到1/15用R_Shiny,教你制作一个样本量计算器.md create mode 100644 专栏/AB测试从0到1/加餐试验意识改变决策模式,推动业务增长.md create mode 100644 专栏/AB测试从0到1/导读科学、规范的A_B测试流程,是什么样的?.md create mode 100644 专栏/AB测试从0到1/结束语实践是检验真理的唯一标准.md create mode 100644 专栏/AI技术内参/086Twitter的广告点击率预估模型.md create mode 100644 专栏/AI技术内参/087阿里巴巴的广告点击率预估模型.md create mode 100644 专栏/AI技术内参/088什么是基于第二价位的广告竞拍?.md create mode 100644 专栏/AI技术内参/089广告的竞价策略是怎样的?.md create mode 100644 专栏/AI技术内参/090如何优化广告的竞价策略?.md create mode 100644 专栏/AI技术内参/091如何控制广告预算?.md create mode 100644 专栏/AI技术内参/092如何设置广告竞价的底价?.md create mode 100644 专栏/AI技术内参/093聊一聊程序化直接购买和广告期货.md create mode 100644 专栏/AI技术内参/094归因模型:如何来衡量广告的有效性.md create mode 100644 专栏/AI技术内参/095广告投放如何选择受众?如何扩展受众群?.md create mode 100644 专栏/AI技术内参/096复盘4广告系统核心技术模块.md create mode 100644 专栏/AI技术内参/096如何利用机器学习技术来检测广告欺诈?.md create mode 100644 专栏/AI技术内参/097LDA模型的前世今生.md create mode 100644 专栏/AI技术内参/098LDA变种模型知多少.md create mode 100644 专栏/AI技术内参/099针对大规模数据,如何优化LDA算法?.md create mode 100644 专栏/AI技术内参/100基础文本分析模型之一:隐语义分析.md create mode 100644 专栏/AI技术内参/101基础文本分析模型之二:概率隐语义分析.md create mode 100644 专栏/AI技术内参/102基础文本分析模型之三:EM算法.md create mode 100644 专栏/AI技术内参/103为什么需要Word2Vec算法?.md create mode 100644 专栏/AI技术内参/104Word2Vec算法有哪些扩展模型?.md create mode 100644 专栏/AI技术内参/105Word2Vec算法有哪些应用?.md create mode 100644 专栏/AI技术内参/106序列建模的深度学习利器:RNN基础架构.md create mode 100644 专栏/AI技术内参/107基于门机制的RNN架构:LSTM与GRU.md create mode 100644 专栏/AI技术内参/108RNN在自然语言处理中有哪些应用场景?.md create mode 100644 专栏/AI技术内参/109对话系统之经典的对话模型.md create mode 100644 专栏/AI技术内参/110任务型对话系统有哪些技术要点?.md create mode 100644 专栏/AI技术内参/111聊天机器人有哪些核心技术要点?.md create mode 100644 专栏/AI技术内参/112什么是文档情感分类?.md create mode 100644 专栏/AI技术内参/113如何来提取情感实体和方面呢?.md create mode 100644 专栏/AI技术内参/114复盘3自然语言处理及文本处理核心技术模块.md create mode 100644 专栏/AI技术内参/114文本情感分析中如何做意见总结和搜索?.md create mode 100644 专栏/AI技术内参/115什么是计算机视觉?.md create mode 100644 专栏/AI技术内参/116掌握计算机视觉任务的基础模型和操作.md create mode 100644 专栏/AI技术内参/117计算机视觉中的特征提取难在哪里?.md create mode 100644 专栏/AI技术内参/118基于深度学习的计算机视觉技术(一):深度神经网络入门.md create mode 100644 专栏/AI技术内参/119基于深度学习的计算机视觉技术(二):基本的深度学习模型.md create mode 100644 专栏/AI技术内参/120基于深度学习的计算机视觉技术(三):深度学习模型的优化.md create mode 100644 专栏/AI技术内参/121计算机视觉领域的深度学习模型(一):AlexNet.md create mode 100644 专栏/AI技术内参/122计算机视觉领域的深度学习模型(二):VGG&GoogleNet.md create mode 100644 专栏/AI技术内参/123计算机视觉领域的深度学习模型(三):ResNet.md create mode 100644 专栏/AI技术内参/124计算机视觉高级话题(一):图像物体识别和分割.md create mode 100644 专栏/AI技术内参/125计算机视觉高级话题(二):视觉问答.md create mode 100644 专栏/AI技术内参/126复盘5计算机视觉核心技术模块.md create mode 100644 专栏/AI技术内参/126计算机视觉高级话题(三):产生式模型.md create mode 100644 专栏/AI技术内参/127数据科学家基础能力之概率统计.md create mode 100644 专栏/AI技术内参/128数据科学家基础能力之机器学习.md create mode 100644 专栏/AI技术内参/129数据科学家基础能力之系统.md create mode 100644 专栏/AI技术内参/130数据科学家高阶能力之分析产品.md create mode 100644 专栏/AI技术内参/131数据科学家高阶能力之评估产品.md create mode 100644 专栏/AI技术内参/132数据科学家高阶能力之如何系统提升产品性能.md create mode 100644 专栏/AI技术内参/133职场话题:当数据科学家遇见产品团队.md create mode 100644 专栏/AI技术内参/134职场话题:数据科学家应聘要具备哪些能力?.md create mode 100644 专栏/AI技术内参/135职场话题:聊聊数据科学家的职场规划.md create mode 100644 专栏/AI技术内参/136如何组建一个数据科学团队?.md create mode 100644 专栏/AI技术内参/137数据科学团队养成:电话面试指南.md create mode 100644 专栏/AI技术内参/138数据科学团队养成:Onsite面试面面观.md create mode 100644 专栏/AI技术内参/139成为香饽饽的数据科学家,如何衡量他们的工作呢?.md create mode 100644 专栏/AI技术内参/140人工智能领域知识体系更新周期只有5~6年,数据科学家如何培养?.md create mode 100644 专栏/AI技术内参/141数据科学家团队组织架构:水平还是垂直,这是个问题.md create mode 100644 专栏/AI技术内参/142数据科学家必备套路之一:搜索套路.md create mode 100644 专栏/AI技术内参/143数据科学家必备套路之二:推荐套路.md create mode 100644 专栏/AI技术内参/144数据科学家必备套路之三:广告套路.md create mode 100644 专栏/AI技术内参/145如何做好人工智能项目的管理?.md create mode 100644 专栏/AI技术内参/146数据科学团队必备的工程流程三部曲.md create mode 100644 专栏/AI技术内参/147数据科学团队怎么选择产品和项目?.md create mode 100644 专栏/AI技术内参/148曾经辉煌的雅虎研究院.md create mode 100644 专栏/AI技术内参/149微软研究院:工业界研究机构的楷模.md create mode 100644 专栏/AI技术内参/150复盘6数据科学家与数据科学团队是怎么养成的?.md create mode 100644 专栏/AI技术内参/150聊一聊谷歌特立独行的混合型研究.md create mode 100644 专栏/AI技术内参/151精读AlphaGoZero论文.md create mode 100644 专栏/AI技术内参/1522017人工智能技术发展盘点.md create mode 100644 专栏/AI技术内参/153如何快速学习国际顶级学术会议的内容?.md create mode 100644 专栏/AI技术内参/154在人工智能领域,如何快速找到学习的切入点?.md create mode 100644 专栏/AI技术内参/155人工智能技术选择,该从哪里获得灵感?.md create mode 100644 专栏/AI技术内参/156内参特刊和你聊聊每个人都关心的人工智能热点话题.md create mode 100644 专栏/AI技术内参/156近在咫尺,走进人工智能研究.md create mode 100644 专栏/AI技术内参/结束语雄关漫道真如铁,而今迈步从头越.md create mode 100644 专栏/Android开发高手课/00导读如何打造高质量的应用?.md create mode 100644 专栏/Android开发高手课/00开篇词焦虑的移动开发者该如何破局?.md create mode 100644 专栏/Android开发高手课/01崩溃优化(上):关于“崩溃”那些事儿.md create mode 100644 专栏/Android开发高手课/02崩溃优化(下):应用崩溃了,你应该如何去分析?.md create mode 100644 专栏/Android开发高手课/03内存优化(上):4GB内存时代,再谈内存优化.md create mode 100644 专栏/Android开发高手课/04内存优化(下):内存优化这件事,应该从哪里着手?.md create mode 100644 专栏/Android开发高手课/05卡顿优化(上):你要掌握的卡顿分析方法.md create mode 100644 专栏/Android开发高手课/06卡顿优化(下):如何监控应用卡顿?.md create mode 100644 专栏/Android开发高手课/06补充篇卡顿优化:卡顿现场与卡顿分析.md create mode 100644 专栏/Android开发高手课/07启动优化(上):从启动过程看启动速度优化.md create mode 100644 专栏/Android开发高手课/08启动优化(下):优化启动速度的进阶方法.md create mode 100644 专栏/Android开发高手课/09I_O优化(上):开发工程师必备的I_O优化知识.md create mode 100644 专栏/Android开发高手课/10I_O优化(中):不同I_O方式的使用场景是什么?.md create mode 100644 专栏/Android开发高手课/11I_O优化(下):如何监控线上I_O操作?.md create mode 100644 专栏/Android开发高手课/12存储优化(上):常见的数据存储方法有哪些?.md create mode 100644 专栏/Android开发高手课/13存储优化(中):如何优化数据存储?.md create mode 100644 专栏/Android开发高手课/14存储优化(下):数据库SQLite的使用和优化.md create mode 100644 专栏/Android开发高手课/15网络优化(上):移动开发工程师必备的网络优化知识.md create mode 100644 专栏/Android开发高手课/16网络优化(中):复杂多变的移动网络该如何优化?.md create mode 100644 专栏/Android开发高手课/17网络优化(下):大数据下网络该如何监控?.md create mode 100644 专栏/Android开发高手课/18耗电优化(上):从电量优化的演进看耗电分析.md create mode 100644 专栏/Android开发高手课/19耗电优化(下):耗电的优化方法与线上监控.md diff --git a/crawl.php b/crawl.php index e6df4d6..71556f0 100755 --- a/crawl.php +++ b/crawl.php @@ -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>/', $response, $matches); @@ -74,7 +74,7 @@ foreach ($lines as $line) { file_put_contents($fileName, $text); - sleep(10); + sleep(5); // preg_match_all('/

([^<]*)<\/p>/', $fileContents, $fileMatches); } diff --git a/专栏/22讲通关Go语言-完/00开篇词Go为开发者的需求设计,带你实现高效工作.md b/专栏/22讲通关Go语言-完/00开篇词Go为开发者的需求设计,带你实现高效工作.md new file mode 100644 index 0000000..94a5cc7 --- /dev/null +++ b/专栏/22讲通关Go语言-完/00开篇词Go为开发者的需求设计,带你实现高效工作.md @@ -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 语言学习之旅吧,Let’s Go! + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/01基础入门:编写你的第一个Go语言程序.md b/专栏/22讲通关Go语言-完/01基础入门:编写你的第一个Go语言程序.md new file mode 100644 index 0000000..0eedb9e --- /dev/null +++ b/专栏/22讲通关Go语言-完/01基础入门:编写你的第一个Go语言程序.md @@ -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 世界”的代码,打印出自己的名字。 + + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/02数据类型:你必须掌握的数据类型有哪些?.md b/专栏/22讲通关Go语言-完/02数据类型:你必须掌握的数据类型有哪些?.md new file mode 100644 index 0000000..c298ed2 --- /dev/null +++ b/专栏/22讲通关Go语言-完/02数据类型:你必须掌握的数据类型有哪些?.md @@ -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 类型转为 string,Atoi 函数则用来把 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 包里有现成的函数哦。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/03控制结构:if、for、switch逻辑语句的那些事儿.md b/专栏/22讲通关Go语言-完/03控制结构:if、for、switch逻辑语句的那些事儿.md new file mode 100644 index 0000000..6d24ecb --- /dev/null +++ b/专栏/22讲通关Go语言-完/03控制结构:if、for、switch逻辑语句的那些事儿.md @@ -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("510 { + + fmt.Println("i>10") + + } else if i>5 && i<=10 { + + fmt.Println("510 条件语句之前,通过分号 ; 分隔被初始化的 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("51 { + +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 遍历集合,记得来听课! + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/04集合类型:如何正确使用array、slice和map?.md b/专栏/22讲通关Go语言-完/04集合类型:如何正确使用array、slice和map?.md new file mode 100644 index 0000000..af6f7c4 --- /dev/null +++ b/专栏/22讲通关Go语言-完/04集合类型:如何正确使用array、slice和map?.md @@ -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] 的值为 c,slice[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 的切片,长度是 4,make 函数还可以传入一个容量参数: + +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 对应 Key,V 对应 Value。map 中所有的 Key 必须具有相同的类型,Value 也同样,但 Key 和 Value 的类型可以不同。此外,Key 的类型必须支持 == 比较运算符,这样才可以判断它是否存在,并保证 Key 的唯一。 + +Map 声明初始化 + +创建一个 map 可以通过内置的 make 函数,如下面的代码所示: + +nameAgeMap:=make(map[string]int) + + +它的 Key 类型为 string,Value 类型为 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 是返回的 Value,ok 用来标记该 Key 是否存在,如果存在则打印 age。 + +如果要删除 map 中的键值对,使用内置的 delete 函数即可,比如要删除 nameAgeMap 中 Key 为飞雪无情的键值对。我们用下面的代码进行演示: + +delete(nameAgeMap,"飞雪无情") + + +delete 有两个参数:第一个参数是 map,第二个参数是要删除键值对的 Key。 + +遍历 Map + +map 是一个键值对集合,它同样可以被遍历,在 Go 语言中,map 的遍历使用 for range 循环。 + +对于 map,for 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 个 unicode(utf8)字符,和我们看到的字符的个数一致。 + +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 的类型是整型,并且集合中的元素比较少,应该尽量选择切片,因为效率更高。在实际的项目开发中,数组并不常用,尤其是在函数间作为参数传递的时候,用得最多的是切片,它更灵活,并且内存占用少。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/05函数和方法:Go语言中的函数和方法到底有什么不同?.md b/专栏/22讲通关Go语言-完/05函数和方法:Go语言中的函数和方法到底有什么不同?.md new file mode 100644 index 0000000..db1ba8d --- /dev/null +++ b/专栏/22讲通关Go语言-完/05函数和方法:Go语言中的函数和方法到底有什么不同?.md @@ -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 的返回值了,也就是说,示例中只有一个 return,return 后没有要返回的值。 + +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 率。 + +本节课给你留的思考题是:方法是否可以作为表达式赋值给一个变量?如果可以的话,如何通过这个变量调用方法? + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/06struct和interface:结构体与接口都实现了哪些功能?.md b/专栏/22讲通关Go语言-完/06struct和interface:结构体与接口都实现了哪些功能?.md new file mode 100644 index 0000000..08f2dd7 --- /dev/null +++ b/专栏/22讲通关Go语言-完/06struct和interface:结构体与接口都实现了哪些功能?.md @@ -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 则成功,否则失败。 + +总结 + +这节课虽然只讲了结构体和接口,但是所涉及的知识点很多,整节课比较长,希望你可以耐心地学完。 + +结构体是对现实世界的描述,接口是对某一类行为的规范和抽象。通过它们,我们可以实现代码的抽象和复用,同时可以面向接口编程,把具体实现细节隐藏起来,让写出来的代码更灵活,适应能力也更强。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/07错误处理:如何通过error、deferred、panic等处理错误?.md b/专栏/22讲通关Go语言-完/07错误处理:如何通过error、deferred、panic等处理错误?.md new file mode 100644 index 0000000..87c1722 --- /dev/null +++ b/专栏/22讲通关Go语言-完/07错误处理:如何通过error、deferred、panic等处理错误?.md @@ -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 值为 1,errorMsg 值为 “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 字段用于存放已存在的 error,string 类型的 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 error,target 也包含在这个嵌套 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。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/08并发基础:Goroutines和Channels的声明与使用.md b/专栏/22讲通关Go语言-完/08并发基础:Goroutines和Channels的声明与使用.md new file mode 100644 index 0000000..a7b9ddc --- /dev/null +++ b/专栏/22讲通关Go语言-完/08并发基础:Goroutines和Channels的声明与使用.md @@ -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,它是并发安全的,性能也不错。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/09同步原语:sync包让你对并发控制得心应手.md b/专栏/22讲通关Go语言-完/09同步原语:sync包让你对并发控制得心应手.md new file mode 100644 index 0000000..5b5559f --- /dev/null +++ b/专栏/22讲通关Go语言-完/09同步原语:sync包让你对并发控制得心应手.md @@ -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 个协程,每个协程下载文件的 1⁄10 大小,只有 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。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/10Context:你必须掌握的多线程并发控制神器.md b/专栏/22讲通关Go语言-完/10Context:你必须掌握的多线程并发控制神器.md new file mode 100644 index 0000000..cef11eb --- /dev/null +++ b/专栏/22讲通关Go语言-完/10Context:你必须掌握的多线程并发控制神器.md @@ -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 进行控制,比如下载一个文件超时退出的需求。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/11并发模式:Go语言中即学即用的高效并发模式.md b/专栏/22讲通关Go语言-完/11并发模式:Go语言中即学即用的高效并发模式.md new file mode 100644 index 0000000..728992a --- /dev/null +++ b/专栏/22讲通关Go语言-完/11并发模式:Go语言中即学即用的高效并发模式.md @@ -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 模式。 + +总结 + +并发模式和设计模式很相似,都是对现实场景的抽象封装,以便提供一个统一的解决方案。但和设计模式不同的是,并发模式更专注于异步和并发。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/12指针详解:在什么情况下应该使用指针?.md b/专栏/22讲通关Go语言-完/12指针详解:在什么情况下应该使用指针?.md new file mode 100644 index 0000000..725cac7 --- /dev/null +++ b/专栏/22讲通关Go语言-完/12指针详解:在什么情况下应该使用指针?.md @@ -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 类型的指针类型是 *int,float64 类型的指针类型是 *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 语言的开发中我们要尽可能地使用值类型,而不是指针类型,因为值类型可以使你的开发变得更简单,并且也是并发安全的。如果你想使用指针类型,就要参考我上面讲到的使用指针的条件,看是否满足,要在满足和必须的情况下才使用指针。 + +这节课到这里就要结束了,在这节课的最后同样给你留个思考题:指向接口的指针是否实现了该接口?为什么?思考后可以自己写代码验证下。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/13参数传递:值、引用及指针之间的区别?.md b/专栏/22讲通关Go语言-完/13参数传递:值、引用及指针之间的区别?.md new file mode 100644 index 0000000..59fb369 --- /dev/null +++ b/专栏/22讲通关Go语言-完/13参数传递:值、引用及指针之间的区别?.md @@ -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 作为参数传递给函数 printString,Go 语言的编译器会提示你如下错误信息: + +./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?什么情况下该用谁?”记得来听课! + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/14内存分配:new还是make?什么情况下该用谁?.md b/专栏/22讲通关Go语言-完/14内存分配:new还是make?什么情况下该用谁?.md new file mode 100644 index 0000000..6ad852f --- /dev/null +++ b/专栏/22讲通关Go语言-完/14内存分配:new还是make?什么情况下该用谁?.md @@ -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,并且使用不同的长度和容量作为参数,看看它们的效果。 + +下节课我将介绍“运行时反射:字符串和结构体之间如何转换?”记得来听课! + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/15运行时反射:字符串和结构体之间如何转换?.md b/专栏/22讲通关Go语言-完/15运行时反射:字符串和结构体之间如何转换?.md new file mode 100644 index 0000000..1403e77 --- /dev/null +++ b/专栏/22讲通关Go语言-完/15运行时反射:字符串和结构体之间如何转换?.md @@ -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/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 引入自己的项目吧,相信你的付出会有回报的。 + +下一讲我将介绍“协作开发:模块化管理为什么能够提升研发效能”,记得来听课! + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/20协作开发:模块化管理为什么能够提升研发效能?.md b/专栏/22讲通关Go语言-完/20协作开发:模块化管理为什么能够提升研发效能?.md new file mode 100644 index 0000000..70c4c96 --- /dev/null +++ b/专栏/22讲通关Go语言-完/20协作开发:模块化管理为什么能够提升研发效能?.md @@ -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 语言提供的模块、包这些能力,就可以很好地让我们使用现有的轮子,在多人协作开发中,更好地提高工作效率。 + +最后,为你留个作业:基于模块化拆分你所做的项目,提取一些公共的模块,以供更多项目使用。相信这样你们的开发效率会大大提升的。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/21网络编程:Go语言如何玩转RESTfulAPI服务?.md b/专栏/22讲通关Go语言-完/21网络编程:Go语言如何玩转RESTfulAPI服务?.md new file mode 100644 index 0000000..285f6dd --- /dev/null +++ b/专栏/22讲通关Go语言-完/21网络编程:Go语言如何玩转RESTfulAPI服务?.md @@ -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 框架,如 Mux,Gin、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 服务,记得来听课哦。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/22网络编程:Go语言如何通过RPC实现跨平台服务?.md b/专栏/22讲通关Go语言-完/22网络编程:Go语言如何通过RPC实现跨平台服务?.md new file mode 100644 index 0000000..0425b34 --- /dev/null +++ b/专栏/22讲通关Go语言-完/22网络编程:Go语言如何通过RPC实现跨平台服务?.md @@ -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的使用可以看官网的文档,入门是很容易的。 + + + + \ No newline at end of file diff --git a/专栏/22讲通关Go语言-完/23结束语你的Go语言成长之路.md b/专栏/22讲通关Go语言-完/23结束语你的Go语言成长之路.md new file mode 100644 index 0000000..e2242a9 --- /dev/null +++ b/专栏/22讲通关Go语言-完/23结束语你的Go语言成长之路.md @@ -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 语言的知识,让你在职场中更具备竞争力。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/00开篇词用好A_B测试,你得这么学.md b/专栏/AB测试从0到1/00开篇词用好A_B测试,你得这么学.md new file mode 100644 index 0000000..40e2949 --- /dev/null +++ b/专栏/AB测试从0到1/00开篇词用好A_B测试,你得这么学.md @@ -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测试慢慢变成你在工作中的一项核心竞争力。 + +最后,今天是开篇,你可以在评论区写下你对这门课的期待,或者你的学习计划,让我们一起见证彼此的成长吧! + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/01统计基础(上):系统掌握指标的统计属性.md b/专栏/AB测试从0到1/01统计基础(上):系统掌握指标的统计属性.md new file mode 100644 index 0000000..5b3d596 --- /dev/null +++ b/专栏/AB测试从0到1/01统计基础(上):系统掌握指标的统计属性.md @@ -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 Deviation),n为随机变量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测试的统计过程中有什么难理解的地方,以及是如何解决的? + +欢迎在留言区写下你的思考和想法,我们可以一起交流讨论。如果你觉得有所收获,欢迎你把课程分享给你的同事或朋友,一起共同进步! + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/02统计基础(下):深入理解A_B测试中的假设检验.md b/专栏/AB测试从0到1/02统计基础(下):深入理解A_B测试中的假设检验.md new file mode 100644 index 0000000..2dd01a0 --- /dev/null +++ b/专栏/AB测试从0到1/02统计基础(下):深入理解A_B测试中的假设检验.md @@ -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测试的主要流程吧! + +思考题 + +这节课涉及的统计概念都是虽然经常听到,但是难理解的,你们在学习统计中有没有对这些概念的理解有独特的心得?可以拿出来分享给大家。 + +欢迎在留言区写下你的思考和想法,我们可以一起交流讨论。如果你觉得有所收获,欢迎你把课程分享给你的同事或朋友,一起共同进步! + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/04确定指标:指标这么多,到底如何来选择?.md b/专栏/AB测试从0到1/04确定指标:指标这么多,到底如何来选择?.md new file mode 100644 index 0000000..7800412 --- /dev/null +++ b/专栏/AB测试从0到1/04确定指标:指标这么多,到底如何来选择?.md @@ -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测试,都会有相应的护栏指标吗?如果有的话,是什么具体的指标呢?这些护栏指标的作用又是什么呢? + +欢迎在留言区写下你的思考和想法,我们可以一起交流讨论。如果你觉得有所收获,欢迎你把课程分享给你的同事或朋友,一起共同进步! + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/05选取实验单位:什么样的实验单位是合适的?.md b/专栏/AB测试从0到1/05选取实验单位:什么样的实验单位是合适的?.md new file mode 100644 index 0000000..3255ffe --- /dev/null +++ b/专栏/AB测试从0到1/05选取实验单位:什么样的实验单位是合适的?.md @@ -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 > 匿名ID(Cookies)/设备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测试是不是可以用其他单位?为什么? + +欢迎在留言区写下你的思考和想法,我们一起交流讨论。如果你有所收获,也欢迎你把今天的内容分享给你的朋友,一起共同进步! + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/06选择实验样本量:样本量越多越好吗?.md b/专栏/AB测试从0到1/06选择实验样本量:样本量越多越好吗?.md new file mode 100644 index 0000000..3f5ed41 --- /dev/null +++ b/专栏/AB测试从0到1/06选择实验样本量:样本量越多越好吗?.md @@ -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测试的Power,Power越大,就越能探测到两组的不同。 + +所以啊,你把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 Power(1-β)。 + + +细心的你可能已经发现:上面这些参数都是计算概率类指标要用的参数,所以现在网上的这些样本量计算器只能计算概率类的指标,并不能计算均值类的指标,所以我们在使用时一定要注意要求输入的参数是什么,才能根据不同类型的指标选择正确的计算方法。对于均值类指标,现在网上还没有比较好的样本量计算器,在这种情况下我建议你通过公式来计算。 + +为了方便大家日后计算A/B测试中各类指标的样本量,我会在专栏的最后一节课,教大家用R做一个既可以计算概率类指标,还可以计算均值类指标的线上样本量计算器,敬请期待! + +小结 + +这节课我们主要学习了怎么确定A/B测试所需的样本量大小,了解了背后的理论基础,我给你总结了影响样本量的四个因素,其中,向上箭头表示增大,向下箭头表示减小。 + + + +这里我想要再强调一下,这节课介绍的计算A/B测试样本量的方法,是测试前对样本量的估计值,是为了让A/B测试结果达到统计显著性的最小样本量,所以,只要最终的实际样本量大于最小样本量就行。当然如果业务条件允许的话,样本量自然是越多越好。 + +最后我想说的是,当我们用网上的A/B测试样本量计算器时,要注意输入的参数是什么,因为绝大部分的计算器都是让用户输入转化率,只能计算概率类的指标,所以当计算概率类指标时我们可以用网上的计算器,但如果是其他类的指标(如均值类)的话不能用网上的计算器,还是得靠你自己利用公式计算测试所需的最小样本量,或者跟着我在专栏的最后,一起做一个既包含概率类指标,又包含均值类指标的线上样本量计算器。 + +思考题 + +你有用过网上的A/B测试样本量计算器吗?有没有想过为什么网上大部分的样本量计算器只能算概率类的指标而不能计算均值类指标呢? + +欢迎在评论区留言、讨论,也欢迎点击“请朋友读”,把今天的内容分享给你的同事、好友,和他一起学习、成长。好,感谢你的收听,我们下节课再见。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/07分析测试结果:你得到的测试结果真的靠谱吗?.md b/专栏/AB测试从0到1/07分析测试结果:你得到的测试结果真的靠谱吗?.md new file mode 100644 index 0000000..4055fad --- /dev/null +++ b/专栏/AB测试从0到1/07分析测试结果:你得到的测试结果真的靠谱吗?.md @@ -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.5(50%)为中心构建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节课再来好好讨论! + +思考题 + +你觉得分析结果前的合理性检验还可以参考哪些护栏指标呢?为什么? + +欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,欢迎你把这一讲分享给你的朋友,邀请他一起学习。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/08案例串讲:从0开始,搭建一个规范的A_B测试框架.md b/专栏/AB测试从0到1/08案例串讲:从0开始,搭建一个规范的A_B测试框架.md new file mode 100644 index 0000000..4a29db2 --- /dev/null +++ b/专栏/AB测试从0到1/08案例串讲:从0开始,搭建一个规范的A_B测试框架.md @@ -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测试的流程、步骤有了更清晰的认识,欢迎你点击“请朋友读”,把今天的内容分享给你的同事、好友,大家一起学习、成长。好,感谢你的收听,我们进阶篇的课程再见。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/09测试结果不显著,要怎么改善?.md b/专栏/AB测试从0到1/09测试结果不显著,要怎么改善?.md new file mode 100644 index 0000000..91f2e2a --- /dev/null +++ b/专栏/AB测试从0到1/09测试结果不显著,要怎么改善?.md @@ -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测试中,你是不是也遇到过没能得到显著结果的情况?你当时是怎么处理的,有没有从实验中获得一些宝贵的经验? + +欢迎在评论区留言、讨论,也欢迎点击“请朋友读”,把今天的内容分享给你的同事、好友,和他一起学习、成长。好,感谢你的收听,我们下节课再见。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/10常见误区及解决方法(上):多重检验问题和学习效应.md b/专栏/AB测试从0到1/10常见误区及解决方法(上):多重检验问题和学习效应.md new file mode 100644 index 0000000..f64872f --- /dev/null +++ b/专栏/AB测试从0到1/10常见误区及解决方法(上):多重检验问题和学习效应.md @@ -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%减去这个反面事件的概率。 + +这里我们用P(A)来表示出现事件A的概率。P(每个检验出现第一类错误)=5%,那么P(每个检验不出现第一类错误) = (1-5%)=95%,所以P(20个检验中完全没有第一类错误)= 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值,常用的方法就是通过控制FDR(False 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测试中遇到多重检验问题和学习效应?以及当时是如何处理的呢? + +欢迎在评论区写下你学习本节课的收获和深度思考,如果今天的内容能帮你解答了一些困惑问题,也欢迎点击“请朋友读”,和他一起学习、成长。感谢你的收听,我们下节课再见。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/11常见误区及解决方法(下):辛普森悖论和实验组_对照组的独立性.md b/专栏/AB测试从0到1/11常见误区及解决方法(下):辛普森悖论和实验组_对照组的独立性.md new file mode 100644 index 0000000..62f4a70 --- /dev/null +++ b/专栏/AB测试从0到1/11常见误区及解决方法(下):辛普森悖论和实验组_对照组的独立性.md @@ -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测试中遇到辛普森悖论和实验/对照组的独立性被破坏的情况?以及当时是如何处理的呢? + +欢迎把你的思考和收获分享在留言区,我们一起学习、讨论。如果这两节的误区系列帮你解答了一些疑难问题,也欢迎你把课程分享给你的同事、好友。我们下节课再见。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/12什么情况下不适合做A_B测试?.md b/专栏/AB测试从0到1/12什么情况下不适合做A_B测试?.md new file mode 100644 index 0000000..1722559 --- /dev/null +++ b/专栏/AB测试从0到1/12什么情况下不适合做A_B测试?.md @@ -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测试却不适用的情况?详细说一说原因和结果。 + +欢迎你把对本节课的思考和想法分享在留言区,我会在第一时间给你反馈。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/13融会贯通:A_B测试面试必知必会(上).md b/专栏/AB测试从0到1/13融会贯通:A_B测试面试必知必会(上).md new file mode 100644 index 0000000..ef253c5 --- /dev/null +++ b/专栏/AB测试从0到1/13融会贯通:A_B测试面试必知必会(上).md @@ -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测试的面试题吗?或者是有什么好的面试经验吗?欢迎分享出来,我们一起探讨。 + +欢迎分享出来,我们一起交流、探讨。也欢迎你把本节课推荐给你的朋友,一起进步、成长。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/14举一反三:A_B测试面试必知必会(下).md b/专栏/AB测试从0到1/14举一反三:A_B测试面试必知必会(下).md new file mode 100644 index 0000000..a6c197a --- /dev/null +++ b/专栏/AB测试从0到1/14举一反三:A_B测试面试必知必会(下).md @@ -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测试的相关术语的话,你会怎么解释呢?选取一两个尝试着解释下。 + +欢迎把你的解释分享在评论区,我们一起交流、讨论。同时如果你有所收获,也欢迎你把这节面试课分享给你有需要的朋友。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/15用R_Shiny,教你制作一个样本量计算器.md b/专栏/AB测试从0到1/15用R_Shiny,教你制作一个样本量计算器.md new file mode 100644 index 0000000..6a557b0 --- /dev/null +++ b/专栏/AB测试从0到1/15用R_Shiny,教你制作一个样本量计算器.md @@ -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_test(L11-12): + +numericInput("avgRR_prop_test", label = "原始指标", value = 5, min = 0, step=1) + + +最小可检测相对提升lift_prop_test(L18-19): + +numericInput("lift_prop_test", label = "最小可检测相对提升", value = 5,min = 0, step=1) + + +置信水平sif_prop_test(L42-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来实施的话,对于概率类和均值类指标样本量的计算,你会选择哪些函数呢? + +如果你在制作样本量计算器的过程中有遇到什么问题,或者有什么经验,欢迎在留言区和我分享,也欢迎你把课程分享给你的朋友、同事。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/加餐试验意识改变决策模式,推动业务增长.md b/专栏/AB测试从0到1/加餐试验意识改变决策模式,推动业务增长.md new file mode 100644 index 0000000..204edfb --- /dev/null +++ b/专栏/AB测试从0到1/加餐试验意识改变决策模式,推动业务增长.md @@ -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测试需要长期坚持,要形成循环,而不仅仅是闭环。如果说从发现问题到试验结果上线,再到效果回归是一个闭环的话,那么还需要在发现问题前加一个动词“持续”,“持续发现问题”,这就让试验意识形成了循环,在循环中形成持续向上的趋势。这个意识的重要性不在于一次两次试验有效还是无效,而是能让我们在决策前先用试验验证并长期这样做,形成习惯。 + +试验意识的建立,让我们的决策模式不再局限于依赖经验和直觉。试验意识加经验的决策模式成为我们的决策系统,由于这个系统有概率优势,虽然单次决策有时有效有时无效,但长期来看每一次微小进步的叠加效果就能驱动业务的整体增长,而其中的经验必将带来惊艳的效果。 + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/导读科学、规范的A_B测试流程,是什么样的?.md b/专栏/AB测试从0到1/导读科学、规范的A_B测试流程,是什么样的?.md new file mode 100644 index 0000000..81f9cf2 --- /dev/null +++ b/专栏/AB测试从0到1/导读科学、规范的A_B测试流程,是什么样的?.md @@ -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测试的旅程吧! + + + + \ No newline at end of file diff --git a/专栏/AB测试从0到1/结束语实践是检验真理的唯一标准.md b/专栏/AB测试从0到1/结束语实践是检验真理的唯一标准.md new file mode 100644 index 0000000..f1aeb6a --- /dev/null +++ b/专栏/AB测试从0到1/结束语实践是检验真理的唯一标准.md @@ -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年,注定是个特别的年份,突如其来的疫情打乱了很多人在学习、生活和工作上的节奏。在这种生存与挑战、安稳与不确定的摇摆之间,不如把心里的思绪和想法,投放到实践中,从而突破自己内心的围城。 + +这也是我最后想与你共勉的:唯有步履不停,人生路上才能遇到更多的惊喜。 + +最后的最后,我也为你准备了调查问卷,题目不多,希望你可以花两分钟填一下。十分期待能听到你的反馈,说说你对这门课程的想法和建议。 + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/086Twitter的广告点击率预估模型.md b/专栏/AI技术内参/086Twitter的广告点击率预估模型.md new file mode 100644 index 0000000..7c3061c --- /dev/null +++ b/专栏/AI技术内参/086Twitter的广告点击率预估模型.md @@ -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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/087阿里巴巴的广告点击率预估模型.md b/专栏/AI技术内参/087阿里巴巴的广告点击率预估模型.md new file mode 100644 index 0000000..2a6c848 --- /dev/null +++ b/专栏/AI技术内参/087阿里巴巴的广告点击率预估模型.md @@ -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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/088什么是基于第二价位的广告竞拍?.md b/专栏/AI技术内参/088什么是基于第二价位的广告竞拍?.md new file mode 100644 index 0000000..7f45776 --- /dev/null +++ b/专栏/AI技术内参/088什么是基于第二价位的广告竞拍?.md @@ -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 1305–1314. ACM, 2015. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/089广告的竞价策略是怎样的?.md b/专栏/AI技术内参/089广告的竞价策略是怎样的?.md new file mode 100644 index 0000000..6d476f0 --- /dev/null +++ b/专栏/AI技术内参/089广告的竞价策略是怎样的?.md @@ -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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/090如何优化广告的竞价策略?.md b/专栏/AI技术内参/090如何优化广告的竞价策略?.md new file mode 100644 index 0000000..3d8f641 --- /dev/null +++ b/专栏/AI技术内参/090如何优化广告的竞价策略?.md @@ -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 804–812. 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 1077–1086. 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 1465–1474. ACM, 2015. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/091如何控制广告预算?.md b/专栏/AI技术内参/091如何控制广告预算?.md new file mode 100644 index 0000000..16e938f --- /dev/null +++ b/专栏/AI技术内参/091如何控制广告预算?.md @@ -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 2217–2226. 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 1613–1619. 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 1307–1315. 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 407–416. ACM, 2016. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/092如何设置广告竞价的底价?.md b/专栏/AI技术内参/092如何设置广告竞价的底价?.md new file mode 100644 index 0000000..2ab728e --- /dev/null +++ b/专栏/AI技术内参/092如何设置广告竞价的底价?.md @@ -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 1–18, 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 1897–1906. ACM, 2014. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/093聊一聊程序化直接购买和广告期货.md b/专栏/AI技术内参/093聊一聊程序化直接购买和广告期货.md new file mode 100644 index 0000000..29ef6ea --- /dev/null +++ b/专栏/AI技术内参/093聊一聊程序化直接购买和广告期货.md @@ -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:1–1: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):465–479, 2015. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/094归因模型:如何来衡量广告的有效性.md b/专栏/AI技术内参/094归因模型:如何来衡量广告的有效性.md new file mode 100644 index 0000000..84a333d --- /dev/null +++ b/专栏/AI技术内参/094归因模型:如何来衡量广告的有效性.md @@ -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 258–264. 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 1–9. ACM, 2014. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/095广告投放如何选择受众?如何扩展受众群?.md b/专栏/AI技术内参/095广告投放如何选择受众?如何扩展受众群?.md new file mode 100644 index 0000000..7414771 --- /dev/null +++ b/专栏/AI技术内参/095广告投放如何选择受众?如何扩展受众群?.md @@ -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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/096复盘4广告系统核心技术模块.md b/专栏/AI技术内参/096复盘4广告系统核心技术模块.md new file mode 100644 index 0000000..49efd48 --- /dev/null +++ b/专栏/AI技术内参/096复盘4广告系统核心技术模块.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 096 复盘 4 广告系统核心技术模块 + 今天我准备了 18 张知识卡,和你一起来对广告系统核心技术模块的内容做一个复盘。 + +在这个模块,我们一起学习了18篇文章,讨论了5大话题,包括广告系统架构、知名公司的广告点击率预估模型、出价系统、预算等。通过这些点,我们勾勒出了这个领域的主线。希望你能沿着这条线,去做更多探索。 + +提示:点击知识卡,可以一键到达你最想复习的那一篇文章。 + +广告系统架构 + + + + + + + +广告点击率预估 + + + + + + + + + + + + + +出价系统 + + + + + + + +预算 + + + + + + + +高级话题 + + + + + + + +积跬步以至千里 + +最后,恭喜你在人工智能领域的千里之行,又往前迈出了一步。 + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/096如何利用机器学习技术来检测广告欺诈?.md b/专栏/AI技术内参/096如何利用机器学习技术来检测广告欺诈?.md new file mode 100644 index 0000000..6964d0f --- /dev/null +++ b/专栏/AI技术内参/096如何利用机器学习技术来检测广告欺诈?.md @@ -0,0 +1,83 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 096 如何利用机器学习技术来检测广告欺诈? + 在上一期的内容中,我们聊了如何帮助广告商扩大受众群这个话题,也就是受众扩展技术。受众扩展的目的是让广告商投放的广告能够接触到更广泛的受众,甚至有可能提高广告效果。 + +在计算广告高级话题的最后一篇分享,同时也是整个广告模块的最后一篇分享里,我想来聊一聊广告中一个非常棘手,同时也是一个非常实际的问题:欺诈检测(Fraud Detection)。 + +什么是广告欺诈 + +广告欺诈是一个多大规模的问题呢? + +根据一个统计数字[1],到2015年的时候,就因为广告欺诈,全美的市场营销和媒体业每年的耗费约为82亿美元。这个数字中大约有56%,也就是46亿多美元的耗费来自于“非法流量”(Invalid Traffic)。我们把这个数字和全美每年596亿的广告支出进行对比,就可以看出这是一个惊人的数字。当然,因为各种欺诈手段层出不穷,并不是所有的欺诈都能够被甄别出来。因此,我们其实有理由相信真实的数字会更高。 + +那么,怎么来定义广告欺诈呢?什么样的行为算是广告欺诈呢? + +我们这里主要讨论三种形式的广告欺诈。这三种广告欺诈模式其实对应着三种流行的广告计费模式。 + +第一种欺诈叫“展示欺诈”(Impression Fraud),也就是造假者产生虚假的竞价信息,然后把这些竞价展示放到广告交易平台上去贩卖,并且在广告商购买了这些展示后获利。 + +第二种欺诈叫“点击欺诈”(Click Fraud),也就是造假者在广告商产生虚假的点击行为。 + +第三种欺诈叫“转化欺诈”(Conversion Fraud),也就是造假者完成某种虚假的动作,例如填写表格,下载某个应用等来虚拟真实的转化事件。 + +在真实的场景中,这三种欺诈手段经常混合出现。例如点击欺诈和展示欺诈可能同时出现,这样就能在报表中展示一个看似合理的点击率。 + +广告欺诈的产生源 + +了解了广告欺诈的基本形式之后,我们来看一下这些欺诈产生的源头都在什么地方。因为广告产业的有利可图,产生欺诈的途径也是多种多样的,我们这里就看一些经典的形式。 + +首先,有一种欺诈来源途径叫PPV(Pay-Per-View)网络。 + +利用PPV进行欺诈的主要流程就是尝试通过购买流量,然后在一些合法的展示机会中插入用户肉眼看不见的0像素的标签(Tag),诱导广告商,让广告商以为产生了更多的合法流量。 + +对于这样的欺诈,一般来说,广告商必须去检测展示机会用户是不是看不见,或者是否是由0像素产生的。然后还可以采用黑名单的方式,对屡次利用PPV来进行欺诈的IP地址进行屏蔽。 + +另外一种欺诈手段是通过“僵尸网络”(Botnets)。 + +这种方法主要是试图直接控制用户的终端电脑或者其他的移动设备,从而进行很多方面的攻击。在过去,僵尸网络的一大应用主要是来产生拒接服务的DDoS(Distributed 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 1240–1248. 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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/097LDA模型的前世今生.md b/专栏/AI技术内参/097LDA模型的前世今生.md new file mode 100644 index 0000000..a00ba14 --- /dev/null +++ b/专栏/AI技术内参/097LDA模型的前世今生.md @@ -0,0 +1,79 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 097 LDA模型的前世今生 + 在文本挖掘中,有一项重要的工作就是分析和挖掘出文本中隐含的结构信息,而不依赖任何提前标注的信息。今天我要介绍的是一个叫做LDA(Latent 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)的下标(Index)z_n;二是从以φ和z共同为参数的多项分布中产生一个字(Word)w_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这个模型进行怎么样的修改呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/098LDA变种模型知多少.md b/专栏/AI技术内参/098LDA变种模型知多少.md new file mode 100644 index 0000000..4595524 --- /dev/null +++ b/专栏/AI技术内参/098LDA变种模型知多少.md @@ -0,0 +1,69 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 098 LDA变种模型知多少 + 我们在之前的分享中曾经介绍过文本挖掘(Text Mining)中的重要工具LDA(Latent 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,马尔科夫链蒙特卡洛)以及 VI(Variational 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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/099针对大规模数据,如何优化LDA算法?.md b/专栏/AI技术内参/099针对大规模数据,如何优化LDA算法?.md new file mode 100644 index 0000000..ded53cc --- /dev/null +++ b/专栏/AI技术内参/099针对大规模数据,如何优化LDA算法?.md @@ -0,0 +1,69 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 099 针对大规模数据,如何优化LDA算法? + 周一,我们分享了LDA(Latent Diriclet Allocation)的各种扩展模型,介绍了基于上游的和下游的两种把额外信息融入到LDA模型中的方法。同时,我们也讨论了在时间尺度上如何把LDA模型扩展到可以“感知”不同的时间段对于模型的影响。以LDA为代表的主题模型在过去的十年间发展出了一整套的扩展,为各式各样的应用场景提供了有力的工具。 + +尽管LDA在模型的表达力上给研究者们提供了把各种场景和模型结合的可能性,但是LDA的训练过程比较复杂,而且速度也比较慢。因此,如何能够把LDA真正应用到工业级的场景中,对于很多人来说,都是一件煞费苦心的事情。今天我们就来聊聊LDA的算法优化问题。 + +LDA模型训练 + +我们首先来回顾一下LDA模型的训练过程,从高维度上为你分析一下为什么这个过程很困难。 + +LDA模型中最重要的未知变量就是每个单词对应的主题下标(Index)或者说是主题“赋值”(Assignment)。这个主题下标是从每个文档对应的主题分布中“采样”得来的。每个文档的主题分布本身也是一个未知的多项式分布,用来表达当前这个文档的所属主题,比如有多少百分比属于运动、有多少百分比属于金融等等。这个分布是从一个全局的狄利克雷(Diriclet)分布中产生的。狄利克雷分布在这里起到了超参数的作用,其参数的取值往往也是未知的。但是我们可以根据一些经验值对其进行设置。除了每个文档的主题分布和主题赋值以外,我们还需要对全局的主题语言模型进行估计。这些语言模型直接决定了,各类词语出现的概率是多少。 + +流行的LDA训练方法有两个,一个是基于吉布斯采样(Gibbs Sampling)的随机方法,一个是基于变分推断(Variational Inference)的确定性方法(Deterministic)。这两种方法的初始形态都无法应对大型数据。这里我们来简要介绍一下这两种方法。 + +吉布斯采样主要是针对主题赋值进行采样,最开始是完全随机的结果,但是慢慢会收敛到参数的后验概率的真值。这里面比较慢的一个原因,是这个收敛过程可能需要几百到几千个不等的迭代。同时,吉布斯采样只能一个文档一个文档进行,所有的数据结构都需要在采样的过程中进行更改。这个过程比较慢的另外一个原因,是吉布斯采样的核心是如何对一个离散分布进行采样。而离散分布采样本身,如果在分布的参数变化的情况下,最好能够达到O(KlogK),这里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)的优化方法中,大数据的应用往往需要SGD(Stochastic 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 (NIPS’10), 2010. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/100基础文本分析模型之一:隐语义分析.md b/专栏/AI技术内参/100基础文本分析模型之一:隐语义分析.md new file mode 100644 index 0000000..ac54a56 --- /dev/null +++ b/专栏/AI技术内参/100基础文本分析模型之一:隐语义分析.md @@ -0,0 +1,57 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 100 基础文本分析模型之一:隐语义分析 + 本周我们分享了文本挖掘中的一个重要工具LDA(Latent 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的隐语义模型可以说是标准的无监督文本挖掘的核心算法。 + +总结 + +今天我为你介绍了基于矩阵分解的隐语义模型的相关知识。 + +一起来回顾下要点:第一,我们聊了聊为什么需要隐语义模型;第二,我们聊了一下基于矩阵分解的隐语义模型的核心思想及其局限。 + +最后,给你留一个思考题,如果我们要限制矩阵分解的结果是非负数,我们应该怎么做呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/101基础文本分析模型之二:概率隐语义分析.md b/专栏/AI技术内参/101基础文本分析模型之二:概率隐语义分析.md new file mode 100644 index 0000000..09d7220 --- /dev/null +++ b/专栏/AI技术内参/101基础文本分析模型之二:概率隐语义分析.md @@ -0,0 +1,51 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 101 基础文本分析模型之二:概率隐语义分析 + 在上一篇的分享里,我们展开了文本分析这个方向,讨论了“隐语义分析”(Latent Semantic Indexing)这个模型。隐语义分析的核心是基于矩阵分解的代数方法。这种方法的好处自然是能够直接利用代数计算方法对文本进行分析,而短板则是无法很好地解释结果。而“解释性”是很多概率模型的一大优势,因此,自然就有很多研究者想到是否能够把概率的语言移植到隐语义分析上。 + +今天,我们就来分享“概率隐语义分析”(Probabilistic Latent Semantic Indexing)的一些基本内容。概率隐语义分析有时候又被简称为 PLSA(Probability 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的建模流程有没有什么局限性? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/102基础文本分析模型之三:EM算法.md b/专栏/AI技术内参/102基础文本分析模型之三:EM算法.md new file mode 100644 index 0000000..feb0fa9 --- /dev/null +++ b/专栏/AI技术内参/102基础文本分析模型之三:EM算法.md @@ -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算法以及一系列基于变分EM(Variational EM)算法的核心思想。这么做的目的其实就是把对数函数从积分的外面给拿到里面。 + +当我们有了这个下限之后,我们就可以套用MLE的一切流程了。注意,这时候,我们有两组未知数。一组未知数是我们模型的参数,另外一组未知数就是模型的隐含变量。于是,当得到下限之后,我们就需要对这两组未知数分别求导,并且得到他们的最优表达。 + +当我们按照当前的模型参数,对模型的隐含变量所对应的概率分布求解后,最优的隐含变量的概率分布就等于隐含变量基于数据的后验概率。什么意思呢?意思就是说,如果我们把隐含变量的取值直接等于其后验概率分布,就得到了当前的最优解。这个步骤常常被叫作“E步”。 + +在进行了E步之后,我们再按照当前的隐含变量,求解这个时候最佳的模型参数。这常常被认为是“M步”。一次E步,一次M步则被认为是EM算法的一个迭代轮回。 + +EM算法貌似很神秘,但如果我们理解了整个流程的精髓,就可以把这个算法总结为:EM算法是利用杨森不等式得到MLE的一个下限,并且优化求解模型参数和模型的隐含变量的一个过程。 + +掌握了这个精髓,我们就可以看到,为什么LDA和PLSA等隐变量模型需要利用EM或者类似EM的步骤进行求解。第一,这些模型的MLE都有一个对数函数在积分符号外面,使得这个过程无法直接求解。第二,这些模型本身就有隐含变量,因此不需要额外制造新的隐含变量。 + +总结 + +今天我为你介绍了一个经常用于求解概率图模型的EM算法。 + +一起来回顾下要点:第一,我们回顾了EM算法和MLE算法的关系;第二,我们讨论了EM算法的核心思想。 + +最后,给你留一个思考题,EM算法在实际应用中有哪些问题呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/103为什么需要Word2Vec算法?.md b/专栏/AI技术内参/103为什么需要Word2Vec算法?.md new file mode 100644 index 0000000..980ace3 --- /dev/null +++ b/专栏/AI技术内参/103为什么需要Word2Vec算法?.md @@ -0,0 +1,57 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 103 为什么需要Word2Vec算法? + 至此,关于文本分析这个方向,我们已经介绍了 LDA(Latent Diriclet Allocation),这是一个出色的无监督学习的文本挖掘模型。还有“隐语义分析”(Latent Semantic Indexing),其核心是基于矩阵分解的代数方法。接着,我们分享了“概率隐语义分析”(Probabilistic Latent Semantic Indexing),这类模型有效地弥补了隐语义分析的不足,成为了在LDA兴起之前的有力的文本分析工具。我们还介绍了EM(Expectation 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好在哪里,又有什么不足的地方? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/104Word2Vec算法有哪些扩展模型?.md b/专栏/AI技术内参/104Word2Vec算法有哪些扩展模型?.md new file mode 100644 index 0000000..36b2ac7 --- /dev/null +++ b/专栏/AI技术内参/104Word2Vec算法有哪些扩展模型?.md @@ -0,0 +1,63 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 104 Word2Vec算法有哪些扩展模型? + 从上一期的分享开始,我们进入到文本分析的另外一个环节,那就是介绍一个最近几年兴起的重要文本模型,Word2Vec。这个模型对文本挖掘、自然语言处理等很多领域都有重要影响。我们讨论了Word2Vec模型的基本假设,主要是如何从离散的词包输入获得连续的词的表达,以及如何能够利用上下文从而学习到词的隐含特性。我们还聊了两个Word2Vec模型,SG(SkipGram)模型和CBOW(Continuous-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 (ICML’14), 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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/105Word2Vec算法有哪些应用?.md b/专栏/AI技术内参/105Word2Vec算法有哪些应用?.md new file mode 100644 index 0000000..c23d78a --- /dev/null +++ b/专栏/AI技术内参/105Word2Vec算法有哪些应用?.md @@ -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和主题模型提供的向量,是互补的还是可以相互替换的呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/106序列建模的深度学习利器:RNN基础架构.md b/专栏/AI技术内参/106序列建模的深度学习利器:RNN基础架构.md new file mode 100644 index 0000000..007f9e4 --- /dev/null +++ b/专栏/AI技术内参/106序列建模的深度学习利器:RNN基础架构.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 106 序列建模的深度学习利器:RNN基础架构 + 前面我们介绍了一个重要的文本模型,Word2Vec,我们聊了这个模型的基本假设,模型实现,一些重要的扩展,以及其在自然语言处理各个领域的应用。 + +接下来,我们来讨论更加复杂的基于深度学习的文本分析模型。这些模型的一大特点就是更加丰富地利用了文字的序列信息,从而能够对文本进行大规模建模。 + +今天,我们首先来看一看,序列建模的深度学习利器 RNN(Recurrent 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的基本的加码解码的框架。 + +最后,给你留一个思考题,对比HMM,RNN的优势有哪些? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/107基于门机制的RNN架构:LSTM与GRU.md b/专栏/AI技术内参/107基于门机制的RNN架构:LSTM与GRU.md new file mode 100644 index 0000000..b6968b3 --- /dev/null +++ b/专栏/AI技术内参/107基于门机制的RNN架构:LSTM与GRU.md @@ -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或者1,0代表不允许通过,1代表可以通过。这其实就是门机制的一个简单实现。我们只需要利用这个向量和隐含向量相应单元相乘,就能实现控制这些单元的目的。当然,这只是一个逻辑上的门机制,实际的门机制要有更多细节,也更加复杂。 + +基于门机制的RNN架构都有哪些呢?这里介绍两个比较流行的,分别是LSTM和GRU。我们这里不对这些模型展开详细的讨论,而是给你一个直观的介绍,帮助你从宏观上把握这些模型的核心思想。 + +LSTM的思路是把隐含状态分为两个部分。一部分用来当作“存储单元”(Memory Cells),另外一部分当作“工作单元”(Working Memory)。存储单元用来保留信息,并且用来保留梯度,跨越多个时间点。这个存储单元是被一系列的门控制,这些门,其实是数学函数,用来模拟刚才我们说的门的机制。对于每一步来说,这些门都要决定到底需要多少信息继续保留到下一个时间点。 + +总体来说,LSTM模型的细节很多,也很复杂。虽然LSTM已经成为了一种典型而且成功的RNN模型,但是实践者们还是觉得这个模型可以简化,于是就催生了GRU模型。 + +GRU模型的核心思想其实就是利用两套门机制来决定隐含单元的变化。一个门用于决定哪些单元会从上一个时间点的单元里复制过来,并且形成一个临时的隐含状态,另外一个门则控制这个临时状态和过去状态的融合。GRU在结构上大大简化了LSTM的繁复,在效果上依然能够有不错的表现。 + +总结 + +今天我为你介绍了文本序列建模利器RNN的几个实例。 + +一起来回顾下要点:第一,我们复习了RNN的基本概念和框架;第二,我们聊了两个带有门机制的经典的RNN模型,分别是LSTM和GRU。 + +最后,给你留一个思考题,RNN需要门机制,你认为到底是建模的需要,还是需要解决梯度异常的问题从而能够让优化算法工作? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/108RNN在自然语言处理中有哪些应用场景?.md b/专栏/AI技术内参/108RNN在自然语言处理中有哪些应用场景?.md new file mode 100644 index 0000000..4e9cbb8 --- /dev/null +++ b/专栏/AI技术内参/108RNN在自然语言处理中有哪些应用场景?.md @@ -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对不同级别的数据进行建模的形式就叫作“层次式”(Hierarchical)RNN建模。 + +特性提取器 + +在更多的场景中,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提取的信息能否完整捕捉文档里的内容,这一点我们怎么来判断呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/109对话系统之经典的对话模型.md b/专栏/AI技术内参/109对话系统之经典的对话模型.md new file mode 100644 index 0000000..e0a7165 --- /dev/null +++ b/专栏/AI技术内参/109对话系统之经典的对话模型.md @@ -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。 + +总结 + +今天我为你介绍了对话系统的一些基础的背景信息。 + +一起来回顾下要点:第一,我们讲了什么是对话系统,对话系统从方法论上来说有什么流派;第二,我们聊了对话系统的分类;第三,我们分析了对话系统的基本架构。 + +最后,给你留一个思考题,你认为,让对话系统能够真正智能的最大挑战是什么? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/110任务型对话系统有哪些技术要点?.md b/专栏/AI技术内参/110任务型对话系统有哪些技术要点?.md new file mode 100644 index 0000000..05a36df --- /dev/null +++ b/专栏/AI技术内参/110任务型对话系统有哪些技术要点?.md @@ -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进行建模。 + +总结 + +今天我为你介绍了任务型对话系统的基本技术要点。 + +一起来回顾下要点:第一,我们复习了任务型对话系统的基本组件;第二,我们进一步聊了这些组件的一些最基础的技术要点和背后的模型思想。 + +最后,给你留一个思考题,任务型对话系统需要每个组件单独进行学习还是尽可能把所有组件连在一起进行训练?这两种方法的优劣在什么地方呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/111聊天机器人有哪些核心技术要点?.md b/专栏/AI技术内参/111聊天机器人有哪些核心技术要点?.md new file mode 100644 index 0000000..f3943d4 --- /dev/null +++ b/专栏/AI技术内参/111聊天机器人有哪些核心技术要点?.md @@ -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或者是序列模型对对话系统进行建模。 + +最后,给你留一个思考题,你觉得在什么样的条件下,非任务型聊天机器人可以展现出真正的“人工智能”呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/112什么是文档情感分类?.md b/专栏/AI技术内参/112什么是文档情感分类?.md new file mode 100644 index 0000000..23bc50b --- /dev/null +++ b/专栏/AI技术内参/112什么是文档情感分类?.md @@ -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),来对整个文档做一种粗浅的判断。当然,这种打分机制背后都有一种理论来支撑。 + +比如,有一种打分模式依靠首先识别的“词类”进行分析,特别是大量的相邻的两个词的词性,诸如“特别好”。这里,“特别”是副词,“好”是形容词,然后就可以得出在某些情况下,副词和形容词的这种搭配特别多的时候,并且在正向的情感词比较多的时候,整个文档也许就是比较偏向正向的情感。 + +我们需要指出的是,这种方法虽然听上去比较“山寨”,但是对于很多产品和项目来说,获取大量高质量的标签信息往往是非常耗时,甚至是不可能的,例如上百万的用户对产品的评价数据。因此,在没有训练数据的情况下,利用某种打分机制,可以通过最简单的一些情感词库开发出文档情感分类的算法,这其实也不失为一种快速迭代的方式。 + +总结 + +今天我为你介绍了一类基础的文字情感分析任务——文档情感分类的基本技术要点。 + +一起来回顾下要点:第一,我们讲了基于监督学习的文档情感分类任务以及这类任务下的重要特性和模型;第二,我们聊了如何在没有大规模训练数据的基础上进行非监督的文档情感分类。 + +最后,给你留一个思考题,如何把文档情感分类任务扩展到可以针对多种实体多种情感的分析呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/113如何来提取情感实体和方面呢?.md b/专栏/AI技术内参/113如何来提取情感实体和方面呢?.md new file mode 100644 index 0000000..2f461cb --- /dev/null +++ b/专栏/AI技术内参/113如何来提取情感实体和方面呢?.md @@ -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产生理想效果的瓶颈。 + +总结 + +今天,我为你介绍了一类基础的文字情感分析任务——情感“实体”和“方面”的提取。 + +一起来回顾下要点:第一,我们介绍了什么是情感“实体”和“方面”;第二,我们聊了目前在这个方向上比较通行的一些方法,比如基于“频率”的提取,利用句子的一些特殊结构等。 + +最后,给你留一个思考题,除了我们介绍的这些方法,你还能想到其他方法来提取实体和方面的关键词吗? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/114复盘3自然语言处理及文本处理核心技术模块.md b/专栏/AI技术内参/114复盘3自然语言处理及文本处理核心技术模块.md new file mode 100644 index 0000000..9e179a7 --- /dev/null +++ b/专栏/AI技术内参/114复盘3自然语言处理及文本处理核心技术模块.md @@ -0,0 +1,69 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 114 复盘 3 自然语言处理及文本处理核心技术模块 + 到目前为止,我们讲完了人工智能核心技术的第三个模块——自然语言处理及文本处理核心技术。 + +整个模块共18期,6大主题,希望通过这些内容,能让你对自然语言处理及文本处理核心技术有一个全面系统的认识和理解,为自己进一步学习和提升打下基础。今天我准备了 18 张知识卡,和你一起来对这一模块的内容做一个复盘。 + +点击知识卡跳转到你最想看的那篇文章,温故而知新。如不能正常跳转,请先将App更新到最新版本。 + +LDA模型 + + + + + + + +基础文本分析 + + + + + + + +Word2Vec + + + + + + + +基于深度学习的语言序列模型 + + + + + + + +基于深度学习的聊天对话模型 + + + + + + + +文本情感分析 + + + + + + + +积跬步以至千里 + +最后,恭喜你在这个模块中已经阅读了37690字,听了120分钟的音频,获得一张新的通关卡,这是一个不小的成就。在人工智能领域的千里之行,我们又往前迈出了一步。 + + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/114文本情感分析中如何做意见总结和搜索?.md b/专栏/AI技术内参/114文本情感分析中如何做意见总结和搜索?.md new file mode 100644 index 0000000..a388c77 --- /dev/null +++ b/专栏/AI技术内参/114文本情感分析中如何做意见总结和搜索?.md @@ -0,0 +1,59 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 114 文本情感分析中如何做意见总结和搜索? + 在文本“情感分析”(Sentiment Analysis)这个领域,我们首先介绍了最基础的文档情感分类这个问题。在绝大多数情况下,这是一个监督学习的问题。当然,我们也可以通过建立情感词库来进行简单的非监督学习的步骤。紧接着,我们讨论了文本情感分析中的另一个关键技术,即情感“实体”和“方面”的提取。这个任务可以说是很多情感分析的根基,我们需要从无结构的文本中提取实体和方面等结构信息,便于进一步的分析。我们讲了如何通过词频、挖掘配对信息以及利用监督学习来对实体和方面进行挖掘。 + +今天,我们来看文本情感分析的另外一个主题——意见总结(Opinion Summarization)和意见搜索(Opinion Search)。 + +意见总结 + +为什么“意见总结”这个任务会很重要的呢? + +假如你希望在电商网站上购买一款数码相机。这个时候,你可能需要打开好几款相机的页面进行比较。对于相机的硬件指标,能够从这些页面上相对容易地直接得到,除此以外,你可能还比较关心对这些相机的评价。 + +在这个场景下,“意见总结”的重要性就凸显出来了。因为优秀的相机款式往往有上百甚至上千的用户评价,这些评价包括了用户对产品很多方面的评价,有褒义和贬义的情绪。如果对这些评价逐一进行浏览,很明显是一种非常低效的做法。因此,从购物网站的角度来说,如果能够为用户把这些评论进行总结,从而让用户看到总体的有代表性的评论,无疑能够帮助用户节省不少时间和精力,让用户获得更好的体验。 + +简单来说,意见总结就是从无结构的文本中提取出来的各种情感信息的综合表达。我们这里聊的意见总结主要是指“基于方面的意见总结”(Aspect-based Opinion Summarization)。也就是说,意见的总结主要是围绕着产品的种种方面来产生的。 + +概括一下,基于方面的意见总结有两个特点。第一,这样的总结主要是针对物体的实体以及对应的方面来进行的。第二,意见总结需要提供数量化的总结。什么是数量化的总结?就是总结里需要指出,持有某种意见的用户占多少百分比,又有多少百分比的用户有其他意见。很明显,这里还牵涉到如何表达和显示这些意见总结的步骤。 + +可以说,基于方面的意见总结成为了意见总结的主要任务。另外,基于方面的意见总结还可以与其他文本技术相结合,从而能够延展这个技术的效果。比如,总结语句的生成可以分为“句子选择”和“人工句子生成”这两种方案。 + +首先来说一下句子选择这个想法。句子选择的思路是,我们希望在最后的意见总结里,能够利用已有的非常有代表性的句子,这样用户看到的最后的总结会显得更加真实。那么这里有两个问题:一个问题是如何对所有的句子进行筛选;第二个问题是如果有重复多余的字句,又如何进行进一步的选择。 + +通常情况下,我们通过对句子打分来筛选,这个时候,一般需要设计一个打分机制,这个机制往往是看这个句子对某一个实体的方面是否进行了有情感的评价。然后,对所有句子进行聚类,这样所有评价类似的句子就可以被聚集到一起,从而能够过滤掉重复多余的字句。 + +那么,人工句子生成又是怎么运作的呢?首先,我们必须知道这个物品的哪些方面得到了用户的评价,而且都是什么样的评价,比如是正面评价还是负面评价。然后,把这些信息和一个语言模型,也就是语句生成器相连接,从而能够“生成”最后的总结语句。值得注意的是,这样生成的总结语句并不会出现在所有用户的原始评价中,因此也可能会对用户的最终体验有一定的影响。 + +除了基于方面的意见总结以外,还有一些类似的但是并不完全一样的总结方案。比如,有一种总结方案叫“针对性观点总结”(Contrastive View Summarization)。这个任务更加突出针对同一个主题的两种截然相反的观点。这种意见总结不仅可以针对商品,也针对新闻事件,比如某一个政策法规、选举结果等往往比较有争议的话题事件,“针对性观点总结”往往会有比较好的用户体验。 + +意见搜索 + +我们可以认为“意见搜索”是建立在意见总结之上的一个任务。通常情况下,意见搜索需要完成的任务是用户输入一个主体的名字,我们需要返回和这个主体相关的意见信息,这些意见信息有可能是通过意见总结而呈现给用户的。 + +意见搜索的难点,或者说是和传统搜索不一样的地方主要还是在于针对意见信息的索引和检索。 + +第一,我们需要在索引库中找到有哪些文档和字句包含了我们所需要查询的主体。可以说,这一点和传统的搜索是非常类似的。 + +第二,我们需要在找到的文档和字句中检查是否包含主体的某种意见,以及其褒义或者贬义的评价。这就是有别于传统搜索的地方。在找到了所有关于某个主体的情感评价以后,我们需要设计一个评分机制从而返回最有说服力的文档,并且还需要在这些文档的基础上进行意见总结。很显然,这些步骤都是传统的搜索中并没有的。 + +按照上面所说的这两点,我们可以把意见搜索分为两个阶段。 + +第一个阶段,就是利用现有的搜索技术,比如我们介绍过的文本搜索或者基于排序学习的搜索等方法,得到最初的一个文档的备选集。然后进入下一个阶段,就是通过一个模型,针对所有的文档进行基于意见的打分。这个模型可以是简单的分类器,用于分析当前的字句和主体的意见究竟有没有关系,也可以是一个更加复杂的模型,输出当前的文档和主体的哪一个方面有关系。在这里,任何一种文本分类器都可以被利用起来。 + +总体来说,意见搜索可以算是对于意见分析和总结的一个综合体现。 + +总结 + +今天,我为你介绍了一类比较高级的文本情感分析技术:意见的总结和搜索。至此,我们对于文本情感分析的分享就告一段落了。 + +一起来回顾下要点:第一,我们讲了意见总结的的重要性,基本概念和技术;第二,我们分享了意见搜索的基本概念和两个阶段的技术。 + +最后,给你留一个思考题,除了常见的观点和评分以外,用户对于产品的评价一般还在意哪些信息呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/115什么是计算机视觉?.md b/专栏/AI技术内参/115什么是计算机视觉?.md new file mode 100644 index 0000000..bd2c11b --- /dev/null +++ b/专栏/AI技术内参/115什么是计算机视觉?.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 115 什么是计算机视觉? + 在之前的一系列分享中,我们详细讲解了人工智能核心技术中的搜索、推荐系统、计算广告以及自然语言处理和文本处理技术。从今天开始,我们来分享专栏里人工智能核心技术模块的最后一部分内容:计算机视觉技术。 + +可以说,计算机视觉技术是人工智能技术的核心方向,特别是深度学习技术在计算机视觉中的应用,在最近五六年的人工智能浪潮中担当了先锋者的角色。甚至可以说,如果没有深度学习技术在过去几年对计算机视觉一些核心领域的推动和促进,就很可能没有这一波的人工智能技术浪潮。 + +我们可以这么来看待人工智能技术,它可以说是利用计算机技术来对人的感官,例如视觉、听觉、触觉以及思维进行模拟,从而建立起逻辑推断等智能才具备的能力。其中,计算机视觉技术无疑是至关重要的,也是非常困难的。 + +今天我会带你先来看看究竟什么是计算机视觉,以及这个方向的研发都需要解决哪些核心问题。 + +计算机视觉的定义 + +关于计算机视觉(Computer Vision,CV),有两种人们普遍接受的定义。 + +第一种定义认为,计算机视觉是从数字图像(Digital Images)中提取信息。这些信息可以是图像中的物品识别(Identification)、导航系统的位置测量(Space Measurement)以及增强现实(Augmented Reality)的应用。 + +计算机视觉的第二种定义主要是从应用的角度出发,认为计算机视觉是为了构建可以理解数字图像内容的算法,从而有多种应用。 + +那到底什么是计算机视觉呢?主要解决哪些问题?我们可以拿人类视觉的主要功能来做类比,就比较容易理解了。 + +当人类面对一个现实中的场景时,我们有一个感官器官来收集信号,那就是“眼睛”。眼睛收集的原始信号转换为人可以处理的信息之后,这些信息就来到了“大脑”这一个人类信息处理中心,进行分析和处理。 + +这个过程中最主要的一个处理模块,就是对信号产生“语义”(Semantic)解释或者进行逻辑上的理解。比如,当我们看到一个公园的一角以后,需要识别这个场景里的桥梁、水、树等物体,并且在头脑中形成这些物体的概念。可以说,这就是人类视觉系统的一个简单的框架,眼睛收集信息,大脑处理信息。 + +那么,在整体的框架上,计算机视觉其实就是希望模仿人类的视觉系统构架。输入依然是一个现实中的场景,但是我们需要借助其他的感知仪器(Sensing Device)来从中获取原始信息。最常见的感知仪器包括照相机、摄像机以及现在广泛普及的手机摄像头。从这些感知仪器中获取了最初级的信息之后,计算机视觉的“大脑”就是计算机。这里的“计算机”其实是指计算机算法,通过算法理解原始数据,构建语义信息。 + +这么理解起来,计算机视觉技术好像挺简单的。就像1966年,麻省理工大学的一个本科生想做这样一个暑期项目,并且认为这个项目可以在一个暑假里解决。这或许就是计算机视觉的一个起源了。但是,令人感慨的是,计算机视觉绝不是可以在一个假期内解决的项目,整整半个多世纪已经过去了,计算机视觉依然有很多值得挑战的课题,也依然还在高速发展中。 + +计算机视觉的领域特点 + +了解了计算机视觉的定义之后,我们来进一步聊一聊这个领域的一些特点。 + +首先,计算机视觉是一个“跨学科领域”。正如刚才所说,对人类视觉的研究给计算机视觉带来了重要的启发。那这里就涉及到生物领域的研究,包括对人的眼睛以及视觉神经的研究。一方面,我们需要感知器来从现实世界中获取信息。那么,对于感知仪器来说,设备越是精确,就越能完整地捕捉外界世界的信息。这里就涉及到物理,特别是光学的研究。另一方面,人脑是处理所有信号并且形成语义概念的器官,理解人脑的信息处理机制就会对计算机视觉的发展有重要的作用,这就涉及到脑科学和认知科学等领域。 + +除此以外,计算机视觉毕竟是一个和计算机结合得很紧密的学科方向。因此,要想设计高效的计算机视觉算法,就必须和计算机科学的很多其他方向结合并借鉴,例如信息检索、计算机体系结构、机器学习等。 + +计算机视觉的另外一个特点,就是这个领域包含了很多非常深刻的困难问题。我们说,从计算机视觉被当作一个暑假项目到现在,五十多年已经过去了,这个领域依然在蓬勃发展着。时至今日,我们依然不能说计算机视觉是一个已经被完全解决的问题。 + +那计算机视觉任务“难”在哪里呢?我认为根本原因在于计算机视觉算法处理的输入,也就是数字化了的图像信息和我们需要理解的语义信息之间存在巨大的鸿沟。举例来说,一个200乘以200的RGB图像其实就是一个由12万个数字组成的矩阵,但是这个矩阵可能代表一个非常复杂的图像。从数字到具体的图像中的物体,再到去理解这个图像的语义,这中间有很长的距离。 + +一直以来,计算机视觉也在尝试去构造和逼近一些人类视觉系统的特点,但是困难重重。比如,人类视觉系统的反应很快。有实验表明,从一幅普通场景的图像中,人类只需要150毫秒就能够识别出里面的物体。另外,人类视觉系统的复杂性还来自于对世界认知的理解。例如,人可以依靠过去的记忆或者经验,还可以依靠其他外界知识,来对图像中的物体进行判断。这些都是计算机视觉系统难以企及的。 + +当然,在经历了半个世纪的研究之后,也有不少学者提出怀疑的观点,计算机视觉研究是否要对人类视觉系统进行完全的模仿呢?一种观点是,计算机视觉系统并不需要亦步亦趋地完全照搬人类视觉系统,这可能也并不是一条切实有效的道路。有一种观点认为,计算机视觉系统可以从人类视觉或者其他领域得到灵感,但是究竟应该如何搭建一个有效的系统,还是需要开辟新的研究道路。 + +计算机视觉的应用 + +计算机视觉技术的领用非常广泛,可以说是深入到了普通人生活的方方面面。在这些应用中,除了我们日常比较容易接触到的,例如面部识别、光学字符识别(OCR)、电影特效、视觉搜索以外,还包括最近几年飞速兴起的自动驾驶、自动无人商店、虚拟现实、增强现实等等。 + +可以说计算机视觉的应用任务领域众多。近几年都受到深度学习的影响,绝大多数领域都得到了高速发展,但是依然需要领域知识来构建更加有效的模型。 + +小结 + +今天我和你讲了计算机视觉技术的一个,是我们计算机视觉基础知识系列的第一篇,帮助你对计算机视觉有一个最基本的概念性的了解。 + +一起来回顾下要点:第一,我们聊了什么是计算机视觉;第二,我们讲了计算机视觉的特点;第三,我们简要提及了一些计算机视觉的应用。 + +最后,给你留一个思考题,我们说计算机视觉的核心挑战是从数字到语义的理解,那么理解图像数据有什么特殊的地方吗? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/116掌握计算机视觉任务的基础模型和操作.md b/专栏/AI技术内参/116掌握计算机视觉任务的基础模型和操作.md new file mode 100644 index 0000000..014d1e4 --- /dev/null +++ b/专栏/AI技术内参/116掌握计算机视觉任务的基础模型和操作.md @@ -0,0 +1,63 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 116 掌握计算机视觉任务的基础模型和操作 + 今天,我们来聊一聊计算机视觉的一些最基础的操作和任务,包括像素表达、过滤器和边界探测。基于这些内容,我们一起讨论利用计算机来处理视觉问题的核心思路。很多时候,越是基础的内容就越重要,因为只有掌握了基础的思路,我们才能在今后复杂的任务中灵活应用。 + +像素表达 + +我们在上一次的分享中谈到了计算机视觉任务中一个非常重要的步骤,那就是把现实世界的信号通过感知仪器(Sensing Device)收集起来,然后在计算机系统中加以表达。那么,在所有的表达中,最基础的就是“像素表达”(Pixel)。我们这里就展开说一说这种表达的思路。 + +把图像信息利用像素来进行表达是一种非常直观简单的表达方式。 + +对于黑白图像来说,图像就被转换为了0或者1的二元矩阵。这个矩阵的每一个元素就是一个像素,0代表黑,1则代表白。 + +对于灰度图像来说,每一个像素,或者说是矩阵的每一个元素,代表灰度的“强度”(Intensity),从0到255,0代表黑,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)的办法,然后在这个基础上计算梯度大小和方向,有了这些之后再进行一些后期处理。 + +小结 + +今天我和分享了计算机视觉的一些基本数学模型和操作。 + +一起来回顾下要点:第一,像素是对真实世界的采样,我们分别讲了对黑白、灰度和彩色图像的像素表达;第二,我们讲了在像素表达之上的过滤器,介绍了移动平均和卷积两种方式;第三,我们简要提及了利用函数的梯度计算来进行边界检测的任务。 + +最后,给你留一个思考题,从边界检测的任务中,如何知道检测到的像素是真正属于边界呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/117计算机视觉中的特征提取难在哪里?.md b/专栏/AI技术内参/117计算机视觉中的特征提取难在哪里?.md new file mode 100644 index 0000000..495a56a --- /dev/null +++ b/专栏/AI技术内参/117计算机视觉中的特征提取难在哪里?.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 117 计算机视觉中的特征提取难在哪里? + 在上一次的分享中,我们聊了计算机视觉的一些最基础的操作和任务,包括像素表达和过滤器这两个视觉问题。我们还简单介绍了边界探测这个任务,了解如何从计算机视觉的角度来对这个任务进行建模。 + +今天,我们来看计算机视觉基础问题中的另一个核心任务,那就是特征(Feature)提取。 + +特征提取的目的 + +在深入讨论特征提取之前,我们先来了解一下特征提取的目的,或者说是研究特征提取的必要性。 + +从大的方面来说,计算机视觉的一部分任务是实现对图像的智能理解。那么,理解图像的语义就是其中一个非常重要的任务。 + +我们提到的边界检测或者是颜色检测,虽然都是理解图像的任务,但是这些任务并不理解图像中具体的物体,比如哪里是一只动物、哪里是行人等。那么,怎样才能理解图像中具体的物体呢?或者更进一步,整个图像表达了怎样的社会关系或者说是场景关系?例如一张会议室的图像,我们不仅关心里面的陈设和人物,还关心会议室的整体气氛,以及这样的气氛是不是传递出了更复杂的人物之间的社会关系。 + +那么,如何实现这种更高维度的语义理解呢?这往往需要我们对底层的一些图像先进行抽象,然后再在抽象出来的特征基础上,进一步来建模。 + +除了我们这里提到的对图像本身的理解以外,在很多任务中,我们还需要对图像和其他信息结合起来进行理解和分析。一种常见的形式是图像和一段文字结合起来,对某一个物品或者某一个事件进行描述。例如电商网站的商品信息,一般都有精美的图片和详细的介绍,这些信息组合起来完整地描述了整个商品的信息。 + +这个时候,我们就要同时理解图像和文字信息。很明显,在这样的任务中,一种比较容易想到的模式是先从图像和文字中分别抽取一定的抽象特征,有了高度概括的图像特征和文字特征之后,我们再在这个基础上进行建模。 + +从比较小的计算机视觉的任务来说,很多时候,一个任务会涉及到两个步骤:把任务抽象为提取特征,然后转换为一个普通的机器学习任务。这个流程的第二步可以是一个监督学习任务,例如回归或者分类;也可以是一个非监督学习任务。需要注意的是,我们这里提到的两个步骤,并不一定是绝对地把建模过程当作两个完全独立的步骤,而是从逻辑上对这两个步骤进行区分。事实上,在现代的深度学习架构中,这两个步骤往往都在统一的一个架构下进行训练,从而能够得到更好的效果。 + +今天,我们就从传统的计算机视觉的角度,来看看特征提取有哪些难点和经典方法。 + +特征提取的难点及基本思路 + +图像数据的特征提取为什么有难度呢?原因在于图像信息本身的复杂性。 + +试想我们有两张人民大会堂的建筑物照片,一张是从地面拍摄的,一张是从空中拍摄的。虽然这两张照片可能在角度、色彩、位置等方面有很多的不同,但是因为这两张照片本身所描述的对象是一致的,都是人民大会堂,因此我们希望从这两个图片中提取的特征有一些相似性。也就是说,我们需要找到在诸多变化因素中不变的成分。 + +一个经典的思路是从局部信息(Local Information)入手,从图像中提取相应的特征。从实际的效果来看,局部特征(Local Feature)比全局特征更加稳固。 + +回到上面的例子,如何构造一个能够匹配两个图片的普遍的局部特征呢?过程如下:第一,找到一组关键的点或者是像素;第二,在关键点周围定义一个区域;第三,抽取并且归一化这个区域;第四,从归一化后的区域提取“局部描述子”(Local Descriptor)。得到局部描述子之后,我们就可以利用它来进行匹配了。 + +从上面这个流程来看,整体的思路其实就是希望从局部找到具有代表性的特征,然后把所有因为各种因素造成的特征变化归一化掉。 + +当然,这个简单的流程是有一些问题的。比如,如果我们针对两幅不同的图像分别进行上述的流程,那么很有可能最后得到的关键点和局部描述子都不一样。所以我们需要一种更具普适性的方法。 + +其实从70年代开始,就有一大部分的计算机视觉工作是在研究如何构建局部特征描述子。在这30多年的发展历程中,很多研究工作者提出了不少既有理论基础又有实用价值的特征提取方法。甚至是最近的深度学习热潮,从某种程度上来说也是一个重要的特征提取成果。 + +在这些研究成果中,比较有代表性的局部描述子包括SIFT(Scale-invariant feature transform)描述子和HOG(Histogram 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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/118基于深度学习的计算机视觉技术(一):深度神经网络入门.md b/专栏/AI技术内参/118基于深度学习的计算机视觉技术(一):深度神经网络入门.md new file mode 100644 index 0000000..e2e1693 --- /dev/null +++ b/专栏/AI技术内参/118基于深度学习的计算机视觉技术(一):深度神经网络入门.md @@ -0,0 +1,71 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 118 基于深度学习的计算机视觉技术(一):深度神经网络入门 + 在最近几年的人工智能发展中,深度学习技术成为了一个强劲的推动力。对于计算机视觉来讲,深度学习在过去几年重新改写了这个领域的核心方法论。时至今日,深度学习已经深入到了计算机视觉技术的方方面面,成为解决各类视觉问题的有力工具。 + +从今天开始,我们将介绍一系列以深度学习为背景的计算机视觉技术。那么在这个环节的第一篇分享中,我们首先来了解一下什么是深度学习。 + +为什么是深度学习 + +在了解一些深度学习技术细节之前,我们首先要来看一下为什么需要深度学习技术。 + +初学者经常会有一个误区,那就是认为和“深度学习”相对的就是“浅层学习”(Shallow Learning)。这种看法也对,也不对。 + +“对”的地方在于“深度学习”的确强调从数据或者说是特征(Feature)中构造多层或深度的变换,从而能够得到非线性的表征(Representation)。显然,这种效果是线性模型所达不到的。 + +“不对”的地方是,在所谓的“深度学习”,或者准确地讲是深度神经网络技术发展之前,就已经有了很多构造复杂非线性表征的尝试和技术。这些技术在机器学习和人工智能的发展中都起到了举足轻重的作用。 + +说到这里,我们就要从线性模型聊起了。从线性模型发展到非线性模型,这一步貌似理所当然,但其实这里面有一个非常重要的思路,那就是线性模型并不是不能处理数据中的非线性关系,这一点很容易被忽视。很多时候,我们其实是可以构造非线性的特征,然后利用线性模型来把所有的非线性特征给串联起来。 + +举个例子,在网页搜索中,我们经常利用类似PageRank来表征一个网页的重要性。这个模型本身就是非线性的对网页图(Graph)的一种表征。所以,即便在此之上构建线性模型,整个模型其实也是包含了非线性的表征转换。 + +其实,对于很多深度学习模型而言,即便进行了复杂的表征转换,在最后一层对最终的输出进行建模的时候依然是一个线性模型。所以,线性模型在非线性特征的帮助下,依然能够满足整体的非线性建模的需求。 + +那么,这种非线性特征外加线性模型的方法有什么问题呢? + +这类方法的主要问题是,如何才能系统性地找到这些非线性特征呢?其实,传统的机器学习中的特征工程(Feature Engineering)主要就是在做特征寻找这个事情,也就是要消耗人力和物力去寻找这些特征。有时候,找到一个好的特征可能还需要灵感和其他领域特定的知识。 + +那么,有没有办法让模型自身就能从现有的数据中发现这些非线性关系,从而不需要额外的特征工程呢? + +其实在机器学习发展的早期,研发人员就意识到了这个方向的重要性,这样就发展出了各种各样的非线性模型。 + +这里面比较有代表性的模型是“决策树”(Decision Tree),以及在此基础上发展出来的一系列“树模型”(Tree Models)。我们在专栏里介绍过,树模型在搜索和推荐的一些场景中都有不错的性能表现。在很大程度上,树模型可以表达非线性的关系,但是它的困难之处在于无法表达过于深层次的数据关系。一般来说,3到4层的树已经算是比较深的结构了,如果一个树模型有特别多层次还能被训练成功,这样的例子是比较少见的。 + +除了树模型之外,还有一类模型,用来挖掘数据中间的隐含关系,特别是非线性关系,这就是“概率图模型”(Probabilistic Graphical Model)。例如在文本挖掘领域非常流行的LDA(Latent Dirichlet Allocation),以及在推荐领域流行的矩阵分解(Matrix Factorization),都可以被看作是概率图模型中的佼佼者。 + +概率图模型的一大优势是可以融入众多对于数据以及所需要处理问题的直觉,从而能够让模型具有一定的可解释性,甚至是“因果性”(Causality)。但是,概率图模型的最大挑战就是每一个模型都需要单独计算训练算法。也就是说,算法无法做到普适通用。这就极大地限制了概率图模型在实际问题,特别是大数据问题中的应用。 + +综合来看,我们急需一种方法,能够自动挖掘数据中的非线性关系,而且最好能够找到数据中的隐含规律,这种隐含的规律可能是非常多层次的非线性转换;并且,这种方法还需要在计算上直接通用,不同模型可以共用一个计算框架。 + +所有这些因素如何囊括在一个方法里呢?答案就是深度学习技术。 + +深度学习的特点 + +深度学习技术慢慢成为了主流的非线性模型。接下来我们来看一看深度学习技术的一些特点。 + +首先,深度学习技术是一个非常大的外延,这里面包含了很多不同的模型和模型的计算框架技术。这两者都是深度学习成功必不可少的组成部分。 + +深度学习中有一种最简单也是最基础的模型,就是“深度神经网络”(Deep Neural Networks)。这种模型其实很早就已经被提出了。 + +从形式上来说,深度神经网络就是把多层简单的非线性操作叠加起来,希望能够发现更加复杂的非线性关系。实际上,有理论研究表明,在有足够的内部隐含变量的情况下,深度神经网络可以表达任意复杂的函数关系。也就是说,深度神经网络有希望能够对现实世界中的复杂现象进行建模。这一点对于我们刚才提到的树模型和概率图模型来说,有相当大的难度或者说几乎是不可能的。 + +另外,回到我们刚才说的特征工程的需求,深度神经网络的确可以减轻这方面的压力。虽然并不如很多人预期的那样,深度神经网络依然需要依赖一定的初始数据,但是已经有实验表明,通过深度神经网络学习的特征在很多时候相比于研发人员手动挖掘的特征要更加健壮。 + +实际上,在计算机视觉这样的领域里,利用深度神经网络来挖掘特征基本上已经完全代替了手动的特征挖掘。 + +深度神经网络还有一个特点就是计算的普适性。刚才我们讲到概率图模型有一个“死穴”,就是计算无法做到模型普适,或者说在不牺牲性能的前提下,计算无法做到普适性。那么这一点来说,以深度神经网络为代表的深度学习,依赖简单的梯度下降就能对非常复杂的网络进行计算。而这种计算是可以针对不同的模型的,因此这就极大地降低了深度学习在实际工程应用中的代价。 + +小结 + +今天我为你讲了基于深度学习的计算机视觉技术的第一篇分享,帮助你对深度学习有一个更加明确的认识。 + +一起来回顾下要点:第一,深度学习技术能够自动挖掘数据中的非线性关系;第二,在计算机视觉领域里,利用深度神经网络来挖掘特征已经基本代替了手动的特征挖掘,而且,深度神经网络还具有计算的普适性。 + +最后,给你留一个思考题,和树模型或者概率图模型相比较,深度学习有什么劣势吗?如果有,你认为它最大的劣势是什么? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/119基于深度学习的计算机视觉技术(二):基本的深度学习模型.md b/专栏/AI技术内参/119基于深度学习的计算机视觉技术(二):基本的深度学习模型.md new file mode 100644 index 0000000..fd0fc05 --- /dev/null +++ b/专栏/AI技术内参/119基于深度学习的计算机视觉技术(二):基本的深度学习模型.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 119 基于深度学习的计算机视觉技术(二):基本的深度学习模型 + 在上一期的介绍里,我们讨论了以深度学习为背景的计算机视觉技术,重点讲解了为什么需要深度学习,特别是从传统模型的眼光来看深度学习模型的特点。 + +今天,我们来聊一聊应用到图像上的一些最基本的深度学习模型。 + +前馈神经网络 + +前馈神经网络(Feedforward Networks)应该算是最基本的神经网络架构了。这种架构是理解其他神经网络结构的基础。 + +我们可以从最基本的线性模型(Linear Model)入手,来理解前馈神经网络。线性模型说的是有一组输入x,然后有一个输出y,我们学习到一组向量,有的时候也叫作系数w,来通过x预测y。这种线性模型可以算是最简单的机器学习模型了。在图像的情况下,输入往往是一个向量,输出也是一个向量,这个时候,我们需要学习的系数就从一个向量变为一个矩阵了。 + +那么,试想一下,如果我们把多层的线性模型进行叠加,能否得到多层的神经网络结构呢?答案是否定的。即便是多层的线性模型,只要每一层的变换是线性的,那么最后的结果一定也是线性的。因此,要想构建多层的非线性模型,每一层的变换也一定要是非线性的。 + +那么,如何在线性模型的基础上,我们只进行一些最小的改动,就能引入非线性的因素呢? + +在这里,我们引入一个叫“激活函数”(Activation Function)的概念。直观地理解,激活函数就是在线性模型输出的基础上进行非线性变换。一个最简单的激活函数就是Sigmoid函数,也就是把负无穷到正无穷的实数给映射到0~1这个范围内。我们经常提到的对数几率回归,其实就是对这种变换的另一种称呼。 + +利用了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”区域中的最大值。 + +当一个图像经过了卷积和池化等一系列的操作以后,我们就说已经提取了这个图像的关键特征。这个时候,我们往往会把数据经过基本的前馈神经网络来进一步融合,最后能够完整地总结数据信息。在前馈神经网络之后,这就又是一个线性的决策层,可以是回归,也可以是分类。 + +小结 + +今天我为你讲了基于深度学习的计算机视觉技术的第二篇分享,帮助你对深度学习的基本模型,包括前馈神经网络和卷积神经网络有一个基本的认识。 + +一起来回顾下要点:第一,我们从线性模型入手讨论了前馈神经网络架构,重点介绍了激活函数和线性整流函数;第二,我们讲了在前馈神经网络基础之上的卷积神经网络,核心是用向量来描述一个矩阵的信息。 + +最后,给你留一个思考题,卷积神经网络中池化层的必要性是什么?为什么需要总结周围的单元到一个单元?这难道不是丢失了很多信息吗? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/120基于深度学习的计算机视觉技术(三):深度学习模型的优化.md b/专栏/AI技术内参/120基于深度学习的计算机视觉技术(三):深度学习模型的优化.md new file mode 100644 index 0000000..3378194 --- /dev/null +++ b/专栏/AI技术内参/120基于深度学习的计算机视觉技术(三):深度学习模型的优化.md @@ -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)等,已经变成了深度模型优化的标准流程之一,目的就是能够有效地计算梯度,而且有较好的数值稳定性。 + +小结 + +今天我为你讲了机器学习模型,包括传统模型和深度学习模型的优化流程。 + +一起来回顾下要点:第一,我们聊了什么是模型、目标函数和优化过程这三个概念,清晰理解这三个概念之间的关系非常重要;第二,我们从一般机器学习模型的优化过程入手,讲了深度模型的优化。 + +最后,给你留一个思考题,有哪些因素造成了深度模型优化的困难? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/121计算机视觉领域的深度学习模型(一):AlexNet.md b/专栏/AI技术内参/121计算机视觉领域的深度学习模型(一):AlexNet.md new file mode 100644 index 0000000..825d22a --- /dev/null +++ b/专栏/AI技术内参/121计算机视觉领域的深度学习模型(一):AlexNet.md @@ -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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/122计算机视觉领域的深度学习模型(二):VGG&GoogleNet.md b/专栏/AI技术内参/122计算机视觉领域的深度学习模型(二):VGG&GoogleNet.md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/AI技术内参/123计算机视觉领域的深度学习模型(三):ResNet.md b/专栏/AI技术内参/123计算机视觉领域的深度学习模型(三):ResNet.md new file mode 100644 index 0000000..daed4df --- /dev/null +++ b/专栏/AI技术内参/123计算机视觉领域的深度学习模型(三):ResNet.md @@ -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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/124计算机视觉高级话题(一):图像物体识别和分割.md b/专栏/AI技术内参/124计算机视觉高级话题(一):图像物体识别和分割.md new file mode 100644 index 0000000..20ea2ce --- /dev/null +++ b/专栏/AI技术内参/124计算机视觉高级话题(一):图像物体识别和分割.md @@ -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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/125计算机视觉高级话题(二):视觉问答.md b/专栏/AI技术内参/125计算机视觉高级话题(二):视觉问答.md new file mode 100644 index 0000000..62ccc93 --- /dev/null +++ b/专栏/AI技术内参/125计算机视觉高级话题(二):视觉问答.md @@ -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 (NIPS’16), 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. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/126复盘5计算机视觉核心技术模块.md b/专栏/AI技术内参/126复盘5计算机视觉核心技术模块.md new file mode 100644 index 0000000..4640c21 --- /dev/null +++ b/专栏/AI技术内参/126复盘5计算机视觉核心技术模块.md @@ -0,0 +1,53 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 126复盘 5 计算机视觉核心技术模块 + 复盘 5 计算机视觉核心技术模块 + +今天我们来对计算机视觉核心技术模块做一个复盘。在这个模块里,我们一起学习了12期内容,讨论了四个话题,这些话题主要围绕计算机视觉的基础知识和深度学习技术在这个领域的应用。 + +之所以这么安排,是因为没有深度学习技术,就不会有现在计算机视觉的发展。我们站得稍微高一点就可以看到,正是因为深度学习技术在计算机视觉中的成功应用,才有了近几年的人工智能浪潮。 + +提示:点击知识卡跳转到你最想看的那篇文章,温故而知新。 + +图像技术基础 + + + + + + + +基于深度学习的计算机视觉技术 + + + + + + + +计算机视觉领域的深度学习模型 + + + + + + + +计算机视觉高级话题 + + + + + + + +积跬步以至千里 + +最后,恭喜你学完了这个模块中的内容。今日记一事,明日悟一理,积久而成学。每一个收获都是一个不小的成就。 + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/126计算机视觉高级话题(三):产生式模型.md b/专栏/AI技术内参/126计算机视觉高级话题(三):产生式模型.md new file mode 100644 index 0000000..40a0186 --- /dev/null +++ b/专栏/AI技术内参/126计算机视觉高级话题(三):产生式模型.md @@ -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 (NIPS’14), 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 + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/127数据科学家基础能力之概率统计.md b/专栏/AI技术内参/127数据科学家基础能力之概率统计.md new file mode 100644 index 0000000..38c111d --- /dev/null +++ b/专栏/AI技术内参/127数据科学家基础能力之概率统计.md @@ -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里发生的可能性呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/128数据科学家基础能力之机器学习.md b/专栏/AI技术内参/128数据科学家基础能力之机器学习.md new file mode 100644 index 0000000..9178ef1 --- /dev/null +++ b/专栏/AI技术内参/128数据科学家基础能力之机器学习.md @@ -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均值聚类算法作为例子探讨了如何用“三步套路”来分析无监督学习的模型和算法。 + +最后,给你留一个思考题,在现实场景中,当你发现一个模型并没有很好地解决你的问题时,从这个“三步套路”的角度来看,究竟哪个方面最容易出问题? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/129数据科学家基础能力之系统.md b/专栏/AI技术内参/129数据科学家基础能力之系统.md new file mode 100644 index 0000000..bdfd45f --- /dev/null +++ b/专栏/AI技术内参/129数据科学家基础能力之系统.md @@ -0,0 +1,71 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 129 数据科学家基础能力之系统 + 对于初学人工智能的工程师或者数据科学家来说,在知识积累的过程中,“系统”往往是一个很容易被忽视的环节。特别是非计算机科学专业出身的朋友,一般都没有真正地建立过“系统”的概念,在今后从事人工智能的相关工作时,很可能会遇到一些障碍。 + +今天我想给你分享一下,作为人工智能工程师和数据科学家,需要建立的关于“系统”的最基本认知。这些认知能够帮助你把书本的理论知识和现实的应用场景快速结合起来。 + +理解管道(Pipeline) + +在很多人工智能初学者的认知中,机器学习的流程是这样的。有一个已经准备好的数据集,这个数据集里面已经有了各种特征以及所对应的标签或者响应变量。这个时候,你需要做的就是利用这个数据集和一些现成的机器学习工具包,来训练一些机器学习模型。模型训练好以后,就可以计算一些已知的评估指标了,比如准确度、精度等等。 + +这是一般教科书和课程上介绍的标准机器学习流程,也是很多机器学习论文中的实验环境。遗憾的是,这个静态的流程并不适用于工业级的数据产品。 + +要支持工业级的人工智能产品,一个最基本的概念就是,你需要搭建一个管道让你的环境是动态的、闭环的。在英文的语言背景里,“管道”这个词很形象地说明了这个环境的特点。我们把数据想象成“管道”里的水,这里面的一个核心思想,就是数据从一个环节到下一个环节,源源不断。我们再把最终的产品,也就是这个管道的末端,和最开始的数据采集部分,也就是这个管道的开始端,结合起来思考,这就是一个闭合的环路。 + +理解数据产品的核心,就要理解它是一个闭合环路。几乎关于数据产品的一切难点、问题以及解决方案都可以从这个闭合环路中产生。从一个静态的机器学习流程到一个动态的管道似的闭合环路,这是一个质变,对整个环节上的所有步骤都有全新的要求。 + +我这里就拿数据集来举个例子。静态的流程中,我们不需要太过关注这个数据集的来源。甚至采集数据集的代码或者脚本都可以是一次性的,可以不具备重复使用的价值。但是这种情况在管道的环境中是不可能的。 + +在管道中,采集数据的可靠性和可重复性是非常重要的步骤,这就对采集数据所采用的代码有不一样的要求。这部分代码需要被反复检验,每一步都需要人工智能工程师和数据科学家进行检验。如果我们把这个例子扩展到数据管道的其他部分,就可以很清楚地看到,数据管道对于构建一个机器学习流程所带来的根本变化。 + +管道的另外一个重要特性是自动化,一个不能自动化的管道是不能被称为管道的。这里的自动化有两层意思,一层意思是指数据本身可以被自动采集、整理、分析,然后自动流入机器学习部分,有结果后自动输出并能被线上的系统使用;另一层意思是指,每一个环节本身都不需要人工干预,或者仅需极少数的人工,自身可以高可靠地运行。由此可见,管道的自动化对每个环节的技术选择和实现都有非常高的要求。 + +现代互联网公司中,每个团队,甚至成立专门的团队,一般都会针对机器学习管道开发工具平台,使管道的灵活度、自动化、可靠性有足够保障。对于初学者而言,尝试从管道的角度去理解问题,从整个系统的角度来认识产品开发过程,认识机器学习流程,才有可能设计出能真正满足线上需求的技术方案。 + +理解线上和线下的区别 + +了解了一个数据系统的闭合回路以后,很自然地,就会出现下一个问题,这也是一个核心的系统级问题,在这个管道中,哪些部分是在“线上”,哪些部分又在“线下”呢? + +这里我们先来理清“线上”这个概念。“线上”往往是说,对于交互性很强的互联网产品(包括电商、搜索引擎、社交媒体等),从用户来到某一个页面,到我们为这个页面准备好所需内容(例如推荐的商品或者搜索的结果),这中间的响应时间对应的就是“线上”,这部分时间非常短暂,往往只有几百毫秒。如何在这几百毫秒的时间内进行复杂的运算就非常有讲究了。 + +“线下”的概念是相对于“线上”而言的。通常情况下,不能在这几百毫秒之内完成的运算,都是某种程度的“线下”运算。 + +理解线上和线下的区别是初学者迈向工业级应用的又一个重要的步骤。哪些计算可以放到线上,哪些可以放到线下,就成了种种机器学习架构的核心区别。 + +初学者还需要注意的一个问题是,线上和线下都是相对的概念。今天放在线下计算的某些部分,明天可能就会放到线上进行计算。所以,慢慢学会把握两者之间的转换之道,对于初学者进阶至关重要。 + +我这里举一个简单的线上和线下分割的例子。比方说,我们要构建一个检测垃圾邮件的系统。对于这样一个系统而言,哪些部分是线上,哪些部分是线下呢? + +初看,我们在这里讨论的是一个比较容易的架构,但并不代表实现这个架构的难度也很小。在最简单的情况下,检测垃圾邮件需要一个二分分类器。如何训练这个分类器的参数就是一个关键。 + +假设我们训练一个逻辑回归二分分类器。那么,逻辑回归的参数,也就是一组线性系数究竟应该在什么环境中得到呢?很明显,训练一个逻辑回归肯定需要大量的训练数据。在有一定量(大于几千的垃圾邮件和非垃圾邮件)的训练数据时,训练逻辑回归的参数就不可能在几百毫秒内完成。在这样的思路下,训练逻辑回归就不得不放到线下来计算。一旦这个决定做出以后,一系列的模块就都必须放在线下计算了。 + +另外,数据的收集肯定也得放到线下,这样才能保证可以把训练数据传输到后面的管道模块中。还有特征的生成,至少是训练数据特征的生成,很自然地也就需要放在线下。 + +训练逻辑回归本身,刚才我们提到了,需要放在线下。而放在线下这个决定(从某种意义上来说,无所谓时间多了一点还是少了一点,总之无法满足几百毫秒的线上计算就需要放在线下),又可以让训练逻辑回归本身,采用更加复杂的二阶算法,使参数能够得到更好的收敛。 + +你可以看到,因为一个决定,带来了关于整个管道的一系列决定。而这些决定又影响了模型算法的选择,比如可以选用相对耗时的更复杂的算法。 + +那么在这个架构下,线上的部分是什么呢?首先,训练完一个模型之后,要想使用这个模型,我们必须把模型的参数存放到某个地方(也许是一个数据库或者是一个存储系统),线上的系统可以在几百毫秒的时间内马上得到这些参数。仅仅得到参数是不够的,还需要对当前的邮件进行判断。 + +这一步就有一些问题了。一种选择是,线上的部分拿到模型参数,然后实时动态产生这个邮件的特征,再实时计算出一个分数,并且判断是否是垃圾邮件。整个过程的这三个步骤需要在几百毫秒内进行完毕。 + +实际上,这里面的第二步往往比较耗时,甚至有的特征并不能在线上进行计算。比如,也许有一个特征需要查询这个邮件的来源是否可靠,这里就可能需要数据库操作,这一步也许就会非常耗时(在几百毫秒的场景中而言)。因此,动态产生特征,除非特征都非常简单,很有可能并不能完全在线上完成。 + +我们可以对框架进行简单的修改。所有的邮件首先输送到一个特征产生的模块中,这里并不是一个完全线上的环境,运算的需求可能超过几百毫秒,但总体只是几秒,最多十多秒。所有的特征产生以后,对邮件的判断也在这里完成,最终将邮件是否是垃圾邮件这个简单的选项保存下来。在线上的系统,也就是用户来到这个邮件系统界面的时候,我们只是从保存的结果中,直接读出一个标签,速度非常快。 + +如上,我们通过检测垃圾邮件系统的例子,分析了线上和线下的分割情况。现在来做一个思考,刚才描述的这个架构有什么问题吗?问题就是,线上的结果是一个事先计算好的结果,模型本身也是事先计算好的。因此,当有大量突发数据(比如一大批新的垃圾邮件)来临的时候,这个架构可能无法很快反应,更新模型。可见,如何理解线上和线下是一个需要慢慢琢磨的学习过程。 + +小结 + +今天我为你讲了数据科学家和人工智能工程师需要掌握的关于系统基础的两个核心概念。一起来回顾下要点:第一,现代数据流程不是一个静态的数据集,而是一个动态的闭环管道。 第二,理解什么计算可以放到线上,什么计算可以放到线下至关重要。 + +最后,给你留一个思考题,如果让你设计一个商品的推荐系统,哪些部分放到线下,哪些部分放到线上呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/130数据科学家高阶能力之分析产品.md b/专栏/AI技术内参/130数据科学家高阶能力之分析产品.md new file mode 100644 index 0000000..81b9060 --- /dev/null +++ b/专栏/AI技术内参/130数据科学家高阶能力之分析产品.md @@ -0,0 +1,89 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 130 数据科学家高阶能力之分析产品 + 人工智能工程师和数据科学家的主要工作是什么?很多人认为,他们的主要工作是利用复杂的机器学习模型和算法来解决产品中的难题。这样的认识既“对”也“不对”。“对”的地方是说,机器学习模型和算法的确是人工智能技术在产品上落地的核心步骤。“不对”的地方是说,这种认识往往片面地总结了人工智能从业人员的工作范畴。 + +实际上,要想真正地提出一个好的人工智能解决方案,分析产品的能力是必需的。从较高的层次来讲,就是分析一个产品目前遇到的难题是什么,为什么需要用人工智能技术去解决,哪些是可以用人工智能技术解决的,哪些不能。 + +今天,我就来分享一下,站在人工智能工程师和数据科学家的角度,我们如何理解并提升分析产品的能力,学会了解产品的需求。 + +产品需求的庖丁解牛 + +一个数据驱动的产品往往是一个复杂的复合体。这里面当然有很多数据、人工智能的元素,也有不少其他元素,比如设计、人机交互、商业规则、心理学等等。那么,如何在这么一个综合复杂的体系中找到人工智能技术的合适位置,以及技术究竟要扮演什么样的角色,其实是一个数据驱动型产品能否成功的核心问题。 + +想要对这个问题有一个比较全面的认识,我们首先需要回答这么一个问题,那就是人工智能技术到底能够给产品带来什么? + +很多朋友可能觉得这个问题不言自明。人工智能技术难道不是解决产品的核心算法难题吗? + +这种看法其实不够全面。人工智能技术给产品带来的其实不仅是一些核心的模型和算法,更重要的是,带给产品一项根本性的能力:数据驱动的决策过程。 + +什么叫作“数据驱动的决策过程”呢?我们还是要从人工智能技术的特点说起。 + +人工智能技术的特点有两个方面:第一,数据驱动。第二,在不确定的因素下智能决策。 + +数据驱动 + +我们先来谈谈第一个方面,数据驱动。这里其实是两个部分,“数据”和“驱动”。 + +一个产品要想利用人工智能,第一步,也是非常重要的一个步骤,就是要有“数据”的概念。什么是“数据”的概念?就是一个产品需要有数据收集的意识,并有数据收集的机制。然后,一个有“数据”的产品需要慢慢建立数据管道,并开始建立数据的检测系统。这些都是人工智能介入的先决条件和基础设施。 + +没有数据,没有流畅的数据链条,是无法构建一个健康的人工智能决策环境的。这一点说起来容易,要真正做到其实需要很扎实的技术基础。很多团队、很多产品最终无法利用人工智能技术的方方面面,一个关键原因就是在数据链条上出了各种问题。 + +有了数据以后,第二个环节就是“驱动”。也就是说,只有数据是不行的,还必须有一个意识,主动利用这些数据来驱动产品的发展,驱动产品方方面面的进化。这个步骤其实不仅是针对产品的决策人员,比如产品经理、项目负责人,也是针对这个产品的所有参与人员的。 + +参与产品的各方面人员,包括工程方面的、设计方面的、市场方面的,大家有没有意识,在有了数据链条之后,通过数据检测、数据分析不断加深对产品的认识,提出更好的想法。当产品遇到各种问题时,大家有没有一个意识,那就是先到数据中去找答案,先去看数据是不是出了什么问题,去理解数据中显示出来的内容。 + +如果说数据驱动的第一部分是有关“硬件”的,是数据链条的技术以及实现,那么第二部分就是有关“软件”的,是项目人员的意识和责任。 + +智能决策 + +当一个产品有了数据驱动的基础以后,下一步,就需要建立“智能决策”的理念。 + +“智能决策”是什么意思? + +要想明白“智能决策”的意义,我们首先要来想一想“非智能决策”是什么样的。 + +很多传统的产品或者不是数据驱动的科技产品都是非智能决策的产物。非智能决策主要是指,不依靠数据,或者依靠很少量的数据,由产品经理或产品负责人人工地进行决策。注意,非智能决策并不一定无法带来好的产品。实际上,在历史的很长时间里,各行各业都是依靠非智能决策在进行演化。 + +非智能决策的一大特点是决策的主观性。通俗地讲,就是决策者依靠自我的认识,主观“拍板”决策关于产品的方方面面。因为没有一个系统的方法论,或者说是没有一整套机制给决策者相应的信息,来帮助决策者完成决策,非智能决策所带来的产品结果往往有很大的偶然性。 + +这种偶然性来自于决策者本人的各种能力,来自于执行者的能力。由于这样的偶然性和主观性,非智能决策的另外一个特点,或者说是结果,就是不可复制性。这是因为决策的方法和方式都不能动态地随着时间、随着数据的变化而变化。 + +非智能决策在什么时候会变得比较困难呢?数据量太大的时候,需要做选择的可能性太多的时候,需要做的选择本身复杂度变高的时候。这些特征也正是新时期下互联网产品或者人工智能产品的特点。因此,在将来非智能决策会越来越多地让位给智能决策。 + +简单来说,智能决策就是产品的决策者依据产品的特点,把一些复杂的、需要依靠大量数据、选择面太广的决策交给人工智能模型和算法,并且建立起一整套体系,利用人工智能手段依靠数据来对整个产品进行快速迭代。 + +如果给这种产品决策找一些典型的类比,就像现代搜索引擎技术,代替了传统的图书馆管理员的角色;现代的电子商务网站为用户推荐各类商品,代替了传统商店里的导购;智能自动驾驶汽车,代替了人类的手工驾驶。 + +智能决策不仅仅是某一项任务的智能化,更重要的是一种理念的提升。也就是说,一旦产品的决策中出现了有需要大量数据、有复杂选项的时候,作为产品的决策者就需要马上意识到,这部分决策任务应该逐渐从人转移到智能模型和算法上,依靠数据驱动流程来加快迭代。这一点是智能决策的关键。 + +我们可以接着之前的电子商务网站的例子来说明智能决策的理念。最开始的时候,也许这个网站只有一个简单的界面,可以根据用户的一些历史信息来推荐商店的商品。这个时候,智能决策的部分还仅限于推荐模块这一部分。紧接着,越来越多的用户开始使用这个网站,于是任务就变得越来越多,也越来越困难。 + +比如,如何设计下一版的网站界面?设计师、前端工程师、用户体验工程师、甚至产品经理都会有自己的看法。这个任务本身就很困难,怎么能让上百万的用户满意?怎么能体现出不同用户的不同选择偏好?怎么能体现出这个产品自身的美学价值?你看,这就是一个需要基于数据的复杂的决策任务。 + +很多团队能够意识到推荐模块需要智能决策,却意识不到“下一版的界面”问题可能也需要智能决策。其实,一旦一个问题变成智能决策问题,我们反而有章可循。 + +比如这个界面问题,所有人的意见、想法或者创意,依据一定的规则,可以用一些人工智能模型和算法来表达。然后通过现代的A/B在线测试手段,可以针对不同的人群展示不同的界面。随后通过数据链条来对测试进行监控和分析。 + +这时候,决策反而变得简单。因为我们不需要为数百万用户拿一个主意,而需要做的是为智能决策提供足够多的创意,然后由智能模型和算法以及实验流程来选择用户喜欢的界面。最后,下一版的网站,不只有一个界面,而是有几十甚至是几百种不同的界面,为百万千万的用户服务。 + +小结 + +我们之前提到了数据驱动,提到了智能决策。那么回到我们今天最开始的主题,作为人工智能工程师或者数据科学家的一个高阶技能,就是能够培养这样一种理念,对产品进行持续分析,检测产品是否遵循了数据驱动的理念,挖掘产品有哪些需求可以进行智能决策。 + +一旦有了这个分析产品的能力以后,我们可以发现人工智能技术将成为产品进化的驱动器和核心机制,而不仅仅是锦上添花的一种噱头。这就是对产品的一种完全革新式的思维。 + +同时,我这里需要指出的是,今天在这里提到的数据驱动也好、智能决策也好,你可以认为这些都不是什么新思想。但是,如何把这些思想真正地应用到产品实践中却是一件非常困难的事情。 + +另外一点需要注意的是,这些理念本身也不是教条,也是一个与时俱进的过程。并不是所有的产品在所有的阶段都需要做数据驱动,都需要做智能决策。我们之前提到了,有很多没有真正数据驱动的产品也依然获得了成功。所以,对产品的分析能力,其实需要你在产品的迭代过程中逐步提升。 + +今天我为你讲了数据科学家和人工智能工程师如何提升产品分析能力。一起来回顾下要点:第一,产品分析的能力其实就是对产品需求的一个分解,而分解之后的产品迭代很大程度上依赖于数据驱动和智能决策。 第二,我详细地阐述了什么是数据驱动,什么是智能决策,究竟怎样可以为产品带来这两项核心能力。 + +最后,给你留一个思考题,什么样的产品不太适合数据驱动和智能决策呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/131数据科学家高阶能力之评估产品.md b/专栏/AI技术内参/131数据科学家高阶能力之评估产品.md new file mode 100644 index 0000000..6c9cc1f --- /dev/null +++ b/专栏/AI技术内参/131数据科学家高阶能力之评估产品.md @@ -0,0 +1,71 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 131 数据科学家高阶能力之评估产品 + “如果你无法衡量它,你就无法改进它。”(If you can’t measure it you can’t improve it.)这是一句你可能会经常听到的话,这句话也被应用到很多不同的场景中。那么,对于人工智能工程师和数据科学家来说,这句话其实是他们工作核心的核心。不管是模型和算法,还是产品迭代,都离不开“指标”和“评估”这两个方面。 + +评估一个产品的好坏,是一项说起来最容易但做起来最困难的工作。任何人,从用户到产品经理,对某一个产品都可能有自己的主观意见。然而对一个产品,特别是要面对成千上万用户的产品来说,依靠主观感觉是很难有一个完整、全面的评价的。同时,有一个成熟的产品评价体系可以成为产品不断迭代的领航标。 + +今天,我就来聊一聊如何评估一个数据驱动型产品,又如何从评估产品的角度来推动产品的迭代。我们需要建立层次化的评估体系,需要一个衡量产品好坏的框架。这个框架要从宏观到微观,能够对你的产品进行全方位的检测,并且这种检测能够帮助你更容易地进行决策。 + +产品的经济收益 + +你可能要问,是的,我们需要评估一个产品,但是如何找到衡量产品的这些指标呢? + +比方说你要做一个社交网络的网站,怎么来制定检测指标呢?首先,你要问自己,我做这个社交网络的最终目的是什么?很明显,一个商业网站的终极目标是赚钱,也就是说,你最终的指标是你网站的经济收益。知道了这一点远远不够,你至少还需要思考两个问题。第一,如何衡量你的经济收益;第二,你能否用经济收益来直接指导你的产品构建。 + +我们先谈谈第一个问题。衡量经济收益看似简单其实不易。从比较大的维度上来说,你可以衡量总收入,你也可以衡量利润,你可以衡量收入的年增长率,还可以衡量季度增长率。从比较具体的维度来说,很多社交网站依靠广告收入,对广告收入的衡量本身就是一个非常复杂的问题。 + +总体来看,衡量经济收益,有两点值得你思考。其一,如何衡量你收入的现状。其二,如何衡量你收入的增长。今天,关于收入的指标我就不展开讨论了。 + +刚才讲的第二个问题就更加复杂。衡量经济收入的指标往往太过宏观,而且衡量起来有难度,因此用经济指标来指导产品的发展是很困难的。我刚才说了一些经济收益指标,比如年收入、年增长率、季度增长率,这些指标的衡量需要至少等待一个季度以上,甚至一年的时间。这些有时间间隔的指标,无法给产品的快速迭代带来很大的指导意义。 + +另一方面,很多产品并不直接产生经济结果。也就是说,经济收益是一个“副产品”。这个时候,如果我们只看经济收益,就无法真正指导我们构建更好的产品。比如,我刚才提到,对于一个社交网站来说,广告收入是一个“副产品”,绝大多数用户来到这个网站的主要目的不是点击广告。因此,仅仅衡量广告有可能让社交网络产品的迭代误入歧途。 + +层次化的评估体系 + +如果单从经济指标无法对产品有全面的指导作用,那怎么才能更加有效地建立评估体系呢?这里就引出下一个话题,那就是多层次的评估体系。 + +接下来,我就由低到高依次从五个层面来说明一下,这个层次化的评估到底是什么意思。 + +最低层次的评估主要围绕着产品的最小组成单元。比如我们刚才用的社交网络的例子,社交网络的各个页面上的模块就可以是最小的被评估的单元。 + +为什么要用这个概念呢?原因是这样的,每一个模块往往是产品的一个逻辑单元,一个最小的承载产品理念的单元。不管是工程团队还是产品团队的运作,基本上都是为这些模块而工作。因此,观察最小单元的效果对产品和工程团队都有直接的指导意义。如果团队目前对这个模块做了一些更改,那么最直接的效果就是这个模块的一些指标会发生变化。这是产品迭代的一个重要组成部分。 + +在这个层次,衡量模块的指标主要是模块的直接效果指标。比如,模块本身的点击率,模块本身的驻留时间,模块上一些其他的用户活跃指标等。这些都是最低层次的模块级别的指标,和产品、工程团队的运作有密切联系。 + +第二个层次的指标是从单个模块上升到一个页面。这个时候,就不仅需要理解单个模块的情况,还需要对整个页面上所有模块产生的功能群进行深入研究。在这个层次,产品功能群的思考可能会涉及到多个产品团队,也可能会出现模块间冲突的情况。 + +比如不少现代搜索引擎的搜索页面往往都有广告模块。长期的经验告诉我们,广告模块的效果和普通搜索模块的效果往往有相反关系的耦合。也就是说,普通搜索模块的效果提高了,广告模块的某些指标反而可能下降。反过来,广告模块的效果提高了,也很有可能是因为普通搜索模块的质量突然变差。因此,在有经验的产品团队面前,广告效果有意想不到的提高,可能并不意味着是件好事情。 + +第二个层次的指标比第一个层次变得复杂起来。不过这个层次的指标依然是可以直接测量的。比如页面的点击率,页面的驻留时间,页面上其他的用户指标等等。这些指标虽然可以直接测量,但是分析时需要对页面上的所有模块有全面了解。 + +前两个层次的指标主要是测量用户在某一个模块或者页面上的表现,核心是看产品的更改对用户的直接影响。而且,第一层次和第二层次的指标非常易于检测。通常情况下,如果页面和模块发生了什么问题,这些指标就能很快地反映出页面的情况。然后通过排查,我们就能快速发现问题,这也就是通常所说的,这些指标都比较“敏感”。 + +“敏感”指标的第一个好处是,这些指标具有非常强的指导意义,能够帮助产品团队快速认识问题并提出解决方案。“敏感”指标的第二个好处无疑就是,产品团队的绝大多数改动都能够比较容易地反应到这些指标上。因此,这是一个容易建立的、良性循环的指标体系。当然,仅有这两个层次的指标还是远远不够的,我们可以看到,这两个层次的指标和一个产品最终目标的衡量还有一定距离。 + +第三个层次的指标,就从某一个模块、某一个页面上升到了用户这个层级,主要是检测用户在一个会话(Session)中的表现。这个时候,用户往往在一个会话中,和多个模块、多个页面进行非常复杂的互动。在这个层次上,我们已经很难仅凭观测就能琢磨出用户在这个会话中是否真正感觉满意。这个时候,我们往往就需要建立用户模型(User Model),以及通过一些统计的方法建模,从而实现真正理解用户行为的目的。 + +举一个例子,如果我们构建一个电子商务网站,在一个用户会话中检测用户是否购买了一些商品,这些商品的总价值又是多少。这个监测指标有时候被称作GMV(Gross Merchandise Value),也就是通常所说的网站成交金额。GMV还是比较容易计算的,就是计算每个会话之后用户购买的商品价值,然后对所有会话的结果求和。但是要真正理解用户会话行为对GMV的影响,就是一个比较困难的任务了。 + +我们可以想象,即便是同一个用户,是否在一个会话中购买商品,这是一个非常复杂的决策过程。在一个会话中,用户可能会接触到搜索页面,可能会接触到各种推荐的模块,也可能会跳转某个商品的页面,还可能会跳转首页。并且,每个用户的用户轨迹不同,接触各个页面和模块的流程也是不一样的。可以肯定地说,任何一个流程中的每一个环节,都有可能对用户是否购买商品、以及购买多少价钱的商品有或多或少的影响。而如何来测量和建模这样的影响,就是第三层次指标的核心挑战。 + +第四个层次的指标是从一个用户会话上升到多个用户会话。这个时候,我们关心的是用户较长时间的体验问题。对于一些复杂的任务,用户需要多个会话才能完成。套用我们刚才举的电商GMV的例子,很多用户购买比较贵重或者是一些有特定需求的商品(比如婚纱)时,往往无法在一个用户会话中完成决策。 + +那么这种情况下,检测指标的复杂性又进一步提高。比如说,用户可能先在电商网站上搜索了关于婚纱的信息,但在这一次会话中并没有完成交易。用户之后可能又从其他途径了解了一些更多的信息,然后又重新到电商网站开始一个新的会话。在这个会话中,用户也许重点比较了好几个婚纱,然后决定购买其中一件。这个例子还是一个比较简单的情况了。 + +第三和第四层次的指标有两个特点。第一,相对于第一、第二层次的指标而言,这些指标已经不那么“敏感”了。也就是说,仅改变某一个模块甚至某一个页面,是很难在短时间内改变第三,特别是第四层次的指标的。从上面的例子可以看出,用户的购买行为是非常复杂的,仅仅因为提高了某个推荐模块,是不是就能让用户多买贵的东西,答案是不确定的。第二个特点就是,第三和第四层次的指标依然可以用传统的A/B测试来进行观测,只不过需要很仔细地设计实验。 + +第五个层次的指标就是用户和产品的长期指标。我们最开始提到的经济指标其实就是第五层次的指标。类似的指标还包括月活跃用户、年度活跃用户等等。这些指标有两个特点。第一,这些指标往往是产品的终极目标,一般极其难以撼动,特别是对于成熟的产品而言。第二个特点是,这些指标往往无法通过A/B测试进行衡量。也就是说,我们往往无法通过常规的实验就能够观测到这些长期指标的变化,这也是为什么这些指标被称为“长期”的原因。 + +小结 + +今天我为你讲了数据科学家和人工智能工程师如何评估产品的能力,这属于比较高阶的分析问题的能力。一起来回顾下要点:第一,我们如何来认识衡量产品经济收益这件事情。第二,我们很详细地阐述了什么是五个层次的评估体系,以及这个评估体系每个层次的特点。 + +最后,给你留一个思考题,如果第五个层次无法直接通过A/B测试进行观测,那我们如何在平时进行A/B测试的时候,就能确保是在优化第五个层次的指标,也就是我们产品的终极目标呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/132数据科学家高阶能力之如何系统提升产品性能.md b/专栏/AI技术内参/132数据科学家高阶能力之如何系统提升产品性能.md new file mode 100644 index 0000000..09d07e4 --- /dev/null +++ b/专栏/AI技术内参/132数据科学家高阶能力之如何系统提升产品性能.md @@ -0,0 +1,77 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 132 数据科学家高阶能力之如何系统提升产品性能 + 人工智能工程师和数据科学家的一个核心任务,就是依靠人工智能、机器学习这样的工具来帮助产品不断提升品质,吸引更多用户,以实现既定的长期目标。这里有一个关键点,就是我们如何开发出一套方法论,让提升产品性能的过程可以“有章可循”,并成为一个系统性的流程。 + +初入门槛的工程师和数据科学家,容易把精力和眼光都集中在具体的算法模型上面。这固然是短期内的重要工作,但是,如何能够持续不断地为产品提供前进的动力,才是让人工智能技术有别于之前多次技术浪潮的根本因素。今天,我就来为你剖析一下,持续不断地、系统性提升产品性能的一些关键步骤。 + +优化长期目标 + +一个产品如果需要利用数据驱动的人工智能技术来提升品质,第一件事情一定不是专注于部署某一个模型或者算法。或者说,如果已经急迫地上线了第一个简单的算法,接下来最重要的事情一定是停下来,看一看我们是否已经弄明白,这个产品到底需要“优化”什么目标,是否有一个指标检测体系,来指导我们的优化过程。 + +我们利用人工智能技术手段一定要优化产品的长期目标,这是系统性提升产品性能的一个关键。乍一听这是一句废话,难道算法和模型还有不优化产品长期目标的时候?你心中一定有这样的疑问。其实,确定你所制定的技术方案一定能够优化产品的长期目标,是一件比较困难的事情。 + +设想一下这些例子。比如你为一个在线视频的网站设计推荐系统,你根据很多教科书上的推荐系统案例,优化某一个视频的评分(Rating),这是在优化这个产品的长期目标吗? + +比如,你为一个电子商务网站设计搜索系统,你根据传统的信息检索以及搜索的案例,优化查询关键词和产品的相关度(Relevance),这是在优化这个产品的长期目标吗? + +再比如,你为一个新闻网站设计新闻流系统,你根据产品的基本特点,希望提高新闻的点击率,是在优化这个产品的长期目标吗? + +针对上面这些问题,答案或许都是——不确定。或者说,你正在优化的可能会、也可能不会对这个产品的长期目标有影响,这就需要我们建立一个系统性的方法论,来引导我们回答这个问题。 + +因此,知道我们是否在优化产品的长期目标需要一个前提,那就是我们必须要建立产品的指标检测体系。在专栏的上一期内容里,我们已经介绍了五个层次的产品评估体系。对于提升产品来说,建立这些层次是关键的一步。然而,要想真正系统性地提升产品,还有一个至关重要的步骤,那就是打通这五个层次,建立一个立体的产品提升流程,从而实现优化产品的长期目标。 + +我们先来简单回顾一下这五个层次的指标。从最高层次说起,第五层次的指标主要是产品的长期指标,比如季度利润的增长、年利润、月活跃人数等。这些指标和产品的最终目的息息相关,却非常难直接衡量,也就是这些指标对产品的一般变化不是很敏感。 + +第四层次的指标主要是用户在多个会话的交互表现。第三层次的指标是指用户在单一会话的交互表现。这两个层次的指标比较容易在A/B测试的范畴内测量。这些指标能比较宏观地检测一个产品的高维度表现,了解用户一般是如何和这个产品进行交互的。 + +第二层次是页面层级的指标,这个时候,我们观测到的基本上已经是产品团队可以直接控制的因素了。第一个层次的指标是模块级别的指标,这是产品团队直接运作的结果。 + +这五个层次的指标从宏观到微观,构成了一个检测的体系。如果我们要优化产品的长期目标,也就是说第五层次的指标,而我们能够直接掌握的产品决策,往往只能带来第一、第二层次指标的显著变化,这两者之间的差距如何来弥补呢? + +我们前面举了好几个例子,比如视频推荐、产品搜索、新闻流产品等等。之前提到的技术方案大多数直接针对第一或者第二层次的指标,这些方案是不是能够对第五层次的指标奏效,其实是一个不确定的问题。 + +那么,问题的核心就变成了,如何在只能运作第一或者第二层次指标的情况下,对第三、第四甚至五层次的指标有间接的控制和影响呢? + +建立层级指标之间的联系 + +上面我们提到了,要想持续地提高产品,最重要的就是要一直优化产品的长期目标。但是,如果我们只能控制产品的短期指标,如何才能优化产品的长期目标呢? + +答案其实很简单,就是我们必须在所有层级的指标之间建立联系。这些联系因产品而异,但核心思想却是一致的。 + +回到之前的一个例子,那就是构建一个视频推荐系统。如果我们希望直接优化用户对视频的评分,就必需回答一个问题,能够给用户推荐打高分的视频,和产品的长期指标之间有什么联系?假设这里产品的长期指标是月活跃用户数目,那么问题就是,给用户推荐打高分的视频,和月活跃用户数目之间的联系是什么? + +注意,这里说的建立联系不仅是逻辑联系,而且也是数据链联系。也就是说,我们不仅需要尽可能地在逻辑上理清,为什么推荐高分视频有利于帮助月活跃用户数的增长,还需要用数据来为这样的观点提供证据,这才是最重要的一个环节。 + +简单说来,我们可以这么做。首先,从所有的用户群体中找到用户样本。然后,通过数据来研究,用户的活跃程度和被推荐的视频评分之间的关系。从最高的维度上说,那就是建立一个回归问题,比如用户的月活跃程度作为响应变量,被推荐视频的评分用作一个特征变量。 + +当然,这个时候我们还可以引入其他的重要变量,比如性别、年龄组、地区等等,用来排除这些因素的干扰。直接研究这两者之间的关系一般来说是一个有难度的工作。比如你很可能并没有那么全面的数据,也有可能这两个变量都需要做一些变形,还可能负例太多(也就是说有大量的用户并没有因为评分的高低而改变他们的行为)等等。 + +如何具体地建立这个模型我今天先不讲,但有一点是可以肯定的,那就是这样做一个分析,可以很好地帮助你了解优化对象和长期目标之间的联系。 + +我们不仅需要了解第一层级和第五层级指标之间的关系,每一个层级之间的关系也是需要去研究的,这样才能更加全面地了解自己的产品。这一步就是把之前分散的五个层级打通的重要步骤,也就是如何建立一个立体体系的关键。 + +那么,如果出现了这样的情况,长期运作的第一层级指标和自己的长期目标没有联系,该怎么办呢?第一,祝贺你,你进入了真实的产品运作环境。从很多产品的长期运作经验来看,很多传统的指标特别是教科书上的指标,都和真实的长期指标有很弱的关系,甚至根本没有太大的联系。第二,这会帮你早日抛弃错误的优化目标,转向更加正确的道路。 + +寻找一个正确的第一、第二层级的指标,让这个指标和最后第五层次的长期目标之间有正向联系,就是能够持续不断地推动产品前进的一个重要动力。因为这个因素,产品团队才能够不断地试错,但不会失去大方向。 + +然而,说起来貌似很容易的事情,做起来其实是很困难的。我刚才说了,很可能有一些指标,看上去有一定的意义,但并不一定和长期目标有任何正相关。怎么才能找到恰当的指标呢? + +一个简单的方法是尽可能多地记录指标,然后根据后期的实验数据和分析来确定指标之间的联系。回到刚才那个例子,就是说,我们对于一个视频网站,可以记录很多第一、第二层级的指标,有可能有上百上千个。然后我们根据数据,从这么些指标中,和最终的长期目标做回归分析,建立一些备选集。 + +这里需要数据、也需要经验。我们还可能发现,最终的长期目标和好多第一或者第二层级的指标都有关系,这也是很正常的。这就说明,优化长期目标是一件复杂的事情,很多短期目标和长期目标并不是只有简单的线性关系。 + +当确定好了第一、第二层级的指标后,那就可以开始用机器学习的手段,把指标当做算法模型的目标函数,从而重新设计算法,使其能够开始优化新的指标。这一步也需要很高的机器学习技巧和丰富的经验,因为并不是所有的指标,都能很容易地转换成机器学习可以优化的对象。 + +小结 + +今天我为你讲了,人工智能工程师和数据科学家的一个高阶能力技巧,如何才能不断提升产品的品质。一起来回顾下要点:第一,我们要专注产品的长期目标。第二,一定要建立产品短期目标和长期目标之间的关系,从而能够在直接优化短期目标的同时间接优化长期目标。 + +最后,给你留一个思考题,请你认真想一想,对于我们上面举例的推荐视频网站来说,有哪些第一或者第二层级的指标和用户的活跃程度有关呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/133职场话题:当数据科学家遇见产品团队.md b/专栏/AI技术内参/133职场话题:当数据科学家遇见产品团队.md new file mode 100644 index 0000000..02f845f --- /dev/null +++ b/专栏/AI技术内参/133职场话题:当数据科学家遇见产品团队.md @@ -0,0 +1,66 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 133 职场话题:当数据科学家遇见产品团队 + 我们在之前的分享中已经聊了数据科学家应该具备的基本能力,主要是希望从机器学习、统计知识、系统知识等方面给你一个完整的基本知识框架。然后我们聊了一些数据科学家的高阶能力,主要是能够通过分析产品、建立产品的评估体系以及对产品的长短期目标进行建模来系统性地提升产品性能。 + +今天我们就把话题从数据科学家和人工智能算法工程师自身的修养和提升,扩展到一个更大的范围,那就是在职场中必然会遇到的发展和协作问题,我们来聊聊数据科学家和产品团队的关系这个话题。 + +作为数据科学家或者算法专家,不知道你有没有遇到过这样的场景: + + +你正在开发最新的推荐算法,产品经理找到你说,希望给在北京的女性用户推荐一款红色的高跟鞋; +你正在研究如何使用最新的深度学习技术来提高搜索结果,产品的设计师告诉你,产品团队现在决定在近期做一个推广,需要在搜索结果上方展示一个很大的条幅,使得整个搜索页面往下移动了不少; +你正在给公司的广告系统设计新的模型,产品的营销人员告诉你,这周需要展示给用户的折扣信息,广告位从以前的6个变成了3个。 +相信类似的场景你应该不陌生。这也就是我们今天要探讨的问题,数据科学家如何在一个更加广阔的环境中协作。 + + +数据科学家和产品团队的关系会出问题吗? + +数据科学家和产品团队究竟有着怎样的关系呢?先理清楚这个问题,我们才能去探讨这样的关系会有怎样的互相依赖以及可能存在的问题。 + +在很多数据驱动的互联网公司,产品团队(Product Team)和工程团队(Engineering Team)往往是实施一个具体产品的两个关键的力量。 + +产品团队通常情况下是产品经理领军,拥有各类不同的产品负责人、设计师、UI设计师等人员对整个产品的设计、理念进行把关和掌控。 + +工程团队则主要是工程经理领军,各类架构师、算法工程师、前端工程师、数据库工程师等人员对整个产品的工程技术甚至运行维护进行把关和掌控。 + +在这个产品的图谱里,数据科学家所组成的“人工智能”团队有可能是独立于产品团队和工程团队的第三方力量,也可能是属于工程团队的一部分。这两种情况其实也略有不同。我们在这里就简化讨论一种情况,那就是数据科学家所在的团队和产品团队并不完全是一个团队的情况。 + +从大的格局来说,不管是什么团队,产品人员也好,工程师人员也好,都是为了产品的进步和提高出谋划策的,都是希望产品能够越做越好的。这一点毋容置疑。 + +然而,由于不同的团队分工以及各类人员不同的专业背景,在如何能够让产品做得越来越好这一点上可能就会存在不同的意见,甚至是严重分歧。设计人员可能认为产品下一步最大的可能性来自于更加简洁明亮的设计风格;产品营销人员可能认为用户应该会对下一场促销更感兴趣;工程师可能认为下一步需要整个团队重写一个重要框架代码,让页面渲染速度得到提升从而使得用户体验得到改善;数据科学家或者算法工程师正在考虑开发一个更加复杂的机器学习模型,来提升产品的智能响应;产品经理也许在想着如何做一个全新的手机界面,来体现一种新的用户生活体验。 + +这些想法也许都对产品有益,甚至都能让产品或多或少有所进步。但是,我们经常看到的是,不同背景的人员都对自己的专业很自信,有时候甚至是“过度”自信,从而只相信自己所处岗位所能发挥的作用。从数据科学家这个角度来说,因为大数据、机器学习以及其他人工智能技术手段的不断进步,可能就会导致我们过分强调算法和模型对产品带来的影响,而忽略了产品是一个非常有机的整体。 + +在这样的情况下,作为数据科学家或者人工智能工程师,往往会遇到我们今天开始提及的情景。一方面你在做着自己认为能让产品有最大收益的事情,而另一方面,整个产品有机整体的各个部分都在运作着,有可能会“破坏”掉你所做过的或者正在做的努力。如果这时候数据科学家以一种算法第一的心态看待产品,就会发现自己的工作非常难以展开,也会和产品的其他部门产生矛盾。 + +另外一种情形是,产品经理或者产品部门对机器学习或人工智能抱有不切实际的幻想,认为这是解决一切问题的灵丹妙药。于是所有和产品进步相关的想法都希望通过人工智能来得以实现,这无疑给数据科学家和工程师增加了很大的压力。 + +然而不管处在哪种场景中,我们都可以看到,数据科学作为一个技术工程范畴和其所从事的人,数据科学家,无疑都是在一个复杂的环境中对产品起着作用。要想充分发挥出数据科学的作用,我们必须深入理解数据科学家和产品团队的关系,从而打造一个有机的产品团队生态体,使得处于各个职能的人员都能够在一个和谐竞争的状态下对产品有所贡献。 + +如何把握数据科学家和产品团队的关系 + +既然我们聊到了数据科学家或者人工智能工程师和产品团队之间的微妙关系,那么,有没有什么方法能够让这种关系变得更加明朗,更有利于数据科学发挥出更大的作用呢? + +首先有一点很重要,也是整个团队需要先明确的是,数据科学、人工智能在现阶段来说,依然是大多数产品的“奢侈品”。什么意思呢?也就是说,没有很多基础设施的建设,没有一些最基本的产品功能,没有最简单的数据链路,就不可能应用最基本的数据科学,也不可能对产品进行持续提高。正因为此,数据科学家其实应该和产品经理建立好关系,从而能够从一开始就心系整个产品的发展,能够有一颗包容的心,为产品能够快速达到这个最基本的状态出谋划策,同时也要让整个产品时刻都处于这个状态。 + +这里面涉及到一个“教育”和“再教育”的问题。不是所有的产品人员都对人工智能有所了解,也不是所有的产品人员对数据链条的概念都有所耳闻。比方说,产品的数据是通过前端的一段JavaScript代码进行数据传输的,而这段代码可能和某一个产品的界面设计有紧耦合。当设计人员“突然”对现在的设计进行了更改,满心希望这样的更改可以改进产品,哪知道这也许反而“破坏”了这段收集数据的代码,从而使得数据链条断裂,而机器学习的某些代码可能就无法正常运行,或者模型接收到的是垃圾数据。在传统的观念里,一位设计师,可能很难理解为什么自己的工作会和机器学习紧密结合。所以,这就需要数据科学家和各个岗位的人员去交流、去沟通,让更多的人能够理解数据产品的涵义。 + +其次,数据科学和人工智能让产品成为一个有机整体。我们一定要去理解产品效果的复杂性和组合性。比方说,在很多互联网产品中,通过经验我们经常能够发现,产品外观设计的改变,常常能够带来比纯算法改变好得多的效果提升,而很多营销手段又常常能够几倍地提高用户对产品的转化率,也使得产品的效果得以提升。当然了,这并不是说,夸大任何一方面就能够让产品有更大的提高。实际上,产品的最优情况往往是各个方面的一个复杂的协调平衡状态。因此,理解数据科学在整个大环境中的位置就十分重要。 + +最后还有一个可以去做的,那就是看如何利用人工智能和数据科学去帮助产品团队的其他人员,比如能否帮助设计师和前端找到更好的创意,能否帮助产品经理找到更好的产品迭代方法等等,让人工智能和数据科学融入到整个产品完整的图谱中,要比提高单个算法更有意义。 + +小结 + +今天我为你讲了人工智能工程师和数据科学家所面临的一个重要的职场话题,那就是如何把握和产品部门的其他人员的关系。 + +一起来回顾下要点:第一,我们简要剖析了数据科学家和产品团队之间可能产生问题的原因和一些经典的情况。第二,我们分析了要去更好地推动这个关系,有哪些需要注意的地方,有哪些可以做的事情。 + +最后,给你留一个思考题,如果营销人员告诉你一个他们的方案,但这会影响你所负责的产品算法的呈现,这个时候你会怎么做呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/134职场话题:数据科学家应聘要具备哪些能力?.md b/专栏/AI技术内参/134职场话题:数据科学家应聘要具备哪些能力?.md new file mode 100644 index 0000000..a0b4b9d --- /dev/null +++ b/专栏/AI技术内参/134职场话题:数据科学家应聘要具备哪些能力?.md @@ -0,0 +1,99 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 134 职场话题:数据科学家应聘要具备哪些能力? + 周一,我们探讨了在公司内部,数据科学家和产品团队的其他职能人员在协作中都会遇到哪些问题,以及如何看待数据科学家或者人工智能工程师所做的算法性工作在一个产品发展中的位置。 + +那么,今天我们稍微换一个方向,来讨论数据科学家和算法工程师在应聘方面的问题。一起来看看,作为数据科学家,在面试一家公司时,究竟应该怎么准备,有哪些信息是需要了解的。 + +希望今天的内容对正在思考进入这个行业的年轻学者、工程师有所帮助,从大的方向上为你的应聘提供一些可借鉴的内容。 + +数据科学家应聘的“硬”实力 + +对于数据科学家或者人工智能工程师来说,最核心的竞争力无疑是他们对人工智能、机器学习等技术的知识积累以及融会贯通的能力。 + +我们之前的一系列分享中已经提到了这些“硬”实力的大范畴,这里我做一个简单的归纳。 + + +首先,我们需要理解和掌握一些机器学习的基本概念和理论。 + + +第一个重点无疑就是监督学习。 + +什么是监督学习呢?监督学习就是指我们通过外部的响应变量(Response Variable)来指导模型学习我们关心的任务从而达到我们需要的目的这一过程。监督学习中需要彻底掌握三个最基础的模型,包括线性回归(Linear Regression)、对数几率回归(Logistic Regression)和决策树(Decision Trees)。 + +怎么理解我说的“彻底掌握”呢?这里的彻底掌握有三层含义。 + +第一,需要了解这些模型的数学含义,能够理解这些模型的假设和解法。比如,线性回归或者对数几率回归的目标函数是什么;写好了目标函数之后,如何求解最优解的过程。对于这些核心模型,必须能够做到完全没有差错地理解。 + +第二,需要了解什么场景下使用这些模型是最合适的,以及怎样把一个实际问题转化成为这些模型的应用,如果不能直接转换还有什么差距。 + +第三,能不能写实际的代码或者伪代码来描述这些模型的算法,真正达到对这些算法的掌握。 + +监督学习当然不限于这三个算法,但是这三个算法是绝大多数机器学习任务在工业界应用的起点,也是学习其他算法模型的支点,可以按照这个思路去了解更多的算法。在面试中,能够对这些基本算法的理解有扎实的基本功,这一点很重要。 + +了解机器学习的第二个重点就是无监督学习。 + +无监督学习并没有明显的响应变量,其核心往往是希望发现数据内部潜在的结构和规律,从而为我们进行下一步决断提供参考。 + +从面试角度来说,“K均值算法”往往是考察数据科学家整个无监督学习能力的一个核心点。因此,对于这个算法有必要认真学习,做到真正的、彻底的理解。 + +怎么学习呢?和前面我们提到的监督学习一样,也需要从编程实现和算法本身两个方面入手对K均值进行把握。在掌握了K均值之后,还可以进一步去了解一些基于概率模型的聚类方法,扩宽视野,比如“高斯混合模型”(Gaussian Mixture Model)。 + + +其次,虽然机器学习和统计学习有不少的重合部分,但是对于合格的数据科学家和人工智能工程师来说,一些机器学习方向不太容易覆盖到的统计题目也是需要掌握的。 + + +第一,我们必须去理解和掌握一些核心的概率分布,包括离散分布和连续分布。这里的重点不仅仅是能够理解概念,而且是能够使用这些概率分布去描述一个真实的场景,并且能够去对这个场景进行抽象建模。 + +第二,那就是要理解假设检验。这往往是被数据科学家和算法工程师彻底遗忘的一个内容。我们要熟悉假设检验的基本设定和它们背后的假设,清楚这些假设在什么情况下可以使用,如果假设被违背了的话,又需要做哪些工作去弥补。 + +第三,那就是去学习和理解因果推断(Casual Inference)。这虽然不是经典的统计内容,但是近年来受到越来越多的关注。很多学者和工程师正在利用因果推断来研究机器学习模型所得结果的原因。 + + +再次,还有一个很重要的“硬”技能,就是要对系统有一个基本了解。 + + +第一,就是具备最基本的编程能力,对数据结构和基础算法有一定的掌握。编程语言上,近年来,Python可以说受到了诸多数据相关从业人员的青睐。因为其语言的自身特点,相对于其他语言而言,比如C++或者Java,Python对于从业人员来说是降低了学习和掌握的难度。但另一方面,我们也要意识到,大多数人工智能产品是一个复杂的产品链路。整个链路上通常是需要对多个语言环境都有所了解的。因此,掌握Python,再学习一两个其他的语言,这时候选择Java或者C++,是十分必要的。另外,很多公司都采用大数据环境,比如Hadoop、Spark等来对数据进行整合和挖掘,了解这些技术对于应聘者来常常说是一个让用人单位觉得不错的“加分项”。 + +第二,就是对于搭建一个人工智能系统(比如搜索系统、人脸识别系统、图像检索系统、推荐系统等)有最基本的认识。机器学习算法能够真正应用到现实的产品中去,必须要依靠一个完整的系统链路,这里面有数据链路的设计、整体系统的架构、甚至前后端的衔接等多方面的知识。考察候选人这方面的能力是查看候选人能否把算法落地的一个最简单的方式。因此,从我们准备面试的角度来说,这部分的内容往往就是初学者需要花更多时间了解和进阶的地方。 + +数据科学家应聘的“软”实力 + +前面我们聊了数据科学家应聘的“硬”技能,下面,我们再来看看候选人还需要注意和培养哪些“软”技能。 + +数据科学家的第一“软”技能就是如何把一个业务需求转化成机器学习设置的“翻译”能力。 + +什么意思呢?和纯理论学习的情况有所不同,大多数真实的业务场景都是非常复杂的。当产品经理提到一个产品构思的时候,当设计人员想到一个业务创新的时候,没有人能够告诉你,作为一个数据科学家而言,这个问题是监督学习的问题还是无监督学习问题,这个问题是可以转换成一个分类问题还是一个回归问题。有时候,你会发现好像几条路都走得通。因此,如何能够从逻辑上,从这些不同的设置所依赖的假设上来对业务场景进行分析,就成了数据科学家必不可少的一个核心能力。 + +分析业务场景这个“软”技能的确非常依赖工作经验。这里不仅仅是一个机器学习问题的“翻译”,还需要对整个系统搭建有所了解,因为真正合适的场景“翻译”往往是机器学习的问题设置和系统局限性的一个平衡和结合。举一个例子,一个推荐系统需要在百毫秒级给一个用户进行推荐,那么相应的方案就必然有一个计算复杂度的限制。 + +因此,场景的“翻译”其实是考察数据科学家和人工智能工程师的一个非常重要的步骤,也是看候选人是否真正能够学以致用的有效手段。 + +说到这里,你是不是会有疑问:如果我没有相关的从业经验,那如何来锻炼这种“翻译”能力呢? + +其实,现在丰富的互联网产品已经为我们提供了一个无形的平台。当你在现实中看到一个真实产品的时候,比如京东的产品搜索、科大讯飞的语音识别系统等等,你设想一下,如果你是设计者,如果你是需要实现这个产品功能的数据科学家,你会怎么做? + +实际上,很多面试问题,都是面试官直接询问你对某一个现成产品的设计思路,比如谷歌的面试官可能会询问你如何设计一个搜索查询关键字拼写检查组件。这个方法一方面是帮助你“开脑洞”,另一方面也是一种非常好的思维锻炼。 + +另外一个很重要的“软”技能就是数据科学家的沟通表达能力。 + +这可能会让有一些人感到意外,因为大家也许认为数据科学家和人工智能工程师完全是技术岗位,并不需要与人打交道。其实,这个理解是片面的。就像刚才提到的,数据科学家的一个重要职责就是把现实的业务场景“翻译”成机器学习的设置,那么在这个过程中,会和业务人员、其他工程师、科学家进行高频的沟通和交流。如何把你的思路、方案清晰地表达给同事和团队成员是非常重要的职责。 + +实际上,数据科学家不仅在公司内部承载着的这样的沟通任务,我们往往还需要在社区中做演讲、参与讲座等活动,成为社区中的一份子,都离不开沟通表达能力的磨练。 + +如何锻炼沟通表达能力呢?这里,我给初学者一个简单而实用的方法,那就是用一两句话来总结你的方案。你尝试用一小段话,但是不夹带任何专业术语,把你的方案说给不懂机器学习的人听。这个训练方法可以让你反复思考,直到找到一个最简洁有力的表达。 + +小结 + +今天我为你讲了人工智能工程师和数据科学家的一个重要的职场话题,那就是作为数据科学家应聘时需具备的“硬”实力和“软”实力。 + +一起来回顾下要点:第一,我们讨论了机器学习、统计知识和系统这三大“硬”实力。第二,我们分析了场景翻译和沟通能力这两个“软”实力。 + +最后,给你留一个思考题,当下深度学习框架大行其道,那么对于应聘来说,你觉得了解和掌握各种深度学习框架会让你更有优势吗? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/135职场话题:聊聊数据科学家的职场规划.md b/专栏/AI技术内参/135职场话题:聊聊数据科学家的职场规划.md new file mode 100644 index 0000000..0bca1ef --- /dev/null +++ b/专栏/AI技术内参/135职场话题:聊聊数据科学家的职场规划.md @@ -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)。 + +除了从人工智能团队管理岗位入手以外,数据科学家还可以挑战和人工智能有关的一些管理岗位,比如数据,或者有时候叫大数据部门。这些部门和人工智能部门经常紧密合作,所以数据科学家也算是对这些部门耳濡目染,相对来说有着比较清晰的认识。 + +毋容置疑,数据科学家从纯技术岗位到管理岗位的转换过程中,肯定会面临不少困难。对于有志转岗的数据科学家来说,他们往往在纯技术岗位上工作得比较优秀,一些管理的机会自然出现,于是也就顺理成章地转了过去。然而,对于这些优秀的纯技术人员来说,比如“中级”或者“高级”数据科学家,真正的挑战在于,如何能够去领导一个团队去完成一个使命。一些优秀的数据科学家因为自身条件优异,往往存在大包大揽的情况,希望靠自己的能力做出比整个团队还要好的成绩,反而在管理岗上无法施展应有的水平。其实,如何做一个优秀的人工智能技术管理者,这还是一个非常有新意和挑战的话题,篇幅有限,今天就不展开了。 + +数据科学家的“跨界发展” + +除了我们刚才说的在纯技术岗位的发展以及往管理职位发展以外,数据科学家其实还有一些横向发展的机会。 + +比如,最“无缝”发展的就是在工程团队或者数据分析类团队之间进行转换。因为数据科学家的工作性质,这两类团队的工作或多或少都已经包含在了数据科学家的日常工作中了。因此,数据科学家可以比较自然地转换到这些团队中。当然,这里还是需要对一些技能进行加强培训。 + +另外,数据科学家其实比较适合转移到产品经理岗位。在“中级数据科学家”之后,这些技术人员需要对业务、对整个产品有比较深入的理解,包括需求、数据、工程技术等,才能对一个产品提出比较合适和成熟的解决方案。另外,数据科学家还需要不断提升产品的质量水平,这里面其实就有不少产品经理的角色。因此,数据科学家算是具备成为一个产品经理的一些条件。不过,我们这里要指出的是,数据科学家的整个背景训练主要是以纯技术为主,特别是人工智能算法,因此转换到产品经理的时候,可能往往过分强调算法的力量,而忽视整个产品的其他方面。所以,即便是一个成熟的数据科学家依然需要一段时间的培养和培训,才能够转换到产品经理的角色。 + +小结 + +今天我为你讲了人工智能工程师和数据科学家的职场规划问题。一起来回顾下要点:第一,我们简单介绍了最为自然的一条发展途径,走纯技术的路子,数据科学家可以有怎样的一条道路向前发展。第二,我们分析了从技术岗位到管理岗位的一个转换,数据科学家又有什么优势。第三,我们简单讲了从数据科学家到其他类型职位一个转换的问题。 + +最后,给你留一个思考题,你有没有什么方法,可以知道自己比较适合什么样的职业发展规划呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/136如何组建一个数据科学团队?.md b/专栏/AI技术内参/136如何组建一个数据科学团队?.md new file mode 100644 index 0000000..ffd846c --- /dev/null +++ b/专栏/AI技术内参/136如何组建一个数据科学团队?.md @@ -0,0 +1,10 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 136 如何组建一个数据科学团队? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/137数据科学团队养成:电话面试指南.md b/专栏/AI技术内参/137数据科学团队养成:电话面试指南.md new file mode 100644 index 0000000..e58689c --- /dev/null +++ b/专栏/AI技术内参/137数据科学团队养成:电话面试指南.md @@ -0,0 +1,97 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 137 数据科学团队养成:电话面试指南 + 眼下,数据科学或者人工智能团队已经成了很多数据驱动公司的标准配置团队,数据科学家或者人工智能工程师也成为了最“性感”的职业。不少公司都在想办法建立或者扩展自己的数据科学团队。那么,对于一个公司来说,究竟需要什么样的数据科学团队呢?这就成了很多公司在发展过程中都会遇到的棘手的问题。 + +我们在之前的一篇分享里已经剖析过,作为一个工程团队的负责人,你该如何招聘自己的数据科学家团队。在那篇分享里,我们探讨了目前人才市场上大致有两类数据科学家,一类偏数据分析,一类偏算法模型。并且我们详细探聊了聊这两类数据科学家所需的技能和在不同团队(比如大团队和小团队)中起到的作用。 + +今天,我们来聊一聊组建数据科学家团队所必不可少的一个步骤:电话面试。 + +筛选简历 + +在电话面试之前,有一个步骤是必不可少的,那就是筛选简历。因为人工智能和数据科学家的职业背景的原因,我来分享一下如何筛选具有博士学历,特别是计算机专业相关毕业生的简历。筛选简历的过程需要很细心,对于普通的博士毕业生,我们会快速看以下两个方面的信息。 + +第一,候选人是否有高水平的论文发表。关于论文发表,首先需要看的是论文档次,也就是论文是否发表在高质量的会议上或者高水平的期刊上。对于计算机专业的博士生来说,会议一般比期刊更重要。其次,我们也要看候选人的论文是专注一个问题或者一个小领域还是很多领域都有涉猎。同时,对于这些论文,要关注候选人是第几作者。然后,我们需要关注的是论文发表频率,看论文工作是否都是一年做出来的。最后,我们可以去看一看这些论文的引用数。一般来说,博士刚毕业不会有很高的论文引用量,但也不乏水平比较高的候选人,论文会有惊人的引用量。 + +第二,我们需要看一看候选人是否有工业界实习经历,是研究实习还是工程实习。这里面,我们可能关注的是实习的公司。而且,我们可以关注是否是同一家公司还是多个公司。如果是研究实习的话,我们还需要去看一看候选人是否有相应的论文发表。 + +在看了这两个因素之后,我们心中对于这个候选人就有一个很基本的认识。在需要高标准的情况下,一个博士毕业生需要有3-4篇第一作者的高水平论文发表(在毕业的时候,引用数在70-100左右),然后有1-2次工业界实习经验。 + +除了这两个硬指标以外,我们还会关注下面这些内容: + + +简历里是否有一些信息不完整的部分。比如有一些明显断档的经历,没有本科学校,没有说明博士生导师; +会什么编程语言和开发工具。是否只熟悉Matlab或者R,是否有开源项目贡献; +是否已经有审稿经验; +是否已经有组织会议的经验。 + + +所有这些因素都没有明显问题之后,我们已经定位到了比较靠谱的候选人(通常,只有少数人能够通过上面这轮简历筛选)。我们可以根据实际情况来调整在筛选简历这里的标准线从而让候选人能够和我们直接交流证明自己的实力。 + +这里再说几个比较细的准则: + + +博士生的论文中,非第一作者的一般不算数; +已经发表的会议论文和同一内容的期刊文章算一篇; +可以有非第一档次会议或者期刊的论文,但没有第一档次就很难说明问题; +如果有单一作者的论文,是一个比较大的问题,电话面试的时候一定要问清楚原因; +课程项目原则上也不算数(注意,这是对博士毕业生而言); +简历是LaTex生成还是Word; +毕业学校和GPA,一般不是很侧重要考虑的问题。 + + +再说几个对于已经有工作经验的候选人的简历筛选要素: + + +如果有教职经验或者博士后经验,原则上是一个大问题,需要电话面试问清楚; +一两年左右频繁换公司是一个大问题,需要电话面试问清楚。 + + +这里要多说一句的是,上面这些标准是对计算机相关专业比较适用的准则。而对于数学、应用数学、统计、物理等专业的人来说,可能有些标准需要重新设定(比如发表论文的标准需要降低)。总之,这里说的是一些比较大的方向,不过在把握了这些原则之后,我们就可以安排少量的候选人电话面试了。 + +这里我们简单说一下对于硕士阶段的候选人的简历筛选。一般来说,硕士和博士有不同的培养目标,因此上面所说的很多标准和原则对硕士毕业生并没有完全的指导意义。对于硕士毕业生来说,公司实习经验是很重要的。不排除一些优秀的硕士毕业生已经有论文发表,因此这方面也可以降低一些标准来衡量。对于硕士毕业生来说,学科项目可以作为一些参考,不过因为大多数学科项目都没有真正的应用性,我们只能从一个侧面了解这个候选人可能具备的一些技能。 + +电话面试 + +筛简历的过程之后,就是电话面试了。电话面试的目的是要验证这个候选人是不是像简历里所说的那样有相应的经历。当然,有一些公司在电话面试的时候也会考察候选人解决问题的能力,这个内容也会经常出现在电话面试的安排中。对于科学家的职位,我们一般需要1-3轮电话面试,来了解下面这些信息: + + +了解候选人简历上的基本信息,如果对简历上的内容有疑点,需要在这个阶段问清楚; +考察候选人是否具备基本的专业知识,并对相关领域有一定的见解,考察候选人是否有其他领域的知识; +考察候选人是否有基本的专业相关的编写代码能力; +初步感知候选人的表达能力。 + + +在询问候选人简历信息的时候,以下这些内容是需要弄明白的: + + +对于候选人是第一作者的论文,候选人是否能够很清晰地说出这些论文所解决的问题及解决思路。在进一步的沟通里,候选人是否能够讲清楚模型细节甚至是公式细节。候选人能否把实验的目的、数据、比较算法讲清楚。当然,这需要面试官提前做好准备。同时,询问候选人其他作者在这篇论文中的贡献; +对于候选人是非第一作者的论文,询问候选人在这个工作中起到了什么作用。看候选人是否诚实可信,也可以看出候选人的学术道德水平; +对于单一作者的文章,需要候选人解释为什么这个工作没有合作人,博士生导师为什么不是合作者,这个论文的研究时间如何而来; +对于有博士后经验或者教职经验的候选人,要询问候选人是否了解工业界研究和学术界研究的区别,如果以后有机会,是否还考虑学术界教职; +对于有工作经验的候选人,要询问候选人反复换工作的原因,询问清楚候选人在项目里的具体贡献,候选人的职业规划,看职业规划和简历经历是否相吻合。对于在某一个公司待了很长时间没有升职的候选人,也需要询问一下为何在原公司里没有其他机会。 + + +在考察候选人专业知识的时候,需要弄明白以下这些内容: + + +对于某一专业最基础的一些概念和知识,候选人是否能够清晰地讲解出来。这一条其实很多人很难做到,不少人能够做复杂的工作,却往往在最基础的内容上含混不清。而在一些跨领域的工作中,基础知识往往是一个科学家所能够依赖的,提供解决方案的最初的工具。所以,基础很重要; +候选人是否能诚实地说明自己懂什么,不懂什么。在广泛的领域里,科学家应该有足够的自信说自己的专长是什么,自己的局限在哪里; +候选人是否对跨领域知识一窍不通,还是略有知晓,界限在哪里; +在考察编程水平方面,虽然很多公司已经有比较完备的方案考察软件工程师,但这些题目和考察目的其实不太适合科学家,这需要公司专门针对科学家制定一些考察题目。 + + +在上述考察候选人各个方面的过程中,一个贯穿始终的主题就是要看候选人是不是能和面试人员进行有效的沟通。当然,也要考虑到,有人可能不太适应电话面试,而在面对面的交流时则毫无问题。 + +小结 + +今天我们分析了组建一个数据科学或者人工智能工程师团队,你需要招聘什么样的数据科学家。我们重点讲了讲如何筛选博士阶段候选人的简历以及电话面试的问题,我们是从招聘的角度来讲这个问题,那么从应聘的角度来看,也希望能给你一些启发和借鉴。 + +最后,给你留一个思考题,如果一个候选人并没有什么论文,但是有多年的企业经验,如何来衡量这样的候选人呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/138数据科学团队养成:Onsite面试面面观.md b/专栏/AI技术内参/138数据科学团队养成:Onsite面试面面观.md new file mode 100644 index 0000000..08ec670 --- /dev/null +++ b/专栏/AI技术内参/138数据科学团队养成:Onsite面试面面观.md @@ -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面试之后,如何来决定是否录用这个候选人呢?是不是需要所有的人都赞同? 如果不是所有人都赞同,怎么综合意见做出最后的决定呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/139成为香饽饽的数据科学家,如何衡量他们的工作呢?.md b/专栏/AI技术内参/139成为香饽饽的数据科学家,如何衡量他们的工作呢?.md new file mode 100644 index 0000000..98fce97 --- /dev/null +++ b/专栏/AI技术内参/139成为香饽饽的数据科学家,如何衡量他们的工作呢?.md @@ -0,0 +1,75 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 139 成为香饽饽的数据科学家,如何衡量他们的工作呢? + 本周我们聊了在构建一个数据科学家团队时,从筛选简历入手到电话面试,再到Onsite面试这一系列的流程。从无到有,建立一个数据科学家或者人工智能团队的确是一件煞费苦心的事情。 + +那么今天,我们来聊一聊数据科学家团队管理的下一个重要的步骤,那就是如何来衡量数据科学家或者人工智能工程师在团队中的业绩,有时候也被称为是绩效评定(Performance Review)。绩效评定的种种规则必须在团队建立的初期就明确,否则就会出现一些不定因素,对于招聘、培训以及留住人才都有着不可估量的影响。 + +数据科学家的价值 + +如何对数据科学家团队进行绩效评定呢?这个问题的核心其实是要回答,数据科学家或者人工智能工程师究竟应该(以及实际)为你的公司或者组织带来什么核心价值?只有梳理清楚这个核心问题,才能真正建立起衡量数据科学家团队的价值体系,从而达到为公司和组织赋能的目的。 + +那么,数据科学家团队或者人工智能工程师团队应该为企业或组织带来什么样的价值呢? + +对于这类相对来说比较抽象的问题,其实很难有一个标准答案。每一个组织或者公司都有自己一套衡量价值的方式。这里我们并不追求一个统一的答案,而是希望能够为这个问题提供一些参考。 + +关于这个问题,在我们前面的一些分享中,其实已经提到过,那就是数据科学团队最重要的一部分价值来自于为企业或者团队引入数据驱动的决策过程,这是数据科学团队或者人工智能团队的一个核心价值。很多企业或者组织,在没有这些团队之前是无法真正做到数据驱动、持续决策的。 + +也就是说,“数据驱动”和“持续决策”这两点可以看作是数据科学家团队的主要价值体现。那么,怎么衡量数据科学家团队这个问题,也就变成了如何来衡量这些团队在围绕这两方面的工作中做得怎么样。 + +注意,我在这里其实并没有明确提及数据科学团队和人工智能团队对产品直接带来的效果,比如点击率升高了多少,用户存留增加了多少,什么产品又上线了等等。主要是出于以下两点考虑。 + +第一,每一个公司、每一个组织甚至是每一个产品在这些价值上都有不一样的需求,没有一个统一的模式。 + +第二,如果数据科学团队为组织建立起了数据驱动的持续决策过程,那么很多产品级别的性能提高或者核心功能的实现就会成为顺理成章、水到渠成的结果。相反,如果仅仅强调某一个产品性能的提升或者某一个单点技术的突破,很可能无法真正建立有效的人工智能团队,并且团队的“战斗力”也无法真正得到最大程度的发挥。 + +数据科学家团队的评价误区 + +刚才我们从一个比较大的概念上做了一个讨论,看数据科学家团队的价值应该如何来评价。但在实际操作中,往往存在两种比较明显的误区。 + +第一种误区是“唯技术论”。那就是觉得人工智能团队能够快速帮助公司、组织甚至是项目很快打开突破口,希望人工智能技术能够给公司业务带来突破性的发展,从而对人工智能团队有过高的预期。 + +在这种思路的指导下,在前期往往可能有一个比较大的热情,能够招聘到不少的人才或者能够拉起团队开始一些不错的项目。但很快,由于急功近利的心态和不切合实际的需求,常常又让人工智能团队身陷绝境。而这个时候,最容易产生的一种情绪是走另外一个极端,那就是从“唯技术论”到“技术无用论”。在这样的背景下,产品遇到的任何困难、任何失败都有可能归因到人工智能团队上。 + +第二种误区是对人工智能团队或者数据科学家团队心存怀疑,本质上觉得这些团队都无法真正能够帮助到团队。因此从一开始就不信任这些团队,蹑手蹑脚,在政策和发展上限制这些团队。由于这种不信任,使得人工智能团队不能真正发挥作用,因此催生了进一步的不信任,恶性循环,最终得出这样的结论,“人工智能是花瓶,没有用”。 + +这两种误区的核心其实是一种行为,那就是忽略了人工智能团队需要一个“生态环境”。什么生态环境?比如产品部门、数据部门以及其他的工程部门,必须协调发展。 + +绝大多数数据科学团队和人工智能团队都需要依赖一个比较强有力的数据部门的支持。同时,产品上,如果人工智能的算法或者模型并不能和产品有机结合,那无论如何,都是无法真正帮助产品,为产品赋能的。我们之前也提过,其实在很多时候,人工智能都是锦上添花的部分,而产品的整体呈现才是最为重要、做需要认真思考的问题。 + +数据科学家的评定 + +有了前面这些思路作为基础,我们现在来看一看数据科学家的评定的问题。 + +第一,对于数据科学家或者人工智能工程师来说,我们需要看他们是否对于建立、完善、和推动“数据驱动的持续决策”这一长期任务有不间断的贡献。 + +具体来说,那就是数据科学家或者工程师是不是在帮助建立和推动数据驱动的链路,是不是在思考如何能够更快、更好地解决数据的问题。这里的数据包括获取数据、整理数据、分析数据以及利用数据的整个流程。我们的数据科学家或者人工智能工程师应该持续在这几个方面有所贡献。 + +你可能会有疑问,这不是数据工程师的责任吗?没错,这确实是数据工程师的职责。但是,如果我们的科学家并不清楚数据的情况,并不了解如何进一步推动数据链路的进步,那产品线将来肯定会出问题。 + +同理,数据科学家也需要在帮助“持续决策”上不断做出贡献。这里主要指的是实验的平台,以及围绕着实验平台进行决策的工具,比如图表,比如更加复杂的假设检验工具,比如因果推断的工具等等。数据科学家和人工智能工程师必须要具备这样的敏感度。 + +第二,那就是考察数据科学家和人工智能工程师本身的职责和专长,针对某一个产品能否提出切实可行的机器学习解决方案,能否和产品部门以及其他工程部门一起,让解决方案落地。 + +这一点检验的就是解决方案的落地能力。当然,这里不仅仅依赖于解决方案本身,还依赖于其他的因素,比如数据,比如产品。这里面有一部分是第一点的内容,主要是评定数据科学家或者人工智能工程师对于跨部门合作以及共同构建一个人工智能生态系统的能力。这一条的重点是评定在一个较小范围内落地解决方案的能力。 + +第三,那就是数据科学家和人工智能工程师必须能够不断提高自我修养,能够持续学习不断进步。 + +这一点,可能是在所有的工程团队和产品团队里面都比较突出的。虽然所有的团队都需要不断进步,然而人工智能这个领域实在是变化太快。因此,在这个方面,人工智能相关的工作都必须要有比较不一样的评价标准。 + +在一些企业中,数据科学家的持续学习主要体现为参加会议、发表论文、参与学术讨论、发表开源软件等形式。如果在一些初创公司或者是暂时没有这些能力的组织中,我们也要思考如何来评价员工是不是在积极地持续学习。 + +小结 + +今天我们分析了组建一个数据科学家或人工智能团队后,你怎样来认识这个团队的价值,怎么来评价员工的工作。 + +简单地做个总结:第一,我们讲了数据科学家团队对于推动“数据驱动持续决策”这一目标的作用;第二,我们梳理了面对人工智能团队上可能存在的两个误区;第三,我们简单聊了聊如何在大的方面来评定数据科学家的工作。希望这些内容能给你带来一些启发和借鉴。 + +最后,给你留一个思考题,需不需要把人工智能团队的工作和企业的KPI挂钩?如果需要,该怎么挂;如果你觉得不需要,又是什么理由呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/140人工智能领域知识体系更新周期只有5~6年,数据科学家如何培养?.md b/专栏/AI技术内参/140人工智能领域知识体系更新周期只有5~6年,数据科学家如何培养?.md new file mode 100644 index 0000000..8296ce5 --- /dev/null +++ b/专栏/AI技术内参/140人工智能领域知识体系更新周期只有5~6年,数据科学家如何培养?.md @@ -0,0 +1,55 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 140 人工智能领域知识体系更新周期只有5~6年,数据科学家如何培养? + 在上一期的分享里,我们聊了数据科学家团队管理的一个重要步骤,那就是如何来衡量数据科学家或者人工智能工程师在团队中的业绩,我们重点讲了如何看待数据科学家团队的价值和数据科学家评定的一些误区。 + +今天,我们来聊另一个数据科学家团队的高级话题,那就是数据科学家的培养的问题。 + +为什么要培养数据科学家 + +为什么要培养数据科学家?这个问题看上似乎是显而易见的,但实际上,如果不了解数据科学家或者人工智能团队的一个重要性质,你很可能无法很好地运营这样一个团队。究竟是什么性质这么重要呢?那就是数据科学家或者人工智能工程师有强烈的持续学习和不断更新自我的需要,这是数据科学家培养的一个非常重要的理念。 + +那么,数据科学家为什么需要不断学习?简单来说,是因为数据科学家所需要的技能和知识处在一个快速变化的环境中。如果数据科学家不能对这些快速变化的技能和知识加以学习,就很可能被迅速淘汰。 + +我们这里所说的技能有知识性的技能也有实际的工具性质的技能。 + +从知识性的来看,机器学习和人工智能技术每隔一段时间就会有一些重要的发展,了解和掌握这些更新的技术需要一定的门槛。因此,持续学习是为了能够迈过这些门槛。从过去的经验来看,每一次这样的重要发展所带来的新门槛都不可避免地让一些工程师和数据科学家落伍。 + +比如,在过去不到20年的时间里,机器学习就经历了“支持向量机”(Support Vector Machine)、“概率图模型”(Probabilistic Graphical Model)以及“深度学习”(Deep Learning)这三股大的思潮。也就是平均5~6年,数据科学家和人工智能工程师就需要面对一些完全不同的建模思想和工具。更不要说,在这些大的思潮之下,每年出现的新模型也是层数不穷。这还没有提及应用的领域,比如推荐系统、搜索、广告系统、计算机视觉、自然语言处理等等。如果不能在这些领域知识的快速变化中取得主动,很可能就无法胜任未来的工作。 + +在实际工具技能层面则更是日新月异。比如近日如火如荼的深度学习框架TensorFlow仅有3年多的历史,五六年前还根本就不存在。而如今借助机器学习迅速崛起的编程语言Python在五六年前也没有近日的火爆。而在支持向量机年代非常受欢迎的LibSVM和SVMLight工具,可能今天已经很少听到。知识框架的变化相比,工具技能层面的变化更加琐碎,更加细节,这也为人工智能科学家提出了更高的挑战。 + +那么,在知识结构和工具技能都快速变化的情况下,团队的负责人就需要针对这样的特点进行有远见的管理安排。 + +第一,需要为学习这些技能和知识提供时间。任何数据科学家现有的知识体系都不能保证永不过时。事实上,就像我们刚才提到的,现在每5~6年就有一个比较大的知识体系更新,这个更新速度在未来还有可能会更快。那么,花费了非常大的代价招聘来的整个团队就有可能面临着短时间内过时的危机,所以,要能够利用平时的时间,把持续学习的内容安排进团队的日常运作中,可以有效降低团队遭遇知识鸿沟(Gap)的风险。 + +第二,需要团队里的资深数据科学家能够战略性的挖掘接下来有可能进入主流视野的技术,从而早作准备。尽管这可能显得有一点过于超前,但是对于大多数的互联网或者高科技企业来说,技术实力上的领先无疑是最强大的生产力。因此,在日常的安排中,如果在团队人手富裕的情况下,能够有一些数据科学家专注比较“面向未来”的技术,从而为今后的技术积累以及整个团队的“技术纵深”打下基础。 + +其实,谷歌的DeepMind或者Facebook的人工智能研究院都有着这样的性质。这些机构研发的技术未必能够马上应用到这些公司的主流产品上,但是这些技术有可能让这些公司或者团队能够在未来3~5年内有一个比较舒适的纵深,这些公司的其他团队需要做的,就是沿着这个纵深前进。 + +除了从一个团队以及数据科学家本身的不断更新换代的这个思路来看待培训以外,还有一个方面,那就是绝大多数公司和团队的数据科学家都不可能是在招聘的时候就已经是最一流的数据科学家或者人工智能工程师了。 + +你往往只能招聘到博士毕业生、硕士毕业生。他们的知识面和技能在刚进入公司的时候还非常稚嫩。对于一些博士毕业生而言,以前做的一些研究都是在一个非常窄的领域,还没有形成一个完整的体系。对于一些硕士毕业生而言,很可能完全没有真正接触过现实的问题,之前的学习主要是课堂项目。因此,对于团队中的年轻成员,学习和培养就成为了一个非常必要的环节,让他们能够真正融入到工业级人工智能解决方案的研究和部署中。 + +全方位的培养计划 + +刚才我们简单聊了聊从技术层面培养数据科学家的一个思路。其实,我们之前反复强调的一个思想就是,人工智能团队并不是单独存在的。一个能够真正运转并且能够为公司或者组织带来价值的人工智能团队一定是整个组织中的一个有机部分,并且能够为公司和组织带来数据驱动的持续决策的能力。因此,在这样的一个目标下,数据科学家的培养不应该仅仅是技术层面上的,还应该是更加全方位的。 + +如果说简单一点,对于一个数据科学家的全方位培养中,很重要的一条,那就是团队协作的能力,特别是跨团队的组织、协调和沟通的能力。我们在之前的分享中已经提到过,数据科学家的工作需要和数据工程、设计师、前端工程师、后端工程师、产品经理等角色的人员打交道。而在这个过程中,任何一个环节的沟通出了问题,都有可能造成项目的失败。因此,有没有聆听的能力、有没有表达的能力、有没有了解需求的能力、有没有分清主次的能力等等,这些软实力就成为了数据科学家培养计划中的一个重点。 + +从过程上来说,一个新入职的数据科学家的核心目标还是从技术上慢慢从学生或者初级工程师逐渐成熟起来。最开始,年轻的数据科学家应该“多听”、“多看”、“多想”,但“少发表意见”。从和资深的员工一起参与项目开始,逐渐学习怎么和其他的部门一起工作,甚至从熟悉其他部门的词汇、语言入手。 + +最后,我想说的是,除了团队之间的沟通能力以外,数据科学家上台演讲的能力也很重要。能够把自己的解决方案说清楚,能够用通俗的语言来解释复杂的问题,能够不使用数学符号依然可以把解决方案的主要思想梳理明白并且能够传递出足够多的信息,这些都是数据科学家进阶必不可少的技能。 + +小结 + +今天我们分析了数据科学家或者人工智能工程师团队的培养问题。进行一个简单的总结:第一,我们讲了数据科学家为什么需要培养。在主要的技术技能培养的道路上,有什么样的情况;第二,我们详细梳理了数据科学家全方位培养中的重点是什么。希望这些内容能给你一些启发和借鉴。 + +最后,给你留一个思考题,怎么能够把数据科学家的持续学习纳入绩效考核呢?或者到底应不应该纳入考核? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/141数据科学家团队组织架构:水平还是垂直,这是个问题.md b/专栏/AI技术内参/141数据科学家团队组织架构:水平还是垂直,这是个问题.md new file mode 100644 index 0000000..86166a6 --- /dev/null +++ b/专栏/AI技术内参/141数据科学家团队组织架构:水平还是垂直,这是个问题.md @@ -0,0 +1,59 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 141 数据科学家团队组织架构:水平还是垂直,这是个问题 + 周一我们聊了数据科学家培养的话题,我们分析了数据科学家培养的重要性,要从技术的提高和整体的团队协作几个角度来进行培养。 + +今天我们来讨论数据科学家团队高级话题中的最后一个,也是非常现实的一个问题,那就是对于一个组织来说,究竟应该形成怎样的组织架构呢?是选择一个集中式的数据科学家团队或者叫水平式的组织架构?还是成立一个分散式的、每个产品部门都有数据科学家的垂直式的组织架构呢?对于很多公司或组织来说,在构建一个数据科学家团队的时候都会遇到这个棘手的问题。 + +水平架构的数据科学家团队 + +什么是水平架构的数据科学家团队呢?简单来说,那就是一个公司或者组织的所有数据科学家都在一个团队中,有一个统一架构管理,比如一个数据科学家总监或者一个首席科学家。这个团队负责和公司所有的其他团队合作,提供数据科学以及人工智能解决方案。 + +例如大家熟悉的微软研究院、谷歌DeepMind、雅虎研究院、IBM研究院、Facebook人工智能研究院都是水平架构团队的卓越代表。 + +水平架构的团队有哪些好处呢? + +第一,便于管理。这些团队有统一的招聘标准、业绩评价标准、员工晋升标准和内部的运作模式。这些管理体系的建立是需要时间和经验的。统一的管理常常意味着高效,大家对这些管理上的细节有统一的认识,因此整个团队对内管理上能够更加方便快捷。对于希望快速发展壮大的一些组织来说,这一点尤为重要。 + +关于这一点,很多管理者其实并没有完全理解,难免出现因多个相似团队存在而造成一些不必要的内耗。比如说,如果一个组织内部在没有协调的情况下出现了两个人工智能团队,而这两个团队各自有一套招聘方法、人员评定的方法以及项目管理的模式。如果没有足够重视,那很快就会演变为激烈的摩擦,从而无法让团队真正有效地运行。在很多公司的发展中都有这样的例子,因为有多个类似的人工智能团队而无法集中资源。 + +第二,品牌效益。刚才提到的类似微软研究院、雅虎研究院以及IBM研究院都是卓越的品牌。一个组织一旦形成了品牌,那就可以相对比较容易地收获一些品牌红利,比如说招聘的便利。很多年轻的研究员或者硕博毕业生的首选都还是进入知名的机构或者团队。甚至有很多时候,年轻的工程师或者科学家都希望追随某一位在这些组织获得成功的学者或者前辈,于是就考虑加入这些团队。还有一些优势比如在社区里的话语权效应,典型的例子就是谷歌的TensorFlow框架,这个框架就是凭借着谷歌以及DeepMind的品牌优势,从而能够在众多深度学习框架中后发制人,迅猛发展。 + +第三,团队对外协作变得更加清晰简单。这里主要是说和公司其他部门之间的协作会变得更加明了。试想公司现在有一个新的产品部门,希望能够利用人工智能的一些技术来构建自己的产品,那么如果公司内部有三个不同的人工智能团队,有几套差不多的系统框架,对于这个新的产品部门来说,该如何选择合作呢?这势必又会引发我们刚才提到的团队之间恶性竞争的问题。 + +垂直架构的数据科学家团队 + +刚才我们主要分析了水平架构团队的一些优势,中间提到了垂直架构团队的一些问题。那是不是垂直架构的团队就没有任何优势了呢? + +凡事肯定都有正反两面。 + +垂直架构的团队往往是从不同产品线的需求中慢慢演变而来的。举个例子,在类似Facebook、谷歌、雅虎等公司的内部,搜索、推荐以及广告部门往往由于各自的需求不同,在历史的进程中,都分别组建了自己的具有人工智能性质的团队。这些团队在发展过中,也都形成了一些自己的软件框架以及成熟的算法模型。由于这些产品线细节的复杂性,对于很大的公司来说,这些差异性所带来的麻烦就需要不同的团队来支持。同时,因为有不同的团队来做人工智能的研发,从整个公司的层面来看,公司会更加“坚韧”,也更容易有不同的创新点。 + +如果仅有一个集中的水平团队来支持公司所有的产品线,随着公司产品的增多,这个水平团队的任务将会越来越重。并且,这个水平团队将不可避免地开始选择那些这个团队认为更加重要的功能加以支持,这必然就难以满足所有团队的需求,正所谓“众口难调”。这个情况下,也就给了其他团队一定的“借口”开始发展自己的人工智能团队。这其实也是很多公司里,不同人工智能团队发展的一个轨迹。在某种程度上,这种情况也是“解放”了这个水平团队所承担的重负,让每个产品团队最终能够有比较完整的自主权来发展。 + +垂直发展模式是大多数公司发展到一定程度所经历的现实阶段,也是超大型公司规避技术风险的一种方式,即不同的团队之间互为“备份”,整个公司的发展存在合适的内部竞争。 + +混合组织架构 + +其实你可以想到,在水平结构和垂直架构之间,存在着一种混合的模式。这种混合的模式往往是希望能够汲取水平架构和垂直架构两者之间的优点,从而能够更大地发挥效益。 + +举例来说,谷歌存在DeepMind这样的水平架构人工智能团队,但同时各个产品组也有不少人工智能研发人员。这样,垂直的架构分布在各个产品组,能够保证产品线的正常运作以及不断推陈出新,又能够保证有DeepMind这样的相对比较独立的机构拥有较高辨识度,形成一个比较完整的实体可以进行更多的创新和尝试,而且也能够有一个清晰的品牌吸引人才。类似的情况还有Facebook的人工智能研究院和其他工程产品线内的人工智能团队的关系。简言之,就是希望用一个较小的、更加核心、更加精英化的水平团队以及各个产品线中的垂直团队一起相互作用。 + +当然,从管理的层面而言,这样两者都需要的混合模式对公司的领导智慧和协调能力都是极大的考验。事实上,你可以发现,在上述的区分中,产品团队中的人工智能研发人员和这个更加精英的水平架构的人工智能团队之间可能会产生不信任的摩擦。举例来说,理想状态下,这个核心的团队应该做一些更加超前的创新和思考,而产品的团队做一些更加“接地气”的项目。但是,有的时候产品团队中也会有工程师或者科学家的能力其实不错,也能做超前的研究和创新。慢慢地,就会让人觉得这个核心的研发团队的名声和实际在公司内部的影响力“名不副实”。 + +在混合模式下,我们现在还不能说业界已经有一个成熟的模型可以供大家参考了。很多在尝试的公司也在水平和垂直的架构中互相摇摆。 + +小结 + +今天我们分析了如果要组建一个数据科学家或者人工智能团队,你需要建立的是水平架构的组织还是垂直架构的组织。 + +我们来进行一个简单的总结:第一,我们讲了什么是水平架构的团队组织,这样的组织有什么优势;第二,我们详细梳理了一下垂直架构组织的由来,并且帮助你理解这种组织架构的现实原因和在大型公司中的好处;第三,我们简单谈了谈对混合架构的摸索。希望这些内容能给你一些启发和借鉴。 + +最后,给你留一个思考题,对于一个初创公司来说,如果希望建立人工智能团队,应该选取什么样的架构呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/142数据科学家必备套路之一:搜索套路.md b/专栏/AI技术内参/142数据科学家必备套路之一:搜索套路.md new file mode 100644 index 0000000..1ade840 --- /dev/null +++ b/专栏/AI技术内参/142数据科学家必备套路之一:搜索套路.md @@ -0,0 +1,83 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 142 数据科学家必备套路之一:搜索套路 + 到目前为止,我们已经完整地介绍了搜索、推荐和广告的主流技术,为你呈现了这些产品技术方向的整个生态系统。在这些系列的分享里,我们重点介绍了这些技术方向的基本模型,然后花了不少篇幅讲如何评测模型的好坏,包括如何进行线下评测以及线上评测。同时,我们从传统的经典模型讲到最近几年利用深度学习对这些技术方向的提升,帮助你理顺了这些技术发展的脉络。 + +尽管我们已经在之前的文章中分享了这些技术的方方面面,但是对于很多经验较少的数据科学家或者人工智能工程师来说,依然会感到无法得心应手地把这些模型和知识给应用到真实场景中。 + +其实,出现这种情况一方面是个人经验积累的原因,毕竟从初学者到能够熟练应用各种模型工具应对实际产品的需要,是一个长时间磨炼的结果;然而另一方面,也是因为搜索、推荐和广告这些产品场景其实是有一些套路,在没有接触到这些套路的时候往往会觉得不得要领,而在慢慢熟悉了这些套路之后,进步也就会慢慢加快。 + +那么,在接下来的三篇文章里,我就有针对性地来分享在这三个领域里的一些常见套路。今天,我们首先从搜索产品套路说起。 + +多轮打分套路 + +我们前面已经介绍过多轮打分的系统架构了。当我们想要构建任何一个搜索引擎时,都应该立刻想到多轮打分这个架构,这是一个基本套路。 + +我们先来回顾一下多轮打分最基本的模式:针对一个搜索关键词,我们首先从索引中找到第一批,也是数目相对比较多的相关文档,这就是第一轮打分;然后,我们再根据一个相对比较复杂的模型,对剩余的文档进行打分排序,这是第二轮打分。 + +很多时候,两轮打分就已经能够满足需求了,可以直接给用户返回结果集。当然了,我们也常常加入第三轮打分,这个时候经常是实现一个商业逻辑层,可以针对最后的搜索结构加上一些商业规则。 + +多轮打分这个套路之所以关键,是因为它其实影响了一系列的技术决定。 + +首先,只有第一轮是直接作用在索引上的。这一轮往往可以并行化,并且不需要太多考虑文档之间的顺序。 + +我来举个例子说明。在一个大型搜索引擎的架构下,假设我们有一亿个文档,每五百万个文档存放在一个数据节点上。如果我们有一个关键词是“人工智能”,那么就要到这20个数据节点上分别查找包含这个关键词的文档。当我们把所有的文档汇集起来以后,排序取出前1000个文档,这个时候就进入第二轮。最简单的第二轮打分是在一个计算节点上进行的,而且这个时候我们只针对1000个文档进行打分,对计算时间的要求就大幅度降低了。那么,在这样的情况下,第二轮打分使用的模型可以是比较复杂的模型。后面每一轮打分所针对的文档往往是越来越少,因此模型的复杂度可以越来越高。 + +从解决问题的角度上来说,第一轮在索引上的打分,是要解决“召回”(Recall)的问题。这一步有可能是从非常多甚至是成千上万的文档中返回几百到几千不等的文档,因此一旦一些文档没有在这一轮中被返回,就无法在后面的轮数中被重新筛选出来。所以,我们要理清这么一个思路,那就是如果你认定系统有“召回”的问题,也就是说,本该搜出来的东西,完全搜索不出来,那肯定是在第一轮打分就出了问题。第二轮以后的打分解决的就是“精度”(Precision)的问题。 + +同时,我们可以看到,什么时候解决“召回”问题,什么时候解决“精度”问题,这其实是取决于具体的业务场景。 + +对于“精度”非常看重的搜索场景,比如说网页的信息类关键词,例如“特朗普”、“比尔盖茨”,人们往往只关注前10位,甚至是前3位的搜索结果。那么很明显,我们可以先有一个比较简单的第一轮架构,比如就是文字匹配,而把功夫都下在第二轮以后的打分上。 + +而对于“召回”比较看重场景,比如说法律文档搜索,那必须要做好的就是第一轮的打分,这个时候可能需要采用简单的文字直接匹配和语义的模糊匹配。 + +高频和长尾的套路 + +刚开始接触搜索产品的朋友往往会有一个困惑,那就是不知道该如何提升一个搜索产品,有一种无从下手的感觉。那么,对于搜索产品的提高有没有什么套路可言呢? + +一个比较基本的套路,就是把搜索关键词按照某种频率或者是流量分为“高频关键词”和“长尾关键词”,从而为这两类不同的关键词设计排序算法。 + +为什么要把关键词按照频率分开呢?我来介绍一下最主要的思路。 + +对于很多搜索网站来说,一些高频的关键词往往占据了相对来说比较大的流量,而很多长尾的关键词,也就是仅仅出现过几次的关键词则并没有太多人搜。因此,如果我们先解决了高频的关键词,也就解决了大部分的搜索问题,从而可以把精力留下来慢慢解决低频的长尾关键词。 + +而实际上,高频关键词因为有足够多的数据量,反而往往比较容易得以解决,而低频关键词,因为数据的匮乏,往往需要更多的精力和时间。所以说,从投资回报的角度来看,我们也需要做区分,首先来解决高频的搜索关键词。 + +刚才我们提到了高频关键词的一个特点,就是有足够多的用户数据。那么,这里有一种非常简单的思路,或者说是在没有较好模型的时候可以首先使用的一种方法,那就是每天记录下高频关键词文档的用户点击数据。然后我们可以直接按照点击率,或者是文档的转换率排序,并且把这个排序存在某种存储中。当用户在这一天搜索这些高频关键词的时候,我们甚至可以直接从存储中调出事先算好的排序结果。 + +更加极端的做法,就是手工对高频词进行更频繁的标注,这种做法往往也是非常有效的。例如我们刚才说的“特朗普”的例子,我们可以手工标注好前10名的结果,然后存下来。只需要每几天更新一下这个标注,我们甚至不需要使用任何模型就可以提供非常高质量的搜索结果。 + +当然,使用这种方法,显然无法对几百万的搜索关键词都这么一一处理。不过,我们这里针对的主要是高频关键词,所以,即便是针对最高频的1千个关键词进行手工标注,也会对整体的搜索效果有非常明显的提升。 + +相反,长尾的关键词往往需要花比较多的心思。对于长尾来说,我们还可以细分。比如对于有一定数据量的关键词,我们可以尝试针对这些关键词单独训练一个模型。之所以要单独训练一个模型,原因也很简单,如果针对所有的关键词只有一个模型的话,高频的关键词因为流量大,往往就会让模型偏重于去解释高频的信息,而忽略了这些中低频的关键词的作用。 + +因此,先把高频词单独处理了,然后就可以针对依然可以训练的中频关键词再选取一个单独的模型。而针对非常低频的关键词,我们往往需要借助其他的方法来挖掘这些关键词的信息,例如利用同类的其他关键词的数据,或者利用外界的知识库、知识图谱的信息等。 + +三大模型套路 + +除了分开处理高频和长尾关键词以外,搜索模型的提升还有一个非常简单的“三大模型套路”。 + +我们构建一个搜索引擎,从最原始的简单系统,慢慢到比较复杂的以至于到后期非常复杂的系统,从模型上来说要跨越三个台阶。在这里我们主要是针对第二轮的打分系统来进行讨论。 + +第一个台阶是使用线性模型。当我们设置好了最基本的第一轮打分系统以后,首先要做好的是能够利用线性模型对文档进行排序。这一步其实往往是搜索系统从“无人工智能”到“有人工智能”的第一步。这一步对搜索效果性能的提升可能会有10%~20%。 + +第二个台阶是使用配对法线性模型。一般来说,这一步搜索效果会有2%~5%的提升。 + +第三个台阶是使用树模型,特别是GBDT模型。这一步搜效果的提升和第二步相似,约有2%~5%的提升。然而,要从第二个台阶到达这个步骤,模型的特性可能会发生不小的变化。这一个台阶可以算是一个比较困难的台阶。 + +从工程研发的角度来说,可以采用一年一个台阶的做法。在已经穷尽了当前台阶所有可能用到的特性以后,再进入到下一个台阶,也就是说要尽可能地“榨干”当前台阶模型效果的“养分”。 + +总结 + +今天我为你介绍了做搜索产品的几个套路。 + +一起来回顾下要点:第一,我们回顾和总结了多轮打分系统的架构套路;第二,我们介绍了区分高频关键词和长尾关键词的套路;第三,我们简单讨论了“三大模型套路”,跨越三个台阶,逐步提升搜索效果。 + +最后,给你留一个思考题,为什么不鼓励直接采用深度学习模型呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/143数据科学家必备套路之二:推荐套路.md b/专栏/AI技术内参/143数据科学家必备套路之二:推荐套路.md new file mode 100644 index 0000000..08683a7 --- /dev/null +++ b/专栏/AI技术内参/143数据科学家必备套路之二:推荐套路.md @@ -0,0 +1,75 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 143 数据科学家必备套路之二:推荐套路 + 在上一期的分享里我们讨论了做搜索产品的套路,给你介绍了多轮打分、高频和长尾以及三大模型套路。你有没有感受到这些高于某一个具体模型的套路的重要性呢? + +今天,我们来看看推荐的一些套路。 + +多轮打分套路 + +上一篇我们提到,想要构建一个搜索引擎,应该立刻想到基于多轮打分的架构,有这个意识就是一个基本套路。 + +其实这个套路对于推荐,也是适用的。 + +把推荐问题构建成一个多轮打分的“类搜索”问题,其实是推荐在工业界应用的一个非常重要的套路。 + +这个思路的好处是把搜索和推荐问题给归一化了。也就是说,我们可以依靠同样一套软件架构来解决两大类相似的问题。搜索是有关键词的推荐,而推荐则是无关键词的搜索。虽然这是一种相对比较简化的看待这两种问题的方式,但是统一的架构在工程上面可以带来非常多的好处,比如重复构建相似的特征工程的流水线,以及更重要的如何优化索引等工程,这些都可以很快地应用在搜索和推荐这两个重要的场景上。 + +当然,在工程以外还有其他好处。在学术界,关于推荐系统搭建的方法往往是一种独立的模型,然后搜索系统又是另外一种独立的模型。这些模型之间缺乏能够系统性联系起来的纽带。把推荐问题看成是多轮打分的搜索问题之后,我们就找到了一种简单又自然的方法,能够把很多不同类型的推荐模型给整合到一起。 + +比如,很多之前我们介绍过的推荐模型就可以担任第一轮打分,也就是我们常说的“候选集选择”(Candidate Selection)这一组件的角色。像协同过滤模型,就可以是我们为每一个用户或者每一个物品产生最终推荐物品的一个候选集合。 + +在搜索里,我们是利用索引以及简单的检索方法,从海量的文档中找到几百或者几千个初步相关的文档,然后再根据第二轮的复杂模型来重排序。那么在推荐里,我们其实就可以利用各种不同的协同过滤、矩阵分解等模型来达到第一步的筛选功能。 + +而对于第二轮打分,我们就完全可以依赖基于特性的排序学习模型来学习推荐的结果。这种方式其实是极大地利用各种搜索算法,特别是排序学习的进步,来提升推荐的效果。 + +是否把推荐问题看成是多轮打分的搜索问题,是区别工业界和学术界推荐模型的一个重要标志。 + +高频用户和低频用户套路 + +既然我们提到了把推荐问题看成是某种意义上的搜索问题,那么,根据用户行为的频率来进行不同的推荐策略,其实就是一个顺理成章的套路了。 + +这个套路的思路和搜索类似。对于高频用户而言,我们有足够多的数据,所以往往可以学习到一个比较好的模型。而且,对于真正的高频用户来说,提高推荐的质量往往需要个性化,也就是说,我们需要更多地利用这些高频用户他们自己的数据,来提供推荐结果。 + +一般来说,针对高频用户的个性化推荐有两种比较常见的方法。 + +一种方法就是构造更多的高频用户的特性。比如,有一个用户点击了某一个物品的信息,或者这个用户购买了某一个物品,这些特性都有助于我们的模型学习到关于这个用户的具体喜好。 + +另外一种比较常见的方法是为这些高频用户单独构建模型。这个方法其实主要是针对第二轮打分的模型而言的。一般来说,一个比较简单直观的方法是把所有用户的数据收集起来,然后训练一个全局的第二轮打分模型。这样做的好处当然是可以利用所有的数据,并且学习出来的模型往往也比较稳定。但是,一个全局的模型往往并不能为某一个用户提供最优的推荐结果,这一点其实很容易理解,因为一个全局的模型往往是某种“平均结果”。所以,我们可以根据用户的数据来为这些高频用户“定制模型”。 + +说了针对高频用户的一些思路以后,我们来看看针对低频用户的一些套路。 + +当我们需要为低频用户进行推荐的时候,因为数据缺乏的关系,这时候的选择就不太多了。一个普遍使用的方法,是对低频用户进行分组。这种分组一般来说是根据用户的人口信息,例如年龄、性别和地理位置。分组之后,我们把这些组别中的用户信息整合起来,统一建立这些组别的模型。 + +还有一个比较普遍方法,是给低频用户推荐流行的信息。这里的假设是,流行信息之所以是流行的,就是因为这些信息本身可能就有较高的点击率、驻留时间和购买率,因此在不清楚这些低频用户喜好的情况下,推荐这些内容其实是相对比较合理、也是保险的。 + +批量和实时套路 + +这个“批量和实时”套路其实和多轮打分以及高频、低频用户都有一些关联,但是有时说的是不太一样的事情。 + +在设计推荐系统架构的时候,我们刚才讲了多轮打分的思路,那是不是每一个用户到我们的网站或者服务时,系统都需要从第一轮开始一直到最后一轮,完全重新生成一个用户的所有推荐结果? + +其实,我们可以这么想一想,如果一些用户,特别是低频用户,每周仅仅光顾几次我们的网站或服务,甚至每个月才光顾一次,我们并不需要针对这些用户来实时更新推荐结果,而可以按照一定的频率,例如每天一次或者每周一次提前生成好所有这些用户的推荐结果,然后存储到某一个地方。等用户访问网站时,我们就可以直接从存储中调出已经生成好的推荐结果。 + +其实这个思路不仅仅用于低频用户,高频用户也可以采用这样的方式。不过,更新推荐结果的频率可能就不是每天或者每周,而应该是每几个小时、每几十分钟甚至是更短的时间。 + +对于很多应用来说,推荐的结果其实并不需要是实时的。即便是在很多看似需要实时的应用上,我们依然可以用很多的批量计算来达到推荐的目的。 + +举个例子,在很多移动场景中,我们可以为一个用户生成一个基本的推荐结果,一两百个物品,然后从服务器端推送到用户的手机上。当用户在手机上产生了新的行为之后,我们可以根据这些行为对用户已经在手机上的这个集合进行模型的微调,然后重新排序。这里用户看到的可能是感觉上已经有更新的推荐结果,但这种实时的效果其实是建立在批量预处理上的。 + +能够理解什么时候需要利用批量的计算结果,什么时候需要实时的计算结果,是处理好推荐问题的一个关键套路。 + +总结 + +今天我为你介绍了做推荐产品的几个套路。 + +一起来回顾下要点:第一,把推荐问题看成一个多轮打分的“类搜索”问题,是推荐在工业界应用的一个重要套路;第二,对高频用户进行个性化推荐有两种常用的思路,包括构造更多的特性和定制建模;针对低频用户的推荐套路也有两个,一个是分组一个是推荐流行的信息;第三,我们聊了批量处理和实时处理的套路,关键是判断在什么场景下使用哪种套路。 + +最后,给你留一个思考题,从多轮打分系统的架构看,推荐和搜索又有哪些区别需要注意呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/144数据科学家必备套路之三:广告套路.md b/专栏/AI技术内参/144数据科学家必备套路之三:广告套路.md new file mode 100644 index 0000000..cc5728b --- /dev/null +++ b/专栏/AI技术内参/144数据科学家必备套路之三:广告套路.md @@ -0,0 +1,73 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 144 数据科学家必备套路之三:广告套路 + 讲完了搜索产品和推荐系统的套路,今天我们继续来看数据科学家应该掌握的广告产品的一些套路。 + +利用搜索和推荐的套路 + +前面我们讲过两种普遍使用的互联网广告模式,搜索广告和展示广告。对于搜索广告而言,一个基本套路就是尽量利用现有的搜索系统来推送广告。而对于展示广告而言,一个基本套路就是尽量利用现有的推荐系统来推送广告。 + +我们在介绍推荐套路时提过,推荐系统和搜索系统的很多方面其实都有重叠,所以做好一套搜索系统是非常有必要的,几乎所有的广告应用其实最终也可以在搜索系统的架构上搭建。因此,我们可以说,搜索系统是很多现代人工智能系统应用的一个核心技术组件。 + +具体来说,广告其实也和一般的文档一样,首先利用搜索引擎的索引把这些广告都存储起来。对于搜索广告来说,利用关键词的倒排索引,可以轻松地找到相关的广告,这和找到相关文档的原理其实是一样的。 + +当然,我们前面也提到过,广告的排序和普通文档有一个不一样的地方,那就是竞价。因此,在从索引中提取广告的时候,我们必须要去思考一个问题,如何让广告竞价的赢家能够从索引中被提取出来? + +我们知道,广告的竞价常常是以点击率和出价的乘积来作为排序的依据。这就会有一个问题,如果我们从索引中提取广告的时候,仅仅看哪些广告从关键词的角度是相关的,而忽略了点击率和出价,那么,最后提取出来的广告很有可能不是真正能够赢得竞价的广告。 + +如何来对这个问题进行修正呢?一种做法是在索引里面增加点击率信息。也就是针对每一个关键词,我们不是按照文本的相关度去索引最相关的文档,而是按照点击率去索引点击率最高的一系列文档。 + +那么,当需要针对某一个关键词提取广告的时候,我们就直接从这个关键词所对应的索引中提取点击率最高的几个广告。这个时候,我们再从某一个存储出价的数据库中读取这些广告的出价,并且进行竞价排序。 + +从这个流程我们可以看出,最终的竞价排名很可能并不是完全依赖点击率和出价的乘积,而是在点击率先有了一定的保证下的这个乘积的排序。这种有保障的点击率常常被叫做“质量值”(Quality Score),用来描述这些广告的点击率高于一个设定的阈值。 + +接下来,我们来看广告提取的另外一个重要的要求,就是需要满足广告投放的业务逻辑。比如,有一个广告的投放要求是针对男性,现在有一个女性用户,那么,我们就不应该针对这个用户显示这个广告,而不管这个广告的点击率和出价信息是怎样的。 + +如何实现这样的效果呢?我们依然可以利用索引。在索引中,我们插入广告的各种投放条件作为被索引的对象,然后把在这个投放条件下的各种广告作为文本。这样,我们就可以提取满足任意投放条件的广告了。 + +针对这些投放条件的组合,例如投放条件是“女性、在北京”,我们可以认为是在索引上进行“且”操作,也就是提取出同时满足两个关键词的操作。事实上,针对任意一个关键词的广告,我们都是进行了多个“且”操作。例如,针对“可乐”这个关键词,我们可能是需要提取这个关键词点击率最高的100个广告(如果有那么多的话),并且这些广告的投放条件都满足“女性、在北京”。当提取出了这些广告之后再进行竞价排名。 + +当然,在这样的架构下,我们就需要对索引有快速更新的能力,例如某一个广告的点击率或者投放条件都有可能发生变化。 + +层次建模套路 + +对于广告系统的建模有一个基本的套路,那就是层次建模(Hierarchical Modeling)。什么是层次建模呢?在广告的生态系统中,至少有广告商、广告推广计划、单一广告这三个层次的实体。提高广告投放精准度的一个核心问题,就是如何能够对这这三种实体进行有效建模。 + +当我们对当前的广告商一无所知的时候,需要看一看过去有没有其他类似的广告商在平台投放过广告,如果有,那么能否借鉴那些过去的数据。当这个广告商开始投放广告以后,我们就可以积累数据,慢慢就能够增强对这个广告商的建模能力。 + +类似的,当我们计划推出某一个广告推广计划的时候,我们先看一看同一个广告商有没有类似的推广计划,或者看一看其他类似的广告商有没有相近的推广计划。当某一个广告开始运行的时候,我们看一看同一个推广计划下其他广告的表现,或者是同一个广告商下其他广告的表现。 + +层次建模的一个重要的特点就是利用可以利用的一切其他信息来进行建模。在计算广告中,经过验证,层级信息往往是最有用的特性。 + +具体和泛化的套路 + +这个套路其实并不是完全针对广告的。就像我们之前所说的广告、搜索和推荐之间的关系,这个套路其实也可以应用在搜索中。 + +前几年,Google的工程师发现,如果仅仅利用深度学习模型来学习抽象的特性,从而寄希望模型的性能得以提升,这种方法也许可以很好地解决计算机视觉的一些问题,但是对于搜索、广告和推荐的效果则并不好。 + +下面我们聊聊工程师们发现的这里面的原因[1]。 + +一个好的模型必须具备两种能力。第一,能够对具体的关键词进行匹配。比如,我需要匹配“可口可乐”,那么任何与“百事可乐”相关的广告其实都是不能显示的。这就要求模型中针对每一个具体的关键词能够进行字对字的匹配,而不是模糊匹配。第二,那就是具有泛化能力。比如,我们要去对“可口可乐”在2018年的广告推广进行建模,模型就能够借鉴“可口可乐”在2017年的推广数据,以及借鉴“可口可乐”公司其他推广的数据。这里面的借鉴能力其实就是模型的泛化能力。 + +由此,我们可以得到一个好模型的重要套路:一个好的模型既要能够精确记忆某一种关键词,又要能够在广告层次上进行泛化。 + +总结 + +今天我为你介绍了做广告产品的几个套路。 + +一起来回顾下要点:第一,搜索系统是很多现代人工智能系统应用的一个核心技术组件,广告系统也可以借鉴搜索系统的套路;第二,广告生态中层次建模的套路,就是利用可以利用的一切其他信息来进行建模;第三,一个好模型的套路,关键是模型的具体能力和泛化能力并存。 + +最后,给你留一个思考题,为什么在计算机视觉中,对于具体匹配的要求没有那么高呢? + +参考文献 + + +Heng-Tze Cheng, Levent Koc, Jeremiah Harmsen, Tal Shaked, Tushar Chandra, Hrishi Aradhye, Glen Anderson, Greg Corrado, Wei Chai, Mustafa Ispir, Rohan Anil, Zakaria Haque, Lichan Hong, Vihan Jain, Xiaobing Liu, and Hemal Shah. Wide & Deep Learning for Recommender Systems. Proceedings of the 1st Workshop on Deep Learning for Recommender Systems (DLRS 2016). ACM, New York, NY, USA, 7-10, 2016. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/145如何做好人工智能项目的管理?.md b/专栏/AI技术内参/145如何做好人工智能项目的管理?.md new file mode 100644 index 0000000..886c3e4 --- /dev/null +++ b/专栏/AI技术内参/145如何做好人工智能项目的管理?.md @@ -0,0 +1,61 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 145 如何做好人工智能项目的管理? + 关于数据科学团队养成这个主题,在之前的分享中,我们已经聊了数据科学团队招聘以及一些高级话题,主要是围绕如何组建一个高效的团队,包括数据科学家的绩效评定、培养以及如何构建水平和垂直的组织架构这些内容。 + +在接下来的几篇分享里,我们重新回到数据科学团队的本源,来看一看数据科学团队在整个公司的位置,以及在数据科学团队的发展和运行中,又有哪些至关重要的环节。 + +今天我们就来聊一聊运行数据科学团队里面的一个核心问题,就是如何针对人工智能项目进行管理,从而保证团队运行的项目能够顺利完成,同时能够真正帮助企业利用人工智能技术来推动产品的发展。 + +一说到项目管理,很多成熟的工程师或者项目经理可能会不以为然,觉得不需要对人工智能项目进行额外的关注。但是在实际工作中,如何运作数据科学项目关系到整个产品的推进,甚至可能对公司的发展都会有不小的影响。 + +那么,通常情况下,针对人工智能项目会有哪些项目管理的模式呢?我们先来看看两种极端模式。 + +把人工智能项目当作“研究项目” + +第一种模式是把人工智能项目完全当作是研究项目。很多从学术界转到工业界的研究人员和工程师,在处理人工智能项目时就很容易陷入这种状态。那么研究项目有哪些特点,或者说,如果我们把人工智能项目完全当作是研究项目,会带来什么问题呢? + +首先,在很多状态下,研究项目并没有特别明确的目标。有些项目看似是利用一个现成的方法来解决一个实际问题,但是做了一阵子才发现问题的定义并不清晰,而那个现成的方法可能需要重新修改,才能在新的问题上使用。而且,修改这个方法也许还需要进行一些理论推导,即便做了所有这些步骤以后,依然没有人可以保证这个方法对新的问题一定有效。 + +也就是说,研究项目的每一个步骤都充满了不确定性。这种过程和结果的不确定性,可以说是研究项目最大的特点。不确定的特点带来的结果,往往就是不太好控制整个研究项目的范围。 + +举一个例子,如果我们要针对一组图像构建一个分类器。这个项目其实可大可小,可快可慢。如果我们直接用现成的模型,然后利用迁移学习的办法,不去重新训练模型,仅仅是把模型直接应用到新数据上,那快则一天,慢则一个星期,可能就完成了这个任务。 + +然而,这么做对分类精度是没有任何保证的。我们可能会发现分类器的精度比我们想象的要低得多。那么,这个时候就会达到一个比较危险的时刻。原始项目范围内需要做的任务都已经完成了,但是没有达到效果,后面可以做的事情,范围可能就会非常大,也没人能说得清楚,做了这些额外的任务之后,是不是一定能提高分类器的精度。 + +因此,把人工智能项目完全看作研究项目的弊端就很明显了,我们无法很好地把握整个项目的周期,特别是完成时间。同时,种种的不确定还可能造成项目范围的不可控。显然,这种局面是工程项目中最不愿意看到的。 + +把人工智能项目当作“软件工程项目” + +另外一个人工智能项目管理的极端模式,就是完全按照软件工程的模式来进行管理。我们这里不讨论具体的软件工程管理方法,我们仅从宏观上来讨论软件工程管理模式对于人工智能项目管理的弊端。 + +软件工程管理的一个核心思想,就是能够把一个大的任务拆分成一些细节的任务,然后假设如果能够完成小的细节任务,那么大的项目也就能顺利完成。同时,软件工程管理还有一个重要的假设,那就是工程的绝大多数步骤都是确定的,没有过多的变数。 + +遗憾的是,正如我们刚才提到的,人工智能项目的一个特征,就是不确定性。因此,按照软件工程进行管理,就容易做一些看似有意义,但其实对工程进展并没有真正帮助的任务。这些任务往往是数据科学家或者工程师凭空制造出的,目的就是为了符合软件工程的管理流程。过于细节的任务划分,往往就会把整个项目真正的目标给迷失掉,从而无法针对是否达到目标很好地进行控制。 + +回到上面那个图像分类器项目的例子,如果我们采用纯粹的软件工程管理方式,那步骤很可能是这样的:需要先写一个计划书,再对数据进行描述,然后找责任相关方来探讨是否需要重新训练模型等等。这些步骤耗费了大量时间,但是对于能否构造出高精度的分类器并没有帮助。 + +人工智能项目的管理 + +那到底该如何来管理人工智能项目呢?人工智能还处于发展的初期,目前其实并没有一个完全成型的项目管理方法论和一个放之四海而皆准的框架。不过,通过刚才对两种极端情况的讨论,相信我们可以在真实的项目管理过程中想出一些办法。 + +首先,我们需要有一个迭代的思路。迭代思路是为了能够有效地管理项目的范围。还是回到我们所说的图像分类器项目,如果我们利用迭代的思路来进行项目管理,就不会把一个绝对的模型精度当作是项目的唯一目标,而把提高精度当作目标,但事先不会针对精度有过分苛责的追求,那么每一天每一周需要做的工作就相对比较容易可控。 + +其次,我们需要分清楚项目中哪些部分是相对可控的,而哪些部分是比较不容易控制的。当一个模型被训练出来后,要把这个训练流程形成一个每天可以更新的工作流(Workflow),这个任务是相对比较可控的。可控的部分我们就可以利用软件工程的项目管理方法了,来对这些任务进行细分。那不可控的任务呢?比如希望提高当前模型的精度,或者是数据量大了十倍以上,依然希望能够进行操作等等。针对这些任务都没有直接的答案,寻找解决方法的过程充满了不确定性,那就无法真正利用软件工程的项目手段了。 + +最后,是人工智能工程项目管理的一个“小窍门”,那就是设置完整的“交工日期”(Deadline)。不同的交工日期往往意味着完全不同的解决方案,甚至这些解决方案之间会有非常大的精度区别。我们的交工日期要建立在迭代思想上,这样就能保证我们的项目在每一个交工日期都有一个成型的结果。如果模型的精度还有提升的空间,我们就可以依赖下一次迭代去完成精度的提高。 + +小结 + +今天我为你讲了数据科学团队的一个核心问题,那就是如何针对人工智能项目进行管理。 + +一起来回顾下要点:第一,我简单介绍了什么是人工智能项目管理;第二,我们分析了两种极端的项目管理模式以及各自的弊端;第三,我们讨论了如何利用两种极端模式来寻求中间路线的办法。 + +最后,给你留一个思考题,你自己的经验里,人工智能项目在运行过程中,哪些步骤或者说是流程是最消耗时间的? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/146数据科学团队必备的工程流程三部曲.md b/专栏/AI技术内参/146数据科学团队必备的工程流程三部曲.md new file mode 100644 index 0000000..6a218c2 --- /dev/null +++ b/专栏/AI技术内参/146数据科学团队必备的工程流程三部曲.md @@ -0,0 +1,71 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 146 数据科学团队必备的工程流程三部曲 + 今天,我们继续来聊一聊数据科学团队的一些基础构建思路,讨论一些日常的在“工程流程”方面所需要注意的问题。和我们上一次分享的项目管理不一样,工程流程没有很多可以直接借鉴的经验,需要从业人员进行更多的思考和创新。 + +什么是工程流程 + +我们首先来看一看什么是“工程流程”。一般来说,工程流程指的是我们有什么制度或者说是策略来保障所做的项目能够达到一定的质量标准。 + +那工程流程和项目管理流程有什么区别呢?我们说,项目管理流程是从宏观上把握项目的进展,而工程流程则主要是在微观上,定义和掌控具体的每一个步骤上的输入、输出和过程。从另外一个角度来说,这两者之间并不存在一个必然的关联关系,一个项目在细节的工程流程上成功与否和一个项目自身的最终成功与否,并不能完全划等号。 + +你是不是有疑问,既然如此,那我们为什么还要关注工程流程呢?原因是虽然一个好的工程流程并不一定带来项目的成功,但是可以增加成功的可能性或者说是概率。同时,一个好的工程流程可以帮助一个团队在日常的运作中减少问题的发生,从而能够达到事半功倍的效果。 + +那么,工程流程究竟包含哪些方面呢? + +我们在今天的分享里讲三个方面。第一,代码管理的流程;第二,开发部署环境的流程;第三,数据管理的流程。这三个流程可以说是涵盖了一个人工智能项目发展和成功所必不可少的三个重要方面。 + +代码管理流程 + +人工智能项目一个很重要的环节就是开发代码。然而,因为数据科学、人工智能项目的一些特殊性,从业人员对于代码的管理普遍存在不够重视的情况。 + +我们在上一期的分享里提到过,数据科学和人工智能的很多项目,往往会被当作学术界的研究项目来进行开发。如果是研究项目,代码开发有哪些特点呢?我简单归纳了两大特点。第一,代码的主要目的是完成学术文章发表所需要的实验结果;第二,在绝大多数情况下,代码很容易变成无人维护和不能继续更新的情况。如果是当作研究项目来开发,那研究人员很可能并不在意代码的可读性、可维护性以及可扩展性等软件工程非常重视的方面,那么这样开发出来的代码就无法真正扩展为一个大型项目的代码库。 + +那么,对于一个人工智能项目的代码管理,我们需要去关注哪些因素呢? + +第一,所有的代码一定要保存在代码版本管理工具中(而不是某一个数据科学家或者工程师自己的电脑上),这也是一个先决条件。在当今的软件开发的工具中,Git(或者是企业级的GitHub)已经成为了一个标准的工具,用于代码版本管理、追踪和分享。任何项目以及任何人只要开始进行开发,都需要把所有的代码,包括核心的文档存放在代码版本管理工具中。这保证了代码能够被追踪并得到及时的备份。 + +第二,如果我们利用Git或者类似的代码版本管理工具,代码的开发一定要尽量遵循这个版本管理工具所提倡的某种流程。比如,我们有两个工程师在同一个项目中一起工作,在这样的情况下,大部分的代码版本管理工具都可以允许这两个工程师在不同的“分支”(Branch)进行开发。这两个分支和项目当前的“主分支”(Master)又不同,因此,项目目前代码的运行不会受到这两个分支的影响,而这两个分支之间也不会互相影响。尽管进行分支开发已经算是软件开发的一个标准流程了,但是在人工智能项目中,依然有很多开发人员不遵循这个方法来进行代码不同开发进度之间的隔离。 + +开发部署环境流程 + +了解了代码版本管理的重要性之后,我们来看一看开发环境流程的控制。 + +从代码到可以被运行和部署的软件包,成为一个大型互联网产品或者人工智能产品中的一个组成部分,往往还有很多路要走。 + +首先,开发部署环境中一个重要的组成就是流畅的从代码到部署软件包的“直通流程”。目前在软件开发领域,有诸如“持续集成”(CI,Continuous Integration)和“持续交付”(CD,Continuous Delivery)这样的方法,来帮助工程师能够相对方便地对代码进行包装和部署。我们在这里并不去展开讨论CI/CD的内涵,但是要意识到,对于人工智能项目,我们也需要有能够流畅部署的思想。 + +那么,具体来说,哪个部分需要有流畅部署的思想呢?如果我们从大的角度来看,一个数据科学项目最需要动态更新的部分,往往是模型的产生,也就是说我们希望能够用最新的数据来训练和测试模型,让模型能够考虑到最新的用户行为。因此,就可以说,只要是对模型的产生流程有影响的步骤,都需要能够达到流畅部署。假如我们更新了模型产生的代码,这些代码必须能够快速地反应到生产系统中。 + +除了快速和持续部署,人工智能项目的另外一个重要需求就是可以对代码的不同分支进行测试和运行。也就是说,我们不仅仅需要能对“主分支”(Master)进行部署,还需要能够对不同的其他分支进行部署,从而能够无缝运行这些不同的分支。 + +这一点我们在开发环境中往往很容易忽视。举个例子,人工智能项目需要做大量的A/B测试,而这些测试中的“控制组”(Control)和“待遇组”(Treatment),往往就对应着代码中不同分支的开发成果。因此,在我们不清楚“待遇组”所对应的代码是不是能够真正带来好处之前,最好不要把这个分支和主分支进行合并。我们首先需要在线测试这个分支的效果,这就带来了运行不同分支的一个需求。 + +数据管理流程 + +最后我们来简单聊一聊数据管理流程。这也是从事数据科学项目我们最容易忽视的一个部分。 + +对于绝大多数的人工智能产品来说,代码,也就是项目的业务逻辑,是和数据是密不可分的。从某种意义上说,我们应该把对数据的关注程度排在第一位。因为即便有正确的代码,如果数据出现偏差,有时候哪怕是一点点小的偏差,都有可能对最后的结果(例如模型)产生重大的影响。因此,我们需要不断地强调数据质量的重要性。 + +那么,对于一个项目的数据管理,又有哪些方面需要注意呢? + +在绝大多数的项目或者是产品中,数据的产生者、数据的运营者以及数据的使用者往往是不同的团队,这是数据管理的最大挑战。这种角色上的差别往往导致了对于数据质量的忽视。 + +举一个例子,如果数据的产生者是一个产品团队,在最近的一次软件更新中,一个工程师把旧代码中对数据进行追踪的部分主观臆断地删除了一些字段,或者是为了变量名好看,更改了字段的名字。如果这个更新没有通知数据的运营者或者是使用者,这往往会带来什么后果呢?后果是下游整个软件线的流程中,数据可能发生重大变化。严重的时候,这样的问题会导致模型发生完全异常,产生不可控的后果。 + +因此,从“端到端”的思维来考虑数据链路是非常有必要的。在数据的产生、运行和使用的链条上,所有的用户必须达成某种数据的API,或者说是“共识”。任何对于数据的改变都需要在满足这种共识的基础上来沟通和进行。同时,数据的检测也是非常重要的,否则就是“垃圾进入导致垃圾输出”。 + +小结 + +今天我为你讲了数据科学团队的另外一个核心问题,那就是如何对工程流程进行管理。 + +一起来回顾下要点:第一,我们简单介绍了什么是人工智能项目的工程流程,以及这个概念和项目管理流程的区别;第二,我们分析了人工智能项目工程流程的三个主要方面,包括代码管理的要素,如何开发和部署环境,以及数据管理的要素。 + +最后,给你留一个思考题,除了我们所提及的工程流程的这三个方面,你还能想到什么其他的方面,在工程流程中也是至关重要的,需要我们在开发中注意呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/147数据科学团队怎么选择产品和项目?.md b/专栏/AI技术内参/147数据科学团队怎么选择产品和项目?.md new file mode 100644 index 0000000..b1a8407 --- /dev/null +++ b/专栏/AI技术内参/147数据科学团队怎么选择产品和项目?.md @@ -0,0 +1,69 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 147 数据科学团队怎么选择产品和项目? + 上一期内容,我们聊了聊数据科学团队在工程流程方面所需要注意的三大问题,分别是代码管理流程,开发部署环境流程和数据管理流程。 + +今天,我们来继续讨论数据科学团队发展这个话题,来看另外两个关键问题:如何选择合作产品以及如何选择项目。 + +如何选择合作产品 + +选择什么样的产品进行合作,对于一个数据科学或者人工智能团队的发展来说,是一个非常重要的问题,是决定工作能否事半功倍的关键步骤。 + +作为工程技术团队,很多数据科学或者人工智能团队都需要支持多个产品,或者说是有机会选择产品。一个稳定的产品往往可以让一个人工智能团队得到快速健康的发展,并且能够逐渐形成良性循环,发展到可以支持更多的产品。 + +那么,什么样的产品是值得合作的产品呢? + +我先来说一类需要谨慎合作的产品,那就是全新的产品方向。对于全新的产品来说,公司之前在这个方向没有太多的产品积累,也可能完全没有技术积累。对于这一类项目,我们需要格外小心,特别是当你的团队还在发展的初期。 + +新产品有一个特点,那就是极大的不确定性。产品范围、需求和时间一般都是不确定的,这些都是一个稳定项目的天敌。另外,还有一个比较棘手的问题,那就是新的产品方向,特别是公司以前从来没有研发过的项目,往往缺乏数据。对于机器学习来说,数据匮乏就是“巧妇难为无米之炊”了。 + +然而从另外一个角度来说,新的产品方向往往又能得到公司高层的重视。毕竟新产品往往是公司“新的赌注”(New Bet),所以很多时候也能够得到不少团队的支持和资源的倾斜。 + +那么,在这种情况下,如果你是数据科学团队的负责人,你就要对这个新产品的利弊有一个充分的认识。如果你的团队已经相对比较成熟,有好几个稳定的产品支持,还有一些剩余的资源可以分配,那么接受一个全新的产品也不失为一种尝试,虽然有一定的风险。最坏的情况是这个产品方向完全失败,但是不会对团队产生致命的影响。 + +了解了这种风险比较高的产品之后,我们来看一看什么类型的产品更值得一个人工智能团队来合作。总结来说,这样的产品一般需要满足以下两个方面。 + +第一,看方向,这个产品最终是需要数据来驱动的。如果一个产品最终会产生大量的数据,而且这些数据能够表征这个产品方方面面的发展,那么,针对这样的项目,人工智能可以起到巨大的推动作用。 + +第二,看地位,这个产品是公司的核心发展项目。这一点看似容易识别,但是在一个相对比较大的公司里,有时候反而并不那么容易识别。那怎么判断呢?介绍一个相对比较简单的方法,就是看一个产品是否和公司的利润或者说是公司的核心用户数据有关系。因为从公司的层面看,一个团队的投资回报率很关键,很多时候会以此来决定是否继续支持这个团队的发展。 + +举个例子,有这么一个产品,虽然不直接产生公司的利润,但是能够帮助公司增长用户,那这个项目也可以算是公司的核心项目。能够支持公司的核心项目,是人工智能团队稳定快速成长的基石。 + +如何选择项目 + +选择好了合作的产品之后,一个产品的迭代过程中还会产生很多不同的项目。是不是这些项目都值得做呢?接下来我们就来看一看究竟应该选择什么样的项目来做。 + +关于项目的选择,我们先有这样一个共识。在团队和产品发展的不同时期,对于如何选择项目应该有不同的考虑。 + +在这里,有一种思维模式可以帮助我们来对不同的项目进行筛选,那就是“投资组合”(Portfolio)的思维。通俗地讲,投资组合思维的核心就是“不能把所有的鸡蛋都放在一个篮子里”。 + +简单来说,我们可以利用四象限法,把不同的项目分为“高投入、高回报”、“低投入、高回报”、“高投入、低回报”和“低投入、低回报”这四种类型。那么,针对这四种不同的类型,我们就需要利用投资组合的思路来选择项目。 + +从理论上来说,我们希望所有的项目都是“低投入、高回报”的,肯定不希望项目是“高投入、低回报”。那么,从表面上看,对于项目的选择,我们其实并没有太多可以争议的地方。但是,实际的情况是,对于绝大多数项目来说,我们并不知道这个项目属于什么类型,最好的情况也无非是对项目有一个估计,而这种估计很有可能会和真实情况相差甚远。 + +因此,对于投资组合的思路来说,这不仅仅是一种对于投入和回报的估计,还包括对于不同类型项目的选择。这里呢,我就讲一些在以往工作中积累的项目选择经验。 + +一般来说,对于一个人工智能项目来说,特征工程(Feature Engineering)都是属于“高回报”的项目。对于大多数的类似项目来说,特征工程往往能够针对项目带来本质上的提升。寻找到好的特征是一个项目能够持续成功的重要途径。 + +在“高回报”的情况下,我们需要考虑的是,这个项目是“高投入”的?还是“低投入”的?如何评价一个特征工程项目的投入成本呢?我们问以下三个问题。这个项目可以基于现在的数据链路(Pipeline)来做吗?是否只是计算一些数据的简单统计量?是否只是把每天不同的统计量做一些叠加?如果三个问题的答案都是“是”,那么这种类型的特征工程项目就属于“低投入”。 + +在一个产品的早期,应该尽量尝试这样的项目,快速发现有用的,特别是那些能够让产品的效果得到迅速提升的特性。而且,因为特征工程“高回报”的特点,在产品迭代的任何一个时期,我们其实都可以关注某一部分的特征工程项目。只是说,也许在产品的初期,找到一系列特征,或者说挖掘出一系列有效的特征,往往会非常容易;但是在产品的中后期,难度就要大一些。 + +我介绍的另一个经验可能和你想象的不太一样,对于核心算法(例如搜索、推荐、广告)的改进,比如改进排序算法属于“高投入”的项目。这类改进算法项目的一个明显特点,就是往往需要有较长时间的研发周期。而且,这类项目的升级换代往往需要“基础设施”(Infrastructure)或者平台级别的变化。也就是说,这类项目的投入比较大,周期比较长。 + +当然了,这类项目的回报如何,其实并不是特别容易估算的。例如,我们在特征不变的情况下,从线性模型更改到树模型,可能会有5%~10%的性能提升。但是更改到树模型之后,也许我们还能够加入更多适应于树模型的特性,带来后面的10%~20%的提升。因此,如果考虑到一个回报的系列性效果,算法的更改升级还是应该引起我们足够的重视的。 + +小结 + +今天我为你讲了数据科学团队的两个核心问题,那就是如何选择产品以及如何选择项目。 + +一起来回顾下要点:第一,我们聊了聊产品的选择,尽量对新产品持谨慎态度,同时尽量支持并开发公司的核心产品;第二,我们分析了如何选择项目,重点是对四种类型的项目进行一个探讨。 + +最后,给你留一个思考题,如果我们希望从树模型升级到“深度模型”,这种项目属于我们介绍的四种类型项目中的哪一类呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/148曾经辉煌的雅虎研究院.md b/专栏/AI技术内参/148曾经辉煌的雅虎研究院.md new file mode 100644 index 0000000..70892a1 --- /dev/null +++ b/专栏/AI技术内参/148曾经辉煌的雅虎研究院.md @@ -0,0 +1,81 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 148 曾经辉煌的雅虎研究院 + 雅虎是最早成功的互联网公司之一,也是最早意识到需要把基础研究,特别是机器学习以及人工智能研究,应用到实际产品中的公司。雅虎从很早就开始招聘和培养研究型人才,雅虎研究院就是在这个过程中应运而生的。 + +今天我就来说一说雅虎研究院的历史,以及过去十多年间取得的成就,聊一聊如何通过引进高级人才,迅速构建起一支世界级的研发团队。当然,也会聊一聊研究院的衰落。高级研发机构对于企业而言往往是锦上添花的事情,在整个公司产品和视野都欠缺的情况下,也往往避免不了最后衰败的结局。 + +雅虎研究院的创立 + +雅虎研究院的故事要从一个叫乌萨马·菲亚德(Usama Fayyad)的人说起。乌萨马出生在北非突尼斯的迦太基(Carthage),早年在突尼斯以及其他地中海沿岸国家度过,包括中东、非洲以及南欧的一些国家。高中时期在约旦的安曼生活,后来在美国密歇根大学度过了他的本科(1984年)、硕士(1986年)以及博士(1991年)生涯。毕业之后,乌萨马来到了美国加州南部隶属于美国国家航空航天局(NASA)的喷气推进实验室(Jet Propulsion Laboratory)工作,一直到1996年。之后加入微软研究院,从事数据挖掘的研究工作。 + +早在1994年,乌萨马就和拉马萨米(Ramasamy Uthurusamy)一起组织了最后一届KDD研讨班,然后在1995年,他们把这个研讨班升级成了会议,并在加拿大蒙特利尔举办了第一届KDD大会(First International Conference on Knowledge Discovery in Data)。从此,KDD大会成了数据挖掘、数据科学以及应用机器学习的顶级会议。 + +1996年,乌萨马又创办了一本叫《数据挖掘和知识发现》(Data Mining and Knowledge Discovery)的学术期刊,并亲自担任主编。这本期刊也渐渐成了数据挖掘领域主要的学术期刊之一。乌萨马本人可以说在20世纪90年代中期,就已经开始成为数据挖掘领域重要的领军人物。 + +进入21世纪,乌萨马先是在2000年创立了一家叫Audience Science的数据挖掘公司并担任CEO,然后又在2003年创立了一家叫DMX Group的数据挖掘咨询公司,后者于2004年被雅虎收购。不久后,他成为雅虎的执行副总裁以及首席数据官(Chief Data Officer),这也是互联网历史上的第一位首席数据官。 + +因为雅虎在搜索以及广告业务上的扩展,乌萨马意识到应该成立一个类似于微软研究院,但更偏向于互联网业务的研究组织,这个想法得到了公司CEO杨致远的支持。乌萨马当时的首要任务是为研究院物色一位院长。 + +经过一段时间的寻找,他成功邀请到普拉巴卡·拉加万(Prabhakar Raghavan)来担纲。今天回头来看,普拉巴卡无疑成功地引领了雅虎研究院,并让其一度成为人人向往的互联网研究机构。当然,这跟普拉巴卡本人的经历也密切相关。 + +首先,他本人就是知名的学者,参与撰写的经典教科书《随机算法》(Randomized Algorithms)和《信息检索导论》(Introduction to Information Retrieval)在学术界享有盛誉。他还是ACM、IEEE的院士,也是美国工程院院士,这为他招纳学术界权威人士和博士生提供了便捷。加入雅虎之前,他已经在IBM研究院以及Verity任职多年,IBM的从业经历更是让他对企业文化和工业界的研究机构有了很深的了解。 + +2005年7月,雅虎研究院正式成立,普拉巴卡担任研究院负责人,向乌萨马汇报。2008年,雅虎研究院与之前就在搜索与广告事业部存在的应用科学部门合并。在卡罗尔·巴茨(Carol Bartz)任职CEO期间,普拉巴卡直接给她汇报,并且普拉巴卡还曾担任首席战略官。 + +雅虎研究院的蓬勃发展和辉煌 + +雅虎研究院组建之后,首要任务当然就是吸引工业界和学术界的知名学者,从而能够组建一个有效的团队。普拉巴卡利用他个人和乌萨马的声望,很快就做到了这点。 + +比如,之前和普拉巴卡在IBM共事的安德鲁·汤姆金斯(Andrew Tomkins)加入团队,担任负责搜索的首席科学家以及搜索方面的副总裁(安德鲁后于2009年之后加入谷歌担任工程总监)。 再比如,曾和普拉巴卡在IBM共事的安德烈·布罗德(Andrei Broder)2005年加入团队,担任负责计算广告方面的副总裁。 + +安德烈本人大有名头。他在斯坦福大学攻读博士期间师从图灵奖得主高德纳(Donald Knuth),然后在曾经名噪一时的第一代搜索引擎公司AltaVista担任首席科学家,之后加入位于纽约的IBM研究院组建企业级搜索平台。和普拉巴卡一样,安德烈也是ACM和IEEE的双料院士。2012年安德烈加入谷歌,担任杰出科学家 (Distinguished Scientist)。 + +我们这里简单列举一些曾经在雅虎研究院工作过的知名学者,我们便可一览其盛况: + + +Ronald J. Brachman:哈佛大学计算机科学博士,加入雅虎研究院之前长期于贝尔实验室工作,曾担任贝尔实验室人工智能研究部的负责人。1996年之后担任AT&T实验室通信服务研究中心副总裁。2005年加入雅虎研究院协助普拉巴卡进行管理,并于2012年到2016年间担任雅虎研究院首席科学家以及负责人。Ronald曾任AAAI主席。2016年之后担任纽约康奈尔科技大学的Jacobs Technion-Cornell研究院院长。 +Yoelle Maarek:以色列理工大学计算机科学博士,加入雅虎研究院之前曾任IBM研究院的杰出工程师和谷歌的工程总监。历任雅虎研究院以色列分部的负责人、高级研究总监,并在2016年Ronald离开之后任雅虎研究院的负责人。 +Jan Pedersen:斯坦福大学统计学博士。2002年加入AltaVista担任首席科学家(在安德烈之后)。2003年加入雅虎研究院担任搜索和广告方面的首席科学家(在安德鲁·汤姆金斯之前)。2009年加入微软,担任Bing核心搜索部门(Core Search)的首席科学家。2017年加入Twitter,担任数据科学副总裁。 +Ben Shahshahani:普渡大学电气工程博士。曾在Nuance Communications担任工程总监。2005年加入雅虎研究院,之后历任负责搜索广告的高级总监以及搜索与媒体科学组的副总裁。2012年加入谷歌任工程总监。2014年回到雅虎,任广告科学方面副总裁。 +Ricardo Baeza-Yates:滑铁卢大学计算机科学博士,ACM和IEEE双料院士,信息检索和搜索方面的权威,著有《现代信息检索》( Modern Information Retrieval)一书。他在雅虎研究院担任拉美和欧洲分部的副总裁直至2016年,也是智利科学院以及工程院的院士。 +Ravi Kumar:康奈尔计算机科学博士,加入雅虎研究院之前在IBM 研究院从事数据挖掘算法的研究。2005年加入研究院之后担任首席研究科学家。2012年加入谷歌担任高级主任研究科学家(Senior Staff Research Scientist)。他的论文引用数达3万次以上。 +Evgeniy Gabrilovich:以色列理工大学博士,在雅虎研究院担任首席研究科学家,并且担任自然语言处理方向研究的负责人。2012年加入谷歌担任高级主任研究科学家。2012年当选ACM杰出科学家(ACM Distinguished Scientist)。 +Deepak Agarwal:康涅狄格大学(University of Connecticut)统计学博士,加入雅虎研究院之前在AT&T担任高级研究科学家一职。2006年加入雅虎研究院担任首席研究科学家,主要研究推荐系统相关的内容。2012年加入LinkedIn,担任人工智能和机器学习方面的副总裁。 +Alexander Smola:柏林理工大学计算机科学博士,加入雅虎研究院之前任澳大利亚国立大学教授。2008年加入雅虎研究院后任首席研究科学家(Principal Research Scientist)。2013年加入卡内基梅隆大学任教授一职。2016年加入亚马逊担任机器学习方面的总监。他的论文引用数达8万次以上。 +Jianchang (JC) Mao:密歇根州立大学计算机科学博士,加入雅虎研究院之前曾在IBM 研究院任职,还曾担任Verity的首席软件架构师。2004年加入雅虎之后任广告科学方面副总裁。2012年加入微软之后,先后担任Bing的多个职务并于2016年被提升为公司副总裁。他的论文引用数达1万次以上。 +Raghu Ramakrishnan:德克萨斯大学奥斯汀分校计算机科学博士,加入雅虎研究院之前担任威斯康星大学教授。2006年加入雅虎研究院之后任云计算方面的副总裁。2012年加入微软之后一直担任CTO,负责云计算领域。他的论文引用数达3万次以上。 + + +当然,在雅虎研究院工作过的知名人士还有很多,这里无法一一列举。不过我们可以看出,不少人在离开雅虎之后,依然在业界发挥着不小的作用。 + +除了招揽到一批优秀人才,雅虎研究院也发表了一系列有价值的研究成果,在很短的时间内建立了学术研究上的威望。在10年间,据不完全统计,雅虎研究院的学者获得过两次信息检索顶级会议ACM SIGIR的最佳论文、3次数据科学和数据挖掘顶级会议ACM KDD 的最佳论文、两次机器学习顶级会议ICML的最佳论文、两次推荐系统顶级会议ACM RecSys的最佳论文、两次信息检索以及网络信息挖掘的权威会议ACM WSDM的最佳论文、两次信息检索和数据库领域顶级论文ACM CIKM最佳论文以及一系列有影响力的最佳论文奖项,涵盖了搜索、广告、推荐系统、数据挖掘、机器学习、人机交互等诸多方面,为互联网研究和发展做出了重大贡献。 + +可以说在非常短的时间内,雅虎研究院就用卓越的研究成果向世人证明了这个团队和组织的实力。曾经在某一段时期内,世界各国的优秀研究人员和博士毕业生都希望跻身雅虎研究院的研发队列。 + +雅虎研究院逐渐成为历史 + +2012年是雅虎历史上格外动荡的一年。先是公司CEO卡罗尔·巴茨在上一年的9月份被董事会解雇;然后经历了短暂的临时CEO——蒂姆·莫尔斯(Tim Morse);之后新CEO斯科特·汤普森(Scott Thompson)在1月上任,5月份就因学历造假丑闻离职;罗斯·莱文索恩(Ross Levinsohn)之后担任公司临时CEO直至7月。然后,玛丽莎·梅耶尔(Marissa Mayer)加入公司担任CEO。短短不到一年的时间里,共有5个人担当了CEO的职位。 + +在这个过程中,普拉巴卡离职并加入谷歌,很多之前追随他的人也先后加入谷歌。普拉巴卡离开后,罗纳德·布拉赫曼(Ronald J. Brachman)接过了研究院领导人的位置,并在2012到2016的4年间为玛丽莎重新招募了超过100名博士科学家。 + +2016年2月,雅虎宣布研究院不再作为一个独立实体而存在,罗纳德离职,所有研究人员被分散到各个工程部门,依然保留雅虎研究院的对外旗号,耶艾尔·玛瑞克(Yoelle Maarek)担任负责人。2017年雅虎和Verizon合并,雅虎作为一个独立的公司成为历史。 + +雅虎研究院逐渐淡出历史舞台,这固然有公司高层频繁更换的原因,也有一些更加深层次的原因。研究院的成果往往都需要一定时间才能直接在产品中体现出来,因此,虽然在技术上研究院能够帮助公司提升水平,但是实际产品的效果未必就一定能够受到用户的青睐。 + +例如,研究院曾经投入了大量人力物力,利用机器学习来提高搜索引擎的搜索品质。可以说,雅虎是最早将人工智能和机器学习技术大规模应用在搜索引擎上的公司。但是搜索引擎的好坏很多时候是一个产品、技术、设计的综合体现,雅虎研究院研发的算法并没有在产品的综合表现中挣得额外加分。 + +相似的例子还包括雅虎研究院在早期就投入了很多力量研发广告平台,甚至包括安德烈·布罗德本人到斯坦福大学开设了世界历史上第一门计算广告学的课程。然而,雅虎整个平台的产品都在下滑,因此广告平台受到了额外的压力。虽然研究院的科学家们在算法和模型上做出了很多创新,也在一定时间内带来了不小的收益,但都无法改变整个公司产品线运营不佳的情况。于是,雅虎研究院的成果在雅虎整体业绩不理想、公司产品缺乏想象力的大背景下显得杯水车薪,并不能从整体上扭转公司的颓势。在公司进入动荡之后,研究院对于高层领导来说,往往也就不是公司的重点发展对象了,研究院的瓦解也就成了必然。 + +小结 + +今天我为你分析了雅虎研究院的兴衰。一起回顾下要点:首先,雅虎研究院曾通过引进高级人才的方式,迅速构建起了一支世界级的研发团队,并发表了一系列有价值的研究成果,建立起在学术研究上的威望,创造了研究院曾经的辉煌;其次,因高层变动以及一些深层次的问题,雅虎研究院没有摆脱最后衰落的结局,一切辉煌终成历史。 + +最后,给你留一个思考题:到底什么样的企业环境能够最好地发挥研究院的成果,又是怎样的研发流程能够使研究院成为公司新动力的源泉?雅虎研究院在当年并没有找到答案。不知道随着最近一批互联网新贵纷纷成立人工智能研发团队的契机,大家是否能够找到更好的研究院运作模式。 + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/149微软研究院:工业界研究机构的楷模.md b/专栏/AI技术内参/149微软研究院:工业界研究机构的楷模.md new file mode 100644 index 0000000..fd9df21 --- /dev/null +++ b/专栏/AI技术内参/149微软研究院:工业界研究机构的楷模.md @@ -0,0 +1,63 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 149 微软研究院:工业界研究机构的楷模 + 随着人工智能的兴起,各大公司纷纷建立起自己的研发团队,来对这一重要技术领域进行探索。一些有一定规模的公司还会成立更加精英的“研究院”或者“实验室”,吸引业界顶尖人才到自己的公司进行基础的和前沿的研发工作。 + +那如何来组建这样的机构呢?有没有可以参考的类似机构呢?答案是有这样一个机构,甚至很多公司会不惜重金去聘请这个机构的研究和管理人才。今天我们就来聊聊这个机构,那就是“微软研究院”。 + +微软研究院自1991年成立以来,已经走过了接近40年的岁月。依托于微软这个软件时代的巨头,微软研究院也和微软公司一起经历了从软件时代到互联网时代、到移动时代、再到人工智能时代的重大变迁,见证了这几个时代中前沿科技对公司的影响。 + +微软研究院的历史有多辉煌呢?至少有五位图灵奖得主曾经在微软研究院工作过,包括发明了“霍尔逻辑”和快速排序的托尼·霍尔(Tony Hoare)爵士、在数据库领域有卓越贡献的吉米·格雷(Jim Gray)、个人计算的先驱巴特勒·兰普森(Butler Lampson)、个人电脑的先驱查尔斯·萨克尔(Charles P. Thacker)和在分布式算法方面有突出贡献并发明了LaTeX排版语言的莱斯利·兰波特(Leslie Lamport)。研究院里的美国科学院院士、工程院院士、IEEE院士以及其他各类最高学术成就获得者,可以说是数不胜数。更别提发表了2万多篇论文,每年都有各种激动人心的项目的研发。从很多角度来看,微软研究院都堪称工业界研究机构的模板。 + +接下来我们就一起来梳理一下微软研究院这个具有传奇色彩研究机构的传奇故事。 + +微软研究院的成立 + +虽然微软研究院在工业界研究机构中有着显赫的地位,但是其最早的发展则鲜为人知。我们了解微软研究院成立的早期历史,也是为了体会一下缔造者们的思想和视野,以及他们是如何一步一步地把一个小的团队发展成为了工业界的典范的。 + +先介绍第一个关键人物,他的名字叫内森·米尔沃德(Nathan Myhrvold)。1996年,他成为了微软历史上首位首席科技官(CTO)。米尔沃德说服了比尔盖茨等微软高层来建立一个研究性质的机构。这个决定其实让很多人都感到出乎意料,那米尔沃德是怎么想的呢? + +他认为,微软之前的成功是把大型计算机的一些经验移植到了当时方兴未艾的小型计算机,也就是个人电脑上。这样把现成经验进行产品转换的思路迟早会让微软黔驴技穷,应该及早着眼于一条能够“持续造血”的路,所以微软需要有一个长期专注基础研究的部门。 + +米尔沃德不仅仅只是提出了这样一个洞见,他还为基础研究的一些特性拟定了基调。首先,基础研究或者说是更高级的研发工作和产品开发有本质的区别。其次,有时候基础研究并不一定能给母公司带来足够的好处,但这种失败率其实和普通的产品研发也很类似,因此没有必要因为基础研究的一些失败而过分敏感。 + +同时,米尔沃德对基础研究能够给公司带来什么样的好处也做了深刻思考,他总结了三点:第一,基础研究能够让公司在时间上获得接触到最新科技的优势;第二,能够帮助公司更广泛地接触先进技术;第三,能够给公司带来知识财产,比如专利等。 + +当这个在微软建研究机构的想法得到认同后,米尔沃德希望为这个新的研究部门找一个领军人,他想到了理查德·拉希德(Richard Rashid),这是我们要介绍的第二个关键人物。 + +拉希德于1974年从斯坦福大学毕业,获得数学和比较文学的学士学位。1980年,他在罗杰斯特大学(University of Rochester)获得计算机科学博士学位,然后成为了卡内基梅隆大学计算机系的一名教授。在担任教授期间,他在计算机系统、网络系统、人工智能以及编程语言等诸多领域发表了众多论文。除了论文之外,他比较有影响的工作还包括一款叫作“Mach”的计算机操作系统微内核。 + +米尔沃德通过共同的好友找到拉希德,那已经是1991年夏天的事情了。拉希德的第一反应是觉得这是一个玩笑。1991年,微软还不是今天这样的大公司。一家规模并不大的公司要成立一个研究机构让人感觉不太现实。 + +拉希德和家人被邀请到微软访问,和比尔盖茨以及其他微软的管理高层见了面。他原以为微软的人并不明白建立一个研究部门要干什么,也不一定真心希望有这么一个研究机构。所以,当发现比尔盖茨非常支持并且非常真诚地理解建立研究机构的需求时,拉希德还是相当吃惊的。然而,即便如此,他还是拒绝了这个邀约,因为他当时觉得对这个职位并不感兴趣。 + +米尔沃德也没放弃,在接下去的一段时间,他把拉希德的电话打烂了。恰好在这个时候,拉希德也迎来了他职业生涯的一个选择关口,卡内基梅隆大学希望他出任计算机学院的院长。这样,他就需要在两份工作邀约之间做一个抉择。 + +时间到了1991年的8月31日,拉希德决定出任微软研究院的院长。从1991年到2013年,拉希德都是微软研究院的领导人。这个研究机构在他带领下的20多年时间里发生了惊天动地的变化。 + +微软研究院模式 + +刚刚我们回忆了微软研究院历史中最初的一个片段。但是从这个片段中,我们其实已经体会到了这个组织之所以成功的一些因素,这些因素是在最开始就深深根植到了组织的架构中的。 + +第一个要素是研究院的领导人一定要有深厚的研究背景。成立研究院的时候,米尔沃德第一时间想到的是到学术界找到一个学术权威。实践告诉我们,正是拉希德的加入,让微软研究院在招收顶级人才方面有着无与伦比的优势。当然,是不是任何一个学术大家都胜任这样的工作呢?这是另外一个值得我们思考的问题。 + +具体来说,拉希德带来的优势有哪些呢?一方面,他可以借助曾经是卡内基梅隆大学教授这个身份从学校吸引人才;另一方面,他在学术界的朋友也会因此加入。实际上,拉希德决定加入微软研究院后,第一个电话就打给了他在IBM研究院的一个好友,邀请他加入微软。而拉希德卸任之后接手微软研究院的彼得·李(Peter Lee)也曾经是卡内基梅隆大学计算机系的主任。 + +由此看来,虽然米尔沃德在一开始颇费了一番工夫才把拉希德纳入微软,但是后续的招聘工作就相对顺利起来。类似地,李开复到北京建立微软亚洲研究院,基本上也是一样的思路。 + +第二个要素就是研究院这样的部门必须要有公司最高层的一致支持。这种支持还必须是一种真诚的支持。试想没有米尔沃德和比尔盖茨以及微软高层和拉希德的真挚交流,拉希德不可能选择加入微软研究院,那这个机构是否还存在可能就是另外一番光景了。微软高层对研究院的支持不是一朝一夕,而是30多年来一直如此。并且在最近的人工智能浪潮下,这种支持只增不减,这也是这个机构能够成功的一个重要因素。 + +第三个要素也就是公司的高层一定要明确研究院究竟会对公司造成什么样的影响,或者说,要对研究院的产出有一个合理预期。从这一点来说,微软的早期领导人非常明确地认识到基础研究和一般的产品开发是有区别的,并且对基础研究的失败率有一个完整的理解。这一点是非常惊人的。对于1991年的微软,就能有这样一种非常不容易的状态,并且还能够一致保持这样状态,这也许就是这个机构和这家公司几经沉浮依然茁壮发展的秘诀。 + +小结 + +今天我和你一起简单回顾了微软研究院早期的一些不为人知的历史,并归纳了微软研究院成功的三个要素。 + +最后,给你留一个思考题,微软最近又在研究院的基础上成立了一个全新的AI部门,你觉得原因是什么?这个部门和研究院是不是有重叠呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/150复盘6数据科学家与数据科学团队是怎么养成的?.md b/专栏/AI技术内参/150复盘6数据科学家与数据科学团队是怎么养成的?.md new file mode 100644 index 0000000..bc7cda0 --- /dev/null +++ b/专栏/AI技术内参/150复盘6数据科学家与数据科学团队是怎么养成的?.md @@ -0,0 +1,95 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 150 复盘 6 数据科学家与数据科学团队是怎么养成的? + 今天,我准备了24张知识卡,和你一起复盘数据科学家养成和数据科学团队的养成这两个模块。在这两个模块里,我们一共讨论了8个话题,从方方面面分享了数据科学家所需具备的技能;也从组建一个团队的角度出发,让你更了解自己在团队中的角色;最后,我们还聊了雅虎研究院等知名团队,帮你拓展视野。 + +提示:点击知识卡跳转到你最想看的那篇文章,温故而知新。 + +数据科学家基础能力 + + + + + + + +数据科学家高阶能力 + + + + + + + +数据科学家职场话题 + + + + + + + +数据科学家套路 + + + + + + + +数据科学团队基础 + + + + + + + +数据科学团队招聘 + + + + + + + +数据科学团队高级话题 + + + + + + + +著名数据科学人工智能团队漫谈 + + + + + + + +积跬步以至千里 + +最后,恭喜你学完了这个模块的所有内容。不管你是一名数据科学或人工智能的初学者,还是已经积累很多宝贵的经验,亦或要组建自己的数据科学或人工智能团队,都希望这一模块的内容对你有所帮助和启发。 + +学无止境,选择了人工智能这条路,就意味着我们选择了一种生活方式。今天就让我们以几句诗结尾,作者是我很喜欢的美国诗人罗伯特·弗罗斯特(Robet Frost)。 + +Two roads diverged in a wood, and I— + +I took the one less traveled by, + +And that has made all the difference. + +树林中分出两条路,而我—— + +我选择了那条少有人走的路, + +从此一切与众不同。 + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/150聊一聊谷歌特立独行的混合型研究.md b/专栏/AI技术内参/150聊一聊谷歌特立独行的混合型研究.md new file mode 100644 index 0000000..a06ca45 --- /dev/null +++ b/专栏/AI技术内参/150聊一聊谷歌特立独行的混合型研究.md @@ -0,0 +1,68 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 150 聊一聊谷歌特立独行的混合型研究 + 上一讲我们介绍了微软研究院发展早期的一段故事,一起讨论了为什么说微软研究院是工业界研究院的楷模。 + +今天我们来看另外一种“混合型”的工业界研究机构模式,聊一聊谷歌研究院,一起来讨论这种模式是不是更加适合互联网企业的需求。 + +研究背景起家的谷歌 + +谷歌的创立比微软晚了将近20年,但两个公司有一些相似的地方,其中之一就是创始人都是中断了学业,投身到创业的浪潮中。不过拉里·佩奇(Larry Page)和谢尔盖·布林(Sergey Brin)当时是在攻读博士学位,关于如何进行网页搜索的最初想法,是从他们的博士研究课题衍生出来的[1]。由此可见,谷歌从一开始就和研究类项目有着千丝万缕的联系。 + +佩奇和布林的论文发表在1998年的国际万维网(WWW)大会上。这篇论文介绍了PageRank算法,在当时这简直就是一个石破天惊的算法。那它和当时其他搜索引擎的关键技术相比,独特之处在哪里呢?计算网页的相关度或者说是重要度完全不依赖文本信息,而仅仅依靠由网页之间关系组成的图,而且能够得到一种非常稳定的排序。或许就是因为这个独特的算法让两个斯坦福大学的年轻人放弃了继续攻读博士学位的想法,转身在硅谷找了一个车库,从而演绎了一个传奇的创业故事。 + +或许就是因为创始人的背景,我们可以看到谷歌对学术界的最新研究成果有一种特殊的青睐,在谷歌发展的路上,屡屡上演收购案例,收购的很多公司都是因为有一些研究成果而成立的小公司。 + +举几个例子。同样来自斯坦福的博士毕业生塔哈尔·哈维利瓦拉(Taher H. Haveliwala),改进了PageRank算法[2],他创办的公司在2003年的时候被谷歌收购。2010年,达蒙·霍洛维兹(Damon Horowitz)和瑟潘达·卡姆瓦尔(Sepandar D. Kamvar)在当年的国际万维网大会上发表了一篇“社交搜索”(Social Search)的论文,论文标题都跟佩奇和布林当年发表的PageRank论文有异曲同工之妙。两个作者所在的公司很快就被谷歌收购了。最近的例子就是我们都熟知的杰弗里·辛顿(Geoffrey Hinton)所创立的公司以及位于英国的DeepMind公司,也都是因为在深度学习方面的重要贡献被谷歌先后收购。 + +混合型工业界研究 + +尽管谷歌对于学术研究有一种天然的亲近,然而在很长一段时间里,谷歌其实并没有真正成立完全独立的基础研究部门。所以很多学术圈的研究人员,还有工业界的研究同仁,都对谷歌产生了一种误解。 + +2012年的时候,谷歌的研发总监彼得·诺维格(Peter Norvig)发表了一篇文章[4]来介绍谷歌的研究模式。 + +谷歌的研究模式到底是怎样的呢?简单来说,就是让研究和产品的研发紧密结合起来,而不完全建立独立运行的研究院。当然,这个模式在收购了DeepMind之后算是被打破了。但是在谷歌20年的发展历史上,混合型研究一直是谷歌研发的主流。 + +诺维格在文章中解释道,谷歌研究工作的一大重要目的就是为终端用户带来重要的和实际的好处。很明显,这一目的和纯粹的学术研究有很大的距离。我们来看微软研究院和雅虎研究院,它们的重要贡献指标就是学术论文发表的质量和数量。 + +我们再具体来看看这两种道路的差异。 + +传统的学术研究是这样的:研究人员首先构想一个学术课题,然后在一种比较受限的环境中对这个课题进行研究。这里说的受限的环境,往往是指数据并不是全量数据,而是采样过后的数据,这些数据能够在学者们的笔记本或者小型集群中进行计算。甚至在有的情况下,研究人员会使用完全虚拟的数据。另外,在这种受限的环境中进行研究还会带来这样一个问题,由于开发的代码和软件不需要重复使用,也不需要开发生产环境的代码,所以这些代码质量都相对较低。 + +那谷歌的研究工作是怎样做的呢?谷歌研究要求从一开始就使用生产环境来编写代码。这些代码和普通的产品代码没有区别,也使用和一般产品线代码相同的数据、相同的流程。这样,一旦有了研究成果就可以无缝接入现在的产品线中。这样的要求对于研究者来说,其实是拔高了研究的难度,但是对于研究成果和产品对接来说就将困难降低到了最小。 + +总结一下,让工程和研究结合在一起,并且有意模糊这两者的区别,就是谷歌混合型研究的核心思想。工程师和科学家在同样的项目中工作,大家都面临同样的限制,这样就可以让研究的课题不至于完全天马行空,而是能够落到实处。 + +混合型研究的思考 + +我们上面介绍了谷歌的研究背景以及基于此慢慢形成的混合型研究模式。为什么这条道路在谷歌就能够落地实施呢?我想这里面有一个非常重要的先决条件,这是诺维格的文章里没有提到的,那就是大量高素质人才的涌入。 + +在这些人才中,博士生比比皆是,甚至有很多教授。所以,谷歌的工程团队中有很多博士生担任着普通工程师的角色。说得通俗一点,谷歌用博士生在干硕士生甚至是本科生就可以胜任的工作。一个团队中的工程师和科学家并没有本质的区别,这才使得任何一个科研想法都可以很容易地在工程层面得以实现。 + +由此我们可以看到,从某种意义上来说,谷歌其实并不需要单独的研究机构,自己工程团队的水平就已经非常出色了。我们从TensorFlow、语言翻译等知名项目就可以看出来,这些项目都是工程团队达到了很高的研究水平。 + +当然,在这种模式下,谷歌的基础研究其实是受限的。在收购了DeepMind后,谷歌也开始依靠这样单独的研发机构来推动和产品结合得不那么紧密的研究方向。 + +小结 + +今天我为你介绍了谷歌与众不同的混合型研究模式。这种模式对工程团队的水平要求比较高,如果没有高水平的工程团队,研究人员和工程师就会产生隔阂,沟通不畅,研发就会有问题。从这个角度来看,建立单独的研究机构或许更能实现很多公司的初衷。 + +讲到这里,如果我们要借鉴谷歌的这种混合型模式,你觉得挑战是什么?如果有了高水平工程团队这一保障后,你觉得想要成功还有什么挑战? + +欢迎你给我留言,我们一起讨论。 + +参考文献 + + +Sergey Brin and Lawrence Page. The anatomy of a large-scale hypertextual Web search engine. Proceedings of the seventh international conference on World Wide Web 7 (WWW7), Philip H. Enslow, Jr. and Allen Ellis (Eds.). Elsevier Science Publishers B. V., Amsterdam, The Netherlands, The Netherlands, 107-117, 1998. +Taher H. Haveliwala. Topic-sensitive PageRank. Proceedings of the 11th international conference on World Wide Web (WWW ‘02). ACM, New York, NY, USA, 517-526, 2002. +Damon Horowitz and Sepandar D. Kamvar. The anatomy of a large-scale social search engine. Proceedings of the 19th international conference on World wide web (WWW ‘10). ACM, New York, NY, USA, 431-440, 2010. +Alfred Spector, Peter Norvig, and Slav Petrov. Google’s hybrid approach to research. Commun. ACM 55, 7 (July 2012), 34-37, 2012. + + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/151精读AlphaGoZero论文.md b/专栏/AI技术内参/151精读AlphaGoZero论文.md new file mode 100644 index 0000000..293013a --- /dev/null +++ b/专栏/AI技术内参/151精读AlphaGoZero论文.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 151 精读AlphaGo Zero论文 + 长期以来,利用人工智能技术挑战人类的一些富有智慧的项目,例如象棋、围棋、对话等等都被看做人工智能技术前进的重要参照。人工智能技术到底是不是能够带来“拟人”的思维和决策能力,在某种意义上成了衡量人工智能水平以及整个行业发展的试金石。 + +在这些项目中,围棋可以说是一个难度很大的项目,更是饱受关注。一方面,围棋的走棋可能性非常多,且棋局变化多端,这似乎给计算机算法带来了很大的困难。另一方面,围棋在一些国家和地区(比如中国、日本和韩国)不仅仅是一个智力竞技项目,更有一系列理念、人格等全方位的配套文化概念,使得人们对此产生怀疑,人工智能模型和算法是否能够真正学习到有价值的信息,而不仅仅是模拟人的行为。 + +2015年,来自谷歌DeepMind团队的AlphaGo打败了职业二段樊麾,在这之后短短两年的时间里,该团队的人工智能技术迅猛发展,不仅以4比1击败韩国的李世石九段,更是在今年战胜了即时世界排名第一的柯杰,可谓战胜了被誉为“人类智慧皇冠”的围棋项目。 + +前段时间,DeepMind团队在《自然》杂志上发表了AlphaGo的最新研究成果,AlphaGo Zero,这项技术更是把人工智能在围棋上的应用推向了一个新高度,可以说是利用计算机算法把围棋的棋艺发展到了一个人类之前无法想象的阶段。 + +今天,我就来带你认真剖析一下这篇发表在《自然》杂志上的名为《不依靠人类知识掌握围棋》(Mastering the Game of Go without Human Knowledge)的论文 。标题中的不依靠人类知识当然有一点夸张,不过这也正是这篇论文的核心思想,那就是仅用少量甚至不用人类的数据来驱动人工智能算法。在之前的很多人工智能模型和算法来看,这是一个巨大的挑战。 + +作者群信息介绍 + +文章共有17位作者,都来自伦敦的谷歌DeepMind团队。AlphaGo的第一篇论文也是发表在《自然》杂志,当时有20位作者,比较起来,这篇论文的作者数目减少了。另外,虽然两篇论文的主要作者都是三名(共同贡献),但是这三个人发生了一些变化。下面,我就介绍一下本文的三个主要作者。 + +第一作者大卫·希尔维(David Silver)目前在DeepMind领导强化学习(Reinforcement Learning)的多项研究。大卫的经历很传奇,早年曾在南非生活和学习,1997年从剑桥大学毕业后,先到一家名为Elixir Studios的游戏公司工作了好几年。然后到加拿大的阿尔伯塔大学(University of Alberta)学习机器学习,特别是强化学习。他当时就开始尝试开发用计算机算法来进行围棋博弈。大卫2013年全职加入DeepMind,之后迅速成了DeepMind在强化学习,特别是深度学习和强化学习结合领域的领军人物。 + +第二作者朱利安·施瑞特维泽(Julian Schrittwieser)是谷歌的一名工程师,他长期对围棋、人工智能感兴趣。值得注意的是,朱利安这次成为主要作者,而在之前的第一篇文章中还只是普通贡献者,可以推断在AlphaGo Zero这个版本里有相当多的工程优化。 + +第三作者卡伦·西蒙彦(Karen Simonyan)是DeepMind的一名科学家,长期从事计算机视觉和人工智能技术的研究。他来自2014年DeepMind收购的一家名为Vision Factory的人工智能公司。卡伦最近几年的论文都有高达几千的引用量。 + +论文的主要贡献 + +首先,这篇论文的主要“卖点”就是较少利用、或者说没有利用传统意义上的数据驱动的棋局。第一篇论文里的AlphaGo以及后面的一些版本,都是主要利用人类的棋局作为重要的训练数据,采用监督学习(Supervised Learning)和强化学习结合的形式。在AlphaGo Zero这个版本里,人类的棋局被彻底放弃,而完全依靠了强化学习,从完全随机(Random)的情况开始,“进化”出了具有人类经验的各种走法的围棋策略,并且达到了非常高的竞技水平。可以说这是本篇论文的核心贡献。 + +在核心的模型方面也有不少改进,比如一个很大的改进就是把策略网络(Policy Network)和价值网络(Value Network)合并,这样就能更加有效地用简单统一的深度模型来对这两个网络进行建模。另外,整个模型的输入特征也有变化,从深度模型提取特征外加人工挑选特征,到这篇文章提出的完全依靠棋盘的图像信息来自动抓取特征,可谓是减少人工干预的一个重要步骤。 + +文章的另一大看点是实验结果。作者们展示了新的AlphaGo Zero模型能够战胜之前很多版本的模型,最令人惊奇的可能莫过于AlphaGo Zero在“自学”的过程中,还“悟”到了很多人类在围棋学习过程中领悟的棋局招数。 + +论文的核心方法 + +AlphaGo Zero模型的核心起源于一个简单的深度网络模型。这个深度网络的输入是棋盘当前位置的表达(Representation)以及过去的历史信息,输出一个走子的概率以及对应的价值。这个价值是用来描述当前棋手能够赢的概率。刚才我们已经说了,这个深度网络集合了策略网络和价值网络,形成了这么一个统一的评价整个棋盘的神经网络。在具体的网络架构方面,AlphaGo Zero采用了计算机视觉领域最近流行的残差架构(ResNet),可以说也是这个方法的一个小创新。 + +有了这个基本的神经网络之后,作者们就需要和强化学习结合起来。具体来说,在每一个位置的时候,算法都会去执行一个蒙特卡罗树搜索(Monte Carlo Tree Search),对当前的神经网络模型输出的走子策略进行一个修正,或者可以认为是“加强”。这个蒙特卡罗树搜索的输出结果依然是走子的概率,但是这个概率往往比之前单从神经网络得到的要强。然后,更新神经网络的参数,使得参数尽可能地接近蒙特卡罗树搜索的结果。 + +那么,什么是蒙特卡罗树搜索?简单来说,就是我们从当前的棋盘情况出发,对整个棋盘产生的所有可能性进行有限制情况的搜索,也就是说,不是“穷举法”。大体说来,从某一个可能性走到下一个可能性主要是依靠下一个可能性发生的概率,以及通过神经网络来判断是否能赢的可能性。 + +整个算法最开始的时候是从随机的位置初始化,然后通过对神经网络的更新,以及每一个迭代通过利用蒙特卡罗树进行搜索,从而找到更加合适的神经网络模型的参数,整个算法非常简单明了。不管是结构上还是复杂度上都比之前的版本要简洁不少。文章反复强调公布的算法可以在单机上运行(基于Google Cloud的4 TPU机器),相比于最早的AlphaGo需要使用176个GPU,也可以看到整个模型的进化效果。 + +方法的实验效果 + +AlphaGo Zero的实验效果是惊人的。从模拟中看,大约20小时后,这个版本的模型就能够打败依靠数据的监督学习版本的AlphaGo了。而到了40小时后,这个版本已经可以打败挑战了李世石的AlphaGo。也就是说,不依靠任何人类棋局,AlphaGo Zero在不到2天的运算时间里,就能够达到顶级的人类水平。 + +除了可以打败之前的AlphaGo版本以外,这个版本相比于监督学习的版本,在大约20小时以后也可以更好地预测人类对战的走子。并且随着训练时间的推移,这种预测的准确性还在不断提升。 + +刚才我们也提到了,AlphaGo Zero在自我训练的对战中,在不依靠人类数据的情况下,的确是发现了相当多的人类熟悉的对战套路。然而,有一些人类在围棋历史中较早发现的套路却没有或者较晚才在AlphaGo Zero的训练历史中习得。这打开了很多问题,比如发生这样情况的原因究竟是什么等等。 + +最后,作者们展示了AlphaGo Zero非常强大的实战能力,在和之前最强的AlphaGo版本,也就是AlphaGo Master的对战中,AlphaGo Zero取得了100比0的绝对优势。而相同的AlphaGo Master与人对弈的成绩是60比0。 + +小结 + +今天我为你讲了发表在《自然》杂志上的这篇关于AlphaGo Zero的论文,这篇文章介绍了一个简洁的围棋人工智能算法,结合深度学习和强化学习,不依靠人类的信息。 + +一起来回顾下要点:第一,关注这篇文章主要作者的信息,我们可以推断出文章的一些变化方向。第二,这篇文章有两大看点,一是很少或者几乎没有利用人类的棋局数据,二是得到了显著的实验结果。第三,文章提出的核心模型将策略网络和价值网络合并,与强化学习相结合。 + +最后,给你留一个思考题,有人说AlphaGo Zero并不是完全不依靠人类信息,比如围棋本身的规则就是很强的监督信息;再比如,不管每一步的走动如何,棋局最后是输是赢,依然是很强的信息。那么,AlphaGo Zero到底是不是还是依赖了很强的数据呢?我们能不能把AlphaGo Zero看做是监督学习的产物呢?你怎么看? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/1522017人工智能技术发展盘点.md b/专栏/AI技术内参/1522017人工智能技术发展盘点.md new file mode 100644 index 0000000..01d127d --- /dev/null +++ b/专栏/AI技术内参/1522017人工智能技术发展盘点.md @@ -0,0 +1,61 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 152 2017人工智能技术发展盘点 + 今天是大年初一,在这里先给你拜个年,祝新年新气象,新年新开始! + +在今天这个辞旧迎新的日子里,我们对过去一年的人工智能技术发展做一个简单的盘点,梳理思路,温故知新。 + +2017年,对于人工智能整个领域的发展,是举足轻重的一年。这一年,人工智能的各个领域都蓬勃发展,我们目睹了一些在AI发展史上的标志性事件。比如,从人工智能的技术上看,人工智能系统AlphaGo Master与人类世界实时排名第一的棋手柯洁展开围棋人机对决,最终连胜三盘;从人工智能的投入上看,很多互联网公司都先后成立单独的人工智能研发机构,像阿里巴巴的达摩院;从云服务和人工智能结合的发展来看,2017年谷歌在这一方向发展迅猛,不仅在中国开设了研发中心,还宣布已经有超过万家企业和组织正在使用谷歌的人工智能接口。 + +今天我希望能够从几个关键领域和发展方向出发,在繁多的科技进步中,理清关键信息,对过去一年的产业动态和发展做出点评,给你一个清晰而简单的信息参考。 + +人工智能在棋牌上的迅猛发展 + +2017年的一个标志性的事件,无疑是AlphaGo在围棋这项运动中的“收官”表现。虽然2016年AlphaGo战胜李世石之后,很多人依然对人类能够在围棋这个古老的运动中有所发挥保留着期望,也给予其他围棋选手以希望。然而,5月27日,AlphaGo Master与当时人类世界实时排名第一的棋手柯洁展开人机对决并且直接连胜三盘,可以说这个结果完全摧毁了人类在这个项目上的希望。随后,AlphaGo团队的负责人德迈斯⋅哈萨斯(Demis Hassabis)宣布,乌镇围棋峰会将是AlphaGo参加的最后一场赛事,这也意味着AlphaGo以完美的表现“收官”。 + +几个月后的10月,DeepMind团队在《自然》杂志上发表了一篇文章,介绍了AlphaGo Zero,一个没有用到人类棋局数据的AlphaGo,比以前任何击败人类的版本都要强大。通过跟自己对战,AlphaGo Zero经过3天的学习,以100:0的成绩超越了AlphaGo Lee的实力,21天后达到了AlphaGo Master的水平,并在40天内超过了所有之前的版本。这种完全不依靠人类棋局的办法,并且能够通过自我训练达到最高人类水平,可以说是让人工智能界目瞪口呆。我们在之前的分享中曾经详细介绍了这篇论文的核心内容([精读AlphaGo Zero论文])。 + +从2016年开始到2017年年底,短短一年多的时间内,AlphaGo经历三次重大进化,并以非常完整的形式在《自然》杂志上总结最后成果,不得不让人惊叹人工智能在这一方向上发展的神速。 + +另外一项成就和AlphaGo在围棋上所取得的成就旗鼓相当,那就是来自卡内基梅隆大学团队的“利不拉图斯”(Libratus)在宾夕法尼亚州匹兹堡的“里维斯”(Rivers)赌场战胜四位德州扑克顶级选手获得最终胜利。这个胜利背后的一些原理已经被团队发表在了NIPS 2017的论文中,并且这篇论文也获得了NIPS 2017的最佳论文。我们在之前的NIPS 2017最佳论文推荐中也介绍了这方面的内容([精读NIPS 2017最佳研究论文之三:如何解决非完美信息博弈问题?])。相比于围棋来说,德州扑克这种非对称信息博弈的难度应该说更大,而卡内基梅隆大学团队的成绩在未来应该会有更大的发挥空间。 + +计算机视觉的发展 + +自从和深度学习紧密结合以来,计算机视觉在最近的5~6年里迅猛发展。2017年,在像素级别的分割工作上有了一个不错的进展。通俗地讲,就是给定一个输入的图像,我们希望能够不仅分析这个图像里究竟有哪些物体,还能够对于图像中的每一个像素,知道其属于哪一个物体,也就是我们经常所说的,把物体从图像中“抠”出来。 + +来自Facebook人工智能研究院的明星团队在ICCV上的两篇最佳论文可以说给这个问题提供了非常漂亮的解决方案。 + +第一篇的重要进展是Mask R-CNN这篇文章中,作者们提出了“两个阶段”的策略。第一个阶段叫做“区域提交网络”(Region Proposal Network),目的是从图像中提出可能存在的候选矩形框。第二个阶段,从这些候选框中使用一个叫RoIPool的技术来提取特征从而进行标签分类和矩形框位置定位这两个任务。Mask R-CNN主要是针对第二部分进行了更改。也就是说,不仅仅在第二部分输出区域的类别和框的相对位置,同时,还输出具体的像素分割。 + +和很多类似工作的区别是,像素分割、类别判断、位置预测是三个独立的任务,并没有互相的依赖。这是作者们认为Mask R-CNN能够成功的一个重要的关键。之前的一些工作,像素分割成为了类别判断的依赖,从而导致这几个任务互相有了干扰。我们在介绍ICCV 2017最佳论文中已经对这篇文章有了详细的论述([精读2017年ICCV最佳研究论文]()。 + +另外一个重要工作则是“焦点损失(Focal Loss)”,这也是图像分割的一个重要进展。如果说Mask R-CNN是“两个阶段”的的代表作的话,焦点损失则是在“一个阶段”工作上有了显著进展。焦点损失要解决的问题,就是对输入图像进行物体识别和语义分割这两个任务。和两阶段的模型不同的是,一个阶段模型是希望直接从输入图像入手,希望能够从输入图像中提取相应的特征,从而可以直接从这些特征中判断当前的图像区域是否属于某个物体,然后也能够一次性地找到矩形框的位置用于定位这个物体。 + +这种思路虽然直观,但是有一个致命的问题,那就是对于一个输入图像来说,大量的区域其实并不包含目标物体,因此可以认为是学习过程中的“负例”(Negative Instance)。如何能够有效地学习这么一个“不均衡”(Imbalanced)的数据集是这一种思路需要考虑的问题。 + +焦点损失提出了一个新的目标函数,用于取代传统的交叉熵(Cross Entropy)的目标函数。这个新的目标函数的主要目的就是让一个阶段模型能够在正负例比例非常不协调的情况下,依然可以训练出较好的模型,使得一个阶段模型在效果上能够和两个阶段模型媲美。我们在之前的论文分享中已经详细介绍过这个工作的内容([精读2017年ICCV最佳学生论文])。 + +人工智能研究机构的发展 + +2017年我们见证了越来越多的公司和机构设立人工智能研发团队。 + +10月11日,在阿里云栖大会上阿里巴巴 CTO 张建锋宣布阿里巴巴成立全球研究院。达摩一成立就打算在一系列的领域做出成绩。从宣布的战略看来,达摩希望涉足量子计算、机器学习、基础算法、网络安全、视觉计算、自然语言处理、人机自然交互、芯片技术、传感器技术、嵌入式系统等,涵盖机器智能、智联网、金融科技等多个产业领域,整个布局可谓雄心壮志。 + +第一步的计划是在全球各地组建前沿科技研究中心,包括亚洲达摩院、美洲达摩院、欧洲达摩院,并在北京、杭州、新加坡、以色列、圣马特奥、贝尔维尤、莫斯科等地设立不同研究方向的实验室,初期计划引入100名顶尖科学家和研究人员。这样的布局会对阿里的中长远发展有什么帮助,我们拭目以待。 + +很多公司在人工智能团队的布局也可谓争先恐后。一个比较突出的代表是今日头条。互联网的新贵今日头条2017年扩张明显。2月14日,前微软亚洲研究院常务副院长马维英离职微软,加入今日头条,出任副总裁,管理今日头条人工智能实验室。之后,任职华为诺亚方舟实验室主任的李航博士也选择离开了华为,加入今日头条人工智能实验室。如果加上之前比较知名的李磊、王长虎,今日头条的阵容可谓兵强马壮。这样的人员配置能够在今后的发展中对今日头条产生多大的影响呢?这一点还需要时间的检验。 + +年底,谷歌的人工智能团队在李飞飞博士和李佳博士的带领下,高调在北京开设分部,成立一个致力于人工智能的中国中心。在公开的新闻稿里,谷歌认为北京有在未来技术领域拥有发言权的野心,这种野心推动了中国政府的大力投入,让国内在人工智能领域展现出日益增长的实力。谷歌已在北京有数百名从事研发和其他工作的员工。谷歌中国人工智能中心将由李飞飞和李佳领导。李飞飞是斯坦福大学人工智能实验室的负责人,领导着谷歌云业务的人工智能部门。李佳是谷歌云业务的人工智能部门研发主管。 + +小结 + +今天我为你简单梳理了2017年人工智能领域发生的一些事情,希望能够帮助你去回顾这一年。人工智能的蓬勃发展,表现在每天都有太多的新技术新动态,这里我们只是以点带面,让你感受到过去这一年行业的飞速变化。 + +对于2017年人工智能领域的发展,你有怎样的感受和体验呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/153如何快速学习国际顶级学术会议的内容?.md b/专栏/AI技术内参/153如何快速学习国际顶级学术会议的内容?.md new file mode 100644 index 0000000..918e13b --- /dev/null +++ b/专栏/AI技术内参/153如何快速学习国际顶级学术会议的内容?.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 153 如何快速学习国际顶级学术会议的内容? + 我们在专栏中已经多次分享了计算机科学(特别是机器学习、数据挖掘和人工智能)领域顶级学术会议的会议论文,向你介绍了这些会议中一些最新的学术和研究成果,希望这些内容能够起到抛砖引玉的作用。 + +最近今年,随着机器学习和人工智能的逐渐火热,越来越多的研究人员投入到这些领域中,这些顶级会议的投稿论文数和收录稿件数都成倍增长,不少会议会有几百篇论文被收录。这就给希望学习这些论文的人,不仅是初学者,甚至是长期在这些领域工作的研究人员和实践者,都带来了不小的挑战。 + +现实情况下,尝试去看完一个会议的所有论文是不现实的,从某种程度上看也是不必要的。那么,怎样才能从这些会议中快速地、有效率地吸收到更多的内容呢?想要事半功倍地学习顶级学术会议的论文,究竟有没有什么方法可以帮助我们实现这个目的呢? + +今天我就结合自己的经验,和你分享如何快速学习顶级会议的内容。 + +顶级会议的基本内容架构 + +在学习任何顶级会议的论文内容之前,我们其实最需要了解的就是这些会议本身有什么规律,或者说会议的内容安排上是不是遵循了一定的逻辑架构。这里,我们首先来看一看这些顶级会议在内容安排上的规律。 + +毫无疑问,所有顶级会议的核心内容是收录的论文。这一点,我已经反复提及了。有一个细节可能是初学者容易忽视的,那就是很多会议都对收录的论文进行了区分,用于区分出最优秀的论文和一般的论文。 + +这种区分一般有两种形式。第一种形式是论文在会议上的表现形式有区别。最优秀的少量论文是时长20~30分钟的演讲报告,而其他大量的论文是时间很短的报告,或者有的会议干脆就没有这些论文报告时间。第二种形式是这些论文在会议论文集中发表的长度有区别,这也就有了所谓长论文和短论文的区分。通常情况下,长论文是8~11页,而短论文则是2~6页不等。 + +除了收录的论文以外,顶级会议往往还有很多精彩的内容。你可以关注的第二个有价值的内容形式是“讲座”(Tutorial)。“讲座”的形式常常有半天的、全天的,以及超过一天由几个研究者针对某一个特定主题的集中式分享和讨论。一般来说,“讲座”往往是一些最新的热点问题,但同时有了一整套阶段性的成果,这时候,在这个领域有突出贡献的研究者,就利用“讲座”的模式把这些内容给总结出来进行分享。 + +第三种你可以关注的内容形式就是“研讨班”。“研讨班”是针对某一个特定的近期热门的主题,由一些学者或者工程师组织的小型会议。和“讲座”一样,“研讨班”往往也是半天到一天不等。“研讨班”被认为是小型会议,因此有时候也还会收录一些论文。然而,这些论文的数量和质量都有很大的差别。 + +从组织的角度来讲,“研讨班”的目的主要是吸引更多人对某个主题的关注,以及给目前针对这个主题工作的研究者们提供一个交流的场所和平台。从这个角度来讲,很多“研讨班”分享的内容可能都是“还在进行的工作”(Work In Progress)。所以,“研讨班”所关注的热点往往比“讲座”还要激进。近年来,机器学习和人工智能的热点越来越多,有的会议有多达十几个甚至二十几个“研讨班”在大会期间举行。 + +最后,还有一个可以关注的则是很多会议和研讨班中都有的“邀请演讲”(Invited Talk)或者叫作“主题演讲”(Keynote Talk)。如果是亲自参加这些会议或者有视频录像,那么这些演讲往往还是值得一听的。从会议组织的角度来说,这些演讲往往是邀请在本领域有重大贡献的学者或者是相关领域的权威学者。因此,通过这些演讲,我们可以了解到这些领域的重要学者都在针对什么问题进行思考,以及他们眼中这些领域的发展方向是怎样的。 + +上面介绍了几个顶级会议值得关注的点,需要注意的是,并非所有会议都有“研讨班”或者“讲座”。 + +如何精选内容 + +了解了这些顶级会议内容的一个基本架构以后,我们现在来聊一聊如何从这些不同形式的内容中精选出你可以学习的信息。 + +首先,我们来说一说论文。刚才提到了,论文可能分为长论文、短论文以及报告论文和非报告论文。那么,有一些比较粗浅的方法可以帮助你筛选。例如,针对长论文和短论文,可以首先关注长论文。对于报告论文和非报告论文,可以先关注报告论文。利用这种简单的方法,我们已经过滤掉了至少一半以上的论文数量。这里的一个基本假设是,会议安排这些论文的区分其实就是一个最基本的质量控制。 + +另外,很多会议把所有的论文分为不同的“组”(Track)。每一个组其实有一个主题,例如搜索、推荐、用户建模等等。这其实也是帮助你通过论文内容的类别进行区分。所以,如果你已经知道自己感兴趣的内容,那就可以直奔主题。再庞大的会议,针对某一个主题的论文数目往往也是非常有限的。 + +还有一种便捷的方法就是“跟踪”某一些学者。当然,这需要你对这些领域有哪些活跃的学者、有哪些权威的学者要先有一个了解。你心里有这么一个清单,那么,只关注这些你清单上的学者在这个会议上发表的论文就是一个非常简洁的方法。 + +举一反三,还有一些和追踪学者类似的办法,比如你可以追踪某几个研究组或者公司。当然,这也需要你对这些领域有一定的了解。 + +对于论文来说,另外一个精选内容就是获奖论文。虽然对于获奖论文在长期来看是不是最具有学术价值一直存在争议,但是作为快速学习和扩宽视野的方法,浏览这些获奖论文以及获奖作者的一些附带研究,可以说是了解一个领域的另外一个捷径。 + +说完了论文之后,我们来看一看“讲座”。其实,这里也有一个窍门,那就是如果你是一个初学者,我建议你不要一上来就去看一个会议的论文,而是从“讲座”入手。 + +我们前面提到过,“讲座”是学者已经总结好的最近已经形成一定系统性的研究成果。这里,“总结好”和“系统性”这两个特点非常重要。这意味着“讲座”里面的内容往往不是一篇论文的介绍,而是多篇相对于一组主题论文的总结。这一组论文以及相关主题极有可能已经形成了一个稳定的内容块,而“讲座”的目的就是希望更多人认可、学习、普及这个内容块。有很多“讲座”的内容在几年后就成为了这些领域核心经典思想的一部分。因此,学习“讲座”其实就是一个走捷径的过程。 + +另一方面,因为“讲座”具有总结性质,所以肯定会提供很多具体论文的文献资料,这些资料可以作为进一步学习的“指南针”。正因为这个性质,对于“讲座”内容的把握你可以做到先从概念上和大思想上对一个主题进行把握,例如看你自己能不能简单地把这个“讲座”中的核心技术、核心思想复述出来。同时,你也可以来衡量一下到底需不需要对这个“讲座”具体指引的论文进行阅读。绝大多数的时候,我们对于“讲座”的把握在这个层次其实就够了。 + +最后,我们来说一说“研讨班”的内容。和“讲座”不同的是,“研讨班”的内容因为其性质,往往比较杂。我们刚才也提到了,“研讨班”重在为一线的研发人员提供一个交流的平台。因此,他们的交流是第一目的。而且,“研讨班”的很多方向和主题都比较新,因此对于初学者而言,需要甄别哪些内容值得细看哪些内容可以忽略。总体说来,这里面的内容都不推荐给初学者。当然,如果你对某一个领域已经追踪了一段时间,有了一定的了解,那么“研讨班”无疑是最前沿的讨论平台之一。 + +小结 + +今天我为你梳理了如何快速学习顶级会议的内容,希望这篇文章可以帮助你在海量的会议内容中找到自己感兴趣的信息。 + +一起来回顾下要点:第一,我们讲了顶级会议的内容架构;第二,我们简要地介绍了如何在这些内容中进行快速筛选。 + +最后,给你留一个思考题,拿到一篇10页的长论文,究竟该如何快速阅读呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/154在人工智能领域,如何快速找到学习的切入点?.md b/专栏/AI技术内参/154在人工智能领域,如何快速找到学习的切入点?.md new file mode 100644 index 0000000..be83fd9 --- /dev/null +++ b/专栏/AI技术内参/154在人工智能领域,如何快速找到学习的切入点?.md @@ -0,0 +1,67 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 154 在人工智能领域,如何快速找到学习的切入点? + 到现在,我们专栏的人工智能核心技术模块就已经全部介绍完了,我们讲了推荐系统、搜索系统、广告系统、自然语言处理及文本挖掘、计算机视觉这五大模块。相信你对这些领域都有了一个最基本的认识。同时,我们还对一些前沿领域的学术会议进行了专项的讨论,和你分享了一些读论文的经验。 + +这个过程中,我收到了很多反馈,在人工智能领域,面对日新月异的新模型、新方法、新思路,很多人都感到非常难找到切入点来学习和提高。那么,今天我就来分享一下如何快速入门人工智能领域,帮你找到一些学习的捷径。 + +关注知识主干 + +我们已经介绍了这么多知识模块,不知道你是否注意到,在每个人工智能的子领域里,真正的知识主干其实是非常有限的。我这里说的“主干”是指构成这个子领域的重要的假设、思路和方法。如果你能够掌握这些主干的内容,那么也就能够相对容易地了解其他枝节信息了。 + +之所以要了解主干内容,还有一个原因。从时间这个尺度上来看,有一些技术和思想在某个时期曾经有很大的影响力,但是随着时间的变化,它们会被后来更新的技术所取代。因此,只抓主干,也会比较容易看清楚不同类型的技术的时效性。 + +那么,我们怎么去了解什么是知识主干呢? + +对于比较成熟的子领域,或者是传统领域,我们可以依靠教科书。一说起教科书,很多人的第一反应就是枯燥。确实,教科书对于很多知识点的描述过于细节,但是从大的知识块上来讲,教科书还是能够帮助初学者尽快把握重要的知识主干,是一个非常高效的途径。 + +对于一些比较新的、发展迅速的领域,知识的重要结构发生了很大的变化。在这些领域,要么我们还没有成形的教科书,要么就是即便有,教科书的内容也可能是过时的。所以,比较新的领域我们往往需要借助其他的信息途径。 + +在这里,我给你介绍两个途径。 + +第一,“文献综述”(Survey)论文。这一类论文的目的就是对一个较新的,而且是快速变化的领域做一些阶段性的总结。从某种意义上来说,我们其实可以把文献综述看成是另外一种“教科书”。既然某个领域变化快速,那么针对这个领域知识的文献综述就会有很强的时效性。所以,阅读文献综述时,我们需要关注发表的时期,以确保文献的新鲜度。 + +第二,顶级学术会议的“讲座”(Tutorial)。我们之前在如何学习会议内容的分享中提到,讲座往往包含了一些最新的热点问题,同时针对这些问题的研究也有了一整套阶段性的成果,这时候,在这个领域有突出贡献的研究者,就利用“讲座”的模式把这些内容给总结出来进行分享。换句话说,关注这些讲座,也就相当于了解了某个领域的文献综述。 + +值得说明的是,关注知识的主干内容这一方法其实是可以“循环使用的”。什么意思呢? + +举个例子,例如我们要了解深度学习究竟有哪些最基本的内容。在这个层次上的主干内容就有前馈神经网络、卷积神经网络和递归神经网络。那么,接下来,我们就需要大概了解这三种不同的神经网络究竟是什么,解决什么样的问题。层层递进,对于某一个神经网络,比如卷积神经网络,我们要了解的主干知识有“卷积是什么意思”,“这种结构要达到什么目的”。 + +总结一下,不论是学什么内容,第一个思路就是先看看它的主干知识是什么,养成这样一个思考习惯,你的学习效率就会大大提升。 + +关注知名学者 + +针对人工智能领域入门的另外一个“捷径”是关注你所喜欢领域的学者的研究动态。 + +在一些人工智能的子领域,经常出现这样一种局面,那就是少数学者的研究成果奠定了整个领域的重大发展。你有没有发现,这其实也就是另外一种意义上的主干内容。追踪和学习这些学者的研究成果有两个目的:一方面可以了解到最新的动态;另一方面可以让学习有的放矢,达到事半功倍的效果。 + +如果我们针对一个领域搜集到五个左右的学者,然后关注这些学者本人以及所在机构或者实验室的成果,那么一般情况下,你阅读了差不多10篇论文后,就基本能够把握住这个方向发展的大概内容。 + +需要注意的是,我们这里所说的知名学者,主要还是指在某一个领域有重要贡献,特别是贡献了核心思想的学者。对于这些学者,我们不能仅仅关注他们论文的数目,还是要看论文的影响力。对于绝大多数人工智能领域而言,我们可以很容易地在网上找到知名学者的名字和介绍。 + +补充一点,如何在一个完全陌生的领域寻找知名学者呢?寻找这类学者的一个简单的方法是先看这个领域引用度最高的前几篇论文,然后找到这些论文中经常出现的某个学者的名字,然后根据这个学者的名字稍微查询一下,看一看这个学者过去的一些论文发表经历,从而判断这个学者是不是在某个领域持续进行研究。 + +深入学习少量的模型 + +对于很多人工智能的初学者而言,各种眼花缭乱的模型往往让人会觉得不知所措。这个时候,与其一头扎入各式各样层出不穷的新模型里,还不如扎扎实实学懂吃透少数几个模型。这个思路也符合软件领域我们常说的“T型”人才模型,广度很重要,但是没有深度的广度,很多时候就没有价值。先有了深度,广度的拓展就会更容易。 + +比如,从工业界的应用角度来说,我们一般需要掌握的模型就那么几个。可以说,如果你掌握了线性模型、树模型和神经网络模型,就可以解决超过80%甚至更多的业务需求了。那么,我们要做的就是深入细致地学习这三种模型,而不是去了解几十种甚至上百种模型,但是每一种都只是了解了一点皮毛内容而已。 + +那么,对于这些核心内容,多深入才算是深入学习呢? + +首先,也是最基本的,我们要“理解”这些模型的最基本的原理。其次,最好能够从数学的角度来理解这些模型的数学推导。再次,还要理解这些模型的“模型本身和优化算法的区别”,以及各种优化算法的好坏。最后,我们最好能够自己动手写一写这些模型的基本实现。另外,还有很多方面能够帮助我们进行深入学习,比如这些模型会有什么特点?在什么场景下比较适用?这些问题都是非常好的参考。 + +小结 + +今天我为你梳理了快速入门人工智能领域的几个思路,希望你能在学习的过程中快速定位到有用的信息,提高自己的学习效率。但是,有一点我想推心置腹地跟你说,虽然我们今天分享的内容叫作如何快速入门,但是要想在这个领域真正获得成长,我们更需要有一颗平稳的心,要抱持着一种愿意踏踏实实学习基础的态度,这一点是非常必要的。换句话说,成长为一名有能力的数据科学家或者人工智能工程师,往往并没有太多的捷径可走。 + +一起来回顾下要点:第一,我们讲了抓住内容主干的思想;第二,我们简要地介绍了如何跟踪知名学者;第三,我们讨论了深入学习少量模型的好处。 + +最后,给你留一个思考题,你现在要学习一个新模型,是要深入了解模型的数学细节,还是仅仅知道一个大概信息就可以,你怎么做判断呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/155人工智能技术选择,该从哪里获得灵感?.md b/专栏/AI技术内参/155人工智能技术选择,该从哪里获得灵感?.md new file mode 100644 index 0000000..7fb86c2 --- /dev/null +++ b/专栏/AI技术内参/155人工智能技术选择,该从哪里获得灵感?.md @@ -0,0 +1,53 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 155 人工智能技术选择,该从哪里获得灵感? + 在上一讲的内容里,我们分享了在人工智能领域快速学习的一些方法。学习到这些人工智能技术后,我们肯定是希望能够把技术应用到工程实践中,来解决实际产品的问题。 + +这里面有一个非常重要的问题,就是面对这些论文、或者是别人分享的工程实践,作为初学者或者是有一些经验的工程师,我们应该如何来选择技术呢?是去实现当前最新论文介绍的技术?还是跟随其他公司已经有了的架构? + +今天,我们就来聊一聊人工智能领域的工程技术选择这个话题。 + +如何看待学术论文和技术选择的关系 + +首先,我们来看一看论文和工程技术选择之间的关系。我们是不是需要去实现最新论文里的模型呢? + +在回答这个问题之前,我们来聊一下学术论文本身的一些特点。 + +学术论文,作为科学知识和信息传播的一种载体,本身是有其内在的使命和一定的规律的。学术论文的核心功能是在一定的规范框架下,传播、总结和讨论新知识。这里面我们需要注意的是“一定的规范框架”和“新知识”。 + +我们先来说说新知识,这其实是整个学术论文存在的核心价值。很多新知识其实是某种意义上的学术尝试和创新,例如提出的一种新观点、一种新思想、一种新方法或者是一种新算法。这些新知识是不是能够沉淀下去成为某种主流思想,或者说是否能够在实际系统中真正发挥作用,还有待时间的检验。换句话说,新知识的知识结构还不完整,我们并不知道这些知识点后续会有什么样的发展,我们并不能简单地在学术创新和实现现实意义之间划等号。 + +因此,冒然利用最新的技术往往会因为技术不成熟、信息不完整而带来很多风险。举个例子,如果我们在2009年的时候就采用了深度学习技术,当时的很多方法,例如“事先训练”(Pre-Training)等,在后面更加成熟的技术面前都已经被遗弃了。很明显,当时的深度学习框架远远没有现在的成熟,所以就会有很大的前期工程成本。 + +再举个例子,在最基本的“随机梯度下降”算法上进行改进的优化算法,绝大多数都是在理论上对收敛速度的改进。而这些收敛速度基本上都取决于当数据点趋近于无穷多的时候。那么,这种改进其实并没有太大的现实意义。 + +我们刚才还提到学术论文都在“一定的框架范围内”,其实就是说目前计算机科学的绝大多数论文都遵循某种格式和写作要求,也可以说是某种程度的“八股论文”式的写作。这种格式虽然统一了论文发表内容的差异性,但是过分注重在某个标准数据集上的一些性能提升,就有可能发明了一些在其他数据集上完全无法推广的技术。比如,有的论文甚至把在某些标准数据集上非常小(例如小于1%)的提升当作重要的创新。显然,这样的成果往往都无法在工程环境中复现,又何谈现实意义呢? + +说了这么多学术论文的特点,我们再回到刚才最开始的话题,那就是学术论文和工程选择的关系。学术论文提供了一种思路,帮助我们开阔眼界,但是切记,我们不能把学术论文当作是金科玉律和亘古不变的真理。相反,对于每一篇学术论文,我们要带着批判的眼光,有所保留地来看待论文背后的创新点。 + +那学术论文中的思路是不是就完全不要去采用呢?其实也不是。当我们发现有一系列的学术论文,或者很多学术会议都在探讨某个类似的技术时,那么这个技术也许就值得我们尝试去实现一下,来做一些早期的评测,看这样的技术在自己工程环境的数据中是不是依然有效果。 + +如何看到工业界技术分享和技术选择的关系 + +除了正规的学术论文以外,在人工智能火热的今天,各类工业界的技术分享也是层出不穷,这里面的内容水平和可复制性可以说是千差万别。那么,我们到底应该如何看待这些技术分享呢?我总结了两点给你借鉴。 + +第一,一般来说,很多公司的技术分享其实都是只言片语或者说一个整体平台的片段。因此,如果我们直接把这些分享用于自己团队的工程实践中,往往就会产生断章取义的效果。 + +实际上,一个公司的技术选择和这个公司在做选择那个时间段的很多因素都有着密不可分的关系。比如,一个比较大的机器学习系统往往和公司的其他业务需求、数据基础、机器学习基础设施以及团队当时的工程水平等因素相关。某一个系统在一个时期内的形态就是这些相关因素的一个综合体现。然而,技术人员的分享内容很难涵盖所有的方方面面,使得我们容易忽略某个技术选择和公司以及团队的这种内在的紧密联系,而单纯考虑技术分享的内容。因此,尽量去理解其他公司的技术选择思路,要比只关注所分享的内容本身更重要。 + +第二,公司的很多技术选择并不是同行审议的结果,需要我们带着批判的眼光去看待。这一点是和学术论文相比较而言的。一般来说,一篇高水平的论文要想发表在顶级学术会议(或者期刊)上,往往都需要得到同行的审议。这种审议的重点一般会看论文是不是比较了类似方法,以及能否真正说清楚论文的创新点和贡献在哪里,这其实就是对论文质量的某种程度的把关。那么,这个同行审议的环节在公司做技术选择的过程中是不一定存在的。很多公司的技术发展路线选择都不能摆脱种种外部的和内部的限制,最后的结果并不是无懈可击或者不容置疑的。 + +所以,我们不太可能直接复制其他公司的技术框架,但是这些公司的技术分享对我们的好处也是显而易见的。我们一定要抱着开放的心态来学习这些分享中的精华之处,重点是能够理解各个组件之间的逻辑关系,以及一些关键技术点选择背后的推理过程和取舍过程,这种思考对于我们的提高是非常有帮助的。 + +小结 + +今天,我们从如何借鉴学术论文和工业界技术分享两个角度,一起来梳理了人工智能技术选择的思路。工程技术选择是一个综合的复杂问题,我们一方面着眼于创新技术本身,一方面着眼于自己公司、产品和团队的实际情况,以批判的眼光吸收新知识和新实践,最后做出最适合自己发展的技术选择。 + +最后,给你留一个思考题,在一个公司的最佳实践分享中,他们提出了一个模型A,大幅度提高了模型B,现在你会如何来看待这个分享呢? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/156内参特刊和你聊聊每个人都关心的人工智能热点话题.md b/专栏/AI技术内参/156内参特刊和你聊聊每个人都关心的人工智能热点话题.md new file mode 100644 index 0000000..9123f90 --- /dev/null +++ b/专栏/AI技术内参/156内参特刊和你聊聊每个人都关心的人工智能热点话题.md @@ -0,0 +1,149 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 156 内参特刊 和你聊聊每个人都关心的人工智能热点话题 + 暖场篇 + + +Q:您本硕博学的都是计算机科学专业,这个专业其实有很多方向,您为什么会选择人工智能这样一个比较新的领域? + + +A:我到美国读博的时候,开始并没有想去学习人工智能,而是想做一些操作系统及软件的研发。到了美国以后,我的导师认为社交媒体的数据挖掘、机器学习应用比较前沿,希望有一个相关课题,当时把新的课题给了我。我从这个课题入手,接触了机器学习,接触了人工智能,然后慢慢地走上了人工智能的发展方向。 + + +Q:您怎么定义人工智能? + + +A:我认为人工智能有两个方面非常重要。一是人工智能是数据驱动的,也就是说一个人工智能产品一定得是有数据支撑的;另一方面人工智能是一个持续的决策过程,比如说AlphaGo、无人驾驶等,都是利用机器学习,以及其他算法进行持续决策的一个过程。在人工智能这个定义中,数据驱动和持续决策应该说是非常重要的两个元素。 + +落地篇 + + +Q:您能不能详细介绍一下目前在Etsy有哪些人工智能的技术是实实在在落地了的?落地的场景是什么?取得了什么样的效果? + + +A:Etsy人工智能应用主要是围绕三块在做。 + +第一块是搜索。其实这也是普通电商的一个场景,你来到电商平台输入一个关键字,Etsy返回一个搜索的结果,这个看上去很简单,好像跟传统的搜索没有什么区别。但实际上电商的搜索,不管是大一点的电商,如亚马逊、京东、阿里,还是小一点的电商,真正能够做到比较完美的搜索体验是非常困难的。比如一部分用户的购买欲望很强烈,那么我们如何能够帮助他找到最合适的商品;也有一部分用户,并没有特别强的购买意愿,这个时候他希望通过搜索一些关键字来获取灵感,从你的平台上能得到一种逛街的体验,所以这个时候,搜索带来的东西就未见得一定是他要买的,如何能够通过搜索给他带来逛街的体验是比较困难的。这块搜索是我们现在一直在做的,并且用不同的模型来进行落地尝试。 + +那么第二个方向就是推荐系统。电商的推荐也是一个老生常谈的话题了,但在一些新的场景下如何做?比如Etsy上有很多的商品是手工艺品,数量可能很少,那么在给用户推荐的时候,必须考虑到它的件数是有限的,这个地方也是有一些难点。 + +第三个当然就是我们的广告平台。如何能够通过人工智能帮助广告提升,广告是一个买卖的双方系统,如何帮助卖家实现好的宣传效果,这个也需要通过算法来更新的。 + + +Q:目前已落地的一些人工智能技术,起到的作用和我们传统的一些办法相比,效果明显吗? + + +A:人工智能在搜索广告和推荐这块效果已经比较明朗了,肯定是比传统的方法做得好,但是它的提升度在不同的情况下区别还蛮大的。我们如果能够让效果最大化,必须跟产品界面,还有前端等融合,形成一个统一的产品,才能让算法得到的结果以最好的形式展现出来。 + + +Q:您认为人工智能落地遇到的最大的一个挑战,或者困难是什么? + + +A:现在人工智能落地最难的并不是算法本身,最难的是和场景的对接。算法本身可能是一个数学描述,是个模型,但是真实的场景跟模型中间还有一段距离。这个距离需要数据科学家进行“翻译”,把我们的场景和算法衔接起来,需要我们的工程师,能够去根据新翻译的场景调整算法。所以不是所有的经典的算法你都能够马上利用,而是需要一些更改,这种更改必须是基于你的场景,你去理解这个场景,并且能够进行合理的翻译,这实际上是最困难的。 + +观点篇 + + +Q:人工智能对程序员就业有什么影响? + + +A:我刚才已经谈到了,数据科学家和人工智能工程师职能有重叠,但也略有不同。打个比方,就像你踢一场足球赛,需要有11个队员,有前锋,有中场,有后卫,有守门员,一个复杂的人工智能产品需要有不同的角色,这些角色都需要有一定人工智能机器学习背景,比如说前端工程师,一些设计的人员,一些产品经理都需要人工智能背景。那么从这个角度看这个问题,我觉得反而是大家的机会更多了,大家可以根据自己的背景,根据自己的喜好,寻找适合自己的角色。 + + +Q:人工智能会在哪个行业爆发? + + +A:我觉得另外一个角度看这个问题更好,有多少行业能够去利用人工智能?就像你刚才提到的,这个人工智能可能会像空气,会像水,会像电一样,成为一个基础设施,我很认同,今年可能会有一些新的契机,我对这方面也很感兴趣。我们也看到,像吴恩达,他想去对传统制造业和人工智能做一些结合,我觉得这个趋势就是一个很好的切入点,不是说现在就能够彻底地改变制造,因为毕竟各行各业有些很深的问题,你得去了解它,但是它可以打开一扇门,让更多的传统行业,比如像制造业的人来了解人工智能,我觉得这个可能会是未来的方向。 + + +Q:现在有一种说法是人工智能就是人工智障,您怎么看? + + +A:今天我们所看到的人工智能,像AlphaGo也好,或者图像识别的一些突破,云技术的一些突破,它的确比10年前,比20年前的技术要好的多,我觉得人工智能的确到了一个好的阶段。同时,我们也必须要承认,人工智能不能做的事情也很多,并且它做绝大多数事情可能不如我们想象的完美。但是我觉得我们也不必给它贴上人工智障的标签,以表达我们的失望之情。人工智能目前还处于一个很初级的阶段,前面还有很长的路走。我自己希望通过做一个传播者和一个教育者,让更多的人了解人工智能。 + + +Q:您觉得人工智能存在泡沫吗? + + +A:我个人觉得现在的确是有些泡沫,比如说媒体的热炒,媒体可能不是很了解情况,然后去下一些断言。但是,我个人感觉现在的泡沫处于一个比较健康的状况,这个泡沫帮助更多的人了解这个行业,帮助更多的人投身到这个行业,帮助更多的社会资源聚焦到人工智能。 + +人才篇 + + +Q:我相信大家都比较关心数据科学团队的招聘的情况,您现在会做很多招聘的工作,您在招聘的过程中有怎样的一个标准,或者最看中候选人的一个特质是什么? + + +A:招聘大概有这么几个方面,一个是对候选人的基础知识的考察,比如说机器学习基础、统计背景、计算机的基本系统的掌握。第二点,我们会给一些场景,去考察这个候选人是否有能力把一个现实的场景跟他所学的挂上钩,他能否用机器学习或人工智能算法作为一种语言去描述这个场景,这个能力也是很重要的。第三个是动手的能力,比如说他是不是会一些最基本的编写代码,或者数据结构等,大概是分这么三块。 + + +Q:您在专栏里面提到过,数据科学家大概有两类,您能具体说说这两类吗?有什么区别呢? + + +A:在国外,数据科学家是一个比较范的统称,大概有两大类。 + +一类是偏数据分析,比如我有一个商业的问题,或者产品的问题,我想去挖掘这个数据,看能不能回答我这个问题,它重在分析,不偏重如何提高产品品质。比如说他分析圣诞期间什么东西卖得比较火,明年男生会不会更喜欢某款相机,回答类似的静态问题。 + +另外一类的数据科学家主要侧重于怎么用机器学习的算法提高我们的搜索、推荐或广告效率,更偏重于驱动产品。这两类数据科学家的技能有一些重叠的部分,比如最基本的机器学习、统计背景、编程背景等,但是他们的侧重方向不一样。 + + +Q:是不是硕士学历及以上才能做人工智能? + + +A:我觉得要分现状和未来两方面。现状是本科生没有全面的一个训练,一些需要高阶技能的部分,可能硕士也没有完整训练过,并不是说,做人工智能就一定要博士生,而是需要的这些技能刚好只有一些博士生满足。但是随着教育和推广的演进,这个门槛会逐渐降低。我跟朋友闲聊的时候经常举一个例子,牛顿发明整个力学体系时候,那会儿全世界可能只有几个人知道,但是现在牛顿力学体系只是高中的一堂课,它的门槛低多了。未来人工智能、机器学习的门槛也会降低,普通人也可以从事相关的工作。 + +学习篇 + + +Q:如何入门和学习人工智能? + + +A:一方面要去看经典的教材和经典的书籍,这个是不可或缺的;另一方面,人工智能这个领域发展太快了,很多的新东西都是在顶级学术的论文里面,去阅读学习论文也是必不可少的。 + + +Q:如何提高看书的效率? + + +A:这个需要在实践中去学,如果需要某个知识点,要去通读这个知识点相关的内容,相同的内容多看一些不同的观点,把它搞懂搞透。有的讲的比较粗的,需要去其他地方找到比较细的论证,有的比较细可能没有抽象到一个高度,在其他地方反而能找到,要互补,这样看书的方式是最高效的。 + + +Q:您前面提到除了看书,看要看国际会议论文。如果让您给大家推荐三个顶尖国际会议,您推荐哪三个? + + +A:我个人觉得,工业界的朋友可以关注这几个会议。 + +第一个是KDD(Conference on Knowledge Discovery and Data Mining,数据挖掘与知识发现大会)。这个会议有个工业组,论文包括一些已经落地的项目经验,或者将要上线的经验,并且在开会的过程中,会跟工业界有互动的环节。你去KDD可以看到很多人在翻译现实中的场景,这对工业界的朋友来说是很有帮助的。 + +另外一个是WSDM(International Conference on Web Search and Data Mining,网络搜索与数据挖掘国际会议)。这个会议缘起于雅虎、谷歌等硅谷公司,认为传统的学术会议不能满足工业界需求,最近几年,工业界的不少解决方案和新问题都是在这个会议上提出的。 + +第三个是CVPR(Conference on Computer Vision and Pattern Recognition,国际计算机视觉与模式识别会议)。这个会议可以帮助很多初学者拓宽视野,让大家知道机计算机视觉是一个非常广的领域,深度学习只是其中的一部分。 + + +Q:外行看热闹,内行看门道,有很多报道只关注谁获得了最佳论文奖,谁获得了终身成就奖,除此之外,您觉得对于这些顶级会议我们还应该关注什么? + + +A:现在这些会议会有上百篇,甚至将近一千篇的论文。作为一个初学者,或者是想进阶的工程师,或者是数据科学家,一个比较有效的方法,就是根据你的领域去了解。像我比较关注搜索、推荐等,我一次性去了解这次会议在该领域大概有多少论文,有些什么新的东西,这就极大的缩小了关注范围,节省了时间。另一个需要去了解这个会议上有哪些新的想法,这些也许跟你现实的工作不直接相关,但是对于你拓宽视野很有帮助。 + + +Q:那针对阅读论文,我们应该怎么入手呢,您的经验是怎样的? + + +A:学习人工智能方面的知识,特别是读论文,很多时候大家觉得难以入手,因为有太多的论文,一个比较高效的方法是先读文献综述。文献综述总结了当前的发展状况,能帮你解决至少两个问题,第一个问题是,了解相关术语,你用这些术语能查到更多相关的东西,如果你连术语都不知道,可能连这些论文查都查不出来,这些综述能帮助你熟悉这个领域的语言,和它里面的一些子问题。另外一个,能够起到文献综述作用的是国际的顶级会议,这些会议会邀请学者做分享,分享往往会有一个PPT,这些PPT会归纳最近几年的发展和观点。通过综述和讲座,你就已经对这个领域有了一个概念性的理解,然后你再去读三到五篇该领域、子领域的经典论文,这是我个人去体会过相对比较高效的一个方法。 + + +Q:小白如何入门人工智能? + + +A:如果你想真正投身到人工智能,我个人建议,还要相对系统地去学习整个知识体系,比如刚才我们聊过的,机器学习、统计、系统概念、最基本的编程的能力等,还有一些工具,比如Python等,磨刀不误砍柴工,但这肯定需要一个过程。 + + +Q:目前有个说法,Python是机器学习第一语言,一般推荐大家先学习Python,您怎么看? + + +A:Python的确是一个比较好的机器语言学习工具,并且现在围绕Python也产生了不少生态系统,使得Python比较适合机器学习、数据分析,或者是一些场景。但这里我必须强调一下,在一个比较完整的端到端的人工智能应用中,特别是工业级的应用中,往往是一个比较异构化的环境。所谓异构化是指不是单一的语言,或者单一的工具就能够完全的把数据转化成你想要的模型,并且能够用这个模型去推动我们的产品,这里面可能有Python的部分,但可能还有Java的部分,有C++的部分等,如果你真想要有一个进阶,成为一个更加全面的工程师,或者是科学家,就不能只掌握Python。 + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/156近在咫尺,走进人工智能研究.md b/专栏/AI技术内参/156近在咫尺,走进人工智能研究.md new file mode 100644 index 0000000..309ebfd --- /dev/null +++ b/专栏/AI技术内参/156近在咫尺,走进人工智能研究.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 156 近在咫尺,走进人工智能研究 + 在前面两次的分享里,我们聊了一些在人工智能领域快速入门的捷径,以及面对不同的论文或别人分享的技术实践,我们该如何来选择技术。我们今天来看另一个话题:如何做人工智能研究。 + +对于初学者来说,可能很多人会觉得人工智能研究离自己比较远,在工作中一时半会儿也接触不到。但实际上,我们有必要了解做研究的一些基本原理,而且这些思路能够应用到我们的日常工作中。 + +对于中高阶的工程师来说,能够在研究方向更进一步,我们就可以更好地理解优秀的学术论文是怎么写成的,能够快速挖掘出论文的核心价值。同时,也可以把自己手上的工作总结成论文发表出去,真正参与到学术社区的建设当中。当然了,即便我们不以写论文为目的,用做研究的标准来要求自己平时的工作,也可以让很多工作更加严谨,这也是一种不错的进阶。 + +什么是人工智能研究 + +那到底什么是人工智能研究呢? + +简单来说,只要是针对某一个问题,我们有新的方法、新的见解或是新的结果,这都是潜在的研究成果。另外一方面,如果我们发现了一个新的问题,甚至是一个新的领域,那就更具备总结出来加以发表的潜质。 + +你是不是已经注意到了,研究的一个核心要素就是“新”。这也是让很多初学者感到困惑的地方:我们平时的很多工作看上去都那么普通,没有什么创新的地方呀! + +其实,哪怕是在人工智能技术应用非常广泛的很多领域和场景,依然有相当多的地方存在创新点,只是初学者不太容易注意到。我这里举几个例子,你可以感受一下。 + +例如,一个互联网网站经常使用在线测试,也就是通常所说的A/B测试,来判断这一个网站的新功能(或者新特性)是否好于现在的版本。一个最基本的设置就是把现在的版本当做“对照组”,然后把新的功能当做“实验组”,分别在50%的流量上运行这两个组别。然而,在线A/B测试存在一些根本上的挑战。 + +第一,我们举例是把整个网站流量的一半用于测试某一个组别,而有一些小的改变在50%的流量上可能需要2~3周的时间才能看出效果。而在真实情况中,拿来进行A/B测试的流量往往是占比更小的,因此等到能够看出一个实验的效果需要很长时间,这本身就是一个非常耗时的过程。 + +第二,如果我们测试的功能和收入相关,那么假设对照组或者实验组有明显的收入增加或者减少,观察到了这样的结果,但是我们无法在实验的过程中动态地调整这个预先设置好的比例。 + +你看,关于传统意义上A/B测试所面临的难题,我们这里仅仅是列举了两条,其实还有很多其他的问题。从这些问题出发,我们是不是就可以问问自己,能否找到节省A/B测试时间的方法呢?能否让实验达到同样的效果但是使用尽可能少的时间?能否在A/B测试的过程中动态调整用户或者流量的比例呢?这些问题,其实就都是研究课题。 + +再比如,传统的推荐系统非常适合电影推荐的场景,例如著名的Netflix大赛。经历了十多年的发展后,推荐系统方法重点都集中在矩阵分解这样的协同过滤的方向上。那是不是这样的方法就解决了所有的推荐问题呢? + +我们试想一下,如果是电影推荐,我们作为一个用户,看了《纸牌屋1》之后,系统就给我们推荐《纸牌屋2》、《纸牌屋3》以及这一个系列,虽然有一点重复,但依然是可以接受的非常相关的推荐结果。那么,如果我们在一个电商网站上购买了一款佳能相机以后,系统依然给我们推荐类似的相机,我们是不是就会觉得这样的推荐短期内意义不大了呢? + +我们可以看到,推荐系统对场景有非常高的要求。对于一些特定场景的推荐,我们无法直接照搬现有的方法。这个时候,这个场景,就需要一些创新,有可能是把现有的方法进行改进和调整,也有可能是完全提出一些新的思路或新的方法。 + +说到这里,你是不是对人工智能研究的范围有了一个新的认识呢?按照我们在这里举例的思路,你可以观察一下自己手边的项目,这里面或许就潜藏着不少值得研究的课题,说不定你就能发现一个新的研究领域。 + +怎么做人工智能研究 + +由此可见,作为普通的工程师或者初学者,我们的工作中也有机会接触到研究课题和研究领域。那么,接下来,我们就来看一看怎么做人工智能研究。这其实是一个非常大的话题,今天我来给你提供一个简单的指南。 + +要做人工智能研究一般有这么几个步骤。 + +第一,好好总结一下在当前这个场景下,我们要解决的是什么问题,也就是针对要研究的问题进行建模归纳。回到刚才所提的电商推荐的例子,我们需要解决的问题是把类似的相机过滤掉?还是要推荐买了相机后其他的附件?还是要推荐相机的替代品?或者是要从根本上理解这样的情况是怎么产生的,消费者的心理预期是什么样的?你可以看到,即便是尝试解决同一个问题,我们也可以有好多不同的切入点。所以,最重要的第一步就是找准一个切入点。 + +第二,了解前人的工作,查找和学习相关文献。相信我,即便你认为找到了一个貌似完全新的问题或者领域,认真查找了相关文献之后,有很大可能你会发现其实已经有人做了一些类似的工作。甚至,你还可能发现你设想的某种方法已经有人尝试过了。 + +了解前人工作这个步骤,初学者或者是完全在工业界的工程师,往往容易忽略。这一步的核心,其实就是看前人在你设想的问题中是如何寻找方案的,和你设想的思路是不是完全一致。通常情况下,你会发现一定的差异性。于是,基于这些差异性,你可以提出一些“进阶性”(Incremental)的创新思路。这也就是我们常说的“站在巨人的肩膀上”。 + +另外,多了解前人的工作,我们不会把自己的思想和整个大的方法论孤立开来,能够更好地融入到研究的整个社群中去。 + +第三,选择一种测试数据集和一些“基线方法”(Baseline)来进行比较。无论是多么重要的新方法,也无论是什么样的新领域,如果我们不能通过一些标准的数据集,或者是构造一些可以值得信赖的数据集,而且和多种方法进行比较,那么这些创新也都无法通过检验。换句话说,我们提出的新方法、新思路再好,必须有一个相对的比较者,最好这个比较者是之前提出的,已经使用了的,这样,我们才能够更清楚地说明新的方法究竟好在哪里。 + +研究工作,特别是“了解前人工作”和“进行基线比较”这两点,是和一般的技术分享有区分的重要标志。这其实也是我们在上一次的分享中讲到的,有的技术分享其实只是描述做了一件事情,或者是描述了一个系统,我们无法真正知道这个系统的设计和整体的思路在社群中的位置,也不知道这个系统是否和类似系统进行过比较,这其实也给我们的技术选择设置了挑战。通过今天的内容,我们了解了研究工作的思路,把用研究的思路应用在工作中,会让我们平时的技术选择更加严谨。 + +小结 + +今天我为你梳理了进行人工智能研究的一些思路。通过今天的内容,你会发现,人工智能研究离我们并不遥远,很多场景都有可能发现新问题、找到新方法。另外,了解一些研究思路对我们的日常工程开发也会有所借鉴。 + +最后,给你留一个思考题,如何来衡量我们自己做研究的价值? + + + + \ No newline at end of file diff --git a/专栏/AI技术内参/结束语雄关漫道真如铁,而今迈步从头越.md b/专栏/AI技术内参/结束语雄关漫道真如铁,而今迈步从头越.md new file mode 100644 index 0000000..ae71053 --- /dev/null +++ b/专栏/AI技术内参/结束语雄关漫道真如铁,而今迈步从头越.md @@ -0,0 +1,33 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 结束语 雄关漫道真如铁,而今迈步从头越 + 去年,“极客时间”联系到我,询问是否愿意开一个人工智能主题的专栏,因为我对知识经济充满好奇,对知识分享更是有极大的热情,所以我几乎是不加思考就答应了下来,对于接下来一年会遇到的挑战和收获丝毫没有准备。 + +一年过去了,我们的专栏到了说再见的时候。现在我的感受是,我和你一起完成了一个不可能完成的任务。今天就和你聊聊一路走来我遇到的那些挑战和收获。 + +我的第一个挑战就是在浩如烟海的人工智能知识库中,究竟要为你准备什么样的内容才能照顾到不同人群的需求。人工智能技术涵盖面广,变化更新很快,涉及到大量的工程实践以及纯粹的理论工作,如何取舍呢? + +最后呈现给你的156期内容,涵盖推荐、搜索、广告、自然语言处理和计算机视觉这五个人工智能核心技术模块,带你了解最关键的概念、模型和技术;最新的顶级学术会议论文导读,帮助你跟踪最新的技术动态;以及数据科学家成长和数据科学团队建设的经验之谈,帮助初学者快速入门。 + +一切取舍之间的纠结都是为了打造一整套丰富的内容,给初学者一个理想的入门手册,带你跨进人工智能的大门,看清这里面都有什么,在心里画出一张人工智能的地图;同时还能给你一个进阶指南,让你能够找到继续精进的方向。 + +对于顶级学术会议中精彩论文的分享,我花费了很多精力。现在很多会议动辄发表一两百篇甚至几百篇新论文,我每次都要扎进去选取我认为最优质的内容你给解读。我之所以觉得这个部分很重要,是因为在一个发展如此快速的领域,我们不能只埋头学技术、做工程实现,还得关注最新发展、最新思路,这样我们的学习才能事半功倍。 + +在整个专栏准备中的第二个挑战就是内容的撰写。一方面是时间上的安排。白天,我要管理和发展一个15人左右横跨美国东西海岸的工程团队。晚上,我要写出一篇3000字左右的AI技术文章。每篇文章平均3000字,一年的内容就是40多万字。不管是中国的假期还是美国的假期,专栏都没有停止更新。这中间的艰辛和挑战是我在去年欣然答应时所始料未及的。但从另一个角度来说,也正是这份压力和挑战一直督促着我,整整一年,每周雷打不动写出3篇原创文章,最后完成了这项看似不可能完成的任务。 + +另一方面就是内容要怎么写。因为人工智能内容的特殊性,同时专栏文章也都要转成音频形式,我需要尝试使用通俗易懂的语言,来讲解很多即便有数学公式和图表都难以说清楚的机器学习内容,这个难度非常大,我也在不断学习中。在这个过程中,我也逐渐清晰了专栏内容和完整的课本教学之间的关系。专栏内容并不是专业知识的一个替代,也不可能成为替代,但专栏文章可以是一个很好的“向导”,帮助初学者入门,找到经典知识的主脉络和进阶的快速通道。 + +所以,如果有一些内容你还是没有弄明白,欢迎你留言和我继续讨论。另外,可以认真想想到底是哪里不明白,这恰恰是最好的学习机会。通过专栏学习,抓住知识树的树干,然后再去看每一个枝杈上还缺什么,一点一点添枝加叶,日积月累,这棵知识树终会枝繁叶茂、开花结果。 + +过去一年里,我在迎接这些挑战的过程中收获很多,还获得了不少惊喜,在好几次学术和工业界会议上、电话面试甚至面对面的面试中,我都听到这么一句越来越熟悉的话,“洪老师,我是您专栏的用户。”在这个时候,我感到欣慰,也感受到了更大的责任。我越来越体会到当一名知识转播者的快乐和影响力,也让我更加坚定了自己在人工智能知识的传播上贡献自己力量的决心。 + +我相信,“AI技术内参”这个专栏是我系统分享知识的一个开始,也是你学习人工智能的一个开始。接下来,就像在专栏开篇中的期许那样,我们齐头并进,在人工智能的世界里走得更快、更高、更远! + +最后,感谢你一年365天的陪伴。AI是海,我们一起乘风破浪;AI是山,管他雄关漫道,我们一起迈步从头越! + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/00导读如何打造高质量的应用?.md b/专栏/Android开发高手课/00导读如何打造高质量的应用?.md new file mode 100644 index 0000000..8c76d6b --- /dev/null +++ b/专栏/Android开发高手课/00导读如何打造高质量的应用?.md @@ -0,0 +1,93 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 导读 如何打造高质量的应用? + 今年年初,我去上海参加一个移动技术会议,问了很多开发者最近在忙啥。令我非常惊讶的是,大家讲的最多的还是用户体验和应用质量。特别是出海东南亚的同学,面对一堆512MB内存的设备、无处不在的弱网络流下了无助的眼泪。 + +除了内存优化、弱网络优化,想做一款高质量的应用还远远不止这些。一方面,我们面对的环境越来越复杂。过去的iOS开发者可能做梦也想不到,现在也要开始适配屏幕和双卡双待了,更不用说Android那多如繁星的机型、厂家和系统。如果你的应用也要出海,那么还要面对几十个国家不同的语言、环境。 + +另一方面,我们的代码跟业务也越来越复杂了。先不说大量“年久失修”的历史代码,业务越来越复杂,如何管理好几十上百个模块?还要面对React Native、Flutter、TensorFlow等各种语言跟框架堆积在一起的情况,再加上复杂的环境和庞大的系统,想想做一款高质量的应用真的不容易。 + +从应用交付的流程说起 + +既然打造一款高质量的应用那么困难,我们可以先从哪里入手做些什么呢?我的方法是把应用当成一件商品,想象一下商品在流水线生产的过程,那么怎样在每个步骤做好“质检”呢?这就要从应用交付的流程说起。 + +在我看来,一个应用至少会经过开发、编译CI、测试、灰度和发布这几个阶段。每个阶段需要关注什么问题呢? + +1.开发阶段。在面试的时候,常常有人说自己熟练掌握各种开发工具。但是,我们真的懂吗?就拿我们比较熟悉的耗时分析工具Traceview来说,它背后的实现原理是什么?能不能做一个完全没有性能损耗的Traceview?或者怎么样将它移植到线上使用? + +2.编译CI阶段。如何防止代码不断地恶化?怎样进一步优化性能?d8与ReDex有什么神奇的黑科技?如何利用好Coverity、Infer这些静态分析工具?这部分可能需要一些编译原理的知识,你会发现移动开发也有很多值得深入研究的东西。 + +3.测试阶段。我们常说敏捷开发,用户是最好的测试。遇到问题在线上反复试错,对自己、对用户都十分痛苦。我们希望可以做到测试“左移”,尽可能早地发现问题。但是很多时候我们不是不想测试,而是发现测不出什么问题。那么怎样提升实验室发现问题的能力呢?如何尽可能地模拟用户的操作路径?做好测试并不容易,自动化测试结合AI或许可以帮助我们解决一些痛点。 + +4.灰度和发布阶段。动态部署流行起来之后,很多开发变得松懈起来。有问题发补丁,一个不行就两个,两个不行就十个。怎样去保证产品质量?很多线上问题概率很低,基本很难复现,比如对于一个印度的用户,我们希望有一个远程的听诊器,而不需要把用户拉到我们的手术台上。 + +对照应用的交付流程,我来介绍一下专栏的学习方法。专栏“高质量开发”模块主要对应的是开发阶段,你可以带着实践过程的困惑去深入学习开发需要的各种武器。专栏“高效开发”模块主要对应编译CI、测试、灰度和发布阶段,你可以结合实际工作全面提升整个应用交付的效率。另外,我认为一个好的架构可以减少甚至避免团队出错,也是打造一款高质量应用非常重要的一环,因此我会在最后的“架构演进”模块和你聊聊如何设计一个好的架构,以及架构该如何选型。 + + + +移动APM质量平台 + +请你思考一下,在应用交付的这几个阶段中,我们对高质量的目标和实现方式是否一样呢?开发阶段有开发人员,可能希望采集尽可能多的数据;测试阶段有测试人员,可能更针对实验室环境或与竞品对比进行测试;灰度和发布阶段可能也有专门的运维人员,策略会相对保守一些。很明显,不同阶段我们对高质量的目标跟手段可能不太一样。 + +一个公司有多套质量系统,这在大公司是非常普遍的现象。我们希望有一个统一的平台,整合应用的人员和开发流程,这就是我们常说APM质量平台。 + +APM的全称是“Application Performance Management”,即应用性能管理。据我了解,国内像阿里、腾讯、美团点评、饿了么、爱奇艺这些公司都在大力投入。Google今年也发力Android Vitals监控,新增了耗电、权限管理模块。那么APM质量平台究竟有着什么样的魅力呢? + +1. 统一管理。A同学写了一个耗时监控工具,B同学写了一个内存监控工具,它们在不同的仓库,上报格式可能不太一样,各自都搭了一个简单的页面。如果想评估一个应用的质量,总是要去几个系统汇总数据,想想都费劲。 + +2. 统一三端。一个公司可能有多个应用,一个应用也可能有H5、iOS、Android多个端。我们希望它们只是采集数据方式有所不同,上报、后台分析、展示、报警都是共用的。随着技术的发展,我们可能会增加React Native、Flutter这些新模块的监控,这个平台应该是统一演进的。当然我们非常希望业界有一套开源的方案,大家可以一起优化。 + + + +那这个质量平台需要关注哪些问题呢?这需要看我们用户关心什么问题。有的问题可能是致命的,像崩溃、卡死、白屏。另一大类问题就是性能问题,安装包大小、启动、耗时、内存、耗电、流量都是这一个范畴。在这个专栏里,我并不会教你如何从头搭建一个APM平台,我会更期待你掌握背后所需要的知识,它们主要包括: + + + +由于Android版本的碎片化和国内Android生态的乱象,或者换句话说,“Android开发者很苦,国内的Android开发者更苦”。在11月举办的Android绿色联盟开发者大会上推出的应用体验标准,有对应用的兼容性、稳定性、性能、功能和安全做了详细的定义。我贴张图,你可以看下。 + + + +在极致性能的同时,我们希望能更进一步地打造“绿色应用”。在这个过程中,一个全面而强大的APM质量平台会是我们坚实的后盾。当然对于大多数中小开发者来说,我们更建议选择成熟的第三方服务。但深入了解它们背后的原理,无论是对我们如何选择合适的服务,还是日常开发工作都会有很大的帮助。在学习完上面的这些内容之后,你也会觉得其实“性能优化”并不是那么“高不可攀”,我们也可以慢慢地迈向“性能优化专家”之路。 + +不过我们需要明确一点,虽然移动APM质量平台可以帮助我们快速发现和定位问题,但是监控并不能保证实现高质量,这里最关键的永远是人,而不是系统。为什么呢?我举两个小例子。 + +你在工作中可能总能遇到这样的场景,我管它叫反馈问题三连击:“是我的问题吗?”“能复现吗?”“你的测试靠谱吗?”。虽然通过APM质量平台可以减少推卸责任,但有些人的做法通常还是发现空指针加一个判空,发现并发问题加一个锁。这里的空指针真正原因是什么?这里判空了后面的逻辑是否还会运行正常?有没有更加好的方法或架构可以避免这个问题?我们真正应该反问的是这三个问题,把“质量观”深入骨髓,真正去想要得到个人成长,深挖背后的原因。 + +第二个例子是,我发现许多人都在问题无法忍受,或者说是老板无法忍受的时候才去开启各种优化专项,但事后又不了了之。我们很多时候都在用战术的勤奋掩盖战略的懒惰,性能优化的关键在于如何解决存量问题,同时快速发现增量问题。APM质量平台只可以协助我们,并不能解决组织内部的心态问题。 + +总结 + +看到这里可能你会有这样的疑问,我们在小公司根本没有机会学习到这些东西呀?确实如此,个人与公司一起成长是最快速,也是非常难得的事情,但并不一定人人都会有这样的机会。从我自己的经验来看,在搜狗、微信会遇到各种各样的疑难问题,也可以有很多时间去研究,让我在解决问题的过程中获得成长。 + +幸运的是现在大家都更加乐于去分享,在专栏和技术会议中,我们可以看到很多成熟的解决问题的经验和思路,在GitHub我们可以找到很多优秀的源代码。在这个环境下,我们需要耐得住寂寞,多抠一些细节,多深入研究,多停下来总结。 + +我来分享一个我的故事,2013年初我去面试微信,前面都不太理想,最后还是通过了。后来有一次跟面试官闲聊说起这个事情,他们认为有一件事情打动了他们。2012年的时候,LeakCanary还没开源,我在使用MAT做内存泄漏分析的时候总觉得很不爽。为什么不能做自动化?为什么看不到Bitmap的图片?我当时深入研究了内存文件Hprof的格式,做了几个小创新: + +1.实现内存的自动化测试。在自动化测试后回到首页,这个时候获取应用的内存快照。自动分析内存中Activity实例,正常情况应该只存在一个,其他都认为是泄漏。为了支持正式包,还做了通过mapping文件反混淆Hprof文件的功能。 + +2.查看图片。自动将内存中重复的图片、比较大的图片转换成PNG格式输出到文件。 + +现在看起来这些功能并不是太难,但如果放到六年前,想到而且能做到的人相信并不多。讲这个故事还是希望你能在工作和实践中多停下来思考,多深入研究一些细节,很多看似不经意的思考和创新,可能在日后发挥更大的价值。 + +课后作业 + +“纸上得来终觉浅,绝知此事要躬行”,只有通过实践,运用到自己的项目里面,才会对知识有更深入地理解。 + +我为专栏的“课后作业”环节专门设计了可供你操作实践的Sample,并在GitHub新建了专栏的Group,后面会陆续将这些Sample放到上面。课后作业主要是根据Sample进行操作,需要你理解实现原理,并在极客时间App专栏文章下面的留言区提交你的学习总结和思考。我和极客时间为认真提交作业的同学准备了丰厚的学习加油礼包,里面包含我推荐的经典图书、极客时间周历、专栏阅码等礼品。我还会选出坚持参与学习并分享心得的同学,在专栏模块一、模块三结束时,分别送出2张2019年GMTC大会的门票。 + +为了更好地给同学们答疑,我邀请了我的朋友孙鹏飞作为课程的“学习委员”,他将不定期讲解我们Sample的实现原理。鹏飞是Android资深开发工程师,现在负责某知名网约车平台司机端的业务开发、性能优化等工作。我们的学习委员对系统框架、虚拟机、性能优化等都有很深入的理解,实战经验也很丰富,相信鹏飞的加入可以更好地帮助你掌握课程的要点,并能解决实践中的问题。 + +今天是课程导读,也正式开启了我们接下来3个多月的学习之旅,所以最后特别邀请你在留言区也做个自我介绍,介绍下自己,聊聊目前的工作、学习情况,或者说说遇到的具体问题和困惑,也可以写写自己对这个课程的期待,让我们彼此有更多了解。 + +希望你能够积极参与进来,咱们教学相长,共同进步。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。 + + + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/00开篇词焦虑的移动开发者该如何破局?.md b/专栏/Android开发高手课/00开篇词焦虑的移动开发者该如何破局?.md new file mode 100644 index 0000000..eff583a --- /dev/null +++ b/专栏/Android开发高手课/00开篇词焦虑的移动开发者该如何破局?.md @@ -0,0 +1,45 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 焦虑的移动开发者该如何破局? + 最近半年,常常有人问我两个问题:“现在学习移动开发还有前景吗?”“移动开发还有什么可以研究的?”。这两个问题其实对应着同一个现象,无论是准备入行或刚刚入行,还是比较资深的开发者,对于移动开发职业的未来,都有一些迷茫、一些焦虑。为什么会这样? + +现在想想,移动互联网的发展不知不觉已经十多年了,Mobile First也已经变成了AI First。换句话说,我们已经不再是“风口上的猪”。移动开发的光环和溢价开始慢慢消失,并且正在向AI、区块链等新的领域转移。移动开发的新鲜血液也已经变少,最明显的是国内应届生都纷纷涌向了AI方向。 + +可以说,国内移动互联网的红利期已经过去了,现在是增量下降、存量厮杀,从争夺用户到争夺时长。比较明显的是手机厂商纷纷互联网化,与传统互联网企业直接竞争。另外一方面,过去渠道的打法失灵,小程序、快应用等新兴渠道崛起,无论是手机厂商,还是各大App都把出海摆到了战略的位置。 + +从技术的角度来看,今年移动端的技术变革也有点缓慢。大前端的概念虽然说了很久也很多,但React Native、PWA(Progressive Web App)的效果依然不尽人意。在插件化热潮之后,移动端的精品文章开始变少。去年“安利”完Kotlin之后,今年讲得比较多的还是Flutter。 + +这么看来,移动开发的前景不明朗,再加上竞争激烈以及技术变革放缓,我们感到迷茫、焦虑似乎就不难理解了。 + +带着同样的焦虑,2017年年底我选择离开了微信,目前在某知名互联网企业负责海外产品的技术工作。 + +回想一下自己的过去,经历了搜狗手机输入法用户从一千万到一亿,也见证了微信用户从一亿到十亿的增长。在快速成长的同时,让我感到更加快乐的是对社区还是做出了那么一点贡献。写了一些文章,建立并运营了WeMobileDev公众号,输出了一些技术干货和思考;拥抱了开源,Tinker、Mars、WCDB相继开源,特别是Tinker作为腾讯在GitHub的第一个开源产品,让我体会到了开源的魅力。 + +在进入新工作环境的这大半年里,我遇到了一些困难,也接受了很多新的挑战。慢慢明白,或许是移动互联网这个大环境变了,推动我们不得不跟着转变。移动端的招聘量变少,但中高端的职位却多了起来,这说明行业只是变得成熟规范起来了。竞争激烈,但产品质量与留存变得更加重要,我们进入了技术赋能业务的时代。大前端正在跨平台,移动开发者的未来更可能是跨终端,产品、运营、数据分析、后端,技多不压身。 + +那企业需要什么样的移动开发人才?移动开发还可以做些什么?我希望通过这个专栏来回答你这两个问题。专栏里,我会结合业界的现状,讲讲国内外各大公司的尝试,以及他们的发展方向。我会尽量少放源码,侧重结合工作上的实践经验,分享一些疑难问题的解决思路。整体来说,主要包括以下三个部分 (注:60%是Android相关的,40%是可以跨平台的)。 + +1. 高质量开发。在如今的竞争态势下,保证产品的基础用户体验尤为重要。最近国内外各大公司,对APM性能监控系统也越来越重视。我挑选了崩溃、内存、卡顿、启动、I/O、存储、网络、耗电、渲染、安装包体积等比较常见的关键点,为你全面梳理性能优化的经验技巧与方向,帮你学会如何解决已知的存量问题,同时也能及时发现增量问题。 + +2. 高效开发。持续交付、DevOps近年在国内非常火热,我们都在寻找内部突破,提升效率。一个应用从想法到成品,需要经历开发、编译CI、测试、灰度、发布等多个阶段,那怎样提升各个阶段的效率,也是你我比较关心的话题。跨平台开发可能是解决开发阶段的一个答案,动态部署可能是发布阶段的一个答案。头条和快手如何做到数据驱动式的开发,怎样才是高效的运营,在这个模块,我将围绕这些内容一一展开。 + +3. 架构演进。“君有疾在腠理,不治将恐深”,对于一个应用来说,架构一定是核心中的核心。在这个模块,我会讲到Google的一些架构演进,例如Android P、App Bundle、虚拟机、耗电等,也会讲到移动网络架构的一些选择,跨平台开发、动态化实践等热点知识。 + +在我看来,如今的移动开发开始冷下来了,或者有人说开始进入移动互联网的下半场了。其实,对于我们开发人员来说,不管是下半场还是上半场,我们重要的是要把技术做好做精做深。是的,现在移动开发已经不再是风口,但是,这并不是说移动开发已经被淘汰,而是说移动开发的发展进入了成熟期,就像Web技术一样。 + +如果说现在,你还只是能做好产品给过来的“需求”,我认为这是远远不够的。作为一个移动开发工程师,你我都需要深耕细作,都需要有工匠精神,把已有的事情从好做到更好。因为在迈向更好的过程中,你必然需要学习底层原理,你必然需要拓展知识面,你必然需要结合其他的技术,有了这么多必然,你也必然会变得更强。 + +所以,我在这里回答我一开始提出的那个让人焦虑的问题。首先,我认为,移动开发不等于App开发,所有新的技术浪潮其实都可以融入到移动开发的体系里,比如IoT、音视频、边缘计算、VR/AR,我们要做的,只是打好基础,随时准备战斗。其次,从心态上,我觉得我们千万不要把时间浪费在纠结问题上,而是应该放在解决问题上。“王者荣耀”“吃鸡”并不能解决我们的焦虑,拥抱变化,才能拥有未来,让我们共勉。 + +最后,我和极客时间一起,为你准备了丰厚的学习大礼包,听说礼包里面有GMTC大会的门票哦。今天先卖个关子,我会在下一节“导读”里给你揭晓。 + +希望通过这个专栏,可以在这个节骨眼上,给你一些启发和帮助。 + + + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/01崩溃优化(上):关于“崩溃”那些事儿.md b/专栏/Android开发高手课/01崩溃优化(上):关于“崩溃”那些事儿.md new file mode 100644 index 0000000..a54876b --- /dev/null +++ b/专栏/Android开发高手课/01崩溃优化(上):关于“崩溃”那些事儿.md @@ -0,0 +1,163 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 崩溃优化(上):关于“崩溃”那些事儿 + 在各种场合遇到其他产品的开发人员时,大家总忍不住想在技术上切磋两招。第一句问的通常都是“你们产品的崩溃率是多少?” + +程序员A自豪地说: “百分之一。” + +旁边的程序员B鄙视地看了一眼,然后喊到: “千分之一!” + +“万分之一” ,程序员C说完之后全场变得安静起来。 + +崩溃率是衡量一个应用质量高低的基本指标,这一点是你我都比较认可的。不过你说的“万分之一”就一定要比我说的“百分之一” 更好吗?我觉得,这个问题其实并不仅仅是比较两个数值这么简单。 + +今天我们就来聊一聊有关“崩溃”的那些事,我会从Android的两种崩溃类型谈起,再和你进一步讨论到底该怎样客观地衡量崩溃这个指标,以及又该如何看待和崩溃相关的稳定性。 + +Android 的两种崩溃 + +我们都知道,Android崩溃分为Java崩溃和Native崩溃。 + +简单来说,Java崩溃就是在Java代码中,出现了未捕获异常,导致程序异常退出。那Native崩溃又是怎么产生的呢?一般都是因为在Native代码中访问非法地址,也可能是地址对齐出现了问题,或者发生了程序主动abort,这些都会产生相应的signal信号,导致程序异常退出。 + +所以,“崩溃”就是程序出现异常,而一个产品的崩溃率,跟我们如何捕获、处理这些异常有比较大的关系。Java崩溃的捕获比较简单,但是很多同学对于如何捕获Native崩溃还是一知半解,下面我就重点介绍Native崩溃的捕获流程和难点。 + +1.Native崩溃的捕获流程 + +如果你对Native崩溃机制的一些基本知识还不是很熟悉,建议你阅读一下《Android平台Native代码的崩溃捕获机制及实现》。这里我着重给你讲讲一个完整的Native崩溃从捕获到解析要经历哪些流程。 + + +编译端。编译C/C++代码时,需要将带符号信息的文件保留下来。 + +客户端。捕获到崩溃时候,将收集到尽可能多的有用信息写入日志文件,然后选择合适的时机上传到服务器。 + +服务端。读取客户端上报的日志文件,寻找适合的符号文件,生成可读的C/C++调用栈。 + + + + +2.Native崩溃捕获的难点 + +Chromium的Breakpad是目前Native崩溃捕获中最成熟的方案,但很多人都觉得Breakpad过于复杂。其实我认为Native崩溃捕获这个事情本来就不容易,跟当初设计Tinker的时候一样,如果只想在90%的情况可靠,那大部分的代码的确可以砍掉;但如果想达到99%,在各种恶劣条件下依然可靠,后面付出的努力会远远高于前期。 + +所以在上面的三个流程中,最核心的是怎么样保证客户端在各种极端情况下依然可以生成崩溃日志。因为在崩溃时,程序会处于一个不安全的状态,如果处理不当,非常容易发生二次崩溃。 + +那么,生成崩溃日志时会有哪些比较棘手的情况呢? + +情况一:文件句柄泄漏,导致创建日志文件失败,怎么办? + +应对方式:我们需要提前申请文件句柄fd预留,防止出现这种情况。 + +情况二:因为栈溢出了,导致日志生成失败,怎么办? + +应对方式:为了防止栈溢出导致进程没有空间创建调用栈执行处理函数,我们通常会使用常见的signalstack。在一些特殊情况,我们可能还需要直接替换当前栈,所以这里也需要在堆中预留部分空间。 + +情况三:整个堆的内存都耗尽了,导致日志生成失败,怎么办? + +应对方式:这个时候我们无法安全地分配内存,也不敢使用stl或者libc的函数,因为它们内部实现会分配堆内存。这个时候如果继续分配内存,会导致出现堆破坏或者二次崩溃的情况。Breakpad做的比较彻底,重新封装了Linux Syscall Support,来避免直接调用libc。 + +情况四:堆破坏或二次崩溃导致日志生成失败,怎么办? + +应对方式:Breakpad会从原进程fork出子进程去收集崩溃现场,此外涉及与Java相关的,一般也会用子进程去操作。这样即使出现二次崩溃,只是这部分的信息丢失,我们的父进程后面还可以继续获取其他的信息。在一些特殊的情况,我们还可能需要从子进程fork出孙进程。 + +当然Breakpad也存在着一些问题,例如生成的minidump文件是二进制格式的,包含了太多不重要的信息,导致文件很容易达到几MB。但是minidump也不是毫无用处,它有一些比较高级的特性,比如使用gdb调试、可以看到传入参数等。Chromium未来计划使用Crashpad全面替代Breakpad,但目前来说还是 “too early to mobile”。 + +我们有时候想遵循Android的文本格式,并且添加更多我们认为重要的信息,这个时候就要去改造Breakpad的实现。比较常见的例如增加Logcat信息、Java调用栈信息以及崩溃时的其他一些有用信息,在下一节我们会有更加详细的介绍。 + +如果想彻底弄清楚Native崩溃捕获,需要我们对虚拟机运行、汇编这些内功有一定造诣。做一个高可用的崩溃收集SDK真的不是那么容易,它需要经过多年的技术积累,要考虑的细节也非常多,每一个失败路径或者二次崩溃场景都要有应对措施或备用方案。 + +3.选择合适的崩溃服务 + +对于很多中小型公司来说,我并不建议自己去实现一套如此复杂的系统,可以选择一些第三方的服务。目前各种平台也是百花齐放,包括腾讯的Bugly、阿里的啄木鸟平台、网易云捕、Google的Firebase等等。 + +当然,在平台的选择方面,我认为,从产品化跟社区维护来说,Bugly在国内做的最好;从技术深度跟捕获能力来说,阿里UC浏览器内核团队打造的啄木鸟平台最佳。 + +如何客观地衡量崩溃 + +对崩溃有了更多了解以后,我们怎样才能客观地衡量崩溃呢? + +要衡量一个指标,首先要统一计算口径。如果想评估崩溃造成的用户影响范围,我们会先去看UV崩溃率。 + +UV 崩溃率 = 发生崩溃的 UV / 登录 UV + + +只要用户出现过一次崩溃就会被计算到,所以UV崩溃率的高低会跟应用的使用时长有比较大的关系,这也是微信UV崩溃率在业界不算低的原因(强行甩锅)。当然这个时候,我们还可以去看应用PV崩溃率、启动崩溃率、重复崩溃率这些指标,计算方法都大同小异。 + +这里为什么要单独统计启动崩溃率呢?因为启动崩溃对用户带来的伤害最大,应用无法启动往往通过热修复也无法拯救。闪屏广告、运营活动,很多应用启动过程异常复杂,又涉及各种资源、配置下发,极其容易出现问题。微信读书、蘑菇街、淘宝、天猫这些“重运营”的应用都有使用一种叫作“安全模式”的技术来保障客户端的启动流程,在监控到客户端启动失败后,给用户自救的机会。 + +现在回到文章开头程序员“华山论剑”的小故事,我来揭秘他们解决崩溃率的“独家秘笈”。 + +程序员B对所有线程、任务都封装了一层try catch,“消化”掉了所有Java崩溃。至于程序是否会出现其他异常表现,这是上帝要管的事情,反正我是实现了“千分之一”的目标。 + +程序员C认为Native崩溃太难解决,所以他想了一个“好方法”,就是不采集所有的Native崩溃,美滋滋地跟老板汇报“万分之一”的工作成果。 + +了解了美好数字产生的“秘笈”后,不知道你有何感想?其实程序员B和C都是真实的案例,而且他们的用户体量都还不算小。技术指标过于KPI化,是国内比较明显的一个现象。崩溃率只是一个数字,我们的出发点应该是让用户有更好的体验。 + +如何客观地衡量稳定性 + +到此,我们讨论了崩溃是怎么回事儿,以及怎么客观地衡量崩溃。那崩溃率是不是就能完全等价于应用的稳定性呢?答案是肯定不行。处理了崩溃,我们还会经常遇到ANR(Application Not Responding,程序没有响应)这个问题。 + +出现ANR的时候,系统还会弹出对话框打断用户的操作,这是用户非常不能忍受的。这又带来另外一个问题,我们怎么去发现应用中的ANR异常呢?总结一下,通常有两种做法。 + +1. 使用FileObserver监听/data/anr/traces.txt 的变化。非常不幸的是,很多高版本的ROM,已经没有读取这个文件的权限了。这个时候你可能只能思考其他路径,海外可以使用Google Play服务,而国内微信利用Hardcoder框架(HC框架是一套独立于安卓系统实现的通信框架,它让App和厂商ROM能够实时“对话”了,目标就是充分调度系统资源来提升App的运行速度和画质,切实提高大家的手机使用体验)向厂商获取了更大的权限。 + +2. 监控消息队列的运行时间。这个方案无法准确地判断是否真正出现了ANR异常,也无法得到完整的ANR日志。在我看来,更应该放到卡顿的性能范畴。 + +回想我当时在设计Tinker的时候,为了保证热修复不会影响应用的启动,Tinker在补丁的加载流程也设计了简单的“安全模式”,在启动时会检查上次应用的退出类型,如果检查连续三次异常退出,将会自动清除补丁。所以除了常见的崩溃,还有一些会导致应用异常退出的情况。 + +在讨论什么是异常退出之前,我们先看看都有哪些应用退出的情形。 + + +主动自杀。Process.killProcess()、exit() 等。 + +崩溃。出现了Java或Native崩溃。 + +系统重启;系统出现异常、断电、用户主动重启等,我们可以通过比较应用开机运行时间是否比之前记录的值更小。 + +被系统杀死。被low memory killer杀掉、从系统的任务管理器中划掉等。 + +ANR。 + + +我们可以在应用启动的时候设定一个标志,在主动自杀或崩溃后更新标志,这样下次启动时通过检测这个标志就能确认运行期间是否发生过异常退出。对应上面的五种退出场景,我们排除掉主动自杀和崩溃(崩溃会单独的统计)这两种场景,希望可以监控到剩下三种的异常退出,理论上这个异常捕获机制是可以达到100%覆盖的。 + +通过这个异常退出的检测,可以反映如ANR、low memory killer、系统强杀、死机、断电等其他无法正常捕获到的问题。当然异常率会存在一些误报,比如用户从系统的任务管理器中划掉应用。对于线上的大数据来说,还是可以帮助我们发现代码中的一些隐藏问题。 + +所以就得到了一个新的指标来衡量应用的稳定性,即异常率。 + +UV 异常率 = 发生异常退出或崩溃的 UV / 登录 UV + + +前不久我们的一个应用灰度版本发现异常退出的比例增长不少,最后排查发现由于视频播放存在一个巨大bug,会导致可能有用户手机卡死甚至重启,这是传统崩溃收集很难发现的问题。 + +根据应用的前后台状态,我们可以把异常退出分为前台异常退出和后台异常退出。“被系统杀死”是后台异常退出的主要原因,当然我们会更关注前台的异常退出的情况,这会跟ANR、OOM等异常情况有更大的关联。 + +通过异常率我们可以比较全面的评估应用的稳定性,对于线上监控还需要完善崩溃的报警机制。在微信我们可以做到5分钟级别的崩溃预警,确保能在第一时间发现线上重大问题,尽快决定是通过发版还是动态热修复解决问题。 + +总结 + +今天,我讲了Android的两种崩溃,重点介绍了Native崩溃的捕获流程和一些难点。做一个高可用的崩溃收集SDK并不容易,它背后涉及Linux信号处理以及内存分配、汇编等知识,当你内功修炼得越深厚,学习这些底层知识就越得心应手。 + +接着,我们讨论了崩溃率应该如何去计算,崩溃率的高低跟应用时长、复杂度、收集SDK有关。不仅仅是崩溃率,我们还学习了目前ANR采集的方式以及遇到的问题,最后提出了异常率这一个新的稳定性监控指标。 + +作为技术人员,我们不应该盲目追求崩溃率这一个数字,应该以用户体验为先,如果强行去掩盖一些问题往往更加适得其反。我们不应该随意使用try catch去隐藏真正的问题,要从源头入手,了解崩溃的本质原因,保证后面的运行流程。在解决崩溃的过程,也要做到由点到面,不能只针对这个崩溃去解决,而应该要考虑这一类崩溃怎么解决和预防。 + +崩溃的治理是一个长期的过程,在专栏下一期我会重点讲一些分析应用崩溃的方法论。另外,你如果细心的话,可以发现,在这篇文章里,我放了很多的超链接,后面的文章里也会有类似的情况。所以,这就要求你在读完文章之后,或者读的过程中,如果对相关的背景信息或者概念不理解,就需要花些时间阅读周边文章。当然,如果看完还是没有明白,你也可以在留言区给我留言。 + +课后作业 + +Breakpad是一个跨平台的开源项目,今天的课后作业是使用Breakpad来捕获一个Native崩溃,并在留言区写下你今天学习和练习后的总结与思考。 + +当然我在专栏GitHub的Group里也为你提供了一个Sample方便你练习,如果你没使用过Breakpad的话,只需要直接编译即可。希望你可以通过一个简单的Native崩溃捕获过程,完成minidump文件的生成和解析,在实践中加深对Breakpad工作机制的认识。 + +我要再次敲黑板划重点了,请你一定要坚持参与我们的课后练习,从最开始就养成学完后立马动手操作的好习惯,这样才能让学习效率最大化,一步步接近“成为高手”的目标。当然了,认真提交作业的同学还有机会获得学习加油礼包。接下来,就看你的了! + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/02崩溃优化(下):应用崩溃了,你应该如何去分析?.md b/专栏/Android开发高手课/02崩溃优化(下):应用崩溃了,你应该如何去分析?.md new file mode 100644 index 0000000..5bc69e4 --- /dev/null +++ b/专栏/Android开发高手课/02崩溃优化(下):应用崩溃了,你应该如何去分析?.md @@ -0,0 +1,241 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 崩溃优化(下):应用崩溃了,你应该如何去分析? + 在侦探漫画《名侦探柯南》中,无论柯南走到哪里都会遇到新的“案件”,这也很像程序员的“日常”,我们每天工作也会遇到各种各样的疑难问题,“崩溃”就是其中比较常见的一种问题。 + +解决崩溃跟破案一样需要经验,我们分析的问题越多越熟练,定位问题就会越快越准。当然这里也有很多套路,比如对于“案发现场”我们应该留意哪些信息?怎样找到更多的“证人”和“线索”?“侦查案件”的一般流程是什么?对不同类型的“案件”分别应该使用什么样的调查方式? + +“真相永远只有一个”,崩溃也并不可怕。通过今天的学习,希望你能成为代码届的名侦探柯南。 + +崩溃现场 + +崩溃现场是我们的“第一案发现场”,它保留着很多有价值的线索。在这里我们挖掘到的信息越多,下一步分析的方向就越清晰,而不是去靠盲目猜测。 + +操作系统是整个崩溃过程的“旁观者”,也是我们最重要的“证人”。一个好的崩溃捕获工具知道应该采集哪些系统信息,也知道在什么场景要深入挖掘哪些内容,从而可以更好地帮助我们解决问题。 + +接下来我们具体来看看在崩溃现场应该采集哪些信息。 + +1.崩溃信息 + +从崩溃的基本信息,我们可以对崩溃有初步的判断。 + + +进程名、线程名。崩溃的进程是前台进程还是后台进程,崩溃是不是发生在UI线程。 + +崩溃堆栈和类型。崩溃是属于Java崩溃、Native崩溃,还是ANR,对于不同类型的崩溃我们关注的点也不太一样。特别需要看崩溃堆栈的栈顶,看具体崩溃在系统的代码,还是我们自己的代码里面。 + + +Process Name: 'com.sample.crash' +Thread Name: 'MyThread' + +java.lang.NullPointerException + at ...TestsActivity.crashInJava(TestsActivity.java:275) + + +有时候我们除了崩溃的线程,还希望拿到其他关键的线程的日志。就像上面的例子,虽然是MyThread线程崩溃,但是我也希望可以知道主线程当前的调用栈。 + +2.系统信息 + +系统的信息有时候会带有一些关键的线索,对我们解决问题有非常大的帮助。 + + +Logcat。这里包括应用、系统的运行日志。由于系统权限问题,获取到的Logcat可能只包含与当前App相关的。其中系统的event logcat会记录App运行的一些基本情况,记录在文件/system/etc/event-log-tags中。 + + +system logcat: +10-25 17:13:47.788 21430 21430 D dalvikvm: Trying to load lib ... +event logcat: +10-25 17:13:47.788 21430 21430 I am_on_resume_called: 生命周期 +10-25 17:13:47.788 21430 21430 I am_low_memory: 系统内存不足 +10-25 17:13:47.788 21430 21430 I am_destroy_activity: 销毁 Activty +10-25 17:13:47.888 21430 21430 I am_anr: ANR 以及原因 +10-25 17:13:47.888 21430 21430 I am_kill: APP 被杀以及原因 + + + +机型、系统、厂商、CPU、ABI、Linux版本等。我们会采集多达几十个维度,这对后面讲到寻找共性问题会很有帮助。 + +设备状态:是否root、是否是模拟器。一些问题是由Xposed或多开软件造成,对这部分问题我们要区别对待。 + + +3.内存信息 + +OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系。如果我们把用户的手机内存分为“2GB以下”和“2GB以上”两个桶,会发现“2GB以下”用户的崩溃率是“2GB以上”用户的几倍。 + + +系统剩余内存。关于系统内存状态,可以直接读取文件/proc/meminfo。当系统可用内存很小(低于MemTotal的 10%)时,OOM、大量GC、系统频繁自杀拉起等问题都非常容易出现。 + +应用使用内存。包括Java内存、RSS(Resident Set Size)、PSS(Proportional Set Size),我们可以得出应用本身内存的占用大小和分布。PSS和RSS通过/proc/self/smap计算,可以进一步得到例如apk、dex、so等更加详细的分类统计。 + +虚拟内存。虚拟内存可以通过/proc/self/status得到,通过/proc/self/maps文件可以得到具体的分布情况。有时候我们一般不太重视虚拟内存,但是很多类似OOM、tgkill等问题都是虚拟内存不足导致的。 + + +Name: com.sample.name // 进程名 +FDSize: 800 // 当前进程申请的文件句柄个数 +VmPeak: 3004628 kB // 当前进程的虚拟内存峰值大小 +VmSize: 2997032 kB // 当前进程的虚拟内存大小 +Threads: 600 // 当前进程包含的线程个数 + + +一般来说,对于32位进程,如果是32位的CPU,虚拟内存达到3GB就可能会引起内存申请失败的问题。如果是64位的CPU,虚拟内存一般在3~4GB之间。当然如果我们支持64位进程,虚拟内存就不会成为问题。Google Play要求 2019年8月一定要支持64位,在国内虽然支持64位的设备已经在90%以上了,但是商店都不支持区分CPU架构类型发布,普及起来需要更长的时间。 + +4.资源信息 + +有的时候我们会发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系。 + + +文件句柄fd。文件句柄的限制可以通过/proc/self/limits获得,一般单个进程允许打开的最大文件句柄个数为1024。但是如果文件句柄超过800个就比较危险,需要将所有的fd以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏。 + + +opened files count 812: +0 -> /dev/null +1 -> /dev/log/main4 +2 -> /dev/binder +3 -> /data/data/com.crash.sample/files/test.config +... + + + +线程数。当前线程数大小可以通过上面的status文件得到,一个线程可能就占2MB的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说,如果线程数超过400个就比较危险。需要将所有的线程id以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。 + + + threads count 412: + 1820 com.sample.crashsdk + 1844 ReferenceQueueD + 1869 FinalizerDaemon + ... + + + +JNI。使用JNI时,如果不注意很容易出现引用失效、引用爆表等一些崩溃。我们可以通过DumpReferenceTables统计JNI的引用表,进一步分析是否出现了JNI泄漏等问题。 + + +5.应用信息 + +除了系统,其实我们的应用更懂自己,可以留下很多相关的信息。 + + +崩溃场景。崩溃发生在哪个Activity或Fragment,发生在哪个业务中。 + +关键操作路径。不同于开发过程详细的打点日志,我们可以记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助。 + +其他自定义信息。不同的应用关心的重点可能不太一样,比如网易云音乐会关注当前播放的音乐,QQ浏览器会关注当前打开的网址或视频。此外例如运行时间、是否加载了补丁、是否是全新安装或升级等信息也非常重要。 + + +除了上面这些通用的信息外,针对特定的一些崩溃,我们可能还需要获取类似磁盘空间、电量、网络使用等特定信息。所以说一个好的崩溃捕获工具,会根据场景为我们采集足够多的信息,让我们有更多的线索去分析和定位问题。当然数据的采集需要注意用户隐私,做到足够强度的加密和脱敏。 + +崩溃分析 + +有了这么多现场信息之后,我们可以开始真正的“破案”之旅了。绝大部分的“案件”只要我们肯花功夫,最后都能真相大白。不要畏惧问题,经过耐心和细心地分析,总能敏锐地发现一些异常或关键点,并且还要敢于怀疑和验证。下面我重点给你介绍崩溃分析“三部曲”。 + +第一步:确定重点 + +确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。 + +1. 确认严重程度。解决崩溃也要看性价比,我们优先解决Top崩溃或者对业务有重大影响,例如启动、支付过程的崩溃。我曾经有一次辛苦了几天解决了一个大的崩溃,但下个版本产品就把整个功能都删除了,这令我很崩溃。 + +2. 崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。一般来说,大部分的简单崩溃经过这一步已经可以得到结论。 + + +Java崩溃。Java崩溃类型比较明显,比如NullPointerException是空指针,OutOfMemoryError是资源不足,这个时候需要去进一步查看日志中的 “内存信息”和“资源信息”。 + +Native崩溃。需要观察signal、code、fault addr等内容,以及崩溃时Java的堆栈。关于各signal含义的介绍,你可以查看崩溃信号介绍。比较常见的是有SIGSEGV和SIGABRT,前者一般是由于空指针、非法指针造成,后者主要因为ANR和调用abort() 退出所导致。 + +ANR。我的经验是,先看看主线程的堆栈,是否是因为锁等待导致。接着看看ANR日志中iowait、CPU、GC、system server等信息,进一步确定是I/O问题,或是CPU竞争问题,还是由于大量GC导致卡死。 + + +3. Logcat。Logcat一般会存在一些有价值的线索,日志级别是Warning、Error的需要特别注意。从Logcat中我们可以看到当时系统的一些行为跟手机的状态,例如出现ANR时,会有“am_anr”;App被杀时,会有“am_kill”。不同的系统、厂商输出的日志有所差别,当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。 + +4. 各个资源情况。结合崩溃的基本信息,我们接着看看是不是跟 “内存信息” 有关,是不是跟“资源信息”有关。比如是物理内存不足、虚拟内存不足,还是文件句柄fd泄漏了。 + +无论是资源文件还是Logcat,内存与线程相关的信息都需要特别注意,很多崩溃都是由于它们使用不当造成的。 + +第二步:查找共性 + +如果使用了上面的方法还是不能有效定位问题,我们可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步。 + +机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,共性问题例如是不是因为安装了Xposed,是不是只出现在x86的手机,是不是只有三星这款机型,是不是只在Android 5.0的系统上。应用信息也可以作为维度来聚合,比如正在打开的链接、正在播放的视频、国家、地区等。 + +找到了共性,可以对你下一步复现问题有更明确的指引。 + +第三步:尝试复现 + +如果我们已经大概知道了崩溃的原因,为了进一步确认更多信息,就需要尝试复现崩溃。如果我们对崩溃完全没有头绪,也希望通过用户操作路径来尝试重现,然后再去分析崩溃原因。 + +“只要能本地复现,我就能解”,相信这是很多开发跟测试说过的话。有这样的底气主要是因为在稳定的复现路径上面,我们可以采用增加日志或使用Debugger、GDB等各种各样的手段或工具做进一步分析。 + +回想当时在开发Tinker的时候,我们遇到了各种各样的奇葩问题。比如某个厂商改了底层实现、新的Android系统实现有所更改,都需要去Google、翻源码,有时候还需要去抠厂商的ROM或手动刷ROM。这个痛苦的经历告诉我,很多疑难问题需要我们耐得住寂寞,反复猜测、反复发灰度、反复验证。 + +疑难问题:系统崩溃 + +系统崩溃常常令我们感到非常无助,它可能是某个Android版本的bug,也可能是某个厂商修改ROM导致。这种情况下的崩溃堆栈可能完全没有我们自己的代码,很难直接定位问题。针对这种疑难问题,我来谈谈我的解决思路。 + +1. 查找可能的原因。通过上面的共性归类,我们先看看是某个系统版本的问题,还是某个厂商特定ROM的问题。虽然崩溃日志可能没有我们自己的代码,但通过操作路径和日志,我们可以找到一些怀疑的点。 + +2. 尝试规避。查看可疑的代码调用,是否使用了不恰当的API,是否可以更换其他的实现方式规避。 + +3. Hook解决。这里分为Java Hook和Native Hook。以我最近解决的一个系统崩溃为例,我们发现线上出现一个Toast相关的系统崩溃,它只出现在Android 7.0的系统中,看起来是在Toast显示的时候窗口的token已经无效了。这有可能出现在Toast需要显示时,窗口已经销毁了。 + +android.view.WindowManager$BadTokenException: + at android.view.ViewRootImpl.setView(ViewRootImpl.java) + at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java) + at android.view.WindowManagerImpl.addView(WindowManagerImpl.java4) + at android.widget.Toast$TN.handleShow(Toast.java) + + +为什么Android 8.0的系统不会有这个问题?在查看Android 8.0的源码后我们发现有以下修改: + +try { + mWM.addView(mView, mParams); + trySendAccessibilityEvent(); +} catch (WindowManager.BadTokenException e) { + /* ignore */ +} + + +考虑再三,我们决定参考Android 8.0的做法,直接catch住这个异常。这里的关键在于寻找Hook点,这个案例算是相对比较简单的。Toast里面有一个变量叫mTN,它的类型为handler,我们只需要代理它就可以实现捕获。 + +如果你做到了我上面说的这些,95%以上的崩溃都能解决或者规避,大部分的系统崩溃也是如此。当然总有一些疑难问题需要依赖到用户的真实环境,我们希望具备类似动态跟踪和调试的能力。专栏后面还会讲到xlog日志、远程诊断、动态分析等高级手段,可以帮助我们进一步调试线上疑难问题,敬请期待。 + +崩溃攻防是一个长期的过程,我们希望尽可能地提前预防崩溃的发生,将它消灭在萌芽阶段。这可能涉及我们应用的整个流程,包括人员的培训、编译检查、静态扫描工作,还有规范的测试、灰度、发布流程等。 + +而崩溃优化也不是孤立的,它跟我们后面讲到的内存、卡顿、I/O等内容都有关。可能等你学完整个课程后,再回头来看会有不同的理解。 + +总结 + +今天我们介绍了崩溃问题的一些分析方法、特殊技巧、以及疑难和常见问题的解决方法。当然崩溃分析要具体问题具体分析,不同类型的应用侧重点可能也有所不同,我们不能只局限在上面所说的一些方法。 + +讲讲自己的一些心得体会,在解决崩溃特别是一些疑难问题时,总会觉得患得患失。有时候解了一个问题,发现其他问题也跟“开心消消乐”一样消失了。有时候有些问题“解不出来郁闷,解出来更郁闷”,可能只是一个小的代码疏忽,换来了一个月的青春和很多根白头发。 + +课后作业 + +在崩溃的长期保卫战中,你肯定有一些经典的漂亮战役,希望可以拿出来跟其他同学分享。当然也会有一些百思不得其解的问题,今天的课后作业是分享你破解崩溃问题的思路和方法,总结一下通过Sample的练习有什么收获。 + +如果想向崩溃发起挑战,那么Top 20崩溃就是我们无法避免的对手。在这里面会有不少疑难的系统崩溃问题,TimeoutException就是其中比较经典的一个。 + +java.util.concurrent.TimeoutException: + android.os.BinderProxy.finalize() timed out after 10 seconds +at android.os.BinderProxy.destroy(Native Method) +at android.os.BinderProxy.finalize(Binder.java:459) + + +今天的Sample提供了一种“完全解决”TimeoutException的方法,主要是希望你可以更好地学习解决系统崩溃的套路。 + +1.通过源码分析。我们发现TimeoutException是由系统的FinalizerWatchdogDaemon抛出来的。 + +2.寻找可以规避的方法。尝试调用了它的Stop()方法,但是线上发现在Android 6.0之前会有线程同步问题。 + +3.寻找其他可以Hook的点。通过代码的依赖关系,发现一个取巧的Hook点。 + +最终代码你可以参考Sample的实现,但是建议只在灰度中使用。这里需要提的是,虽然有一些黑科技可以帮助我们解决某些问题,但对于黑科技的使用我们需要慎重,比如有的黑科技对保活进程频率没有做限制,可能会导致系统卡死。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/03内存优化(上):4GB内存时代,再谈内存优化.md b/专栏/Android开发高手课/03内存优化(上):4GB内存时代,再谈内存优化.md new file mode 100644 index 0000000..e5f0dde --- /dev/null +++ b/专栏/Android开发高手课/03内存优化(上):4GB内存时代,再谈内存优化.md @@ -0,0 +1,196 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 内存优化(上):4GB内存时代,再谈内存优化 + 在写今天这篇文章前,我又翻了翻三年前我在WeMobileDev公众号写过的《Android内存优化杂谈》,今天再看,对里面的一句话更有感触:“我们并不能将内存优化中用到的所有技巧都一一说明,而且随着Android版本的更替,可能很多方法都会变的过时”。 + +三年过去了,4GB内存的手机都变成了主流。那内存优化是不是变得不重要了?如今有哪些技巧已经淘汰,而我们又要升级什么技能呢? + +今天在4GB内存时代下,我就再来谈谈“内存优化”这个话题。 + +移动设备发展 + +Facebook有一个叫device-year-class的开源库,它会用年份来区分设备的性能。可以看到,2008年的手机只有可怜的140MB内存,而今年的华为Mate 20 Pro手机的内存已经达到了8GB。 + + + +内存看起来好像是我们都非常熟悉的概念,那请问问自己,手机内存和PC内存有哪什么差异呢?8GB内存是不是就一定会比4GB内存更好?我想可能很多人都不一定能回答正确。 + +手机运行内存(RAM)其实相当于我们的PC中的内存,是手机中作为App运行过程中临时性数据暂时存储的内存介质。不过考虑到体积和功耗,手机不使用PC的DDR内存,采用的是LPDDR RAM,全称是“低功耗双倍数据速率内存”,其中LP就是“Lower Power”低功耗的意思。 + +以LPDDR4为例,带宽 = 时钟频率 × 内存总线位数 ÷ 8,即1600 × 64 ÷ 8 = 12.8GB/s,因为是DDR内存是双倍速率,所以最后的带宽是12.8 × 2 = 25.6GB/s。 + + + +目前市面上的手机,主流的运行内存有LPDDR3、LPDDR4以及LPDDR4X。可以看出LPDDR4的性能要比LPDDR3高出一倍,而LPDDR4X相比LPDDR4工作电压更低,所以也比LPDDR4省电20%~40%。当然图中的数据是标准数据,不同的生成厂商会有一些低频或者高频的版本,性能方面高频要好于低频。 + +那手机内存是否越大越好呢? + +如果一个手机使用的是4GB的LPDDR4X内存,另外一个使用的是6GB的LPDDR3内存,那么无疑选择4GB的运行内存手机要更加实用一些。 + +但是内存并不是一个孤立的概念,它跟操作系统、应用生态这些因素都有关。同样是1GB内存,使用Android 9.0系统会比Android 4.0系统流畅,使用更加封闭、规范的iOS系统也会比“狂野”的Android系统更好。今年发布的iPhone XR和iPhone XS使用的都是LPDDR4X的内存,不过它们分别只有3GB和4GB的大小。 + +内存问题 + +在前面所讲的崩溃分析中,我提到过“内存优化”是崩溃优化工作中非常重要的一部分。类似OOM,很多的“异常退出”其实都是由内存问题引起。那么内存究竟能引发什么样的问题呢? + +1.两个问题 + + + +内存造成的第一个问题是异常。在前面的崩溃分析我提到过“异常率”,异常包括OOM、内存分配失败这些崩溃,也包括因为整体内存不足导致应用被杀死、设备重启等问题。不知道你平时是否在工作中注意过,如果我们把用户设备的内存分成2GB以下和2GB以上两部分,你可以试试分别计算他们的异常率或者崩溃率,看看差距会有多大。 + +内存造成的第二个问题是卡顿。Java内存不足会导致频繁GC,这个问题在Dalvik虚拟机会更加明显。而ART虚拟机在内存管理跟回收策略上都做大量优化,内存分配和GC效率相比提升了5~10倍。如果想具体测试GC的性能,例如暂停挂起时间、总耗时、GC吞吐量,我们可以通过发送SIGQUIT信号获得ANR日志。 + +adb shell kill -S QUIT PID +adb pull /data/anr/traces.txt + + +它包含一些ANR转储信息以及GC的详细性能信息。 + +sticky concurrent mark sweep paused: Sum: 5.491ms 99% C.I. 1.464ms-2.133ms Avg: 1.830ms Max: 2.133ms // GC 暂停时间 + +Total time spent in GC: 502.251ms // GC 总耗时 +Mean GC size throughput: 92MB/s // GC 吞吐量 +Mean GC object throughput: 1.54702e+06 objects/s + + +另外我们还可以使用systrace来观察GC的性能耗时,这部分内容在专栏后面会详细讲到。 + +除了频繁GC造成卡顿之外,物理内存不足时系统会触发low memory killer机制,系统负载过高是造成卡顿的另外一个原因。 + +2.两个误区 + +除了内存引起的异常和卡顿,在日常做内存优化和架构设计时,很多同学还非常容易陷入两个误区之中。 + +误区一:内存占用越少越好 + +VSS、PSS、Java堆内存不足都可能会引起异常和卡顿。有些同学认为内存是洪水猛兽,占用越少应用的性能越好,这种认识在具体的优化过程中很容易“用力过猛”。 + +应用是否占用了过多的内存,跟设备、系统和当时情况有关,而不是300MB、400MB这样一个绝对的数值。当系统内存充足的时候,我们可以多用一些获得更好的性能。当系统内存不足的时候,希望可以做到“用时分配,及时释放”,就像下面这张图一样,当系统内存出现压力时,能够迅速释放各种缓存来减少系统压力。 + + + +现在手机已经有6GB和8GB的内存出现了,Android系统也希望去提升内存的利用率,因此我们有必要简单回顾一下Android Bitmap内存分配的变化。 + + +在Android 3.0之前,Bitmap对象放在Java堆,而像素数据是放在Native内存中。如果不手动调用recycle,Bitmap Native内存的回收完全依赖finalize函数回调,熟悉Java的同学应该知道,这个时机不太可控。 + +Android 3.0~Android 7.0将Bitmap对象和像素数据统一放到Java堆中,这样就算我们不调用recycle,Bitmap内存也会随着对象一起被回收。不过Bitmap是内存消耗的大户,把它的内存放到Java堆中似乎不是那么美妙。即使是最新的华为Mate 20,最大的Java堆限制也才到512MB,可能我的物理内存还有5GB,但是应用还是会因为Java堆内存不足导致OOM。Bitmap放到Java堆的另外一个问题会引起大量的GC,对系统内存也没有完全利用起来。 + +有没有一种实现,可以将Bitmap内存放到Native中,也可以做到和对象一起快速释放,同时GC的时候也能考虑这些内存防止被滥用?NativeAllocationRegistry可以一次满足你这三个要求,Android 8.0正是使用这个辅助回收Native内存的机制,来实现像素数据放到Native内存中。Android 8.0还新增了硬件位图Hardware Bitmap,它可以减少图片内存并提升绘制效率。 + + +误区二:Native内存不用管 + +虽然Android 8.0重新将Bitmap内存放回到Native中,那么我们是不是就可以随心所欲地使用图片呢? + +答案当然是否定的。正如前面所说当系统物理内存不足时,lmk开始杀进程,从后台、桌面、服务、前台,直到手机重启。系统构想的场景就像下面这张图描述的一样,大家有条不絮的按照优先级排队等着被kill。 + + + +low memory killer的设计,是假定我们都遵守Android规范,但并没有考虑到中国国情。国内很多应用就像是打不死的小强,杀死一个拉起五个。频繁的杀死、拉起进程,又会导致system server卡死。当然在Android 8.0以后应用保活变得困难很多,但依然有一些方法可以突破。 + +既然讲到了将图片的内存放到Native中,我们比较熟悉的是Fresco图片库在Dalvik会把图片放到Native内存中。事实上在Android 5.0~Android 7.0,也能做到相同的效果,只是流程相对复杂一些。 + +步骤一:通过直接调用libandroid_runtime.so中Bitmap的构造函数,可以得到一张空的Bitmap对象,而它的内存是放到Native堆中。但是不同Android版本的实现有那么一点差异,这里都需要适配。 + +步骤二:通过系统的方法创建一张普通的Java Bitmap。 + +步骤三:将Java Bitmap的内容绘制到之前申请的空的Native Bitmap中。 + +步骤四:将申请的Java Bitmap释放,实现图片内存的“偷龙转凤”。 + +// 步骤一:申请一张空的 Native Bitmap +Bitmap nativeBitmap = nativeCreateBitmap(dstWidth, dstHeight, nativeConfig, 22); + +// 步骤二:申请一张普通的 Java Bitmap +Bitmap srcBitmap = BitmapFactory.decodeResource(res, id); + +// 步骤三:使用 Java Bitmap 将内容绘制到 Native Bitmap 中 +mNativeCanvas.setBitmap(nativeBitmap); +mNativeCanvas.drawBitmap(srcBitmap, mSrcRect, mDstRect, mPaint); + +// 步骤四:释放 Java Bitmap 内存 +srcBitmap.recycle(); +srcBitmap = null; + + +虽然最终图片的内存的确是放到Native中了,不过这个“黑科技”有两个主要问题,一个是兼容性问题,另外一个是频繁申请释放Java Bitmap容易导致内存抖动。 + +测量方法 + +在日常开发中,有时候我们需要去排查应用程序中的内存问题。对于系统内存和应用内存的使用情况,你可以参考Android Developer中 《调查RAM使用情况》。 + +adb shell dumpsys meminfo [-d] + + +1. Java内存分配 + +有些时候我们希望跟踪Java堆内存的使用情况,这个时候最常用的有Allocation Tracker和MAT这两个工具。 + +在我曾经写过的《Android内存申请分析》里,提到过Allocation Tracker的三个缺点。 + + +获取的信息过于分散,中间夹杂着不少其他的信息,很多信息不是应用申请的,可能需要进行不少查找才能定位到具体的问题。 + +跟Traceview一样,无法做到自动化分析,每次都需要开发者手工开始/结束,这对于某些问题的分析可能会造成不便,而且对于批量分析来说也比较困难。 + +虽然在Allocation Tracking的时候,不会对手机本身的运行造成过多的性能影响,但是在停止的时候,直到把数据dump出来之前,经常会把手机完全卡死,如果时间过长甚至会直接ANR。 + + +因此我们希望可以做到脱离Android Studio,实现一个自定义的“Allocation Tracker”,实现对象内存的自动化分析。通过这个工具可以获取所有对象的申请信息(大小、类型、堆栈等),可以找到一段时间内哪些对象占用了大量的内存。 + +但是这个方法需要考虑的兼容性问题会比较多,在Dalvik和ART中,Allocation Tracker的处理流程差异就非常大。下面是在Dalvik和ART中,Allocation Tacker的开启方式。 + +// dalvik +bool dvmEnableAllocTracker() +// art +void setAllocTrackingEnabled() + + +我们可以用自定义的“Allocation Tracker”来监控Java内存的监控,也可以拓展成实时监控Java内存泄漏。这方面经验不多的同学也不用担心,我在今天的“课后作业”提供了一个自定义的“Allocation Tracker”供你参考。不过任何一个工具如果只需要做到线下自动化测试,实现起来会相对简单,但想要移植到线上使用,那就要更加关注兼容性、稳定性和性能,付出的努力要远远高于实验室方案。 + +在课后作业中我们会提供一个简单的例子,在熟悉Android Studio中Profiler各种工具的实现原理后,我们就可以做各种各样的自定义改造,在后面的文章中也会有大量的例子供你参考和练习。 + +2. Native内存分配 + +Android的Native内存分析是一直做得非常不好,当然Google在近几个版本也做了大量努力,让整个过程更加简单。 + +首先Google之前将Valgrind弃用,建议我们使用Chromium的AddressSanitize 。遵循“谁最痛,谁最需要,谁优化”,所以Chromium出品了一大堆Native相关的工具。Android之前对AddressSanitize支持的不太好,需要root和一大堆的操作,但在Android 8.0之后,我们可以根据这个指南来使用AddressSanitize。目前AddressSanitize内存泄漏检测只支持x86_64 Linux和OS X系统,不过相信Google很快就可以支持直接在Android上进行检测了。 + +那我们有没有类似Allocation Tracker那样的Native内存分配工具呢?在这方面,Android目前的支持还不是太好,但Android Developer近来也补充了一些相关的文档,你可以参考《调试本地内存使用》。关于Native内存的问题,有两种方法,分别是Malloc调试和Malloc钩子。 + +Malloc调试可以帮助我们去调试Native内存的一些使用问题,例如堆破坏、内存泄漏、非法地址等。Android 8.0之后支持在非root的设备做Native内存调试,不过跟AddressSanitize一样,需要通过wrap.sh做包装。 + +adb shell setprop wrap. '"LIBC_DEBUG_MALLOC_OPTIONS=backtrace logwrapper"' + + +Malloc钩子是在Android P之后,Android的libc支持拦截在程序执行期间发生的所有分配/释放调用,这样我们就可以构建出自定义的内存检测工具。 + +adb shell setprop wrap. '"LIBC_HOOKS_ENABLE=1"' + + +但是在使用“Malloc调试”时,感觉整个App都会变卡,有时候还会产生ANR。如何在Android上对应用Native内存分配和泄漏做自动化分析,也是我最近想做的事情。据我了解,微信最近几个月在Native内存泄漏监控上也做了一些尝试,我会在专栏下一期具体讲讲。 + +总结 + +LPDDR5将在明年进入量产阶段,移动内存一直向着更大容量、更低功耗、更高带宽的方向发展。伴随内存的发展,内存优化的挑战和解决方案也不断变化。而内存优化又是性能优化重要的一部分,今天我讲到了很多的异常和卡顿都是因为内存不足引起的,并在最后讲述了如何在日常开发中分析和测量内存的使用情况。 + +一个好的开发者并不满足于做完需求,我们在设计方案的时候,还需要考虑要使用多少的内存,应该怎么去管理这些内存。在需求完成之后,我们也应该去回归需求的内存情况,是否存在使用不当的地方,是否出现内存泄漏。 + +课后作业 + +内存优化是一个非常“古老”的话题,大家在工作中也会遇到各种各样内存相关的问题。今天的课后作业是分享一下你在工作中遇到的内存问题,总结一下通过Sample的练习有什么收获。 + +在今天文章里我提到,希望可以脱离Android Studio实现一个自定义的Allocation Tracker,这样就可以将它用到自动化分析中。本期的Sample就提供了一个自定义的Allocation Tracker实现的示例,目前已经兼容到Android 8.1。你可以用它练习实现自动化的内存分析,有哪些对象占用了大量内存,以及它们是如何导致GC等。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/04内存优化(下):内存优化这件事,应该从哪里着手?.md b/专栏/Android开发高手课/04内存优化(下):内存优化这件事,应该从哪里着手?.md new file mode 100644 index 0000000..79e88fa --- /dev/null +++ b/专栏/Android开发高手课/04内存优化(下):内存优化这件事,应该从哪里着手?.md @@ -0,0 +1,194 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 内存优化(下):内存优化这件事,应该从哪里着手? + 在掌握内存相关的背景知识后,下一步你肯定想着手开始优化内存的问题了。不过在真正开始做内存优化之前,需要先评估内存对应用性能的影响,我们可以通过崩溃中“异常退出” 和OOM的比例进行评估。另一方面,低内存设备更容易出现内存不足引起的异常和卡顿,我们也可以通过查看应用中用户的手机内存在2GB以下所占的比例来评估。 + +所以在优化前要先定好自己的目标,这一点非常关键。比如针对512MB的设备和针对2GB以上的设备,完全是两种不同的优化思路。如果我们面向东南亚、非洲用户,那对内存优化的标准就要变得更苛刻一些。 + +铺垫了这么多,下面我们就来看看内存优化都有哪些方法吧。 + +内存优化探讨 + +那要进行内存优化,应该从哪里着手呢?我通常会从设备分级、Bitmap优化和内存泄漏这三个方面入手。 + +1. 设备分级 + +相信你肯定遇到过,同一个应用在4GB内存的手机运行得非常流畅,但在1GB内存的手机就不一定可以做到,而且在系统空闲和繁忙的时候表现也不太一样。 + +内存优化首先需要根据设备环境来综合考虑,专栏上一期我提到过很多同学陷入的一个误区:“内存占用越少越好”。其实我们可以让高端设备使用更多的内存,做到针对设备性能的好坏使用不同的内存分配和回收策略。 + +当然这需要有一个良好的架构设计支撑,在架构设计时需要做到以下几点。 + + +设备分级。使用类似device-year-class的策略对设备分级,对于低端机用户可以关闭复杂的动画,或者是某些功能;使用565格式的图片,使用更小的缓存内存等。在现实环境下,不是每个用户的设备都跟我们的测试机一样高端,在开发过程我们要学会思考功能要不要对低端机开启、在系统资源吃紧的时候能不能做降级。 + + +下面我举一个例子。我们知道device-year-class会根据手机的内存、CPU核心数和频率等信息决定设备属于哪一个年份,这个示例表示对于2013年之后的设备可以使用复杂的动画,对于2010年之前的低端设备则不添加任何动画。 + +if (year >= 2013) { + // Do advanced animation +} else if (year >= 2010) { + // Do simple animation +} else { + // Phone too slow, don't do any animations +} + + + +缓存管理。我们需要有一套统一的缓存管理机制,可以适当地使用内存;当“系统有难”时,也要义不容辞地归还。我们可以使用OnTrimMemory回调,根据不同的状态决定释放多少内存。对于大项目来说,可能存在几十上百个模块,统一缓存管理可以更好地监控每个模块的缓存大小。 + +进程模型。一个空的进程也会占用10MB的内存,而有些应用启动就有十几个进程,甚至有些应用已经从双进程保活升级到四进程保活,所以减少应用启动的进程数、减少常驻进程、有节操的保活,对低端机内存优化非常重要。 + +安装包大小。安装包中的代码、资源、图片以及so库的体积,跟它们占用的内存有很大的关系。一个80MB的应用很难在512MB内存的手机上流畅运行。这种情况我们需要考虑针对低端机用户推出4MB的轻量版本,例如Facebook Lite、今日头条极速版都是这个思路。 + + +安装包中的代码、图片、资源以及so库的大小跟内存究竟有哪些关系?你可以参考下面的这个表格。 + + + +2. Bitmap优化 + +Bitmap内存一般占应用总内存很大一部分,所以做内存优化永远无法避开图片内存这个“永恒主题”。 + +即使把所有的Bitmap都放到Native内存,并不代表图片内存问题就完全解决了,这样做只是提升了系统内存利用率,减少了GC带来的一些问题而已。 + +那我们回过头来看看,到底该如何优化图片内存呢?我给你介绍两种方法。 + +方法一,统一图片库。 + +图片内存优化的前提是收拢图片的调用,这样我们可以做整体的控制策略。例如低端机使用565格式、更加严格的缩放算法,可以使用Glide、Fresco或者采取自研都可以。而且需要进一步将所有Bitmap.createBitmap、BitmapFactory相关的接口也一并收拢。 + +方法二,统一监控。 + +在统一图片库后就非常容易监控Bitmap的使用情况了,这里主要有三点需要注意。 + + +大图片监控。我们需要注意某张图片内存占用是否过大,例如长宽远远大于View甚至是屏幕的长宽。在开发过程中,如果检测到不合规的图片使用,应该立即弹出对话框提示图片所在的Activity和堆栈,让开发同学更快发现并解决问题。在灰度和线上环境下可以将异常信息上报到后台,我们可以计算有多少比例的图片会超过屏幕的大小,也就是图片的“超宽率”。 + +重复图片监控。重复图片指的是Bitmap的像素数据完全一致,但是有多个不同的对象存在。这个监控不需要太多的样本量,一般只在内部使用。之前我实现过一个内存Hprof的分析工具,它可以自动将重复Bitmap的图片和引用链输出。下图是一个简单的例子,你可以看到两张图片的内容完全一样,通过解决这张重复图片可以节省1MB内存。 + + + + + +图片总内存。通过收拢图片使用,我们还可以统计应用所有图片占用的内存,这样在线上就可以按不同的系统、屏幕分辨率等维度去分析图片内存的占用情况。在OOM崩溃的时候,也可以把图片占用的总内存、Top N图片的内存都写到崩溃日志中,帮助我们排查问题。 + + +讲完设备分级和Bitmap优化,我们发现架构和监控需要两手抓,一个好的架构可以减少甚至避免我们犯错,而一个好的监控可以帮助我们及时发现问题。 + +3. 内存泄漏 + +内存泄漏简单来说就是没有回收不再使用的内存,排查和解决内存泄漏也是内存优化无法避开的工作之一。 + +内存泄漏主要分两种情况,一种是同一个对象泄漏,还有一种情况更加糟糕,就是每次都会泄漏新的对象,可能会出现几百上千个无用的对象。 + +很多内存泄漏都是框架设计不合理所导致,各种各样的单例满天飞,MVC中Controller的生命周期远远大于View。优秀的框架设计可以减少甚至避免程序员犯错,当然这不是一件容易的事情,所以我们还需要对内存泄漏建立持续的监控。 + + +Java内存泄漏。建立类似LeakCanary自动化检测方案,至少做到Activity和Fragment的泄漏检测。在开发过程,我们希望出现泄漏时可以弹出对话框,让开发者更加容易去发现和解决问题。内存泄漏监控放到线上并不容易,我们可以对生成的Hprof内存快照文件做一些优化,裁剪大部分图片对应的byte数组减少文件大小。比如一个100MB的文件裁剪后一般只剩下30MB左右,使用7zip压缩最后小于10MB,增加了文件上传的成功率。 + + + + + +OOM监控。美团有一个Android内存泄露自动化链路分析组件Probe,它在发生OOM的时候生成Hprof内存快照,然后通过单独进程对这个文件做进一步的分析。不过在线上使用这个工具风险还是比较大,在崩溃的时候生成内存快照有可能会导致二次崩溃,而且部分手机生成Hprof快照可能会耗时几分钟,这对用户造成的体验影响会比较大。另外,部分OOM是因为虚拟内存不足导致,这块需要具体问题具体分析。 + +Native内存泄漏监控。上一期我讲到Malloc调试(Malloc Debug)和Malloc钩子(Malloc Hook)似乎还不是那么稳定。在WeMobileDev最近的一篇文章《微信Android终端内存优化实践》中,微信也做了一些其他方案上面的尝试。 + +针对无法重编so的情况,使用了PLT Hook拦截库的内存分配函数,其中PLT Hook是Native Hook的一种方案,后面我们还会讲到。然后重定向到我们自己的实现后记录分配的内存地址、大小、来源so库路径等信息,定期扫描分配与释放是否配对,对于不配对的分配输出我们记录的信息。 + +针对可重编的so情况,通过GCC的“-finstrument-functions”参数给所有函数插桩,桩中模拟调用栈入栈出栈操作;通过ld的“–wrap”参数拦截内存分配和释放函数,重定向到我们自己的实现后记录分配的内存地址、大小、来源so以及插桩记录的调用栈此刻的内容,定期扫描分配与释放是否配对,对于不配对的分配输出我们记录的信息。 + + +开发过程中内存泄漏排查可以使用Androd Profiler和MAT工具配合使用,而日常监控关键是成体系化,做到及时发现问题。 + +坦白地说,除了Java泄漏检测方案,目前OOM监控和Native内存泄漏监控都只能做到实验室自动化测试的水平。微信的Native监控方案也遇到一些兼容性的问题,如果想达到灰度和线上部署,需要考虑的细节会非常多。Native内存泄漏检测在iOS会简单一些,不过Google也在一直优化Native内存泄漏检测的性能和易用性,相信在未来的Android版本将会有很大改善。 + +内存监控 + +前面我也提了内存泄漏的监控存在一些性能的问题,一般只会对内部人员和极少部分的用户开启。在线上我们需要通过其他更有效的方式去监控内存相关的问题。 + +1. 采集方式 + +用户在前台的时候,可以每5分钟采集一次PSS、Java堆、图片总内存。我建议通过采样只统计部分用户,需要注意的是要按照用户抽样,而不是按次抽样。简单来说一个用户如果命中采集,那么在一天内都要持续采集数据。 + +2. 计算指标 + +通过上面的数据,我们可以计算下面一些内存指标。 + +内存异常率:可以反映内存占用的异常情况,如果出现新的内存使用不当或内存泄漏的场景,这个指标会有所上涨。其中PSS的值可以通过Debug.MemoryInfo拿到。 + +内存 UV 异常率 = PSS 超过 400MB 的 UV / 采集 UV + + +触顶率:可以反映Java内存的使用情况,如果超过85%最大堆限制,GC会变得更加频繁,容易造成OOM和卡顿。 + +内存 UV 触顶率 = Java 堆占用超过最大堆限制的 85% 的 UV / 采集 UV + + +其中是否触顶可以通过下面的方法计算得到。 + +long javaMax = runtime.maxMemory(); +long javaTotal = runtime.totalMemory(); +long javaUsed = javaTotal - runtime.freeMemory(); +// Java 内存使用超过最大限制的 85% +float proportion = (float) javaUsed / javaMax; + + +一般客户端只上报数据,所有计算都在后台处理,这样可以做到灵活多变。后台还可以计算平均PSS、平均Java内存、平均图片占用这些指标,它们可以反映内存的平均情况。通过平均内存和分区间内存占用这些指标,我们可以通过版本对比来监控有没有新增内存相关的问题。 + + + +因为上报了前台时间,我们还可以按照时间维度看应用内存的变化曲线。比如可以观察一下我们的应用是不是真正做到了“用时分配,及时释放”。如果需要,我们还可以实现按照场景来对比内存的占用。 + +3. GC监控 + +在实验室或者内部试用环境,我们也可以通过Debug.startAllocCounting来监控Java内存分配和GC的情况,需要注意的是这个选项对性能有一定的影响,虽然目前还可以使用,但已经被Android标记为deprecated。 + +通过监控,我们可以拿到内存分配的次数和大小,以及GC发起次数等信息。 + +long allocCount = Debug.getGlobalAllocCount(); +long allocSize = Debug.getGlobalAllocSize(); +long gcCount = Debug.getGlobalGcInvocationCount(); + + +上面的这些信息似乎不太容易定位问题,在Android 6.0之后系统可以拿到更加精准的GC信息。 + +// 运行的GC次数 +Debug.getRuntimeStat("art.gc.gc-count"); +// GC使用的总耗时,单位是毫秒 +Debug.getRuntimeStat("art.gc.gc-time"); +// 阻塞式GC的次数 +Debug.getRuntimeStat("art.gc.blocking-gc-count"); +// 阻塞式GC的总耗时 +Debug.getRuntimeStat("art.gc.blocking-gc-time"); + + +需要特别注意阻塞式GC的次数和耗时,因为它会暂停应用线程,可能导致应用发生卡顿。我们也可以更加细粒度地分应用场景统计,例如启动、进入朋友圈、进入聊天页面等关键场景。 + +总结 + +在具体进行内容优化前,我们首先要问清楚自己几个问题,比如我们要优化到什么目标、内存对我们造成了多少异常和卡顿。只有在明确了应用的现状和优化目标后,我们才能去进行下一步的操作。 + +在探讨了内存优化的思路时,针对不同的设备、设备不同的情况,我们希望可以给用户不同的体验。这里我主要讲到了关于Bitmap内存优化和内存泄漏排查、监控的一些方法。最后我提到了怎样在线上监控内存的异常情况,通常内存异常率、触顶率这些指标对我们很有帮助。 + +目前我们在Native泄漏分析上做的还不是那么完善,不过做优化工作的时候,我特别喜欢用演进的思路来看问题。用演进的思路来看,即使是Google, 在时机不成熟时也会做一些权衡和妥协。换到我们个人身上,等到时机成熟或者我们的能力达到了,就需要及时去还这些“技术债务”。 + +课后作业 + +看完我分享的内存优化的方法后,相信你也肯定还有很多好的思路和方法,今天的课后作业是分享一下你的内存优化“必杀技”,在留言区分享一下今天学习、练习的收获与心得。 + +在文中我提到Hprof文件裁剪和重复图片监控,这是很多应用目前都没有做的,而这两个功能也是微信的APM框架Matrix中内存监控的一部分。Matrix是我一年多前在微信负责的最后一个项目,也付出了不少心血,最近听说终于准备开源了。 + +那今天我们就先来练练手,尝试使用HAHA库快速判断内存中是否存在重复的图片,并且将这些重复图片的PNG、堆栈等信息输出。最终的实现可以通过向Sample发送Pull Request。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/05卡顿优化(上):你要掌握的卡顿分析方法.md b/专栏/Android开发高手课/05卡顿优化(上):你要掌握的卡顿分析方法.md new file mode 100644 index 0000000..70ebd91 --- /dev/null +++ b/专栏/Android开发高手课/05卡顿优化(上):你要掌握的卡顿分析方法.md @@ -0,0 +1,258 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 卡顿优化(上):你要掌握的卡顿分析方法 + “我的后羿怎么动不了!”,在玩《王者荣耀》的时候最怕遇到团战时卡得跟幻灯片一样。对于应用也是这样,我们经常会听到用户抱怨:“这个应用启动怎么那么慢?”“滑动的时候怎么那么卡?”。 + +对用户来说,内存占用高、耗费电量、耗费流量可能不容易被发现,但是用户对卡顿特别敏感,很容易直观感受到。另一方面,对于开发者来说,卡顿问题又非常难以排查定位,产生的原因错综复杂,跟CPU、内存、磁盘I/O都可能有关,跟用户当时的系统环境也有很大关系。 + +那到底该如何定义卡顿呢?在本地有哪些工具可以帮助我们更好地发现和排查问题呢?这些工具之间的差异又是什么呢?今天我来帮你解决这些困惑。 + +基础知识 + +在具体讲卡顿工具前,你需要了解一些基础知识,它们主要都和CPU相关。造成卡顿的原因可能有千百种,不过最终都会反映到CPU时间上。我们可以把CPU时间分为两种:用户时间和系统时间。用户时间就是执行用户态应用程序代码所消耗的时间;系统时间就是执行内核态系统调用所消耗的时间,包括I/O、锁、中断以及其他系统调用的时间。 + +1. CPU性能 + +我们先来简单讲讲CPU的性能,考虑到功耗、体积这些因素,移动设备和PC的CPU会有不少的差异。但近年来,手机CPU的性能也在向PC快速靠拢,华为Mate 20的“麒麟980”和iPhone XS的“A12”已经率先使用领先PC的7纳米工艺。 + +评价一个CPU的性能,需要看主频、核心数、缓存等参数,具体表现出来的是计算能力和指令执行能力,也就是每秒执行的浮点计算数和每秒执行的指令数。 + +当然还要考虑到架构问题, “麒麟980”采用三级能效架构,2个2.6GHz主频的A76超大核 + 2个1.92GHz主频的A76大核 + 4个1.8GHz主频的A55小核。相比之下,“A12”使用2个性能核心 + 4个能效核心的架构,这样设计主要是为了在日常低负荷工作时,使用低频核心更加节省电量。在开发过程中,我们可以通过下面的方法获得设备的CPU信息。 + +// 获取 CPU 核心数 +cat /sys/devices/system/cpu/possible + +// 获取某个 CPU 的频率 +cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq + + +随着机器学习的兴起,现代芯片不仅带有强大的GPU,还配备了专门为神经网络计算打造的NPU(Neural network Processing Unit)。“A12”就使用了八核心的NPU,每秒可执行五万亿次运算。从CPU到GPU再到AI芯片,随着手机CPU 整体性能的飞跃,医疗诊断、图像超清化等一些AI应用场景也可以在移动端更好地落地。最近边缘计算也越来越多的被提及,我们希望可以更大程度地利用移动端的计算能力来降低高昂的服务器成本。 + +也因此在开发过程中,我们需要根据设备CPU性能来“看菜下饭”,例如线程池使用线程数根据CPU的核心数,一些高级的AI功能只在主频比较高或者带有NPU的设备开启。 + +拓展了那么多再回到前面我讲的CPU时间,也就是用户时间和系统时间。当出现卡顿问题的时候,应该怎么去区分究竟是我们代码的问题,还是系统的问题?用户时间和系统时间可以给我们哪些线索?这里还要集合两个非常重要的指标,可以帮助我们做判断。 + +2. 卡顿问题分析指标 + +出现卡顿问题后,首先我们应该查看CPU的使用率。怎么查呢?我们可以通过/proc/stat得到整个系统的CPU使用情况,通过/proc/[pid]/stat可以得到某个进程的CPU使用情况。 + +关于stat文件各个属性的含义和CPU使用率的计算,你可以阅读《Linux环境下进程的CPU占用率》和Linux文档。其中比较重要的字段有: + +proc/self/stat: + utime: 用户时间,反应用户代码执行的耗时 + stime: 系统时间,反应系统调用执行的耗时 + majorFaults:需要硬盘拷贝的缺页次数 + minorFaults:无需硬盘拷贝的缺页次数 + + +如果CPU使用率长期大于60% ,表示系统处于繁忙状态,就需要进一步分析用户时间和系统时间的比例。对于普通应用程序,系统时间不会长期高于30%,如果超过这个值,我们就应该进一步检查是I/O过多,还是其他的系统调用问题。 + +Android是站在Linux巨人的肩膀上,虽然做了不少修改也砍掉了一些工具,但还是保留了很多有用的工具可以协助我们更容易地排查问题,这里我给你介绍几个常用的命令。例如,top命令可以帮助我们查看哪个进程是CPU的消耗大户;vmstat命令可以实时动态监视操作系统的虚拟内存和CPU活动;strace命令可以跟踪某个进程中所有的系统调用。 + +除了CPU的使用率,我们还需要查看CPU饱和度。CPU饱和度反映的是线程排队等待CPU的情况,也就是CPU的负载情况。 + +CPU饱和度首先会跟应用的线程数有关,如果启动的线程过多,容易导致系统不断地切换执行的线程,把大量的时间浪费在上下文切换,我们知道每一次CPU上下文切换都需要刷新寄存器和计数器,至少需要几十纳秒的时间。 + +我们可以通过使用vmstat命令或者/proc/[pid]/schedstat文件来查看CPU上下文切换次数,这里特别需要注意nr_involuntary_switches被动切换的次数。 + +proc/self/sched: + nr_voluntary_switches: + 主动上下文切换次数,因为线程无法获取所需资源导致上下文切换,最普遍的是IO。 + nr_involuntary_switches: + 被动上下文切换次数,线程被系统强制调度导致上下文切换,例如大量线程在抢占CPU。 + se.statistics.iowait_count:IO 等待的次数 + se.statistics.iowait_sum: IO 等待的时间 + + +此外也可以通过uptime命令可以检查CPU在1分钟、5分钟和15分钟内的平均负载。比如一个4核的CPU,如果当前平均负载是8,这意味着每个CPU上有一个线程在运行,还有一个线程在等待。一般平均负载建议控制在“0.7 × 核数”以内。 + +00:02:39 up 7 days, 46 min, 0 users, +load average: 13.91, 14.70, 14.32 + + +另外一个会影响CPU饱和度的是线程优先级,线程优先级会影响Android系统的调度策略,它主要由nice和cgroup类型共同决定。nice值越低,抢占CPU时间片的能力越强。当CPU空闲时,线程的优先级对执行效率的影响并不会特别明显,但在CPU繁忙的时候,线程调度会对执行效率有非常大的影响。 + + + +关于线程优先级,你需要注意是否存在高优先级的线程空等低优先级线程,例如主线程等待某个后台线程的锁。从应用程序的角度来看,无论是用户时间、系统时间,还是等待CPU的调度,都是程序运行花费的时间。 + +Android卡顿排查工具 + +可能你会觉得按照上面各种Linux命令组合来排查问题太麻烦了,有没有更简单的、图形化的操作界面呢?Traceview和systrace都是我们比较熟悉的排查卡顿的工具,从实现上这些工具分为两个流派。 + +第一个流派是instrument。获取一段时间内所有函数的调用过程,可以通过分析这段时间内的函数调用流程,再进一步分析待优化的点。 + +第二个流派是sample。有选择性或者采用抽样的方式观察某些函数调用过程,可以通过这些有限的信息推测出流程中的可疑点,然后再继续细化分析。 + +这两种流派有什么差异?我们在什么场景应该选择哪种合适的工具呢?还有没有其他有用的工具可以使用呢?下面我们一一来看。 + +1. Traceview + +Traceview是我第一个使用的性能分析工具,也是吐槽的比较多的工具。它利用Android Runtime函数调用的event事件,将函数运行的耗时和调用关系写入trace文件中。 + +由此可见,Traceview属于instrument类型,它可以用来查看整个过程有哪些函数调用,但是工具本身带来的性能开销过大,有时无法反映真实的情况。比如一个函数本身的耗时是1秒,开启Traceview后可能会变成5秒,而且这些函数的耗时变化并不是成比例放大。 + +在Android 5.0之后,新增了startMethodTracingSampling方法,可以使用基于样本的方式进行分析,以减少分析对运行时的性能影响。新增了sample类型后,就需要我们在开销和信息丰富度之间做好权衡。 + + + +无论是哪种的Traceview对release包支持的都不太好,例如无法反混淆。其实trace文件的格式十分简单,之前曾经写个一个小工具,支持通过mapping文件反混淆trace。 + +2. Nanoscope + +那在instrument类型的性能分析工具里,有没有性能损耗比较小的呢? + +答案是有的,Uber开源的Nanoscope就能达到这个效果。它的实现原理是直接修改Android虚拟机源码,在ArtMethod执行入口和执行结束位置增加埋点代码,将所有的信息先写到内存,等到trace结束后才统一生成结果文件。 + +在使用过程可以明显感觉到应用不会因为开启Nanoscope而感到卡顿,但是trace结束生成结果文件这一步需要的时间比较长。另一方面它可以支持分析任意一个应用,可用于做竞品分析。 + +但是它也有不少限制: + + +需要自己刷ROM,并且当前只支持Nexus 6P,或者采用其提供的x86架构的模拟器。 + +默认只支持主线程采集,其他线程需要代码手动设置。考虑到内存大小的限制,每个线程的内存数组只能支持大约20秒左右的时间段。 + + +Uber写了一系列自动化脚本协助整个流程,使用起来还算简单。Nanoscope作为基本没有性能损耗的instrument工具,它非常适合做启动耗时的自动化分析。 + +Nanoscope生成的是符合Chrome tracing规范的HTML文件。我们可以通过脚本来实现两个功能: + +第一个是反混淆。通过mapping自动反混淆结果文件。 + +第二个是自动化分析。传入相同的起点和终点,实现两个结果文件的diff,自动分析差异点。 + +这样我们可以每天定期去跑自动化启动测试,查看是否存在新增的耗时点。我们有时候为了实现更多定制化功能或者拿到更加丰富的信息,这个时候不得不使用定制ROM的方式。而Nanoscope恰恰是一个很好的工具,可以让我们更方便地实现定制ROM,在后面启动和I/O优化里我还会提到更多类似的案例。 + +3. systrace + +systrace是Android 4.1新增的性能分析工具。我通常使用systrace跟踪系统的I/O操作、CPU负载、Surface渲染、GC等事件。 + +systrace利用了Linux的ftrace调试工具,相当于在系统各个关键位置都添加了一些性能探针,也就是在代码里加了一些性能监控的埋点。Android在ftrace的基础上封装了atrace,并增加了更多特有的探针,例如Graphics、Activity Manager、Dalvik VM、System Server等。 + +systrace工具只能监控特定系统调用的耗时情况,所以它是属于sample类型,而且性能开销非常低。但是它不支持应用程序代码的耗时分析,所以在使用时有一些局限性。 + +由于系统预留了Trace.beginSection接口来监听应用程序的调用耗时,那我们有没有办法在systrace上面自动增加应用程序的耗时分析呢? + +划重点了,我们可以通过编译时给每个函数插桩的方式来实现,也就是在重要函数的入口和出口分别增加Trace.beginSection和Trace.endSection。当然出于性能的考虑,我们会过滤大部分指令数比较少的函数,这样就实现了在systrace基础上增加应用程序耗时的监控。通过这样方式的好处有: + + +可以看到整个流程系统和应用程序的调用流程。包括系统关键线程的函数调用,例如渲染耗时、线程锁,GC耗时等。 + +性能损耗可以接受。由于过滤了大部分的短函数,而且没有放大I/O,所以整个运行耗时不到原来的两倍,基本可以反映真实情况。 + + +systrace生成的也是HTML格式的结果,我们利用跟Nanoscope相似方式实现对反混淆的支持。 + + + +4. Simpleperf + +那如果我们想分析Native函数的调用,上面的三个工具都不能满足这个需求。 + +Android 5.0新增了Simpleperf性能分析工具,它利用CPU的性能监控单元(PMU)提供的硬件perf事件。使用Simpleperf可以看到所有的Native代码的耗时,有时候一些Android系统库的调用对分析问题有比较大的帮助,例如加载dex、verify class的耗时等。 + +Simpleperf同时封装了systrace的监控功能,通过Android几个版本的优化,现在Simpleperf比较友好地支持Java代码的性能分析。具体来说分几个阶段: + +第一个阶段:在Android M和以前,Simpleperf不支持Java代码分析。 + +第二个阶段:在Android O和以前,需要手动指定编译OAT文件。 + +第三个阶段:在Android P和以后,无需做任何事情,Simpleperf就可以支持Java代码分析。 + +从这个过程可以看到Google还是比较看重这个功能,在Android Studio 3.2也在Profiler中直接支持Simpleperf。 + +顾名思义,从名字就能看出Simpleperf是属于sample类型,它的性能开销非常低,使用火焰图展示分析结果。 + + + +目前除了Nanoscope之外的三个工具都只支持debugable的应用程序,如果想测试release包,需要将测试机器root。对于这个限制,我们在实践中会专门打出debugable的测试包,然后自己实现针对mapping的反混淆功能。其中Simpleperf的反混淆比较难实现,因为在函数聚合后会抛弃参数,无法直接对生成的HTML文件做处理。当然我们也可以根据各个工具的实现思路,自己重新打造一套支持非debugable的自动化测试工具。 + +选择哪种工具,需要看具体的场景。我来汇总一下,如果需要分析Native代码的耗时,可以选择Simpleperf;如果想分析系统调用,可以选择systrace;如果想分析整个程序执行流程的耗时,可以选择Traceview或者插桩版本的systrace。 + +可视化方法 + +随着Android版本的演进,Google不仅提供了更多的性能分析工具,而且也在慢慢优化现有工具的体验,使功能更强大、使用门槛更低。而Android Studio则肩负另外一个重任,那就是让开发者使用起来更加简单的,图形界面也更加直观。 + +在Android Studio 3.2的Profiler中直接集成了几种性能分析工具,其中: + + +Sample Java Methods的功能类似于Traceview的sample类型。 + +Trace Java Methods的功能类似于Traceview的instrument类型。 + +Trace System Calls的功能类似于systrace。 + +SampleNative (API Level 26+) 的功能类似于Simpleperf。 + + +坦白来说,Profiler界面在某些方面不如这些工具自带的界面,支持配置的参数也不如命令行,不过Profiler的确大大降低了开发者的使用门槛。 + +另外一个比较大的变化是分析结果的展示方式,这些分析工具都支持了Call Chart和Flame Chart两种展示方式。下面我来讲讲这两种展示方式适合的场景。 + +1. Call Chart + +Call Chart是Traceview和systrace默认使用的展示方式。它按照应用程序的函数执行顺序来展示,适合用于分析整个流程的调用。举一个最简单的例子,A函数调用B函数,B函数调用C函数,循环三次,就得到了下面的Call Chart。 + + + +Call Chart就像给应用程序做一个心电图,我们可以看到在这一段时间内,各个线程的具体工作,比如是否存在线程间的锁、主线程是否存在长时间的I/O操作、是否存在空闲等。 + +2. Flame Chart + +Flame Chart也就是大名鼎鼎的火焰图。它跟Call Chart不同的是,Flame Chart以一个全局的视野来看待一段时间的调用分布,它就像给应用程序拍X光片,可以很自然地把时间和空间两个维度上的信息融合在一张图上。上面函数调用的例子,换成火焰图的展示结果如下。 + + + +当我们不想知道应用程序的整个调用流程,只想直观看出哪些代码路径花费的CPU时间较多时,火焰图就是一个非常好的选择。例如,之前我的一个反序列化实现非常耗时,通过火焰图发现耗时最多的是大量Java字符串的创建和拷贝,通过将核心实现转为Native,最终使性能提升了很多倍。 + +火焰图还可以使用在各种各样的维度,例如内存、I/O的分析。有些内存可能非常缓慢地泄漏,通过一个内存的火焰图,我们就知道哪些路径申请的内存最多,有了火焰图我们根本不需要分析源代码,也不需要分析整个流程。 + +最后我想说,每个工具都可以生成不同的展示方式,我们需要根据不同的使用场景选择合适的方式。 + +总结 + +在写今天的文章,也就是分析卡顿的基础知识和四种Android卡顿排查工具时,我越发觉得底层基础知识的重要性。Android底层基于Linux内核,像systrace、Simpleperf也是利用Linux提供的机制实现,因此学习一些Linux的基础知识,对于理解这些工具的工作原理以及排查性能问题,都有很大帮助。 + +另一方面,虽然很多大厂有专门的性能优化团队,但我觉得鼓励和培养团队里的每一个人都去关注性能问题更加重要。我们在使用性能工具的同时,要学会思考,应该知道它们的原理和局限性。更进一步来说,你还可以尝试去为这些工具做一些优化,从而实现更加完善的方案。 + +课后作业 + +当发生ANR的时候,Android系统会打印CPU相关的信息到日志中,使用的是ProcessCpuTracker.java。但是这样好像并没有权限可以拿到其他应用进程的CPU信息,那能不能换一个思路? + +当发现应用的某个进程CPU使用率比较高的时候,可以通过下面几个文件检查该进程下各个线程的CPU使用率,继而统计出该进程各个线程的时间占比。 + +/proc/[pid]/stat // 进程CPU使用情况 +/proc/[pid]/task/[tid]/stat // 进程下面各个线程的CPU使用情况 +/proc/[pid]/sched // 进程CPU调度相关 +/proc/loadavg // 系统平均负载,uptime命令对应文件 + + +如果线程销毁了,它的CPU运行信息也会被删除,所以我们一般只会计算某一段时间内CPU使用率。下面是计算5秒间隔内一个Sample进程的CPU使用示例。有的时候可能找不到耗时的线程,有可能是有大量生命周期很短的线程,这个时候可以把时间间隔缩短来看看。 + +usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000): +System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle +CPU Core: 8 +Load Average: 8.74 / 7.74 / 7.36 + +Process:com.sample.app + 50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965 + +Threads: + 43% 23493/singleThread(R): 6.5% user + 36% kernel faults:3094 + 3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults:329 + 0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults:6 + 0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults:982 + ... + + +今天的课后作业是,请你在留言区解读一下上面的信息,分享一下你认为这个示例的瓶颈在什么地方。之后能不能更进一步,自己动手写一个工具,得到一段时间内上面的这些统计信息。同样最终的实现可以通过向Sample发送Pull Request。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/06卡顿优化(下):如何监控应用卡顿?.md b/专栏/Android开发高手课/06卡顿优化(下):如何监控应用卡顿?.md new file mode 100644 index 0000000..5a75cd1 --- /dev/null +++ b/专栏/Android开发高手课/06卡顿优化(下):如何监控应用卡顿?.md @@ -0,0 +1,182 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 卡顿优化(下):如何监控应用卡顿? + “我在秒杀iPhone XS的支付页面卡了3秒,最后没抢到”,用户嘶声力竭地反馈了一个卡顿问题。 + +“莫慌莫慌”,等我打开Android Studio, 用上一讲学到的几个工具分析一下就知道原因了。 + +“咦,在我这里整个支付过程丝滑般流畅”。这个经历让我明白,卡顿跟崩溃一样需要“现场信息”。因为卡顿的产生也是依赖很多因素,比如用户的系统版本、CPU负载、网络环境、应用数据等。 + +脱离这个现场,我们本地难以复现,也就很难去解决问题。但是卡顿又非常影响用户体验的,特别是发生在启动、聊天、支付这些关键场景,那我们应该如何去监控线上的卡顿问题,并且保留足够多的现场信息协助我们排查解决问题呢? + +卡顿监控 + +前面我讲过监控ANR的方法,不过也提到两个问题:一个是高版本的系统没有权限读取系统的ANR日志;另一个是ANR太依赖系统实现,我们无法灵活控制参数,例如我觉得主线程卡顿3秒用户已经不太能忍受,而默认参数只能监控至少5秒以上的卡顿。 + +所以现实情况就要求我们需要采用其他的方式来监控是否出现卡顿问题,并且针对特定场景还要监控其他特定的指标。 + +1. 消息队列 + +我设计的第一套监控卡顿的方案是基于消息队列实现,通过替换Looper的Printer实现。在2013年的时候,我写过一个名为WxPerformanceTool的性能监控工具,其中耗时监控就使用了这个方法。后面这个工具在腾讯公共组件做了内部开源,还获得了2013年的年度十佳组件。 + + + +还没庆祝完,很快就有同事跟我吐槽一个问题:线上开启了这个监控模块,快速滑动时平均帧率起码降低5帧。我通过Traceview一看,发现是因为上面图中所示的大量字符串拼接导致性能损耗严重。 + +后来很快又想到了另外一个方案,可以通过一个监控线程,每隔1秒向主线程消息队列的头部插入一条空消息。假设1秒后这个消息并没有被主线程消费掉,说明阻塞消息运行的时间在0~1秒之间。换句话说,如果我们需要监控3秒卡顿,那在第4次轮询中头部消息依然没有被消费的话,就可以确定主线程出现了一次3秒以上的卡顿。 + + + +这个方案也存在一定的误差,那就是发送空消息的间隔时间。但这个间隔时间也不能太小,因为监控线程和主线程处理空消息都会带来一些性能损耗,但基本影响不大。 + +2. 插桩 + +不过在使用了一段时间之后,我感觉还是有那么一点不爽。基于消息队列的卡顿监控并不准确,正在运行的函数有可能并不是真正耗时的函数。这是为什么呢? + +我画张图解释起来就清楚了。我们假设一个消息循环里面顺序执行了A、B、C三个函数,当整个消息执行超过3秒时,因为函数A和B已经执行完毕,我们只能得到的正在执行的函数C的堆栈,事实上它可能并不耗时。 + + + +不过对于线上大数据来说,因为函数A和B相对比较耗时,所以抓取到它们的概率会更大一些,通过后台聚合后捕获到函数A和B的卡顿日志会更多一些。 + +这也是我们线上目前依然使用基于消息队列的方法,但是肯定希望可以做到跟Traceview一样,可以拿到整个卡顿过程所有运行函数的耗时,就像下面图中的结果,可以明确知道其实函数A和B才是造成卡顿的主要原因。 + + + +既然这样,那我们能否直接利用Android Runtime函数调用的回调事件,做一个自定义的Traceview++呢? + +答案是可以的,但是需要使用Inline Hook技术。我们可以实现类似Nanoscope先写内存的方案,但考虑到兼容性问题,这套方案并没有用到线上。 + +对于大体量的应用,稳定性是第一考虑因素。那如果在编译过程插桩,兼容性问题肯定是OK的。上一讲讲到systrace可以通过插桩自动生成Trace Tag,我们一样也可以在函数入口和出口加入耗时监控的代码,但是需要考虑的细节有很多。 + + +避免方法数暴增。在函数的入口和出口应该插入相同的函数,在编译时提前给代码中每个方法分配一个独立的ID作为参数。 + +过滤简单的函数。过滤一些类似直接return、i++这样的简单函数,并且支持黑名单配置。对一些调用非常频繁的函数,需要添加到黑名单中来降低整个方案对性能的损耗。 + + + + +基于性能的考虑,线上只会监控主线程的耗时。微信的Matrix使用的就是这个方案,因为做了大量的优化,所以最终安装包体积只增大1~2%,平均帧率下降也在2帧以内。虽然插桩方案对性能的影响总体还可以接受,但只会在灰度包使用。 + +插桩方案看起来美好,它也有自己的短板,那就是只能监控应用内自身的函数耗时,无法监控系统的函数调用,整个堆栈看起来好像“缺失了”一部分。 + +3. Profilo + +2018年3月,Facebook开源了一个叫Profilo的库,它收集了各大方案的优点,令我眼前一亮。具体来说有以下几点: + +第一,集成atrace功能。ftrace所有性能埋点数据都会通过trace_marker文件写入内核缓冲区,Profilo通过PLT Hook拦截了写入操作,选择部分关心的事件做分析。这样所有systrace的探针我们都可以拿到,例如四大组件生命周期、锁等待时间、类校验、GC时间等。 + +不过大部分的atrace事件都比较笼统,从事件“B|pid|activityStart”,我们并不知道具体是哪个Activity的创建。同样我们可以统计GC相关事件的耗时,但是也不知道为什么发生了这次GC。 + + + +第二,快速获取Java堆栈。很多同学有一个误区,觉得在某个线程不断地获取主线程堆栈是不耗时的。但是事实上获取堆栈的代价是巨大的,它要暂停主线程的运行。 + +Profilo的实现非常精妙,它实现类似Native崩溃捕捉的方式快速获取Java堆栈,通过间隔发送SIGPROF信号,整个过程如下图所示。 + + + +Signal Handler捕获到信号后,拿取到当前正在执行的Thread,通过Thread对象可以获取当前线程的ManagedStack,ManagedStack是一个单链表,它保存了当前的ShadowFrame或者QuickFrame栈指针,先依次遍历ManagedStack链表,然后遍历其内部的ShadowFrame或者QuickFrame还原一个可读的调用栈,从而unwind出当前的Java堆栈。通过这种方式,可以实现线程一边继续跑步,我们还可以帮它做检查,而且耗时基本忽略不计。代码可以参照:Profilo::unwind和StackVisitor::WalkStack。 + +不用插桩、性能基本没有影响、捕捉信息还全,那Profilo不就是完美的化身吗?当然由于它利用了大量的黑科技,兼容性是需要注意的问题。它内部实现有大量函数的Hook,unwind也需要强依赖Android Runtime实现。Facebook已经将Profilo投入到线上使用,但由于目前Profilo快速获取堆栈功能依然不支持Android 8.0和Android 9.0,鉴于稳定性问题,建议采取抽样部分用户的方式来开启该功能。 + +先小结一下,不管我们使用哪种卡顿监控方法,最后我们都可以得到卡顿时的堆栈和当时CPU运行的一些信息。大部分的卡顿问题都比较好定位,例如主线程执行一个耗时任务、读一个非常大的文件或者是执行网络请求等。 + +其他监控 + +除了主线程的耗时过长之外,我们还有哪些卡顿问题需要关注呢? + +Android Vitals是Google Play官方的性能监控服务,涉及卡顿相关的监控有ANR、启动、帧率三个。尤其是ANR监控,我们应该经常的来看看,主要是Google自己是有权限可以准确监控和上报ANR。 + +对于启动和帧率,Android Vitals只是上报了应用的区间分布,但是不能归纳出问题。这也是我们做性能优化时比较迷惑的一点,即使发现整体的帧率比过去降低了5帧,也并不知道是哪里造成的,还是要花很大的力气去做二次排查。 + + + +能不能做到跟崩溃、卡顿一样,直接给我一个堆栈,告诉我就是因为这里写的不好导致帧率下降了5帧。退一步说,如果做不到直接告诉我堆栈,能不能告诉我是因为聊天这个页面导致的帧率下降,让我缩小二次排查的范围。 + +1. 帧率 + +业界都使用Choreographer来监控应用的帧率。跟卡顿不同的是,需要排除掉页面没有操作的情况,我们应该只在界面存在绘制的时候才做统计。 + +那么如何监听界面是否存在绘制行为呢?可以通过addOnDrawListener实现。 + +getWindow().getDecorView().getViewTreeObserver().addOnDrawListener + + +我们经常用平均帧率来衡量界面流畅度,但事实上电影的帧率才24帧,用户对于应用的平均帧率是40帧还是50帧并不一定可以感受出来。对于用户来说,感觉最明显的是连续丢帧情况,Android Vitals将连续丢帧超过700毫秒定义为冻帧,也就是连续丢帧42帧以上。 + +因此,我们可以统计更有价值的冻帧率。冻帧率就是计算发生冻帧时间在所有时间的占比。出现丢帧的时候,我们可以获取当前的页面信息、View信息和操作路径上报后台,降低二次排查的难度。 + +正如下图一样,我们还可以按照Activity、Fragment或者某个操作定义场景,通过细化不同场景的平均帧率和冻帧率,进一步细化问题排查的范围。 + + + +2. 生命周期监控 + +Activity、Service、Receiver组件生命周期的耗时和调用次数也是我们重点关注的性能问题。例如Activity的onCreate()不应该超过1秒,不然会影响用户看到页面的时间。Service和Receiver虽然是后台组件,不过它们生命周期也是占用主线程的,也是我们需要关注的问题。 + +对于组件生命周期我们应该采用更严格地监控,可以全量上报。在后台我们可以看到各个组件各个生命周期的启动时间和启动次数。 + + + +有一次我们发现有两个Service的启动次数是其他的10倍,经过排查发现是因为频繁的互相拉起导致。Receiver也是这样,而且它们都需要经过System Server。曾经有一个日志上报模块通过Broadcast来做跨进程通信,每秒发送几千次请求,导致系统System Server卡死。所以说每个组件各个生命周期的调用次数也是非常有参考价值的指标。 + +除了四大组件的生命周期,我们还需要监控各个进程生命周期的启动次数和耗时。通过下面的数据,我们可以看出某些进程是否频繁地拉起。 + + + +对于生命周期的监控实现,我们可以利用插件化技术Hook的方式。但是Android P之后,我还是不太推荐你使用这种方式。我更推荐使用编译时插桩的方式,后面我会讲到Aspect、ASM和ReDex三种插桩技术的实现,敬请期待。 + +3. 线程监控 + +Java线程管理是很多应用非常头痛的事情,应用启动过程就已经创建了几十上百个线程。而且大部分的线程都没有经过线程池管理,都在自由自在地狂奔着。 + +另外一方面某些线程优先级或者活跃度比较高,占用了过多的CPU。这会降低主线程UI响应能力,我们需要特别针对这些线程做重点的优化。 + +对于Java线程,总的来说我会监控以下两点。 + + +线程数量。需要监控线程数量的多少,以及创建线程的方式。例如有没有使用我们特有的线程池,这块可以通过got hook线程的nativeCreate()函数。主要用于进行线程收敛,也就是减少线程数量。 + +线程时间。监控线程的用户时间utime、系统时间stime和优先级。主要是看哪些线程utime+stime时间比较多,占用了过多的CPU。正如上一期“每课一练”所提到的,可能有一些线程因为生命周期很短导致很难发现,这里我们需要结合线程创建监控。 + + + + +看到这里可能有同学会比较困惑,卡顿优化的主题就是监控吗?导致卡顿的原因会有很多,比如函数非常耗时、I/O非常慢、线程间的竞争或者锁等。其实很多时候卡顿问题并不难解决,相较解决来说,更困难的是如何快速发现这些卡顿点,以及通过更多的辅助信息找到真正的卡顿原因。 + +就跟在本地使用各种卡顿分析工具一样,卡顿优化的难点在于如何把它们移植到线上,以最少的性能代价获得更加丰富的卡顿信息。当然某些卡顿问题可能是I/O、存储或者网络引发的,后面会还有专门的内容来讲这些问题的优化方法。 + +总结 + +今天我们学习了卡顿监控的几种方法。随着技术的深入,我们发现了旧方案的一些缺点,通过不断地迭代和演进,寻找更好的方案。 + +Facebook的Profilo实现了快速获取Java堆栈,其实它参考的是JVM的AsyncGetCallTrace思路,然后适配Android Runtime的实现。systrace使用的是Linux的ftrace,Simpleperf参考了Linux的perf工具。还是熟悉的配方,还是熟悉的味道,我们很多创新性的东西,其实还是基于Java和Linux十年前的产物。 + +还是回到我在专栏开篇词说过的,切记不要浮躁,多了解和学习一些底层的技术,对我们的成长会有很大帮助。日常开发中我们也不能只满足于完成需求就可以了,在实现上应该学会多去思考内存、卡顿这些影响性能的点,我们比别人多想一些、多做一些,自己的进步自然也会更快一些。 + +课后作业 + +看完我分享的卡顿优化的方法后,相信你也肯定还有很多好的思路和方法,今天的课后作业是分享一下你的卡顿优化的“必杀技”,在留言区分享一下今天学习、练习的收获与心得。 + +课后练习 + +我在上一期中提到过Linux的ftrace机制,而systrace正是利用这个系统机制实现的。而Profilo更是通过一些黑科技,实现了一个可以用于线上的“systrace”。那它究竟是怎么实现的呢? + +通过今天这个Sample,你可以学习到它的实现思路。当你对这些底层机制足够熟悉的时候,可能就不局限在本地使用,而是可以将它们搬到线上了。 + +当然,为了能更好地理解这个Sample,可能你还需要补充一些ftrace和atrace相关的背景知识。你会发现这些的确都是Linux十年前的一些知识,但时至今日它们依然非常有用。 + +1.ftrace 简介、ftrace使用(上)、frace使用(下)。 + +2.atrace介绍、atrace实现。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/06补充篇卡顿优化:卡顿现场与卡顿分析.md b/专栏/Android开发高手课/06补充篇卡顿优化:卡顿现场与卡顿分析.md new file mode 100644 index 0000000..9471cea --- /dev/null +++ b/专栏/Android开发高手课/06补充篇卡顿优化:卡顿现场与卡顿分析.md @@ -0,0 +1,239 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06补充篇 卡顿优化:卡顿现场与卡顿分析 + 我们使用上一期所讲的插桩或者Profilo的方案,可以得到卡顿过程所有运行函数的耗时。在大部分情况下,这几种方案的确非常好用,可以让我们更加明确真正的卡顿点在哪里。 + +但是,你肯定还遇到过很多莫名其妙的卡顿,比如读取1KB的文件、读取很小的asset资源或者只是简单的创建一个目录。 + +为什么看起来这么简单的操作也会耗费那么长的时间呢?那我们如何通过收集更加丰富的卡顿现场信息,进一步定位并排查问题呢? + +卡顿现场 + +我先来举一个线上曾经发现的卡顿例子,下面是它的具体耗时信息。 + + + +从图上看,Activity的onCreate函数耗时达到3秒,而其中Lottie动画中openNonAsset函数耗时竟然将近2秒。尽管是读取一个30KB的资源文件,但是它的耗时真的会有那么长吗? + +今天我们就一起来分析这个问题吧。 + +1. Java实现 + +进一步分析openNonAsset相关源码的时候发现,AssetManager内部有大量的synchronized锁。首先我怀疑还是锁的问题,接下来需要把卡顿时各个线程的状态以及堆栈收集起来做进一步分析。 + +步骤一:获得Java线程状态 + +通过Thread的getState方法可以获取线程状态,当时主线程果然是BLOCKED状态。 + +什么是BLOCKED状态呢?当线程无法获取下面代码中的object对象锁的时候,线程就会进入BLOCKED状态。 + +// 线程等待获取object对象锁 +synchronized (object) { + dosomething(); +} + + +WAITING、TIME_WAITING和BLOCKED都是需要特别注意的状态。很多同学可能对BLOCKED和WAITING这两种状态感到比较困惑,BLOCKED是指线程正在等待获取锁,对应的是下面代码中的情况一;WAITING是指线程正在等待其他线程的“唤醒动作”,对应的是代码中的情况二。 + +synchronized (object) { // 情况一:在这里卡住 --> BLOCKED + object.wait(); // 情况二:在这里卡住 --> WAITING +} + + +不过当一个线程进入WAITING状态时,它不仅会释放CPU资源,还会将持有的object锁也同时释放。对Java各个线程状态的定义以及转换等更多介绍,你可以参考Thread.State和《Java线程Dump分析》。 + +步骤二:获得所有线程堆栈 + +接着我们在Java层通过Thread.getAllStackTraces()进一步拿所有线程的堆栈,希望知道具体是因为哪个线程导致主线程的BLOCKED。 + +需要注意的是在Android 7.0,getAllStackTraces是不会返回主线程的堆栈的。通过分析收集上来的卡顿日志,我们发现跟AssetManager相关的线程有下面这个。 + +"BackgroundHandler" RUNNABLE + at android.content.res.AssetManager.list + at com.sample.business.init.listZipFiles + + +通过查看AssetManager.list的确发现是使用了同一个synchronized锁,而list函数需要遍历整个目录,耗时会比较久。 + +public String[] list(String path) throws IOException { + synchronized (this) { + ensureValidLocked(); + return nativeList(mObject, path); + } +} + + +另外一方面,“BackgroundHandler”线程属于低优先级后台线程,这也是我们前面文章提到的不良现象,也就是主线程等待低优先级的后台线程。 + +2. SIGQUIT信号实现 + +Java实现的方案看起来非常不错,也帮助我们发现了卡顿的原因。不过在我们印象中,似乎ANR日志的信息更加丰富,那我们能不能直接用ANR日志呢? + +比如下面的例子,它的信息的确非常全,所有线程的状态、CPU时间片、优先级、堆栈和锁的信息应有尽有。其中utm代表utime,HZ代表CPU的时钟频率,将utime转换为毫秒的公式是“time * 1000/HZ”。例子中utm=218,也就是218*1000⁄100=2180毫秒。 + +// 线程名称; 优先级; 线程id; 线程状态 +"main" prio=5 tid=1 Suspended + // 线程组; 线程suspend计数; 线程debug suspend计数; + | group="main" sCount=1 dsCount=0 obj=0x74746000 self=0xf4827400 + // 线程native id; 进程优先级; 调度者优先级; + | sysTid=28661 nice=-4 cgrp=default sched=0/0 handle=0xf72cbbec + // native线程状态; 调度者状态; 用户时间utime; 系统时间stime; 调度的CPU + | state=D schedstat=( 3137222937 94427228 5819 ) utm=218 stm=95 core=2 HZ=100 + // stack相关信息 + | stack=0xff717000-0xff719000 stackSize=8MB + + +疑问一:Native线程状态 + +细心的你可能会发现,为什么上面的ANR日志中“main”线程的状态是Suspended?想了一下,Java线程中的6种状态中并不存在Suspended状态啊。 + +事实上,Suspended代表的是Native线程状态。怎么理解呢?在Android里面Java线程的运行都委托于一个Linux标准线程pthread来运行,而Android里运行的线程可以分成两种,一种是Attach到虚拟机的,一种是没有Attach到虚拟机的,在虚拟机管理的线程都是托管的线程,所以本质上Java线程的状态其实是Native线程的一种映射。 + +不同的Android版本Native线程的状态不太一样,例如Android 9.0就定义了27种线程状态,它能更加明确地区分线程当前所处的情况。关于Java线程状态、Native线程状态转换,你可以参考thread_state.h和Thread_nativeGetStatus。 + + + +我们可以看到Native线程状态的确更加丰富,例如将TIMED_WAITING拆分成TimedWaiting和Sleeping两种场景,而WAITING更是细化到十几种场景等,这对我们分析特定场景问题的时候会有非常大的帮助。 + +疑问二:获得ANR日志 + +虽然ANR日志信息非常丰富,那问题又来了,如何拿到卡顿时的ANR日志呢? + +我们可以利用系统ANR的生成机制,具体步骤是: + +第一步:当监控到主线程卡顿时,主动向系统发送SIGQUIT信号。 + +第二步:等待/data/anr/traces.txt文件生成。 + +第三步:文件生成以后进行上报。 + +通过ANR日志,我们可以直接看到主线程的锁是由“BackgroundHandler”线程持有。相比之下通过getAllStackTraces方法,我们只能通过一个一个线程进行猜测。 + + // 堆栈相关信息 + at android.content.res.AssetManager.open(AssetManager.java:311) + - waiting to lock <0x41ddc798> (android.content.res.AssetManager) held by tid=66 (BackgroundHandler) + at android.content.res.AssetManager.open(AssetManager.java:289) + + +线程间的死锁和热锁分析是一个非常有意思的话题,很多情况分析起来也比较困难,例如我们只能拿到Java代码中使用的锁,而且有部分类型锁的持有并不会表现在堆栈上面。对这部分内容感兴趣,想再深入一下的同学,可以认真看一下这两篇文章:《Java线程Dump分析》、《手Q Android线程死锁监控与自动化分析实践》。 + +3. Hook实现 + +用SIGQUIT信号量获取ANR日志,从而拿到所有线程的各种信息,这套方案看起来很美好。但事实上,它存在这几个问题: + + +可行性。正如我在崩溃分析所说的一样,很多高版本系统已经没有权限读取/data/anr/traces.txt文件。 + +性能。获取所有线程堆栈以及各种信息非常耗时,对于卡顿场景不一定合适,它可能会进一步加剧用户的卡顿。 + + +那有什么方法既可以拿到ANR日志,整个过程又不会影响用户的体验呢? + +再回想一下,在崩溃分析的时候我们就讲过一种获得所有线程堆栈的方法。它通过下面几个步骤实现。 + + +通过libart.so、dlsym调用ThreadList::ForEach方法,拿到所有的Native线程对象。 + +遍历线程对象列表,调用Thread::DumpState方法。 + + +它基本模拟了系统打印ANR日志的流程,但是因为整个过程使用了一些黑科技,可能会造成线上崩溃。 + +为了兼容性考虑,我们会通过fork子进程方式实现,这样即使子进程崩溃了也不会影响我们主进程的运行。这样还可以带来另外一个非常大的好处,获取所有线程堆栈这个过程可以做到完全不卡我们主进程。 + +但使用fork进程会导致进程号改变,源码中通过/proc/self方式获取的一些信息都会失败(错误的拿了子进程的信息,而子进程只有一个线程),例如state、schedstat、utm、stm、core等。不过问题也不大,这些信息可以通过指定/proc/[父进程id]的方式重新获取。 + +"main" prio=7 tid=1 Native + | group="" sCount=0 dsCount=0 obj=0x74e99000 self=0xb8811080 + | sysTid=23023 nice=-4 cgrp=default sched=0/0 handle=0xb6fccbec + | state=? schedstat=( 0 0 0 ) utm=0 stm=0 core=0 HZ=100 + | stack=0xbe4dd000-0xbe4df000 stackSize=8MB + | held mutexes= + + +总的来说,通过Hook方式我们实现了一套“无损”获取所有Java线程堆栈与详细信息的方法。为了降低上报数据量,只有主线程的Java线程状态是WAITING、TIME_WAITING或者BLOCKED的时候,才会进一步使用这个“大杀器”。 + +4. 现场信息 + +现在再来看,这样一份我们自己构造的“ANR日志”是不是已经是收集崩溃现场信息的完全体了?它似乎缺少了我们常见的头部信息,例如进程CPU使用率、GC相关的信息。 + +正如第6期文章开头所说的一样,卡顿跟崩溃一样是需要“现场信息”的。能不能进一步让卡顿的“现场信息”的比系统ANR日志更加丰富?我们可以进一步增加这些信息: + + +CPU使用率和调度信息。参考第5期的课后练习,我们可以得到系统CPU使用率、负载、各线程的CPU使用率以及I/O调度等信息。 + +内存相关信息。我们可以添加系统总内存、可用内存以及应用各个进程的内存等信息。如果开启了Debug.startAllocCounting或者atrace,还可以增加GC相关的信息。 + +I/O和网络相关。我们还可以把卡顿期间所有的I/O和网络操作的详细信息也一并收集,这部分内容会在后面进一步展开。 + + +在Android 8.0后,Android虚拟机终于支持了JVM的JVMTI机制。Profiler中内存采集等很多模块也切换到这个机制中实现,后面我会邀请“学习委员”鹏飞给你讲讲JVMTI机制与应用。使用它可以获得的信息非常丰富,包括内存申请、线程创建、类加载、GC等,有大量的应用场景。 + +最后我们还可以利用崩溃分析中的一些思路,例如添加用户操作路径等信息,这样我们可以得到一份比系统ANR更加丰富的卡顿日志,这对我们解决某些疑难的卡顿问题会更有帮助。 + +卡顿分析 + +在客户端捕获卡顿之后,最后数据需要上传到后台统一分析。我们可以对数据做什么样的处理?应该关注哪些指标? + +1. 卡顿率 + +如果把主线程卡顿超过3秒定义为一个卡顿问题,类似崩溃,我们会先评估卡顿问题的影响面,也就是UV卡顿率。 + +UV 卡顿率 = 发生过卡顿 UV / 开启卡顿采集 UV + + +因为卡顿问题一般都是抽样上报,采样规则跟内存相似,都应该按照人来抽样。一个用户如果命中采集,那么在一天内都会持续的采集数据。 + +UV卡顿率可以评估卡顿的影响范围,但对于低端机器来说比较难去优化卡顿的问题。如果想评估卡顿的严重程度,我们可以使用PV卡顿率。 + +PV 卡顿率 = 发生过卡顿 PV / 启动采集 PV + + +需要注意的是,对于命中采集PV卡顿率的用户,每次启动都需要上报作为分母。 + +2. 卡顿树 + +发生卡顿时,我们会把CPU使用率和负载相关信息也添加到卡顿日志中。虽然采取了抽样策略,但每天的日志量还是达到十万级别。这么大的日志量,如果简单采用堆栈聚合日志,会发现有几百上千种卡顿类型,很难看出重点。 + +我们能不能实现卡顿的火焰图,在一张图里就可以看到卡顿的整体信息? + +这里我非常推荐卡顿树的做法,对于超过3秒的卡顿,具体是4秒还是10秒,这涉及手机性能和当时的环境。我们决定抛弃具体的耗时,只按照相同堆栈出现的比例来聚合。这样我们从一棵树上面,就可以看到哪些堆栈出现的卡顿问题最多,它下面又存在的哪些分支。 + + + +我们的精力是有限的,一般会优先去解决Top的卡顿问题。采用卡顿树的聚合方式,可以从全盘的角度看到Top卡顿问题的各个分支情况,帮助我们快速找到关键的卡顿点。 + +总结 + +今天我们从一个简单的卡顿问题出发,一步一步演进出解决这个问题的三种思路。其中Java实现的方案是大部分同学首先想到的方案,它虽然简单稳定,不过存在信息不全、性能差等问题。 + +可能很多同学认为问题可以解决就算万事大吉了,但我并不这样认为。我们应该继续敲问自己,如果再出现类似的问题,我们是否也可以采用相同的方法去解决?这个方案的代价对用户会带来多大的影响,是否还有优化的空间? + +只有这样,才会出现文中的方案二和方案三,解决方案才会一直向前演进,做得越来越好。也只有这样,我们才能在追求卓越的过程中快速进步。 + +课后作业 + +线程等待、死锁和热锁在应用中都是非常普遍的,今天的课后作业是分享一下你的产品中是否出现过这些问题,又是如何解决的?请你在留言区分享一下今天学习、练习的收获与心得。 + +我在评论中发现很多同学对监控Thread的创建比较感兴趣,今天我们的Sample是如何监控线程的创建。在实践前,给你一些可以参考的链接。 + + +Android线程的创建过程 + +java_lang_Thread.cc + +thread.cc + +编译脚本Android.bp + + +对于PLT Hook和Inline Hook的具体实现原理与差别,我在后面会详细讲到。这里我们可以把它们先隐藏掉,直接利用开源的实现即可。通过这个Sample我希望你可以学会通过分析源码,寻找合理的Hook函数与具体的so库。我相信当你熟悉这些方法之后,一定会惊喜地发现实现起来其实真的不难。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/07启动优化(上):从启动过程看启动速度优化.md b/专栏/Android开发高手课/07启动优化(上):从启动过程看启动速度优化.md new file mode 100644 index 0000000..1f87ee6 --- /dev/null +++ b/专栏/Android开发高手课/07启动优化(上):从启动过程看启动速度优化.md @@ -0,0 +1,219 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 启动优化(上):从启动过程看启动速度优化 + +在超市排队结账,扫码支付启动十几秒都还没完成,只能换一个工具支付? + +想买本书充实一下,页面刷出来时候十几秒都不能操作,那就换一个应用购买? + + +用户如果想打开一个应用,就一定要经过“启动”这个步骤。启动时间的长短,不只是用户体验的问题,对于淘宝、京东这些应用来说,会直接影响留存和转化等核心数据。对研发人员来说,启动速度是我们的“门面”,它清清楚楚可以被所有人看到,我们都希望自己应用的启动速度可以秒杀所有竞争对手。 + +那启动过程究竟会出现哪些问题?我们应该怎么去优化和监控应用的启动速度呢?今天我们一起来看看这些问题该如何解决。 + +启动分析 + +在真正动手开始优化之前,我们应该先搞清楚从用户点击图标开始,整个启动过程经过哪几个关键阶段,又会给用户带来哪些体验问题。 + +1. 启动过程分析 + + + +我以微信为例,用户从桌面点击图标开始,会经过4个关键阶段。 + + +T1预览窗口显示。系统在拉起微信进程之前,会先根据微信的Theme属性创建预览窗口。当然如果我们禁用预览窗口或者将预览窗口指定为透明,用户在这段时间依然看到的是桌面。 + +T2闪屏显示。在微信进程和闪屏窗口页面创建完毕,并且完成一系列inflate view、onmeasure、onlayout等准备工作后,用户终于可以看到熟悉的“小地球”。 + +T3主页显示。在完成主窗口创建和页面显示的准备工作后,用户可以看到微信的主界面。 + +T4界面可操作。在启动完成后,微信会有比较多的工作需要继续执行,例如聊天和朋友圈界面的预加载、小程序框架和进程的准备等。在这些工作完成后,用户才可以真正开始愉快地聊天。 + + +2. 启动问题分析 + +从启动流程的4个关键阶段,我们可以推测出用户启动过程会遇到的3个问题。这3个问题其实也是大多数应用在启动时可能会遇到的。 + + +问题1:点击图标很久都不响应 + + +如果我们禁用了预览窗口或者指定了透明的皮肤,那用户点击了图标之后,需要T2时间才能真正看到应用闪屏。对于用户体验来说,点击了图标,过了几秒还是停留在桌面,看起来就像没有点击成功,这在中低端机中更加明显。 + + +问题2:首页显示太慢 + + +现在应用启动流程越来越复杂,闪屏广告、热修复框架、插件化框架、大前端框架,所有准备工作都需要集中在启动阶段完成。上面说的T3首页显示时间对于中低端机来说简直就是噩梦,经常会达到十几秒的时间。 + + +问题3:首页显示后无法操作。 + + +既然首页显示那么慢,那我能不能把尽量多的工作都通过异步化延后执行呢?很多应用的确就是这么做的,但这会造成两种后果:要么首页会出现白屏,要么首页出来后用户根本无法操作。 + +很多应用把启动结束时间的统计放到首页刚出现的时候,这对用户是不负责任的。看到一个首页,但是停住十几秒都不能滑动,这对用户来说完全没有意义。启动优化不能过于KPI化,要从用户的真实体验出发,要着眼从点击图标到用户可操作的整个过程。 + +启动优化 + +启动速度优化的方法和卡顿优化基本相同,不过因为启动实在是太重要了,我们会更加“精打细算”。我们希望启动期间加载的每个功能和业务都是必须的,它们的实现都是经过“千锤百炼”的,特别是在中低端机上面的表现。 + +1. 优化工具 + +“工欲善其事必先利其器”,我们需要先找到一款适合做启动优化分析的工具。 + +你可以先回忆一下“卡顿优化”提到的几种工具。Traceview性能损耗太大,得出的结果并不真实;Nanoscope非常真实,不过暂时只支持Nexus 6P和x86模拟器,无法针对中低端机做测试;Simpleperf的火焰图并不适合做启动流程分析;systrace可以很方便地追踪关键系统调用的耗时情况,但是不支持应用程序代码的耗时分析。 + +综合来看,在卡顿优化中提到“systrace + 函数插桩”似乎是比较理想的方案,而且它还可以看到系统的一些关键事件,例如GC、System Server、CPU调度等。 + +我们可以通过下面的命令,可以查看手机支持哪些systrace类型。不同的系统支持的类型有所差别,其中Dalvik、sched、ss、app都是我们比较关心的。 + +python systrace.py --list-categories + + +通过插桩,我们可以看到应用主线程和其他线程的函数调用流程。它的实现原理非常简单,就是将下面的两个函数分别插入到每个方法的入口和出口。 + +class Trace { + public static void i(String tag) { + Trace.beginSection(name); + } + + + public static void o() { + Trace.endSection(); + } +} + + +当然这里面有非常多的细节需要考虑,比如怎么样降低插桩对性能的影响、哪些函数需要被排除掉。最终改良版的systrace性能损耗在一倍以内,基本可以反映真实的启动流程。函数插桩后的效果如下,你也可以参考课后练习的Sample。 + +class Test { + public void test() { + Trace.i("Test.test()"); + //原来的工作 + Trace.o(); + } +} + + +只有准确的数据评估才能指引优化的方向,这一步是非常非常重要的。我见过太多同学在没有充分评估或者评估使用了错误的方法,最终得到了错误的方向。辛辛苦苦一两个月,最后发现根本达不到预期的效果。 + +2. 优化方式 + +在拿到整个启动流程的全景图之后,我们可以清楚地看到这段时间内系统、应用各个进程和线程的运行情况,现在我们要开始真正开始“干活”了。 + +具体的优化方式,我把它们分为闪屏优化、业务梳理、业务优化、线程优化、GC优化和系统调用优化。 + + +闪屏优化 + + +今日头条把预览窗口实现成闪屏的效果,这样用户只需要很短的时间就可以看到“预览闪屏”。这种完全“跟手”的感觉在高端机上体验非常好,但对于中低端机,会把总的的闪屏时间变得更长。 + +如果点击图标没有响应,用户主观上会认为是手机系统响应比较慢。所以我比较推荐的做法是,只在Android 6.0或者Android 7.0以上才启用“预览闪屏”方案,让手机性能好的用户可以有更好的体验。 + +微信做的另外一个优化是合并闪屏和主页面的Activity,减少一个Activity会给线上带来100毫秒左右的优化。但是如果这样做的话,管理时会非常复杂,特别是有很多例如PWA、扫一扫这样的第三方启动流程的时候。 + + +业务梳理 + + +我们首先需要梳理清楚当前启动过程正在运行的每一个模块,哪些是一定需要的、哪些可以砍掉、哪些可以懒加载。我们也可以根据业务场景来决定不同的启动模式,例如通过扫一扫启动只需要加载需要的几个模块即可。对于中低端机器,我们要学会降级,学会推动产品经理做一些功能取舍。但是需要注意的是,懒加载要防止集中化,否则容易出现首页显示后用户无法操作的情形。 + + +业务优化 + + +通过梳理之后,剩下的都是启动过程一定要用的模块。这个时候,我们只能硬着头皮去做进一步的优化。优化前期需要“抓大放小”,先看看主线程究竟慢在哪里。最理想是通过算法进行优化,例如一个数据解密操作需要1秒,通过算法优化之后变成10毫秒。退而求其次,我们要考虑这些任务是不是可以通过异步线程预加载实现,但需要注意的是过多的线程预加载会让我们的逻辑变得更加复杂。 + +业务优化做到后面,会发现一些架构和历史包袱会拖累我们前进的步伐。比较常见的是一些事件会被各个业务模块监听,大量的回调导致很多工作集中执行,部分框架初始化“太厚”,例如一些插件化框架,启动过程各种反射、各种Hook,整个耗时至少几百毫秒。还有一些历史包袱又非常沉重,而且“牵一发动全身”,改动风险比较大。但是我想说,如果有合适的时机,我们依然需要勇敢去偿还这些“历史债务”。 + + +线程优化 + + +线程优化就像做填空题和解锁题,我们希望能把所有的时间片都利用上,因此主线程和各个线程都是一直满载的。当然我们也希望每个线程都开足马力向前跑,而不是作为接力棒。所以线程的优化主要在于减少CPU调度带来的波动,让应用的启动时间更加稳定。 + +从具体的做法来看,线程的优化一方面是控制线程数量,线程数量太多会相互竞争CPU资源,因此要有统一的线程池,并且根据机器性能来控制数量。 + +线程切换的数据我们可以通过卡顿优化中学到的sched文件查看,这里特别需要注意nr_involuntary_switches被动切换的次数。 + +proc/[pid]/sched: + nr_voluntary_switches: + 主动上下文切换次数,因为线程无法获取所需资源导致上下文切换,最普遍的是IO。 + nr_involuntary_switches: + 被动上下文切换次数,线程被系统强制调度导致上下文切换,例如大量线程在抢占CPU。 + + +另一方面是检查线程间的锁。为了提高启动过程任务执行的速度,有一次我们把主线程内的一个耗时任务放到线程中并发执行,但是发现这样做根本没起作用。仔细检查后发现线程内部会持有一个锁,主线程很快就有其他任务因为这个锁而等待。通过systrace可以看到锁等待的事件,我们需要排查这些等待是否可以优化,特别是防止主线程出现长时间的空转。 + + + +特别是现在有很多启动框架,会使用Pipeline机制,根据业务优先级规定业务初始化时机。比如微信内部使用的mmkernel、阿里最近开源的Alpha启动框架,它们为各个任务建立依赖关系,最终构成一个有向无环图。对于可以并发的任务,会通过线程池最大程度提升启动速度。如果任务的依赖关系没有配置好,很容易出现下图这种情况,即主线程会一直等待taskC结束,空转2950毫秒。 + + + + +GC优化 + + +在启动过程,要尽量减少GC的次数,避免造成主线程长时间的卡顿,特别是对Dalvik来说,我们可以通过systrace单独查看整个启动过程GC的时间。 + +python systrace.py dalvik -b 90960 -a com.sample.gc + + +对于GC各个事件的具体含义,你可以参考《调查RAM使用情况》。 + + + +不知道你是否还记得我在“内存优化”中提到Debug.startAllocCounting,我们也可以使用它来监控启动过程总GC的耗时情况,特别是阻塞式同步GC的总次数和耗时。 + +// GC使用的总耗时,单位是毫秒 +Debug.getRuntimeStat("art.gc.gc-time"); +// 阻塞式GC的总耗时 +Debug.getRuntimeStat("art.gc.blocking-gc-time"); + + +如果我们发现主线程出现比较多的GC同步等待,那就需要通过Allocation工具做进一步的分析。启动过程避免进行大量的字符串操作,特别是序列化跟反序列化过程。一些频繁创建的对象,例如网络库和图片库中的Byte数组、Buffer可以复用。如果一些模块实在需要频繁创建对象,可以考虑移到Native实现。 + +Java对象的逃逸也很容易引起GC问题,我们在写代码的时候比较容易忽略这个点。我们应该保证对象生命周期尽量的短,在栈上就进行销毁。 + + +系统调用优化 + + +通过systrace的System Service类型,我们可以看到启动过程System Server的CPU工作情况。在启动过程,我们尽量不要做系统调用,例如PackageManagerService操作、Binder调用等待。 + +在启动过程也不要过早地拉起应用的其他进程,System Server和新的进程都会竞争CPU资源。特别是系统内存不足的时候,当我们拉起一个新的进程,可能会成为“压死骆驼的最后一根稻草”。它可能会触发系统的low memory killer机制,导致系统杀死和拉起(保活)大量的进程,从而影响前台进程的CPU。 + +讲个实践的案例,之前我们的一个程序在启动过程会拉起下载和视频播放进程,改为按需拉起后,线上启动时间提高了3%,对于1GB以下的低端机优化,整个启动时间可以优化5%~8%,效果还是非常明显的。 + +总结 + +今天我们首先学习了启动的整个流程,其中比较关键的是4个阶段。在这4个阶段中,用户可能会出现“点击图标很久都不响应“”首页显示太慢“和”首页显示后无法操作“这3个问题。 + +接着我们学习了启动优化和监控的一些常规方法。针对不同的业务场景、不同性能的机器,需要采用不同的策略。有些知识点似乎比较“浅尝辄止”,我更希望你能够通过学习和实践将它们丰富起来。 + +我讲到的大部分内容都是跟业务相关,业务的梳理和优化也是最快出成果的。不过这个过程我们要学会取舍,你可能遇到过,很多产品经理为了提升自己负责的模块的数据,总会逼迫开发做各种各样的预加载。但是大家都想快,最后的结果就是代码一团糟,肯定都快不起来。 + +比如只有1%用户使用的功能,却让所有用户都做预加载。面对这种情况,我们要狠下心来,只留下那些真正不能删除的业务,或者通过场景化直接找到那1%的用户。跟产品经理PK可能不是那么容易,关键在于数据。我们需要证明启动优化带来整体留存、转化的正向价值,是大于某个业务取消预加载带来的负面影响。 + +启动优化是性能优化工作非常重要的一环,今天的课后作业是,在你过去的工作中,曾经针对启动做过哪些优化,最终效果又是怎样的呢?请你在留言区分享一下今天学习、练习的收获与心得。 + +课后练习 + +“工欲善其事必先利其器”,我多次提到“systrace + 函数插桩”是一个非常不错的卡顿排查工具,那么通过今天的Sample,我们一起来看一下它是如何实现的。需要注意的是Sample选择了ASM插桩的方式,感兴趣的同学可以课后学习一下它的使用方法,在后续我们也会有关于插桩的专门课程。 + +我们可以将Sample运用到自己的应用中,虽然它过滤了大部分的函数,但是我们还是需要注意白名单的配置。例如log、加解密等在底层非常频繁调用的函数,都要在白名单中配置过滤掉,不然可能会出现类似下面这样大量的毛刺。 + + + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/08启动优化(下):优化启动速度的进阶方法.md b/专栏/Android开发高手课/08启动优化(下):优化启动速度的进阶方法.md new file mode 100644 index 0000000..38cab93 --- /dev/null +++ b/专栏/Android开发高手课/08启动优化(下):优化启动速度的进阶方法.md @@ -0,0 +1,221 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 启动优化(下):优化启动速度的进阶方法 + 专栏上一期,我们一起梳理了应用启动的整个过程和问题,也讲了一些启动优化方法,可以说是完成了启动优化工作最难的一部分。还可以通过删掉或延后一些不必要的业务,来实现相关具体业务的优化。你学会了这些工具和方法,是不是觉得效果非常不错,然后美滋滋地向老大汇报工作成果:“启动速度提升30%,秒杀所有竞品好几条街”。 + +“还有什么方法可以做进一步优化吗?怎么证明你秒杀所有的竞品?如何在线上衡量启动优化的效果?怎么保障和监控启动速度是否变慢?”,老大一口气问了四个问题。 + +面对这四个问题,你可不能一脸懵。我们的应用启动是不是真的已经做到了极致?如何保证启动优化成果是长期有效的?让我们通过今天的学习,一起来回答老大这些问题吧。 + +启动进阶方法 + +除了上期讲的常规的优化方法,我还有一些与业务无关的“压箱底”方法可以帮助加快应用的启动速度。当然有些方法会用到一些黑科技,它就像一把双刃剑,需要你做深入的评估和测试。 + +1. I/O 优化 + +在负载过高的时候,I/O性能下降得会比较快。特别是对于低端机,同样的I/O操作耗时可能是高端机器的几十倍。启动过程不建议出现网络I/O,相比之下,磁盘I/O是启动优化一定要抠的点。首先我们要清楚启动过程读了什么文件、多少个字节、Buffer是多大、使用了多长时间、在什么线程等一系列信息。 + + + +那么如何实现I/O的监控呢?我今天先卖个关子,下一期我会详细和你聊聊I/O方面的知识。 + +通过上面的数据,我们发现chat.db的大小竟然达到500MB。我们经常发现本地启动明明非常快,为什么线上有些用户就那么慢?这可能是一些用户本地积累了非常多的数据,我们也发现有些微信的重度用户,他的DB文件竟然会超过1GB。所以,重度用户是启动优化一定要覆盖的群体,我们要做一些特殊的优化策略。 + +还有一个是数据结构的选择问题,我们在启动过程只需要读取Setting.sp的几项数据,不过SharedPreference在初始化的时候还是要全部数据一起解析。如果它的数据量超过1000条,启动过程解析时间可能就超过100毫秒。如果只解析启动过程用到的数据项则会很大程度减少解析时间,启动过程适合使用随机读写的数据结构。 + + + +可以将ArrayMap改造成支持随机读写、延时解析的数据存储方式。同样我们今天也不再展开这部分内容,这些知识会在存储优化的相关章节进一步展开。 + +2. 数据重排 + +在上面的表格里面,我们读取test.io文件中1KB数据,因为Buffer不小心写成了1 byte,总共要读取1000次。那系统是不是真的会读1000次磁盘呢? + +事实上1000次读操作只是我们发起的次数,并不是真正的磁盘I/O次数。你可以参考下面Linux文件I/O流程。 + + + +Linux文件系统从磁盘读文件的时候,会以block为单位去磁盘读取,一般block大小是4KB。也就是说一次磁盘读写大小至少是4KB,然后会把4KB数据放到页缓存Page Cache中。如果下次读取文件数据已经在页缓存中,那就不会发生真实的磁盘I/O,而是直接从页缓存中读取,大大提升了读的速度。所以上面的例子,我们虽然读了1000次,但事实上只会发生一次磁盘I/O,其他的数据都会在页缓存中得到。 + +Dex文件用的到的类和安装包APK里面各种资源文件一般都比较小,但是读取非常频繁。我们可以利用系统这个机制将它们按照读取顺序重新排列,减少真实的磁盘I/O次数。 + +类重排 + +启动过程类加载顺序可以通过复写ClassLoader得到。 + +class GetClassLoader extends PathClassLoader { + public Class findClass(String name) { + // 将 name 记录到文件 + writeToFile(name,"coldstart_classes.txt"); + return super.findClass(name); + } +} + + +然后通过ReDex的Interdex调整类在Dex中的排列顺序,最后可以利用010 Editor查看修改后的效果。 + + + +我多次提到的ReDex,是Facebook开源的Dex优化工具,它里面有非常多好用的东西,后续我们会有更详细的介绍。 + +资源文件重排 + +Facebook在比较早的时候就使用“资源热图”来实现资源文件的重排,最近支付宝在《通过安装包重排布优化Android端启动性能》中也详细讲述了资源重排的原理和落地方法。 + +在实现上,它们都是通过修改Kernel源码,单独编译了一个特殊的ROM。这样做的目的有三个: + + +统计。统计应用启动过程加载了安装包中哪些资源文件,比如assets、drawable、layout等。跟类重排一样,我们可以得到一个资源加载的顺序列表。 + +度量。在完成资源顺序重排后,我们需要确定是否真正生效。比如有哪些资源文件加载了,它是发生真实的磁盘I/O,还是命中了Page Cache。 + +自动化。任何代码提交都有可能改变启动过程中类和资源的加载顺序,如果完全依靠人工手动处理,这个事情很难持续下去。通过定制ROM的一些埋点和配合的工具,我们可以将它们放到自动化流程当中。 + + +跟前面提到的Nanoscope耗时分析工具一样,当系统无法满足我们的优化需求时,就需要直接修改ROM的实现。Facebook“资源热图”相对比较完善,也建设了一些配套的Dashboard工具,希望后续可以开源出来。 + +事实上如果仅仅为了统计,我们也可以使用Hook的方式。下面是利用Frida实现获得Android资源加载顺序的方法,不过Frida还是相对小众,后面会替换其他更加成熟的Hook框架。 + +resourceImpl.loadXmlResourceParser.implementation=function(a,b,c,d){ + send('file:'+a) + return this.loadXmlResourceParser(a,b,c,d) +} + + +resourceImpl.loadDrawableForCookie.implementation=function(a,b,c,d,e){ + send("file:"+a) + return this.loadDrawableForCookie(a,b,c,d,e) +} + + +调整安装包文件排列需要修改7zip源码实现支持传入文件列表顺序,同样最后可以利用010 Editor查看修改后的效果。 + + + +这两个优化可能会带来100~200毫秒的提高,我们还可以大大减少启动过程I/O的时间波动。特别是对于中低端机器来说,经常发现启动时间波动非常大,这个波动跟CPU调度相关,但更多时候是跟I/O相关。 + +可能有同学会问,这些优化思路究竟是怎么样想出来的呢?其实利用文件系统和磁盘读取机制的优化思路,在服务端和Windows上早已经不是什么新鲜事。所谓的创新,不一定是创造前所未有的东西。我们将已有的方案移植到新的平台,并且很好地结合该平台的特性将其落地,就是一个很大的创新。 + +3. 类的加载 + +在WeMobileDev公众号发布的《微信Android热补丁实践演进之路》中,我提过在加载类的过程有一个verify class的步骤,它需要校验方法的每一个指令,是一个比较耗时的操作。 + + + +我们可以通过Hook来去掉verify这个步骤,这对启动速度有几十毫秒的优化。不过我想说,其实最大的优化场景在于首次和覆盖安装时。以Dalvik平台为例,一个2MB的Dex正常需要350毫秒,将classVerifyMode设为VERIFY_MODE_NONE后,只需要150毫秒,节省超过50%的时间。 + +// Dalvik Globals.h +gDvm.classVerifyMode = VERIFY_MODE_NONE; +// Art runtime.cc +verify_ = verifier::VerifyMode::kNone; + + +但是ART平台要复杂很多,Hook需要兼容几个版本。而且在安装时大部分Dex已经优化好了,去掉ART平台的verify只会对动态加载的Dex带来一些好处。Atlas中的dalvik_hack-3.0.0.5.jar可以通过下面的方法去掉verify,但是当前没有支持ART平台。 + +AndroidRuntime runtime = AndroidRuntime.getInstance(); +runtime.init(context); +runtime.setVerificationEnabled(false); + + +这个黑科技可以大大降低首次启动的速度,代价是对后续运行会产生轻微的影响。同时也要考虑兼容性问题,暂时不建议在ART平台使用。 + +4. 黑科技 + +第一,保活 + +讲到黑科技,你可能第一个想到的就是保活。保活可以减少Application创建跟初始化的时间,让冷启动变成温启动。不过在Target 26之后,保活的确变得越来越难。 + +对于大厂来说,可能需要寻求厂商合作的机会,例如微信的Hardcoder方案和OPPO推出的Hyper Boost方案。根据OPPO的数据,对于手机QQ、淘宝、微信启动场景会直接有20%以上的优化。 + +有的时候你问为什么微信可以保活?为什么它可以运行的那么流畅?这里可能不仅仅是技术上的问题,当应用体量足够大,就可以倒逼厂商去专门为它们做优化。 + +第二,插件化和热修复 + +从2012年开始,淘宝、微信尝试做插件化的探索。到了2015年,淘宝的Dexposed、支付宝的AndFix以及微信的Tinker等热修复技术开始“百花齐放”。 + +它们真的那么好吗?事实上大部分的框架在设计上都存在大量的Hook和私有API调用,带来的缺点主要有两个: + + +稳定性。虽然大家都号称兼容100%的机型,由于厂商的兼容性、安装失败、dex2oat失败等原因,还是会有那么一些代码和资源的异常。Android P推出的non-sdk-interface调用限制,以后适配只会越来越难,成本越来越高。 + +性能。Android Runtime每个版本都有很多的优化,因为插件化和热修复用到的一些黑科技,导致底层Runtime的优化我们是享受不到的。Tinker框架在加载补丁后,应用启动速度会降低5%~10%。 + + +应用加固对启动速度来说简直是灾难,有时候我们需要做一些权衡和选择。为了提升启动速度,支付宝也提出一种GC抑制的方案。不过首先Android 5.0以下的系统占比已经不高,其次这也会带来一些兼容性问题。我们还是更希望通过手段可以真正优化整个耗时,而不是一些取巧的方式。 + +总的来说,对于黑科技我们需要慎重,当你足够了解它们内部的机制以后,可以选择性的使用。 + +启动监控 + +终于千辛万苦的优化好了,我们还要找一套合理、准确的方法来度量优化的成果。同时还要对它做全方位的监控,以免被人破坏劳动果实。 + +1. 实验室监控 + +如果想客观地反映启动的耗时,视频录制会是一个非常好的选择。特别是我们很难拿到竞品的线上数据,所以实验室监控也非常适合做竞品的对比测试。 + +它的难点在于如何让实验系统准确地找到启动结束的点,这里可以通过下面两种方式。 + + +80%绘制。当页面绘制超过80%的时候认为是启动完成,不过可能会把闪屏当成启动结束的点,不一定是我们所期望的。 + +图像识别。手动输入一张启动结束的图片,当实验系统认为当前截屏页面有80%以上相似度时,就认为是启动结束。这种方法更加灵活可控,但是实现难度会稍微高一点。 + + + + +启动的实验室监控可以定期自动去跑,需要注意的是,我们应该覆盖高、中、低端机不同的场景。但是使用录屏的方式也有一个缺陷,就是出现问题时我们需要人工二次定位具体是什么代码所导致的。 + +2. 线上监控 + +实验室覆盖的场景和机型还是有限的,是驴是马我们还是要发布到线上进行验证。针对线上,启动监控会更加复杂一些。Android Vitals可以对应用冷启动、温启动时间做监控。 + + + +事实上,每个应用启动的流程都非常复杂,上面的图并不能真实反映每个应用的启动耗时。启动耗时的计算需要考虑非常多的细节,比如: + + +启动结束的统计时机。是否是使用用户真正可以操作的时间作为启动结束的时间。 + +启动时间扣除的逻辑。闪屏、广告和新手引导这些时间都应该从启动时间里扣除。 + +启动排除逻辑。Broadcast、Server拉起,启动过程进入后台这些都需要排除出统计。 + + +经过精密的扣除和排除逻辑,我们最终可以得到用户的线上启动耗时。正如我在上一期所说的,准确的启动耗时统计是非常重要的。有很多优化在实验室完成之后,还需要在线上灰度验证效果。这个前提是启动统计是准确的,整个效果评估是真实的。 + +那我们一般使用什么指标来衡量启动速度的快慢呢? + +很多应用采用平均启动时间,不过这个指标其实并不太好,一些体验很差的用户很有可能是被平均了。我更建议使用类似下面的指标: + + +快开慢开比。例如2秒快开比、5秒慢开比,我们可以看到有多少比例的用户体验非常好,多少比例的用户比较槽糕。 + +90%用户的启动时间。如果90%的用户启动时间都小于5秒,那么我们90%区间启动耗时就是5秒。 + + +此外我们还要区分启动的类型。这里要统计首次安装启动、覆盖安装启动、冷启动和温启动这些类型,一般我们都使用普通的冷启动时间作为指标。另一方面热启动的占比也可以反映出我们程序的活跃或保活能力。 + +除了指标的监控,启动的线上堆栈监控更加困难。Facebook会利用Profilo工具对启动的整个流程耗时做监控,并且在后台直接对不同的版本做自动化对比,监控新版本是否有新增耗时的函数。 + +总结 + +今天我们学习了一些与业务无关的启动优化方法,可以进一步减少启动耗时,特别是减少磁盘I/O可能带来的波动。然后我们探讨了一些黑科技对启动的影响,对于黑科技我们需要两面看,在选择时也要慎重。最后我们探讨了如何在实验室和线上更好地测量和监控启动速度。 + +启动优化需要耐得住寂寞,把整个流程摸清摸透,一点点把时间抠出来,特别是对于低端机和系统繁忙的场景。而数据重排的优化,对我有非常大的启发,帮助我开发了一个新的方向。也让我明白了,当我们足够熟悉底层的知识时,可以利用系统的特性去做更加深层次的优化。 + +不管怎么说,你都需要谨记一点:对于启动优化要警惕KPI化,我们要解决的不是一个数字,而是用户真正的体验问题。 + +看完我分享的启动优化的方法后,相信你肯定也还有很多好的思路和方法。今天的课后作业是分享一下你“压箱底”的启动优化“秘籍”,在留言区分享一下今天学习、练习的收获与心得。 + +课后练习 + +今天我们的Sample是如何在Dalvik去掉verify,你可以顺着这个思路尝试去分析Dalvik虚拟机加载Dex和类的流程。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/09I_O优化(上):开发工程师必备的I_O优化知识.md b/专栏/Android开发高手课/09I_O优化(上):开发工程师必备的I_O优化知识.md new file mode 100644 index 0000000..0943782 --- /dev/null +++ b/专栏/Android开发高手课/09I_O优化(上):开发工程师必备的I_O优化知识.md @@ -0,0 +1,248 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 I_O优化(上):开发工程师必备的I_O优化知识 + +250GB容量,512MB DDR4缓存,连续读取不超过550MB/s,连续写入不超过520MB/s。 + + +“双十一”在天猫看到一款固态硬盘有上面的这些介绍,这些数字分别代表了什么意思? + +在专栏前面卡顿和启动优化里,我也经常提到I/O优化。可能很多同学觉得I/O优化不就是不在主线程读写大文件吗,真的只有这么简单吗?那你是否考虑过,从应用程序调用read()方法,内核和硬件会做什么样的处理,整个流程可能会出现什么问题?今天请你带着这些疑问,我们一起来看看I/O优化需要的知识。 + +I/O的基本知识 + +在工作中,我发现很多工程师对I/O的认识其实比较模糊,认为I/O就是应用程序执行read()、write()这样的一些操作,并不清楚这些操作背后的整个流程是怎样的。 + + + +我画了一张简图,你可以看到整个文件I/O操作由应用程序、文件系统和磁盘共同完成。首先应用程序将I/O命令发送给文件系统,然后文件系统会在合适的时机把I/O操作发给磁盘。 + +这就好比CPU、内存、磁盘三个小伙伴一起完成接力跑,最终跑完的时间很大程度上取决于最慢的小伙伴。我们知道,CPU和内存相比磁盘是高速设备,整个流程的瓶颈在于磁盘I/O的性能。所以很多时候,文件系统性能比磁盘性能更加重要,为了降低磁盘对应用程序的影响,文件系统需要通过各种各样的手段进行优化。那么接下来,我们首先来看文件系统。 + +1. 文件系统 + +文件系统,简单来说就是存储和组织数据的方式。比如在iOS 10.3系统以后,苹果使用APFS(Apple File System)替代之前旧的文件系统HFS+。对于Android来说,现在普遍使用的是Linux常用的ext4文件系统。 + +关于文件系统还需要多说两句,华为在EMUI 5.0以后就使用F2FS取代ext4,Google也在最新的旗舰手机Pixel 3使用了F2FS文件系统。Flash-Friendly File System是三星是专门为NAND闪存芯片开发的文件系统,也做了大量针对闪存的优化。根据华为的测试数据,F2FS文件系统在小文件的随机读写方面比ext4更快,例如随机写可以优化60%,不足之处在于可靠性方面出现过一些问题。我想说的是,随着Google、华为的投入和规模化使用,F2FS系统应该是未来Android的主流文件系统。 + +还是回到文件系统的I/O。应用程序调用read()方法,系统会通过中断从用户空间进入内核处理流程,然后经过VFS(Virtual File System,虚拟文件系统)、具体文件系统、页缓存Page Cache。下面是Linux一个通用的I/O架构模型。 + + + + +虚拟文件系统(VFS)。它主要用于实现屏蔽具体的文件系统,为应用程序的操作提供一个统一的接口。这样保证就算厂商把文件系统从ext4切换到F2FS,应用程序也不用做任何修改。 + +文件系统(File System)。ext4、F2FS都是具体文件系统实现,文件元数据如何组织、目录和索引结构如何设计、怎么分配和清理数据,这些都是设计一个文件系统必须要考虑的。每个文件系统都有适合自己的应用场景,我们不能说F2FS就一定比ext4要好。F2FS在连续读取大文件上并没有优势,而且会占用更大的空间。只是对一般应用程序来说,随机I/O会更加频繁,特别是在启动的场景。你可以在/proc/filesystems看到系统可以识别的所有文件系统的列表。 + +页缓存(Page Cache)。在启动优化中我已经讲过Page Cache这个概念了,在读文件的时候会,先看它是不是已经在Page Cache中,如果命中就不会去读取磁盘。在Linux 2.4.10之前还有一个单独的Buffer Cache,后来它也合并到Page Cache中的Buffer Page了。 + + +具体来说,Page Cache就像是我们经常使用的数据缓存,是文件系统对数据的缓存,目的是提升内存命中率。Buffer Cache就像我们经常使用的BufferInputStream,是磁盘对数据的缓存,目的是合并部分文件系统的I/O请求、降低磁盘I/O的次数。需要注意的是,它们既会用在读请求中,也会用到写请求中。 + +通过/proc/meminfo文件可以查看缓存的内存占用情况,当手机内存不足的时候,系统会回收它们的内存,这样整体I/O的性能就会有所降低。 + +MemTotal: 2866492 kB +MemFree: 72192 kB +Buffers: 62708 kB // Buffer Cache +Cached: 652904 kB // Page Cache + + +2. 磁盘 + +磁盘指的是系统的存储设备,就像小时候我们常听的CD或者电脑使用的机械硬盘,当然还有现在比较流行的SSD固态硬盘。 + +正如我上面所说,如果发现应用程序要read()的数据没有在页缓存中,这时候就需要真正向磁盘发起I/O请求。这个过程要先经过内核的通用块层、I/O调度层、设备驱动层,最后才会交给具体的硬件设备处理。 + + + + +通用块层。系统中能够随机访问固定大小数据块(block)的设备称为块设备,CD、硬盘和SSD这些都属于块设备。通用块层主要作用是接收上层发出的磁盘请求,并最终发出I/O请求。它跟VFS的作用类似,让上层不需要关心底层硬件设备的具体实现。 + +I/O调度层。磁盘I/O那么慢,为了降低真正的磁盘I/O,我们不能接收到磁盘请求就立刻交给驱动层处理。所以我们增加了I/O调度层,它会根据设置的调度算法对请求合并和排序。这里比较关键的参数有两个,一个是队列长度,一个是具体的调度算法。我们可以通过下面的文件可以查看对应块设备的队列长度和使用的调度算法。 + + +/sys/block/[disk]/queue/nr_requests // 队列长度,一般是 128。 +/sys/block/[disk]/queue/scheduler // 调度算法 + + + +块设备驱动层。块设备驱动层根据具体的物理设备,选择对应的驱动程序通过操控硬件设备完成最终的I/O请求。例如光盘是靠激光在表面烧录存储、闪存是靠电子擦写存储数据。 + + +Android I/O + +前面讲了Linux I/O相关的一些知识,现在我们再来讲讲Android I/O相关的一些知识。 + +1. Android闪存 + +我们先来简单讲讲手机使用的存储设备,手机使用闪存作为存储设备,也就是我们常说的ROM。 + +考虑到体积和功耗,我们肯定不能直接把PC的SSD方案用在手机上面。Android手机前几年通常使用eMMC标准,近年来通常会采用性能更好的UFS 2.0/2.1标准,之前沸沸扬扬的某厂商“闪存门”事件就是因为使用eMMC闪存替换了宣传中的UFS闪存。而苹果依然坚持独立自主的道路,在2015年就在iPhone 6s上就引入了MacBook上备受好评的NVMe协议。 + +最近几年移动硬件的发展非常妖孽,手机存储也朝着体积更小、功耗更低、速度更快、容量更大的方向狂奔。iPhone XS的容量已经达到512GB,连续读取速度可以超过1GB/s,已经比很多的SSD固态硬盘还要快,同时也大大缩小了和内存的速度差距。不过这些都是厂商提供的一些测试数据,特别是对于随机读写的性能相比内存还是差了很多。 + + + +上面的数字好像有点抽象,直白地说闪存的性能会影响我们打开微信、游戏加载以及连续自拍的速度。当然闪存性能不仅仅由硬件决定,它跟采用的标准、文件系统的实现也有很大的关系。 + +2. 两个疑问 + +看到这里可能有些同学会问,知道文件读写的流程、文件系统和磁盘这些基础知识,对我们实际开发有什么作用呢?下面我举两个简单的例子,可能你平时也思考过,不过如果不熟悉I/O的内部机制,你肯定是一知半解。 + +疑问一:文件为什么会损坏? + +先说两个客观数据,微信聊天记录使用的SQLite数据库大概有几万分之一的损坏率,系统SharedPreference如果频繁跨进程读写也会有万分之一的损坏率。 + +在回答文件为什么会损坏前,首先需要先明确一下什么是文件损坏。一个文件的格式或者内容,如果没有按照应用程序写入时的结果都属于文件损坏。它不只是文件格式错误,文件内容丢失可能才是最常出现的,SharedPreference跨进程读写就非常容易出现数据丢失的情况。 + +再来探讨文件为什么会损坏,我们可以从应用程序、文件系统和磁盘三个角度来审视这个问题。 + + +应用程序。大部分的I/O方法都不是原子操作,文件的跨进程或者多线程写入、使用一个已经关闭的文件描述符fd来操作文件,它们都有可能导致数据被覆盖或者删除。事实上,大部分的文件损坏都是因为应用程序代码设计考虑不当导致的,并不是文件系统或者磁盘的问题。 + +文件系统。虽说内核崩溃或者系统突然断电都有可能导致文件系统损坏,不过文件系统也做了很多的保护措施。例如system分区保证只读不可写,增加异常检查和恢复机制,ext4的fsck、f2fs的fsck.f2fs和checkpoint机制等。 + + +在文件系统这一层,更多是因为断电而导致的写入丢失。为了提升I/O性能,文件系统把数据写入到Page Cache中,然后等待合适的时机才会真正的写入磁盘。当然我们也可以通过fsync、msync这些接口强制写入磁盘,在下一其我会详细介绍直接I/O和缓存I/O。 + + +磁盘。手机上使用的闪存是电子式的存储设备,所以在资料传输过程可能会发生电子遗失等现象导致数据错误。不过闪存也会使用ECC、多级编码等多种方式增加数据的可靠性,一般来说出现这种情况的可能性也比较小。 + + +闪存寿命也可能会导致数据错误,由于闪存的内部结构和特征,导致它写过的地址必须擦除才能再次写入,而每个块擦除又有次数限制,次数限制是根据采用的存储颗粒,从十万次到几千都有(SLC>MLC>TLC)。 + +下图是闪存(Flash Memory)的结构图,其中比较重要的是FTL(Flash Translation Layer),它负责物理地址的分配和管理。它需要考虑到每个块的擦除寿命,将擦除次数均衡到所有块上去。当某个块空间不够的时候,它还要通过垃圾回收算法将数据迁移。FTL决定了闪存的使用寿命、性能和可靠性,是闪存技术中最为重要的核心技术之一。 + + + +对于手机来说,假设我们的存储大小是128GB,即使闪存的最大擦除次数只有1000次,那也可以写入128TB,但一般来说比较难达到。 + +疑问二:I/O有时候为什么会突然很慢? + +手机厂商的数据通常都是出厂数据,我们在使用Android手机的时候也会发现,刚买的时候“如丝般顺滑”的手机,在使用一年之后就会变得卡顿无比。 + +这是为什么呢?在一些低端机上面,我发现大量跟I/O相关的卡顿。I/O有时候为什么会突然变慢,可能有下面几个原因。 + + +内存不足。当手机内存不足的时候,系统会回收Page Cache和Buffer Cache的内存,大部分的写操作会直接落盘,导致性能低下。 + +写入放大。上面我说到闪存重复写入需要先进行擦除操作,但这个擦除操作的基本单元是block块,一个page页的写入操作将会引起整个块数据的迁移,这就是典型的写入放大现象。低端机或者使用比较久的设备,由于磁盘碎片多、剩余空间少,非常容易出现写入放大的现象。具体来说,闪存读操作最快,在20us左右。写操作慢于读操作,在200us左右。而擦除操作非常耗时,在1ms左右的数量级。当出现写入放大时,因为涉及移动数据,这个时间会更长。 + +由于低端机的CPU和闪存的性能相对也较差,在高负载的情况下容易出现瓶颈。例如eMMC闪存不支持读写并发,当出现写入放大现象时,读操作也会受影响。 + + +系统为了缓解磁盘碎片问题,可以引入fstrim/TRIM机制,在锁屏、充电等一些时机会触发磁盘碎片整理。 + +I/O的性能评估 + +正如下图你所看到的,整个I/O的流程涉及的链路非常长。我们在应用程序中通过打点,发现一个文件读取需要300ms。但是下面每一层可能都有自己的策略和调度算法,因此很难真正的得到每一层的耗时。 + + + +在前面的启动优化内容中,我讲过Facebook和支付宝采用编译单独ROM的方法来评估I/O性能。这是一个比较复杂但是有效的做法,我们可以通过定制源码,选择打开感兴趣的日志来追踪I/O的性能。 + +1. I/O性能指标 + +I/O性能评估中最为核心的指标是吞吐量和IOPS。今天文章开头所说的,“连续读取不超过550MB/s,连续写入不超过520MB/s”,就指的是I/O吞吐量。 + +还有一个比较重要的指标是IOPS,它指的是每秒可以读写的次数。对于随机读写频繁的应用,例如大量的小文件存储,IOPS是关键的衡量指标。 + +2. I/O测量 + +如果不采用定制源码的方式,还有哪些方法可以用来测量I/O的性能呢? + +第一种方法:使用proc。 + +总的来说,I/O性能会跟很多因素有关,是读还是写、是否是连续、I/O大小等。另外一个对I/O性能影响比较大的因素是负载,I/O性能会随着负载的增加而降低,我们可以通过I/O的等待时间和次数来衡量。 + +proc/self/schedstat: + se.statistics.iowait_count:IO 等待的次数 + se.statistics.iowait_sum: IO 等待的时间 + + +如果是root的机器,我们可以开启内核的I/O监控,将所有block读写dump到日志文件中,这样可以通过dmesg命令来查看。 + +echo 1 > /proc/sys/vm/block_dump +dmesg -c grep pid + +.sample.io.test(7540): READ block 29262592 on dm-1 (256 sectors) +.sample.io.test(7540): READ block 29262848 on dm-1 (256 sectors) + + +第二种方法:使用strace。 + +Linux提供了iostat、iotop等一些相关的命令,不过大部分Anroid设备都不支持。我们可以通过 strace来跟踪I/O相关的系统调用次数和耗时。 + +strace -ttT -f -p [pid] + +read(53, "*****************"\.\.\., 1024) = 1024 <0.000447> +read(53, "*****************"\.\.\., 1024) = 1024 <0.000084> +read(53, "*****************"\.\.\., 1024) = 1024 <0.000059> + + +通过上面的日志,你可以看到应用程序在读取文件操作符为53的文件,每次读取1024个字节。第一次读取花了447us,后面两次都使用了100us不到。这跟启动优化提到的“数据重排”是一个原因,文件系统每次读取以block为单位,而block的大小一般是4KB,后面两次的读取是从页缓存得到。 + +我们也可以通过strace统计一段时间内所有系统调用的耗时概况。不过strace本身也会消耗不少资源,对执行时间也会产生影响。 + +strace -c -f -p [pid] + +% time seconds usecs/call calls errors syscall +------ ----------- ----------- --------- --------- ---------------- + 97.56 0.041002 21 1987 read + 1.44 0.000605 55 11 write + + +从上面的信息你可以看到,读占了97.56%的时间,一共调用了1987次,耗时0.04s,平均每次系统调用21us。同样的道理,我们也可以计算应用程序某个任务I/O耗时的百分比。假设一个任务执行了10s,I/O花了9s,那么I/O耗时百分比就是90%。这种情况下,I/O就是我们任务很大的瓶颈,需要去做进一步的优化。 + +第三种方法:使用vmstat。 + +vmstat的各个字段说明可以参考《vmstat监视内存使用情况》,其中Memory中的buff和cache,I/O中的bi和bo,System中的cs,以及CPU中的sy和wa,这些字段的数值都与I/O行为有关。 + + + +我们可以配合dd命令来配合测试,观察vmstat的输出数据变化。不过需要注意的是Android里面的dd命令似乎并不支持conv和flag参数。 + +//清除Buffer和Cache内存缓存 +echo 3 > /proc/sys/vm/drop_caches +//每隔1秒输出1组vmstat数据 +vmstat 1 + + +//测试写入速度,写入文件/data/data/test,buffer大小为4K,次数为1000次 +dd if=/dev/zero of=/data/data/test bs=4k count=1000 + + +总结 + +在性能优化的过程中,我们关注最多的是CPU和内存,I/O也是性能优化中比较重要的一部分。 + +今天我们学习I/O处理的整个流程,它包括应用程序、文件系统和磁盘三个部分。不过I/O这个话题真的很大,在课后需要花更多时间学习课后练习中的一些参考资料。 + +LPDDR5、UFS 3.0很快就要在2019年面世,有些同学会想,随着硬件越来越牛,我们根本就不需要去做优化了。但是一方面考虑到成本的问题,在嵌入式、IoT等一些场景的设备硬件不会太好;另一方面,我们对应用体验的要求也越来越高,沉浸体验(VR)、人工智能(AI)等新功能对硬件的要求也越来越高。所以,应用优化是永恒的,只是在不同的场景下有不同的要求。 + +课后练习 + +学习完今天的内容,可能大部分同学会感觉有点陌生、有点茫然。但是没有关系,我们可以在课后补充更多的基础知识,下面的链接是我推荐给你的参考资料。今天的课后作业是,通过今天的学习,在留言区写写你对I/O的理解,以及你都遇到过哪些I/O方面的问题。 + +1.磁盘I/O那些事 + +2.Linux 内核的文件 Cache 管理机制介绍 + +3.The Linux Kernel/Storage + +4.选eMMC、UFS还是NVMe? 手机ROM存储传输协议解析 + +5.聊聊Linux IO + +6.采用NAND Flash设计存储设备的挑战在哪里? + +“实践出真知”,你也可以尝试使用strace和block_dump来观察自己应用的I/O情况,不过有些实验会要求有root的机器。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/10I_O优化(中):不同I_O方式的使用场景是什么?.md b/专栏/Android开发高手课/10I_O优化(中):不同I_O方式的使用场景是什么?.md new file mode 100644 index 0000000..899e055 --- /dev/null +++ b/专栏/Android开发高手课/10I_O优化(中):不同I_O方式的使用场景是什么?.md @@ -0,0 +1,200 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 I_O优化(中):不同I_O方式的使用场景是什么? + 今天是2019年的第一天,在开始今天的学习前,先要祝你新年快乐、工作顺利。 + +I/O是一个非常大的话题,很难一次性将每个细节都讲清楚。对于服务器开发者来说,可以根据需要选择合适的文件系统和磁盘类型,也可以根据需要调整内核参数。但对于移动开发者来说,我们看起来好像做不了什么I/O方面的优化? + +事实上并不是这样的,启动优化中“数据重排”就是一个例子。如果我们非常清楚文件系统和磁盘的工作机制,就能少走一些弯路,减少应用程序I/O引发的问题。 + +在上一期中,我不止一次的提到Page Cache机制,它很大程度上提升了磁盘I/O的性能,但是也有可能导致写入数据的丢失。那究竟有哪些I/O方式可以选择,又应该如何应用在我们的实际工作中呢?今天我们一起来看看不同I/O方式的使用场景。 + +I/O的三种方式 + +请你先在脑海里回想一下上一期提到的Linux通用I/O架构模型,里面会包括应用程序、文件系统、Page Cache和磁盘几个部分。细心的同学可能还会发现,在图中的最左侧跟右上方还有Direct I/O和mmap的这两种I/O方式。 + +那张图似乎有那么一点复杂,下面我为你重新画了一张简图。从图中可以看到标准I/O、mmap、直接I/O这三种I/O方式在流程上的差异,接下来我详细讲一下不同I/O方式的关键点以及在实际应用中需要注意的地方。 + + + +1. 标准I/O + +我们应用程序平时用到read/write操作都属于标准I/O,也就是缓存I/O(Buffered I/O)。它的关键特性有: + + +对于读操作来说,当应用程序读取某块数据的时候,如果这块数据已经存放在页缓存中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。 + +对于写操作来说,应用程序也会将数据先写到页缓存中去,数据是否被立即写到磁盘上去取决于应用程序所采用写操作的机制。默认系统采用的是延迟写机制,应用程序只需要将数据写到页缓存中去就可以了,完全不需要等数据全部被写回到磁盘,系统会负责定期地将放在页缓存中的数据刷到磁盘上。 + + +从中可以看出来,缓存I/O可以很大程度减少真正读写磁盘的次数,从而提升性能。但是上一期我说过延迟写机制可能会导致数据丢失,那系统究竟会在什么时机真正把页缓存的数据写入磁盘呢? + +Page Cache中被修改的内存称为“脏页”,内核通过flush线程定期将数据写入磁盘。具体写入的条件我们可以通过/proc/sys/vm文件或者sysctl -a | grep vm命令得到。 + +// flush每隔5秒执行一次 +vm.dirty_writeback_centisecs = 500 +// 内存中驻留30秒以上的脏数据将由flush在下一次执行时写入磁盘 +vm.dirty_expire_centisecs = 3000 +// 指示若脏页占总物理内存10%以上,则触发flush把脏数据写回磁盘 +vm.dirty_background_ratio = 10 +// 系统所能拥有的最大脏页缓存的总大小 +vm.dirty_ratio = 20 + + +在实际应用中,如果某些数据我们觉得非常重要,是完全不允许有丢失风险的,这个时候我们应该采用同步写机制。在应用程序中使用sync、fsync、msync等系统调用时,内核都会立刻将相应的数据写回到磁盘。 + + + +上图中我以read()操作为例,它会导致数据先从磁盘拷贝到Page Cache中,然后再从Page Cache拷贝到应用程序的用户空间,这样就会多一次内存拷贝。系统这样设计主要是因为内存相对磁盘是高速设备,即使多拷贝100次,内存也比真正读一次硬盘要快。 + +2. 直接I/O + +很多数据库自己已经做了数据和索引的缓存管理,对页缓存的依赖反而没那么强烈。它们希望可以绕开页缓存机制,这样可以减少一次数据拷贝,这些数据也不会污染页缓存。 + + + +从图中你可以看到,直接I/O访问文件方式减少了一次数据拷贝和一些系统调用的耗时,很大程度降低了CPU的使用率以及内存的占用。 + +不过,直接I/O有时候也会对性能产生负面影响。 + + +对于读操作来说,读数据操作会造成磁盘的同步读,导致进程需要较长的时间才能执行完。 + +对于写操作来说,使用直接I/O也需要同步执行,也会导致应用程序等待。 + + +Android并没有提供Java的DirectByteBuffer,直接I/O需要在open()文件的时候需要指定O_DIRECT参数,更多的资料可以参考《Linux 中直接 I/O 机制的介绍》。在使用直接I/O之前,一定要对应用程序有一个很清醒的认识,只有在确定缓冲I/O的开销非常巨大的情况以后,才可以考虑使用直接I/O。 + +3. mmap + +Android系统启动加载Dex的时候,不会把整个文件一次性读到内存中,而是采用mmap的方式。微信的高性能日志xlog也是使用mmap来保证性能和可靠性。 + +mmap究竟是何方神圣,它是不是真的可以做到不丢失数据、性能还非常好?其实,它是通过把文件映射到进程的地址空间,而网上很多文章都说mmap完全绕开了页缓存机制,其实这并不正确。我们最终映射的物理内存依然在页缓存中,它可以带来的好处有: + + +减少系统调用。我们只需要一次mmap() 系统调用,后续所有的调用像操作内存一样,而不会出现大量的read/write系统调用。 + +减少数据拷贝。普通的read()调用,数据需要经过两次拷贝;而mmap只需要从磁盘拷贝一次就可以了,并且由于做过内存映射,也不需要再拷贝回用户空间。 + +可靠性高。mmap把数据写入页缓存后,跟缓存I/O的延迟写机制一样,可以依靠内核线程定期写回磁盘。但是需要提的是,mmap在内核崩溃、突然断电的情况下也一样有可能引起内容丢失,当然我们也可以使用msync来强制同步写。 + + + + +从上面的图看来,我们使用mmap仅仅只需要一次数据拷贝。看起来mmap的确可以秒杀普通的文件读写,那我们为什么不全都使用mmap呢?事实上,它也存在一些缺点: + + +虚拟内存增大。mmap会导致虚拟内存增大,我们的APK、Dex、so都是通过mmap读取。而目前大部分的应用还没支持64位,除去内核使用的地址空间,一般我们可以使用的虚拟内存空间只有3GB左右。如果mmap一个1GB的文件,应用很容易会出现虚拟内存不足所导致的OOM。 + +磁盘延迟。mmap通过缺页中断向磁盘发起真正的磁盘I/O,所以如果我们当前的问题是在于磁盘I/O的高延迟,那么用mmap()消除小小的系统调用开销是杯水车薪的。启动优化中讲到的类重排技术,就是将Dex中的类按照启动顺序重新排列,主要为了减少缺页中断造成的磁盘I/O延迟。 + + +在Android中可以将文件通过MemoryFile或者MappedByteBuffer映射到内存,然后进行读写,使用这种方式对于小文件和频繁读写操作的文件还是有一定优势的。我通过简单代码测试,测试结果如下。 + + + +从上面的数据看起来mmap好像的确跟写内存的性能差不多,但是这并不正确,因为我们并没有计算文件系统异步落盘的耗时。在低端机或者系统资源严重不足的时候,mmap也一样会出现频繁写入磁盘,这个时候性能就会出现快速下降。 + +mmap比较适合于对同一块区域频繁读写的情况,推荐也使用线程来操作。用户日志、数据上报都满足这种场景,另外需要跨进程同步的时候,mmap也是一个不错的选择。Android跨进程通信有自己独有的Binder机制,它内部也是使用mmap实现。 + + + +利用mmap,Binder在跨进程通信只需要一次数据拷贝,比传统的Socket、管道等跨进程通信方式会少一次数据拷贝过程。 + + + +多线程阻塞I/O和NIO + +我在上一期说过,由于写入放大的现象,特别是在低端机中,有时候I/O操作可能会非常慢。 + +所以I/O操作应该尽量放到线程中,不过很多同学可能都有这样一个疑问:如果同时读10个文件,我们应该用单线程还是10个线程并发读? + +1. 多线程阻塞I/O + +我们来做一个实验,使用Nexus 6P读取30个大小为40MB的文件,分别使用不同的线程数量做测试。 + + + +你可以发现多线程在I/O操作上收益并没有那么大,总时间从3.6秒减少到1.1秒。因为CPU的性能相比磁盘来说就是火箭,I/O操作主要瓶颈在于磁盘带宽,30条线程并不会有30倍的收益。而线程数太多甚至会导致耗时更长,表格中我们就发现30个线程所需要的时间比10个线程更长。但是在CPU繁忙的时候,更多的线程会让我们更有机会抢到时间片,这个时候多线程会比单线程有更大的收益。 + +总的来说文件读写受到I/O性能瓶颈的影响,在到达一定速度后整体性能就会受到明显的影响,过多的线程反而会导致应用整体性能的明显下降。 + +案例一: +CPU: 0.3% user, 3.1% kernel, 60.2% iowait, 36% idle\.\.\. +案例二: +CPU: 60.3% user, 20.1% kernel, 14.2% iowait, 4.6% idle\.\.\. + + +你可以再来看上面这两个案例。 + +案例一:当系统空闲(36% idle)时,如果没有其他线程需要调度,这个时候才会出现I/O等待(60.2% iowait)。 + +案例二:如果我们的系统繁忙起来,这个时候CPU不会“无所事事”,它会去看有没有其他线程需要调度,这个时候I/O等待会降低(14.2% iowait)。但是太多的线程阻塞会导致线程切换频繁,增大系统上下文切换的开销。 + +简单来说,iowait高,I/O一定有问题。但iowait低,I/O不一定没有问题。这个时候我们还要看CPU的idle比例。从下图我们可以看到同步I/O的工作模式: + + + +对应用程序来说,磁盘I/O阻塞线程的总时间会更加合理,它并不关心CPU是否真的在等待,还是去执行其他工作了。在实际开发工作中,大部分时候都是读一些比较小的文件,使用单独的I/O线程还是专门新开一个线程,其实差别不大。 + +2. NIO + +多线程阻塞式I/O会增加系统开销,那我们是否可以使用异步I/O呢?当我们线程遇到I/O操作的时候,不再以阻塞的方式等待I/O操作的完成,而是将I/O请求发送给系统后,继续往下执行。这个过程你可以参考下面的图。 + + + +非阻塞的NIO将I/O以事件的方式通知,的确可以减少线程切换的开销。Chrome网络库是一个使用NIO提升性能很好的例子,特别是在系统非常繁忙的时候。但是NIO的缺点也非常明显,应用程序的实现会变得更复杂,有的时候异步改造并不容易。 + +下面我们来看利用NIO的FileChannel来读写文件。FileChannel需要使用ByteBuffer来读写文件,可以使用ByteBuffer.allocate(int size)分配空间,或者通过ByteBuffer.wrap(byte[])包装byte数组直接生成。上面的示例使用NIO方式在CPU闲和CPU忙时耗时如下。 + + + +通过上面的数据你可以看到,我们发现使用NIO整体性能跟非NIO差别并不大。这其实也是可以理解的,在CPU闲的时候,无论我们的线程是否继续做其他的工作,当前瓶颈依然在磁盘,整体耗时不会太大。在CPU忙的时候,无论是否使用NIO,单线程可以抢到的CPU时间片依然有限。 + +那NIO是不是完全没有作用呢?其实使用NIO的最大作用不是减少读取文件的耗时,而是最大化提升应用整体的CPU利用率。在CPU繁忙的时候,我们可以将线程等待磁盘I/O的时间来做部分CPU操作。非常推荐Square的Okio,它支持同步和异步I/O,也做了比较多优化,你可以尝试使用。 + +小文件系统 + +对于文件系统来说,目录查找的性能是非常重要的。比如微信朋友圈图片可能有几万张,如果我们每张图片都是一个单独的文件,那目录下就会有几万个小文件,你想想这对I/O的性能会造成什么影响? + +文件的读取需要先找到存储的位置,在文件系统上面我们使用inode来存储目录。读取一个文件的耗时可以拆分成下面两个部分。 + +文件读取的时间 = 找到文件的 inode 的时间 + 根据 inode 读取文件数据的时间 + + +如果我们需要频繁读写几万个小文件,查找inode的时间会变得非常可观。这个时间跟文件系统的实现有关。 + + +对于FAT32系统来说,FAT32系统是历史久远的产物,在一些低端机的外置SD卡会使用这个系统。当目录文件数比较多的时候,需要线性去查找,一个exist()都非常容易出现ANR。 + +对于ext4系统来说,ext4系统使用目录Hash索引的方式查找,目录查找时间会大大缩短。但是如果需要频繁操作大量的小文件,查找和打开文件的耗时也不能忽视。 + + +大量的小文件合并为大文件后,我们还可以将能连续访问的小文件合并存储,将原本小文件间的随机访问变为了顺序访问,可以大大提高性能。同时合并存储能够有效减少小文件存储时所产生的磁盘碎片问题,提高磁盘的利用率。 + +业界中Google的GFS、淘宝开源的TFS、Facebook的Haystack都是专门为海量小文件的存储和检索设计的文件系统。微信也开发了一套叫SFS的小文件管理系统,主要用在朋友圈图片的管理,用于解决当时外置SD卡使用FAT32的性能问题。 + +当然设计一个小文件系统也不是那么简单,需要支持VFS接口,这样上层的I/O操作代码并不需要改动。另外需要考虑文件的索引和校验机制,例如如何快速从一个大文件中找到对应的部分。还要考虑文件的分片,比如之前我们发现如果一个文件太大,非常容易被手机管家这些软件删除。 + +总结 + +在性能优化的过程中,我们通常关注最多的是CPU和内存,但其实I/O也是性能优化中比较重要的一部分。 + +今天我们首先学习了I/O整个流程,它包括应用程序、文件系统和磁盘三部分。接着我介绍了多线程同步I/O、异步I/O和mmap这几种I/O方式的差异,以及它们在实际工作中适用的场景。 + +无论是文件系统还是磁盘,涉及的细节都非常多。而且随着技术的发展,有些设计就变得过时了,比如FAT32在设计的时候,当时认为单个文件不太可能超过4GB。如果未来某一天,磁盘的性能可以追上内存,那时文件系统就真的不再需要各种缓存了。 + +课后练习 + +今天我们讲了几种不同的I/O方式的使用场景,在日常工作中,你是否使用过标准I/O以外的其他I/O方式?欢迎留言跟我和其他同学一起讨论。 + +在文中我也对不同的I/O方式做了简单性能测试,今天的课后练习是针对不同的场景,请你动手写一些测试用例,这样可以更好地理解不同I/O方式的使用场景。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/11I_O优化(下):如何监控线上I_O操作?.md b/专栏/Android开发高手课/11I_O优化(下):如何监控线上I_O操作?.md new file mode 100644 index 0000000..019d5b4 --- /dev/null +++ b/专栏/Android开发高手课/11I_O优化(下):如何监控线上I_O操作?.md @@ -0,0 +1,273 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 I_O优化(下):如何监控线上I_O操作? + 通过前面的学习,相信你对I/O相关的基础知识有了一些认识,也了解了测量I/O性能的方法。 + +但是在实际应用中,你知道有哪些I/O操作是不合理的吗?我们应该如何发现代码中不合理的I/O操作呢?或者更进一步,我们能否在线上持续监控应用程序中I/O的使用呢?今天我们就一起来看看这些问题如何解决。 + +I/O跟踪 + +在监控I/O操作之前,你需要先知道应用程序中究竟有哪些I/O操作。 + +我在专栏前面讲卡顿优化的中提到过,Facebook的Profilo为了拿到ftrace的信息,使用了PLT Hook技术监听了“atrace_marker_fd”文件的写入。那么还有哪些方法可以实现I/O跟踪,而我们又应该跟踪哪些信息呢? + +1. Java Hook + +出于兼容性的考虑,你可能第一时间想到的方法就是插桩。但是插桩无法监控到所有的I/O操作,因为有大量的系统代码也同样存在I/O操作。 + +出于稳定性的考虑,我们退而求其次还可以尝试使用Java Hook方案。以Android 6.0的源码为例,FileInputStream的整个调用流程如下。 + +java : FileInputStream -> IoBridge.open -> Libcore.os.open +-> BlockGuardOs.open -> Posix.open + + +在Libcore.java中可以找到一个挺不错的Hook点,那就是BlockGuardOs这一个静态变量。如何可以快速找到合适的Hook点呢?一方面需要靠经验,但是耐心查看和分析源码是必不可少的工作。 + +public static Os os = new BlockGuardOs(new Posix()); +// 反射获得静态变量 +Class clibcore = Class.forName("libcore.io.Libcore"); +Field fos = clibcore.getDeclaredField("os"); + + +我们可以通过动态代理的方式,在所有I/O相关方法前后加入插桩代码,统计I/O操作相关的信息。事实上,BlockGuardOs里面还有一些Socket相关的方法,我们也可以用来统计网络相关的请求。 + +// 动态代理对象 +Proxy.newProxyInstance(cPosix.getClassLoader(), getAllInterfaces(cPosix), this); + +beforeInvoke(method, args, throwable); +result = method.invoke(mPosixOs, args); +afterInvoke(method, args, result); + + +看起来这个方案好像挺不错的,但在实际使用中很快就发现这个方法有几个缺点。 + + +性能极差。I/O操作调用非常频繁,因为使用动态代理和Java的大量字符串操作,导致性能比较差,无法达到线上使用的标准。 + +无法监控Native代码。例如微信中有大量的I/O操作是在Native代码中,使用Java Hook方案无法监控到。 + +兼容性差。Java Hook需要每个Android版本去兼容,特别是Android P增加对非公开API限制。 + + +2. Native Hook + +如果Java Hook不能满足需求,我们自然就会考虑Native Hook方案。Profilo使用到是PLT Hook方案,它的性能比GOT Hook要稍好一些,不过GOT Hook的兼容性会更好一些。 + +关于几种Native Hook的实现方式与差异,我在后面会花篇幅专门介绍,今天就不展开了。最终是从libc.so中的这几个函数中选定Hook的目标函数。 + +int open(const char *pathname, int flags, mode_t mode); +ssize_t read(int fd, void *buf, size_t size); +ssize_t write(int fd, const void *buf, size_t size); write_cuk +int close(int fd); + + +因为使用的是GOT Hook,我们需要选择一些有调用上面几个方法的library。微信Matrix中选择的是libjavacore.so、libopenjdkjvm.so、libopenjdkjvm.so,可以覆盖到所有的Java层的I/O调用,具体可以参考io_canary_jni.cc。 + +不过我更推荐Profilo中atrace.cpp的做法,它直接遍历所有已经加载的library,一并替换。 + +void hookLoadedLibs() { + auto& functionHooks = getFunctionHooks(); + auto& seenLibs = getSeenLibs(); + facebook::profilo::hooks::hookLoadedLibs(functionHooks, seenLibs); +} + + +不同版本的Android系统实现有所不同,在Android 7.0之后,我们还需要替换下面这三个方法。 + +open64 +__read_chk +__write_chk + + +3. 监控内容 + +在实现I/O跟踪后,我们需要进一步思考需要监控哪些I/O信息。假设读取一个文件,我们希望知道这个文件的名字、原始大小、打开文件的堆栈、使用了什么线程这些基本信息。 + +接着我们还希望得到这一次操作一共使用了多长时间,使用的Buffer是多大的。是一次连续读完的,还是随机的读取。通过上面Hook的四个接口,我们可以很容易的采集到这些信息。 + + + +下面是一次I/O操作的基本信息,在主线程对一个大小为600KB的“test.db”文件。 + + + +使用了4KB的Buffer,连续读取150次,一次性把整个文件读完,整体的耗时是10ms。因为连读读写时间和打开文件的总时间相同,我们可以判断出这次read()操作是一气呵成的,中间没有间断。 + + + +因为I/O操作真的非常频繁,采集如此多的信息,对应用程序的性能会造成多大的影响呢?我们可以看看是否使用Native Hook的耗时数据。 + + + +你可以看到采用Native Hook的监控方法性能损耗基本可以忽略,这套方案可以用于线上。 + +线上监控 + +通过Native Hook方式可以采集到所有的I/O相关的信息,但是采集到的信息非常多,我们不可能把所有信息都上报到后台进行分析。 + +对于I/O的线上监控,我们需要进一步抽象出规则,明确哪些情况可以定义为不良情况,需要上报到后台,进而推动开发去解决。 + + + +1. 主线程I/O + +我不止一次说过,有时候I/O的写入会突然放大,即使是几百KB的数据,还是尽量不要在主线程上操作。在线上也会经常发现一些I/O操作明明数据量不大,但是最后还是ANR了。 + +当然如果把所有的主线程I/O都收集上来,这个数据量会非常大,所以我会添加“连续读写时间超过100毫秒”这个条件。之所以使用连续读写时间,是因为发现有不少案例是打开了文件句柄,但不是一次读写完的。 + +在上报问题到后台时,为了能更好地定位解决问题,我通常还会把CPU使用率、其他线程的信息以及内存信息一并上报,辅助分析问题。 + +2. 读写Buffer过小 + +我们知道,对于文件系统是以block为单位读写,对于磁盘是以page为单位读写,看起来即使我们在应用程序上面使用很小的Buffer,在底层应该差别不大。那是不是这样呢? + +read(53, "*****************"\.\.\., 1024) = 1024 <0.000447> +read(53, "*****************"\.\.\., 1024) = 1024 <0.000084> +read(53, "*****************"\.\.\., 1024) = 1024 <0.000059> + + +虽然后面两次系统调用的时间的确会少一些,但是也会有一定的耗时。如果我们的Buffer太小,会导致多次无用的系统调用和内存拷贝,导致read/write的次数增多,从而影响了性能。 + +那应该选用多大的Buffer呢?我们可以跟据文件保存所挂载的目录的block size来确认Buffer大小,数据库中的pagesize就是这样确定的。 + +new StatFs("/data").getBlockSize() + + +所以我们最终选择的判断条件为: + + +buffer size小于block size,这里一般为4KB。 + +read/write的次数超过一定的阈值,例如5次,这主要是为了减少上报量。 + + +buffer size不应该小于4KB,那它是不是越大越好呢?你可以通过下面的命令做一个简单的测试,读取测试应用的iotest文件,它的大小是40M。其中bs就是buffer size,bs分别使用不同的值,然后观察耗时。 + +// 每次测试之前需要手动释放缓存 +echo 3 > /proc/sys/vm/drop_caches +time dd if=/data/data/com.sample.io/files/iotest of=/dev/null bs=4096 + + + + +通过上面的数据大致可以看出来,Buffer的大小对文件读写的耗时有非常大的影响。耗时的减少主要得益于系统调用与内存拷贝的优化,Buffer的大小一般我推荐使用4KB以上。 + +在实际应用中,ObjectOutputStream和ZipOutputStream都是一个非常经典的例子,ObjectOutputStream使用的buffer size非常小。而ZipOutputStream会稍微复杂一些,如果文件是Stored方式存储的,它会使用上层传入的buffer size。如果文件是Deflater方式存储的,它会使用DeflaterOutputStream的buffer size,这个大小默认是512Byte。 + +你可以看到,如果使用BufferInputStream或者ByteArrayOutputStream后整体性能会有非常明显的提升。 + + + +正如我上一期所说的,准确评估磁盘真实的读写次数是比较难的。磁盘内部也会有很多的策略,例如预读。它可能发生超过你真正读的内容,预读在有大量顺序读取磁盘的时候,readahead可以大幅提高性能。但是大量读取碎片小文件的时候,可能又会造成浪费。 + +你可以通过下面的这个文件查看预读的大小,一般是128KB。 + +/sys/block/[disk]/queue/read_ahead_kb + + +一般来说,我们可以利用/proc/sys/vm/block_dump或者/proc/diskstats的信息统计真正的磁盘读写次数。 + +/proc/diskstats +块设备名字|读请求次数|读请求扇区数|读请求耗时总和\.\.\.\. +dm-0 23525 0 1901752 45366 0 0 0 0 0 33160 57393 +dm-1 212077 0 6618604 430813 1123292 0 55006889 3373820 0 921023 3805823 + + +3. 重复读 + +微信之前在做模块化改造的时候,因为模块间彻底解耦了,很多模块会分别去读一些公共的配置文件。 + +有同学可能会说,重复读的时候数据都是从Page Cache中拿到,不会发生真正的磁盘操作。但是它依然需要消耗系统调用和内存拷贝的时间,而且Page Cache的内存也很有可能被替换或者释放。 + +你也可以用下面这个命令模拟Page Cache的释放。 + +echo 3 > /proc/sys/vm/drop_caches + + +如果频繁地读取某个文件,并且这个文件一直没有被写入更新,我们可以通过缓存来提升性能。不过为了减少上报量,我会增加以下几个条件: + + +重复读取次数超过3次,并且读取的内容相同。 + +读取期间文件内容没有被更新,也就是没有发生过write。 + + +加一层内存cache是最直接有效的办法,比较典型的场景是配置文件等一些数据模块的加载,如果没有内存cache,那么性能影响就比较大了。 + +public String readConfig() { + if (Cache != null) { + return cache; + } + cache = read("configFile"); + return cache; +} + + +4. 资源泄漏 + +在崩溃分析中,我说过有部分的OOM是由于文件句柄泄漏导致。资源泄漏是指打开资源包括文件、Cursor等没有及时close,从而引起泄露。这属于非常低级的编码错误,但却非常普遍存在。 + +如何有效的监控资源泄漏?这里我利用了Android框架中的StrictMode,StrictMode利用CloseGuard.java类在很多系统代码已经预置了埋点。 + +到了这里,接下来还是查看源码寻找可以利用的Hook点。这个过程非常简单,CloseGuard中的REPORTER对象就是一个可以利用的点。具体步骤如下: + + +利用反射,把CloseGuard中的ENABLED值设为true。 + +利用动态代理,把REPORTER替换成我们定义的proxy。 + + +虽然在Android源码中,StrictMode已经预埋了很多的资源埋点。不过肯定还有埋点是没有的,比如MediaPlayer、程序内部的一些资源模块。所以在程序中也写了一个MyCloseGuard类,对希望增加监控的资源,可以手动增加埋点代码。 + +I/O与启动优化 + +通过I/O跟踪,可以拿到整个启动过程所有I/O操作的详细信息列表。我们需要更加的苛刻地检查每一处I/O调用,检查清楚是否每一处I/O调用都是必不可少的,特别是write()。 + +当然主线程I/O、读写Buffer、重复读以及资源泄漏是首先需要解决的,特别是重复读,比如cpuinfo、手机内存这些信息都应该缓存起来。 + +对于必不可少的I/O操作,我们需要思考是否有其他方式做进一步的优化。 + + +对大文件使用mmap或者NIO方式。MappedByteBuffer就是Java NIO中的mmap封装,正如上一期所说,对于大文件的频繁读写会有比较大的优化。 + +安装包不压缩。对启动过程需要的文件,我们可以指定在安装包中不压缩,这样也会加快启动速度,但带来的影响是安装包体积增大。事实上Google Play非常希望我们不要去压缩library、resource、resource.arsc这些文件,这样对启动的内存和速度都会有很大帮助。而且不压缩文件带来只是安装包体积的增大,对于用户来说,Download size并没有增大。 + +Buffer复用。我们可以利用Okio开源库,它内部的ByteString和Buffer通过重用等技巧,很大程度上减少CPU和内存的消耗。 + +存储结构和算法的优化。是否可以通过算法或者数据结构的优化,让我们可以尽量的少I/O甚至完全没有I/O。比如一些配置文件从启动完全解析,改成读取时才解析对应的项;替换掉XML、JSON这些格式比较冗余、性能比较较差的数据结构,当然在接下来我还会对数据存储这一块做更多的展开。 + + +2013年我在做Multidex优化的时候,发现代码中会先将classes2.dex从APK文件中解压出来,然后再压缩到classes2.zip文件中。classes2.dex做了一次无用的解压和压缩,其实根本没有必要。 + + + +那个时候通过研究ZIP格式的源码,我发现只要能构造出一个符合ZIP格式的文件,那就可以直接将classses2.dex的压缩流搬到classes2.zip中。整个过程没有任何一次解压和压缩,这个技术也同样应用到Tinker的资源合成中。 + +总结 + +今天我们学习了如何在应用层面监控I/O的使用情况,从实现上尝试了Java Hook和Native Hook两种方案,最终考虑到性能和兼容性,选择了Native Hook方案。 + +对于Hook方案的选择,在同等条件下我会优先选择Java Hook方案。但无论采用哪种Hook方案,我们都需要耐心地查看源码、分析调用流程,从而寻找可以利用的地方。 + +一套监控方案是只用在实验室自动化测试,还是直接交给用户线上使用,这两者的要求是不同的,后者需要99.9%稳定性,还要具备不影响用户体验的高性能才可以上线。从实验室到线上,需要大量的灰度测试以及反复的优化迭代过程。 + +课后练习 + +微信的性能监控分析工具Matrix终于开源了,文中大部分内容都是基于matrix-io-canary的分析。今天的课后作业是尝试接入I/O Canary,查看一下自己的应用是否存在I/O相关的问题,请你在留言区跟同学们分享交流一下你的经验。 + +是不是觉得非常简单?我还有一个进阶的课后练习。在io_canary_jni.cc中发现目前Matrix只监控了主线程的I/O运行情况,这主要为了解决多线程同步问题。 + +//todo 解决非主线程打开,主线程操作问题 +int ProxyOpen(const char *pathname, int flags, mode_t mode) { + + +事实上其他线程使用I/O不当,也会影响到应用的性能,“todo = never do”,今天就请你来尝试解决这个问题吧。但是考虑到性能的影响,我们不能简单地直接加锁。针对这个case是否可以做到完全无锁的线程安全,或者可以尽量降低锁的粒度呢?我邀请你一起来研究这个问题,给Matrix提交Pull request,参与到开源的事业中吧。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/12存储优化(上):常见的数据存储方法有哪些?.md b/专栏/Android开发高手课/12存储优化(上):常见的数据存储方法有哪些?.md new file mode 100644 index 0000000..208358e --- /dev/null +++ b/专栏/Android开发高手课/12存储优化(上):常见的数据存储方法有哪些?.md @@ -0,0 +1,200 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 存储优化(上):常见的数据存储方法有哪些? + 通过专栏前面我讲的I/O优化基础知识,相信你肯定了解了文件系统和磁盘的一些机制,以及不同I/O方式的使用场景以及优缺点,并且可以掌握如何在线上监控I/O操作。 + +万丈高楼平地起,在理解并掌握这些基础知识的同时,你肯定还想知道如何利用这些知识指导我们写出更好的代码。 + +今天我来结合Android系统的一些特性,讲讲开发过程中常见存储方法的优缺点,希望可以帮你在日常工作中如何做出更好的选择。 + +Android的存储基础 + +在讲具体的存储方法之前,我们应该对Android系统存储相关的一些基础知识有所了解。 + +1. Android分区 + +I/O优化中讲到的大部分知识更侧重Linux系统,对于Android来说,我们首先应该对Android分区的架构和作用有所了解。在我们熟悉的Windows世界中,我们一般都把系统安装在C盘,然后还会有几个用来存放应用程序和数据的分区。 + +Android系统可以通过/proc/partitions或者df命令来查看的各个分区情况,下图是Nexus 6中df命令的运行结果。 + + + +什么是分区呢?分区简单来说就是将设备中的存储划分为一些互不重叠的部分,每个部分都可以单独格式化,用作不同的目的。这样系统就可以灵活的针对单独分区做不同的操作,例如在系统还原(recovery)过程,我们不希望会影响到用户存储的数据。 + + + +从上面的表中你可以看到,每个分区非常独立,不同的分区可以使用的不同的文件系统。其中比较重要的有: + + +/system分区:它是存放所有Google提供的Android组件的地方。这个分区只能以只读方式mount。这样主要基于稳定性和安全性考虑,即使发生用户突然断电的情况,也依然需要保证/system分区的内容不会受到破坏和篡改。 + +/data分区:它是所有用户数据存放的地方。主要为了实现数据隔离,即系统升级和恢复的时候会擦除整个/system分区,但是却不会影响/data的用户数据。而恢复出厂设置,只会擦除/data的数据。 + +/vendor分区:它是存放厂商特殊系统修改的地方。特别是在Android 8.0以后,隆重推出了“Treble”项目。厂商OTA时可以只更新自己的/vendor分区即可,让厂商能够以更低的成本,更轻松、更快速地将设备更新到新版Android系统。 + + +2. Android存储安全 + +除了数据的分区隔离,存储安全也是Android系统非常重要的一部分,存储安全首先考虑的是权限控制。 + +第一,权限控制 + +Android的每个应用都在自己的应用沙盒内运行,在 Android 4.3之前的版本中,这些沙盒使用了标准Linux的保护机制,通过为每个应用创建独一无二的Linux UID来定义。简单来说,我们需要保证微信不能访问淘宝的数据,并且在没有权限的情况下也不能访问系统的一些保护文件。 + +在Android 4.3引入了SELinux(Security Enhanced Linux)机制进一步定义Android应用沙盒的边界。那它有什么特别的呢?它的作用是即使我们进程有root权限也不能为所欲为,如果想在SELinux系统中干任何事情,都必须先在专门的安全策略配置文件中赋予权限。 + +第二,数据加密 + +除了权限的控制,用户还会担心在手机丢失或者被盗导致个人隐私数据泄露。加密或许是一个不错的选择,它可以保护丢失或被盗设备上的数据。 + +Android有两种设备加密方法:全盘加密和文件级加密。全盘加密是在Android 4.4中引入的,并在Android 5.0中默认打开。它会将/data分区的用户数据操作加密/解密,对性能会有一定的影响,但是新版本的芯片都会在硬件中提供直接支持。 + +我们知道,基于文件系统的加密,如果设备被解锁了,加密也就没有用了。所以Android 7.0增加了基于文件的加密。在这种加密模式下,将会给每个文件都分配一个必须用用户的passcode推导出来的密钥。特定的文件被屏幕锁屏之后,直到用户下一次解锁屏幕期间都不能访问。 + +可能有些同学会问了,Android的这两种设备加密方法跟应用的加密有什么不同,我们在应用存储还需要单独的给敏感文件加密吗? + +我想说的是,设备加密方法对应用程序来说是透明的,它保证我们读取到的是解密后的数据。对于应用程序特别敏感的数据,我们也需要采用RSA、AES、chacha20等常用方式做进一步的存储加密。 + +常见的数据存储方法 + +Android为我们提供了很多种持久化存储的方案,在具体介绍它们之前,你需要先问一下自己,什么是存储? + +每个人可能都会有自己的答案,在我看来,存储就是把特定的数据结构转化成可以被记录和还原的格式,这个数据格式可以是二进制的,也可以是XML、JSON、Protocol Buffer这些格式。 + +对于闪存来说,一切归根到底还是二进制的,XML、JSON它们只是提供了一套通用的二进制编解码格式规范。既然有那么多存储的方案,那我们在选择数据存储方法时,一般需要考虑哪些关键要素呢? + +1. 关键要素 + +在选择数据存储方法时,我一般会想到下面这几点,我把它们总结给你。 + + + +那上面这些要素哪个最重要呢?数据存储方法不能脱离场景来考虑,我们不可能把这六个要素都做成最完美。 + +我来解释一下这句话。如果首要考虑的是正确性,那我们可能需要采用冗余、双写等方案,那就要容忍对时间开销产生的额外影响。同样如果非常在意安全,加解密环节的开销也必不可小。如果想针对启动场景,我们希望选择在初始化时间和读取时间更有优势的方案。 + +2. 存储选项 + +总的来说,我们需要结合应用场景选择合适的数据存储方法。那Android为应用开发者提供了哪些存储数据的方法呢?你可以参考存储选项,综合来看,有下面几种方法。 + + +SharedPreferences + +ContentProvider + +文件 + +数据库 + + +今天我先来讲SharedPreferences和ContentProvider这两个存储方法,文件和数据库将放到“存储优化”后面两期来讲。 + +第一,SharedPreferences的使用。 + +SharedPreferences是Android中比较常用的存储方法,它可以用来存储一些比较小的键值对集合。 + +虽然SharedPreferences使用非常简便,但也是我们诟病比较多的存储方法。它的性能问题比较多,我可以轻松地说出它的“七宗罪”。 + + +跨进程不安全。由于没有使用跨进程的锁,就算使用MODE_MULTI_PROCESS,SharedPreferences在跨进程频繁读写有可能导致数据全部丢失。根据线上统计,SP大约会有万分之一的损坏率。 + +加载缓慢。SharedPreferences文件的加载使用了异步线程,而且加载线程并没有设置线程优先级,如果这个时候主线程读取数据就需要等待文件加载线程的结束。这就导致出现主线程等待低优先级线程锁的问题,比如一个100KB的SP文件读取等待时间大约需要50~100ms,我建议提前用异步线程预加载启动过程用到的SP文件。 + +全量写入。无论是调用commit()还是apply(),即使我们只改动其中的一个条目,都会把整个内容全部写到文件。而且即使我们多次写入同一个文件,SP也没有将多次修改合并为一次,这也是性能差的重要原因之一。 + +卡顿。由于提供了异步落盘的apply机制,在崩溃或者其他一些异常情况可能会导致数据丢失。所以当应用收到系统广播,或者被调用onPause等一些时机,系统会强制把所有的SharedPreferences对象数据落地到磁盘。如果没有落地完成,这时候主线程会被一直阻塞。这样非常容易造成卡顿,甚至是ANR,从线上数据来看SP卡顿占比一般会超过5%。 + + +讲到这里,如果你对SharedPreferences机制还不熟悉的话,可以参考《彻底搞懂SharedPreferences》。 + +坦白来讲,系统提供的SharedPreferences的应用场景是用来存储一些非常简单、轻量的数据。我们不要使用它来存储过于复杂的数据,例如HTML、JSON等。而且SharedPreference的文件存储性能与文件大小相关,每个SP文件不能过大,我们不要将毫无关联的配置项保存在同一个文件中,同时考虑将频繁修改的条目单独隔离出来。 + +我们也可以替换通过复写Application的getSharedPreferences方法替换系统默认实现,比如优化卡顿、合并多次apply操作、支持跨进程操作等。具体如何替换呢?在今天的Sample中我也提供了一个简单替换实现。 + +public class MyApplication extends Application { + @Override + public SharedPreferences getSharedPreferences(String name, int mode) + { + return SharedPreferencesImpl.getSharedPreferences(name, mode); + } +} + + +对系统提供的SharedPreferences的小修小补虽然性能有所提升,但是依然不能彻底解决问题。基本每个大公司都会自研一套替代的存储方案,比如微信最近就开源了MMKV。 + +下面是MMKV对于SharedPreferences的“六要素”对比。 + + + +你可以参考MMKV的实现原理和性能测试报告,里面有一些非常不错的思路。例如利用文件锁保证跨进程的安全、使用mmap保证数据不会丢失、选用性能和存储空间更好的Protocol Buffer代替XML、支持增量更新等。 + +根据I/O优化的分析,对于频繁修改的配置使用mmap的确非常合适,使用者不用去理解apply()和commit()的差别,也不用担心数据的丢失。同时,我们也不需要每次都提交整个文件,整体性能会有很大提升。 + +第二,ContentProvider的使用。 + +为什么Android系统不把SharedPreferences设计成跨进程安全的呢?那是因为Android系统更希望我们在这个场景选择使用ContentProvider作为存储方式。ContentProvider作为Android四大组件中的一种,为我们提供了不同进程甚至是不同应用程序之间共享数据的机制。 + +Android系统中比如相册、日历、音频、视频、通讯录等模块都提供了ContentProvider的访问支持。它的使用十分简单,你可以参考官方文档。 + +当然,在使用过程也有下面几点需要注意。 + + +启动性能 + + +ContentProvider的生命周期默认在Application onCreate()之前,而且都是在主线程创建的。我们自定义的ContentProvider类的构造函数、静态代码块、onCreate函数都尽量不要做耗时的操作,会拖慢启动速度。 + + + +可能很多同学都不知道ContentProvider还有一个多进程模式,它可以和AndroidManifest中的multiprocess属性结合使用。这样调用进程会直接在自己进程里创建一个push进程的Provider实例,就不需要跨进程调用了。需要注意的是,这样也会带来Provider的多实例问题。 + + +稳定性 + + +ContentProvider在进行跨进程数据传递时,利用了Android的Binder和匿名共享内存机制。简单来说,就是通过Binder传递CursorWindow对象内部的匿名共享内存的文件描述符。这样在跨进程传输中,结果数据并不需要跨进程传输,而是在不同进程中通过传输的匿名共享内存文件描述符来操作同一块匿名内存,这样来实现不同进程访问相同数据的目的。 + + + +正如我前面I/O优化所讲的,基于mmap的匿名共享内存机制也是有代价的。当传输的数据量非常小的时候,可能不一定划算。所以ContentProvider提供了一种call函数,它会直接通过Binder来传输数据。 + +Android的Binder传输是有大小限制的,一般来说限制是1~2MB。ContentProvider的接口调用参数和call函数调用并没有使用匿名共享机制,比如要批量插入很多数据,那么就会出现一个插入数据的数组,如果这个数组太大了,那么这个操作就可能会出现数据超大异常。 + + +安全性 + + +虽然ContentProvider为应用程序之间的数据共享提供了很好的安全机制,但是如果ContentProvider是exported,当支持执行SQL语句时就需要注意SQL注入的问题。另外如果我们传入的参数是一个文件路径,然后返回文件的内容,这个时候也要校验合法性,不然整个应用的私有数据都有可能被别人拿到,在intent传递参数的时候可能经常会犯这个错误。 + +最后我给你总结一下ContentProvider的“六要素”优缺点。 + + + +总的来说,ContentProvider这套方案实现相对比较笨重,适合传输大的数据。 + +总结 + +虽然SharedPreferences和ContentProvider都是我们日常经常使用的存储方法,但是里面的确会有大大小小的暗坑。所以我们需要充分了解它们的优缺点,这样在工作中可以更好地使用和优化。 + +如何在合适的场景选择合适的存储方法是存储优化的必修课,你应该学会通过正确性、时间开销、空间开销、安全、开发成本以及兼容性这六大关键要素来分解某个存储方法。 + +在设计某个存储方案的时候也是同样的道理,我们无法同时把所有的要素都做得最好,因此要学会取舍和选择,在存储的世界里不存在全局最优解,我们要找的是局部的最优解。这个时候更应明确自己的诉求,大胆牺牲部分关键点的指标,将自己场景最关心的要素点做到最好。 + +课后作业 + +下面是MMKV给出的性能测试报告,你可以看到跟系统的SharedPreferences相比,主要差距在于写的速度。 + + + +没有实践就没有发言权,今天我们一起来尝试测试对比MMKV与系统SharedPreferences的性能差异。请将你的测试结果和分析体会,写在留言区跟同学们分享交流吧。 + +今天的练习Sample是通过复写Application的getSharedPreferences方法替换系统默认实现,这种方式虽然不是最好的方法,不过它主要的优点在于代码的侵入性比较低,无需修改太多的代码。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/13存储优化(中):如何优化数据存储?.md b/专栏/Android开发高手课/13存储优化(中):如何优化数据存储?.md new file mode 100644 index 0000000..d30ad8e --- /dev/null +++ b/专栏/Android开发高手课/13存储优化(中):如何优化数据存储?.md @@ -0,0 +1,259 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 存储优化(中):如何优化数据存储? + “将特定结构的数据转化为另一种能被记录和还原的格式”,这是我在上一期对存储下的一个定义。 + +再来复习一下数据存储的六个关键要素:正确性、时间开销、空间开销、安全、开发成本和兼容性。我们不可能同时把所有要素都做到最好,所谓数据存储优化就是根据自己的使用场景去把其中的一项或者几项做到最好。 + +更宽泛来讲,我认为数据存储不一定就是将数据存放到磁盘中,比如放到内存中、通过网络传输也可以算是存储的一种形式。或者我们也可以把这个过程叫作对象或者数据的序列化。 + +对于大部分的开发者来说,我们不一定有精力去“创造”一种数据序列化的格式,所以我今天主要来讲讲Android常用的序列化方法如何进行选择。 + +对象的序列化 + +应用程序中的对象存储在内存中,如果我们想把对象存储下来或者在网络上传输,这个时候就需要用到对象的序列化和反序列化。 + +对象序列化就是把一个Object对象所有的信息表示成一个字节序列,这包括Class信息、继承关系信息、访问权限、变量类型以及数值信息等。 + +1. Serializable + +Serializable是Java原生的序列化机制,在Android中也有被广泛使用。我们可以通过Serializable将对象持久化存储,也可以通过Bundle传递Serializable的序列化数据。 + +Serializable的原理 + +Serializable的原理是通过ObjectInputStream和ObjectOutputStream来实现的,我们以Android 6.0的源码为例,你可以看到ObjectOutputStream的部分源码实现: + +private void writeFieldValues(Object obj, ObjectStreamClass classDesc) { + for (ObjectStreamField fieldDesc : classDesc.fields()) { + ... + Field field = classDesc.checkAndGetReflectionField(fieldDesc); + ... + + +整个序列化过程使用了大量的反射和临时变量,而且在序列化对象的时候,不仅会序列化当前对象本身,还需要递归序列化对象引用的其他对象。 + +整个过程计算非常复杂,而且因为存在大量反射和GC的影响,序列化的性能会比较差。另外一方面因为序列化文件需要包含的信息非常多,导致它的大小比Class文件本身还要大很多,这样又会导致I/O读写上的性能问题。 + +Serializable的进阶 + +既然Serializable性能那么差,那它有哪些优势呢?可能很多同学都不知道它还有一些进阶的用法,你可以参考《Java 对象序列化,您不知道的 5 件事》这篇文章。 + + +writeObject和readObject方法。Serializable序列化支持替代默认流程,它会先反射判断是否存在我们自己实现的序列化方法writeObject或反序列化方法readObject。通过这两个方法,我们可以对某些字段做一些特殊修改,也可以实现序列化的加密功能。 + +writeReplace和readResolve方法。这两个方法代理序列化的对象,可以实现自定义返回的序列化实例。那它有什么用呢?我们可以通过它们实现对象序列化的版本兼容,例如通过readResolve方法可以把老版本的序列化对象转换成新版本的对象类型。 + + +Serializable的序列化与反序列化的调用流程如下。 + +// 序列化 +E/test:SerializableTestData writeReplace +E/test:SerializableTestData writeObject + +// 反序列化 +E/test:SerializableTestData readObject +E/test:SerializableTestData readResolve + + +Serializable的注意事项 + +Serializable虽然使用非常简单,但是也有一些需要注意的事项字段。 + + +不被序列化的字段。类的static变量以及被声明为transient的字段,默认的序列化机制都会忽略该字段,不会进行序列化存储。当然我们也可以使用进阶的writeReplace和readResolve方法做自定义的序列化存储。 + +serialVersionUID。在类实现了Serializable接口后,我们需要添加一个Serial Version ID,它相当于类的版本号。这个ID我们可以显式声明也可以让编译器自己计算。通常我建议显式声明会更加稳妥,因为隐式声明假如类发生了一点点变化,进行反序列化都会由于serialVersionUID改变而导致InvalidClassException异常。 + +构造方法。Serializable的反序列默认是不会执行构造函数的,它是根据数据流中对Object的描述信息创建对象的。如果一些逻辑依赖构造函数,就可能会出现问题,例如一个静态变量只在构造函数中赋值,当然我们也可以通过进阶方法做自定义的反序列化修改。 + + +2. Parcelable + +由于Java的Serializable的性能较低,Android需要重新设计一套更加轻量且高效的对象序列化和反序列化机制。Parcelable正是在这个背景下产生的,它核心的作用就是为了解决Android中大量跨进程通信的性能问题。 + +Parcelable的永久存储 + +Parcelable的原理十分简单,它的核心实现都在Parcel.cpp。 + +你可以发现Parcel序列化和Java的Serializable序列化差别还是比较大的,Parcelable只会在内存中进行序列化操作,并不会将数据存储到磁盘里。 + +当然我们也可以通过Parcel.java的marshall接口获取byte数组,然后存在文件中从而实现Parcelable的永久存储。 + +// Returns the raw bytes of the parcel. +public final byte[] marshall() { + return nativeMarshall(mNativePtr); +} +// Set the bytes in data to be the raw bytes of this Parcel. +public final void unmarshall(byte[] data, int offset, int length) { + nativeUnmarshall(mNativePtr, data, offset, length); +} + + +Parcelable的注意事项 + +在时间开销和使用成本的权衡上,Parcelable机制选择的是性能优先。 + +所以它在写入和读取的时候都需要手动添加自定义代码,使用起来相比Serializable会复杂很多。但是正因为这样,Parcelable才不需要采用反射的方式去实现序列化和反序列化。 + +虽然通过取巧的方法可以实现Parcelable的永久存储,但是它也存在两个问题。 + + +系统版本的兼容性。由于Parcelable设计本意是在内存中使用的,我们无法保证所有Android版本的Parcel.cpp实现都完全一致。如果不同系统版本实现有所差异,或者有厂商修改了实现,可能会存在问题。 + +数据前后兼容性。Parcelable并没有版本管理的设计,如果我们类的版本出现升级,写入的顺序及字段类型的兼容都需要格外注意,这也带来了很大的维护成本。 + + +一般来说,如果需要持久化存储的话,一般还是不得不选择性能更差的Serializable方案。 + +3. Serial + +作为程序员,我们肯定会追求完美。那有没有性能更好的方案并且可以解决这些痛点呢? + +事实上,关于序列化基本每个大公司都会自己自研的一套方案,我在专栏里推荐Twitter开源的高性能序列化方案Serial。那它是否真的是高性能呢?我们可以将它和前面的两套方案做一个对比测试。 + + + +从图中数据上看来,Serial在序列化与反序列化耗时,以及落地的文件大小都有很大的优势。 + +从实现原理上看,Serial就像是把Parcelable和Serializable的优点集合在一起的方案。 + + +由于没有使用反射,相比起传统的反射序列化方案更加高效,具体你可以参考上面的测试数据。 + +开发者对于序列化过程的控制较强,可定义哪些Object、Field需要被序列化。 + +有很强的debug能力,可以调试序列化的过程。 + +有很强的版本管理能力,可以通过版本号和OptionalFieldException做兼容。 + + +数据的序列化 + +Serial性能看起来还不错,但是对象的序列化要记录的信息还是比较多,在操作比较频繁的时候,对应用的影响还是不少的,这个时候我们可以选择使用数据的序列化。 + +1. JSON + +JSON是一种轻量级的数据交互格式,它被广泛使用在网络传输中,很多应用与服务端的通信都是使用JSON格式进行交互。 + +JSON的确有很多得天独厚的优势,主要有: + + +相比对象序列化方案,速度更快,体积更小。 + +相比二进制的序列化方案,结果可读,易于排查问题。 + +使用方便,支持跨平台、跨语言,支持嵌套引用。 + + +因为每个应用基本都会用到JSON,所以每个大厂也基本都有自己的“轮子”。例如Android自带的JSON库、Google的Gson、阿里巴巴的Fastjson、美团的MSON。 + +各个自研的JSON方案主要在下面两个方面进行优化: + + +便利性。例如支持JSON转换成JavaBean对象,支持注解,支持更多的数据类型等。 + +性能。减少反射,减少序列化过程内存与CPU的使用,特别是在数据量比较大或者嵌套层级比较深的时候效果会比较明显。 + + + + +在数据量比较少的时候,系统自带的JSON库还稍微有一些优势。但在数据量大了之后,差距逐渐被拉开。总的来说,Gson的兼容性最好,一般情况下它的性能与Fastjson相当。但是在数据量极大的时候,Fastjson的性能更好。 + +2. Protocol Buffers + +相比对象序列化方案,JSON的确速度更快、体积更小。不过为了保证JSON的中间结果是可读的,它并没有做二进制的压缩,也因此JSON的性能还没有达到极致。 + +如果应用的数据量非常大,又或者对性能有更高的要求,此时Protocol Buffers是一个非常好的选择。它是Google开源的跨语言编码协议,Google内部的几乎所有RPC都在使用这个协议。 + +下面我来总结一下它的优缺点。 + + +性能。使用了二进制编码压缩,相比JSON体积更小,编解码速度也更快,感兴趣的同学可以参考protocol-buffers编码规则。 + +兼容性。跨语言和前后兼容性都不错,也支持基本类型的自动转换,但是不支持继承与引用类型。 + +使用成本。Protocol Buffers的开发成本很高,需要定义.proto文件,并用工具生成对应的辅助类。辅助类特有一些序列化的辅助方法,所有要序列化的对象,都需要先转化为辅助类的对象,这让序列化代码跟业务代码大量耦合,是侵入性较强的一种方式。 + + +对于Android来说,官方的Protocol Buffers会导致生成的方法数很多。我们可以修改它的自动代码生成工具,例如在微信中,每个.proto生成的类文件只会包含一个方法即op方法。 + +public class TestProtocal extends com.tencent.mm.protocal.protobuf { + @Override + protected final int op(int opCode, Object ...objs) throws IOException { + if (opCode == OPCODE_WRITEFIELDS) { + ... + } else if (opCode == OPCODE_COMPUTESIZE) { + ... + + +Google后面还推出了压缩率更高的FlatBuffers,对于它的使用你可以参考《FlatBuffers 体验》。最后,我再结合“六要素”,帮你综合对比一下Serial、JSON、Protocol Buffers这三种序列化方案。 + + + +存储监控 + +通过本地实验我们可以对比不同文件存储方法的性能,但是实验室环境不一定能真实反映用户实际的使用情况,所以我们同样需要对存储建立完善的监控。那么应该监控哪些内容呢? + +1. 性能监控 + +正确性、时间开销、空间开销、安全、开发成本和兼容性,对于这六大关键要素来说,在线上我更关注: + + +正确性 + + +在专栏第9期中我讲过,应用程序、文件系统或者磁盘都可以导致文件损坏。 + +在线上我希望可以监控存储模块的损坏率,在专栏上一期中也提到SharedPreferences的损坏率大约在万分之一左右,而我们内部自研的SharedPreferences的损耗率在十万分之一左右。如何界定一个文件是损坏的?对于系统SP我们将损坏定义为文件大小变为0,而自研的SP文件头部会有专门的校验字段,比如文件长度、关键位置的CRC信息等,可以识别出更多的文件损坏场景。在识别出文件损坏之后,我们还可以进一步做数据修复等工作。 + + +时间开销 + + +存储模块的耗时也是我非常关心的,而线上的耗时监控分为初始化耗时与读写耗时。每个存储模块的侧重点可能不太一样,例如在启动过程中使用的存储模块我们可能希望初始化可以快一些。 + +同样以系统的SharedPreferences为例,在初始化过程它需要读取并解析整个文件,如果内容超过1000条,初始化的时间可能就需要50~100ms。我们内部另外一个支持随机读写的存储模块,初始化时间并不会因为存储条数的数量而变化,即使有几万条数据,初始化时间也在1ms以内。 + + +空间开销 + + +空间的占用分为内存空间和ROM空间,通常为了性能的提升,会采用空间换时间的方式。内存空间需要考虑GC和峰值内存,以及在一些数据量比较大的情况会不会出现OOM。ROM空间需要考虑做清理逻辑,例如数据超过1000条或者10MB后会触发自动清理或者数据合并。 + +2. ROM监控 + +除了某个存储模块的监控,我们也需要对应用整体的ROM空间做详细监控。为什么呢?这是源于我发现有两个经常会遇到的问题。 + +以前经常会收到用户的负反馈:微信的ROM空间为什么会占用2GB之多?是因为数据库太大了吗,还是其他什么原因,那时候我们还真有点不知所措。曾经我们在线上发现一个bug会导致某个配置重复下载,相同的内容一个用户可能会下载了几千次。 + +download_1 download_2 download_3 .... + + +线上我们有时候会发现在遍历某个文件夹时,会出现卡顿或者ANR。在专栏第10期我也讲过,文件遍历的耗时跟文件夹下的文件数量有关。曾经我们也出现过一次bug导致某个文件夹下面有几万个文件,在遍历这个文件夹时,用户手机直接重启了。需要注意的是文件遍历在API level 26之后建议使用FileVisitor替代ListFiles,整体的性能会好很多。 + +ROM监控的两个核心指标是文件总大小与总文件数,例如我们可以将文件总大小超过400MB的用户比例定义为空间异常率,将文件数超过1000个的用户比例定义为数量异常率,这样我们就可以持续监控应用线上的存储情况。 + +当然监控只是第一步,核心问题在于如何能快速发现问题。类似卡顿树,我们也可以构造用户的存储树,然后在后台做聚合。但是用户的整个存储树会非常非常大,这里我们需要通过一些剪枝算法。例如只保留最大的3个文件夹,每个文件夹保留5个文件,但在这5个文件我们需要保留一定的随机性,以免所有人都会上传相同的内容。 + + + +在监控的同时,我们也要有远程控制的能力,用户投诉时可以实时拉取这个用户的完整存储树。对线上发现的存储问题,我们可以动态下发清理规则,例如某个缓存文件夹超过200MB后自动清理、删除某些残留的历史文件等。 + +总结 + +对于优化存储来说,不同的应用关注点可能不太一样。对小应用来说,可能开发成本是最重要的,我们希望开发效率优先;对于成熟的应用来说,性能会更加重要。因此选择什么样的存储方案,需要结合应用所处的阶段以及使用场景具体问题具体分析。 + +无论是优化某个存储方案的性能,还是应用整体的ROM存储,我们可能对存储监控关注比较少。而如果这块出现问题,对用户的体验影响还是非常大的。例如我们知道微信占用的ROM空间确实不小,为了解决这个问题,特别推出了空间清理的功能,而且在ROM空间不足等场景,会弹框提示用户操作。 + +课后作业 + +今天的课后作业是,你的应用选择了哪种对象序列化和数据序列化方案?对数据的存储你还有哪些体会?请你在留言区写写你的方案和想法,与其他同学一起交流。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/14存储优化(下):数据库SQLite的使用和优化.md b/专栏/Android开发高手课/14存储优化(下):数据库SQLite的使用和优化.md new file mode 100644 index 0000000..9e5dcba --- /dev/null +++ b/专栏/Android开发高手课/14存储优化(下):数据库SQLite的使用和优化.md @@ -0,0 +1,313 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 存储优化(下):数据库SQLite的使用和优化 + 我们先来复习一下前面讲到的存储方法的使用场景:少量的Key Value数据可以直接使用SharedPreferences,稍微复杂一些的数据类型也可以通过序列化成JSON或者Protocol Buffers保存,并且在开发中获取或者修改数据也很简单。 + +不过这几种方法可以覆盖所有的存储场景吗?数据量在几百上千条这个量级时它们的性能还可以接受,但如果是几万条的微信聊天记录呢?而且如何实现快速地对某几个联系人的数据做增删改查呢? + +对于大数据的存储场景,我们需要考虑稳定性、性能和可扩展性,这个时候就要轮到今天的“主角”数据库登场了。讲存储优化一定绕不开数据库,而数据库这个主题又非常大,我也知道不少同学学数据库的过程是从入门到放弃。那么考虑到我们大多是从事移动开发的工作,今天我就来讲讲移动端数据库SQLite的使用和优化。 + +SQLite的那些事儿 + +虽然市面上有很多的数据库,但受限于库体积和存储空间,适合移动端使用的还真不多。当然使用最广泛的还是我们今天的主角SQLite,但同样还是有一些其他不错的选择,例如创业团队的Realm、Google的LevelDB等。 + +在国内那么多的移动团队中,微信对SQLite的研究可以算是最深入的。这其实是业务诉求导向的,用户聊天记录只会在本地保存,一旦出现数据损坏或者丢失,对用户来说都是不可挽回的。另一方面,微信有很大一批的重度用户,他们有几千个联系人、几千个群聊天,曾经做过一个统计,有几百万用户的数据库竟然大于1GB。对于这批用户,如何保证他们可以正常地使用微信是一个非常大的挑战。 + +所以当时微信专门开展了一个重度用户优化的专项。一开始的时候我们集中在SQLite使用上的优化,例如表结构、索引等。但很快就发现由于系统版本的不同,SQLite的实现也有所差异,经常会出现一些兼容性问题,并且也考虑到加密的诉求,我们决定单独引入自己的SQLite版本。 + +“源码在手,天下我有”,从此开启了一条研究数据库的“不归路”。那时我们投入了几个人专门去深入研究SQLite的源码,从SQLite的PRAGMA编译选项、Cursor实现优化,到SQLite源码的优化,最后打造出从实验室到线上的整个监控体系。 + +在2017年,我们开源了内部使用的SQLite数据库WCDB。这里多说两句,看一个开源项目是否靠谱,就看这个项目对产品本身有多重要。微信开源坚持内部与外部使用同一个版本,虽然我现在已经离开了微信团队,但还是欢迎有需要的同学使用WCDB。 + +在开始学习前我要提醒你,SQLite的优化同样也很难通过一两篇文章就把每个细节都讲清楚。今天的内容我选择了一些比较重要的知识点,并且为你准备了大量的参考资料,遇到陌生或者不懂的地方需要结合参考资料反复学习。 + +1. ORM + +坦白说可能很多BAT的高级开发工程师都不完全了解SQLite的内部机制,也不能正确地写出高效的SQL语句。大部分应用为了提高开发效率,会引入ORM框架。ORM(Object Relational Mapping)也就是对象关系映射,用面向对象的概念把数据库中表和对象关联起来,可以让我们不用关心数据库底层的实现。 + +Android中最常用的ORM框架有开源greenDAO和Google官方的Room,那使用ORM框架会带来什么问题呢? + +使用ORM框架真的非常简单,但是简易性是需要牺牲部分执行效率为代价的,具体的损耗跟ORM框架写得好不好很有关系。但可能更大的问题是让很多的开发者的思维固化,最后可能连简单的SQL语句都不会写了。 + +那我们的应用是否应该引入ORM框架呢?可能程序员天生追求偷懒,为了提高开发效率,应用的确应该引入ORM框架。但是这不能是我们可以不去学习数据库基础知识的理由,只有理解底层的一些机制,我们才能更加得心应手地解决疑难的问题。 + +考虑到可以更好的与Android Jetpack的组件互动,WCDB选择Room作为ORM框架。 + +2. 进程与线程并发 + +如果我们在项目中有使用SQLite,那么下面这个SQLiteDatabaseLockedException就是经常会出现的一个问题。 + +android.database.sqlite.SQLiteDatabaseLockedException: database is locked + at android.database.sqlite.SQLiteDatabase.dbopen + at android.database.sqlite.SQLiteDatabase.openDatabase + at android.database.sqlite.SQLiteDatabase.openDatabase + + +SQLiteDatabaseLockedException归根到底是因为并发导致,而SQLite的并发有两个维度,一个是多进程并发,一个是多线程并发。下面我们分别来讲一下它们的关键点。 + +多进程并发 + +SQLite默认是支持多进程并发操作的,它通过文件锁来控制多进程的并发。SQLite锁的粒度并没有非常细,它针对的是整个DB文件,内部有5个状态,具体你可以参考下面的文章。 + + +官方文档:SQLite locking + +SQLite源码分析:SQLite锁机制简介 + +SQLite封锁机制 + + +简单来说,多进程可以同时获取SHARED锁来读取数据,但是只有一个进程可以获取EXCLUSIVE锁来写数据库。对于iOS来说可能没有多进程访问数据库的场景,可以把locking_mode的默认值改为EXCLUSIVE。 + +PRAGMA locking_mode = EXCLUSIVE + + +在EXCLUSIVE模式下,数据库连接在断开前都不会释放SQLite文件的锁,从而避免不必要的冲突,提高数据库访问的速度。 + +多线程并发 + +相比多进程,多线程的数据库访问可能会更加常见。SQLite支持多线程并发模式,需要开启下面的配置,当然系统SQLite会默认开启多线程Multi-thread模式。 + +PRAGMA SQLITE_THREADSAFE = 2 + + +跟多进程的锁机制一样,为了实现简单,SQLite锁的粒度都是数据库文件级别,并没有实现表级甚至行级的锁。还有需要说明的是,同一个句柄同一时间只有一个线程在操作,这个时候我们需要打开连接池Connection Pool。 + +如果使用WCDB在初始化的时候可以指定连接池的大小,在微信中我们设置的大小是4。 + +public static SQLiteDatabase openDatabase (String path, + SQLiteDatabase.CursorFactory factory, + int flags, + DatabaseErrorHandler errorHandler, + int poolSize) + + +跟多进程类似,多线程可以同时读取数据库数据,但是写数据库依然是互斥的。SQLite提供了Busy Retry的方案,即发生阻塞时会触发Busy Handler,此时可以让线程休眠一段时间后,重新尝试操作,你可以参考《微信iOS SQLite源码优化实践》这篇文章。 + +为了进一步提高并发性能,我们还可以打开WAL(Write-Ahead Logging)模式。WAL模式会将修改的数据单独写到一个WAL文件中,同时也会引入了WAL日志文件锁。通过WAL模式读和写可以完全地并发执行,不会互相阻塞。 + +PRAGMA schema.journal_mode = WAL + + +但是需要注意的是,写之间是仍然不能并发。如果出现多个写并发的情况,依然有可能会出现SQLiteDatabaseLockedException。这个时候我们可以让应用中捕获这个异常,然后等待一段时间再重试。 + +} catch (SQLiteDatabaseLockedException e) { + if (sqliteLockedExceptionTimes < (tryTimes - 1)) { + try { + Thread.sleep(100); + } catch (InterruptedException e1) { + } + } + sqliteLockedExceptionTimes++; +} + + +总的来说通过连接池与WAL模式,我们可以很大程度上增加SQLite的读写并发,大大减少由于并发导致的等待耗时,建议大家在应用中可以尝试开启。 + +3. 查询优化 + +说到数据库的查询优化,你第一个想到的肯定是建索引,那我就先来讲讲SQLite的索引优化。 + +索引优化 + +正确使用索引在大部分的场景可以大大降低查询速度,微信的数据库优化也是通过索引开始。下面是索引使用非常简单的一个例子,我们先从索引表找到数据对应的rowid,然后再从原数据表直接通过rowid查询结果。 + + + +关于SQLite索引的原理网上有很多文章,在这里我推荐一些参考资料给你: + + +SQLite索引的原理 + +官方文档:Query Planning + +MySQL索引背后的数据结构及算法原理 + + +这里的关键在于如何正确的建立索引,很多时候我们以为已经建立了索引,但事实上并没有真正生效。例如使用了BETWEEN、LIKE、OR这些操作符、使用表达式或者case when等。更详细的规则可参考官方文档The SQLite Query Optimizer Overview,下面是一个通过优化转换达到使用索引目的的例子。 + +BETWEEN:myfiedl索引无法生效 +SELECT * FROM mytable WHERE myfield BETWEEN 10 and 20; +转换成:myfiedl索引可以生效 +SELECT * FROM mytable WHERE myfield >= 10 AND myfield <= 20; + + +建立索引是有代价的,需要一直维护索引表的更新。比如对于一个很小的表来说就没必要建索引;如果一个表经常是执行插入更新操作,那么也需要节制的建立索引。总的来说有几个原则: + + +建立正确的索引。这里不仅需要确保索引在查询中真正生效,我们还希望可以选择最高效的索引。如果一个表建立太多的索引,那么在查询的时候SQLite可能不会选择最好的来执行。 + +单列索引、多列索引与复合索引的选择。索引要综合数据表中不同的查询与排序语句一起考虑,如果查询结果集过大,还是希望可以通过复合索引直接在索引表返回查询结果。 + +索引字段的选择。整型类型索引效率会远高于字符串索引,而对于主键SQLite会默认帮我们建立索引,所以主键尽量不要用复杂字段。 + + +总的来说索引优化是SQLite优化中最简单同时也是最有效的,但是它并不是简单的建一个索引就可以了,有的时候我们需要进一步调整查询语句甚至是表的结构,这样才能达到最好的效果。 + +页大小与缓存大小 + +在I/O文件系统中,我讲过数据库就像一个小文件系统一样,事实上它内部也有页和缓存的概念。 + +对于SQLite的DB文件来说,页(page)是最小的存储单位,如下图所示每个表对应的数据在整个DB文件中都是通过一个一个的页存储,属于同一个表不同的页以B树(B-tree)的方式组织索引,每一个表都是一棵B树。 + + + +跟文件系统的页缓存(Page Cache)一样,SQLite会将读过的页缓存起来,用来加快下一次读取速度。页大小默认是1024Byte,缓存大小默认是1000页。更多的编译参数你可以查看官方文档PRAGMA Statements。 + +PRAGMA page_size = 1024 +PRAGMA cache_size = 1000 + + +每个页永远只存放一个表或者一组索引的数据,即不可能同一个页存放多个表或索引的数据,表在整个DB文件的第一个页就是这棵B树的根页。继续以上图为例,如果想查询rowID为N+2的数据,我们首先要从sqlite_master查找出table的root page的位置,然后读取root page、page4这两个页,所以一共会需要3次I/O。 + + + +从上表可以看到,增大page size并不能不断地提升性能,在拐点以后可能还会有副作用。我们可以通过PRAGMA改变默认page size的大小,也可以再创建DB文件的时候进行设置。但是需要注意如果存在老的数据,需要调用vacuum对数据表对应的节点重新计算分配大小。 + +在微信的内部测试中,如果使用4KB的page size性能提升可以在5%~10%。但是考虑到历史数据的迁移成本,最终还是使用1024Byte。所以这里建议大家在新建数据库的时候,就提前选择4KB作为默认的page size以获得更好的性能。 + +其他优化 + +关于SQLite的使用优化还有很多很多,下面我简单提几个点。 + + +慎用“select*”,需要使用多少列,就选取多少列。 + +正确地使用事务。 + +预编译与参数绑定,缓存被编译后的SQL语句。 + +对于blob或超大的Text列,可能会超出一个页的大小,导致出现超大页。建议将这些列单独拆表,或者放到表字段的后面。 + +定期整理或者清理无用或可删除的数据,例如朋友圈数据库会删除比较久远的数据,如果用户访问到这部分数据,重新从网络拉取即可。 + + +在日常的开发中,我们都应该对这些知识有所了解,再来复习一下上面学到的SQLite优化方法。通过引进ORM,可以大大的提升我们的开发效率。通过WAL模式和连接池,可以提高SQLite的并发性能。通过正确的建立索引,可以提升SQLite的查询速度。通过调整默认的页大小和缓存大小,可以提升SQLite的整体性能。 + +SQLite的其他特性 + +除了SQLite的优化经验,我在微信的工作中还积累了很多使用的经验,下面我挑选了几个比较重要的经验把它分享给你。 + +1. 损坏与恢复 + +微信中SQLite的损耗率在1/20000~1/10000左右,虽然看起来很低,不过意考虑到微信的体量,这个问题还是不容忽视的。特别是如果某些大佬的聊天记录丢失,我们团队都会承受超大的压力。 + +创新是为了解决焦虑,技术都是逼出来的。对于SQLite损坏与恢复的研究,可以说是微信投入比较大的一块。关于SQLite数据库的损耗与修复,以及微信在这里的优化成果,你可以参考下面这些资料。 + + +How To Corrupt An SQLite Database File + +微信 SQLite 数据库修复实践 + +微信移动端数据库组件WCDB系列(二) — 数据库修复三板斧 + +WCDB Android数据库修复 + + +2. 加密与安全 + +数据库的安全主要有两个方面,一个是防注入,一个是加密。防注入可以通过静态安全扫描的方式,而加密一般会使用SQLCipher支持。 + +SQLite的加解密都是以页为单位,默认会使用AES算法加密,加/解密的耗时跟选用的密钥长度有关。下面是WCDB Android Benchmark的数据,详细的信息请查看链接里的说明,从结论来说对Create来说影响会高达到10倍。 + + + +关于WCDB加解密的使用,你可以参考《微信移动数据库组件WCDB(四) — Android 特性篇》。 + +3. 全文搜索 + +微信的全文搜索也是一个技术导向的项目,最开始的时候性能并不是很理想,经常会被人“批斗”。经过几个版本的优化迭代,目前看效果还是非常不错的。 + + + +关于全文搜索,你可以参考这些资料: + + +SQLite FTS3 and FTS4 Extensions + +微信全文搜索优化之路 + +移动客户端多音字搜索 + + +关于SQLite的这些特性,我们需要根据自己的项目情况综合考虑。假如某个数据库存储的数据并不重要,这个时候万分之一的数据损坏率我们并不会关心。同样是否需要使用数据库加密,也要根据存储的数据是不是敏感内容。 + +SQLite的监控 + +首先我想说,正确使用索引,正确使用事务。对于大型项目来说,参与的开发人员可能有几十几百人,开发人员水平参差不齐,很难保证每个人都可以正确而高效地使用SQLite,所以这次时候需要建立完善的监控体系。 + +1. 本地测试 + +作为一名靠谱的开发工程师,我们每写一个SQL语句,都应该先在本地测试。我们可以通过 EXPLAIN QUERY PLAN测试SQL语句的查询计划,是全表扫描还是使用了索引,以及具体使用了哪个索引等。 + +sqlite> EXPLAIN QUERY PLAN SELECT * FROM t1 WHERE a=1 AND b>2; +QUERY PLAN +|--SEARCH TABLE t1 USING INDEX i2 (a=? AND b>?) + + +关于SQLite命令行与EXPLAIN QUERY PLAN的使用,可以参考Command Line Shell For SQLite以及EXPLAIN QUERY PLAN。 + +2. 耗时监控 + +本地测试过于依赖开发人员的自觉性,所以很多时候我们依然需要建立线上大数据的监控。因为微信集成了自己的SQLite源码,所以可以非常方便地增加自己想要的监控模块。 + +WCDB增加了SQLiteTrace的监控模块,有以下三个接口: + + + +我们可以通过这些接口监控数据库busy、损耗以及执行耗时。针对耗时比较长的SQL语句,需要进一步检查是SQL语句写得不好,还是需要建立索引。 + + + +3. 智能监控 + +对于查询结果的监控只是我们监控演进的第二阶段,在这个阶段我们依然需要人工介入分析,而且需要比较有经验的人员负责。 + +我们希望SQL语句的分析可以做到智能化,是完全不需要门槛的。微信开源的Matrix里面就有一个智能化分析SQLite语句的工具:Matrix SQLiteLint – SQLite 使用质量检测。它根据分析SQL语句的语法树,结合我们日常数据库使用的经验,抽象出索引使用不当、select*等六大问题。 + + + +可能有同学会感叹为什么微信的人可以想到这样的方式,事实上这个思路在MySQL中是非常常见的做法。美团也开源了它们内部的SQL优化工具SQLAdvisor,你可以参考这些资料: + + +SQL解析在美团的应用 + +美团点评SQL优化工具SQLAdvisor开源 + + +总结 + +数据库存储是一个开发人员的基本功,清楚SQLite的底层机制对我们的工作会有很大的指导意义。 + +掌握了SQLite数据库并发的机制,在某些时候我们可以更好地决策应该拆数据表还是拆数据库。新建一个数据库好处是可以隔离其他库并发或者损坏的情况,而坏处是数据库初始化耗时以及更多内存的占用。一般来说,单独的业务都会使用独立数据库,例如专门的下载数据库、朋友圈数据库、聊天数据库。但是数据库也不宜太多,我们可以有一个公共数据库,用来存放一些相对不是太大的数据。 + +在了解SQLite数据库损坏的原理和概率以后,我们可以根据数据的重要程度决定是否要引入恢复机制。我还讲了如何实现数据库加密以及对性能的影响,我们可以根据数据的敏感程度决定是否要引入加密。 + +最后我再强调一下,SQLite优化真的是一个很大的话题,在课后你还需要结合参考资料再进一步反复学习,才能把今天的内容理解透彻。 + +课后作业 + +在你的应用中是否使用数据库存储呢,使用了哪种数据库?是否使用ORM?在使用数据库过程中你有哪些疑问或者经验呢?欢迎留言跟我和其他同学一起讨论。 + +如果你的应用也在使用SQLite存储,今天的课后练习是尝试接入WCDB,对比测试系统默认SQLite的性能。尝试接入Matrix SQLiteLint,查看是否存在不合理的SQLite使用。 + +除了今天文章中的参考资料,我还给希望进阶的同学准备了下面的资料,欢迎有兴趣的同学继续深入学习。 + + +SQLite官方文档 + +SQLite源码分析 + +全面解析SQLite + +图书《SQLite权威指南(第2版)》 + + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/15网络优化(上):移动开发工程师必备的网络优化知识.md b/专栏/Android开发高手课/15网络优化(上):移动开发工程师必备的网络优化知识.md new file mode 100644 index 0000000..c1efe3d --- /dev/null +++ b/专栏/Android开发高手课/15网络优化(上):移动开发工程师必备的网络优化知识.md @@ -0,0 +1,197 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 网络优化(上):移动开发工程师必备的网络优化知识 + 专栏前面我们已经学习过文件I/O和存储优化,相信你已经掌握了文件I/O和存储的性能分析以及优化思路。今天我们就再接再厉,继续学习系统中另外一种常见的I/O——网络I/O。 + +我在写今天的文章时,回想了一下大学期间学的那本几百页厚的《计算机网络》,当时学得也是云里雾里,网络的确涉及了方方面面太多的知识。那我们作为移动开发者来说,都需要掌握哪些必备的网络知识呢?文件I/O跟网络I/O又有哪些差异呢? + +今天我们不谈“经典巨著”,一起来解决移动开发工程师面对的网络问题。 + +网络基础知识 + +现在已经很难找到一款完全不需要网络的应用,即使是单机应用,也会存在数据上报、广告等各种各样的网络请求。既然网络已经无处不在,我们必须要掌握哪些基础知识呢? + +1. 无线网络 + +在过去十年,移动互联网的高速增长离不开无线网络的普及。无线网络多种多样,而且各有各的特点,并且适合使用的场景也不同。 + +下图是iPhone XS支持的无线网络类型,你可以看到WiFi、蜂窝网络、蓝牙、NFC这些都是我们日常经常使用的无线网络类型。 + + + +“千兆级LTE”指的是蜂窝网络在理论上速度可以达到光纤级别的1Gbps(125MB/s)。虽然基于4G标准,但通过MIMO(多输入多输出)、使用载波聚合的LAA等技术,现在已经发展到千兆级LTE。2020年我们也即将迎来5G的商用,它的理论传输速率可以达到20Gbps。目前5G的标准还没有完全release,关于5G的原理我推荐你看看这篇文章。 + + + +“802.11ac无线网络”指的是我们经常使用的WiFi。WiFi由IEEE定义和进行标准化规范,跟任何流行的技术一样,IEEE也一直在积极地发布新的协议。目前最常用的是802.11ac标准,它的理想速率可以达到866.7Mbps。 + +从硬件维度上来看,所有的无线网络都通过基带芯片支持,目前高通在基带芯片领域占据了比较大的优势。之前由于苹果和高通的专利诉讼,iPhone XS选用了英特尔的基带芯片,但同时也出现大量的用户投诉网络连接异常。 + +市面上有那么多的无线网络标准和制式,还有双卡双待等各种特色功能,因此基带芯片对技术的要求非常高。随着未来5G的商用与普及,国内也会迎来新的一波换机潮。这对各大芯片厂商来说是机遇也是挑战,目前高通、MTK、华为都已经发布了5G基带芯片。如果你对当前的5G格局感兴趣,可以阅读《全世界5G格局》。 + +2. Link Turbo + +像5G这种新的标准,可以极大地提升网络速度,但缺点是它需要新的基站设备和手机设备支持,这个过程起码需要几年的时间。 + +手机厂商为了提升用户的网络体验,也会做各种各样的定制优化,华为最近在荣耀V20推出的Link Turbo 网络聚合加速技术就是其中比较硬核的一种“黑科技”。 + + + +从硬件角度来说,WiFi和蜂窝网络属于基带芯片的不同模块,我们可以简单的把它们理解为类似双网卡的情形。所谓的Link Turbo就是在使用WiFi的同时使用移动网络加速。 + +可能有人会疑惑,我都已经连接WiFi了,为什么还要使用收费的移动网络呢?有这个疑问的人肯定没有试过使用公司网络打“王者”团战卡成狗的情形,其实WiFi可能会因为下面的一些原因导致很不稳定。 + + + +事实上,双通道的技术也并不是华为首发。类似iPhone的无线网络助理、小米和一加的自适应WLAN,它们都能在侦测到WiFi网络不稳定时,自动切换到移动网络。iPhone在连接WiFi的时候,移动网络也是依然可以连接的。 + +而Link Turbo硬核的地方在于可以同时使用两条通道传输数据,而且支持TCP与UDP。其中TCP支持使用的是开源的MultiPath TCP(iOS 7也有引入),而UDP则是华为自研的MultiPath UDP。 + +当然这个功能目前比较鸡肋,主要是由于一是覆盖的用户比较少,当前只有V20一台机器支持,而且还需要用户手动开启;二是改造成本,双通道需要我们的后台服务器也做一些改造才能支持。 + + + +但是这项技术还是有一定的价值,一方面流量越来越便宜,很多用户不再那么care流量资费的问题。另一方面华为可以直接跟阿里云、华为云、腾讯云以及CDN服务商合作,屏蔽应用后台服务器的改造成本。目前爱奇艺、斗鱼和映客这些应用都在尝试接入。 + + + +讲到这里你可能会问,为什么今天我会花这么多时间来讲Link Turbo技术?并不是因为我收了广告费,而是我发现很多时候在优化到一定程度之后,单靠应用本身很难再有大的突破。这个时候可能要考虑跟手机厂商、芯片厂商或者运营商合作,因此我们要随时关注行业的动态,并且清楚这些新技术背后的本质所在。 + +网络I/O + +在前面的专栏里,我讲了文件I/O的处理流程以及不同I/O方式的使用场景,今天我们再一起来看看网络I/O跟文件I/O有哪些差异。 + +1. I/O模型 + +“一切皆文件”,Linux内核会把所有外部设备都看作一个文件来操作。在网络I/O中系统对一个 Socket的读写也会有相应的描述符,称为socket fd(Socket描述符)。 + +如下图以Socket读取数据recvfrom调用为例,它整个I/O流程分为两个阶段: + + +等待Socket数据准备好。 + +将数据从内核拷贝到应用进程中 。 + + + + +在《UNIX网络编程》中将UNIX网络I/O模型分为以下五种。 + + + +在开发过程中,比较常用的有阻塞I/O、非阻塞I/O以及多路复用I/O。关于UNIX网络I/O模型的更多资料,你可以参考《UNIX网络编程》第六章、《聊聊Linux五种I/O模型》、《Unix网络I/O模型及Linux的I/O多路复用模型》。 + +在查资料的时候我发现网上有很多文章的描述还是存在问题的,我们需要辩证地看。 + + +多路复用I/O一定比阻塞I/O要好?跟文件I/O一样,最简单的I/O并发方式就是多线程+阻塞I/O。如果我们同一时间活动的网络连接非常多,使用多路复用I/O性能的确更好。但是对于客户端来说,这个假设不一定成立。对于多路复用I/O来说,整个流程会增加大量的select/epoll这样的系统调用,不一定比阻塞I/O要快。 + +epoll一定比select/poll要好?如果同一时间的连接数非常少的情况,select的性能不会比epoll,很多时候会比epoll更好。 + +epoll使用了mmap减少内核到用户空间的拷贝?网上很多的文章都说epoll使用了mmap的技术,但是我查看了Linux与Android的epoll实现,并没有找到相关的实现。而且我个人认为也不太可能会这样实现,因为直接共享内存可能会引发比较大的安全漏洞。 + + +2. 数据处理 + +在下一期我还会跟你一起分析当前一些热门网络库的I/O模型,现在我们再往底层走走,看看底层收发包的流程是怎么样的。 + + + +跟文件I/O一样,网络I/O也使用了中断。不过网络I/O的中断会更加复杂一些,它同时使用了软中断和硬中断。通过硬中断通知CPU有数据来了,但这个处理会非常轻量。耗时的操作移到软中断处理函数里面来慢慢处理。其中查看系统软中断可以通过/proc/softirqs文件,查看硬中断可以通过/proc/interrupts文件。 + +关于网卡收发包的流程网上的资料不多,感兴趣的同学可以参考下面几篇文章: + + +网卡收包流程 + +Linux网络 - 数据包的接收过程 + +Linux网络 - 数据包的发送过程 + +Illustrated Guide to Monitoring and Tuning the Linux Networking Stack: Receiving Data + + +考虑到这块比较复杂,我在专栏里提供给你参考资料,有兴趣的同学可以进一步深入研究。 + +网络性能评估 + +我们常说的网络性能优化,通常都优化哪些方面呢?有的同学可能会关注网络的带宽和服务器成本,特别是直播、视频类的企业,这部分的成本非常高昂。虽然有的时候会做一些取舍,但是用户的访问速度与体验是所有应用的一致追求。 + +1. 延迟与带宽 + +如果说速度是关键,那对网络传输速度有决定性影响的主要有以下两个方面: + + +延迟:数据从信息源发送到目的地所需的时间。 + +带宽:逻辑或物理通信路径最大的吞吐量。 + + +回想一下文件I/O性能评估似乎已经很复杂了,但是它至少整个流程都在手机系统内部。对于网络来说,整个流程涉及的链路就更加复杂了。一个数据包从手机出发要经过无线网络、核心网络以及外部网络(互联网),才能到达我们的服务器。 + + + +那延迟和带宽又跟什么因素有关呢?这里面涉及的因素也非常多,例如信号的强度、附近有没有基站、距离有多远等;还跟使用的网络制式,正在使用3G、4G还是5G网络有关,并且网络的拥塞情况也会产生影响,比如是不是在几万人聚集的大型活动场所等。 + +下面是不同网络制式的带宽和延迟的一般参考值,你可以在脑海里建立一个大致的印象。 + + + +当出现上面说到的那些因素时,网络访问的带宽要大打折扣,延迟会加倍放大。而高延迟、低带宽的网络场景也就是我们常说的“弱网络”,它主要特点有: + + + +关于“弱网络”如何进行优化,我在微信时针对弱网络优化投入了大量的精力,这也是我在下一期所要讲的重点。不过我想说的是,即使未来5G普及了,但是各种各样的影响因素依然存在,弱网络优化这个课题是有长期存在的价值。 + +另外一个方面,不同的应用对延迟和带宽的侧重点可能不太一样。对于直播类应用或者“王者荣耀”来说,延迟会更重要一些;对腾讯视频、爱奇艺这种点播的应用来说,带宽会更加重要一些。网络优化需要结合自己应用的实际情况来综合考虑。 + +2. 性能测量 + +正如文件I/O测量一样,有哪些方法可以帮助我们评估网络的性能呢? + +对于网络来说,我们关心的是下面这些指标: + + +吞吐量:网络接口接收和传输的每秒字节数。 + +延迟:系统调用发送/接收延时、连接延迟、首包延迟、网络往返时间等。 + +连接数:每秒的连接数。 + +错误:丢包计数、超时等。 + + +Linux提供了大量的网络性能分析工具,下面这些工具是Android支持并且比较实用的,这些工具完整的功能请参考文档或者网上其他资料,这里就不再赘述了。 + + + +如果你对Linux底层更加熟悉,可以直接查看/proc/net,它里面包含了许多网络统计信息的文件。例如Android的TrafficState接口就是利用/proc/net/xt_qtaguid/stats和/proc/net/xt_qtaguid/iface_stat_fmt文件来统计应用的流量信息。 + +总结 + +从网络通信发展的历程来说,从2G到4G经历了十几年的时间,这背后离不开几百万个基站、几亿个路由器以及各种各样的专利支持。虽然网络标准不停地演进,不过受限于基建,它的速度看起来很快,但是又很慢。 + +那对我们自己或者应用会有哪些思考呢?HTTP 2.0、HTTP 3.0(QUIC)等网络技术一直在向前演进,我们需要坚持不懈地学习,思考它们对我们可以产生哪些影响,这是对网络“快”的思考。TCP和UDP协议、弱网络等很多东西在这二十多年来依然没有太大的改变,网络的基础知识对我们来说还是非常重要的,这是对网络“慢”的思考。 + +课后作业 + +在讲Link Turbo的时候我说过,iPhone的无线网络助理、小米和一加的自适应WLAN它们在检测WiFi不稳定时会自动切换到移动网络。那请你思考一下,它们是如何实现侦测,如何区分是应用后台服务器出问题还是WiFi本身有问题呢?今天的作业是在留言区写写你对这个问题的看法,欢迎留言跟我和其他同学一起讨论。 + +今天我推荐一本必读的网络书籍:《Web性能权威指南》,它里面第一句话就讲得非常好,我把它分享给你:“合格的开发者知道怎么做,而优秀的开发者知道为什么那么做”。 + +对于想进一步深入研究的同学,你可以研读这些书籍: + + +《UNIX网络编程》 + +《TCP/IP详解 卷1:协议》 + + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/16网络优化(中):复杂多变的移动网络该如何优化?.md b/专栏/Android开发高手课/16网络优化(中):复杂多变的移动网络该如何优化?.md new file mode 100644 index 0000000..9961207 --- /dev/null +++ b/专栏/Android开发高手课/16网络优化(中):复杂多变的移动网络该如何优化?.md @@ -0,0 +1,271 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 网络优化(中):复杂多变的移动网络该如何优化? + 在PC互联网时代,网络优化已经是一项非常复杂的工作。对于移动网络来说,弱网络、网络切换、网络劫持这些问题更加突出,网络优化这项工作也变得更加艰巨。 + +那作为一名移动开发者,面对复杂多变的移动网络我们该如何去优化呢?可能也有人会说,我只要用好AFNetworking/OkHttp这些成熟网络库就可以了,并不需要额外去做什么优化。那你确定你真的能用好这些网络库吗?它们内部是怎样实现的、有哪些差异点、哪个网络库更好呢? + +虽然我们可能只是客户端App开发人员,但在关于网络优化还是可以做很多事情的,很多大型的应用也做了很多的实践。今天我们一起来看一下,如何让我们的应用在各种的网络条件下都能“快人一步”。 + +移动端优化 + +回想上一期我给出的网络架构图,一个数据包从手机出发要经过无线网络、核心网络以及外部网络(互联网),才能到达我们的服务器。那整个网络请求的速度会跟哪些因素有关呢? + + + +从上面这张图上看,客户端网络库实现、服务器性能以及网络链路的质量都是影响网络请求速度的因素。下面我们先从客户端的网络库说过,看看应该如何进行网络优化。 + +1. 何为网络优化 + +在讲怎么去优化网络之前,我想先明确一下所谓的网络优化,究竟指的是什么?在我看来,核心内容有以下三个: + + +速度。在网络正常或者良好的时候,怎样更好地利用带宽,进一步提升网络请求速度。 + +弱网络。移动端网络复杂多变,在出现网络连接不稳定的时候,怎样最大程度保证网络的连通性。 + +安全。网络安全不容忽视,怎样有效防止被第三方劫持、窃听甚至篡改。 + + +除了这三个问题,我们可能还会关心网络请求造成的耗电、流量问题,这两块内容我们在后面会统一地讲,今天就不再展开。 + +那对于速度、弱网络以及安全的优化,又该从哪些方面入手呢?首先你需要先搞清楚一个网络请求的整个过程。 + + + +从图上看到,整个网络请求主要分为几个步骤,而整个请求的耗时可以细分到每一个步骤里面。 + + +DNS解析。通过DNS服务器,拿到对应域名的IP地址。在这个步骤,我们比较关注DNS解析耗时情况、运营商LocalDNS的劫持、DNS调度这些问题。 + +创建连接。跟服务器建立连接,这里包括TCP三次握手、TLS密钥协商等工作。多个IP/端口该如何选择、是否要使用HTTPS、能否可以减少甚至省下创建连接的时间,这些问题都是我们优化的关键。 + +发送/接收数据。在成功建立连接之后,就可以愉快地跟服务器交互,进行组装数据、发送数据、接收数据、解析数据。我们关注的是,如何根据网络状况将带宽利用好,怎么样快速地侦测到网络延时,在弱网络下如何调整包大小等问题。 + +关闭连接。连接的关闭看起来非常简单,其实这里的水也很深。这里主要关注主动关闭和被动关闭两种情况,一般我们都希望客户端可以主动关闭连接。 + + +所谓的网络优化,就是围绕速度、弱网络、安全这三个核心内容,减少每一个步骤的耗时,打造快速、稳定且安全的高质量网络。 + +2. 何为网络库 + +在实际的开发工作中,我们很少会像《UNIX网络编程》那样直接去操作底层的网络接口,一般都会使用网络库。Square出品的OkHttp是目前最流行的Android网络库,它还被Google加入到Android系统内部,为广大开发者提供网络服务。 + +那网络库究竟承担着一个什么样的角色呢?在我看来,它屏蔽了下层复杂的网络接口,让我们可以更高效地使用网络请求。 + + + +如上图所示,一个网络库的核心作用主要有以下三点: + + +统一编程接口。无论是同步还是异步请求,接口都非常简单易用。同时我们可以统一做策略管理,统一进行流解析(JSON、XML、Protocol Buffers)等。 + +全局网络控制。在网络库内部我们可以做统一的网络调度、流量监控以及容灾管理等工作。 + +高性能。既然我们把所有的网络请求都交给了网络库,那网络库是否实现高性能就至关重要。既然要实现高性能,那我会非常关注速度,CPU、内存、I/O的使用,以及失败率、崩溃率、协议的兼容性等方面。 + + +不同的网络库实现差别很大,比较关键有这几个模块: + + + +那网络库实现到底哪家强?接下来我们一起来对比OkHttp、Chromium的Cronet以及微信Mars这三个网络库的内部实现。 + +3. 高质量网络库 + +据我了解业内的蘑菇街、头条、UC浏览器都在Chromium网络库上做了二次开发,而微信Mars在弱网络方面做了大量优化,拼多多、虎牙、链家、美丽说这些应用都在使用Mars。 + +下面我们一起来对比一下各个网络库的核心实现。对于参与网络库相关工作来说,我的经验还算是比较丰富的。在微信的时候曾经参与过Mars的开发,目前也在基于Chromium网络库做二次开发。 + + + +为什么我从来没使用过OkHttp?主要因为它并不支持跨平台,对于大型应用来说跨平台是非常重要的。我们不希望所有的优化Android和iOS都要各自去实现一套,不仅浪费人力而且还容易出问题。 + +对于Mars来说,它是一个跨平台的Socket层解决方案,并不支持完整的HTTP协议,所以Mars从严格意义上来讲并不是一个完整的网络库。但是它在弱网络和连接上做了大量的优化,并且支持长连接。关于Mars的网络多优化的更多细节,你可以参考Wiki右侧的文章列表。 + + + +Chromium网络库作为标准的网络库,基本上可以说是找不到太大的缺点。而且我们可以享受Google后续网络优化的成果,类似TLS 1.3、QUIC支持等。 + +但是它针对弱网络场景没有做太多定制的优化,也不支持长连接。事实上目前我在Chromium网络库的二次开发主要工作也是补齐弱网络优化与长连接这两个短板。 + +大网络平台 + +对于大公司来说,我们不能只局限在客户端网络库的双端统一上。网络优化不仅仅是客户端的事情,所以我们有了统一的网络中台,它负责提供前后台一整套的网络解决方案。 + +阿里的ACCS、蚂蚁的mPaaS、携程的网络服务都是公司级的网络中台服务,这样所有的网络优化可以让整个集团的所有接入应用受益。 + +下图是mPaaS的网络架构图,所有网络请求都会先经过统一的接入层,再转发到业务服务器。这样我们可以在业务服务器无感知的情况下,在接入层做各种各样的网络优化。 + + + +1. HTTPDNS + +DNS的解析是我们网络请求的第一项工作,默认我们使用运营商的LocalDNS服务。这块耗时在3G网络下可能是200~300ms,4G网络也需要100ms。 + +解析慢并不是默认LocalDNS最大的“原罪”,它还存在一些其他问题: + + +稳定性。UDP协议,无状态,容易域名劫持(难复现、难定位、难解决),每天至少几百万个域名被劫持,一年至少十次大规模事件。 + +准确性。LocalDNS调度经常出现不准确,比如北京的用户调度到广东IP,移动的运营商调度到电信的IP,跨运营商调度会导致访问慢,甚至访问不了。 + +及时性。运营商可能会修改DNS的TTL,导致DNS修改生效延迟。不同运营商的服务实现不一致,我们也很难保证DNS解析的耗时。 + + +为了解决这些问题,就有了HTTPDNS。简单来说自己做域名解析的工作,通过HTTP请求后台去拿到域名对应的IP地址,直接解决上述所有问题。 + +微信有自己部署的NEWDNS,阿里云和腾讯云也有提供自己的HTTPDNS服务。对于大网络平台来说,我们会有统一的HTTPDNS服务,并将它和运维系统打通。在传统的DNS基础上,还会增加精准的流量调度、网络拨测/灰度、网络容灾等功能。 + + + +关于HTTPDNS的更多知识,你可以参考百度的《DNS优化》。对客户端来说,我们可以通过预请求的方法,提前拿到一批域名的IP,不过这里需要注意IPv4与IPv6协议栈的选择问题。 + +2. 连接复用 + +在DNS解析之后,我们来到了创建连接这个环节。创建连接要经过TCP三次握手、TLS密钥协商,连接建立的代价是非常大的。这里我们主要的优化思路是复用连接,这样不用每次请求都重新建立连接。 + +在前面我就讲过连接管理,网络库并不会立刻把连接释放,而是放到连接池中。这时如果有另一个请求的域名和端口是一样的,就直接拿出连接池中的连接进行发送和接收数据,少了建立连接的耗时。 + +这里我们利用HTTP协议里的keep-alive,而HTTP/2.0的多路复用则可以进一步的提升连接复用率。它复用的这条连接支持同时处理多条请求,所有请求都可以并发在这条连接上进行。 + + + +虽然H2十分强大,不过这里还有两个问题需要解决。一个是同一条H2连接只支持同一个域名,一个是后端支持HTTP/2.0需要额外的改造。这个时候我们只需要在统一接入层做改造,接入层将数据转换到HTTP/1.1再转发到对应域名的服务器。 + + + +这样所有的服务都不用做任何改造就可以享受HTTP/2.0的所有优化,不过这里需要注意的是H2的多路复用在本质上依然是同一条TCP连接,如果所有的域名的请求都集中在某一条连接中,在网络拥塞的时候容易出现TCP队首阻塞问题。 + +对于客户端网络库来说,无论OkHttp还是Chromium网络库对于HTTP/2.0的连接,同一个域名只会保留一条连接。对于一些第三方请求,特别是文件下载以及视频播放这些场景可能会遇到对方服务器单连接限速的问题。在这种情况下我们可以通过修改网络库实现,也可以简单的通过禁用HTTP/2.0协议解决。 + +3. 压缩与加密 + +压缩 + +讲完连接,我们再来看看发送和接收的优化。我第一时间想到的还是减少传输的数据量,也就是我们常说的数据压缩。首先对于HTTP请求来说,数据主要包括三个部分: + + +请求URL + +请求header + +请求body + + +对于header来说,如果使用HTTP/2.0连接本身的头部压缩技术,因此需要压缩的主要是请求URL和请求body。 + +对于请求URL来说,一般会带很多的公共参数,这些参数大部分都是不变的。这样不变的参数客户端只需要上传一次即可,其他请求我们可以在接入层中进行参数扩展。 + +对于请求body来说,一方面是数据通信协议的选择,在网络传输中目前最流行的两种数据序列化方式是JSON和Protocol Buffers。正如我之前所说的一样,Protocol Buffers使用起来更加复杂一些,但在数据压缩率、序列化与反序列化速度上面都有很大的优势。 + +另外一方面是压缩算法的选择,通用的压缩算法主要是如gzip,Google的Brotli或者Facebook的Z-standard都是压缩率更高的算法。其中如果Z-standard通过业务数据样本训练出适合的字典,是目前压缩率表现最好的算法。但是各个业务维护字典的成本比较大,这个时候我们的大网络平台的统一接入层又可以大显神威了。 + + + +例如我们可以抽样1%的请求数据用来训练字典,字典的下发与更新都由统一接入层负责,业务并不需要关心。 + +当然针对特定数据我们还有其他的压缩方法,例如针对图片我们可以使用webp、hevc、SharpP等压缩率更高的格式。另外一方面,基于AI的图片超清化也是一大神器,QQ空间通过这个技术节约了大量的带宽成本。 + +安全 + +数据安全也是网络重中之重的一个环节,在大网络平台中我们都是基于HTTPS的HTTP/2通道,已经有了TLS加密。如果大家不熟悉TLS的基础知识,可以参考微信后台一个小伙伴写的《TLS协议分析》。 + +但是HTTPS带来的代价也是不小的,它需要2-RTT的协商成本,在弱网络下时延不可接受。同时后台服务解密的成本也十分高昂,在大型企业中需要单独的集群来做这个事情。 + +HTTPS的优化有下面几个思路: + + +连接复用率。通过多个域名共用同一个HTTP/2连接、长连接等方式提升连接复用率。 + +减少握手次数。TLS 1.3可以实现0-RTT协商,事实上在TLS 1.3 release之前,微信的mmtls、Facebook的fizz、阿里的SlightSSL都已在企业内部大规模部署。 + +性能提升。使用ecc证书代替RSA,服务端签名的性能可以提升4~10倍,但是客户端校验性能降低了约20倍,从10微秒级降低到100微秒级。另外一方面可以通过Session Ticket会话复用,节省一个RTT耗时。 + + +使用HTTPS之后,整个通道是不是就一定高枕无忧呢?如果客户端设置了代理,TLS加密的数据可以被解开并可能被利用 。这个时候我们可以在客户端将“证书锁定”(Certificate Pinning),为了老版本兼容和证书替换的灵活性,建议锁定根证书。 + +我们也可以对传输内容做二次加密,这块在统一接入层实现,业务服务器也同样无需关心这个流程。需要注意的是二次加密会增加客户端与服务器的处理耗时,我们需要在安全性与性能之间做一个取舍。 + + + +4. 其他优化 + +关于网络优化的手段还有很多,一些方案可能是需要用钱堆出来的,比如部署跨国的专线、加速点,多IDC就近接入等。 + +除此之外,使用CDN服务、P2P技术也是比较常用的手段,特别在直播这类场景。总的来说,网络优化我们需要综合用户体验、带宽成本以及硬件成本等多个因素来考虑。 + +下面为你献上一张高质量网络的全景大图。 + + + +QUIC与IPv6 + +今天已经讲得很多了,可能还有小伙伴比较关心最近一些比较前沿的技术,我简单讲一下QUIC和IPv6。 + +1. QUIC + +QUIC协议由Google在2013年实现,在2018年基于QUIC协议的HTTP更被确认为HTTP/3。在连接复用中我说过HTTP/2 + TCP会存在队首阻塞的问题,基于UDP的QUIC才是终极解决方案。 + +如下图所示,你可以把QUIC简单理解为HTTP/2.0 + TLS 1.3 + UDP。 + + + +事实上,它还有着其他的很多优势: + + +灵活控制拥塞协议。如果想对TCP内部的拥塞控制算法等模块进行优化和升级,整体周期是相对较长的。对于UDP来说,我们不需要操作系统支持,随时可改,例如可以直接使用Google的BBR算法。 + +“真”连接复用。不仅解决了队首阻塞的问题,在客户端网络切换的时候也不需要重连,用户使用App的体验会更加流畅。 + + +既然QUIC那么好,为什么我们在生产环境没有全部切换成QUIC呢?那是因为有很多坑还没有踩完,目前发现的主要问题还有: + + +创建连接成功率。主要是UDP的穿透性问题,NAT局域网路由、交换机、防火墙等会禁止UDP 443通行,目前QUIC在国内建连的成功率大约在95%左右。 + +运营商支持。运营商针对UDP通道支持不足,表现也不稳定。例如QoS限速丢包,有些小的运营商甚至还直接不支持UDP包。 + + +尽管有这样那样的问题,但是QUIC一定是未来。当然,通过大网络平台的统一接入层,我们业务基本无需做什么修改。目前据我了解,腾讯、微博、阿里都在内部逐步加大QUIC的流量,具体细节可以参考我给出的链接。 + +2. IPv6 + +运维人员都会深深的感觉到IP资源的珍贵,而致力于解决这个问题的IPv6却在中国一直非常沉寂。根据《2017年IPV6支持度报告》,在中国只有0.38%的用户使用v6。 + + + +IPv6不仅针对IoT技术,对万物互联的时代有着非常大的意义。而且它对网络性能也有正向的作用,在印度经过我们测试,使用IPv6网络相比IPv4连接耗时可以降低10%~20%。推行IPv6后,无穷无尽的IP地址意味着可以告别各种NAT,P2P、QUIC的连接也不再是问题。 + +在过去的一年,无论是阿里云还是腾讯云都做了大量IPv6的工作。当然主要也是接入层的改造,尽量不需要业务服务做太多修改。 + +总结 + +移动技术发展到今天,跨终端和跨技术栈的联合优化会变得越来越普遍。有的时候我们需要跳出客户端开发的视角,从更高的维度去思考整个大网络平台。当然网络优化的水还是非常深的,有时候我们需要对协议层也有比较深入的研究,也要经常关注国外的一些新的研究成果。 + +2018年随着工信部发布《推进互联网协议第六版(IPv6)规模部署行动计划》的通知,所有的云提供商需要在2020年完成IPv6的支持。QUIC在2018年被定为HTTP/3草案,同时3GPP也将QUIC列入5G核心网协议第二阶段标准(3GPP Release 16)。 + +随着5G、QUIC与IPv6未来在中国的普及,网络优化永不止步,它们将推动我们继续努力去做更多尝试,让用户可以有更好的网络体验。 + +课后作业 + +你的应用使用的是哪个网络库?对于网络优化,你还有哪些实践经验?欢迎留言跟我和其他同学一起讨论。 + +网络优化是一个很大的话题,在课后你还需要进一步扩展学习。除了今天文章里给出的链接,这里还提供一些参考资料给你: + + +微信客户端怎样应对弱网络 + +阿里亿级日活网关通道架构演进 + +阿里巴巴HTTP 2.0实践及无线通信协议的演进之路 + + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/17网络优化(下):大数据下网络该如何监控?.md b/专栏/Android开发高手课/17网络优化(下):大数据下网络该如何监控?.md new file mode 100644 index 0000000..da1f9cc --- /dev/null +++ b/专栏/Android开发高手课/17网络优化(下):大数据下网络该如何监控?.md @@ -0,0 +1,244 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 网络优化(下):大数据下网络该如何监控? + 通过上一期的学习,我们对如何打造一个高质量的网络已经有了一个整体的认识。但是这就足够了吗?回想一下,一个网络请求从手机到后台服务器,会涉及基站、光纤、路由器这些硬件设施,也会跟运营商和服务器机房有关。 + +不论是基站故障、光纤被挖断、运营商挟持,还是我们的机房、CDN服务商出现故障,都有可能会引起用户网络出现问题。你有没有感觉线上经常突发各种千奇百怪的网络问题,很多公司的运维人员每天过得胆战心惊、疲于奔命。 + +“善良”的故障过了一段时间之后莫名其妙就好了,“顽固”的故障难以定位也难以解决。这些故障究竟是如何产生的?为什么突然就恢复了?它们影响了多少用户、哪些用户?想要解决这些问题离不开高质量的网络,而高质量的网络又离不开强大的监控。今天我们就一起来看看网络该如何监控吧。 + +移动端监控 + +对于移动端来说,我们可能会有各种各样的网络请求。即使使用了OkHttp网络库,也可能会有一些开发人员或者第三方组件使用了系统的网络库。那应该如何统一的监控客户端的所有的网络请求呢? + +1. 如何监控网络 + +第一种方法:插桩。 + +为了兼容性考虑,我首先想到的还是插桩。360开源的性能监控工具ArgusAPM就是利用Aspect切换插桩,实现监控系统和OkHttp网络库的请求。 + +系统网络库的插桩实现可以参考TraceNetTrafficMonitor,主要利用Aspect的切面功能,关于OkHttp的拦截可以参考OkHttp3Aspect,它会更加简单一些,因为OkHttp本身就有代理机制。 + +@Pointcut("call(public okhttp3.OkHttpClient build())") +public void build() { +} + +@Around("build()") +public Object aroundBuild(ProceedingJoinPoint joinPoint) throws Throwable { + Object target = joinPoint.getTarget(); + if (target instanceof OkHttpClient.Builder && Client.isTaskRunning(ApmTask.TASK_NET)) { + OkHttpClient.Builder builder = (OkHttpClient.Builder) target; + builder.addInterceptor(new NetWorkInterceptor()); + } + return joinPoint.proceed(); +} + + +插桩的方法看起来很好,但是并不全面。如果使用的不是系统和OkHttp网络库,又或者使用了Native代码的网络请求,都无法监控到。 + +第二种方法:Native Hook。 + +跟I/O监控一样,这个时候我们想到了强大的Native Hook。网络相关的我们一般会Hook下面几个方法 : + + +连接相关:connect。 + +发送数据相关:send和sendto。 + +接收数据相关:recv和recvfrom。 + + +Android在不同版本Socket的逻辑会有那么一些差异,以Android 7.0为例,Socket建连的堆栈如下: + +java.net.PlainSocketImpl.socketConnect(Native Method) +java.net.AbstractPlainSocketImpl.doConnect +java.net.AbstractPlainSocketImpl.connectToAddress +java.net.AbstractPlainSocketImpl.connect +java.net.SocksSocketImpl.connect +java.net.Socket.connect +com.android.okhttp.internal.Platform.connectSocket +com.android.okhttp.Connection.connectSocket +com.android.okhttp.Connection.connect + + +“socketConnect”方法对应的Native方法定义在PlainSocketImpl.c,查看makefile可以知道它们会编译在libopenjdk.so中。不过在Android 8.0,整个调用流程又完全改变了。为了兼容性考虑,我们直接PLT Hook内存的所有so,但是需要排除掉Socket函数本身所在的libc.so。 + +hook_plt_method_all_lib("libc.so", "connect", (hook_func) &create_hook); +hook_plt_method_all_lib("libc.so, "send", (hook_func) &send_hook); +hook_plt_method_all_lib("libc.so", "recvfrom", (hook_func) &recvfrom_hook); +... + + +这种做法不好的地方在于会把系统的Local Socket也同时接管了,需要在代码中增加过滤条件。在今天的Sample中,我给你提供了一套简单的实现。其实无论是哪一种Hook,如果熟练掌握之后你会发现它并不困难。我们需要耐心地寻找,梳理清楚整个调用流程。 + +第三种方法:统一网络库。 + +尽管拿到了所有的网络调用,想想会有哪些使用场景呢?模拟网络数据、统计应用流量,或者是单独代理WebView的网络请求。 + + + +一般来说,我们不会非常关心第三方的网络请求情况,而对于我们应用自身的网络请求,最好的监控方法还是统一网络库。不过我们可以通过插桩和Hook这两个方法,监控应用中有哪些地方使用了其他的网络库,而不是默认的统一网络库。 + +在上一期内容中,我说过“网络质量监控”应该是客户端网络库中一个非常重要的模块,它也会跟大网络平台的接入服务共同协作。通过统一网络库的方式,的确无法监控到第三方的网络请求。不过我们可以通过其他方式拿到应用的整体流量使用情况,下面我们一起来看看。 + +2. 如何监控流量 + +应用流量监控的方法非常简单,一般通过TrafficStats类。TrafficState是Android API 8加入的接口,用于获取整个手机或者某个UID从开机算起的网络流量。至于如何使用,你可以参考Facebook一个历史比较久远的开源库network-connection-class。 + +getMobileRxBytes() //从开机开始Mobile网络接收的字节总数,不包括Wifi +getTotalRxBytes() //从开机开始所有网络接收的字节总数,包括Wifi +getMobileTxBytes() //从开机开始Mobile网络发送的字节总数,不包括Wifi +getTotalTxBytes() //从开机开始所有网络发送的字节总数,包括Wifi + + +它的实现原理其实也非常简单,就是利用Linux内核的统计接口。具体来说,是下面两个proc接口。 + +// stats接口提供各个uid在各个网络接口(wlan0, ppp0等)的流量信息 +/proc/net/xt_qtaguid/stats +// iface_stat_fmt接口提供各个接口的汇总流量信息 +proc/net/xt_qtaguid/iface_stat_fmt + + +TrafficStats的工作原理是读取proc,并将目标UID下面所有网络接口的流量相加。但如果我们不使用TrafficStats接口,而是自己解析proc文件呢?那我们可以得到不同网络接口下的流量,从而计算出WiFi、2G/3G/4G、VPN、热点共享、WiFi P2P等不同网络状态下的流量。 + +不过非常遗憾的是,Android 7.0之后系统已经不让我们直接去读取stats文件,防止开发者可以拿到其他应用的流量信息,因此只能通过TrafficStats拿到自己应用的流量信息。 + +除了流量信息,通过/proc/net我们还可以拿到大量网络相关的信息,例如网络信号强度、电平强度等。Android手机跟iPhone都有一个网络测试模式,感兴趣的同学可以尝试一下。 + + +iPhone:打开拨号界面,输入“*3001#12345#*”,然后按拨号键。 + +Android手机:打开拨号界面,输入“*#*#4636#*#*”,然后按拨号键(可进入工程测试模式,部分版本可能不支持)。 + + + + +为什么系统可以判断此时的WiFi“已连接,但无法访问互联网”?回想一下专栏第15期我给你留的课后作业: + + +iPhone的无线网络助理、小米和一加的自适应WLAN它们在检测WiFi不稳定时会自动切换到移动网络。那请你思考一下,它们是如何实现侦测,如何区分是应用后台服务器出问题还是WiFi本身有问题呢? + + +我看了一下同学们的回复,大部分同学认为需要访问一个公网IP的方式。其实对于手机厂商来说根据不需要,它在底层可以拿到的信息有很多。 + + +网卡驱动层信息。如射频参数,可以用来判断WiFi的信号强度;网卡数据包队列长度,可以用来判断网络是否拥塞。 + +协议栈信息。主要是获取数据包发送、接收、时延和丢包等信息。 + + +如果一个WiFi发送过数据包,但是没有收到任何的ACK回包,这个时候就可以初步判断当前的WiFi是有问题的。这样系统可以知道当前WiFi大概率是有问题的,它并不关心是不是因为我们后台服务器出问题导致的。 + +大网络平台监控 + +前面我讲了一些应用网络请求和流量的监控方法,但是还没真正回答应该如何去打造一套强大的网络监控体系。跟网络优化一样,网络监控不是客户端可以单独完成的,它也是整个大网络平台的一个重要组成部分。 + +不过首先我们需要在客观上承认这件事情做起来并不容易,因为网络问题会存在下面这些特点: + + +实时性。部分网络问题过时不候,可能很快就丢失现场。 + +复杂性。可能跟国家、地区、运营商、版本、系统、机型、CDN都有关,不仅维度多,数据量也巨大。 + +链路长。整个请求链条非常长,客户端故障、网链障络、服务故障都有可能。 + + +因此所谓的网络监控,并不能保证可以明确找到故障的原因。而我们目标是希望快速发现问题,尽可能拿到更多的辅助信息,协助我们更容易地排查问题。 + +下面我分别从客户端与接入层的角度出发,一起来看看哪些信息可以帮助我们更好地发现问题和解决问题。 + +1. 客户端监控 + +客户端的监控使用统网络库的方式,你可以想想我们需要关心哪些内容: + + +时延。一般我们比较关心每次请求的DNS时间、建连时间、首包时间、总时间等,会有类似1秒快开率、2秒快开率这些指标。 + +维度。网络类型、国家、省份、城市、运营商、系统、客户端版本、机型、请求域名等,这些维度主要用于分析问题。 + +错误。DNS失败、连接失败、超时、返回错误码等,会有DNS失败率、连接失败率、网络访问的失败率这些指标。 + + +通过这些数据,我们也可以汇总出应用的网络访问大图。例如在国内无论我们去到哪里都会问有没有WiFi,WiFi的占比会超过50%。这其实远远比海外高,在印度WiFi的占比仅仅只有15%左右。 + + + +同样的我们分版本、分国家、分运营商、分域名等各种各样的维度,来监控我们的时延和错误这些访问指标。 + +由于维度太多,每个维度的取值范围也很广,如果是实时计算整个数据量会非常非常大。对于客户端的上报数据,微信可以做到分钟级别的监控报警。不过为了运算简单我们会抛弃UV,只计算每一分钟部分维度的PV。 + +2. 接入层监控 + +客户端监控的数据会比接入层更加丰富,因为有可能会出现部分数据还没到达接入层就已经被打回,例如运营商劫持的情况。 + + + +但是接入层的数据监控还是非常有必要的,主要的原因是: + + +实时性。客户端如果使用秒级的实时上报,对用户性能影响会比较大。服务端就不会存在这个问题,它很容易可以做到秒级的监控。 + +可靠性。如果出现某些网络问题,客户端的数据上报通道可能也会受到影响,客户端的数据不完全可靠。 + + +那接入层应该关心哪些数据呢?一般来说,我们会比较关心服务的入口和出口流量、服务端的处理时延、错误率等。 + +3. 监控报警 + +无论是客户端还是接入层的监控,它们都是分层的。 + + +实时监控。秒级或者分钟级别的实时监控的信息会相比少一些,例如只有访问量(PV)、错误率,没有去拆分几百个上千个维度,也没有独立访问用户数(UV),实时监控的目的是最快速度发现问题。 + +离线监控。小时或者天级别的监控我们可以拓展出全部的维度来做监控,它的目的是在监控的同时,可以更好地圈出问题的范围。 + + +下面是一个简单根据客户端、国家以及运营商维度分析的示例。当然更多的时候是某一个服务出现问题,这个时候通过分域名或者错误码就可以很容易的找到原因。 + + + +那在监控的同时如何实现准确的自动化报警呢?这同样也是业界的一个难题,它的难度在于如果规则过于苛刻,可能会出现漏报;如果过于宽松,可能会出现太多的误报。 + +业界一般存在两种报警的算法,一套是基于规则,例如失败率与历史数据相比暴涨、流量暴跌等。另一种是基于时间序列算法或者神经网络的智能化报警,使用者不需要录入任何规则,只需有足够长的历史数据,就可以实现自动报警。智能化报警目前准确性也存在一些问题,在智能化基础上面添加少量规则可能会是更好的选择。 + +如果我们收到一个线上的网络报警,通过接入层和客户端的监控报表,也会有了一个大致的判断。那怎么样才能确定问题的最终原因?我们是否可以拿到用户完整的网络日志?甚至远程地诊断用户的网络情况?关于“网络日志和远程诊断,如何快速定位网络问题”,我会把它单独成篇放在专栏第二模块里,再来讲讲这个话题。 + +总结 + +监控、监控又是监控,很多性能优化工作其实都是“三分靠优化,七分靠监控”。 + +为什么监控这么重要呢?对于大公司来说,每一个项目参与人员可能成百上千人。并且大公司要的不是今天或者这个版本可以做好一些事情,而是希望保证每天每个版本都能持续保持应用的高质量。另一方面有了完善的分析和监控的平台,我们可以把复杂的事情简单化,把一些看起来“高不可攀”的优化工作,变成人人都可以做。 + +最后多谈两句我的感受,我们在工作的时候,希望你可以看得更远,从更高的角度去思考问题。多想想如果我能做好这件事情,怎么保证其他人不会犯错,或者让所有人都可以做得更好。 + +课后作业 + +对于网络问题,你尝试过哪些监控方法?有没有令你印象深刻的网络故障,最终又是通过什么方式解决的呢?欢迎留言跟我和其他同学一起讨论。 + +今天我们练习的Sample是通过PLT Hook,代理Socket相关的几个重要函数,这次还增加了一个一次性Hook所有已经加载Library的方法。 + +int hook_plt_method_all_lib(const char* exclueLibname, const char* name, hook_func hook) { + if (refresh_shared_libs()) { + // Could not properly refresh the cache of shared library data + return -1; + } + int failures = 0; + for (auto const& lib : allSharedLibs()) { + if (strcmp(lib.first.c_str(), exclueLibname) != 0) { + failures += hook_plt_method(lib.first.c_str(), name, hook); + } + } + return failures; +} + + +希望你通过这几次课后练习,可以学会将Hook技术应用到实践当中。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/18耗电优化(上):从电量优化的演进看耗电分析.md b/专栏/Android开发高手课/18耗电优化(上):从电量优化的演进看耗电分析.md new file mode 100644 index 0000000..7710563 --- /dev/null +++ b/专栏/Android开发高手课/18耗电优化(上):从电量优化的演进看耗电分析.md @@ -0,0 +1,204 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 耗电优化(上):从电量优化的演进看耗电分析 + 曾经有一句笑话说的是“用Android手机的男人一定是个好男人,因为他每天必须回家充电,有时候还得1天2次”。 + +我们现在工作和生活都离不开手机,但是却很难找到一款可以完全信赖、可以使用一整天的手机。在十年前的功能机时代,诺基亚可以做到十几天的超长待机。而现在的智能机时代,7nm的CPU、8GB内存、512GB的闪存,硬件一直在飞速发展,为什么电池的发展就不适用摩尔定律,电池技术一直没有突破性的进展呢? + +功耗是手机厂商一直都非常重视的,OPPO更是直接以“充电5分钟,通话2小时”作为卖点。省电优化也是每年Google I/O必讲的内容,那么Android系统都为省电做了哪些努力呢?我们可以怎么样衡量应用的耗电呢? + +耗电的背景知识 + +回顾一下专栏前面的内容,我已经讲过内存、CPU、存储和网络这几块内容了。LPDDR5内存、7nm CPU、UFS 3.0闪存、5G芯片,硬件一直以“更快、更小”的目标向前飞速发展。 + +但是手机上有一个重要部件多年来都没有革命性的突破,被我们吐槽也最多,那就是电池。智能手机的发展就像木桶原理一样,扼住智能手机发展咽喉的终究还是电池。 + +电池技术有哪些重要的评判标准?电池技术这些年究竟又有哪些进展?下面我们一起来聊聊手机电池的知识。 + +1. 电池技术 + +我们先看看苹果和华为这两大巨头最新旗舰机的表现。苹果的iPhone XS Max内置锂离子充电电池,电池容量为3174mAh,30分钟最多可充至50%电量。 + +华为Mate 20 Pro升级到4200mAh高度大容量锂离子电池,并首次搭载40W华为超级快充技术,30分钟充电约70%,还有15W高功率无线快充和反向无线充电“黑科技”。而Mate 20 X更是把电池容量升级到5000mAh,还创造性地将石墨烯技术应用到智能手机中。 + + + +从上面两款旗舰机的电池介绍中,我们可以发现手机电池的一些关键指标。 + + +电池容量。更大的电池容量意味着更长的续航时间,我们可以通过增加电池的体积或者密度来达到这个效果。智能手机的大部分空间都贡献给电池了,以华为Mate 20为例,电池占了所有内部组件中48%的空间,电池容量制约了手机迈向更轻、更薄。 + +充电时间。如果电池容量不是那么容易突破,那只能曲线救国考虑如何用更短的时间把电池充满。这里就需要依靠快充技术了,OPPO“充电5分钟,通话2小时”指的是VOOC闪充技术。快充技术无非是增大电流或者电压,目前主要分为两大解决方案,一个是高压低电流快充方案,另一个是低压大电流快充方案。关于快充技术的盘点,你可以参考这篇文章。 + +寿命。电池寿命一般使用充电循环次数来衡量,一次充电循环表示充满电池全部电量,但是并不要求一次性完成。例如在之前电池充到了25%,如果再充75%,两次组合在一起算是一次充电周期。去年苹果因为“降速门”面临了多起诉讼,通过处理器限速来解决续航不足的问题。根据苹果官方数据,500次充电循环iPhone电池剩余容量为原来的80%。 + +安全性。手机作为用户随时携带的物品,安全性才是首要考虑的因素。特别是从三星Note 7爆炸以来,各大手机厂商都在电池容量方面更加保守。所以无论是电池的密度,还是快充技术,我们首要保证的都是安全性。 + + +从历史久远的镍铬、镍氢,到现在普遍使用的锂离子电池,还是被称为革命性技术的石墨烯电池,虽然达不到摩尔定律,但电池技术其实也在不停地发展,感兴趣的同学可以参考《手机电池技术进步》。 + +事实上Mate 20 X只是使用石墨烯技术用于散热系统,并不是真正意义上的石墨烯电池。根据最新的研究成果表明,使用石墨烯材料可以让电池容量增加45%,充电速度可以加快5倍,循环寿命更高达3500次左右。可能在未来,12分钟就能把我们的手机电池充满,如果能够实现普及的话,将是电池发展史上的一个重要里程碑。 + +2. 电量和硬件 + +1000mAh的功能机我们可以使用好几天,为什么5000mAh的智能机我们需要每天充电?这是因为我们现在的手机需要视频通话,需要打“王者”“吃鸡”,硬件设备的种类和性能早就不可同日而语。 + +但是“王者”“吃鸡”等应用程序不会直接去消耗电池,而是通过使用硬件模块消耗相应的电能,下图是手机中一些比较耗电的硬件模块。 + + + +CPU、屏幕、WiFi和数据网络、GPS以及音视频通话都是我们日常的耗电大户。坦白说,智能手机硬件的飞速提升,许多其实都是厂商叫卖的噱头。绝大部分硬件对于我们来说都已经处于性能过剩的状态,但多余的性能同时也在消耗电量。 + +所以资源调度机制是厂商功耗优化最重要的手段,例如在卡顿优化的时候我就讲过,CPU芯片会分大小核架构,会灵活地为不同任务分配相应的运算资源。手机基带、GPS这些模块在不使用时也会进入低功耗或者休眠模式,达到降低功耗的目的。 + +现在越来越多厂商利用深度学习的本地AI来优化资源的调度,对GPU、运行内存等资源进行合理分配,确保可以全面降低耗电量。厂商需要在高性能跟电量续航之间寻找一个平衡点,有的厂商可能倾向于用户有更好的性能,有的厂商会倾向于更长的续航。 + +功耗的确非常重要,我做手机预装项目时,发现厂商会对耗电有非常严格的规定,这也让我对功耗的认识更深刻了。但是为了为了保证头部应用能有更好的体验,厂商愿意给它们分配更多的资源。所以出现了高通的CPU Boost、微信的Hardcode以及各个厂商的合作通道。 + +但是反过来问一句,为什么厂商只把微信和QQ放到后台白名单,但没有把淘宝、支付宝、抖音等其他头部应用也一起加入呢?根据我的猜测,耗电可能是其中一个比较重要的因素。 + +3. 电量和应用程序 + +各个硬件模块都会耗电,而且不同的硬件耗电量也不太一样,那我们如何评估不同应用程序的耗电情况呢? + + + +根据物理学的知识,电能的计算公式为 + +电能 = 电压 * 电流 * 时间 + + +对于手机来说电压一般不会改变,例如华为Mate 20的恒定电压是3.82V。所以在电压恒定的前提下,只需要测量电流和时间就可以确定耗电。 + +最终不同模块的耗电情况可以通过下面的这个公式计算: + +模块电量(mAh) = 模块电流(mA) * 模块耗时(h) + + +模块耗时比较容易理解,但是模块电流应该怎样去获取呢?Android系统要求不同的厂商必须在 /frameworks/base/core/res/res/xml/power_profile.xml 中提供组件的电源配置文件。 + +power_profiler.xml文件定义了不同模块的电流消耗值以及该模块在一段时间内大概消耗的电量,你也可以参考Android Developer文档《Android 电源配置文件》。当然电流的大小和模块的状态也有关系,例如屏幕在不同亮度时的电流肯定会不一样。 + + + +Android系统的电量计算PowerProfile也是通过读取power_profile.xml的数值而已,不同的厂商具体的数值都不太一样,我们可以通过下面的方法获取: + + +从手机中导出/system/framework/framework-res.apk文件。 + +使用反编译工具(如apktool)对导出文件framework-res.apk进行反编译。 + +查看power_profile.xml文件在framework-res反编译目录路径:/res/xml/power_profile.xml。 + + +对于系统的电量消耗情况,我们可以通过dumpsys batterystats导出。 + +adb shell dumpsys batterystats > battery.txt +// 各个Uid的总耗电量,而且是粗略的电量计算估计。 +Estimated power use (mAh): + Capacity: 3450, Computed drain: 501, actual drain: 552-587 + ... + Idle: 41.8 + Uid 0: 135 ( cpu=103 wake=31.5 wifi=0.346 ) + Uid u0a208: 17.8 ( cpu=17.7 wake=0.00460 wifi=0.0901 ) + Uid u0a65: 17.5 ( cpu=12.7 wake=4.11 wifi=0.436 gps=0.309 ) + ... + +// reset电量统计 +adb shell dumpsys batterystats --reset + + +BatteryStatsService是对外的电量统计服务,但具体的统计工作是由BatteryStatsImpl来完成的,而BatteryStatsImpl内部使用的就是PowerProfile。BatteryStatsImpl会为每一个应用创建一个UID实例来监控应用的系统资源使用情况,统计的系统资源包括下面图里的内容。 + + + +电量的使用也会跟环境有关,例如在零下十度的冬天电量会消耗得更快一些,系统提供的电量测量方法只是提供一个参考的数值。不过通过上面的这个方法,我们可以成功把电量的测量转化为功能模块的使用时间或者次数。 + +准确的测量电量并不是那么容易,在《大众点评App的短视频耗电量优化实战》一文中,为我们总结了下面几种电量测试的方法。 + + + +当测试或者其他人反馈耗电问题时,bug report结合Battery Historian是最好的排查方法。 + +//7.0和7.0以后 +$ adb bugreport bugreport.zip +//6.0和6.0之前: +$ adb bugreport > bugreport.txt +//通过historian图形化展示结果 +python historian.py -a bugreport.txt > battery.html + + +Android耗电的演进历程 + +虽然iPhone XS Max电池容量只有3174mAh,远远低于大部分Android的旗舰机,但是很多时候我们发现它的续航能力会优于大部分的Android手机。 + +仔细想想这个问题就会发现,Android是基于Linux内核,而Linux大部分使用在服务器中,它对功耗并没有做非常严格苛刻的优化。特别是国内会有各种各样的“保活黑科技”,大量的应用在后台活动简直就是“电量黑洞”。 + +那Android为了电量优化都做了哪些努力呢?Google I/O每年都会单独讲解耗电优化,下面我们一起来看看Android在耗电方面都做了哪些改变。 + +1. 野蛮生长:Pre Android 5.0 + +在Android 5.0之前,系统并不是那么完善,对于电量优化相对还是比较少的。特别没有对应用的后台做严格的限制,多进程、fork native进程以及广播拉起等各种保活流行了起来。 + +用户手机用电如流水,会明显感受到下面几个问题: + + +耗电与安装应用程序的数量有关。用户安装越多的应用程序,无论是否打开它们,手机耗电都会更快。 + +App耗电量与App使用时间无关。用户希望App的耗电量应该与它的使用时间相关,但是有些应用即使常年不打开,依然非常耗电。 + +电量问题排查复杂。无论是电量的测量,还是耗电问题的排查都异常艰难。 + + +当然在Android 5.0之前,系统也有尝试做一些省电相关的优化措施。 + + + +2. 逐步收紧:Android 5.0~Android 8.0 + +Android 5.0专门开启了一个Volta项目,目标是改善电池的续航。在优化电量的同时,还增加了的dumpsys batteryst等工具生成设备电池使用情况统计数据。 + + + +从Android 6.0开始,Google开始着手清理后台应用和广播来进一步优化省电。在这个阶段还存在以下几个问题: + + +省电模式不够省电。Doze低功耗模式限制得不够严格,例如屏幕关闭还可以获取位置、后台应用的网络权限等。 + +用户对应用控制力度不够。用户不能简单的对某些应用做更加细致的电量和后台行为的控制,但是其实国内很多的厂商已经提前实现了这个功能。 + +Target API开发者响应不积极。为了不受新版本的某些限制,大部分国内的应用坚持不把Target API升级到Oreo以上,所以很多省电的功能事实上并没有生效。 + + +3. 最严限制:Android 9.0 + +我在Android 9.0刚出来的时候,正常使用了一天手机,在通知栏竟然弹出了下面这样一个提示:微信正在后台严重耗电。 + + + +尽管经过几个版本的优化,Android的续航问题依然没有根本性的改善。但你可以看到的是,从Android 9.0开始,Google对电源管理引入了几个更加严格的限制。 + + + +通过应用待机分组功能,我们可以确保应用使用的电量和它们的使用时间成正比,而不是和手机上安装的应用数量成正比。对于不常用的应用,它们可以“作恶”的可能性更小了。通过省电模式和应用后台限制,用户可以知道哪些应用是耗电的应用,我们也可以对它们做更加严格的限制。 + +另一方面,无论是Google Play还是国内的Android绿色联盟,都要求应用在一年内更新到最新版本的Target API。电池续航始终是Android的生命线,我相信今年的Android Q也会推出更多的优化措施。 + +总结 + +今天我讲了应用程序、Android系统、手机硬件与电池之间的关系,也回顾了Android耗电优化的演进历程。那落实到具体工作时,我们应该如何去做耗电优化呢?下一期我们来解决这个问题。 + +在讲内存、CPU、存储和网络这些知识的时候,我都会讲一些硬件相关的知识。主要是希望帮你建立一套从应用层到操作系统,再到硬件的整体认知。当你的脑海里面有一套完整的知识图谱时,才能更得心应手地解决一些疑难问题,进而可以做好对应的性能优化工作。 + +课后作业 + +今天的课后作业是,在日常的开发过程中,你遇到过哪些耗电问题?遇到这些问题的时候,你一般通过哪些手段去定位和修复呢?欢迎留言跟我和其他同学一起讨论。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file diff --git a/专栏/Android开发高手课/19耗电优化(下):耗电的优化方法与线上监控.md b/专栏/Android开发高手课/19耗电优化(下):耗电的优化方法与线上监控.md new file mode 100644 index 0000000..7a4369a --- /dev/null +++ b/专栏/Android开发高手课/19耗电优化(下):耗电的优化方法与线上监控.md @@ -0,0 +1,245 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 耗电优化(下):耗电的优化方法与线上监控 + 相比启动、卡顿、内存和网络的优化来说,可能大多数应用对耗电优化的关注不是太多。当然并不是我们不想做耗电优化,更多时候是感觉有些无从下手。 + +不同于启动时间、卡顿率,耗电在线上一直缺乏一个可以量化的指标。Android系统通过计算获得的应用耗电数据只是一个估算值,从Android 4.4开始,连这个估算值也无法拿到了。当有用户投诉我们应用耗电的时候,我们一般也无所适从,不知道该如何定位、如何分析。 + +耗电优化究竟需要做哪些工作?我们如何快速定位代码中的不合理调用,并且持续监控应用的耗电情况呢?今天我们就一起来学习耗电的优化方法和线上监控方案。 + +耗电优化 + +在开始讲如何做耗电优化之前,你需要先明确什么是耗电优化,做这件事情的目的究竟是什么。 + +1. 什么是耗电优化 + +有些同学可能会疑惑,所谓的耗电优化不就是减少应用的耗电,增加用户的续航时间吗?但是落到实践中,如果我们的应用需要播放视频、需要获取GPS信息、需要拍照,这些耗电看起来是无法避免的。 + +如何判断哪些耗电是可以避免,或者是需要去优化的呢?你可以看下面这张图,当用户去看耗电排行榜的时候,发现“王者荣耀”使用了7个多小时,这时用户对“王者荣耀”的耗电是有预期的。 + + + +假设这个时候发现某个应用他根本没怎么使用(前台时间很少),但是耗电却非常多。这种情况会跟用户的预期差别很大,他可能就会想去投诉。 + +所以耗电优化的第一个方向是优化应用的后台耗电。知道了系统是如何计算耗电的,那反过来看,我们也就可以知道应用在后台不应该做什么,例如长时间获取WakeLock、WiFi和蓝牙的扫描等。为什么说耗电优化第一个方向就是优化应用后台耗电,因为大部分厂商预装项目要求最严格的正是应用后台待机耗电。 + + + +当然前台耗电我们不会完全不管,但是标准会放松很多。你再来看看下面这张图,如果系统对你的应用弹出这个对话框,可能对于微信来说,用户还可以忍受,但是对其他大多数的应用来说,可能很多用户就直接把你加入到后台限制的名单中了。 + + + +耗电优化的第二个方向是符合系统的规则,让系统认为你耗电是正常的。而Android P是通过Android Vitals监控后台耗电,所以我们需要符合Android Vitals的规则,目前它的具体规则如下: + + + +虽然上面的标准可能随时会改变,但是可以看到,Android系统目前比较关心后台Alarm唤醒、后台网络、后台WiFi扫描以及部分长时间WakeLock阻止系统后台休眠。 + +2. 耗电优化的难点 + +既然已经明确了耗电优化的目的和方向,那我们就开始动手吧。但我想说的是,只有当你跳进去的时候,才能发现耗电优化这个坑有多深。它主要有下面几个问题: + + +缺乏现场,无法复现。用户上传某个截图,你的应用耗电占比30%。通过电量的详细使用情况,我们可能会有一些猜测。但是用户也无法给出更丰富的信息,以及具体是在什么场景发生的,可以说是毫无头绪。 + + + + + +信息不全,难以定位。如果是开发人员或者厂商可以提供bug report,利用Battery Historian可以得到非常全的耗电统计信息。但是Battery Historian缺失了最重要的堆栈信息,代码调用那么复杂,可能还有很多的第三方SDK,我们根本不知道是哪一行代码申请了WakeLock、使用了Sensor、调用了网络等。 + + + + + +无法评估结果。通过猜测,我们可能会尝试一些解决方案。但是从Android 4.4开始,我们无法拿到应用的耗电信息。尽管我们解决了某个耗电问题,也很难去评估它是否已经生效,以及对用户产生的价值有多大。 + + +3. 耗电优化的方法 + +无法复现、难以定位,也无法评估结果,耗电优化之路实在是不容易。在真正去做优化之前,先来看看我们的应用为什么需要在后台耗电? + +大部分的开发者不是为了“报复社会”,故意去浪费用户的电量,主要可能有以下一些原因: + + +某个需求场景。最普遍的场景就是推送,为了实现推送我们只能做各种各样的保活。在需求面前,用户的价值可能被排到第二位。 + +代码的Bug。因为某些逻辑考虑不周,可能导致GPS没有关闭、WakeLock没有释放。 + + +所以相反地,耗电优化的思路也非常简单。 + + +找到需求场景的替代方案。以推送为例,我们是否可以更多地利用厂商通道,或者定时的拉取最新消息这种模式。如果真是迫不得已,是不是可以使用foreground service或者引导用户加入白名单。后台任务的总体指导思想是减少、延迟和合并,可以参考微信一个小伙写的《Android后台调度任务与省电》。在后台运行某个任务之前,我们都需要经过下面的思考: + + + + + +符合Android规则。首先系统的大部分耗电监控,都是在手机在没有充电的时候。我们可以选择在用户充电时才去做一些耗电的工作,具体方法可查看官方文档《监控电池电量和充电状态》。其次是尽早适配最新的Target API,因为高版本系统后台限制本来就非常严格,应用在后台耗电本身就变得比较困难了。 + + +IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); +Intent batteryStatus = context.registerReceiver(null, ifilter); + +//获取用户是否在充电的状态或者已经充满电了 +int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); +boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL; + + + +异常情况监控。即使是最严格的Android P,系统也会允许应用部分地使用后台网络、Alarm以及JobSheduler事件(不同的分组,限制次数不同)。因此出现异常情况的可能性还是存在的,更不用说低版本的系统。对于异常的情况,我们需要类似Android Vitals电量监控一样,将规则抽象出来,并且增加上更多辅助我们定位问题的信息。 + + +耗电监控 + +在I/O监控中,我指定了重复I/O、主线程I/O、Buffer过大以及I/O泄漏这四个规则。对于耗电监控也是如此,我们首先需要抽象出具体的规则,然后收集尽量多的辅助信息,帮助问题的排查。 + +1. Android Vitals + +前面已经说过Android Vitals的几个关于电量的监控方案与规则,我们先复习一下。 + + +Alarm Manager wakeup 唤醒过多 + +频繁使用局部唤醒锁 + +后台网络使用量过高 + +后台WiFi scans过多 + + +在使用了一段时间之后,我发现它并不是那么好用。以Alarm wakeup为例,Vitals以每小时超过10次作为规则。由于这个规则无法做修改,很多时候我们可能希望针对不同的系统版本做更加细致的区分。 + +其次跟Battery Historian一样,我们只能拿到wakeup的标记的组件,拿不到申请的堆栈,也拿不到当时手机是否在充电、剩余电量等信息。 + + + +对于网络、WiFi scans以及WakeLock也是如此。虽然Vitals帮助我们缩小了排查的范围,但是依然需要在茫茫的代码中寻找对应的可疑代码。 + +2. 耗电监控都监控什么 + +Android Vitals并不是那么好用,而且对于国内的应用来说其实也根本无法使用。不管怎样,我们还是需要搭建自己的耗电监控系统。 + +那我们的耗电监控系统应该监控哪些内容,怎么样才能比Android Vitals做得更好呢? + + +监控信息。简单来说系统关心什么,我们就监控什么,而且应该以后台耗电监控为主。类似Alarm wakeup、WakeLock、WiFi scans、Network都是必须的,其他的可以根据应用的实际情况。如果是地图应用,后台获取GPS是被允许的;如果是计步器应用,后台获取Sensor也没有太大问题。 + +现场信息。监控系统希望可以获得完整的堆栈信息,比如哪一行代码发起了WiFi scans、哪一行代码申请了WakeLock等。还有当时手机是否在充电、手机的电量水平、应用前台和后台时间、CPU状态等一些信息也可以帮助我们排查某些问题。 + +提炼规则。最后我们需要将监控的内容抽象成规则,当然不同应用监控的事项或者参数都不太一样。 + + +由于每个应用的具体情况都不太一样,下面是一些可以用来参考的简单规则。 + + + +在安卓绿色联盟的会议中,华为公开过他们后台资源使用的“红线”,你也可以参考里面的一些规则: + + + +2. 如何监控耗电 + +明确了我们需要监控什么以及具体的规则之后,终于可以来到实现这个环节了。跟I/O监控、网络监控一样,我首先想到的还是Hook方案。 + +Java Hook + +Hook方案的好处在于使用者接入非常简单,不需要去修改自己的代码。下面我以几个比较常用的规则为例,看看如果使用Java Hook达到监控的目的。 + + +WakeLock。WakeLock用来阻止CPU、屏幕甚至是键盘的休眠。类似Alarm、JobService也会申请WakeLock来完成后台CPU操作。WakeLock的核心控制代码都在PowerManagerService中,实现的方法非常简单。 + + +// 代理PowerManagerService +ProxyHook().proxyHook(context.getSystemService(Context.POWER_SERVICE), "mService", this); + +@Override +public void beforeInvoke(Method method, Object[] args) { + // 申请Wakelock + if (method.getName().equals("acquireWakeLock")) { + if (isAppBackground()) { + // 应用后台逻辑,获取应用堆栈等等 + } else { + // 应用前台逻辑,获取应用堆栈等等 + } + // 释放Wakelock + } else if (method.getName().equals("releaseWakeLock")) { + // 释放的逻辑 + } +} + + + +Alarm。Alarm用来做一些定时的重复任务,它一共有四个类型,其中ELAPSED_REALTIME_WAKEUP和RTC_WAKEUP类型都会唤醒设备。同样,Alarm的核心控制逻辑都在AlarmManagerService中,实现如下: + + +// 代理AlarmManagerService +new ProxyHook().proxyHook(context.getSystemService +(Context.ALARM_SERVICE), "mService", this); + +public void beforeInvoke(Method method, Object[] args) { + // 设置Alarm + if (method.getName().equals("set")) { + // 不同版本参数类型的适配,获取应用堆栈等等 + // 清除Alarm + } else if (method.getName().equals("remove")) { + // 清除的逻辑 + } +} + + + +其他。对于后台CPU,我们可以使用卡顿监控学到的方法。对于后台网络,同样我们可以通过网络监控学到的方法。对于GPS监控,我们可以通过Hook代理LOCATION_SERVICE。对于Sensor,我们通过Hook SENSOR_SERVICE中的“mSensorListeners”,可以拿到部分信息。 + + +通过Hook,我们可以在申请资源的时候将堆栈信息保存起来。当我们触发某个规则上报问题的时候,可以将收集到的堆栈信息、电池是否充电、CPU信息、应用前后台时间等辅助信息也一起带上。 + +插桩 + +虽然使用Hook非常简单,但是某些规则可能不太容易找到合适的Hook点。而且在Android P之后,很多的Hook点都不支持了。 + +出于兼容性考虑,我首先想到的是写一个基础类,然后在统一的调用接口中增加监控逻辑。以WakeLock为例: + +public class WakelockMetrics { + // Wakelock 申请 + public void acquire(PowerManager.WakeLock wakelock) { + wakeLock.acquire(); + // 在这里增加Wakelock 申请监控逻辑 + } + // Wakelock 释放 + public void release(PowerManager.WakeLock wakelock, int flags) { + wakelock.release(); + // 在这里增加Wakelock 释放监控逻辑 + } +} + + +Facebook也有一个耗电监控的开源库Battery-Metrics,它监控的数据非常全,包括Alarm、WakeLock、Camera、CPU、Network等,而且也有收集电量充电状态、电量水平等信息。 + +Battery-Metrics只是提供了一系列的基础类,在实际使用中,接入者可能需要修改大量的源码。但对于一些第三方SDK或者后续增加的代码,我们可能就不太能保证可以监控到了。这些场景也就无法监控了,所以Facebook内部是使用插桩来动态替换。 + +遗憾的是,Facebook并没有开源它们内部的插桩具体实现方案。不过这实现起来其实并不困难,事实上在我们前面的Sample中,已经使用过ASM、Aspectj这两种插桩方案了。后面我也安排单独一期内容来讲不同插桩方案的实现。 + +插桩方案使用起来兼容性非常好,并且使用者也没有太大的接入成本。但是它并不是完美无缺的,对于系统的代码插桩方案是无法替换的,例如JobService申请PARTIAL_WAKE_LOCK的场景。 + +总结 + +从Android系统计算耗电的方法,我们知道了需要关注哪些模块的耗电。从Android耗电优化的演进历程,我们知道了Android在耗电优化的一些方向以及在意的点。从Android Vitals的耗电监控,我们知道了耗电优化的监控方式。 + +但是系统的方法不一定可以完全适合我们的应用,还是需要通过进一步阅读源码、思考,沉淀出一套我们自己的优化实践方案。这也是我的性能优化方法论,在其他的领域也是如此。 + +课后作业 + +在你的项目中,做过哪些耗电优化和监控的工作吗?你的实现方案是怎样的?欢迎留言跟我和其他同学一起讨论。 + +今天的课后练习是,按照文中的思路,使用Java Hook实现Alarm、WakeLock和GPS的耗电监控。具体的规则跟文中表格一致,请将完善后的代码通过Pull requests提交到Chapter19中。 + +欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。 + + + + \ No newline at end of file