You tell me I'm wrong. Then you'd better prove you're right.

2021-10-11
下一代 Bangumi Research 架构设计

Bangumi Research,即众所周知的 https://ranking.ikely.me,是一个展示 Bangumi 动画科学排名的网站。然而仅仅展示科学排名并不能充分填充这个 title 的 scope。事实上,我们希望它能做更多的事情,但是既有的网站架构限制了它唯一能做的事情就是展示科学排名。如果你查看过它的代码,你会发现它所做的事情仅限于从一个 Azure FileShare 里面定时读取最新的排名,如果发现排名更新了它就下载它并替换已有的排名。如果我想要做更多复杂的东西,比如说点开每个番组并查看它的 details,我是不是还得让它再读取一个文件?所以你可以看到这个网站的运作逻辑是非常简单的。

那么我希望 Bangumi Research 是一个什么样的网站?在我的理想中,它应该:

  • 展示科学排名
  • 展示每个番组的详细资料并包括一些有趣的数据(which requires extra data mining
  • 使用 tag 系统把每个番组连接起来
  • ……

但是目前为止,我还没有把 Bangumi Research 设计成一个可以登陆的客户端的打算。

经过若干周末的努力,下一代 Bangumi Research https://chii.ai 渐成雏形。chii.ai 来源于 Bangumi 的域名 chii.in。以下是这个新的网站的工程设计:

后端设计

用户访问 Bangumi Research 并不会发生写操作,而且用户最主要的目的就是访问我们的科学排名。除此之外,用户还可能会查看番组的详细信息,以及进行 tag 搜索。我不需要关心查看番组信息的 API,因为可以直接复用 Bangumi API。我主要关注以下三个 API 的设计(这只是概念上的展示,实际实现有所不同):

  1. /api/rank

    这个API 应该返回一个数组,其数据结构为

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    [{
    int id;
    DateTime date;
    string name;
    string nameCN;
    int rank; // Bangumi原排名
    int sciRank; // 科学排名
    string type;
    int votenum; // 评分人数
    int favnum; // 收藏人数
    List<Tag> tags;
    }]

    这个就包含了番组一些基本信息以及连带的 tags。这么设计 API 纯粹就是 Bangumi API 没有返回番组 tags,所以我需要自己设计系统返回 tags。

    Tag 的数据结构为:

    1
    2
    3
    4
    5
    6
    {
    string Tag;
    int TagCount;
    int UserCount;
    double Confidence;
    }

    其中 TagCount是该番组被标注为该标签的次数,UserCount是所有参与用标签标注该番组的人数。Confidence是一个事先用某种机器学习算法算出来的值,它表示该标签隶属于该番组的确信度。用户实际在查看番组下的标签的时候,这个字段会用于隐藏某些标签。

  2. /api/tags

    这个 API 返回所有已有的 tags。其数据结构为:

    1
    2
    3
    4
    [{
    string Tag;
    int Coverage; // 该标签被标注到的番组个数
    }]
  1. /api/relatedtags

    这个 API 返回所有与被搜索标签有关的 tags。其数据结构同上。

  2. /api/searchbytags

    这个 API 返回所有与标签相关联的条目。其数据结构不再赘述。

那么我们需要数据库支持这样的操作。理论上两张表:Subject 表和 Tag 表。Subject 表存储所有番组条目信息,以番组 id 为主键。Tag 表存储标签信息。咋一看标签与条目属于多对多的关系,但是在上面的 API 设计中每个标签在不同的番组中有不同的 Confidence,这就使得我们要把标签与条目设计成多对一的关系:在 Tag 表中,每个标签由其标签内容和关联条目唯一确定。另外,Subject 表应该存放科学排名。但是在 Bangumi Spider 中科学排名被生成为一个单独的文件,为了兼容这种历史遗留问题我们就把科学排名也作为一张表,以一对一的关系与 Subject 表以外键连接。

chii.ai 继续沿用 ASP .Net Core 开发后端。有 Linq 和 EntityFramework 的技术支持使得开发体验良好。该 web server 提供若干 restful API 接口。

为了接入 Bangumi API,我另外使用了一个 node 服务把数据库 API 与 Bangumi API 整合成一个 unified API 接口。当然,我也可以用一个 nginx 服务器代理 Bangumi API 的服务,但是我真正的意图是想在前端使用 GraphQL。所以,这个 node 服务是一个 Apollo GraphQL Server,提供整合的 schema 和 API 接口。

前端设计

为什么要用 GraphQL?如果仅仅是“用 GraphQL 那套接口开发”这无疑就会降低我选择这个技术的可能性,毕竟它可能还没有 Restful API 方便。但是 Apollo GraphQL 的生态系统是如此之成熟,这使得选用 GraphQL + Apollo Client 成为一个非常有吸引力的选项。

我最喜欢 Apollo Client 对 Query 的自动缓存功能。想象一下当载入 https://chii.ai 主页面的时候,它首先会载入所有的动画排名。用户可能会点到别的页面再点回来,由于在第一次 Query 的时候我已经缓存了排名结果,我就不需要再向服务器发送一次请求了。听上去是不是很有吸引力?而 Apollo Client 使得这一切都 work under the hood!

这套缓存系统是如何运作的呢?Apollo Client 对每个发出的 Query 都做了缓存。这听上去挺荒唐,因为以每一个 Query 作为 key 结果直接作为 value 可能占用太多内存了。实际上,GraphQL 里面我们知道返回的数据结构。而不同的Query可能共享同样的数据结构,只不过内容不同。这时候 Apollo Client 将实际返回的数据的 id 和数据结构类型作为 key,返回的数据作为 value。在最理想的情况,每个数据结构里面的子数据结构都有 typename 和 id,这样可以彻底 normalize 所有返回的数据。以 typename 和 id 作为缓存的 key 和直接以 Query 本身作为 key 相比可以大大减少内存占用。

这里面还有一些精妙的东西。比如说你用查询排名的 Query 返回了一连串动画。然后用户点进去其中一个动画,这会发出第二个 Query 请求请求这个动画的 details。第一个 Query 返回动画这个数据结构的列表,第二个 Query 返回一个动画数据结构。由于这个动画具有唯一的 ID,它在缓存里的 key 也是唯一的。那么第二次 Query 的结果实际上会对第一次 Query 里面那个列表里面的动画进行一次更新——它会覆盖先前的值。这么做是自然的,因为两者同属一个数据结构,自然在后端返回的东西应该是一致的。如果你的后端不是这样(比如说某些 API 返回部分 field,另一些 API 又返回另一些 field,甚至同一 field 的内容还不同),那么你会遇到一些 bug。

这个缓存系统听上去挺先进。但是实际上我观察到它也有一些缺点:因为缓存要建 Hash Table,与不缓存的方案相比,它会带来一些时间上的 cost,甚至会造成页面卡顿!这在 fabric 架构下的 React 应用程序简直就是不可忍受的。

有了 Apollo Client 前端的开发其实还不够现代。我们还需要 graphql-codegen 辅助我们自动从 GraphQL Schema 生成可以执行的 Apollo Client Query。那么实际的开发流程就变成了:我需要什么 Query/Mutation,我先写出来,连带上会返回的 schema。我还需要一个 graphql-codegen 配置文件告诉它 server side schema 在哪里。通过一次 yarn generate,我就可以得到直接可以在 Javascript 里 import 的 typed schema,typed query/mutation。这样就可以作为 Typescript 开发的一个良好起点。

毫无疑问,前端全部使用 React Hooks 编写。我使用了微软下一代 UI 库 fluentui northstar。但是其使用还是不如 ant-design 方便,以至于我不得不把 Table 和 Pagination 两个组件按照 antd 的接口通用化了一下下。

整体架构

如上图所示,该站整体架构分成五个部分。首先,由 .Net Core 和 Postgers server 构成了后端逻辑的核心。其次,Apollo Server 作为 GraphQL server,运行在 Node 上,其接收前端请求并向真正的后端发出 API 请求。Apollo Server 实际上也封装了某些 Bangumi API,使得其成为一个 API 的 hub。为了减轻后端压力,Apollo Server 后接 Redis 作为 cache。前端是一个 React Application,部署在 Nginx 里面。该网站开发时使用 docker compose,部署在 Azure Kubernetes Service 上。

一些反思

这个架构是否就是最好的架构?恐怕并不是的。我的 GraphQL 服务器并不是原生的 GraphQL 服务,这其实造成了一些 latency:发送一个请求需要通过 GraphQL server 再转接到 .Net Core 的 server。这么做的背后逻辑其实是我想用 GraphQL 那一套生态系统开发,这就产生了后端既有 Restful API 也有 GraphQL 的情形。

如何避免这种用 node server 做 GraphQL server 转接的方式发生?最理想的想法就是后端所有逻辑使用 node 重写。但是我后端主要代码逻辑都在 .Net Core 里面,这样做太浪费时间了。

一种想法是,Apollo Server 所做的主要事情就是一个 resolver。我是否可以在前端使用一个 service worker,把 resolver 作为一个单独的进程,前端主进程只要与这个 service worker 相通信就可以了呢?这样实际上就把一段后端逻辑搬到了前端,而且后端又简化了不少。这需要我深入研究 service worker 的使用方式。

目前的想法暂时就是这些。前端的代码在这里,后端的代码在这里。各位看官如果还有什么问题,欢迎在评论里面提出。

2021-01-09
Review the year 2020

时光飞逝。当我看到 2019 年的痕迹的时候,比如假子的正统圣诞节的帖子,我还以为这些事情刚刚发生在昨天。2020 好像就这么快地过去了,还是……人们根本就不愿意提起它?

我确信 2020 年是一个非常重要的年份。正如《我爱XXX》的一九〇〇年一样。由于 COVID-19 的爆发,我们的生活方式在短期内被迫发生改变,而且在更将长远的将来,我们的整个世界将转向一个与我们之前所认识的完全不同的新世界。

虽然我在 2020 年初就已经更加向死宅的方向发展(比如说休假两个星期只待在家里),但是 work from home 这种新型的工作与生活相结合的生活方式我实在是无法接受。认识我的人都知道,我继承了法国人的生活方式,那就是 6 点准点下班!但是在家工作的话,根本就没有”下班”这种说法。在正常上班的时间段里,我可能还要买菜、做饭、睡觉之类的,一天过去了,到了 6 点我也不知道我今天具体完成了啥。更让我想念的是我放在公司里的单簧管,我一直想吹《利兹与青鸟》,但是只要公司不开门我就哪也去不了。幸运的是,二月末公司就有条件开放了。我成了第一批去公司上班的人。在公司工作,有乐器玩,令人舒适。

虽然我是研究机器学习出身,但是我远离机器学习已有多年,实际工作其实是前端。由于公司里的项目我已经比较熟悉,所以实际开发花不了我多少时间。但是前端技术每天都在进化,我自然也在考虑更好的工作机会。在三月末的时候,Yongdong 突然发了一封信,call for developers for Microsoft Teams,重点是要在苏州成立一个新 Team,主攻 Microsoft Teams 的前端和移动端开发,需要 React developer!众所周知,由于 COVID-19 在世界逐渐流行,远程成为了常态,自然这时候 call for developers 也是意料之中的。我立刻就去了这个 Team, as a short-term volunteer.

这个 Team 确实有我想要的东西:严格的编程规范和最先进的技术,当然也有 ping 一万年也 ping 不通的 code reviewer. 其实这个 Team 让我感觉到我是真的加入了微软。你可以感受到这个 Sharepoint group 的文化与我先前那个几近于机器学习创业团队的文化不同。我感觉每个人都很有组织,且乐于加入公司的各种活动。这样的正规军看上去比我原来的 Team 战斗力强多了。

不过既然是为了 COVID-19 临时加入,我切身感受到的是我们美国同事的压力。我们的美国同事非常专业,在 code review 的时候一眼就能看通这个 code 里面可能的问题。同时你简直无法想象为了帮助我们这些家伙会在当地凌晨三点和我们持续通话。在 DRI 的时候,这些同事会连 code review 都没法完成,因为疫情期间的 DRI 数量增长到了平时的 5–6 倍!然后白天他们还会开会、带孩子。我深深地为这些人的献身精神感到尊敬。正是有了这些人的持续付出,Microsoft Teams 的日活才能增长到 1.15 亿。同时,我也为自己在这一危难关头加入开发这么一个重大的项目而深感自豪。

所以当我看到有人开发了一个奋斗逼提醒 Hackathon 项目的时候我非常生气。这些人对微软简直一无所知,来微软估计也就是冲着微软没有强迫加班的文化。在微软有无数默默奉献的工作者,而且据我观察,越是资深的人为工作奉献的也就越多。照那个项目开发者的想法,9 点还在工作的人都是奋斗逼,那我们的美国同事又算什么?三更半夜开会的 PM 和 manager 又算什么?那些所有为了抗击 COVID-19 而加班加点工作的人算什么?只有这些人的持续推进,信任才会被构建,合作才有可能,项目也会随之完成它的目标。我时刻对这些人保持尊敬,并知道如果我站到那个重要的位子上,我也有必要为关心下属和团队而付出更多。

2020 年是与过去完全不一样的那个世界的第一年。即使中国大陆极大程度上控制住了疫情,口罩和健康码仍旧成为了我们日常的一部分。我最讨厌的就是,小区从年初至今只留一个入口,而以前我可以走侧门更快地去上班。虽然有那么些不便,我常常想念我在美国的朋友们,因为我知道他们所承受的不便远远多于我们。(可怜那些连 gym 都去不了的人。虽然是个死宅,我还是每周去两次 gym 的。)

《集合啦!动物森友会》也是这个新世界的常态之一。在不能见面的情况下,我与数位 Bangumier 和推友在线上完成了面基。我相信绝大部分人已经弃坑了这个游戏,但是我是一直玩到了年末。在此,我要感谢 mono 在我建岛初期送来了 30 个铁矿石解决了我把岛上所有石头都敲掉的燃眉之急;我要感谢 LukuYunnaSunny 等人数次光临敝人的小岛。感谢 Bangumi Switch 群为我去年带来的欢乐。遗憾的是,我试图还想联系更多的推友但是我没有他们的联系方式,或是他们压根就不理我的邮件。难道真的把我忘了吗?😢

但是这几年来更让我在意的是,公共卫生意义上的病毒虽然可以被消灭,一种传播学上的病毒却已经影响了每一个社交媒体的使用者。2020 年之所以荒谬,是因为社会同温层越发表现出拒绝交流的倾向。从蔡英文和韩国瑜的支持者还有拜登和川普的支持者身上你可以看到,这是一个全球性的问题。这种病毒甚至渗透到了 Bangumi,每当看到某些爱国小将挑起两岸三地对立的发言总让我精神紧张。

最严重的还是华人群体在疫情期间受的伤害。中国大陆有一些民调表明绝大部分国人认为中国为抗击 COVID-19 做出了卓越的贡献,世界感谢中国。哎,实际上西方社会的人民认为,病毒来源于中国,中国应该为疫情负主要责任。用脑子分析一下也就明白,大多数西方人连米都不会煮(比如饭煮了还要过一遍凉水之类的),对东方世界一无所知,但是 COVID-19 切切实实地影响了每一个人的生活方式,当人们要寻找一个罪魁祸首的时候,那当然是中国啦。我见到有想出国的同事,就会警告说新冠疫情之前的世界和之后的世界完全是不一样的。在国外的华人是这种认知割裂的首当其冲的受害者。我无法想象当疫情结束之后,再次走出国门的华人将会受到怎样的认知冲击。

最不缺这种同温层取暖的人的地方大概就是 Twitter 了。我一直在想一个问题:为什么 Twitter 成为了这么一个同温层取暖的地方?当人们在 Twitter 上看到一个异见者,讲道理要花很长时间,而 Twitter 只有 140 个字,所以发推的人反驳的最高效手段就是撰写一条博人眼球的论调挖苦讽刺,以最短的推文形成最有力的打击,而且这样还能获得不少 likes & retweets. 久而久之,为了维护自己的网络形象,发推的人也会逐渐迎合受众。我觉得挺悲惨的一件事情,特别是对于那些讨厌中国大陆而移民的推友,要么就发一些山山水水,要么就逮着黑中国的段子猛发。而我很少看见这些人描述自己的生活细节,说好的要融入他国社会呢?当然,既然是因为讨厌中国大陆而移居国外,为了贯彻自己的愿望,在身在国外之后还要以中国的丑闻作为自己活下去的精神支柱,这不就是在消耗他人的悲惨么。

这种生态,在 @MoeWowolf 的推文笔下显得淋漓尽致:

胖曾因为我嘲讽他对2016大选的预测而拉黑我;类似地,多多猫@AtlanticCat因为我嘲讽他对2020大选地预测也在前段时间拉黑了我。那么以多多猫为例:他本人就职于花街,欢呼富人和企业减税,至少十余年的港美金融经历,夏季度假已经把东南亚国家耍了个遍,于是从实然层面看,他是个标准的精英既得利益者

但是多猫的言论则狂呼MAGA,川普万岁,制造业回流,红脖最伟大左派精英最低贱,这一表态和他实然的生态位显然是矛盾的:锈带和hillbilly的故事如果花街是第二责任人那没有人敢称第一;他的做题家爬梯历史和他愤恨的大陆码农也完全同构。因此,肯定另有原因驱动多多猫做与他实际行为完全脱节的表态

反复运用上面的方法,结合田野日志,最终我们就能大致理出多多猫的底层动机:他的MAGA态度无关于他对美国政治左右的理解,而是一个狗哨:出于一些原因他认定川普上台可以毁掉中国,而且他认为在这个过程中首先毁掉的是一批当年他又恨又斗不过的两面人熟人,弄残这批两面人最能让多多猫觉得大快人心

当多猫认定川普能够为他借刀以实现他的隐秘愿望之后,他自然就为此大鸣大放,嘲笑白左黄左的段子一天比一天精进。但是,多猫站队的这个认知,脱离了实然,未能在现实世界中实现。许多其它的推特国师,为川普落选大发雷霆,也和多猫同构,愤恨于”我本来想整死的那个两面人现在居然照旧马跑舞跳”

其实这样近乎键盘cosplay的论战和党争,难道不正是劣根性的体现吗?通过谎称的言论和对借刀的幻想来掩盖自己隐秘的真实理想,也就意味着作为成年人你压根没打算自己为自己内心真诚的理想出力,而是用一些自以为机智的心机去当便乘人:多猫很可能从未向川普的campaign捐过款。

相应地,我们在推特的争吵中经常忽视一点:许多人的争吵不过是反复陈述己方的基础假设。较复杂的争吵包含了许多推理,可以用逻辑去分析,但基础假设不可推理:它的基石地位只能靠实验去验证,去打脸。凑巧,国师们的另一大能力就是,当实验打脸理论的时候,他们想到的不是改理论,而是改实验数据。

希拉里落选的时候,胖不觉得希拉里过于自满,而是俄国干预;川普落选的时候,多猫也不觉得川普的真人秀技巧之下毫无落地能力,而是民主党做票(做票大军中会有胖吗?)。如果这只是推友们的线上人生秀,那没什么关系。但如果这类认知被真的拿来做与现实生活相关的决定,必然会步入德匹下的结局。

玩这种线上人生秀的人还是多的。下场最惨的就是相信了这些人生秀的人。在公司里我觉得我和另一位同事发展得最为悲惨(不是因为我们很久没有 promote),而是其他同事都很快结婚买房子了而我们却什么都没有做。恰好这位同事总是看空房市,而那个时候的我也很相信 Twitter 上的野哨,我们就经常有共同语言。最近我并不这么想了,因为我逐渐发现,这些 Twitter 上面成天黑中国的家伙大多都买了房子–你要知道,买房子是可以被看做一种投资行为,意在赌上这个城市的发展,也就是说,他们选择了相信国运!有一位北京推友虽然没买但也看了三十多个房子,还整天怀疑房地产泡沫是否能持续–如果他真的相信自己的想法那么看三十多个房子干什么?当然还有我认识的一位推友同事,在 Twitter 上装得像个萌豚死宅似的,线下也是一派 Twitter 中文圈政治正确的论调,可是你知道他早就结了婚,生了孩子,还买了两套房么?他当然不会在 Twitter 上告诉你啦。这些人确实用行动选择了自己想要的世界。这都不算投共,那什么算投共?

这种网络与现实行为的脱钩让我感到深深的背叛。自从远离了 Twitter 之后我觉得我看世界真实了很多。虽然我们每天能看到不少观点,但是只有事实和逻辑才是经得住检验的,再加上金融市场。如果你试图去做一些投资,你很快就会从 Twitter 那种同温层抱团的社区毕业。

我马上就要三十岁了,虽然别离了学生时代的指点江山,但是真实的生活让我感觉更接地气,尤其是面对一个全新的世界的时候。那么,让我祝愿我在新的一年里能更加不惑于这个世界。

2020-01-03
Bangumi Spider 的历史遗留问题及基于 GitHub Actions 的解决方案

在我的 GitHub 页面里,Bangumi Spider 这个基于 scrapy 的爬虫恐怕是维护时间最久的东西了。从 2015 年为了写 Chi (Bangumi 的好友同步率——推荐系统)开始,这个东西就一直维护到今天。顾名思义,它的主要作用就是爬取所有的用户、所有的条目和用户标记条目的记录。今天,Bangumi Spider 的主要作用是服务于 https://ranking.ikely.me,每月爬取所有标记记录并计算动画排名。动画排名应该每个月第 1 + n 天会更新,n 取决于爬虫爬取速度。

Overall technical background of ranking.ikely.me

在此图中,左边是理想中的爬虫部分。定时任务首先每月向爬虫服务器 scrapyd 发送爬取请求,爬取后的数据以文本形式放在 Azure Blob 里。然而这时候还是 raw data,需要一个 postprocessing job 对爬取后的数据进行处理形成最后可以直接被 ranking.ikely.me serve 的数据。看上去很简单,是不是?然而在多年的技术发展中,该项目背负了沉重的技术债。

首先,在很久以前,这套东西为了能运行,爬虫服务和应用服务是在同一台虚拟机上的。第一版应用服务用的还是 flask + jinja 一套 SSR。postprocessing job 在 2016 年的时候还是我线下手动生成的,于是在之后的三年里 ranking.ikely.me 一直都没有更新是因为我忘记了线下生成数据的格式。终于在 2018 年,这一套应用用 dotnet core 重写了一遍,并封装成 docker image。scrapyd 被移出虚拟机封装成 docker image。于是该虚拟机就成了 ranking web application docker container instance + reverse proxy。同时为了能自动化生成排名数据,postprocessing job 被移到了该虚拟机里面。postprocessing job 运行时所需要的内存巨大,在去年大约要 10 GB 内存(在今年涨到了 14 GB)。crontab job 也被写在了该虚拟机里面。这一套系统运行了六个月,然后又不能自动更新排名了,因为 Bangumi 页面样式更新使得某些数据无法爬取。这里面存在的问题有:

  1. Azure 虚拟机高配置而长时间空转,浪费 budget;
  2. scrapyd container instance 在爬取数据过后还会重启,丢失已部署的爬虫,经调查有不法人士黑入该 instance;
  3. ranking web application 并非 continuously deployed,需要手动更新;

在理想的状况下,每一个部件应该自动化运行并充分利用现有 budget。而 GitHub Actions 的出现,为实现这种理想提供了便利(当然,需要指出的是,GitHub Actions 并未唯一实现这种自动化的手段)。在图的每一个被标记的部分都应该有自动化:

  1. crontab job 自动化
  2. 自动按需启动 scrapyd server 并在运行结束关闭
  3. 自动按需启动 postprocessing job 并在运行结束关闭
  4. 随着爬虫的更新,postprocessing job 也应该更新

即使不能做到 100% 自动化,能大幅降低服务空置率也是对宝贵 budget 的一种有效利用。此外,我希望将 ranking web application 移出虚拟机,用 Azure web service (Linux) + container 的技术去 serve,以降低成本。

GitHub Actions

GitHub Actions ,据官方描述,能够极大简化你的 CI/CD 流程。但实际上它能做的事情不仅局限于 CI/CD。在这篇文章中,我将介绍 Bangumi Spider 新添加的 GitHub Actions 如何实现自动化部署和排名自动化更新。

在 Bangumi Spider 里面,有一个叫做 scrapyd 的 folder 存放了 Bangumi Spider 专属 scrapyd dockerfile,其和普通的 scrapyd 不同之处在于使用 nginx 在访问前进行一次验证。在这个 workflow 里面,GitHub Actions 先 build docker image,再 publish 到 Docker Hub,最后更新 Azure container image。

正如上文所述,我不希望 scrapyd 在不执行爬虫任务的时候运行,所以我在最后关闭了它。在另一个定时 workflow 里面,我启动相应的 Azure container service,在线部署爬虫并运行爬取用户记录番组的数据和番组数据的 jobs。

另一个叫 postprocess 的 folder 存放了对已爬取进行后处理的 Dockerfile。其执行任务净是一堆脚本文件,以单机有限资源处理百万行数据。这是一个有艺术性的话题,但是我不想讲。针对这个 Docker image 的构建 workflow 被描述在这个文件里。相应地,我在另一个 workflow 定时启动相应的 Azure container service,运行完成就结束。

需要指出的是,在这里我发现了 GitHub Actions 是可以支持每次 commit 所触动的文件而出发 Actions 的——倒不如说我发现 Travis CI 不支持这项功能。这样我每次提交的时候我就可以通过检查修改的文件是否涉及 scrapyd 和 postprocess 两个 folder 而 conditionally update docker images。就这项功能而言,我觉得 Travis CI 已经完全落伍了。

Azure Web App Service

Azure 提供 App Service 的服务,并附带一个 Azure 的证书,而且也可以绑定到自己的域名。关键是这个服务的价格比自己 host 虚拟机要便宜(如果用 Basic plan 的话)。App Service 最好的地方在于支持 docker image 和 docker-composed images,于是我把 dotnet core 的服务也使用 docker image 部署在其上。https://ranking.ikely.me 使用 CloudFlare 做 CDN 并由其提供证书,关于如何把 CloudFlare 的证书导入到 Azure App Service 的操作参见这篇文章

Achievements

通过这么一番操作,我们已经:

  • 100% 实现了全自动无人干预更新排名(除非 Sai 老板再次更新 Bangumi 页面导致爬虫需要连带更改)
  • Conditionally update docker image
  • 大幅削减预算:从每月七十多刀的虚拟机削减到十四刀左右。
  • 增强了 scrapyd 的安全性。

2019-10-26
My Kaggle Days China experience

I have been a Kaggle fan for a long time. A community of data scientists and engineers devoting for pioneer data science practice has always been attractive for me. Though I’m not a dedicated Kaggler, I would still devote several month’s weekends per year to some Kaggle competitions after work, to grasp the spirit of dedication and religious attitude. That’s why I registered it the first time when I received the registration notification email.

Besides the lectures and GrandMasters, another thing that specifically attracts me is the offline data science competition. I have been curious about the authentic ability of offline coding, since in my opinion, most Kagglers online are fed by public Kernels. What would be their real performance without Kernels? Though I’m only a linear model Kaggler, I’m certain that I am somewhat experienced than others in feature selection, so there might be a chance for me to win.

I checked the previous Kaggle days before this event, and all the offline competitions are about tabular data. So I tried to get me familiar with all common EDA APIs and code snippets of pandas feature generation and scikit-learn compatible cross-validation. I know deeply that my skills in traditional machine learning cannot achieve high place on leaderboard in the age of deep learning, so I invited one of my colleague, Lihao, who is a deep learning expert, to join me.

DAY 1

Opening of Kaggle Days China

The first day of the event was all about lectures and workshops. There were several interesting workshops for you to attend, but you must register first. The first thing I regreted was that there was an lecture about LightGBM that I really wanted to attend, but it conflicted with a workshop about modeling pipeline. In fact, I attended that lecture when it was about to end, and even so, I still learned something insightful from that. I may need to go review the lecture videos later.

Someone mentioned before that all the things to do when attending a technical meeting is to chat with people: no need to attend lectures, no workshops, just communicating. And I have to say this is the best part of the Kaggle Day. I did talk with a lot of people. However, I was still too shy during the meeting because it wasn’t me who tried to get to know others first. I have to say everybody in Expert group have their domain knowledge, not all of them are necessarily experienced Kagglers, but they know AI industries in China very well. As a SDE working in a small city, Suzhou, I have not felt this excitement of communicating with industry experts for a long time ever since I left Beijing in 2015.

Announce of competition title on day 2

At the end of the day, the organizer disclosed the title of tomorrow’s data science competition. Though I had expected that it should be another tabular data competition, the title indicated that it would be a computer vision competition. It reminds me of a previous competition classifying astronomical objects, but it may not necessarily be in the same form. Having no practical knowledge of contemporary computer vision, in which deep learning has dominated, I regretted I didn’t follow my domestic advisor Lianwen Jin well when I was in graduate school. My working experience also could not contribute to this competition since I’m working in NLP group. Fortunately, when we were about to leave, a guy came to us, asking if he can join us. He said he had some CV background. Perhaps this was the best news I received that day, so I was grateful for him.

DAY 2

I had decided from yesterday’s night that if the competition was really a computer vision competition, I would resort to fast.ai. I leaned about it this summer, and this was the only thing I know how to use in modern deep learning based CV. It turned out it is. This competition requires us to classify images into two classes, so it’s a typical binary classification problem.

CV requires GPU equipped machines, and on the night before competition, we were required to configure our machines on specified service provided by UCloud. It was actually a Jupyter Notebook backended by 8 GPUs. However, without proper configuration, that machine is almost unusable. It has tf 2.0 alpha installed, not final release version nor stable tf 1.14. So Lihao spent a lot of time configuring the machine in the morning.

I originally thought that one need to perform EDA and do proper train/test split first, but soon I discarded this idea for this CV competition. However, Williame Lee, the guy I mentioned above, spent some time inspecting data first. He tried to find out some patterns of the image. But in my opinion, features are extracted automatically by deep neural networks, and even if I concluded some patterns, we don’t know where to feed it if I use deep neural network to extract feature.

Kaggle offline competition ongoing

The core spirit of fast.ai is using pre-trained networks to classify images, and fine-tune them at the end. This turned out to be a very successful idea. I used the whole morning to build the pipeline, and it works! My first classifier, which is using ResNet34 as pretrained model, works as well as baseline. Later, Lihao trained this model further to push it to 0.85, and we tried several other models like ResNet18 and ResNet50. Even NN simple as RetNet18 can achieve good results at 0.82 after fine-tunning. Williame also developed a neural net using mobile net which is achieving 0.81 on public leaderboard.

Meanwhile at the same time, Fengari shared his 0.88 baseline, which is using EfficientNet. You can imagine that this fed many competitors attending this competition. Lihao then switched to this new baseline and adapted its cross-validation scheme. At last, we merged our three ResNet pretrained models and two EfficientNet adaptations as final result. That placed us at 17th over the whole leaderboard (34 teams). Not too bad for me as my first CV competition experience!

Day 2’s experience is like thrown in to a swimming pool (I don’t know how to swim, really) and learn to swim by myself. I have successfully trained a deep neural network targeting computer vision for the first time. Now I’m not fearing CV any more!

The organizer soon announced the winners, who are sitting in-front of us face-to-face during the whole competition. They are using multi-tasking to improve our model, which is a key technique that Williame implied in the morning. Their solution is here: https://github.com/okotaku/kaggle_days_china

Before this Kaggle Day, my ambition was to stand on the winning stage. But unfortunately, I still have many things to learn to achieve this goal. I asked Lihao later if this is the ideal tech venue that he likes, his answer was no, but he still prized the core spirit of this Kaggle community. I hope next year, I would find someone who bear the same mindset as me and debut together. If I could cross-dress the next time, the best!

2019-08-01
Saving the React novice: update a component properly

Front end development in React is all about updating components. As the business logic of a webpage is described in render() and lifecycle hooks, setting states and props properly is the core priority of every React developer. It relates to not only the functionalities but also the rendering effectiveness. In this article, I’m going to tell from the basics of React component update, then we will look at some common errors that React novice would often produce. Some optimizing techniques will also be described to help novice set up a proper manner when thinking in React.

Basics

A webpage written in React consists of states. That is to say, every change of a React component will lead to a change in the appearance of a webpage. Since state can be passed down to child components as props, the change of state and props are responsible for the variation of view. However, there are two key principles pointed out by React documentation:

  1. One can not change props directly.
  2. One can only update state by using setState() .

These two constraints are linked with how React works. React’s message passing is unidirectional, so one can not mutate props from child component to parent component. setState() is related to a component’s lifecycle hooks, any attempt to change state without using setState() will bypass lifecycle hooks’ functionality:

React lifecycle hooks diagram, referring to http://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

However, during my development experiences, I have observed numerous cases where these two principles are broken. A major part of those misbehaved patterns can be traced back to the selective ignorance of a important property of JavaScript.

Assignment: reference or value?

Assignment to const value

Let’s look at the example above. We all know const allows us to declare a variable that cannot be assigned a second time, so there’s no doubt why when we are trying to assign temp with 3 a TypeError is thrown. However, const does not imply constness to the internal field of an object. When we are trying to mutate the internal field of obj , it just works.

Assigned object is an reference to original

This is another common operation. From the script we know a and b are two non-object variables, and re-assignment to b leads to a !== b . However, when we are trying to assign d as c, which is an object, mutating the internal field does not change the equal relationship between the two. That implies that d is a reference of c.

So we can conclude two observations from the above:

  1. const does not mean constness to object’s field. It certainly cannot prevent developer from mutating its internal field.
  2. Assigning an object to another variable would pass on its reference.

Having acknowledged of the above, we can go on to the following code:

As you read the code, you are clearly aware of the intention of the author: onChange is a place where this.state is changed: it is changed according to incoming parameters. The author get a copy of original data first, then modify its value, then push to nextData. At last, the author calls this.setState to update.

If this code’s pattern appears in your projects and it works, it is normal. According to React’s component lifecycle, the this.state.data is changed to nextData , and it will eventually effect the render()‘s return value. However, there are a series of flaws in this code that I have to point out. If you fully understand and agree with the two observations I mentioned above, you will find the following points making you uncomfortable:

  1. data=props.data in line 5 is assigning this.props.data‘s reference to this.state.data, which means changing this.state.data directly COULD mutate this.props.data.
  2. prevData is assigned as a reference to this.state.data in line 10. However, as you read through the code, you will realize that this is not the real intention of the author. He wants to “separate” prevData from this.state.data by using const. However, this is a totally misunderstanding of const.
  3. In line 13, each item in prevData is mutated by assigning its field a to another value. However, as we mentioned before, prevData is a reference to this.state.data, and this.state.data is a reference to this.props.data. That means by doing so, the author changed the content of this.state.data without using setState and modified this.props.data from child component!
  4. In line 18–20, the author finally calls setState to update this.state.data. However, since he has already changed the state in line 13, this is happening too late. (Perhaps the only good news is that this.state.data is no longer a reference to this.props.data now.)

Well, someone may clam: so what? My page is working properly! Perhaps those people do not understand the functionalities of lifecycle hooks. Usually, people write their business logic in lifecycle hooks, such as deriving state from props, or to perform a fetch call when some props changes. At this time, we may write like the following:

1
2
3
4
5
componentDidUpdate(prevProps){
if (this.props.data !== prevProps.data) {
// business logic
}
}

Every time a component finished its update, it will call componentDidUpdate. This happens whenever setState is called or props is changed.

Unfortunately, if a novice developer unintentionally mutated this.state or this.props , these lifecycle hooks will not work, and will certainly cause some unexpected behaviors.

How to make every update under control?

If you are an lazy guy and like the natural thinking of using a temporary variable separating itself from original, as I displayed above, you are welcomed to use immer. Every time you are trying to update state, it would provide you a draft, and you can modify whatever you want on that before returning. An example is given by its documentation.

However, you should know that the most proper way to update a state field without modifying it directly through reference is to perform a clone. The clone sometimes needs to be deep to make sure every field is a through copy of the original one, not reference. One can achieve that goal by deepClone from lodash. But I do not recommend that since it may be too costy. Only in rare cases you will need deepClone.

Rather, I recommend using Object.assign(target, …sources) . What this function does is updating target by using elements from sources. It will return target after update is complete, but its content will be different from those of sources. So updating object should be like:

1
const newObj = Object.assign({}, this.state.obj, {a: 1})

The actual programming can be more easy: you should know that there’s spread syntax available for you to expand an object or array. Using spread syntax, you can easily create an new object or array by writing:

1
2
const newObj = {...this.state.obj}
const newArray = [...this.state.data]

That allows you to copy the original content of an object/an array into a new object/array. The copy behavior at this point is shadow copy, which means only the outer most object/array is changed, and if there’s an object inside the field of the original object, or an object at some position of an index, that inner object will be copied as a reference. This avoids the costy operations inside deepClone. I like this, because it gives you precise control on what you need to change.

So the proper component I gave above should be like this:

Some further advice

I would suggest all components you write from now on should be changed to React.PureComponent. This is no different from React.Component except it has its default shouldComponentUpdate function: it performs a shadow comparision of its states and props to check whether it should be updated, meanwhile React.Component would always return true if you do not provide custom logic. This would not only improve page’s performance but will also help you realize the unexpected rendering when you made the mistake I mentioned above.

If you need similar functionality on function components, you can try React.mono() which is available since React 16.6.

2019-07-10
RegExp.test() returns true but RegExp.exec() returns null?

Consider the following Javascript script:

1
2
3
4
5
6
7
8
const regex = /<(\w+)>/g
const testString = "<location>"
// Check whether testString contains pattern "<slot>"
// If yes, extract the slot name

if (regex.test(testString)) {
console.log(regex.exec(testString)[1])
}

It seems to be a perfect logic extracting the tag name of the tag string: one uses test to prevent the situation that testString does not match. However, it would throw an error:

1
TypeError: regex.exec(...) is null

It just violates our intuition: the string do match the regex, but it could not be matched. So why does this happen?


As MDN has stated, invoking test on a regex with flag “global” set would set “lastIndex” on the original regex. That lastIndex allows user to continue test the original string since there may be more than one matched patterns. This is affecting the exec that comes along. So the proper usage of a regex with “g” flag set is to continue executing the regex until it returns null, which means no other pattern could be found in the string.

This behavior is sometimes undesirable, since in the above scenario, I just want to know whether the string matches the predefined pattern. And the reason I don’t want to create the regex on the fly is to save the overhead of creating an object.

One obvious solution is to remove “g” flag. But sometimes, we do want to keep the “g” flag to find if a string matches the given pattern, and we don’t wish to modify the internal state of regex. In that case, one can switch to string.search(regex) , which would always returns the index of first match, or -1 if not matched.

2019-05-03
Write a reusable modern React component module

今天这篇文章主要讲怎么用一种科学、优雅的方式开发一个可复用的 React Component,其实实际上不仅限于 React Component,如果想要写任何一个可以被 npm install 的模块都是可以适用的!

听起来很简单?这个事情的结论是可以用一句话概括的,在我研究了半个月之后发现确实是的(,我们走了一些弯路)。为了照顾那些大忙人,可以直接下拉到文章最底端,使用我推荐的库就行了,我可以保证那个库和它背后的模板是 best practice。

有人说,既然你已经给出了答案,那么还要读这篇文章干什么呢?因为写一个 js module 有很多种写法。可是要做到可复用还要做一些额外的工作。以最简单的方式,我写一个 mymodule.js,在最下方写上 export default MyModule,然后在另一个调用其的文件里面写上 import MyModule from ‘./mymodule’ 这个事情就算完了。我可以对外宣称,这是一个可复用的组件!真的吗?

我担心的事情有:

  1. 这个组件依赖于 React, React-dom,怎么让用户知道他们也必须用这两个东西呢?
  2. 如果用户在自己的应用上面用了 React 16.2,但是我的组件使用了 React 16.8,用户在使用我的组件的时候会出现兼容性问题吗?会不会为了兼容性问题而装两个版本的 React 呢?
  3. 用户调用我的包,是通过文件直接调用的,怎么才能把我的包放在 node_modules 里,即,通过 npm install 的形式安装?
  4. 我的包使用了某些先进的语言特性。通过文件直接调用是无法通过 babel 转义为较低版本的 Javascript 的。甚至,用户都不能通过 import MyModule from './mymodule' 的形式调用!

但是在使用 npm 上面的包的时候,我似乎完全不用担心上面这些问题。所以为了写一个可复用的模块还需要某些额外的操作。

第一个实例

如果我们在 Google 上搜索 “write a reusable react component module” 等词语,我们可以找到一些号称自己是模板的东西,比如说这个 rinse-react,它是一个可以作为 boilerplate 的项目。让我们去看看它为了让 rinse-react 模块化做了哪些工作。

如果你去你的任何一个项目里的 node_modules 里看,你会发现 package.json 至关重要,因为它指定了一个模块的入口点,那就是 main 这个 field。对于 rinse-react 这个模块,其入口点是 dist/rinse.js,可以猜出这个文件是经由 webpack 打包后输出的。

于是我们打开 webpack.config.js, 里面的输入和各种 loader 同正常的 SAP 配置大同小异。但是需要重点指出的是 output.libraryTarget,它的含义就是把 src/index.js 里的返回值以怎样的形式作为模块输出。这里面要指定的值与模块会被怎样调用有关。我们除了 import MyModule from 'mymodule' 这种方式,还可能以 var MyModule = require('mymodule') CommonJS 的形式调用,也可能会以 define('MyModule', [], function() { ... }) 的 AMD 形式调用。为了兼容所有的调用方式,在 rinse-react 里面设置 output.libraryTarge = 'umd' ,这样被 webpack bundle 之后的模块就可以以任何一种形式调用。

对模板的优化

一部分人可以欢呼了, 因为看起来找到了一个可以拿来用的模板。但是有没有发现其中的问题?

  1. 它看上去也把当前版本的 react, react-dom 和 styled-components 打包了进去,增加了库的大小;
  2. 它实际上是先通过 babel 的转译再被下游应用调用的模块,所以下游应用使用 ES6 的 import 的时候,并不会有真正的 tree-shaking (所谓 tree-shaking,就是 ES6 通过分析 import 和 export 判定哪些代码被真正地调用,从而在执行前就把不被调用的代码给去掉)。
  3. 我也没有必要在下游应用 bundle 的时候对源模块进行二次 bundling。

对于第一个问题,在 package.json 里面可以使用 peerDependencies 解决。在 peerDependencies 里面的东西,应该是下游应用也同时依赖的东西。如果你不把依赖放在peerDependencies 而是放在 dependencies 里(就像 rinse-react 一样),它们就会成为私有的依赖。

那么自然地,有没有 peerDependencies 在模块里的版本和下游应用的版本不匹配的情况呢?当然会有。这时候如果出现了不可兼容的版本,npm install 的时候会有提示,而在实际开发中,我发现安装的是最高指定版本。

但是我们还没有解决如何真正地实现 tree-shaking 特性。幸运的是,最佳实践告诉我们,rollup.js 正是为了解决这一问题而来的。rollup 也是一种打包工具,但是和 webpack 的目的不同,rollup 的初衷是为了尽量把模块的依赖打平并且高效地利用 tree-shaking。从这一出发点来讲,编写可复用的模块应该使用 rollup.

在 rollup 打包的过程中,在 package.json 里面会提供两个入口:传统的 main 指向打包后兼容 UMD 的打包内容;前卫的 module 应该会指向一个类似于 main.es.js 的文件:它使用 ES6 的先进特性。这样,在一个实际应用试图 import 一个模块的时候,它会先查看 package.json 是否有 module,如果有的话就以 module 指向的文件作为入口,避免了 babel 转译并且最大限度利用 tree-shaking. 如果应用在 build 的时候不支持 module,就 fallback 到 main 所指向的 UMD.

一个基于 rollup 的库模板

由于很偶然的原因,在我试图研究 Ant Design 如何开发出如此优雅的组建库的同时,我发现了一个可以自动生成基于 rollup bundling 的库模板生成器。这个东西基本上解决了我上面所有的困惑。我诚邀各位试一试这个 create-react-library,并且劝退那些想要研究 Ant Design 的人,他们家自己研发的 rc-init 连文档都没有且都没有人维护的。

当然,在我使用这个模板生成器的时候,他们只能生产出基于 babel 6 的配置。为了与实际开发环境匹配,我又手动修改到了 babel 7。

剩下的问题

看来怎样开发一个 js 库这个问题到现在算是解决了。但是这样的库真的能与 Ant Design 相媲美了吗?在实际下游应用开发过程中会有按需加载的需要,为了能让用户按需加载 Ant Design,babel-plugin-import 应运而生。怎样优雅地面向按需加载开发是一个需要研究的问题。

参考文献

  1. Rinse-react: https://rinsejs.io/
  2. Webpack: output.libraryTarget: https://webpack.js.org/configuration/output/#outputlibrarytarget
  3. Writing Reusable Components in ES6 https://www.smashingmagazine.com/2016/02/writing-reusable-components-es6/
  4. CommonJS vs AMD vs RequireJS vs ES6 Modules https://medium.com/computed-comparisons/commonjs-vs-amd-vs-requirejs-vs-es6-modules-2e814b114a0b
  5. 你的 Tree-Shaking 并没什么卵用 https://juejin.im/post/5a5652d8f265da3e497ff3de
  6. Webpack and Rollup: the same but different https://medium.com/webpack/webpack-and-rollup-the-same-but-different-a41ad427058c

2019-04-21
Release of rankit v0.3 and roadmaps for future bangumi ranking

After several years (really!) of development, I’m pleased to announce that rankit has been upgraded to v0.3. The version of previous major release is v0.1, which was made in 2016. So what has changed during these three years?

At the time when rankit v0.1 was developed, it was aimed to implement all fascinating algorithms mentioned in a beautiful ranking book and provide an alternative ranking solution to Bangumi. The programming interface designed at that time is far from practical. As I was looking into more ranking algorithms, the more I felt that rankit should include some popular ranking algorithms that are based on time series. One of them is Elo ranking, which has a simple implementation in rankit v0.2. But the usage scenario is limited: updating ranking is tedious and its interfaces are not compatible with existing ranking algorithms.

In v0.3, following updates are made to address those problems mentioned above:

  1. Split rankers to “Unsupervised ranker” and “Time series ranker”. Each of those two rankers have their own interface, and they both consume the Table object to keep records.
  2. Introduced Elo ranker, Glicko 2 ranker and TrueSkill ranker (only paired competition record is supported)
  3. Updated interface to make it more user-friendly. For unsupervised ranker, only rank method is provided since it needs no update. For time series ranker, user should provide one use update to accumulate new record data and one can retrieve latest leader board by invoking leaderboard after each update.

The documentation of rankit has also been updated.

One side product of rankit is the “Scientific animation ranking for Bangumi”. For a long time, the ranking is not updated and it is gradually forgotten. I gave it a refresh last year and it is now having a completely new look with faster response and simple search functionality. More importantly, the ranking will be updated monthly. I would invite you all to have a try it here: https://ranking.ikely.me

The latest scientific animation ranking also involves minor algorithm changes. It is often witnessed that at certain time, users with peculiar intention would pour into Bangumi to rate one specific animation with certain high or low score. This impacted the proper order of rating. In previous version of scientific ranking, one can neglect those users who rate one anime and leave permanently, but could not handle those users who rate multiple animations. I adjusted the scores user rated overall and made several normalization according to the distribution of users’ rating, and all versions of normalized scores are fed into rankit to calculate latest rank. The final rank is still merged using Borda count.

But could this be solved from another perspective? One thing I have been thinking about is how to involve time series ranking into current ranking scheme. Ideally, time series ranking should act to combat ranking manipulation behavior in a way other than pairwise ranking. As I was reading about TrueSkill ranking, their brilliant idea to inference ranking using linear chain graph stroke me. Actually, TrueSkill is a generative graph model that organized in a order same as competition score. Another issue that need to resolve is to help users adjust historical ratings automatically: a user would rate an animation with wider range before, but his or her rating criteria may change with the evolvement of time. How to propagate recent rating criteria to historical ratings? All these should be supported in the next version of rankit. That is, to enable ranking for multiple players in a match, and power to propagate recent competition result to historical data.

2017-10-02
Mangaki data challenge 1st place solution

Mangaki data challenge is an otaku-flavor oriented data science competition. It’s goal is to predict user’s preference of an unwatched/unread anime/manga from two choices: wish to watch/read and don’t want to watch/read. This competition provides training data from https://mangaki.fr/ which allows users to favorite their anime/manga works. Three major training tables are provided as described as follows:

  1. Wish table: about 10k rows
User_id Work_id Wish
0 233 1
  1. Record table: for already watched/read anime/manga. There are four rates here: love, like, neutral and dislike.
User_id Work_id Rate
0 22 like
2 33 dislike
  1. Work table: detailed information of available anime/manga. There are three categories: anime, manga and album. There is only one album in this table, all the others are anime (about 7k) and manga (about 2k)
Work_id Title Category
0 Some_anime anime
1 Some_manga manga

For the testing data, one should predict 100k user/work pair on whether the user wish or not wish to watch/read an anime/manga. As you can see, the testing data is much larger than training data. Besides, during my analysis of this dataset, it is also not ensured that all users or works appeared in test set are contained in training set.

Traditional recommendation system methods (that I know)

Recommendation system building has long been studied and there are various methods in solving this particular problem. For me, I also tried to build a recommender for https://bgm.tv several years ago (you can read technical details here). The simplest solution is SVD (actually, a more simple and intuitive solution is by using KNN), then one can move on to RBM, FM, FFM and so on. One assumption that holds firm in all these methods is that users should have an embedding vector capturing their preferences, and works should also have their embedding vector capturing their characteristics. It is reasonable that we should be constrained in this embedding-dotproduct model?

Recently, the common practice on Kaggle competition is by using GBDT to solve (almost all except computer vision related) questions. As long as a model can handle classification, regression and ranking problem very well, it can be applied in all supervised machine learning problems! And by using model ensembing under stacknet framework, one can join different characteristics of models altogether to achieve the best result.

In this competition, my solution is quite fair and straightforward: feature engineering to generate some embeddings, and use GBDT/Random Forest/Factorization Machine to build models from different combinations of features. After all, I used a two-level stack net to ensemble them, in which level two is a logistic regression model.

Feature Engineering

From wish table:

  • Distribution of user’s preference on anime/manga (2d+2d)
  • Distribution of item’s preference (2d)
  • Word2vec embedding of user on wish-to-watch items (20d)
  • Word2vec embedding of user on not-wish-to-watch items (10d)
  • Word2vec embedding of item on wish-to-watch users (20d)
  • Word2vec embedding of item on not-wish-to-watch users (10d)
  • Lsi embedding of user (20d)
  • Lsi embedding of item (20d)

From record table:

  • Distribution of user’s preference on anime/manga (4d+4d)
  • Distribution of item’s preference (4d)
  • Mean/StdErr of user’s rating (2d)
  • Mean/StdErr of item’s rating (2d)
  • Word2vec embedding of user on loved and liked items (32d)
  • Word2vec embedding of user on disliked items (10d)
  • Word2vec embedding of item on loved and liked users (32d)
  • Word2vec embedding of item on disliked users (10d)
  • Lsi embedding of user (20d)
  • Lsi embedding of item (20d)
  • Lda topic distribution of user on love, like and neutral items (20d)
  • Lda topic distribution of item on love, like and neutral ratings (20d)
  • Item categorial (1d, categorial feature)
  • User Id (1d, only used in FM)
  • Item Id (1d, only used in FM)

Model ensembing

The first layer of stack net is a set of models that should have good capability of prediction but with different inductive bias. Here I just tried three models: GBDT, RF (all backended by lightGBM) and FM (backended by FastFM). I trained models from record table feature and training table feature separately, and one can further train different models using different combinations of features. For example, one can use all features (except user id and item id) in record table feature. But since GBDT would keep eye on most informative feature if all feature were given, it would be helpful to split features into several groups to train model separately. In this competition, I did not split too much (just because I don’t have too much time). I just removed the first four features (because I see from the prediction result that they have having a major effect on precision) and trained some other models.

Model stacking

The stack net requires one to feed all prediction result from the first layer as feature to second feature. The stacking technique requires one to do KFold cross-validation at the beginning, and then to predict each fold’s result based on all other folds as training data on the second level. Here is the most intuitive (as far as I think) description of model stacking technique: http://blog.kaggle.com/2017/06/15/stacking-made-easy-an-introduction-to-stacknet-by-competitions-grandmaster-marios-michailidis-kazanova/

In this competition, by using a single GBDT and all the features from record table one can reach 0.85567 on LB. By leveraging model stacking technique, one can reach to 0.86155, which is my final score.

Is this the ultimate ceiling?

Definitely not. One can push the boundary much further:

  1. I did not tune the embedding generation parameters very well. In fact, I generated those features using default parameters gensim provided. The dimension of embeddings are just get by my abrupt decision, no science involved. Maybe one can enlarge the sliding window of word2vec or use more embedding dimensions to achieve better results.
  2. I only used lightGBM to build GBDT. One can also use xgboost. Even though they all provides GBDT, lightGBM is a leaf-wise tree growth algorithm based model, while xgboost is depth-wise tree growth. Even though two models are all CART based GBDT, they behaves differently.
  3. I did not introduced any deep model generated features. GBDT is such a kind of model that relies on heavy feature engineering while deep model would learn features automatically. By combining them altogether in stacking model one can obtain much higher AUC definitely.
  4. I did not use more complex features. Sometimes, population raking would also effect user’s behavior. A user would select those animes ranked high as “wish to watch”. I did not tried this idea out.

Conclusion

I must say this competition is very interesting because I see no other competition targets on anime/manga prediction. Another good point of this competition is that the training data is very small, so that I could do CV efficiently on my single workstation. And before this competition, I have never tried stack net before. This competition granted me some experience in how to do model stacking in an engineering experience friendly way.

One thing to regret is that too few competitors were involved in this competition. Though I tried to call for participants to join on Bangumi, it seems still not many people joined. The competition holder should make their website more popular next time before holding next data challenge!

One more thing: one may be interested in the code. I write all my code here but they are not arranged in an organized way. But I think the most important files are: “FeatureExtraction.ipynb” and “aggregation.py”. They are files about how to do feature engineering and how to partition features. “CV.ipynb” gives some intuition on how to train models.

2017-04-14
Console as a SQL interface for quick text file processing

最近在处理业务上的某些事情时,会发现这样的问题:为了调试某个程序,会 dump 出一堆中间文件的 log,去查找哪些地方发生了异常。这种文件都是 tsv 文件,即使用 TAB 分割的列表文件。一种最简单的做法就是在文本编辑器中打开这些文件,然后通过观察去查看异常,可以配合编辑器的搜索功能。在这方面 sublime text 最为适合,因为只有这个能打开大文件。但是,这样每一次打开都需要很长的时间,而且我去搜索某个字符串的时候,每输入一个字符编辑器就会停顿很长时间。

于是我希望能有这么一种东西,对文本文件这种没有被某种结构化工具存储的、没有明确定义 schema 的东西进行快速的查找、计算。简单地借用 SQL 中的术语,我希望能在不导入数据的情况下进行 SELECTWHEREORDERLIMITJOIN 等基本功能。幸运的是,最近发现了以前打印出来一直都没看的讲义,一些最基本的 Unix command 就基本上涵盖了我所希望的对文本文件处理的功能。本文就按照这些 SQL 功能语句把这些命令进行梳理。

本文进行处理的数据对象是在 2 月用 Bangumi_Spider 爬取的 Bangumi Data,这包括用户、条目和收藏记录:

1
2
3
head user-2017-02-17T12_26_12-2017-02-19T06_06_44.tsv -n 5
head record-2017-02-20T14_03_27-2017-02-24T10_57_16.tsv -n 5
head subject-2017-02-26T00_28_51-2017-02-27T02_15_34.tsv -n 5
uid    name    nickname    joindate    activedate
7    7    lorien.    2008-07-14    2010-06-05
2    2    陈永仁    2008-07-14    2017-02-17
8    8    堂堂    2008-07-14    2008-07-14
9    9    lxl711    2008-07-14    2008-07-14
name    iid    typ    state    adddate    rate    tags
2    189708    real    dropped    2016-10-06        
2    76371    real    dropped    2015-11-07        
2    119224    real    dropped    2015-03-04        
2    100734    real    dropped    2014-10-09        
subjectid    authenticid    subjectname    subjecttype    rank    date    votenum    favnum    tags
1    1    第一次的親密接觸    book    1069    1999-11-01    57    [7, 84, 0, 3, 2]    小説:1;NN:1;1999:1;国:1;台湾:4;网络:2;三次元:5;轻舞飞扬:9;国产:2;爱情:9;经典:5;少女系:1;蔡智恒:8;小说:5;痞子蔡:20;书籍:1
2    2    坟场    music    272        421    [108, 538, 50, 18, 20]    陈老师:1;银魂:1;冷泉夜月:1;中配:1;银魂中配:1;治愈系:1;银他妈:1;神还原:1;恶搞:1;陈绮贞:9
4    4    合金弹头7    game    2396    2008-07-17    120    [14, 164, 6, 3, 2]    STG:1;结束:1;暴力:1;动作:1;SNK:10;汉化:1;2008:1;六星:1;合金弹头:26;ACT:10;NDS:38;Metal_Slug_7:6;诚意不足:2;移植:2
6    6    军团要塞2    game    895    2007-10-10    107    [15, 108, 23, 9, 7]    抓好社会主义精神文明建设:3;团队要塞:3;帽子:5;出门杀:1;半条命2:5;Valve:31;PC:13;军团要塞:7;军团要塞2:24;FPS:26;经典:6;tf:1;枪枪枪:4;2007:2;STEAM:25;TF2:15

由于爬虫的性质,这些数据有以下缺陷:

  1. 非实时。我所说的“实时”并不是今天是 4 月 16 日而数据只是 2 月的,而是我无法保证数据是在某一个时间点上的快照。对于用户数据,由于爬取一次需要两天的时间,在这两天的时间里,可能用户修改了他们的昵称和用户名而在爬取的数据上未反映出来。更为严重的问题是,对于收藏数据,可能会出现在爬取数据的时候用户进行了收藏的操作,导致爬取的数据出现重复或缺失。而且由于用户数据和收藏数据是分开爬取的,我无法保证通过用户名能把两个 table 一一对应地 join 起来。
  2. 非顺序。可以从预览的数据中看到。
  3. 爬虫本身缺陷。由于我对于 Bangumi 出现 500 错误没有在处理上体现出来,所以会导致某些数据有所缺失。

在下面的文章里,我们将一边使用 Unix Command 对数据进行类似于 SQL 语句的操作,一边阐述 Bangumi_Spider 产生的 data 的各种特点和后续处理需要注意的问题。

1. SELECT … WHERE … ORDER BY …

筛选 2017 冬季番组

现在我们有了条目数据, 而条目数据是记录了标签信息的,我们可以从标签信息中抽取出 2017 年冬季番组。这个标签是“2017 年 1 月”。我们可以用一个 grep 语句取出这些番组:

1
grep "2017年1月" subject-2017-02-26T00_28_51-2017-02-27T02_15_34.tsv | grep "anime" | wc -l
90

然而这个抽取方式有很大的缺陷。我们没有指定应该在数据的哪一列上查找“anime”或“2017 年 1 月”!如果有一部音乐条目的名字里面就有 anime 这个词,而又被打上了 2017 年 1 月的标签呢?这显然不是我们希望得到的。实际上,需要指定列的话,最好的方式就是使用 awk

1
2
3
awk -F "\t" '$9 ~ /[;\t]2017年1月:/ && $4=="anime"' subject-2017-02-26T00_28_51-2017-02-27T02_15_34.tsv > anime_selection.tsv
wc -l anime_selection.tsv
head anime_selection.tsv -n 5
85 anime_selection.tsv
122772    122772    六心公主    anime        2016-12-30    26    [19, 41, 1, 1, 4]    17冬:1;原创:1;PONCOTAN:4;2016年:2;广桥凉:1;TVSP:1;池赖宏:1;原优子:1;mebae:1;TV:4;日本动画:1;片山慎三:1;Studio:1;STUDIOPONCOTAN:4;2016:5;TVA:1;短片:2;上田繁:1;搞笑:4;中川大地:2;岛津裕之:2;种崎敦美:1;2017年1月:1;テレビアニメ:1;オリジナル:1;SP:1;6HP:2;村上隆:10;未确定:1
125900    125900    锁链战记~赫克瑟塔斯之光~    anime    3065    2017-01-07    88    [66, 24, 216, 20, 60]    山下大辉:3;17冬:1;原创:1;游戏改:47;CC:1;花泽香菜:7;TV:22;未确定:2;グラフィニカ:2;佐仓绫音:4;2017年1月:61;锁链战记:1;2017:10;锁链战记~Haecceitas的闪光~:15;热血:2;チェインクロ:1;石田彰:22;声优:2;2017年:4;Telecom_Animation_Film:1;十文字:1;柳田淳一:1;战斗:2;内田真礼:2;剧场版:1;奇幻:17;2017·01·07:1;工藤昌史:3;2015年10月:1;TelecomAnimationFilm:9
126185    126185    POPIN Q    anime        2016-12-23    10    [134, 11, 3, 3, 0]    荒井修子:1;黒星紅白:4;原创:3;黑星红白:1;2016年:5;_Q:1;日本动画:1;2016年12月:2;未确定:1;小泽亚李:1;2017:2;2016:5;动画电影:1;2017年:5;Q:3;东映动画:1;种崎敦美:1;2017年1月:1;宫原直树:1;POPIN:6;東映アニメーション:12;剧场版:24;东映:4;萌系画风:1;濑户麻沙美:5
129805    129805    混沌子    anime    2910    2017-01-11    197    [264, 24, 764, 60, 67]    上坂すみれ:3;ブリドカットセーラ恵美:2;季番:2;黑暗推理向慎入:2;2016年:3;CHAOS:5;游戏改:67;SILVER_LINK.:2;悬疑:5;游戏:9;TV:73;未确定:9;伪:4;GAL改:106;2017:28;科幻:2;志倉千代丸:4;SILVERLINK.:34;SLIVERLINK.:70;2017年:10;志仓千代丸:4;2017年1月:170;5pb.:112;混沌子:3;反乌托邦:16;剧透禁止:27;极权主义世界:8;松冈祯丞:3;神保昌登:6;CHILD:5
131901    131901    神怒之日    anime        2017-10-01    0    [79, 1, 0, 3, 1]    GENCO:3;2017年10月:2;TV:4;未确定:2;2017年:2;GAL改:4;游戏改:4;LIGHT:2;2017:3;エロゲ改:3;2017年1月:1

可以看到,我们提升了一些 precision。awk 的思想就是把一个文本文件按行处理,然后在单引号里面对行进行编程。你可以看到,我们使用内部变量 $9 指定第 9 列的内容(你可以看到,是从 1 开始标号的),这一列是标签。我们使用正则表达式对 $9 进行匹配,正则表达式需要用斜杠包围。同时,我们指定第四列的内容是 anime。这样就可以将满足我们需求的条目筛选出来。顺便说一句,$0 是整行的内容。

按照在看人数排序

我们在条目中用一个数组记录了想看、看过、在看、搁置和抛弃的人数。这个数组用方括号所包围。我现在希望对我刚才抽取的冬季番组按照在看人数从大到小排序。这需要我们抽取出第八列,然后从该列中抽取出在看人数。使用以下命令可以达到我的目的:

1
awk -F "\t" '{match($8, /\[([0-9]+), ([0-9]+), ([0-9]+), ([0-9]+), ([0-9]+)\]/, m); printf("%d\t%s\t%s\t%d\t%d\t%d\t%d\t%d\n", $1, $3, $4, m[1], m[2], m[3], m[4], m[5])}' < anime_selection.tsv | sort -t$'\t' -k6,6 -nr | sed 5q
179949    小林家的龙女仆    anime    157    84    2065    19    46
185792    小魔女学园    anime    245    51    1471    30    29
174043    为美好的世界献上祝福! 第二季    anime    297    63    1431    21    28
174143    人渣的本愿    anime    271    51    1381    60    133
188091    珈百璃的堕落    anime    126    46    1366    22    73

按照管道切割,第一个语句还是使用 awk,但是执行的功能不是筛选,而是构造新表。在这个构造新表的过程中,观看人数的抽取使用了 match 函数。被抽取的对象存储在变量 m 里,是 match 的第三个变量。第一个变量是目标匹配字符串,第二个变量是正则表达式。注意需要将匹配的对象用圆括号括起来。

awk 的函数有很多,具体的可以直接参考 awk 手册(我估计没人会看的)或是这里。很多都是 C 语言的内置函数。

对于排序功能我们需要调用 sort 命令。首先需要指定输入文件的分隔符(注意这里有点 hacky,必须是 -t$'\t')。由于我们希望对在看人数从大到小排序,我们必须指定按照第 6 列排序,即 -k6,6。同时我们指定 -nr 表明视第六列为数字并倒序。

结果显示了在二月某个时刻收看番组的前五个。Very reasonable。

抽取标签列表

既然我们已经爬取了标签,能不能对爬取的标签列表进行展开?这时候要使用 awksplit 函数和 array 类型了:

1
awk -F "\t" '{split($9, tags, ";");for(i in tags){ split(tags[i], itm, ":"); printf("%d\t%s\t%s\t%d\n", $1, $3, itm[1], itm[2]);};}' < anime_selection.tsv | sort -t$'\t' -k1,1n -k4,4nr | head -n 20
122772    六心公主    村上隆    10
122772    六心公主    2016    5
122772    六心公主    PONCOTAN    4
122772    六心公主    STUDIOPONCOTAN    4
122772    六心公主    TV    4
122772    六心公主    搞笑    4
122772    六心公主    2016年    2
122772    六心公主    6HP    2
122772    六心公主    中川大地    2
122772    六心公主    岛津裕之    2
122772    六心公主    短片    2
122772    六心公主    テレビアニメ    1
122772    六心公主    オリジナル    1
122772    六心公主    17冬    1
122772    六心公主    2017年1月    1
122772    六心公主    mebae    1
122772    六心公主    SP    1
122772    六心公主    Studio    1
122772    六心公主    TVA    1
122772    六心公主    TVSP    1
sort: write failed: standard output: Broken pipe
sort: write error

由于标签列表是按照分号分隔的,我们首先使用 split($9, tags, ";") 把分割后的字符串存储在 array tags 里。接着在 for(i in tags) 里,i 实际上是 index,对于每一个 tag 我们再次使用 split,得到具体的 tag 和 tag 人数。可以看到,可以使用 C 语言的方式写 awk 的行处理逻辑。在写的时候是可以隔行的,虽然我都写在了一行。

在此之后,我们首先对条目的 id 排序,再在条目中对 tag 的标记人数排序。这里 sort 需要使用两个 -k 选项指定排序顺序。同时我们把 -nr 的条件写在每个排序列的后面,这样可以对列按照不同的排序逻辑排序。

2. Aggregation

计数

我们更为关心的是收藏数据中的一些统计数据,如平均分、活跃人数等。最基本的统计数据形式就是计数。虽然可以使用 awk 完成,但是这个比较特殊,有更为简单的方法。

比如说,我们想在上文抽取出来的冬季番组标签中统计每个番组会有多少标签。鉴于我们已经把标签对每个动画展开了,我们就可以对每个动画出现的次数进行统计,就可以得到该动画对应的标签数量:

1
cut -f1,2 < anime_taglist.tsv| uniq -c | sed 10q
     29 122772    六心公主
     30 125900    锁链战记~赫克瑟塔斯之光~
     25 126185    POPIN Q
     30 129805    混沌子
     11 131901    神怒之日
     30 143205    南镰仓高校女子自行车社
     29 146732    碧蓝幻想
     30 148037    伤物语III 冷血篇
     30 148099    刀剑神域:序列之争
     30 148181    飙速宅男 新世代

其中,cut 命令对我们抽取出来的标签列表的第一列和第二列单独取出,然后送给 uniquniq 会对每行进行 de-duplication 的操作,并且加上 -c 的选项会给出每行出现的个数。在输出的内容中,第一列就是每个动画对应的标签数量。

需要重点强调的是,uniq 只能对已经排序的输入有效。

可以看到,在一个条目页面,标签数量最多只有 30 个。

最大、最小值

有时候我们会对列表的某几项求取最大、最小值。下面显示了如何求取每一部动画标记人数最多的 tag。当然,我们也是用 awk 完成的。

1
awk -F "\t" '$1!=prev {print $0; prev=$1}' <anime_taglist.tsv | sed 10q
122772    六心公主    村上隆    10
125900    锁链战记~赫克瑟塔斯之光~    2017年1月    61
126185    POPIN Q    剧场版    24
129805    混沌子    2017年1月    170
131901    神怒之日    GAL改    4
143205    南镰仓高校女子自行车社    2017年1月    34
146732    碧蓝幻想    A-1Pictures    38
148037    伤物语III 冷血篇    剧场版    80
148099    刀剑神域:序列之争    剧场版    87
148181    飙速宅男 新世代    2017年1月    36

请注意,受了 uniq 的启发,这里的处理也是需要输入已经排好序。

平均值

既然有了 awk,我们就可以做更多的复杂计算。这里我们用平均值举一个例子:我们希望对爬取的收藏记录挑出动画和有评分的,计算其平均值。我还是希望能在有序输入上计算,于是先生成有序输入:

1
2
sed 1d record-2017-02-20T14_03_27-2017-02-24T10_57_16.tsv | awk -F "\t" '$6 && $3=="anime"' | sort -k2,2n | cut -f7 --complement > anime_record.tsv
head anime_record.tsv
103122    2    anime    do    2012-10-18    9
103909    2    anime    collect    2012-11-06    8
104394    2    anime    collect    2012-12-28    9
110320    2    anime    collect    2012-12-11    10
112414    2    anime    collect    2012-12-26    7
118090    2    anime    collect    2013-01-31    7
125406    2    anime    collect    2013-03-04    8
165363    2    anime    collect    2013-10-10    6
183190    2    anime    collect    2014-02-01    8
207963    2    anime    collect    2014-07-21    8

然后按照下式计算每个动画的平均值,并把平均值从高到低排序。

1
awk -F "\t" '{cnt[$2]+=1; cum[$2]+=$6}; END {for(i in cnt){printf("%d\t%f\t%d\n", i, cum[i]/cnt[i], cnt[i]);}}' < anime_record.tsv | sort -t$'\t' -k2,2nr -k3,3nr | head
202419    10.000000    4
186960    10.000000    3
143694    10.000000    2
158396    10.000000    2
193619    10.000000    2
11121    10.000000    1
127124    10.000000    1
127125    10.000000    1
127597    10.000000    1
137405    10.000000    1
sort: write failed: standard output: Broken pipe
sort: write error

需要注意的是,这里又使用了 awk 里面的字典数据结构(associative array)。你可以看作是一个 python 里面的 dict。我们使用了两个变量:cntcum 存储每一个动画 id 的评分人数和评分总和。在最后,我们在 END 包围的代码块里面生成最后的结果。这时候 END 里面的语句是在文件遍历之后执行的。

3. Union, intersection and except

在用户记录中丢失的用户

在前文我们讲过,用户数据可能会因爬虫爬取时出现 500 错误而丢失,但是条目记录可能保留了这部分数据。而且,由于用户数据爬取的时间先于收藏数据,会出现用户在其间改了用户名、还有新的注册用户加入 Bangumi 等问题。这里我们试着查看有多少这类在用户数据中丢失的用户。

首先我们用用户数据生成用户 id 和 username 的列表:

1
2
sed 1d user-2017-02-17T12_26_12-2017-02-19T06_06_44.tsv| cut -f1,2 | sort -t$'\t' -k1,1n > user_list.tsv
tail user_list.tsv
321167    321167
321168    321168
321169    321169
321170    321170
321171    321171
321172    321172
321173    321173
321174    321174
321175    321175
321176    321176

还有条目数据中的用户列表:

1
2
sed 1d record-2017-02-20T14_03_27-2017-02-24T10_57_16.tsv | cut -f1 | sort -t$'\t' -k1,1 | uniq > record_user.tsv
head record_user.tsv
100004
100017
100018
100026
100039
100049
100051
100054
10006
100060

这实际上就是求 record_user 对于 user_list 中 user 的差集。如果是这样的话,我们可以使用 comm 命令:

1
2
cut -f2 user_list.tsv| sort | comm -13 - record_user.tsv > user_failure.tsv
head user_failure.tsv
12372
128259
1465
15000
174374
17601
210506
223507
257848
273783

comm 命令对每个文件的行操作,同时它的先验要求是文件已经排过序。它可以给出三列数据:仅在第一个文件中出现的行;仅在第二个文件中出现的行;在两个文件中同时出现的行。可以看出,这个就是求差集和并集的操作。通过指定 -13,我们指定只输出仅在第二个文件中出现的行,也就是在 user_list.tsv 中没有爬到的用户。

4. Join

获得收藏数据中的用户 id

在收藏数据中,我们记录下了用户的用户名,却没有记录用户 id 和用户昵称!这个是爬虫在设计时候的缺陷。这时候只能通过和用户数据 join 来弥补了。可是怎么在文本文件中进行 join 的操作呢?

首先,我们抽取两组数据:

1
2
sed 1d record-2017-02-20T14_03_27-2017-02-24T10_57_16.tsv| sort -t$'\t' -k1,1 > record.sorted.tsv
head record.sorted.tsv
100004    10380    anime    collect    2012-09-30    10    标签:;中二病;花泽香菜;嘟嘟噜;牧瀬紅莉栖;Steins
100004    10440    anime    collect    2012-10-02    9    标签:;あの日見た花の名前を僕達はまだ知らない
100004    10639    anime    collect    2012-10-02        标签:;虚渊玄;奈绪蘑菇;梶浦
100004    16235    anime    collect    2012-10-02    8    标签:;未来日记;我妻由乃
100004    18635    anime    collect    2012-10-02    7    标签:;罪恶王冠;泽野弘之
100004    23684    anime    collect    2012-09-30    9    标签:;黑子的篮球;基
100004    23686    anime    do    2012-10-01        标签:;刀剑神域;2012年7月;SAO;川原砾;梶浦由纪
100004    2453    anime    collect    2012-10-02    7    标签:;BRS;Black★RockShooter
100004    2463    anime    collect    2012-10-02    8    标签:;デュラララ!!
100004    265    anime    collect    2012-09-30    10    标签:;EVA;庵野秀明;神作;Evangelion;补完
1
2
sed 1d user-2017-02-17T12_26_12-2017-02-19T06_06_44.tsv| cut -f1,2,3 | sort -t$'\t' -k2,2 > user_list.sorted.tsv
head user_list.sorted.tsv
10    10    MoMo.
100    100    Jacob
10000    10000    漠漠
100000    100000    natalie_1204
100001    100001    七堂伽蓝
100002    100002    宇
100003    100003    二的二次方
100004    100004    从不卖萌的K
100005    100005    Astrid
100006    100006    tsiaben

需要注意的是,我们希望对用户的用户名进行 join,其前提是我们的列表需要对被 join 的列进行排序。我们已经在上面进行了对用户名列的排序操作。

接着,我们可以使用 join 了。我们可以首先进行 INNER JOIN:

1
2
# inner join
join -t$'\t' -1 2 -2 1 user_list.sorted.tsv record.sorted.tsv | wc -l
6483106
1
join -t$'\t' -1 2 -2 1 user_list.sorted.tsv record.sorted.tsv | sed 5q
100004    100004    从不卖萌的K    10380    anime    collect    2012-09-30    10    标签:;中二病;花泽香菜;嘟嘟噜;牧瀬紅莉栖;Steins
100004    100004    从不卖萌的K    10440    anime    collect    2012-10-02    9    标签:;あの日見た花の名前を僕達はまだ知らない
100004    100004    从不卖萌的K    10639    anime    collect    2012-10-02        标签:;虚渊玄;奈绪蘑菇;梶浦
100004    100004    从不卖萌的K    16235    anime    collect    2012-10-02    8    标签:;未来日记;我妻由乃
100004    100004    从不卖萌的K    18635    anime    collect    2012-10-02    7    标签:;罪恶王冠;泽野弘之
join: write error: Broken pipe

可以看到我们已经 join 了一些有价值的东西。被 join 的那个字段总是在第一列。

如果使用 LEFT OUTER JOIN 或是 RIGHT OUTER JOIN,我们需要用 -a 指定哪个文件需要全部输出:

1
2
# left outer join
join -t$'\t' -1 2 -2 1 -a 1 user_list.sorted.tsv record.sorted.tsv | wc -l
6718272
1
join -t$'\t' -1 2 -2 1 -a 1 user_list.sorted.tsv record.sorted.tsv | head
10    10    MoMo.
100    100    Jacob
10000    10000    漠漠
100000    100000    natalie_1204
100001    100001    七堂伽蓝
100002    100002    宇
100003    100003    二的二次方
100004    100004    从不卖萌的K    10380    anime    collect    2012-09-30    10    标签:;中二病;花泽香菜;嘟嘟噜;牧瀬紅莉栖;Steins
100004    100004    从不卖萌的K    10440    anime    collect    2012-10-02    9    标签:;あの日見た花の名前を僕達はまだ知らない
100004    100004    从不卖萌的K    10639    anime    collect    2012-10-02        标签:;虚渊玄;奈绪蘑菇;梶浦
join: write error: Broken pipe
1
2
# right outer join
join -t$'\t' -1 2 -2 1 -a 2 user_list.sorted.tsv record.sorted.tsv | wc -l
6492074

这里我们可以看到使用 RIGHT OUTER JOIN 的时候,对于没有被 join 上的用户就是在 user_list 中没有出现的用户。这时候没有被 join 上用户名和用户昵称的记录应该和 user_failure.tsv 的内容是一样的。

1
2
join -t$'\t' -1 2 -2 1 -a 2 user_list.sorted.tsv record.sorted.tsv | sort > record.complete.tsv
join -t$'\t' -1 2 -2 1 user_list.sorted.tsv record.sorted.tsv | sort > record.inner.tsv
1
comm -13 record.inner.tsv record.complete.tsv| cut -f1 | uniq | diff - user_failure.tsv
140d139
< sdf4
141a141
> sdf4

可以看到两个文件在实际内容上,除了顺序有所差别,并没有本质上的变动。这一定程度上证明了 JOIN 的正确性。

当然,我们既然有了 join,就会有人问怎么进行多个 column 的 join?遗憾的是,目前 join 只支持一列的 join。对于多列的 join 可以参考这个

5. Conclusion and future plan for Bangumi Spider

依据现有的技术,我们可以通过配合 awksort 进行文本文件操作从而组合成各种我们想要的类似于 SQL 语句的功能,这个给我们线下调试带来了很大的方便。同时我们可以使用 catcomm 达到交集、并集和差集的功能,使用 join 可以把两个文件通过指定的列 join 起来。在懒得使用 pandas 或数据库的时候,这些命令给我们带来了很大的方便。

同时,我觉得目前 Bangumi Spider 问题太多。我计划从一下方面入手将 Bangumi Spider 升级:

  1. 废除 user spider,更新 record spider 使之记录 user spider 目前所记录的所有信息,在爬虫结束任务之后,通过 raw record spider 生成 record 记录和 user 记录。后期处理还要力求 record spider 不能重复爬取条目。
  2. 修改 Bangumi Spider 对 Bangumi 主站 500 错误的处理。
  3. 保留 subject spider 的部分功能,特别是 infobox 那边的功能,去掉 tag 信息。 subject 要和 record 相 join 解决条目重定位的问题。