Rekko Challenge 2019:怎么样



不久前,Okko在线电影的推荐系统竞赛-Rekko Challenge 2019Boosters平台上举行。 对我来说,这是参加排行榜比赛的第一次经历(以前我只是在黑客马拉松比赛中尝试过力量)。 实践中,这项任务很有趣并且对我来说很熟悉,有一笔奖金,这意味着参加是有意义的。 结果,我获得了第14名,组织者为此颁发了纪念T恤。 好啊 谢谢啦

在本文中,我将简要介绍您的任务,讨论我提出的假设,以及如何在不积累经验的情况下将推荐系统中的竞争拖到前15名中,这对于那些只打算参加比赛的人特别有用。

推荐系统


推荐系统的主要目标是为用户提供他购买的东西(不幸的是,这种商业化的观点强加给我们)。
有不同的任务陈述(排名,搜索相似任务,预测特定元素)以及相应的解决方案。 好吧,我们都喜欢选择的可变性,这是由针对每个问题的几种潜在解决方案提供的。 在“ 推荐系统的解剖 ”一文中很好地描述了各种方法。 当然,没有人取消NFL定理 ,这意味着在竞争问题中我们可以尝试不同的算法。

问题陈述


组织者的文章中阅读有关任务和数据的更多信息。 TL; DR在这里,我将描述理解上下文所必需的最低要求。

数据集包含一万多部具有匿名属性的电影。 以下选项可用作用户-项目交互矩阵:

  • 交易-包含用户购买内容/租赁/按订阅观看的事实;
  • 评级-用户对电影的评级;
  • 书签-将电影添加到书签的事件。

所有信息都是在一定时间段内获取的,以链接到实际的任意单位表示。

t s = f t s r e a l


内容具有以下属性集:



您可以在组织者的文章中详细了解它们,但是我想立即关注引起我注意的内容:“属性”参数。 它包含一类分类属性,基数约为36,000。 每部电影平均有15个值。 乍一看,描述这些内容的最基本属性都是按照以下值加密的:演员,导演,国家/地区,电影所属的订阅或收藏。

有必要预测测试用户将在未来两个月观看的20部电影。 测试用户是所有50万用户中的5万。 在排行榜上,他们分为两半:公开/私人各25,000。

公制


组织者选择了20个元素的平均归一化平均精度(MNAP @ 20)作为度量。 与通常的MAP的主要区别在于,对于在测试期间未观看20部电影的用户而言,配给率不会在k上发生,而是在观看的电影的实际价值上发生。



此处阅读更多内容并查看Cython中的代码

验证方式


解决问题。 首先,有必要确定已验证的内容。 由于我们需要预测未来的电影,因此我们无法对用户进行简单分类。 由于时间是匿名的,因此我至少必须至少开始对其进行解密。 为此,我请了几个用户,制定了交易时间表并透露了一定的季节性。 假设它是每天,并且知道两天之间的时间差,我们就可以计算出上传数据的时间。 原来,这些都是6个月的交易。 后来在讨论比赛的电报频道中证实了这一点。



上表以一个月的数据为例,显示了每小时的交易频率。 每周三个主要高峰类似于星期五,星期六和星期日晚上。

因此,我们有六个月的观看时间,接下来的两个电影需要预测。 我们将训练样本时间的后三分之一用作验证数据集。



接下来的几份意见书表明,该分组的选择很好,并且本地验证速度与排行榜的相关性极佳。

尝试对数据进行匿名处理


首先,我决定尝试对所有电影进行匿名处理,以便:

  • 通过内容的元信息生成一堆迹象。 至少,以下人会想到:类型,演员表,订阅条目,文字说明等;
  • 从侧面抛出交互作用的炉子,以减少矩阵的稀疏性。 是的,竞争规则并未禁止使用外部数据。 当然,没有希望与开放数据集相匹配,但是没有人取消对俄语门户网站的解析。

这似乎是合乎逻辑的动机,根据我的期望,这将成为功能强大的解决方案。

首先,我决定解析Okko网站,并提取所有电影及其属性(等级,持续时间,年龄限制等)。 好了,如何解析-事实证明一切都很简单,在这种情况下,您可以使用API​​:



进入目录并选择特定类型或订阅后,您只需进入任何元素。 响应上述要求,流派/订阅中具有所有属性的电影的整个阵列出现了问题。 很舒服:)

因此,结构中一个元素的属性看起来
"element": { "id": "c2f98ef4-2eb5-4bfd-b765-b96589d4c470", "type": "SERIAL", "name": " ", "originalName": " ", "covers": {...}, "basicCovers": {...}, "description": "      ,    ...", "title": null, "worldReleaseDate": 1558731600000, "ageAccessType": "16", "ageAccessDescription": "16+    16 ", "duration": null, "trailers": {...}, "kinopoiskRating": 6, "okkoRating": 4, "imdbRating": null, "alias": "staraja-gvardija", "has3d": false, "hasHd": true, "hasFullHd": true, "hasUltraHd": false, "hasDolby": false, "hasSound51": false, "hasMultiAudio": false, "hasSubtitles": false, "inSubscription": true, "inNovelty": true, "earlyWindow": false, "releaseType": "RELEASE", "playbackStartDate": null, "playbackTimeMark": null, "products": { "items": [ { "type": "PURCHASE", "consumptionMode": "SUBSCRIPTION", "fromConsumptionMode": null, "qualities": [ "Q_FULL_HD" ], "fromQuality": null, "price": { "value": 0, "currencyCode": "RUB" }, "priceCategory": "679", "startDate": 1554670800000, "endDate": null, "description": null, "subscription": { "element": { "id": "bc682dc6-c0f7-498e-9064-7d6cafd8ca66", "type": "SUBSCRIPTION", "alias": "119228" } }, "offer": null, "originalPrice": null }, ... ], "emptyReason": null }, "licenses": null, "assets": {...}, "genres": { "items": [ { "element": { "id": "Detective", "type": "GENRE", "name": "", "alias": "Detective" } }, ... ], "totalSize": 2 }, "countries": { "items": [ { "element": { "id": "3b9706f4-a681-47fb-918e-182ea9dfef0b", "type": "COUNTRY", "name": "", "alias": "russia" } } ], "totalSize": 1 }, "subscriptions": { "items": [ { "element": { "id": "bc682dc6-c0f7-498e-9064-7d6cafd8ca66", "type": "SUBSCRIPTION", "name": "   ", "alias": "119228" } }, ... ], "totalSize": 7 }, "promoText": null, "innerColor": null, "updateRateDescription": null, "contentCountDescription": null, "copyright": null, "subscriptionStartDate": null, "subscriptionEndDate": null, "subscriptionActivateDate": null, "stickerText": null, "fullSeasonPriceText": null, "purchaseDate": null, "expireDate": null, "lastWatchedChildId": null, "bookmarkDate": null, "userRating": null, "consumeDate": null, "lastStartingDate": null, "watchDate": null, "startingDate": null, "earlyWatchDate": null } 


由于一部电影可以属于几种流派/订阅,因此仍然需要遍历所有流派,解析JSON,然后允许重复。

是的,我很幸运,在这里节省了很多时间。 如果不是您这种情况,并且您需要解析html内容,那么中心上的文章可以为您提供帮助,例如, here

我以为,“那是帽子。我们只能把帽子退下来。” 第二天我意识到:“重点是帽子”:数据不是绝对匹配的。 关于它下面。

首先,目录的大小显着不同:在数据集中-10,200,是从站点收集的-8870。这是根据数据集的历史性得出的:仅下载了站点上的内容以及2018年的比赛数据。 有些电影已经不可用了。 哎呀

其次,比赛的潜在属性是仅以下方面的持久直觉:

Feature5-年龄限制。 这很容易理解。 该属性的基数是5个唯一的浮点值和“ -1”。 在收集的数据中,发现“ ageAccessType”属性的基数仅为5。映射如下所示:

 catalogue.age_rating = catalogue.age_rating.map({0: 0, 0.4496666915: 6 0.5927161087: 12 0.6547073468: 16 0.6804096966000001: 18}) 

Feature2-通过电影搜索转换后的电影分级。 最初,在EDA阶段,我们正在处理评级的想法是通过参数与总观看次数的相关性提出的。 随后,相信此评级来自电影搜索,从而证实了站点数据中存在“ kinopoiskRating”参数。

离比赛更近一步! 现在,仍然需要找到一种方法来逆转以匿名形式显示的feature2参数的转换。

这是feature2中值的分布形式



因此, kinopoiskRating参数值的分布为:



当我将这些图像展示给我的同事Sasha时,他立即看到这是3度。 不尊重三个数学家,但是Pi的个数是偶数。 结果,结果是这样的:



似乎全部,但不完全是。 我们看到了相同的分布,但标称值和数量仍未收敛。 如果我们知道一定数量的比较示例,则只需近似线性函数即可找到因子。 但是我们没有它们。

顺便说一下,近似不是最合适的词。 我们需要一个误差几乎为零的解决方案。 收集的数据的准确性是在分隔符后2个字符。 如果您认为有很多电影的评分为6.xx,而有些电影的评分相同,那么您应该在这里争取精度。

您还能尝试什么? 您可以依赖最小值和最大值并使用MinMaxScaler,但是此方法的不可靠性立即引起怀疑。 让我提醒您,影片的数量最初并不一致,我们的数据集是历史数据,并且是网站上的当前状态。 即 我们无法保证两组中最低和最高收视率的电影是相同的(事实证明它们的年龄限制不同,并且时长未从“完全”这个词收敛),也无法确定OKKO在多长时间内更新一次API每天更改电影搜索等级。

因此很明显,我需要更多的属性候选者进行匹配。

还有什么有趣的?

feature1是某种日期。 从理论上讲,对于日期,组织者承诺保留状态分离,这暗示着线性函数。 通常,转换应与交互矩阵的ts属性相同。 如果您按Feature_1看电影的发行...



...然后,电影上映日期的假说立即消失了。 直觉表明,该行业生产的电影数量应随时间增加。 确认如下。

我们从网站收到的数据中有14个属性。 实际上,不幸的是,这些值仅包含以下内容:







上面的示例均与feature_1相似。 好了,没有更多的可比较的想法了,看来这项任务的所有大惊小怪都是徒劳的。 当然,我听说过比赛,他们是手动标记数据的,但是当涉及成千上万的副本时却不是。

解决方案


1.简单的模型

意识到并非一切都如此简单,所以我开始谦虚地研究实际情况。 首先,我想从一个简单的开始。 我尝试了几种行业中常用的解决方案,即: 协同过滤 (在我们的案例中基于项目)和矩阵分解

社区广泛使用了一些适合这些任务的python库: 隐式LightFM 。 第一个能够基于ALS进行分解,并具有最近邻协作过滤功能,并具有用于预处理项目-项目矩阵的多个选项。 第二个因素有两个独特的因素:

  • 分解基于SGD,这使得可以使用基于采样的损失函数,包括WARP
  • 它使用一种混合方法,以这种方式组合有关用户属性和模型中项目的信息,以使用户的潜矢量是其属性的潜矢量之和。 和类似的项目。 当用户/项目出现冷启动问题时,此方法变得极为方便。

我们没有任何用户属性(除了能够根据与电影的互动来显示用户的属性),因此我仅使用项目的属性。

总共有6个设置用于枚举参数。 三种矩阵的组合用作交互矩阵,其中评级被转换为二进制。 下表中每种设置具有最佳超参数的比较结果。
型号在20时测试MNAP
隐式ALS0.02646
隐式余弦kNN CF0.03170
隐式TFIDF kNN CF0.03113
LightFM(无项目功能),BPR丢失0.02567
LightFM(无项目功能),WARP丢失0.02632
具有项目功能的LightFM,WARP丢失0.02635

如您所见,经典协作过滤已被证明比其他模型要好得多。 并不完美,但基线并不需要太多。 使用此配置提交在公共排行榜上给出了0.03048。 我当时不记得这个职位,但是在比赛结束时,这项参赛作品肯定会进入前80名并获得铜牌。

2.你好助推

有什么能比一个模型更好? 正确:几种型号。

因此,下一个选择是合奏,或者在建议的范围内,选择第二个等级。 作为一种方法,我从Avito的同事那里摘取了这篇文章。 似乎严格按照配方烹饪,并定期搅拌和调味,具有薄膜的特性。 唯一的偏差是应聘者人数:我从LightFM入围前200名,因为 拥有500,000个用户,更根本无法容纳到内存中。

结果,我在验证中获得的速度比在一个模型上差。

经过几天的试验,人们意识到没有任何事情在起作用,也没有任何东西可以单独起作用。 或我不知道该如何烹饪(剧透:正确的第二个答案)。 我做错了什么? 我想到两个原因:

  • 一方面,从生成“硬阴性”样本的角度来看,从一级模型中获取前200名是明智的。 与用户也相关但未被用户观看的电影。 另一方面,在测试期间可以观看其中一些电影,我们将这些示例作为底片展示。 接下来,我决定降低这一事实的风险,并通过以下实验重新验证该假设:我将所有阳性样本+随机样本作为训练样本。 测试样品的速度没有提高。 在这里有必要澄清一下,在测试抽样中,第一层模型也有最高的预测,因为在排行榜上没有人会告诉我所有积极的例子。
  • 目录中的10200部电影中,只有8296部进行了互动。 近2,000部电影被剥夺了用户的注意力,部分原因是这些电影无法购买/租赁/作为订阅的一部分。 聊天中的人问,在考试期间是否可以放映一些难以接近的电影。 答案是肯定的。 绝对不可能将它们丢弃。 因此,我建议在接下来的2个月内再提供近2,000部电影。 否则,为什么要将它们放入数据集中?

3.神经元

在上一段中提出了一个问题:我们如何处理完全没有互动的电影? 是的,我们还记得LightFM中的项目功能,但是正如我们所记得的,它们没有输入。 还有什么 神经元!

开源的阿森纳(Arsenal)有一些相当受欢迎的高级库,可与推荐系统一起使用:Maciej Kula(LightFM的作者)和TensorRec的Spotlight 。 第一个是PyTorch,第二个是-Tensorflow。

Spotlight可以分解具有神经元和模型序列的隐式/显式数据集。 同时,在“开箱即用”的因式分解中,无法添加用户/项目功能,因此请将其删除。

相反,TensorRec只知道如何分解,并且是一个有趣的框架:

  • 表示图-嵌入时转换输入数据的一种方法(可以为用户/项目设置不同的值),根据该方法将在预测图中进行计算。 该选择由具有不同激活选项的图层组成。 也可以使用抽象类并坚持进行自定义转换,该转换由一系列keras图层组成。
  • 预测图可让您选择最后的运算:您最喜欢的点积,欧几里德和余弦距离。
  • 损失-也有很多选择。 我们对WMRB的实施感到满意(本质上是相同的WARP,只知道如何学习批量和分布式)

最重要的是,TensorRec能够使用上下文功能,实际上作者承认他最初是受LightFM的启发。 好吧,让我们看看。 我们采用交互(仅交易)和项目功能。

我们发送到各种配置的搜索并等待。 与LightFM相比,培训和验证需要很长时间。

此外,还带来了一些不便之处:

  1. 从更改详细标志开始, fit方法没有任何更改,也没有为您提供回调。 我必须编写一个函数,使用fit_partial方法在内部训练一个时代,然后运行训练和测试的验证(在两种情况下,都使用样本来加快过程)。
  2. 总的来说,该框架的作者非常出色,并且在任何地方都使用tf.SparseTensor。 但是,值得一提的是,作为预测(包括用于验证),您会得到密集的结果(矢量),每个用户的长度为n_items。 这有两个技巧:创建一个循环,使用top-k过滤批量生成预测(库方法没有这样的参数),并使用RAM准备条带。

最终,在最佳配置选项下,我设法将测试样本压缩为0.02869。 有一些类似于LB。

好吧,我希望什么? 在项目特征上添加非线性会增加指标的两倍吗? 天真。

4.贝克的任务

所以等等。 看来我再次遇到了杂耍的神经元。 从事这项业务时,我想检验什么假设? 假设是:“在延迟选择的后两个月中,排行榜上将出现近2,000部新电影。 其中一些人会占很大比例。”

因此,您可以分两步进行检查:

  1. 很高兴看到我们在测试期间诚实地增加了多少部电影,涉及火车。 如果我们只看风景,那么“新”电影只有240(!)。 该假设立即动摇。 似乎购买新内容的时间间隔不大。
  2. 我们完成。 我们有机会训练模型以仅使用基于项目特征的表示形式(例如,在LightFM中,如果未使用身份矩阵预先填充属性矩阵,则默认情况下会完成此操作)。 此外,出于不诚实,我们只能服从该模型(!)我们无法访问且从未看过的电影。 从这些结果中,我们提交并获得0.0000136。

宾果! 这意味着您可以停止从电影属性中挤出语义。 顺便说一句,后来在DataFest上,OKKO的家伙说,大部分无法访问的内容只是一些老电影。

您需要尝试新事物,以及新事物-被遗忘的旧事物。 就我们而言,并不是完全被遗忘,而是几天前发生的事情。 协同过滤?

5.调整基线

我如何帮助CF基线?

主意1

在互联网上,我找到了一个关于使用似然比测试过滤掉小电影的演示。

在下面,我将留下我的python代码来计算LLR得分,我不得不在膝盖上写下以测试该想法。

LLR计算
 import numpy as np from scipy.sparse import csr_matrix from tqdm import tqdm class LLR: def __init__(self, interaction_matrix, interaction_matrix_2=None): interactions, lack_of_interactions = self.make_two_tables(interaction_matrix) if interaction_matrix_2 is not None: interactions_2, lack_of_interactions_2 = self.make_two_tables(interaction_matrix_2) else: interactions_2, lack_of_interactions_2 = interactions, lack_of_interactions self.num_items = interaction_matrix.shape[1] self.llr_matrix = np.zeros((self.num_items, self.num_items)) # k11 - item-item co-occurrence self.k_11 = np.dot(interactions, interactions_2.T) # k12 - how many times row elements was bought without column elements self.k_12 = np.dot(interactions, lack_of_interactions_2.T) # k21 - how many times column elements was bought without row elements self.k_21 = np.dot(lack_of_interactions, interactions_2.T) # k22 - how many times elements was not bought together self.k_22 = np.dot(lack_of_interactions, lack_of_interactions_2.T) def make_two_tables(self, interaction_matrix): interactions = interaction_matrix if type(interactions) == csr_matrix: interactions = interactions.todense() interactions = np.array(interactions.astype(bool).T) lack_of_interactions = ~interactions interactions = np.array(interactions, dtype=np.float32) lack_of_interactions = np.array(lack_of_interactions, dtype=np.float32) return interactions, lack_of_interactions def entropy(self, k): N = np.sum(k) return np.nansum((k / N + (k == 0)) * np.log(k / N)) def get_LLR(self, item_1, item_2): k = np.array([[self.k_11[item_1, item_2], self.k_12[item_1, item_2]], [self.k_21[item_1, item_2], self.k_22[item_1, item_2]]]) LLR = 2 * np.sum(k) * (self.entropy(k) - self.entropy(np.sum(k, axis=0)) - self.entropy(np.sum(k, axis=1))) return LLR def compute_llr_matrix(self): for item_1 in range(self.num_items): for item_2 in range(item_1, self.num_items): self.llr_matrix[item_1, item_2] = self.get_LLR(item_1, item_2) if item_1 != item_2: self.llr_matrix[item_2, item_1] = self.llr_matrix[item_1, item_2] def get_top_n(self, n=100, mask=False): filtered_matrix = self.llr_matrix.copy() for i in tqdm(range(filtered_matrix.shape[0])): ind = np.argpartition(filtered_matrix[i], -n)[-n:] filtered_matrix[i][[x for x in range(filtered_matrix.shape[0]) if x not in ind]] = 0 if mask: return filtered_matrix != 0 else: return filtered_matrix 


结果,所得矩阵可用作蒙版,仅保留最重要的交互作用,并且有两种选择:使用阈值或保留具有最高值的前k个元素。 同样,将对购物的几种影响的组合以一种速度进行排名,换句话说,该测试表明了它的重要性,例如,添加到有关可能转换为购买的收藏夹中。 看起来很有希望,但是使用表明,使用LLR分数进行过滤会产生很小的增加,而将多个分数组合只会使结果恶化。 显然,该方法不适用于此数据。 在优点中,我只能指出,在弄清楚如何实现此想法的同时,我不得不深入研究了内幕。

在猫的下方将保留一个以隐式方式应用此自定义逻辑的示例。

隐式修改矩阵
 #   LLR scores.     n_items * n_items. llr = LLR(train_csr, train_csr) llr.compute_llr_matrix() #     id, ,      llr_based_mask = llr.get_top_n(n=500, mask=True) #     ,  - CosineRecommender. model = CosineRecommender(K=10200) model.fit(train_csr.T) # model.similarity -   co-occurrence (  Cosine - ).        . masked_matrix = np.array(model.similarity.todense()) * llr_based_mask #  fit()  scorer     model.similarity. #     . model.scorer = NearestNeighboursScorer(csr_matrix(masked_matrix)) #      :   recommend   . test_predict = {} for id_ in tqdm(np.unique(test_csr.nonzero()[0])): test_predict[id_] = model.recommend(id_, train_csr, filter_already_liked_items=True, N=20) #   tuples (item_id, score),   id  . test_predict_ids = {k: [x[0] for x in v] for k, v in test_predict.items()} #       / ,     ,   Cython. 


想法2

提出了另一个想法,可以改善对简单协作过滤的预测。 业界通常使用某种阻尼函数来进行旧的估算,但是我们的情况并非如此简单。 可能,您需要以某种方式考虑2种可能的情况:

  1. 观看该服务的用户大多是新用户
  2. “审查员。” 也就是说,那些来得较近并且可以观看以前很流行的电影的人。

因此,可以将协作组件自动划分为两个不同的组。 为此,我发明了一个函数,该函数代替在用户和影片的交集处将隐式值设置为“ 1”或“ 0”,而该值显示该影片在用户观看历史中的重要性。

= α * 小号一个ř Ť Ê + β * Δ W¯¯ Ç ħ Ť 中号Ë


其中StartTime是影片的发行日期,而ΔWatchTime是发行日期和用户观看日期之间的差, αβ是超参数。

在这里,第一个术语负责提高最近发行的电影的速度,第二个术语负责考虑用户对旧电影的沉迷。 如果电影是很久以前发行的,并且用户立即购买了电影,那么在今天,应该不考虑这个事实。 如果这是新奇的事物,那么我们必须将其推荐给更多也观看新商品的用户。 如果电影很古老,并且用户只是现在才看过,那么这对于像他这样的人也很重要。

仍然有一个琐碎的工作,可以对系数αβ进行分类 。 过夜,工作就完成了。 根本没有关于范围的假设,因此该范围最初很大,下面是局部最优搜索的结果。



使用这个想法,在一个矩阵上建立一个简单的模型,本地验证的速度为0.03627,公共LB的速度为0.03685,与之前的结果相比,它看起来像是一个很好的提升。 当时,它使我进入了前20名。

想法3

最终的想法是,用户观看了很长时间的旧电影完全可以忽略。这种CF筛选方法通常称为修剪。我们来看几个值并检验假设:



事实证明,对于拥有悠久历史的用户,仅保留最后25个交互是有意义的。

总的来说,通过处理数据,我们在本地测试样本上获得了0.040312的速度,使用这些参数提交的结果分别在公共/私人部分给出了0.03870和0.03990的结果,并为我提供了第14名和一件T恤。

确认的细分


在jupyter Notebook中运行项目是一项不费力的工作。您总是迷失在分散在多个笔记本中的代码中。就可重复性而言,仅在输出单元中跟踪结果完全危险。因此,数据专家可以尽其所能。我和我的同事创建了我们的cookiecutter数据科学框架-海洋。这是用于创建结构和项目管理的工具。您可以在我们的文章中了解其优势很大程度上是由于采用了分离实验的好方法,所以在检验假设时,我没有失去理智,也没有感到困惑。

成功的秘诀(不是我的)


众所周知,排行榜的私有部分被打开后,几乎所有拥有顶级解决方案的人都在第一层使用了简单模型,在第二层使用了附加功能。正如我稍后所了解的,我在此实验中的穿刺主要是在训练第二级模型时分割样本的一种方法。我尝试过:



意识到这很愚蠢,我开始寻找非常复杂的方法,例如这样的方法:



嗯,我从第二名的Evgeny Smirnov的演讲中找到了正确的方法。在一级模型的测试部分,这是用户的分裂。看起来微不足道,但是这个主意并没有出现在我身上。



结论


竞赛是竞赛。重型模型确实可以提供更高的准确性,特别是如果您可以烹饪的话。这种经验会派上用场吗?除了回答之外,我要说的是比赛结束后,组织者抛弃了破坏者-产品模型中的功能重要性图,根据该图,很明显他们在生产中使用了相同的“成功”方法。事实证明,它们也在产品中流动。

事实证明,对我而言,参与的经验在专业上真的很有用。感谢您阅读本文;欢迎提出问题,建议,意见。

Source: https://habr.com/ru/post/zh-CN461055/


All Articles