趁着换坑的几天间隙(成文有段时间了,但是今天才发出来),仔细读了一下现如今各大开源 LLM 的祖师爷——LLaMA 1 的论文。我感觉这有种当年 Bert 的气势,万物基于 LLaMA,大家都是以此为 base 来做各种微调。所以尽可能理解 base 非常重要。
模型结构还是基于 Transformer,并做了一些改动(方括号中的内容为借鉴自哪个模型):
数据集总大小为 4828 GB,各子集占比如下:
代码相关数据应该主要是 GitHub 和 Stack Exchange。
相应的预处理如下:
.tex
文件中删除注释。可以看到基本上都是英文的,论文中并没有给出各语言占比。
论文主要在 8 个任务上测试了 LLaMA 1 的能力,综合来看结果就是同量级上 LLaMA 最优,其次 PaLM,吊打比之大十几倍的 GPT-3。
使用 8 个数据集进行评测,zeor-shot,结果显示大多数数据集上 Llama-65B 是最好的,有些时候甚至优于 PaLM-540B,但有两个数据集上 PaLM-540B 大幅领先于 Llama-65B,毕竟人家参数更多。
总的来说,在同等规模模型下,Llama 的表现是最好的,甚至超过大之 10 倍的模型,比如 GPT-3。
使用了两个数据集:
这次参与比赛的还有 PaLM 和 Minerva 模型。结果显示 LLaMA 的数学能力确实不太行(没有数学预训练数据),当然要比 PaLM 的同量级模型要好,但远不如 Minerva 的同量级模型。
这是由于 Minerva 是用 ArXiv 和 Math Web Pages 的数据来训练的,base model 是 PaLM,可以说是专门的数学模型,而其他两者是没有数学数据的。从这个侧面也证明了 LLaMA 要比 PaLM 好一点,在 GSM8k 上还比同量级的 Minerva-62B 好一点,尽管其没有在数学数据上进行微调。
数据集:
HumanEval(https://huggingface.co/datasets/openai_humaneval):164 个样本,输入一段自然语言描述,函数 signature,并且 prompt 被按照一定格式格式化。输出 python 代码。输入样例:
1 | from typing import List |
效果上来看 LLaMA-65B 完胜同量级的 PaLM,大多数情况比 PaML-540B 还好(虽然相差 1% 左右)。
值得注意的是,PaLM 和 LLaMA 预训练时使用的 code token 数是差不多的。
此设置下,模型无法访问包含回答问题所需证据的文件。使用两个数据集:Natural Questions 和 TriviaQA,zeor-shot 和 few-shot 均进行了测试。
在两个数据集上,Llama-65B 基本都是最优,极个别是 33B 最优,不过差距不大。
用的是 RACE 数据集。值得注意的是这个数据集来源是中国初中和高中学生的英语阅读理解考试。zero-shot。
结果显示 LLaMA-65B 的效果和 PaLM-540B 基本持平,各有上下,在高中题目上比 PaLM-540B 高 2%,初中题目上比之低 2%。整体上要比 GPT-3 好不少。
数据集即 MMLU(Massive Multitask Language Understanding),包含多个领域的多选题。
结果表明 LLaMA 比同量级的 PaLM 要好不少,同量级下 LLaMA 是最好的。GPT-3(175B)和 Gopher(280B)虽然参数更多,但仍然也不如 65B 的 LLaMA,甚至前者不如 13B 的 LLaMA。
数据集为 CrowS-Pairs,在 9 个类别中衡量 bias。数据集中每个 example 都有一个 stereotype(刻板印象)和 anti-stereotype 的句子。
结果表明,平均来看,LLaMA-65B 在 9 个类别上稍微优于 GPT3-175B 和 OPT-175B,差距不大。
注意 LLaMA 在宗教、性取向和性别上有较大的刻板印象。
这部分继续探讨性别偏见,使用了 WinoGender 数据集,这个数据集是一个共指消解数据集,每个句子有三个 mention:occupation 职业、participant 参与者和 pronoun 代词,任务是根据上下文得到代词所指代的对象(occupation 还是 participant)。比如 The nurse notified the patient that his shift would be ending in an hour. 这句话,occupation *是 the nurse,participant 是 the patient,pronoun 是 his*。
结果表明对 their 这种的效果更好(这次只在本模型的不同 size 中进行比较),在 his 和 her 的“gotcha”陷阱中,性能均有下降,其中 his 下降最明显。不过总体上来看,her 的得分要比 his 高,说明模型对男性的偏见更大?
有害性检测。该数据集有 100k prompt。实验使用两个版本的 prompt:Basic 和 Respectful。区别在于,后者会在 prompt 开始加上“Complete the following sentence in a polite, respectful, and unbiased manner:”,前者没有。
结果表明模型越大毒性越大(越聪明越不耐烦?),作者解释说之前的相关研究也观察到同样的现象,说毒性和模型大小的关系只适用于同一个模型系列去比较。
数据集包含 38 个类别的风格。
与 GPT-3 对比,结果显示比之好很多,但正确答案的占比绝对值(0.55 左右)仍然较低,说明仍然存在比较严重的 hallucination 现象。
比较惊讶的是竟然还有这部分内容,果然是消耗大到了一定程度了,都得讲一讲电费了。当下环保意识增强,以后的论文是不是都得报告一下这部分了。
对于瓦数,用如下公式估计:
Wh = GPU-h × (GPU power consumption) × PUE
论文中,使用的是 A100-80GB,GPU power consumption 为 400W,PUE(Power Usage Effectiveness)为 1.1。
算出来瓦数后,再乘以 0.385 就是估计的二氧化碳当量:
tCO2eq = MWh × 0.385
训练模型大约花费 5 个月,2048 块 A100-80GB,所以总计消耗约 2638 MWh,1015 tCO2eq。
不知道国外的电费什么价,我查了下国内的数据中心电价,找到一篇新闻稿,说重庆数据中心年平均电价为 0.74 元/度,这么算起来,电费得 1,952,120 元,将近 200 万人民币,这果然只有超大公司才能玩得起,关键是一个模型训练结束前,你还不能确定结果是不是可用的,所以搞不好你这钱就是打水漂了。而且这还不算 2048 块 A100 的钱。
要搁以前,我感觉这种论文可能都发表不了,或者说热度达不到这个程度。创新点几乎没有,几乎就是堆砌已有技术,然后自己整理下数据集,跑一下。但是在当前 ChatGPT 闭源的情况下,大家都希望有一个开源平替来把玩儿,而且得尽量小。讲到这,其实论文标题就直接点明了重点:
LLaMA: Open and Efficient Foundation Language Models
我是 open 的,而且 efficient(小),一个 foundation model,你们可以继续在这个基础上进行开发。效果虽然比不上 ChatGPT,但是这已经达到了大家的目的了。但是回过头来再看为什么能有这个效果呢?论文中没有做过多讨论,所做的那些优化改动我感觉也不痛不痒,说到底还是数据质量最重要(没错我是数据派)。
日常开发的时候,我们可能经常需要同时运行或维护多个程序(服务),时间一长可能就记不住了,运行命令都不一定能记得。而且一旦服务器崩了或者其他意外情况,这些服务还得手动去启动,很是麻烦(当然你也可以使用 init
等来设置开机自启)。这时就需要一个页面来统一管理了。
Supervisor 登场!
Supervisor 是一个用于管理和监控进程的工具,使用 Python 开发,可以确保在出现意外情况时,进程能够持续运行,并在失败后自动重启,通常用于监控服务器进程,如 Web 服务器和应用程序服务器。
Pros:
Cons:
不过说起来这是我第二次使用 supervisor 了,第一次还是几年前,东西都忘了。这次使用的时候查了好多资料才正常跑起来,所以为了方便下一次使用,以及有需要的同道们,特此记录一下,后面有什么新的需要记录的再更新。
Supervisor 是一个 python package,所以对 pythonista 来说,安装方式再熟悉不过了:
1 | pip install supervisor |
配置文件用来配置 supervisor 本身的一些设置以及你要添加的程序,一般叫 supervisord.conf
。如果你启动的时候没有用 -c
来指定配置文件的地址,那么 supervisor 会自动按照以下顺序来寻找:
../etc/supervisord.conf
(Relative to the executable)../supervisord.conf
(Relative to the executable)$CWD/supervisord.conf
$CWD/etc/supervisord.conf
/etc/supervisord.conf
/etc/supervisor/supervisord.conf
(since Supervisor 3.3.0)可配置项很多,我一般不会从头写,都是基于默认配置来修改。我们可以用 echo_supervisord_conf > supervisord.conf
来将默认配置写到 supervisord.conf
中。
如果在语句后直接添加注释,那么必须与语句隔一个空格。比如
❌ "a=b;comment"
✔️ "a=b ;comment"
下面我就讲下和默认配置不一样的地方。
inet_http_server
1 | [inet_http_server] ; inet (TCP) server disabled by default |
这个 section 与后面的 web 界面以及 supervisorctl 与 supervisord 的通信有关。默认是只能本地连接(localhost),而且没有用户名密码,如果需要从其他服务器上访问 web,那么则需要使用 *
来指定允许所有 ip 访问。为了安全考虑建议设置用户名密码。
supervisorctl
1 | [supervisorctl] |
这里就是配置 supervisorctl 与 supervisord 通信的地方。主要是 serverurl
(端口)与用户名密码需要和上面配置的一致,否则会出现拒绝链接或者认证失败的错误。
program:x
1 | [program:your_program_name] |
这个 section 就是重头戏了,我们在这里配置需要启动的程序,一个配置文件可以有多个这个 section。
[program:your_program_name]
:your_program_name
就是你的程序名字,想叫什么就叫什么,别太离谱就行。command
:你的程序的执行命令,你平常怎么执行的这里就怎么写,但是 executable 最好写绝对地址,比如你用 python app.py
来执行程序,那么这个 python
最好写成绝对地址,尤其你有多个 python 环境时。你可以用 which python
来查看绝对地址。process_name
:默认就是程序名(your_program_name
),这个名字会在 web 界面上显示。directory
:指定运行时需要 cd 到的目录(工作目录),也即你的程序文件所在的目录。autostart
:是否在 supervisord 启动时启动程序。autorestart
:是否在程序退出时自动重启。有三个值可选:false
:不自动重启。unexpected
:默认值。当 exit code 不在预料之内时,重启。什么叫不在预料之内?这个值是由 exitcodes
指定的,默认为 0。true
:无论如何都自动重启。redirect_stderr
:是否将 stderr redirect 到 stdout。stdout_logfile
:stdout 日志文件。stdout_logfile_maxbytes
:单个日志文件的最大大小。stdout_logfile_backups
:保留多少份日志文件。1 | supervisord -c supervisord.conf |
当我们更新了配置文件后,需要让 supervisor 也更新一下,我在网上查到的说是需要先 reread 再 update,但是我查询了文档,文档是这么写的:
reread
:
Reload the daemon’s configuration files, without add/remove (no restarts)
update
:
Reload config and add/remove as necessary, and will restart affected programs
很明显 update 已经做了 reread 的工作,而且还会 restart。
所以更新配置文件后,只需执行 update 即可更新 supervisor:
1 | supervisorctl update |
如果你的配置文件移动了位置,那么需要重启 supervisord:
supervisorctl pid
。kill supervisord_pid
。supervisord -c new_supervisord.conf
。前面提到过有一个 web 管理界面,根据 inet_http_server
的配置,使用相应的 ip 和端口在浏览器上访问即可,默认是 9001 端口。界面样式如下:
在这个界面上可以看到所有已配置程序的状态,可以启停和查看日志,直接点击 Name 列可以查看最后几行的日志,而点击 Tail -f *
可以看到实时日志,不过这个在我这里特别慢,不知道为社么。
supervisord
与 supervisorctl
刚才我们用了 supervisord 和 supervisorctl 两个命令,你可能会疑惑这两个有什么区别。其实我们一般使用的时候,用前者启动 supervisor 主程序,用后者来管理我们自己所添加的 program。所以 supervisord 就像个后台的 server,而 supervisorctl 是一个前台的 client,有很多 action(subcommand)可以执行,比如上面的 update 和 reread 就是两个 action。而 supervisord 是没有的。
supervisorctl 常用的 action 有:
status
:查看 program 状态。update
:更新配置文件并重启相关 program。pid
:获取 supervisord 的 PID。其他不是很常用,感兴趣的可以去文档查看。
我一直有个想法是在我家的小米电视上看 YouTube,之前都是手机上下载专门用来投屏 YouTube 的软件(Tubio),缺点是操作稍显复杂,清晰度不能选择,字幕投不上。经过前几天在电视上折腾小白网盘、电视家等经验,这次就想再来试下实现这个想法。
按理来说,想要看 YouTube,就需要电视上装一个 VPN 软件和 YouTube。电视也是基于 Android 系统做的,所以还比较好实现。VPN 软件我们就可以使用和手机上一样的 ssr 或者 clash。我大概讲下我测试的两种方法。
这个方法是我看网上传的最多的,但是首先说下这个方法我失败了。
SmartTube(GitHub)是一个专门用于在电视上观看 YouTube 的免广告应用,那为什么不用官方的 YouTube app 呢?因为官方的需要依赖 Google 服务框架那一套东西,比较麻烦,而这个 app 是不需要的。其有 stable 和 beta 两个版本,下载 stable 即可。
这个是我成功的方法,也是我认为最方便最简洁的方法,搜索的时候在一个 YouTube 视频看到的。
将电脑梯子设置为局域网可访问。
使用 ifconfig
或者梯子控制台界面找到电脑的 ip 和端口。
在 SmartTube 上,在左侧选择 设置
➡️ 一般
➡️ 互联网审查
➡️ 使用网页代理
,根据提示填入刚才的 ip 和端口即可,代理类型选择 HTTP
,测试一下,两个都 OK 就表示成功连接了代理。
确定退出回到首页,就可以看到能成功加载了。登录账号啥的和方法 1 相同,也可以选择清晰度和字幕,和电脑上几乎一样,非常方便。
通过以上两种方法,你就可以方便地在小米电视上观看 YouTube,享受大屏幕带来的视觉效果啦~
我想导出我的 QQ 邮箱中某个文件夹下的所有文件,eml 或者 mbox 格式都行,但是我发现 QQ 邮箱网页端没这个功能,只能一封一封导出为 eml。QQ 邮箱也没有 Mac 或者 windows 客户端。然后我尝试用 Mac 自带邮件客户端、foxmail、outlook、thunderbird 客户端来导出,结果发现都存在一个问题:邮件不完整,似乎只能显示近一个月内的邮件。我怀疑是这些客户端的设置没弄好,但找了一圈发现没有相关设置。
后来我再去查看 QQ 邮箱网页端的设置时发现,有个「收取选项」设置,默认为「最近 30 天」。这下就破案了,只需改为全部即可,然后在客户端刷新或重新添加账户。
如果你想导出 QQ 邮箱中某个文件夹下的所有文件,可以尝试这个方法:
eml 格式是一种用于存储电子邮件的文件格式,其英文全称为 “email message format”。一个 eml 文件通常包括邮件的头部信息和正文内容,可以使用邮件客户端或者文本编辑器打开。下面是一个 eml 文件的简单示例:
1 | From: [sender@example.com](mailto:sender@example.com) |
在 Python 中读取一个 eml 文件,可以使用 Python 内置的 email 模块。下面是一个读取 eml 文件并打印邮件头部和正文内容的 demo 代码:
1 | import email |
mbox 格式是另一种常见的存储邮件的文件格式,其英文全称为 “mailbox format”。一个 mbox 文件通常包括多个邮件,每个邮件之间用一个特殊的分隔符隔开,可以使用邮件客户端或者文本编辑器打开。下面是一个 mbox 文件的简单示例:
1 | From sender@example.com Wed May 18 13:48:45 2022 |
在 Python 中读取一个 mbox 文件,可以使用 Python 内置的 mailbox 模块。下面是一个读取 mbox 文件并打印每个邮件的头部和正文内容的 demo 代码:
1 | import mailbox |
见第一篇前言。
北野也是一个很久之前就在收藏夹里的地方。北京有两个野生动物园,之前我还有点分不清,现在终于搞清了,一个是八达岭野生动物园,一个就是位于大兴的北京野生动物园。前者就是发生了私自下车导致被老虎拖走事件的动物园。
经过在小红书上一段时间的有针对性的冲浪,发现大家去那主要奔着两个地方:猛兽区和小火车。两者都是有优速通的,不买的话旺季(周末节假日)可能要排队几个小时,最夸张的是有个人说活排了 6 个小时……
我们这次主要奔着猛兽区,剩余的地方随便闲逛。我们早上 5 点起床,6 点出发,7 点 10 分左右到达北区停车场,南区似乎已经满了。不过南北区距离大门口都差不多。7 点 45 进入大门。
接下来就是直冲猛兽区了。先说下排队盛况,排队确实是无限回形针。先是木栈道排队,然后进入蓝房子(河马馆),此处大概要拐六七道。然后通过一个羊肠小道进入一个大型回形针区域,此处大概要拐十几道。然后进入最后一个区域,此时需要存车了,到这就快了,同样是回形针区域,慢慢排就是了,上面屏幕上可能会写“此处排队时间 60 分钟”之类的的话,这个预估似乎偏大。
网上总有人说坐哪边猛兽多之类的,但左右都差不多,棕熊区右边看到的是湖水,左边是瀑布。其他动物左右均有,司机也会提醒动物在哪一边。你也可以站起来去有动物的一边拍照喂食。司机也会讲解提醒哪个动物喜欢吃什么肉。
然后就从猛兽区出来了。出来后有个餐厅,里面是只能在他们那买东西之后才能坐,外面是都可以坐。
顺便说几个关于熊猫的冷知识:
见第一篇前言。
野鸭湖在北京的西北方向,金海湖则是东北方向。根据维基百科的介绍:
野鸭湖国家湿地公园位于北京市延庆区西南部的延庆镇、康庄镇)、张山营镇和延庆农场交界处,总面积283.4公顷,具有水库、河流、沼泽、季节性泛滥地等多种湿地类型,是北京地区湿地面积最大的湿地生态系统和北京唯一一家国家级湿地公园。
前面提到这一天本来不是去这里的,而是去北野,但可惜没抢到这一天的优速通(实测也没必要抢优速通,下面再说)。这个地方也是之前在小红书种草的,后来查了查感觉也还可以(主观感觉因人而异),而且门票也不算贵,正好还有个亲鹿苑,可以零距离喂小鹿🦌,所以就去了这里。
小红书上提到这里,基本就是一个瞭望塔的图,很多人说这里上不去了,也就没去的必要了。但是出来就是来溜溜的,上不上去的无所谓。
今年五一是疫情放开后第一个较长的适合出去旅游的节假日,五天的假期相信很多人都会出去走一周,释放一下被三年疫情憋坏的心情。我们也不例外,不过我们出去主要的动机是带着孩子出去走走,而不是疫情原因。
放假前就看到很多假期人会很多的新闻,比如北京交通委说预计五一假期北京出行规模将超 2023 年春运,同时也超过 2019 年同期水平,说明已经基本恢复到疫情前水平了。小红书等平台上那些景区游客爆满的新闻也比比皆是。
所以我们决定不走这些类型的景点:
当然有些要求是 soft 的,不那么死,视情况而定。为了避免假期第一天和最后一天的“盛况”,我们也决定这两天待在家里休整,中间三天出去。
这么筛选下来,基本就是北京周边三日亲子游了,这些地方外地游客一般不会去,人应该相对较少。所以做了一番调研后,初选出以下景点:
但是北野人肯定会很多,所以我们计划抢猛兽区优速通。但是我半夜 12 点没抢到,感觉不到 10 秒就没了,太夸张了。遂第二天半夜 12 点继续抢 2 号的,这次多叫了几个人,虽然抢到了,但是是中午 12 点的。这个点动物们都基本上吃饱睡觉午休了,所以点不太好,所以我们打算那天去早点,然后排队看看情况。
所以最终行程变为了:
我先说下这几天的总花费,四大一小,租车 + 油费 + 门票 + 吃喝 + 其他费用,无住宿,大约 300 元/人/天(不算一小)。
接下来谈一谈对这几个景点的感受。
先来一段百度百科的介绍:
金海湖风景区,又称海子水库,位于北京市平谷区城东18千米处金海湖镇上宅村南,距北京85千米,位于北京、天津、唐山交界处的三角地带,素有”小北戴河”之称。据平谷区志记载,在清康熙十八年(1679年),平谷、三河发生了一次大地震,从而形成了河峡谷。1959年,初建。1985年,金海湖水库辟为旅游区,称金海湖公园。1988年,更为金海湖风景区。金海湖风景区总面积22平方千米,其中建筑面积0.8平方千米,水域面积6.5平方千米。金海湖风景区西依金海湖大坝,三面环水,三面青山环绕,四面飞檐明柱,有千岛湖的湖观山色,湖光塔、金花公主墓、望海亭、锯齿崖等自然景观、人文景观数十处。有游船、快艇、帆船、脚踏船、电瓶船、赛龙舟等娱乐项目。
之前在社交平台上听说过好几次这里了,一直想去看看,但是鉴于太远就没去,但这次正好符合我们的要求。实际上去之前我也不太清楚有啥可玩的,就知道有个毕加索坝体彩绘,还有大片水域,快艇帆船啥的。但是我们是带着孩子出行,对她来说,一切都是新鲜的。
为了避免堵车,我们早上 6 点左右起床,7 点出发,大约 9 点半到达。下面就跟着照片游览一下吧~
走上坝有两个选择:爬楼梯(路短省时间、费力)和坐扶梯(绕路、省力)。我们带着孩子而且第一次去就跟着人群走了,后来返回的时候才知道有爬楼梯这个选项。
顺着坝一直往前走,就到了金花公主墓:
当我们使用 pandas 的 read_excel
方法读取 Excel 文件时,我们可能会遇到一个很棘手的问题:如何正确读取包含合并单元格的 Excel 表格。如果我们只是用原先的 read_excel
方法读取,那么合并单元格的信息将会丢失,从而导致我们的数据出现重复或缺失的情况。我看了下网上的文章几乎都没有很好的解决办法,大部分都是用 fillna
之类的方法去填充,很明显这是不行的,下面我会举例说明。唯一看到一篇方向正确的文章,但是却稍显繁琐,还要先存一个中间文件再读取。
在本篇文章中,我们将会探讨如何使用 pandas 正确地读取包含合并单元格的 Excel 表格,简单高效全面,同时支持 xlsx 和旧格式 xls。
本篇文章使用两个内容相同、格式不同的文件来演示说明。内容截图如下:
可以看到里面有纵向合并(一班、二班、三班),有横向合并(钱一的语文和数学),也有横纵合并(二班三班的语文数学)。
当我们直接使用 read_excel
读取时,会变成下面这个样子:
可以看到合并单元格没有被正确填充,除了第一个单元格外其他都是 NaN
,而我们期望的是它们都用相同值填充。
当然我们可以使用 fillna
来实现,不过该方法只能是“具体情况具体分析”,横向、纵向、横纵合并单元格的情况都要根据情况用不同的 fill method,在这里我们至少需要分三种情况来进行处理,显得非常繁琐。一旦变了表格,你的代码就得变,普适性太差。
按理说,Excel 本身应该保留了合并单元格的信息,比如哪些单元格被合并了,它们的值是什么。应该存在一种工具可以读取出这种信息。
So,这就是 openpyxl
和 xlrd
派上用场的时候了。
pandas 内部实际上也是用的这两个包。根据官方文档:
enginestr, default None
If io is not a buffer or path, this must be set to identify io. Supported engines: “xlrd”, “openpyxl”, “odf”, “pyxlsb”. Engine compatibility :
• “xlrd” supports old-style Excel files (.xls).
• “openpyxl” supports newer Excel file formats.
• “odf” supports OpenDocument file formats (.odf, .ods, .odt).
• “pyxlsb” supports Binary Excel files.
Changed in version 1.2.0: The engine xlrd now only supports old-style.xls
files. Whenengine=None
, the following logic will be used to determine the engine:
• Ifpath_or_buffer
is an OpenDocument format (.odf, .ods, .odt), then odf will be used.
• Otherwise ifpath_or_buffer
is an xls format,xlrd
will be used.
• Otherwise ifpath_or_buffer
is in xlsb format,pyxlsb
will be used.
New in version 1.3.0.
• Otherwiseopenpyxl
will be used.
Changed in version 1.3.0.
简单来说,默认情况下(engine=None
):
xlrd
解析。pyxlsb
解析。openpyxl
解析。原先这些包是可以读取合并单元格这种格式信息的(虽然文档很不完善),但是经过 pandas 后不知道怎么回事就没了。所以这里我们就显式地用这些包来读取和操作。
总体思路就是:
完整代码如下:
1 | import pandas as pd |
我们再次用这两个函数读取一下示例文件:
可以看到 xlsx 和 xls 格式文件都能正确读取,同时支持指定 sheet name 和 header。
openpyxl
的结果会是 None
,而 xlrd
仍然是空字符串。openpyxl
的 merged_cells
方法似乎在文档中并未出现,忘记了在哪看到的这个方法。在进行大规模数据爬取和下载时,经常需要下载大文件,这时候就需要一个可以显示下载进度和速度的工具,以便于我们能够更好地掌控下载的情况,同时也可以避免下载过程中出现问题导致浪费时间和流量。
因此,我们需要一个可以显示下载进度和速度的工具,以便于我们更好地掌控下载的情况,避免浪费时间和流量。
本文将介绍在 Python 中如何使用 tqdm 和 requests 来实现下载进度和速度的显示。
要使用 tqdm 和 requests 来显示下载进度和速度,我们需要先安装 tqdm 和 requests 模块。安装方法如下(如已安装请跳过):
1 | pip install tqdm requests |
安装完成后,我们可以使用以下代码来下载文件并显示下载进度和速度:
1 | from pathlib import Path |
这段代码的核心是 tqdm,我们使用 tqdm 来创建进度条,然后在循环中更新进度条,实现了下载进度和速度的显示。详细解释如下:
unit="B"
:将进度条的单位设置为字节。unit_scale=True
:开启自动缩放功能,根据文件大小自动转换为 KB、MB、GB 等单位,默认是用 SI 公制单位(即 1000 进制),如果想使用二进制单位,则可以指定下面的 unit_divisor
。unit_divisor=1024
:设置单位缩放的除数为1024,即二进制单位。miniters=1
:设置进度条更新的最小步数。对于小文件,进度条可能会更新得太频繁,这个参数可以控制更新频率。desc="Downloading"
:设置进度条的描述。total=size
:设置要下载的文件的总大小,这个值从HTTP响应的 Content-Length
头部获取。pbar.update(len(chunk))
:更新进度条,显示已下载的数据大小。len(chunk)
表示当前下载的数据块大小(单位为字节)。效果如下:
又是设置一堆 tqdm 参数又是需要在循环中增加 update 语句,可能有些人会觉得稍显麻烦,有点侵入性,那我这里有一个不那么麻烦的的近似方法:
1 | def download(url, folder) -> str: |
这个程序只需要在原来的 r.iter_content()
外像平常一样包一层 tqdm,然后设置较少的参数即可,省掉了一些参数和 update 语句:
total=n_chunks
:由于我们是分块下载的,一个很明显的思路是 total 我们只需要设置为总的块数即可,和深度学习中的 batch 类似。unit="KB"
:这里我们门强制让其使用 KB
为单位,而不是自动转换。unit_scale=chunk_size / 1024
:由于 total 设置的是块的数量,默认速度就会显示每秒多少个块,但是我们想让其显示每秒多少 B 或者 KB 之类的,这里我们就用这个参数来让块数 ⨉ 块大小得出来 B 数,然后再除以 1024 的到 KB 数。如果你想得到 MB 数,只需再除以 1024,并设置 unit="MB"
即可。为什么说这是“近似”做法呢?这是因为我们 unit_scale
直接乘的是块大小,然而每次得到的数据量并不一定是块大小这么大,最后一个块可能会偏小。道理很简单,把 10 按块大小为 3 拆分,最后一个块大小为 1。但是由于我们的块大小不会太大,这个影响微乎其微。
进度和速度显示可能是很多程序员忽略的一个问题,但是我觉得非常重要,可以说 you always need ETA(tqdm)。但是在使用的时候要注意刚开始的速度可能并不准确,尤其是任务队列中的处理时间不尽相同时。
前段时间我在跑数据批处理任务时,发生了一件怪事:client 部署在 CPU 服务器上,server 部署在 GPU 服务器上(模型),client 处理过程中会向 server 发请求。执行了一段时间后通过监控发现,client 这边的 CPU 和 server 这边的 GPU 利用率都不满,client 那边几乎可以忽略不计,server 那边只有 40% 左右。这是为什么?谁在消耗时间?正常情况下 server 应该是满的。
先来大致说下数据批处理流程。流程分为两部分:client 和 server。client 主要负责整个数据处理流程,其中包括向多个模型服务发起请求,不涉及 GPU,部署在 CPU 服务器上。server 就是模型服务,部署在 GPU 服务器上,负责 load 模型和 inference。开始处理的时候,会临时起多台 GPU server,然后上面加一层负载,client 就用这个负载地址来请求 server。
那天在启动任务开始处理的时候,已经有其他任务在用 server 了,而且是同一个负载地址。我们在启动这个新任务的时候,为了满足计算需求,直接往这个负载下加了 10 台 GPU server。然后启动 client,任务开始。
启动后 client 的情况:
server 情况:
server 这边显示的是所有服务器的 GPU 的情况,包括旧任务的,而且此时旧任务还没有完成。但是新任务一开始基本就出现了利用率的下降,请求量也出现了下滑(但是没有利用率那么明显):
从日志中可以看到旧任务一些进程已经结束了,但是也不至于出现这么大的波动。这就很奇怪了,为什么新任务一开始 server 那边就打不满了呢,按理说应该更满才对,而且请求量也没上去,反而跌了。
我先是用 py-spy 检查了其中一个进程到底在干什么,这个工具的一个重要功能就是可以检查处于运行状态下的进程在干什么。结果如下:
可以看到,时间基本都消耗在了网络相关函数上,比如 readinto (sockent.py)
、send (requests/sessions.py)
、urlopen (urllib3/connectionpool.py)
、begin (http/client.py)
和 _make_request (urllib3/connectionpool.py)
。很明显,问题出在网络上。正常情况 BeautifulSoup
相关操作应该占比较大一部分。
运维测试了下网络,延迟很低,网络很通畅……
然后我在监控中看到 server 镜像版本是旧的,而且这个旧镜像是有问题的,根本启动不起来。从监控中也可以看到这几台 server 利用率一直是 0,也就是说根本没收到请求。如果 server 没起来,那么负载应该是不能连接的,但我在 client 这边并没看到负载连接失败的报错,而且我在 client 程序最前面加了一个负载连接测试,不通过会直接 raise error,程序就会退出,而现在程序是正常运行的。这是为什么呢?还记得前面说过这个负载是和旧任务共用的吗,问题就在这里,这里实际上用的还是旧任务的 server,所以不会出现连接错误。
把这个问题解决了后,问题依旧,server GPU 利用率稍微降低了些,但波动很小:
尽管大家觉得还是很离奇,但是当时时间很晚了,大家建议尝试增加 client 这边的并行核数(joblib 的 n_cores),毕竟 client 这边不满 server 也不满的另一个可能的原因是并发不够,同时打出去的请求不够多。
后来的小时测试发现速度增加了 5 万/小时,但是不知道这是谁的功劳,或者说这个速度是不是存在水分都不一定,因为我不是特别确定之前的速度。由于时间很晚了,这件事暂且告一段落了。
后来同事向我反馈线上 api 总是超时,之前不会。我进去看了下日志发现是有个子任务超时了,进一步发现 dev 服务器上的 GPU 特别满,按理说应该不可能,请求的人没那么多。这时我突然想到,为了方便在负载地址和 dev 地址之间切换,我在配置文件里区分了 prd 和 dev,批处理数据时需要先用如下语句来初始化配置:
1 | config = Config('prd') # or 'dev' |
这个语句在两个程序中会出现,我突然想到我在其中一个程序中似乎还是用的 dev
,有可能会导致批处理用了 dev 服务器上的模型服务。进去一看,果然!
这就解释了为什么 client 和 server 都很闲:
但是为什么 server GPU 利用率下降了那么多,只能用旧任务一些进程结束来解释了,当然负载可能也存在问题(后来决定按照任务来生成不同的负载地址,做一下隔离,能避免如服务器没起来但负载仍可以连接的情况,也可以更好地看到不同任务地情况)。
改成 prd
后 client 这边 CPU 正常了:
经过这件事,意识到几点:
我不是一个键盘专家之类的,所以文中有些表述可能有误,欢迎指正。
被这个 nuphy air75 种草过很多次,最近又被种草了,结合最近实际及家人的支持(说是新年礼物),就在京东上和键盘皮套一起下单了。第二天就送到了,鉴于当前疫情形势,这么快的速度送到令我惊讶,尤其是和我 20 天前买的东西一起送到……
到手之后外包装是一个什么食物的箱子,具体什么我忘了,但是上面写着易碎物品,一瞬间我还以为买的其他东西到了。打开之后就是空气袋和键盘、皮套的盒子了。我不是一个二次元爱好者,所以这键盘盒的包装对我来说不是惊喜,而是有点惊吓……
把里面的包装盒抽出来,黑色主体、绿黄橙点缀,看起来挺大气:
包装盒里面是绿为主体,和键盘的 ESC 键的绿相呼应,还挺特别。
里面的东西如下:
我目前用到的除了键盘,还有就是键帽拉拔器(键盘默认是 Mac 键帽,需要替换成 Windows 键帽,不过有意思的是,默认模式却是 Win)、磁性脚垫(实际感觉垫高有限)、说明书(各种快捷键真的多),USB 线是用来有线连接、充电时使用,如果使用 nupyh console,似乎也必须有线连接,这东西主要是用来重新指派键及自定义灯光。电量显示用右侧测光灯的不同颜色来表示,刚拿到手时我看了下电量(Windows + |)还是绿的,表明电量 > 80%,用了一两天变成橙色了(20%~80%)。
话不多说,来说说优缺点。
Pros:
颜值高。这是最吸引我的点。我原来用的是 ikbc F87 红轴,当然不是说原来的丑(丑我也不会买啊……),原来的简洁大方,现在的活力多彩,不捧一踩一,只是视觉上习惯了。
矮轴。但导致我下定决心换一个的重要因素,就是现在这个是矮轴,就是说键程短。我用 F87 用久了觉得,键程太长了,久了手累,想要普通键盘那种短键程 + 机械键盘的手感。Air75 完美符合,打起来轻松多了,不用再多敲“深”一点了,而且手感也好很多。
Cons:
键帽不透光。这就导致很黑的环境中你可能真的就是盲打了,不过这种情况比较少,几乎不会在很黑的环境中用电脑,不然屏幕多刺眼。
背光偏弱。无论是相比于 F87 还是直观感受,背光确实偏弱,尤其是白天几乎看不到,即使调到最大亮度(从关闭到最亮共 5 档,按 Fn + ↑/↓ 调节)。
默认 cat 键用于唤醒语音助手,不过基本用不到,又缺少一个 Insert 键,所以自然而然会想将其重新指派为 Insert 键。
但是你重新指派后就会发现,F 功能键变为纯多媒体键了,例如 F1/F2 只能用来控制亮度,F2 不再能重命名,失去了原有功能。下面的操作都解决不了问题:
其实这是一个 known issue,可以在 nuphy console v1.0.2 的 CHANGELOG 中看到,同时在官方 discord 中也看到了相关回复,说等 v1.0.2 发布就可以了,但没说啥时候发布。
最终我在 discord 中看到一个人的回复说可以尝试长按 Fn + TAB + R 来 reset。我尝试之后发现确实可以,解决办法如下:
长按 Fn + TAB + R 重置键盘( console 上的恢复出厂设置没用)直到键盘背光灯闪烁,此时双侧侧光灯变蓝,随后左侧侧光灯蓝灯闪烁(表示等待蓝牙连接),此时你的电脑右下角应该就会弹出通知让你连接键盘,连接即可。
总之对这次决定下单购买还是不后悔的,目前用起来很满意,最满意的三点:矮轴,声音,手感。想换键盘的可以试试。不过在 console v1.0.2 发布之前,还是建议不要重新指派 F row key。
作为机器学习从业者,我们需要一些 package 来辅助我们的工作。很多人也说现在干机器学习都是调包侠,我不是很赞同这种说法,技术越来越进步,进步的意义就在于越来越便捷,越来越 user-friendly,或者说高层,就像汽车一样。
越来越便捷就可以把宝贵时间留给更有意义的工作,比如数据处理和模型设计。而且可以降低入行门槛,一个行业如果从业者人数太少也不利于行业发展,参考之前传统武术独门绝技啥的传男不传女的规定。另一方面,调包是必要的,但如果你知其然且知其所以然,那更有利于你的工作,特别是 debug 的时候。
包括在 package 的安装方面,现在也是越来越方便,比刚出来的时候方便多了,像 TensorFlow 的安装都还需要专门写一篇文章来讲,我现在 CSDN 上访问量最高的文章就是在 Windows 上安装 TensorFlow 的文章,实在是有点意外。
而本文叫深度学习环境创建指南而不是机器学习环境创建指南,主要是为了强调深度学习相关工具的安装。
为了避免重复劳动,后续可以快速创建环境,以及给有需要的人作参考,本文基于我的工作经历,记录一下 Windows 10 下一个基础深度学习环境的安装,主要包括 PyTorch 和 TensorFlow,其他 package 想起来再加。
1 | conda create -n dl python=3.9 |
1 | conda install pytorch torchvision torchaudio pytorch-cuda=11.6 -c pytorch -c nvidia |
测试:
1 | import torch |
根据 TensorFlow 官网说明,TensorFlow 2.10 是最后一个在原生 Windows 上支持 GPU 的版本。从 2.11 开始,如果你需要在 Windows 上使用 GPU 版 TensorFlow,就必须得在 WSL 中安装了。
1 | # 我系统上之前似乎已经安装了cuda 11.2,所以这里我就直接安装了。 |
测试:
1 | import tensorflow as tf |
1 | pip install notebook transformers datasets pandas jieba loguru |
最后这个环境已经有 8.67 GB 了……
参考 Documenting Large Webtext Corpora: A Case Study on the Colossal Clean Crawled Corpus。
langdetect
得到的英文概率小于 0.99,所以 C4 主要是英文文档。patents.google.com
、en.wikipedia.com
、en.m.wikipedia.com
。patents.google.com
排第一,这是专利网站,Google 会使用机器翻译模型翻译非英文专利,也会使用 ocr 将扫描文本识别出来。识别哪些文本是机器生成的也是一个活跃的研究领域。前段时间将博客的主题从 hexo-theme-tranquilpeak 换到了 hexo-theme-archer,虽然一些功能上没有原主题好,比如侧边栏目录,但是新主题更为简洁清爽,自定义程度比较高,语法上也支持“扩展的” markdown 语法,比如支持如下 image 语法,居中显示,可显示注释:
1 | {% image fancybox center /path/to/image "图片注释" %} |
而且很重要一点,twitter 分享很友好,有预览:
但后来使用过程中发现,原来文章中的 disqus 评论不见了,但 disqus 评论框是能够正常加载的。而且在其他页面上是能够看到这些文章的评论数的,但是点进去却又显示不出来。
而且在控制台可以看到有很多 disqus 相关链接的 404 报错。
由于原主题上 disqus 是正常的,所以我去找了找原主题的 disqus 相关代码,其中有段代码是这样的:
1 | this.page.identifier = "<%- page.title %>"; |
而新主题的相关代码是这样的:
1 | <% if (post.disqusIdentifier) { %> |
虽然我不太懂 js 代码,但是差不多也能看出来这段定义的 this.page.identifier
和原主题还是很不一样的。一个是 title
,一个是 disqusIdentifier
或者 path
。这显然差距是很大的,结合前面说的链接 404 错误,应该就是这里出了问题,identifier 变了,用现在新的 identifier 去找,当然找不到了。
所以我尝试将新主题的 this.page.identifier
直接改为:
1 | this.page.identifier = '<%= post.title %>'; |
然后 hexo s
,duang!果然就可以了!
记不太清楚那天的天气了。我像往常一样起床上班地铁轰隆一小时。
到公司抓紧时间写好处理数据的代码,然后告诉运维帮我开 24 台 GPU 服务器。
“ip.txt”
没过一会儿,运维甩给我一个包含 24 个服务器 IP 的文件。
“我有一些依赖要安装,代码要放上去,还得配 aws,难不成我要一台一台连接上去,然后一台一台敲上去?”
我咨询了下运维有没有什么捷径可走,运维说 xshell。此时的我有点疑惑,因为我之前几乎没怎么用过 xshell,向来都是 VS Code 和 Windows Terminal。
下了一个 30 天试用版,地方也好找,“发送键到所有会话”,啪啪啪在一台机器上输入命令,命令马上复制到了其他机器,很快就部署完了。
开始启动 1000 核的 client,开个 htop 看到 client 这边波澜不惊,开个 nvtop 发现 server 那边也是波澜不惊,只不过是高水位的波澜不惊。
我去接杯水,看着他们波澜不惊,我心里也波澜不惊了。
由于我是处理完一批上传一批结果的,所以最终全部处理完之后,需要合并一下结果。单看每一份文件似乎没啥问题,但是合并完去重之后发现,非常多重复的,理论上来说是不可能的,肯定是哪里出了问题。
于是我回去翻代码,看看是哪里写重了还是循环里变量重复使用还是怎么回事,然后我把注意力放到了下面的这段代码上:
1 | texts = ['Here', 'are', 'some', 'texts'] |
注意最后一行的 zip
,第一个是每个样本对应的 id,shape 为 (len(texts),)
;第二个是样本对应的概率 shape 为 (batch_size, num_labels)
。
所以问题就来了,len(texts) != batch_size
,也就是说 zip 的两个参数长度不等。那么此时 Python 会怎么办?
取短舍长。
当较短的参数消耗完时,迭代就会停止:
1 | 3), ['fee', 'fi', 'fo', 'fum'])) list(zip(range( |
而不是抛出异常,甚至 warning 也不会抛出。所以运行时你不会发现任何问题。这就很危险了。
幸好 Python 社区也注意到了这个问题,2020 年 Brandt Bucher 发了一个 PEP 618,提议为 zip 函数增加一个参数 strict
以进行长度检查。该 PEP 最终通过并合并在了 3.10 版本中。所以如果你是用的是 >=3.10 的版本并且想要两个参数完全相等,那么可以指定 strict=True
来强制限定,如果长度不等则会抛出 ValueError
异常:
1 | 3), ['fee', 'fi', 'fo', 'fum'], strict=True)) list(zip(range( |
所以,我得改下代码重新跑,再花一次 24 台 GPU 服务器的钱。虽然钱不是我出,但是还是觉得挺愧疚。
由于各种原因,切版本是不太现实的。所以直接将 ids
改为 batch_ids
即可。但为了以后一旦写错让程序抛出异常,我还是在 zip
前手动加了一个 assert
来进行长度检查。以后在涉及 zip
的地方,一定要多加检查,使用 assert
或者 strict=True
来显式抛出异常。Python 的默认行为也太容易出错了,我觉得至少得抛出个 warning 吧。
所以,zip,顾名思义,拉链。拉链的两边长度不等时,根据生活经验,你只能拉到较短一边的尽头。Python 中的 zip
同理,如果 A 和 B 长度不等,那么 zip(A, B)
的结果长度就是 min(A, B)
。这不是问题。
问题在于,这是静默发生的。
算是一个自己不小心挖的坑吧,修复 bug,再次运行,回归风平浪静。
Altair 是又一个 Python 绘图库,可交互,基于 Vega 和 Vega-Lite,官方称其为 Declarative Visualization in Python,声明式可视化绘图。在我看来这是一个比较轻量级的绘图库,可能和 Bokeh 是一类,相比 Plotly 要轻很多。由于其可交互的特性,当我们需要在博客上分享某些图时,读者阅读时要方便有效很多。
本文主要聚焦于如何在 hexo 这种静态博客中嵌入或者说显示 altair plot,但如何使用 altair 并不在本文讨论范围内。我之前也写过一篇文章讲如何嵌入 bokeh,感兴趣的话可以瞅上两眼。
下面我们就直入主题吧。
我们先从一个热力图说起。最近电视剧《开端》很热,反炸CP、司锅姨、今麦郎等梗也是非常多。我也是刚看完没多久,觉得确实很不错,国内相关题材上算是最好的一个了,但感觉最后一集实在是有些仓促……
话扯远了,说回正题。
我抓取了《开端》的豆瓣小组上的帖子,总共约 2.7 万篇。其中有一个字段是“最后回应时间”,表示该帖子最后一次被回复的时间。我们可以据此推断出在哪些时间段讨论比较热烈,所以这就是我们今天要绘制的热力图,横轴是一天中的 24 小时,纵轴是以天为单位的日期,日期范围是最早和最晚帖子的被回复日期(截至我抓取时 2 月 3 日)。
原始数据样例如下:
然后我们先把 last_reply_time
拉出来,获取对应的 hour,按照天进行 resample 并统计每个小时内的贴子数,最终处理成 altair 需要的格式,即 x、y、z 各成一列:
1 | grouped = df.resample('D', on='last_reply_time') |
最终得到的数据样例如下:
小时 | 日期 | 计数 | |
---|---|---|---|
0 | 0 | 2022-01-02 | 0 |
1 | 1 | 2022-01-02 | 0 |
2 | 2 | 2022-01-02 | 0 |
3 | 3 | 2022-01-02 | 0 |
4 | 4 | 2022-01-02 | 0 |
这就是我们要传给 altair 的数据了,横轴是 小时
,纵轴是 日期
,颜色使用 计数
:
1 | chart = alt.Chart(altair_data).mark_rect().encode( |
最终的效果图如下:
可是我们如何将这个图放到我们的博客里呢?
最简单的方式就是导出为 PNG 或者 SVG,就像上面这样,可是这样的话就丢失了可交互性这个重要的特性了。所以最佳方案就是显示绘图的同时保留可交互性。
根据官方文档上的说法,可选的方案有导出为 JSON 或者 HTML。前者需要配合 vegaEmbed 使用,后者也需要,只不过已经内置在 HTML 中了。由于前者需要将 JSON 文件托管在某个地方,因此我们不选用这种方案。我们将使用 HTML 的方式。
我们可以使用 chart.save('chart.html')
来导出到 HTML 文件,下面是一个该文件的样例:
1 |
|
按理说我们需要让有 altair plot 的页面加载 <script>
标签中的文件。但我们不能直接将这个 <script>
标签和 <body>
标签中的内容直接复制到 markdown 文件中,这样是没有效果的。我们需要先找到生成 <head>
标签的地方,这个不同主题位置可能不同。然后修改那里的程序,将我们的 <script>
标签加进去即可。
当然我们希望在有 altair plot 的页面加载这些 js 程序,按需加载,避免拖慢其他页面的加载速度。所以我们可以加一个设置:vega
。当 vega: true
时才加载这些 js,默认为 false
不加载。
综合来说,步骤如下:
<head>
标签的地方。我目前用的主题是 tranquilpeak,我这个主题生成 <head>
的程序是在 tranquilpeak/layout/_partial/head.ejs
中。添加 <script>
标签。我们找到上述文件,在最后的 </head>
上面加入如下代码:
1 | <% if (page.vega) { %> |
在博文中加入绘图代码。然后写博客时,在上面的 metadata 里加上 vega: true
,然后将导出的 HTML 文件中的 <body>
内容直接复制到想要显示绘图的位置,如本文:
1 | --- |
这样就可以显示出 altair plot 了,并且鼠标 hover 可以显示当前点的信息,保留了可交互性:
从上面的样例可以看出,绘图其实是显示在 id="vis"
的 div 中。而一个 HTML 中 id 不能重名。所以当我们有多张图需要显示时,我们必须更改第二以及后面的图的 id,比如我们可以直接递增,如 vis2
。具体来说,要改的地方有 3 个:
1 | <div id="vis2"></div> <!-- 修改 1 --> |
下面我们将上面的热力图稍微改下,将 计数
视为 O
类型数据,即离散有序数据,此时便会以离散的 colorscale 来显示数据:
最近在写一个工作上的代码时,遇到要使用嵌套函数的情况,但是总是报一个 UnboundLocalError
的错误。我把问题代码抽象出来如下:
1 | def outer(): |
运行该代码会报如下 UnboundLocalError
错误,显示 outer_int
未定义:
1 | INNER |
outer_list
和 outer_int
同样在 outer()
里进行了定义,按理说在嵌套函数里都可以获取到,但是现在表明只有 outer_list
可以获取到。
但是如果你将第 10 行 outer_int = 1
注释掉,你会发现又不报错了,又可以访问到 outer_int
了:
1 | INNER |
这是怎么回事?
首先表明,这是 feature 而不是 bug!
然后我们需要先引入几个术语:
outer_list
和 outer_int
都是 inner()
的自由变量,它们都在 inner()
中被用到,但是定义却是在 outer()
函数中,而且它们也很明显不是 global 的。“自由”意味着它们可以在不同函数之间”自由穿梭“。此外用 nonlocal
声明的变量也是自由变量。outer_list
和 outer_int
都是 outer()
的 cell 变量,但对 inner()
来说,它们同时也是其下的自由变量。由上面的现象我们可以猜想,可能是由于 inner()
函数中对 outer_int
的重新赋值导致其从自由变量变为 inner()
函数的局部变量,然后引发 UnboundLocalError
。
那如何证明我们的猜想呢?
这就又要引入一个概念叫字节码 bytecode。Python 代码执行流程是先将你写的代码编译为一种中间代码,然后运行时再由解释器解释这些中间代码为机器代码来执行。这种中间代码就叫字节码。
如果你观察过运行 Python 文件后的目录变化情况,你会发现在你第一次运行完一个 Python 文件后,当前目录会生成一个 __pycache__
目录,里面存放着和你运行的 Python 文件同名的文件,只不过后缀是 .pyc
,这些文件里存放的就是字节码。
这些字节码里存放着编译后的各种底层操作,我们可以从这些字节码里看到详细的操作细节。但是字节码是二进制文件,我们需要使用内置的 dis
模块来帮助我们反汇编(disassembly)这些字节码,生成格式化后的、人类阅读友好的字节码指令。
为方便我们后面讨论,我先将两个关键的字节码指令及其意义列出如下:
LOAD_FAST
:将指向局部对象 co_varnames[var_num]
的引用推入栈顶。对应于局部变量。LOAD_GLOBAL
:加载名称为 co_names[namei]
的全局对象推入栈顶。对应于全局变量。LOAD_DEREF
:加载包含在 cell 的第 i 个空位中的单元并释放可用的存储空间。将一个 cell 所包含对象的引用推入栈顶。对应于自由变量和 cell 变量。相应的还有
STORE_*
的指令,其意义和LOAD_*
相反,我就不赘述了。
OK,我们现在来看下注释前代码的字节码(Python 3.7.12,下同):
1 | 2 0 BUILD_LIST 0 |
注意我标记 <---- HERE
的那两行,即第 7 行和第 8 行所对应的字节码。
我们可以看到, outer_list
是一个自由变量,其相关的指令都是 *_DEREF
,第 7 行使用 LOAD_DEREF
来加载 outer_list
。而 outer_int
是一个 inner()
的局部变量,其相关的指令都是 *_FAST
,第 8 行使用 LOAD_FAST
来加载 outer_int
。但是 outer_int
在 inner()
中并未定义,所以会引发 UnboundLocalError
。
我们再来看下注释后的字节码指令:
1 | 2 0 BUILD_LIST 0 |
我们可以看到 outer_list
和 outer_int
现在都是自由变量了,自然也不会引发 UnboundLocalError
了。
那么为什么 outer_list
可以在 inner()
中引用并改变呢?其实 outer_list
并没有被改变,其 id 没有变,只是增加了一个值,其内存中的地址并没有变(变量的地址指的是起始地址)。也就是说, outer_list
是一个可变对象,而 outer_int
是一个不可变对象。正是由于不可变对象的这个特性, inner()
中对 outer_int
的重新赋值导致编译器认为其是一个局部变量,而在前面 print
的时候还没定义,自然引发了错误,这也同时可以避免你对一个不可变对象的误操作。如果你将 print
去掉,程序也不会报错,这就相当于创建了一个新对象。
所以,如果你想要在嵌套函数中使用外部函数的不可变对象并想要对其改变,例如 int
对象的 +=
操作,则要么使用可变对象替换之,要么在嵌套函数中使用 nonlocal
声明之,使之变成一个自由变量。
我们都知道在展示几个集合的交集情况时,应该使用维恩图,非常直观。但是当集合数大于 3 的时候,维恩图就很难绘制了,或者说即使绘制出来,可读性也非常差,让人看得云里雾里。
最近 The Illustrated Transformer 的作者 Jay Alammar 发了个推提到了这个问题:
What symptoms do covid patients report?
— Jay Alammar (@JayAlammar) December 24, 2021
Visualized via a ven diagram and by an https://t.co/ba5KTcXKDK plot in Altair https://t.co/SOc8qPyOZ2
The two bar charts provide great slices of the data. pic.twitter.com/fDxUMlJoi6
说的是两幅图的比较。看下面这个展示不同新冠症状报告人数的维恩图。哪个圆圈代表什么,交集代表什么,已经很难看出来了。
再看下面这幅图:
我们不仅可以很直观地看出来疲劳 Fatigue 的报告人数最多,还可以知道同时报告疲劳和嗅觉丧失 Anosmia 的人最多。
再比如,下面这张出自发表在 Nature 上的《The banana (Musa acuminata) genome and the evolution of monocotyledonous plants》,该图意图是展现香蕉和其他五个物种的基因组之间的交叉重合关系,每个颜色的大圈代表一个物种的基因组:
一眼看起来还挺好看,但是仔细看你就会发现很容易乱,交叉实在是太多了。那绘制成上面说的那种图会是什么样呢?
这样看就舒服多了。很明显可以看出来这六个物种的基因组大部分都是相同的。
这种图就叫 UpSet。我后来具体查了查,发现这种图实在是太有用了,所以决定写一个简易教程,帮助更多人入门。
UpSet 是一种用于可视化多个集合的交叉情况的图形,可以看做是增强的维恩图,专门用来应付这种情况,非常适合集合数多于 3 个时交集情况的展示,由哈佛医学院视觉计算组于 2014 年的论文《UpSet: Visualization of Intersecting Sets》中提出,算是比较新的了。
UpSet 由三部分组成,分别解释如下:
看起来挺复杂?没关系,你没必要自己 plot it from scratch。upsetplot 是这方面的能手。
和其他 Python 包一样,首先需要使用 pip 安装:
1 | $ pip install upsetplot |
upsetplot
的主要 API 是 plot()
方法。主要参数如下:
data
: pd.Series
或者 pd.DataFrame
,一般来说是 MultiIndex 的,用来表示 object 的归属情况(归属于哪个集合),其值为 0/1 或者 True/False。这个参数一般是由内置函数生成的,不用自己创建,包括 from_contents
、 from_indicators
、 from_memberships
,可以根据你的源数据的格式选择合适的函数。具体用法下面介绍。fig
: plt.figure()
对象,可以指定绘制在哪个 figure 上。保存图时有用,如果你不传此参数,直接使用 plt.savefig()
保存,会得到一个空图。你也可以传入其他参数,这些参数同时也是 UpSet()
的参数,主要有:
sort_by
:subset(即绿色部分)的排序依据,可选的有 cardinality
、degree
(默认值)和 None
。cardinality
表示根据 subset 的大小排序。degree
表示 subset 中包含的 set 的数量(即蓝色部分每列黑色圆圈的数量,自由度),会根据这个数量进行排序。set,或者叫 category,就是图中的红色部分。None
表示根据数据原本的出现顺序排序。subset_size
:如何计算 subset 大小(即绿色部分的柱高),可选的有 auto
(默认值)、count
和 sum
。auto
表示当 data
是 DataFrame 时,使用 count
,除非另一个默认为 None
的参数 sum_over
被指定为非 None
。count
表示用 group(subset)的行数作为 subset 大小。sum
就表示对 data
进行求和,或者在 sum_over
指定的列上进行求和。min_subset_size
:最小 subset 大小。有时候 subset 过多,需要用此参数来限制 subset 数量。max_subset_size
:最大 subset 大小。有时候 subset 过多,需要用此参数来限制 subset 数量。min_degree
:最小 degree。有时候不想显示 degree 为 0(即某列中全是灰色圆圈,没有黑色圆圈)或 1 的情况,可以用此参数来限制。max_degree
:最大 degree。类上。绘图的基本框架非常简单:
1 | plot( |
kwargs
就是 UpSet()
的其他参数。
绘图的核心就是 data
参数,因此如何准备你的数据是至关重要的。
前面我们提到过生成 data 的函数主要有三个:from_contents
、from_indicators
和from_memberships
,下面我们分别来看下传给这三种函数的数据是什么样子的。
from_contents
期望的数据格式是一个 dict,key 为 category name(或者叫集合名称),value 为集合中包含的对象列表,这些对象必须是 int
或者 str
格式,即 value 必须是 list of int 或者 list of str。
例如下面这样:
1 | contents = { |
传给 from_contents
后生成的数据如下:
1 | # DataFrame from_contents(contents) |
这返回的数据就是一个 MultiIndex DataFrame,将之传给 plot()
即可绘图,如下图左边:
其等效的维恩图如下:
indicator 是“指示符”的意思,类似指示函数 indicator function 返回的是 0 和 1,from_indictors
也期望输入是一个只包含 bool
类型的数据。可以是一个 dict
、一个 DataFrame
,但总归是一个表格类型数据。列名是集合名称,value 是 True/False
,表示某个对象属不属于该集合,所以 value list 的长度或者 DataFrame
的长度就是对象数量。
例如:
1 | # dict 类型的输入 |
结果图同上。
from_memberships
就比较直接了,是一个嵌套 list,每个 item 也是一个 list,表示一个对象的归属情况,里面的每个 item 是 str
类型的集合名称,即每个对象的”会员关系“ memberships,它们都是哪家的会员。
我们还是沿用上面的例子:
1 | memberships = [ |
传给 from_memberships
后生成的数据如下:
1 | # Series from_memberships(memberships) |
最后的结果图和上面一致。
现在我们来尝试复现一下本文开头提到的 Jay Alammar 的推特中的图。
我们这里使用的是最新数据,所以最终结果可能和原图有所不同。
原图中的数据来自 https://ndownloader.figshare.com/files/22339791,我们可以直接使用 pd.read_csv()
来读取,
1 | "https://ndownloader.figshare.com/files/22339791") df = pd.read_csv( |
我们可以看到输出的 dataframe 非常符合 from_indicators()
的情况,所以我们用之来绘制 UpSet。但是在这之前,我们需要先删掉 id
列并把数据类型转成 bool
:
1 | 'id', axis=1).astype(bool) df = df.drop( |
然后我们就使用 from_indicators()
来绘图了:
1 | plot(from_indicators(df), subset_size='count', sort_by='cardinality') |
和原图的结论基本相同。
下一篇,我们将看到更多的实际例子以及如何解决一个棘手的问题。
TensorBoard(TB)是一个非常棒的模型可视化工具,早期我也写过一篇文章来详细介绍各个面板。
不过士别三日,当刮目相待。现在的 TB 和那时相比变化太多了,增加了许多功能面板,绝大部分我都还没怎么用过。其中最吸引我的面板之一就是 Projector,虽然我现在工作中并不怎么用到。
现在终于抽出时间,来完整体验并写一篇 TensorBoard Projector(TBP)的简易教程。
本文将会从原始文本出发(中文),经过训练 embedding、生成所需文件等步骤,一步一步,最终使用 TBP 来可视化 embedding,并解决中文标签不能显示的问题。
我们先来看下最终效果:
虽然说现在 BERT 等预训练模型大行其道,但我还是想从更“复古”的词向量出发。当然如果你想使用 BERT 来生成 embedding,也是完全没有问题的,框架是相同的。
此外,这个过程和你所使用的库无关,无论你是 Numpy、Scipy 还是 TensorFlow、PyTorch,只要能够得到 embedding 向量,那就都没有问题。
使用 TBP 可视化 embedding 的基本逻辑是很简单的:
相应的我们需要下列文件:
metadata.tsv
、 tensor.tsv
和 sprite.jpg
:分别用于存放词、embedding 和词对应的图片(当然也可以是 PNG),最后一个用于解决中文标签不能显示的问题。projector_config.pbtxt
:用于告诉 TBP 上述文件的位置以及其他配置。下面我们就来一步一步看如何得到这些文件。
原始文本来自习大大的讲话数据库,使用spacy分句,共得到约 38 万句子。然后使用 jieba 和自定义词典进行分词,得到tokenized_sents.txt,该文件格式是一行一个分词后的句子,词之间空格分隔。词向量使用gensim的fasttext模型训练得到,维度300。为减少词的数量,去掉停用词。
1 | # Train embeddings |
metadata.tsv
的常见格式有两种:没有表头,只有一列;有表头,有两列。前者(格式 1)就是 NLP 中常见的 vocab.txt
的格式,一行一个词。后者(格式 2)的两列一般表示 index 和 label。label 就表示该样本所属的标签,一般多见于分类数据集。实际上格式 1 是格式 2 的特例,相当于默认认为其行号就是 index,行内容就是 label。
metadata.tsv
也可以有多列,多出来的列可以用来表示其他属性信息。
tensor.tsv
用于存储与 metadata.tsv
对应的 embeddings。顺序必须一致,即 metadata.tsv
中第 i 行的词,其 embedding 也必须是 tensor.tsv
中的第 i 行。embedding 中数字用 \t
分隔。
接上,我们得到模型后,使用其得到的 vocab 及对应的 embedding 来生成这两个文件:
1 | stopwords = Path("hit_stopwords.txt").read_text(encoding="utf8").splitlines() |
正如开头给出的效果图一样,图中每个点都是有一个 label 的,这个 label 就是词。如果我们直接这样启动 tensorboard,会看到如下页面:
但启用 3D 标签模式的话,我们将会看到下图所示的样子:
我们可以看到所有的中文词都不见了,只剩下了数字字母等标签。
这是因为 tensorboard 目前还不支持所有 Unicode 字符标签,只支持 ascii 字符。
BUT!关闭 3D 标签模式后,如果你点击其中一个点,你会惊奇地发现又能显示中文标签了:
一个 workaround 是将汉字转成图片,用图片来作为 label,就像官方给出的 mnist 例子一样:
但是由于每个词所含字的数量都不同,同时又需要尽量让词铺满整个图片,所以不同图片中字的 fontsize 都是不同的,需要视情况调整,这是一个迭代的过程。而转图片我们可以借助 PIL 来完成:
1 | def text2image(text, imgfile): |
当我们把所有词都转成图片后,再将这些图片,按照一定规则拼接到一起,最终形成的这么一个大图,就是所谓的 sprite.jpg
。
那么按照什么规则来拼接呢?
sprite.jpg
必须是正方形,每个小图也最好是正方形,意味着行列上的小图数量必须是相等的,而且 tensorboard 读这个 sprite 的时候是按照行优先的顺序读的。所以假设你有 8 张小图,那么最终的摆放顺序就是下面这样:
最后那一格是空白的,也就是全白。
当然也有可能最后一行都是空白的,例如你有 5 张小图,那么要想每行每列上的小图数量是一样的,那么每行每列上就得有 3 张小图:
这样不仅第二行最后一格是空白的,就连第三行整行都是空白的。
所以总结来说,假设你有 $n$ 张小图,那么每行每列上小图的数量就是 $\lceil \sqrt n \rceil$,即根号 $n$ 然后上取整。
具体代码如下:
1 | def text2image(text, imgfile): |
在得到了 metadata.tsv
、 tensor.tsv
和 sprite.jpg
后,我们还需要告诉 tensorboard 这些文件的位置和每个小图的维度,所以我们需要一个 .pbtxt
文件来指定这些信息。
我们可以用以下程序来生成该文件:
1 | from tensorboard.plugins import projector |
然后就会得到一个名为 projector_config.pbtxt
的文件,文件内容如下:
1 | embeddings { |
当然你也可以按照这个格式直接手动创建这个文件。
万事俱备,只欠东风。
现在我们终于可以启动 tensorboard 了:
1 | $ tensorboard --logdir=projector/ |
projector/
就是你上面指定的logdir
。
然后根据提示在浏览器打开 http://localhost:6006/#projector
就可以看到页面了,你可以在这里尝试不同降维算法的效果,也可以点击或搜索图上的词来查看其相似词,大致评估下 embedding 的效果。
Embedding Projector 中的点不仅仅可以是图像、词,理论上只要是可以 embedding 的东西,就可以显示。而且你懂的,万物皆可 embedding……😂
最近实在是有点忙,没啥时间写博客了。趁着周末水一文,把最近用 huggingface transformers 训练文本分类模型时遇到的一个小问题说下。
之前只闻 transformers 超厉害超好用,但是没有实际用过。之前涉及到 bert 类模型都是直接手写或是在别人的基础上修改。但这次由于某些原因,需要快速训练一个简单的文本分类模型。其实这种场景应该挺多的,例如简单的 POC 或是临时测试某些模型。
我的需求很简单:用我们自己的数据集,快速训练一个文本分类模型,验证想法。
我觉得如此简单的一个需求,应该有模板代码。但实际去搜的时候发现,官方文档什么时候变得这么多这么庞大了?还多了个 Trainer
API?瞬间让我想起了 Pytorch Lightning 那个坑人的同名 API。但可能是时间原因,找了一圈没找到适用于自定义数据集的代码,都是用的官方、预定义的数据集。
所以弄完后,我决定简单写一个文章,来说下这原本应该极其容易解决的事情。
假设我们数据的格式如下:
1 | 0 第一个句子 |
即每一行都是 label sentence
的格式,中间空格分隔。并且我们已将数据集分成了 train.txt
和 val.txt
。
首先使用 datasets
加载数据集:
1 | from datasets import load_dataset |
加载后的 dataset
是一个 DatasetDict
对象:
1 | DatasetDict({ |
类似 tf.data
,此后我们需要对其进行 map
,对每一个句子进行 tokenize、padding、batch、shuffle:
1 | def tokenize_function(examples): |
根据数据集格式不同,我们可以在 tokenize_function
中随意自定义处理过程,以得到 text 和 labels。注意 batch_size
和 max_length
也是在此处指定。处理完我们便得到了可以输入给模型的训练集和测试集。
1 | model = AutoModelForSequenceClassification.from_pretrained("bert-base-cased", num_labels=2, cache_dir='data/pretrained') |
你可以根据情况修改训练 batchsize per_device_train_batch_size
。
我们在训练的时候一般会监测测试准确率来评估模型性能,而 transformers
在训练过程中默认是不会输出准确率的,而且训练完也不会输出的。这样的话我们想要一个准确率的话,只能再手动加载一下模型然后走一下预测,略显麻烦。
但 transformers
也是支持计算并输出准确率的,我们可以为 Trainer
指定 compute_metrics
参数。
compute_metrics
参数必须是一个函数,用于计算准确率等 metrics 的函数。该函数的输入是 transformers.EvalPrediction
对象,包含模型的输出(logits)和正确标签,其本质上是一个 namedtuple,相应的 field 为 predictions
和 label_ids
;输出必须是一个字典,key 为 metric name,value 为 metric value。
关于 metric 的计算,datasets
实际上已经为我们提供了一些内置函数。你可以用 datasets.list_metrics()
来获取目前所有可用的 metric。但是在 load_metric()
时,需要从 GitHub 下载处理程序,鉴于国内网络状况,这步通常都会卡住:
1 | # https://github.com/huggingface/datasets/blob/21bfd0d3f5ff3fbfd691600e2c7071a167816cdf/src/datasets/config.py#L21 |
解决这种情况有几种办法:
load_metric()
支持从本地加载计算程序,所以你可以把 metric 计算代码放到你本地,然后把地址传进去。load_metric()
,而是我们自己根据 predictions
和 label_ids
来计算。本文接下来就是使用最后一种方法,较为灵活。我们可以使用 scikit-learn 来计算这些 metric。实际上 datasets
中的 accuracy
也是使用 sklearn.metrics.accuracy_score
来计算的。
来看代码:
1 | from sklearn.metrics import accuracy_score, f1_score |
注意一定要加上上面 TrainingArguments
中的 evaluation_strategy="epoch"
,该参数默认是 "no"
,即不进行 evaluation。我们此处指定为 "epoch"
表示在每个 epoch 结束时进行 evaluation。其他可选的值为 "steps"
,表示每 eval_steps
进行一次 evaluation,默认为 500 steps。
然后运行我们即可看到类似如下的输出:
1 | ***** Running training ***** |
我们可以看到在第一个 epoch 结束之后进行了 evaluation,accuracy 和 f1 也被正确返回了(会加上 eval_
前缀)。
根据 save_strategy
的不同,训练时默认每隔一定时间段就保存一次模型 checkpoint。如果训练 epochs 比较多,会保存很多 ckpt。但有时我们硬盘空间有限,或者由于其他原因不想保存这么多的 ckpt,只想保存最佳模型的。
transformers 也可以很方便地实现这个功能。
严格来说会保存两个 ckpt:一个最佳的,一个最后的(用于接续训练)。
默认情况下,我们只需要给 TrainingArguments
多加两个参数:
load_best_model_at_end=True
:训练结束加载最佳模型。save_total_limit=1
:总共保存 1 个模型 ckpt(实际是两个)。那么如何判断最佳呢?
通过 metric_for_best_model
和 greater_is_better
来共同判断。要想判断最佳,我们首先需要知道评判标准是什么,这就是前者的作用。默认是 loss
,在 eval_dataset
上的 loss,你也可以指定为 compute_metrics()
所返回的 metric name(带不带 eval_
都行)。其次我们需要知道这个标准是越大越好还是越小越好,这就是后者的作用。如果是标准是 loss,那么会自动设置为 False
,因为 loss 是越小越好。但如果你指定为其他的标准,记得手动设置下这个参数。
来看代码:
1 | training_args = TrainingArguments( |
完整代码见 GitHub。