RISC-V 函数调用约定

调用函数前后存在旧数据与新数据两种状态,RISC-V 对使用哪些寄存器存储前后状态做了人为规定,这样的一系列规定称为调用约定(Calling Convention)。CS 61C 的补充资料在这方面描述得最为明了,本文主要根据这篇文章对 RISC-V 调用约定的要点做了总结。 基本定义 首先将发起调用的函数称为调用者(caller),将被调用的函数称为被调用者(callee)。注意,一个函数是调用者或被调用者是由其行为决定,当它被其他函数调用时是被调用者,当它调用其他函数时是调用者,两个身份可以先后存在。 其次 RISC-V 约定在一部分寄存器中的内容在调用函数后不会被改变,称为由被调用者保存的寄存器(callee-saved registers),包括 s0 - s11(保存寄存器,saved registers)和 sp。调用函数可能更改另一部分寄存器中的内容,这些寄存器称为由调用者保存的寄存器(caller-saved registers ),包括 a0 - a7(参数寄存器,argument registers)、t0 - t6(临时寄存器,temporary registers)和 ra(返回地址,return address)。 寄存器的功能和约定可以总结如下表: 编号 寄存器 ABI 名称 描述 保存方 0 x0 zero 常数 0 - 1 x1 ra 返回地址 caller 2 x2 sp 栈指针 callee 3 x3 gp 全局指针 - 4 x4 tp 线程指针 - 5 ~ 7 x6 ~ x7 t0 ~ t2 临时 caller 8 x8 s0 / fp 保存 / 帧指针 callee 9 x9 s1 保存 callee 10 ~ 11 x10 ~ x11 a0 ~ a1 函数参数 / 返回值 caller 12 ~ 17 x12 ~ x17 a2 ~ a7 函数参数 caller 18 ~ 27 x18 ~ x27 s2 ~ s11 保存 callee 28 ~ 31 x28 ~ x31 t3 ~ t6 临时 caller 实际案例 从抽象的概念来看比较难以理解,接下来将以上概念代入到几个具体的案例中理解 RISC-V 的调用约定。 调用者的视角 当我们调用一个函数时,被调用的函数对由其保存的寄存器负责,也就是说由被调用者保存的寄存器内容在该函数调用前后不变。但「不变」并不是指该函数无法使用这些寄存器,实际上函数可以使用任何一个寄存器,RISC-V 中的寄存器并没有「使用权限」的概念,只是在函数结束前必须将修改值恢复原状——这个过程我形象地称其为对寄存器中的值负责(preserve)。 将以上过程抽象为黑盒,从调用者的视角来看,当然可以认为被调用的函数不会修改由被调用者保存寄存器中的值。 上述过程可以通过以下代码理解: addi s0, x0, 5 # 寄存器 s0 的值为 5 jal ra, func # 调用 func addi s0, s0, 0 # 不论 func 是什么,s0 的值还是 5 从反面来思考,就会意识到被调用的函数不对由调用者保存的寄存器负责,也就是说在该函数结束后,由调用者保存寄存器中的值是不可靠的垃圾值: addi t0, x0, 5 # 寄存器 s0 的值为 5 jal ra, func # 调用 func addi t0, t0, 0 # t0 中的值是垃圾值! {warn}在调用函数后,不由被调用者负责寄存器中的值是否发生改变,实际取决于函数的实现,但作为负责编码的工程师,理应将这些寄存器中的值都视为垃圾,不应当依赖垃圾值执行程序。{end warn} 规避垃圾值问题的技巧是,在寄存器中的值变得不可靠前,预先将其中值保存下来: addi t0, x0, 5 # t0 中的值为 5 addi a0, t0, 10 # a0 中的值为 15,a0 是函数参数 # 调用函数前,调用者需要做的事 addi sp, sp, -8 # 栈指针向下移动 sw t0, 0(sp) # 将 t0 的值压入栈帧 sw a0, 4(sp) # 将 a0 的值压入栈帧 jal ra, func # 调用函数 func mv s0, a0 # 将函数返回值 a0 中的值存入 s0 mv s1, a1 # 将函数返回值 a1 中的值存入 s1 # 调用函数后,调用者需要做的事 lw t0, 0(sp) # 从栈帧中弹出原先 t0 的值,并把值写入 t0 lw a0, 4(sp) # 从栈帧中弹出原先 a0 的值,并把值写入 a0 addi sp, sp, 8 # 栈指针向上移动 # 目前 t0 与 a0 的值都是可靠的,因为它们的值是预先存入栈帧并从中还原的 从上面的代码中可以观察出 2 点,也是调用者视角下的调用约定: 在调用者函数内,在调用函数前后,必须通过栈内存手动维护由调用者保存的寄存器前后一致(如 a0 和 t0); 在调用者函数内,可以任意修改由被调用者保存寄存器而不用担心副作用(如 s0 和 s1)。 被调用者视角 理解调用者视角下的调用约定后,就不难理解被调用者视角下的操作了。直接观察下例: # 函数正式操作前,被调用者需要做的事 addi sp, sp, -12 # 栈指针向下移动 sw ra, 0(sp) # 将 ra 的值压入栈帧 sw s0, 4(sp) # 将 s0 的值压入栈帧 sw s1, 8(sp) # 将 s1 的值压入栈帧 # 函数正式操作 # 函数正式操作后,被调用者需要做的事 lw ra, 0(sp) # 从栈帧中弹出原先 ra 的值,并把值写入 ra lw s0, 4(sp) # 从栈帧中弹出原先 s0 的值,并把值写入 s0 lw s1, 8(sp) # 从栈帧中弹出原先 s1 的值,并把值写入 s1 addi sp, sp, 12 # 栈指针向上移动 ret # 从函数中返回 可以得出类似的 2 点调用约定: 在被调用者函数内,在函数正式操作前后,必须通过栈内存手动维护由被调用者保存的寄存器前后一致(如 ra、s0 和 s1); 在被调用者函数内,可以任意修改由调用者保存寄存器而不用担心副作用。 总而言之,RISC-V 的调用约定中规定了寄存器的保存方(saver),函数(caller / callee)对其相应寄存器中的内容负责,caller 维护 caller-saved 寄存器,callee 维护 callee-saved 寄存器。因为需要维护寄存器内容,在函数正式操作前都要把需要维护的内容存入栈内存,并在函数操作结束后从中还原。 References Calling Convention - CS 61C Fall 2024 Calling Convention - RISC-V

2024/12/1
articleCard.readMore

[译] Understanding Incremental Decoding in fairseq

近来一直在使用 fairseq 做项目,因为其功能较多而源码也比较复杂,光靠官方文档也难以完全理解。ankur6ue 的一篇文章对 fairseq 中的增量解码(Incremental Decoding)操作做了详尽的介绍,于是我节选了其中的一部分,将其译为中文,希望对和我一样在读源码的朋友有所帮助。 推理过程中的增量解码 在语言翻译任务的推理过程,解码器逐步地输出目标语言词汇的概率分布。最简单的翻译算法通过贪心策略直接选择概率最高的目标词,这个方法和训练过程中计算损失函数的方式一致。另一种方法则是保存所有可能的目标序列,再从中选出最小化对数似然的的结果。但这种方法需要基于似然搜索所有的可能序列,同时由于词表大小通常在数百上千的规模,导致计算开销随着序列长度指数上升。 集束搜索(Beam Search)在两种极端策略间取得了平衡,由于网络上已经有许多非常好的教程,本文不在此展开介绍集束搜索的工作原理。因为集束搜索在每个步骤只考虑 \(B\) 个前缀序列,搜索空间就由 \(V\times V\) 下降至 \(B\times V\)(其中 \(B\) 为集束宽度,\(V\) 为目标词表的大小),所以相比暴力搜索的方法显著高效。解码结果缺乏多样性是集束搜索的一项缺陷,因为一条输入序列可能具有多条正确翻译,这项缺陷就会影响翻译任务。针对该问题也提出了许多解决方法,例如 Diverse Beam Search 在标准的集束分数中添加了一个差异项,通过对先前步骤已使用过的词施加惩罚并使用 top-k 随机取样在下一步的生成中随机选出前 k 个最有可能的备选项(代替了集束搜索中永远选择前 \(B\) 个备选项的取样方式),从而产生更多样的结果。 尽管集束搜索比蛮力搜索更高效,但由于每个步骤都要重新计算所有前缀 token(prefix tokens),其计算开销会随着解码序列长度的增加而线性增长。 在这个例子中,用 A、B、C 等字母表示 token,在推理时,集束会扩展成由翻译出句子所构成的 batch。如果输入 batch 由 2 个句子构成,同时将集束宽度设置为 3,最终得到 batch 的大小就为 6。在计算过程中,每个集束都作为 batch 中的元素并行计算。 当模型完成前面一部分的 token 的解码计算后,我们就会思考:是否可以重复利用这些计算结果?其实增量解码正是这个思路的实现。增量解码使用名为增量状态(incremental state)的数据结构保存先前计算结果,用于后续的卷积计算。在每个计算步骤中,解码器只需对当前 token 做计算,若是模型中的某些层需要先前 token 的信息(例如卷积层),则从增量状态中取出所需结果。而在编码器的计算过程中,编码器与解码出的目标序列无关,它只在一开始时计算输入序列并产生每个输入字词的编码,这些编码本就会被解码器重复使用。 增量解码如何节省计算开销? 增量解码的具体实现稍有些复杂,希望下图能够帮助读者更好地理解整个过程。我推荐读者尝试使用 Python Debugger 在以下代码中设置断点,相信能够更容易理解每一步所做的操作。 {caption}在第(1)步中,input_buffer 中每个卷积模块对应的值都是 None,其内存大小由 beam_size (3)、conv_kernel_width (3) 和 conv_kernel_input_dimension (512) 分配,初始化为 0。{end caption} 我们假设输入 1 条句子,那么 batch 的大小与集束宽度相等(该样例中为 3)。首先,每个集束都由开始标记构成(BOS),因而输入卷积层的嵌入向量相等。 {caption}在完成创建和初始化后,input_buffer 如上图所示{end caption} 然后 input_buffer 左移 1 个位置并将输入添加到最后一列。由于 input_buffer 全由 0 填充,左移后并没有明显的变化,不过我们在下一步就会看到它的作用。 {caption}在最右侧填入输入列后的 incremental_state 如上图所示,因为在第(1)步中的所有输入 token 都是 BOS token,填入每个集束的输入向量也都相同。{end caption} 接着,输入数据与卷积滤波器做计算,将计算结果传递给后续层——即 GLU 和注意力层。 现在我们考虑下一个线性卷积层模块,进入该层的输入来源于前一个模块。正如前一个部分中的卷积层,该卷积层也有自己的增量状态,同样由 0 初始化并填入输入数据。 和先前一样,输入数据与卷积核做计算并传入 GLU 和注意力层,解码器中的所有结构都重复这一过程,最后的输出就为每个集束的词表概率分布向量。集束搜索算法从该结果中得到最优的 token,用于下一步的计算,在这里用 A、B、C 表示解码结果。 接下来我们考虑在步骤(2)所做的操作,我们目前的集束为 再一次重复第一层的卷积操作,由于每个集束的输入 token 不同(A、B 和 C),其嵌入向量也不再相等。此外,由于步骤(1)中的初始化过程,input_buffer 也不再全为 0。 接着,input_buffer 左移并将新的输入添加至最后一列。 每个集束的 input_buffer 与卷积核做计算后传入到后续层中,与先前步骤中相同。 整个过程中,input_buffer 作为内存记录先前步骤给出的结果,用于计算卷积结果。input_buffer 同时节省了计算开销,这一点可以在下一个卷积操作中看出。 由于 input_buffer 中保存了先前步骤的输入,在当前步骤中可以直接用于完成卷积计算。最重要的是,先前步骤的输入是再前一个步骤的计算结果,保存的计算结果就避免了解码过程中的重复计算,从而节省计算开销。后续过程与前文所述一样,左移并填入输入,完成卷积计算。 为什么需要重排增量状态? 在每一步开始前,generator 都会重新排列解码器和编码器的 incremental_state。 这是由于集束搜索会导致每个集束中前缀 token 的顺序发生改变,通过一个简单的样例描述这个过程。假设下图是步骤(2)得到的集束状态,其中展示了每个步骤得到的 token 和预测分数。 到了步骤(3)时,通过预测分数得到 N、P 和 S,箭头表示了每个结果 token 的来源。 (译者案:作者在这里描述得不是很清晰,需要额外补充一些说明。集束搜索过程如下图示意,其主要操作是在每一步中只取 top-k 个预测分数最高的结果作为下一步的前缀 token,其他分支中止不再计算。在该例中,输入 BOS token 后,在若干结果中取 top-3 分数最高的结果,分别为 A、B 和 C。那么下一步的输入就为 [BOS A]、[BOS B] 和 [BOS C],再取 top-3 分数最高的结果。由 A 生成的结果分数无法位于 top-3,A token 所属的分支就被中止,后续不会再计算,在 buffer 中存储其状态也是无用的了,因此要将其替换为有效的前缀 token。) 于是就如下图所示,重新排列每个集束。 当对当前 token N、P 和 S 执行解码操作(预测下一个 token)时,我们必须重排 incremental_state 使卷积操作能够使用正确的前缀 token。这个操作可能不能马上明白,需要花些时间仔细理解。 另外还有一点,fairseq 的代码也重新排列了编码器的状态,然而由于编码器状态只取决于输入 token,并不会随集束状态改变,其重排也就不是必需的,至少在本文的例子中不需要这样的操作。 为什么集束搜索返回的 token 数量是集束数量的两倍? 在 fairseq 中,集束搜索返回输出 token 的数量是集束数量的两倍。这是由于集束搜索中的部分集束可能会返回表示句子结束的 EOS token,而我们不想要集束搜索太早就停止。当 EOS token 出现在结果的前半部分时,可以将预测总分与其他已有结果的分数相比较从而完成句子。下图展示了相关代码并附上了一些注释,希望能有助读者理解。 {caption}(a)返回表示集束中具有 EOS token 的掩码;(b)具有 EOS token 集束对应的索引,只有在 EOS 出现在前半部分的情况下(:beam_size)。注意集束搜索返回 2 * beam_size 个结果;(c)对于前 beam_size 个具有 EOS 的集束,组合预测结果并判断是否完成句子。如果是,减少剩余句子的数量,注意我们处理的是一整个 batch 的输入句子;(d)如果剩余句子的数量是 0,完成;(e)如果能够完成一整个 batch 的目标句子,从 batch 中移除元素并调整 batch 索引。{end caption} if (!document.getElementById('mathjaxscript_pelican_#%@#$@#')) { var align = "center", indent = "0em", linebreak = "false"; if (false) { align = (screen.width

2024/8/4
articleCard.readMore

将化学分子的 ChEMBL ID 转化为 SMILES 的两种方法

ChEMBL 是一个大型的化学分子数据库,其中收集了大量化合物的化学、生物学数据,也是化学信息学、生物信息学领域中许多研究的数据来源。很多情况下,不管是按需求从 ChEMBL 中提取出的指定数据,还是从文章中下载的原始数据都是使用 ChEMBL ID 作为化合物的标识。而为了进一步使用这些数据,通常要将其转换为例如 SMILES 或是 InChI 等更具体的分子表示。 本文分别介绍使用 ChEMBL 的 Web API 和 PostgreSQL 将 ChEMBL ID 转化为 SMILES 的两种方法,在得到 SMILES 之后,想获得其他的分子表示就也很容易了。 ChEMBL Web API ChEMBL 在其官方接口文档中介绍了多种获取数据库信息的方法,因为完成转化后通常还要额外处理数据,最灵活方便的还是其中的 ChEMBL Web 服务 Python 包 chembl-webresource-client,本文也主要介绍它的使用方法。 $ pip install chembl-webresource-client 通过 pip 安装后,通过 ChEMBL ID 过滤分子并取出其 "molecule_structures" 信息: >>> from chembl_webresource_client.new_client import new_client >>> molecule = new_client.molecule >>> info = molecule.filter(chembl_id="CHEMBL10").only(["molecule_structures"]) >>> info [{'molecule_structures': {'canonical_smiles': 'C[S+]([O-])c1ccc(-c2nc(-c3ccc(F)cc3)c(-c3ccncc3)[nH]2)cc1', 'molfile': '\n RDKit 2D\n\n 27 30 0 0 0 0 0 0 0 0999 V2000\n 10.3778 -8.9759 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 9.7898 -8.6390 0.0000 S 0 0 0 0 0 0 0 0 0 0 0 0\n 9.7877 -7.9612 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 9.0572 -9.0655 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 9.0585 -9.9128 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 8.3255 -10.3375 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 7.5911 -9.9151 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 7.5898 -9.0679 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 8.3228 -8.6431 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 6.8577 -10.3401 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 6.7798 -11.1765 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 5.9511 -11.3526 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.6063 -12.1270 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 6.0503 -12.8454 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.6484 -13.5912 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 4.8016 -13.6161 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 4.4801 -14.2128 0.0000 F 0 0 0 0 0 0 0 0 0 0 0 0\n 4.3566 -12.8952 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 4.7584 -12.1493 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5275 -10.6189 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 6.0943 -9.9893 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 4.6845 -10.5304 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 4.1385 -11.1746 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.3050 -11.0228 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.0196 -10.2251 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 3.5678 -9.5791 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 4.4013 -9.7308 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 1 0\n 2 3 1 0\n 2 4 1 0\n 4 5 2 0\n 5 6 1 0\n 6 7 2 0\n 7 8 1 0\n 8 9 2 0\n 9 4 1 0\n 7 10 1 0\n 10 11 2 0\n 11 12 1 0\n 12 13 1 0\n 13 14 2 0\n 14 15 1 0\n 15 16 2 0\n 16 17 1 0\n 16 18 1 0\n 18 19 2 0\n 19 13 1 0\n 12 20 2 0\n 20 21 1 0\n 21 10 1 0\n 20 22 1 0\n 22 23 2 0\n 23 24 1 0\n 24 25 2 0\n 25 26 1 0\n 26 27 2 0\n 27 22 1 0\nM CHG 2 2 1 3 -1\nM END\n> <chembl_id>\nNone\n\n> <chembl_pref_name>\nundefined', 'standard_inchi': 'InChI=1S/C21H16FN3OS/c1-27(26)18-8-4-16(5-9-18)21-24-19(14-2-6-17(22)7-3-14)20(25-21)15-10-12-23-13-11-15/h2-13H,1H3,(H,24,25)', 'standard_inchi_key': 'CDMGBJANTYXAIV-UHFFFAOYSA-N'}}] 尽管指定了仅 "molecule_structures" 部分的信息,但还是输出了很长结果,其中包括 InChI 等各种结构数据 ,只有 'canonical_smiles' 字段是目标信息。 >>> type(info) chembl_webresource_client.query_set.QuerySet 查询结果对象是与 Django QuerySet 相似的 chembl_webresource_client.query_set.QuerySet,支持多种类似的过滤操作,具体内容可以查看官方案例。 处理单条数据 QuerySet 同样也兼容列表、字典等 Python 基本数据结构的操作,所以对于单条 ChEMBL ID,我们可以通过以下方法很方便地直接取出查询到的 SMILES 结果: >>> chembl_id = "CHEMBL10" >>> molecule.filter(chembl_id=chembl_id).only(["molecule_structures"])[0]["molecule_structures"]["canonical_smiles"] 'C[S+]([O-])c1ccc(-c2nc(-c3ccc(F)cc3)c(-c3ccncc3)[nH]2)cc1' 处理多条数据 若要查询多条 ChEMBL ID,只需要把参数 chembl_id 改为 molecule_chembl_id__in 并传入ChEMBL ID 的列表即可。 {warn}输入 ChEMBL ID 列表时,参数名称 molecule_chembl_id__in 中位于 “in” 前的是双下划线。{end warn} >>> chembl_id = ["CHEMBL10", "CHEMBL100", "CHEMBL100"] >>> infos = molecule.filter(molecule_chembl_id__in=chembl_id).only(["molecule_chembl_id", "molecule_structures"]) >>> {info["molecule_chembl_id"]: info["molecule_structures"]["canonical_smiles"] for info in infos} {'CHEMBL10': 'C[S+]([O-])c1ccc(-c2nc(-c3ccc(F)cc3)c(-c3ccncc3)[nH]2)cc1', 'CHEMBL100': 'CC1(C)Oc2ccc(C#N)cc2[C@@H](N2CCCC2=O)[C@@H]1O', 'CHEMBL1000': 'O=C(O)COCCN1CCN(C(c2ccccc2)c2ccc(Cl)cc2)CC1'} 通过这种方法查询 SMILES 的优点在于方便灵活,可以很容易地将查询代码嵌入处据处理的脚本中。但由于该方法是通过 Web API 查询结果,速度受限于网络,内容大小受限于 memory,无法用于大量数据的转化。经笔者测试,将 1000 条 ChEMBL ID 转化为 SMILES 耗时约 102 秒,用这种方法处理较小规模的数据更加合理。 PostgreSQL 若要将大批量的 ChEMBL ID 转化为 SMILES,更推荐将 ChEMBL 数据库下载至本地,查询的速度更快。ChEMBL 提供了MySQL、PostgreSQL 等多种数据库的下载方式,读者可以下载熟悉的数据库压缩包,本文选择在 Linux 上使用 PostgreSQL。当然,在安装 ChEMBL 数据库前必须安装相应的数据库软件,很容易就能检索到各种数据的安装方法,这里从略。 $ wget "https://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/latest/chembl_34_postgresql.tar.gz" -O chembl_34_postgresql.tar.gz $ tar -zxvf chembl_34_postgresql.tar.gz 在安装好对应的数据库软件后,下载并解压数据库压缩包,得到 chembl_34_postgresql.dmp 和 INSTALL_postgresql 两个文件,先来看看安装指导: $ cat INSTALL_postgresql ... Instructions ------------ 1. Log into PostgreSQL database server where you intend to load chembl data and run the following command to create new database: pgdb=# create database chembl_34; 2. Logout of database and run the following command to load data. You will need to replace USERNAME, HOST and PORT with local settings. Depending on your database setup you may not need the host and port arguments. $> pg_restore --no-owner -h HOST -p PORT -U USERNAME -d chembl_34 chembl_34_postgresql.dmp 创建数据库 按照安装指引,先通过 psql 进入 PostgreSQL 终端,进入终端后会显示 =# 提示符: $ psql psql (12.15 (Ubuntu 12.15-0ubuntu0.20.04.1)) Type "help" for help. postgres=# {note}正确安装 PostgreSQL 却无法通过 psql 进入数据库终端可能是用户问题,可以尝试通过 sudo -i -u postgres 切换到 PostgreSQL 的默认管理账号再进入。{end note} 使用指引提供的命令创建名为 chembl_34 的数据库,创建完成后使用 \l 可以看到新创建的数据库,最后用 exit 退出数据库终端: postgres=# CREATE DATABASE chembl_34; CREATE DATABASE postgres=# \l List of databases Name | Owner | Encoding | Collate | Ctype | Access privileges -----------+----------+----------+-------------+-------------+----------------------- chembl_34 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | postgres=# \q 导入数据库 $ pg_restore --no-owner -h HOST -p PORT -U USERNAME -d chembl_34 chembl_34_postgresql.dmp pg_restore 命令用于从数据库文件恢复(导入)数据库,使用到了以下几个参数: --no-owner:不与最初创建数据库的用户匹配; -h:PostgreSQL 服务主机地址; -p:PostgreSQL 服务端口号; -U:PostgreSQL 用户名,与上文创建数据库所使用的用户一致; -d:数据库名称,与上文中创建的数据库名保持一致; <path>:数据库文件的路径,也就是解压得到的 .dmp 文件。 如果忘记了 PostgreSQL 服务的端口号,可以使用以下命令查询本机的 PostgreSQL 监听端口: $ sudo netstat -plunt|grep postgres tcp 0 0 127.0.0.1:5432 0.0.0.0:* LISTEN 3887/postgres 因此我就使用以下命令导入数据库: $ pg_restore --no-owner -h 127.0.0.1 -p 5432 -U postgres -d chembl_34 chembl_34_postgresql.dmp Password: 输入 PostgreSQL 用户的密码后没有输出,就已经开始导入数据库了,大约耗时 30 分钟。 提取 SMILES 导入完成后进入数据库查看其结构: postgres=# \c chembl_34 You are now connected to database "chembl_34" as user "postgres". chembl_34=# 进入 chembl_34 数据库后可以使用 \dt 列出所有的表,使用 \d <table_name> 查看表信息。但是这样一个个查看实在太费事,好在 ChEMBL 提供了更直观的图示和文档。 打开下载页面中名为 chembl_34_schema.png 的文件,图片展示了 ChEMBL 数据库中的所有表、表字段以及表之间的关系,Schema Documentation 页面对字段信息做了详细介绍。 我们的目标是将 ChEMBL ID 转换为 SMILES,所以要寻找一条由 CHEMBL_ID 字段起始到达 CANONICAL_SMILES 字段的连线: {caption} 整张图片太大,因此对图片裁剪拼贴,该部分位于原图的左上角和右上角{end caption} 其中需要注意的是,文档中说明了 entity_id 与 molregno 字段的值相同,因此图中二者可以连结起来,图中的关系也可以用文字描述成 chembl_id_lookup: chembl_id → entity_id | molecule_dictionary: molregno | compound_structures: molregno → canonical_smiles 理清了关系后暂时先放在一边,接下来处理需要转换的 ChEMBL ID。将 ChEMBL ID 存储为 CSV 文件,类似于 chembl_id CHEMBL10 CHEMBL100 CHEMBL1000 CHEMBL10000 CHEMBL100004 CHEMBL100005 CHEMBL100006 CHEMBL100007 CHEMBL100008 CHEMBL100009 然后准备将 ChEMBL ID 导入为数据库中的一张新表,进入 chembl_34 数据库后,替换相应路径并执行命令: CREATE TEMPORARY TABLE input_chembl_id ( id SERIAL PRIMARY KEY, chembl_id VARCHAR(20) ); COPY input_chembl_id(chembl_id) FROM '/path/to/input_chembl_id.csv' CSV HEADER; 成功导入 CSV 后理应能够通过以下方法输出表中数据: chembl_34=# SELECT * FROM input_chembl_id LIMIT 10; id | chembl_id ----+-------------- 1 | CHEMBL10 2 | CHEMBL100 3 | CHEMBL1000 4 | CHEMBL10000 5 | CHEMBL100004 6 | CHEMBL100005 7 | CHEMBL100006 8 | CHEMBL100007 9 | CHEMBL100008 10 | CHEMBL100009 (10 rows) 根据上文得到的 chembl_id 与 canonical_smiles 字段间关系,可以编写 SQL 语句获取对应的 SMILES: SELECT input_chembl_id.chembl_id, compound_structures.canonical_smiles FROM (((input_chembl_id LEFT JOIN chembl_id_lookup ON input_chembl_id.chembl_id = chembl_id_lookup.chembl_id) LEFT JOIN molecule_dictionary ON molecule_dictionary.molregno = chembl_id_lookup.entity_id) LEFT JOIN compound_structures ON molecule_dictionary.molregno = compound_structures.molregno) LIMIT 10; 执行上述 SQL 即可输出 ChEMBL ID 对应的 SMILES: chembl_id | canonical_smiles --------------+----------------------------------------------------------- CHEMBL10 | C[S+]([O-])c1ccc(-c2nc(-c3ccc(F)cc3)c(-c3ccncc3)[nH]2)cc1 CHEMBL100 | CC1(C)Oc2ccc(C#N)cc2[C@@H](N2CCCC2=O)[C@@H]1O CHEMBL1000 | O=C(O)COCCN1CCN(C(c2ccccc2)c2ccc(Cl)cc2)CC1 CHEMBL10000 | O=c1oc(Nc2ccc(I)cc2)nc2ccccc12 CHEMBL100004 | CCO/C(O)=C1/C(C)=NC(C)=C(C(=O)OCCSc2ccccc2)C1C CHEMBL100005 | COC(=O)C(Cc1ccc2c(c1)OCO2)c1c2ccccc2nc2ccccc12 CHEMBL100006 | COc1cc(C)c(OC)c(CC(C)N)c1 CHEMBL100007 | CNC(Cc1cc(Br)ccc1N)c1sccc1C CHEMBL100008 | CN(C)C(=O)Cn1c(-c2ccc(Br)cc2)nc2cccnc21 CHEMBL100009 | CC1=NS(=O)(=O)c2ncccc2N1 (10 rows) 对于大规模的转换,通常需要查将询结果导出为 CSV 文件,那么查询并保存结果的完整 SQL 语句就为: COPY (SELECT input_chembl_id.chembl_id, compound_structures.canonical_smiles FROM (((input_chembl_id LEFT JOIN chembl_id_lookup ON input_chembl_id.chembl_id = chembl_id_lookup.chembl_id) LEFT JOIN molecule_dictionary ON molecule_dictionary.molregno = chembl_id_lookup.entity_id) LEFT JOIN compound_structures ON molecule_dictionary.molregno = compound_structures.molregno)) TO '/path/to/chembl_id_smiles.csv' WITH (FORMAT csv, HEADER); 替换其中的输出路径并执行即可得到结果。经测试,将 300 万条 ChEMBL ID 转化为 SMILES 耗时不到 30 秒,非常适合大规模数据的场景。

2024/5/7
articleCard.readMore

通过 PDM 和 GitHub Actions 在 PyPI 上自动化发布你的 Python 包吧

最近换用 PDM 作为主要的 Python 环境管理工具,虽然使用细节上还不太熟悉,但终究是搭配着 Anaconda 用起来了。PDM 是一款轻巧的工具,但它却涵盖了 Python 开发中的各种场景,例如自动生成项目的 pyproject.toml,自动解决 package 的版本依赖问题,就算我还未使用很久,也已经为之着迷了。 {caption} PDM 提供的各种功能{end caption} 很值得注意的是 PDM 提供了 build 的功能,能够将源码构建为 Python package 并发布到 PyPI,不再需要其他繁琐的工具,我也抱着极大的兴趣探索了结合 PDM 与 GitHub Actions 发布 Python 包的方法。PDM 提供了多平台的多种安装方法,读者可以根据自己的要求安装,本文在 Windows 上使用 pdm==2.15.0 作为演示。 初始化项目 安装好 PDM 后,新建项目文件夹,用终端进入文件夹并执行 pdm init 初始化项目,在这里我使用的是 PowerShell。PDM 会通过几个问答来指引用户初始化项目: > pdm init Creating a pyproject.toml for PDM... Please enter the Python interpreter to use 0. cpython@3.9 (F:\Miniconda\python.EXE) 1. cpython@3.12 (C:\Users\Leo\scoop\apps\python\current\python.exe) 2. cpython@3.12 (C:\Users\Leo\scoop\shims\python3.exe) 3. cpython@3.9 (D:\Python39\python.exe) 4. cpython@3.7 (D:\Python37\python37.exe) 5. cpython@3.7 (D:\Python37\python.exe) Please select (0): 3 Virtualenv is created successfully at D:\Code\gh-action-demo\.venv Project name (gh-action-demo):leo-gh-action-demo Project version (0.1.0): Do you want to build this project for distribution(such as wheel)? If yes, it will be installed by default when running `pdm install`. [y/n] (n): y Project description (): Which build backend to use? 0. pdm-backend 1. setuptools 2. flit-core 3. hatchling Please select (0): License(SPDX name) (MIT): Author name (Leo): Author email (im.yczeng@foxmail.com): Python requires('*' to allow any) (>=3.9): Project is initialized successfully 大部分设置项可以直接回车,选用括号中的默认值即可,比较重要的两项是 PDM 会自动搜寻设备上的 Python 解释器,需要根据需求选择项目的 Python 版本,如果输出中没有列出目标的 Python,就要检查 PDM 还是 Python 没有正确安装; 选择项目 build backend 为 pdm-backend,这一点没有特别的原因,只是因为我们在用 PDM 所以就都用 PDM 的 toolkit 好啦。 以上步骤都完成后,项目文件夹的结构会是 D:\CODE\GH-ACTION-DEMO │ .gitignore │ .pdm-python │ pyproject.toml │ README.md ├─.venv ├─src │ └─gh_action_demo └─tests .venv 是项目的虚拟环境,src 是项目源码目录,tests 用于存放测试文件。pyproject.toml 保存了项目的配置项,也包括 PDM 初始化时的配置,里面的内容也可以根据开发的需求手动修改: [project] name = "leo-gh-action-demo" version = "0.1.0" description = "Default template for PDM package" authors = [ {name = "Leo", email = "im.yczeng@foxmail.com"}, ] dependencies = [] requires-python = ">=3.9" readme = "README.md" license = {text = "MIT"} [build-system] requires = ["pdm-backend"] build-backend = "pdm.backend" [tool.pdm] distribution = true 从零开始的开发 如果在项目开始之前就选用了 PDM 管理环境那当然最好不过,直接在 src/<project_name> 目录中写入第一行代码吧。完成后执行 pdm build 开始构建,这个过程中 PDM 会生成以下内容: D:\CODE\GH-ACTION-DEMO ├─.pdm-build │ │ .gitignore │ └─gh_action_demo-0.1.0.dist-info │ METADATA │ WHEEL ├─dist │ gh_action_demo-0.1.0-py3-none-any.whl │ gh_action_demo-0.1.0.tar.gz ... .pdm-build 是构建过程产生的中间文件,dist 目录中就是可以用于正式发布的 Python package 压缩包了。让我们查看一下 PDM 帮助打包了项目中的哪些内容: > tar -tf .\dist\gh_action_demo-0.1.0.tar.gz gh_action_demo-0.1.0/README.md gh_action_demo-0.1.0/pyproject.toml gh_action_demo-0.1.0/src/gh_action_demo/__init__.py gh_action_demo-0.1.0/src/gh_action_demo/main.py gh_action_demo-0.1.0/tests/__init__.py gh_action_demo-0.1.0/PKG-INFO 可以看到压缩包是很规范的结构,唯一不太妙的地方在于 PDM 将 tests 目录也打包在内,测试文件中可能会包含体积比较大的数据文件,这对于使用 package 的用户是没有必要的。因此可以在 pyproject.toml 中添加以下的配置项,手动指定不纳入 package 的目录或文件: [tool.pdm.build] excludes = ["tests/"] 再次运行 pdm build,可以发现新构建的压缩包已经把 tests/ 排除在外了。 从已有项目迁移 很多情况下,我们并不是一开始就使用 PDM 的项目结构,例如我的项目也是开发了一半时才换用 PDM 管理。如果项目比较简单,可以调整项目的结构,在 src/ 中组织源码,那么构建的方法就和前文一致了。如果项目结构比较复杂或是有特殊需求而不能将源码调整至 src/ 中,那么就需要在 pyproject.toml 中手动设置打包的源码目录,例如: [tool.pdm.build] includes = ["main/", "plugins/"] excludes = ["tests/"] 运行 pdm build 即可将 main/ 和 plugins/ 目录打包。 准备发布到 PyPI 我们在本地完成了 package 的构建,dist 目录中生成的 *.tar.gz 和 *.whl 就已经可以借助各种工具发布到 PyPI。不过更优的方法是完成开发后直接将代码推送至 GitHub 仓库,借助 GitHub Actions 的功能在云上完成构建并自动发布。 不论何种方法,在发布之前都要先注册 PyPI 和 TestPyPI 的账号。PyPI 是安装 Python package 的常用仓库,不用作多余的介绍。TestPyPI 是 PyPI 的测试仓库,主要用于测试发布、安装 package 是否正常,可视作在 PyPI 正式发布前的「预览版」。二者的流程完全相同,唯一不同在于数据、账号并不通用,必须分别注册。二者注册过程中包括开启二次验证等操作,步骤稍有些复杂,但很容易能够查找到详尽的教程,在此不再赘述。 {note}因为本文用于演示发布的流程,后文主要以 TestPyPI 作为示例,设置项中与 PyPI 的细微区别以提示的方式给出。{end note} 登录 TestPyPI 账号后进入发布页面,我们选用 Trusted Publisher 的方式发布。 拖动页面至最底部,在「Add a new pending publisher」中选择「GitHub」,按要求填入 Publisher 的信息: 必填项包括以下 4 项: PyPI Project Name:在 TestPyPI(PyPI)中索引的 package 名称,必须与 pyproject.toml 中的 name 一致; Owner:GitHub 用户名; Repository name:GitHub 仓库名; Workflow name:GitHub Actions 文件名,GitHub Actions 通过 YAML 文件配置,所以后缀应为 .yml 或 .yaml。 完成后选择「Add」,就能看到准备中的 Publisher: 添加 GitHub Actions 在 GitHub 中创建仓库,注意仓库名应与 Publisher 中设置的仓库名完全一致。在推送代码之前,我们还需要在项目中添加 GitHub Actions 的配置。 在项目文件夹中添加 .github/workflows/ 文件夹,在其中新建一个 YAML 文件,注意文件名应与 Publisher 中设置的「Workflow name」一致。在 YAML 文件中添加配置,对 PDM 官方文档提供的模版稍作修改并逐行添加了注释: # 显示在 GitHub UI 中的 workflow 名称 name: Publish Python distributions to TestPyPI # 触发条件,在任何分支有 push 时触发该 workflow on: push # workflow 由若干个 job 组成,job 之间并行运行,此处只有 testpypi-publish 1 个 job jobs: # job 标识名称 testpypi-publish: # 显示在 GitHub UI 中的 job 名称 name: upload release to TestPyPI # 运行 job 的计算机平台 runs-on: ubuntu-latest # PDM 所需的权限 permissions: # This permission is needed for private repositories. contents: read # IMPORTANT: this permission is mandatory for trusted publishing id-token: write # 每个 job 由若干 step 组成 steps: # PDM 所需的环境配置 - uses: actions/checkout@v3 - uses: pdm-project/setup-pdm@v3 # 运行命令行程序,通过 PDM 发布到 TestPyPI # `pdm publish` 命令先完成 `pdm build` 再将 `dist/` 中的内容发布 - name: Publish package distributions to TestPyPI run: pdm publish --repository testpypi {note}若要将 package 发布到 PyPI 只需要将 workflow 中的 pdm publish --repository testpypi 改为 pdm publish。{end note} 完成以上步骤后,再检查一下项目的文件结构,应当为 D:\CODE\GH-ACTION-DEMO │ .gitignore │ .pdm-python │ pyproject.toml │ README.md ├─.github │ └─workflows │ testpypi_publish.yml ├─.pdm-build ├─.venv ├─dist ├─src │ └─gh_action_demo │ main.py └─tests 确认无误后就可以把项目推送到 GitHub 仓库了。 {note}需要推送到 GitHub 仓库的仅有 .gitignore pyproject.toml README.md .github/ src/ tests/ 这几个文件与目录,.pdm-build/ .venv dist 都不应当上传,很方便的是 PDM 生成的 .gitignore 已经自动排除了这些留在本地的项目。{end note} 如果上述配置全部正确,打开 GitHub 的仓库中的「Actions」界面就能看到创建的 workflow 是否成功执行: 如果提示 workflow 全部完成,转至 TestPyPI 的项目界面就能找到刚发布的 package: 根据页面的提示,这时候通过 pip install -i https://test.pypi.org/simple/ leo-gh-action-demo 就能将发布的 package 安装到 Python 坏境中。 更复杂一些 好了,经过以上的步骤,我们就完成将 Python package 发布到 PyPI 的基本目标了。不过在实际中会有更复杂的 workflow,考虑这样的开发场景: 每个分支的每次 push 都要执行 pdm build 检查项目是否能构建; 每个打上版本号 tag(如 v1.2.1rc1)的 commit 都是准备发布的版本,需要自动创建 pre-release 并将其发布到 TestPyPI,用于测试各种功能; 确认无误后手动从 tag 创建正式 release,同时自动发布到 PyPI。 分析以上需求,可以划分为 2 个 workflow 实现: 测试构建 workflow 由 push 触发 pdb build, 如果 push 中有版本 tag,则进一步执行 pdm publish,将构建文件发布到 GitHub release 和 TestPyPI; 若无版本 tag,则结束 workflow。 正式发布 workflow 由手动 release 触发 pdm build,自动下载 release 中的文件并执行 pdm publish,正式发布到 PyPI。 测试构建 workflow 按上文的步骤在 TestPyPI 中创建新的 Publisher,填入 workflow 的文件名,例如 build_py_dist.yml。接着在 .github/workflows 中创建同名文件,写入 workflow 内容: name: Build Python distributions # 由 push 触发 on: push jobs: # 分别在不同平台构建 .whl 安装包 build_whl: strategy: # 使用 matrix 组合在多种 OS 平台上完成 wheel 的构建 matrix: os: [ubuntu-22.04, windows-2022] # 给对应的 OS 添加一个新变量 plat_name # Linux plat_name 中的 2_35 来自于 GNU libc 版本 include: - os: ubuntu-22.04 plat_name: manylinux_2_35_x86_64 - os: windows-2022 plat_name: win_amd64 name: Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - uses: pdm-project/setup-pdm@v3 with: architecture: x64 # 查看 GNU libc 版本 - run: ldd --version # 通过 PDM 构建 wheel - name: PDM build wheels # 指定 `--no-sdist` 参数时只生成二进制 wheel 文件 run: pdm build --no-sdist --config-setting="--plat-name=${{ matrix.plat_name }}" # 将 dist/ 目录中的所有 .whl 文件上传暂存 - uses: actions/upload-artifact@v4 with: name: pdm-build-wheel-${{ matrix.plat_name }} path: dist/*.whl # 将源码打包为 tar.gz build_sdist: name: Build source distribution runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: pdm-project/setup-pdm@v3 # 通过 PDM 打包源码 - name: PDM build source dist # 指定 `--no-wheel` 参数时只生成打包的源码 run: pdm build --no-wheel # 将 dist/ 目录中的打包的源码上传暂存 - uses: actions/upload-artifact@v4 with: name: pdm-build-sdist path: dist/*.tar.gz # 使用构建文件完成 pre-release pre_release: name: Pre-release package distributions to GitHub # 只有 push 的 tag 以 "v" 起始时才运行该 job if: startsWith(github.event.ref, 'refs/tags/v') # 且在 build_whl 和 build_sdist 两个 job 完成的情况下执行 needs: [build_whl, build_sdist] runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v3 # 每个 job 都会使用新的容器,需要将上传暂存的构件下载到 dist/ 目录 - uses: actions/download-artifact@v4 with: pattern: pdm-build-* path: dist merge-multiple: true # 使用 dist/ 目录中的文件创建一个 pre-release - uses: ncipollo/release-action@v1 with: artifacts: "dist/*" prerelease: true # 将构建文件发布到 TestPyPI publish_pkg: name: Publish package to TestPyPI # 只有 push 的 tag 以 "v" 起始时才运行该 job if: startsWith(github.event.ref, 'refs/tags/v') needs: [build_whl, build_sdist] runs-on: ubuntu-latest permissions: contents: read id-token: write steps: # 使用 PDM 发布已构建的文件 - uses: actions/checkout@v3 - uses: pdm-project/setup-pdm@v3 - uses: actions/download-artifact@v4 with: pattern: pdm-build-* path: dist merge-multiple: true # `pdm publish --no-build` 会自动发布 `dist` 中预先构建好的文件 - name: Publish package distributions to TestPyPI run: pdm publish --no-build --repository testpypi 这个 workflow 需要执行的任务比较多,而且涉及了逻辑判断,看起来就比较复杂。各个步骤的介绍已经放在注释中,我想再简单介绍一下其中用到有意思的功能和要注意的细节。 strategy.matrix 可以对 n 组变量做笛卡尔积,组合变量创建出子任务;使用 include 可以为特定条件下的子任务引入新变量。将二者放在一起使用,就能实现一个简单的字典,通过键值对来指定变量。在上面的 workflow 中使用它们就是为了得到 {"ubuntu-22.04": "manylinux_2_35_x86_64", "windows-2022": "win_amd64"},根据不同的平台为 wheel 文件分配不同的后缀。 前文提过,每个 job 并行执行,所以需要使用 needs 来设定执行先决的 job,就能按需组织成任务序列。此外,每个 job 都会使用新的容器,也就是不同 job 间生成的文件是不互通的,因此我通过 upload-artifact 和 download-artifact 两个 action 上传和下载文件,实现不同容器间文件的传输。 测试一下构建的情景,具体的步骤是: 修改 pyproject.toml 中的 version,例如 0.1.1b1; 通过 git push 推送 commit 到远端仓库。 {caption} 每次的 push 都会自动检查是否能构成功构建,因为没有 tag 条件并不执行 publish 任务{end caption} 测试发布具体的步骤是: 修改 pyproject.toml 中的 version,例如 0.1.1b1; 通过 git tag v0.1.1b1 为当前分支最新 commit 打上版本 tag; 通过 git push origin v0.1.1b1 推送 tag 到远端仓库。 {caption} 每次的 push tag 都会在构建完成后自动发布到 pre-release{end caption} {caption} 同时也会自动发布到 TestPyPI{end caption} {note}PEP 440 制定了 Python package 的版本号规范,在 PyPI 上发布自己的 package 时理应也要按该规范设定版本。{end note} 正式发布 workflow 相比之下,正式发布的工作流程就简单了许多,就不作过多介绍了。同样需要在 PyPI 中添加 Publisher,在 .github/workflows 中创建 publish_pypi.yml,写入以下内容: name: Publish distributions to PyPI # 由正式 release 触发 on: release: types: [released] jobs: # 将构建文件发布到 PyPI publish_pkg: name: Publish package to PyPI runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@v3 - uses: pdm-project/setup-pdm@v3 # 从 release 中下载所有文件到 dist/ 目录 - uses: robinraju/release-downloader@v1.10 with: latest: true fileName: "*" out-file-path: "dist" # 使用 PDM 将下载的文件发布到 PyPI - name: Publish package distributions to PyPI run: pdm publish --no-build 正式发布的具体步骤就是: 在 GitHub 仓库的 release 中找到正式版本的 pre-release; 编辑 pre-release,将其改为 release,提交后就会自动发布到 PyPI。 References GitHub Actions 文档 - GitHub 文档 Publishing with a Trusted Publisher - PyPI Docs 使用PDM来管理Python项目 - 李理的博客

2024/4/25
articleCard.readMore

华为云踩坑:由 URL 编码导致 yum 安装时的 No such file or directory 错误

最近想要在华为云的 BMS 上部署一个 Web 应用,咨询了华为的工程师后,得到了可行的明确答复。那么首先就需要安装 Nginx,在安装 Nginx 所需的依赖遇到了 [Errno 2] No such file or directory 的错误,一层层查找后发现这可能是一个由代理设置引起 URL 编码错误而导致的 bug。由于网络上几乎没有找到任何相关的资料,就把整个过程留下来作为记录。 首先从安装 Nginx 所需的依赖开始: $ sudo yum -y install gcc gcc-c++ make libtool zlib zlib-devel openssl openssl-devel pcre pcre-devel ... Error: Problem: package pcre-devel-8.32-15.1.h6.aarch64 requires libpcre16.so.0()(64bit), but none of the providers can be installed - package pcre-devel-8.32-15.1.h6.aarch64 requires libpcre32.so.0()(64bit), but none of the providers can be installed - package pcre-devel-8.32-15.1.h6.aarch64 requires libpcrecpp.so.0()(64bit), but none of the providers can be installed - cannot install both pcre-8.32-15.1.h1.aarch64 and pcre-8.42-4.h3.eulerosv2r8.aarch64 - cannot install both pcre-8.32-15.1.h6.aarch64 and pcre-8.42-4.h3.eulerosv2r8.aarch64 - cannot install the best candidate for the job 竟然提示没有找到可用的包,按理来说 BMS 已经配置了华为官方的源,既不会有网络问题也不会缺失 pcre-devel 这样的常见包。于是我开始排查 yum 源的问题,先检查设备的系统和架构: $ uname -m aarch64 $ cat /etc/os-release NAME="EulerOS" VERSION="2.0 (SP8)" ID="euleros" ID_LIKE="rhel fedora centos" VERSION_ID="2.0" PRETTY_NAME="EulerOS 2.0 (SP8)" ANSI_COLOR="0;31" 可以看到操作系统是华为的 EulerOS 2.0 (SP8),架构是 aarch64。再检查默认的 yum 源: $ sudo cat /etc/yum.repos.d/EulerOS.repo [euler-base] name=EulerOS-2.0SP8 base baseurl=http://mirrors.huaweicloud.com/euler/2.3/os/aarch64/ enabled=1 gpgcheck=1 gpgkey=http://mirrors.huaweicloud.com/euler/2.3/os/RPM-GPG-KEY-EulerOS 仔细看默认的 yum 源,系统版本明明是 2.0 (SP8),URL 却指向了 2.3。测试直接替换后的链接 http://mirrors.huaweicloud.com/euler/2.8/os/aarch64/ 可达,那么就尝试改为使用该 yum 源。 更换 yum 源 这里的修改很简单,我就没有额外备份,使用 vim 直接编辑文件 /etc/yum.repos.d/EulerOS.repo 并保存,修改后的 yum 源信息为 [euler-base] name=EulerOS-2.0SP8 base baseurl=http://mirrors.huaweicloud.com/euler/2.8/os/aarch64/ enabled=1 gpgcheck=1 gpgkey=http://mirrors.huaweicloud.com/euler/2.8/os/RPM-GPG-KEY-EulerOS 通过以下命令更新 yum 源: $ sudo yum clean all # 清除旧 yum 源缓存 $ sudo yum makecache # 生成新 yum 源缓存 $ sudo yum repolist # 检查 yum 源连接状态 EulerOS-2.0SP8 local repo for internal use 0.0 B/s | 0 B 00:00 EulerOS-2.0SP8 base 7.4 MB/s | 17 MB 00:02 Failed to synchronize cache for repo 'base', ignoring this repo. Last metadata expiration check: 0:00:06 ago on Fri 15 Mar 2024 09:54:47 AM CST. repo id repo name status euler-base EulerOS-2.0SP8 base 16,599 上述信息中的 Fail 指示 EulerOS-2.0SP8 local repo for internal use 这个源不可用,看名字应该是内部使用的 yum 源,在此处没有影响。列举出的 repolist 中有 EulerOS-2.0SP8 base 一项,说明更改后的源已经可用。 [Errno 2] No such file or directory 再次尝试安装 Nginx 的依赖: $ sudo yum -y install gcc gcc-c++ make libtool zlib zlib-devel openssl openssl-devel pcre pcre-devel ... (19/29): gcc-c++-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm 6.2 MB/s | 7.4 MB 00:01 ... [Errno 2] No such file or directory: '/var/cache/dnf/euler-base-85cc05102200a8ac/packages/gcc-c++-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm' The downloaded packages were saved in cache until the next successful transaction. You can remove cached packages by executing 'dnf clean packages'. 提示 [Errno 2] No such file or directory,没有找到 gcc-c++ 的 RPM 文件,奇怪的是在安装输出的信息中分明提示已经成功下载了 gcc-c++。 错误信息中指引了一个文件目录 /var/cache/dnf/euler-base-85cc05102200a8ac/packages/,不妨检查一下其中的文件: $ sudo ls /var/cache/dnf/euler-base-85cc05102200a8ac/packages/ cpp-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm make-4.2.1-10.h3.eulerosv2r8.aarch64.rpm gcc-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm openssl-1.1.1-3.h31.eulerosv2r8.aarch64.rpm gcc-c%2b%2b-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm openssl-devel-1.1.1-3.h31.eulerosv2r8.aarch64.rpm gcc-gfortran-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm openssl-libs-1.1.1-3.h31.eulerosv2r8.aarch64.rpm keyutils-libs-devel-1.5.10-8.h4.eulerosv2r8.aarch64.rpm pcre2-devel-10.32-3.h1.eulerosv2r8.aarch64.rpm krb5-devel-1.16.1-21.h1.eulerosv2r8.aarch64.rpm pcre2-utf16-10.32-3.h1.eulerosv2r8.aarch64.rpm libcom_err-devel-1.44.3-1.h4.eulerosv2r8.aarch64.rpm pcre2-utf32-10.32-3.h1.eulerosv2r8.aarch64.rpm libgfortran-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm pcre-8.42-4.h3.eulerosv2r8.aarch64.rpm libgomp-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm pcre-cpp-8.42-4.h3.eulerosv2r8.aarch64.rpm libkadm5-1.16.1-21.h1.eulerosv2r8.aarch64.rpm pcre-devel-8.42-4.h3.eulerosv2r8.aarch64.rpm libselinux-devel-2.8-4.h2.eulerosv2r8.aarch64.rpm pcre-utf16-8.42-4.h3.eulerosv2r8.aarch64.rpm libsepol-devel-2.8-2.eulerosv2r8.aarch64.rpm pcre-utf32-8.42-4.h3.eulerosv2r8.aarch64.rpm libstdc%2b%2b-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm zlib-1.2.11-14.h4.eulerosv2r8.aarch64.rpm libstdc%2b%2b-devel-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm zlib-devel-1.2.11-14.h4.eulerosv2r8.aarch64.rpm libverto-devel-0.3.0-6.h1.eulerosv2r8.aarch64.rpm 可以看到下载的 29 个文件都在其中,从中寻找报错的 gcc-c++,看到文件名时恍然大悟,gcc-c++ 被转义成了 gcc-c%2b%2b。同样带有 + 的 libstdc++ 和 libstdc++-devel 两个安装文件也都被用 %2b 转义,用未转义前的名称自然无法寻找到这些文件。 起初以为这是 yum 在处理特殊符号时 URL 编码的 bug,但在互联网上用关键词检索找不到任何相关的信息。仔细一想,yum 是无数 Linux 平台上默认的包管理器,怎么可能犯这么低级的错误,况且在安装这么常见的依赖时就能引发的 bug 理应很快就被修复了。 在许久漫无目的地寻找后,偶然发现了 GitHub 上的一篇 Issue,大意是说代理软件应当支持识别 yum.conf 中的 URL 编码,否则会导致一些问题。这倒提醒了我,会不会是代理导致的问题呢? 修改 yum 源代理 设备可能带有华为用来日常管理维护设备的内部默认代理,不宜擅自修改,最好是仅修改 yum 源所使用的代理,不影响其他服务的运作。同样用 vim 修改 /etc/yum.repos.d/EulerOS.repo 文件的内容,仅在最后添加一行: [euler-base] name=EulerOS-2.0SP8 base baseurl=http://mirrors.huaweicloud.com/euler/2.8/os/aarch64/ enabled=1 gpgcheck=1 gpgkey=http://mirrors.huaweicloud.com/euler/2.8/os/RPM-GPG-KEY-EulerOS proxy=_none_ 然后用同样的操作尝试更新 yum 源: $ sudo yum clean all $ sudo yum makecache EulerOS-2.0SP8 local repo for internal use 0.0 B/s | 0 B 00:00 EulerOS-2.0SP8 base 0.0 B/s | 0 B 00:01 Failed to synchronize cache for repo 'base', ignoring this repo. Failed to synchronize cache for repo 'euler-base', ignoring this repo. Metadata cache created. 发现禁止源 EulerOS-2.0SP8 base 使用代理后,就无法连接上源仓库了。可以确定华为云上的 BMS 确实设置有供 yum 安装所使用的特殊代理,文件名的 URL 编码异常可能由该代理导致,从而引起 [Errno 2] No such file or directory 的错误。 解决方案 由于华为云 BMS 获取 yum 源仓库必须通过默认代理,不能通过取消代理解决该问题。那么就只能通过最朴素、最直接的方法解决这个问题了——手动改文件名。注意核对 cache 文件目录,手动将文件名中的 %2b 改回为 +,我这里有 gcc-c%2b%2b libstdc%2b%2b libstdc%2b%2b-devel 三个文件需要修改: $ cd /var/cache/dnf/ $ sudo mv ./euler-base-85cc05102200a8ac/packages/gcc-c%2b%2b-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm ./euler-base-85cc05102200a8ac/packages/gcc-c++-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm $ sudo mv ./euler-base-85cc05102200a8ac/packages/libstdc%2b%2b-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm ./euler-base-85cc05102200a8ac/packages/libstdc++-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm $ sudo mv ./euler-base-85cc05102200a8ac/packages/libstdc%2b%2b-devel-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm ./euler-base-85cc05102200a8ac/packages/libstdc++-devel-7.3.0-20190804.h29.eulerosv2r8.aarch64.rpm 然后再尝试原先的安装命令,就发现先前提示无法找到的安装包能够成功安装上了。 References 为 yum 源配置代理 - GitBook 如何知道你是否使用了代理服务器? - Linux 中国

2024/3/15
articleCard.readMore

为友治藏书印一方

上次动刀还是在很久很久以前了。早先时候朋友向我抱怨白文的藏书印不容易干,稍不慎就把书印成了「大花脸」。我也不自揣,心想索性为他刻一方朱文的藏书印。因为日常各种事务迁延,这想法一直拖到了最近都还没开始动手,再拖下去就要到年后了,想到那时的忙闲也未可知,赶紧挤着时间操起刀来。要没有这样的「任务」记挂在心头,每天忙忙碌碌后谁又会有心思去做这些无用之事。 让我刻印不是朋友的主意,自然也没有交待我文字内容,我就自作主张以他的斋号起首,下缀以「收藏经籍记」几个字,刻一方扁章。虽说是斋号,天下广厦千万间尚没有容身之所,哪里还有这额外的书斋。 明清文人有个说法,大意是「书斋大都建在石头上」。这不是说以石料来架梁构椽,而是说凡有书斋必有斋馆印,哪怕是在心头上的书斋,也需有斋馆印作为凭信。所以我也可以戏言,有了这方印,朋友的书斋才总算是「建」起来了。 这样的重任在肩,我不敢懈怠。于是费心经营结构,又操刀几日,终于完成了这方藏书印。藏书印还是以朱文最好,不易遮盖文字,也不易污损纸面。扁章具有独特的书卷气,盖在书上也很是典雅。这方印基本能够满意,但也有几处毛病欠推敲。日前已经将藏书印转交了朋友,第一次创作这样风格的扁章,用照片给自己留个纪念。

2024/1/14
articleCard.readMore

忙忙碌碌中匆匆过眼我的 2023 年

弹指一挥间,2023 年已经接近尾声了。在 2023 年元旦的时候,我发了一条微博,大意是祝世界和平。事与愿违,今年是不太平的一年,大大小小的战事在世界各地又起。 {caption} 2023 年元旦发的微博{end caption} 在大家谈论「这是未来最好一年」的氛围里回望这一年,令人最值得庆幸的是新冠肺炎这场「战争」结束了。路口的活动板房上垂挂着两条渐褪色的条幅,标识着这里曾经是核酸检测亭。透过路旁小区的铁栅栏门,在宣传黑板上,依稀还能看到写有「外地返乡人员」一类字样的告示。这些痕迹依约记述着没有过去很久却已经渐行渐远的「疫情时代」,3 年的疫情草草地画上了一个句号。 「疫情时代」重塑了每一个人的生活,或许有了常备药物的习惯,或者把戴口罩作为了稀松平常的事,又或是学会了一个人独处很长时间。这不能说不是在一次次的惶恐、担忧、焦虑之中的额外收获。 新冠的馀波不仅仅在生活习惯上显现,同样也冲击着每个人的生活。今年很明显地就能感受到,经济的低迷伴随着就业市场的不景气,行业的冷气向下传到了每个人的身上。想到未来一段时间内,可能要持续顶着下行的压力,不由地收紧了手头的开支,很多人讨论的「消费降级」大概就是这么回事。 外界的环境总是难以改变,如何在飘摇之中把握住自己的生活成了维系良好心态和生活质量的不二法门。回首这一年,撇去糟糕的环境,还是能发现那些令人欣喜的片段。又因处于阴暗的色调之中,这些珍贵如琉璃般的琐碎片段愈现出晶莹的光辉来。 一次出差 把时间拨回 5 月,在光景尤好的时节,我得到了一次出差的机会。目的地是成都,在那里花上三两天听报告和会议。成都是我不曾去过的城市,又由于这是我第一次公干外出,不论是食宿还是交通,整个行程充满了新鲜感。 任务完了后,剩余的时间虽然不多,也能让我们一行人在这座陌生的城市里流连。于是览灌口、登青城、访草堂,犹嫌行之不足。至于临别前夜,一场夏日的豪雨将我们困在锦里的一条回廊下,我又何忍就这么匆匆离去呢?希望来日能专有时间故地重游,以补我的走马看花之过。 行在两都之间 9 月末了,匆匆了结了手上的项目后,得到了稍得喘息的一段时间,便和同伴盘算着外出游玩。天津与西安间的机票很是实惠,我与同伴就计划乘铁路至洛阳,再至西安,最后乘飞机返回。 几年来在大江南北穿棱,飞机已经坐得惯了,早没有了当初的新鲜感。而铁路所带来旅途惊喜却从不曾因为乘火车的次数消少,这大概是因为窗外的风景总是不同,车厢里偶遇的人总是不同,相互攀谈的故事也绝没有重样。我一向以为,花几天时间乘最慢的火车前往目的地,再从另一条线路返回,双足虽没有深踏入城市中,也是一种旅行,很有「乘兴而行,兴尽而返,何必见戴」的逍遥。就在火车缓缓开动的那一刻起,是未开始旅途,已在旅途中了。当然,考虑到时间与同伴的感受,最后的选择还是更快捷些的动车。 自天津向洛阳,在华北平原上驰走,放眼四野都是平整的田地。麦苗初插,田间铺开的全是禾绿的麦子。从车窗极目向最远眺望,只能看见田地伴着房屋消失在地平线前,望不见半点的山,从未到过华北的人是绝难想象这样的景象的。一路上我饶有兴致地持地图比照途经何处,将近郑州时,列车轻轻一甩,奔流的黄河被抛在了后头,我意犹未尽地向后追望,只能依稀觑见铁桥的桁架了——原来黄河那样的窄。 入郑州境内后转而西行,景色已大不相同,不待片刻,已置身于群山的环合之中。我按图索骥,向南找寻,遥遥地在群山中兀自高出的大概就是嵩山了。过了不少隧道,眼前明朗开阔起来,现出一片群山环抱的平原,就到了洛阳境内。在到达洛阳站前,列车会横穿过伊河,这里是眺望伊阙的绝佳位置。藉着钢桥的高度,向南望去,就能看到伊河水迎面汩汩而来。在河水的末梢,两岸的山石相对而出,紧紧夹住中间的伊河,如同汉宫门外的望阙,的确不负伊阙之名。 从洛阳向西安的路途最为有趣,这段路程向西而行,正处于黄河「几」字形的右下一折。北有黄河相拒,南又有秦岭相屏,于是铁路只得架设在黄河与秦岭间狭窄的山麓上。列车近乎贴着秦岭而行,仿佛伸出手就可以掬得山中的一捧青翠。向北望去,山峦陡然高耸起来,密匝匝连成一片台地,迎面一侧刀劈斧凿般平整,这石壁屏障就已经在山西境内了。秦岭的山麓也向石壁延展,灭失在不远处。石壁与山麓之间似乎有一条幽幽的沟壑,尽管目光不可及,但我知道黄河就在那里静静地流淌。 历史上多少次西都与东都的交替兴废,多少逃亡的君臣,多少不果腹的流民,都沿着黄河奔走在这条故道上。在这条交通要道上堆积着多么厚重的历史尘埃,凡是途经此地的人,谁能没有「山河表里潼关路」的感怀呢?看着窗外忽闪的景色 ,「坐地日行八万里」之感自不必说,行走在两都之间,尤难得的是更多了一层「一篇读罢头飞雪」的黍离之情。 我是喜欢对着窗外发獃的无聊人,无论心中如何翻涌,在旁人眼里大概都是一路上平静望着窗的痴人。洛阳、西安的诸多名胜,前人之述备矣,可这路途中的风光却少有人论及,于是就由我这样的无聊人录下无聊景与生发的无聊情。 徜徉于书市 在天津的过去三年,由于新冠的影响,我再没有去过旧书市,今年终于得尝所愿,又能把周末的时间浪费在淘书上了。 上大学初来天津时,旧书摊大多聚集在古文化街前的广场上,书商与购书者人头攒动,书籍品类琳琅满目。在这样的集市里淘书,不仅需要一双慧眼,更要有一双快手,必要时还需一幅硬心肠——犹豫不决正将书放下时就被人购走是常有的事。几番轮转变迁,如今的古文化街,只在玉皇阁附近有几家铺面还在经营旧书,周六例行的书集也已经不复。由于铺面的租金,这几家旧书的价格甚高,失却了那种淘书的乐趣。 在这种境况下,今年我开始有意识地寻找天津的旧书店。T 君也是爱书之人,他读的书远比我多,对于书的眼力自然也比我好,而他在天津的年岁没有我长,对天津的书店不如我熟稔。于是在周末时,屡屡是我带着他穿棱在天津的大街小巷,南大书屋、古籍书店、肆拾贰书房,不一而足。 令人失望的是这些书店大都不对我们的胃口,只有鼓楼西街的旧书集值得一观。每周六早晨,书商与旧货商都会聚集在此,摆开地摊招徕顾客。虽然集市规模已经小了许多,更是有古玩、二手衣帽类的摊位杂厕其间,但我还是愿意花上半天时间在其间寻寻觅觅。书摊上的旧书大多很便宜,贵者也不过 10 元左右,找到好书所带来的欣喜心情更是远超过可计的价格了。 三两周就往鼓楼西街一逛成了我的习惯,经由我的绍介,每去书集时也总要和 T 君搭伙。周六时起个早,乘公交至西南角,买上一份热腾的煎饼粿子,边走边吃,这就开始逛书集了。 有的书商并不了解书,只会用诸如「一版」「初印」「某某年」一类词衒人耳目,助他拉高价格。另一类书商则自己也爱藏书,对摆开的书籍如数家珍,若是谈话投机,甚至直愿送予知音。这人与人之间的关系、人与书之间的关系,也是我爱淘旧书的缘由之一。 今年在书集上前后买入了近二十册旧书,很大一部分都还没有时间认真翻阅,不得不说淘旧书也是一种慢瘾。今年购入的诸多书籍中,最合我心意的是一本《新旧约全书》。 初次在书集上遇见它时,正躺在塑料布角落,布面精装,灰暗显得有些陈旧的封皮不惹人注意。我随手翻开,一眼就喜欢得紧。虽说是近代影印,但底本是民国的好版本,铅字既清晰又美观。再仔细检查内容,不是文言、方言这类更有趣的版本,是最为通行的和合本,但也不失它的价值。我当下就想买下,转而又心想价格不会便宜,便倖倖地舍之而去。 过两周后,我又在书集上遇见了它。我心中犯疑:估计是价格太高了,这么漂亮的书都没被人买走,于是我又错过了它。此后数次,我在书集上都没有找到是书,但它却频频入梦,我屡屡懊悔于竟不及问价。 很偶然的一次,在书集漫步而正觉无有「书获」时,它竟又出现在了我的眼前!我作镇定状上前询价,卖家曰:「三十。」强忍心中欣喜与嘴角笑意,与卖家一阵交谈,终以 20 元收入囊中。见我有意,卖家另赠一本《红楼梦》残帙,聊以插架。 这得而复失、失而复得的周折,真恍若邯郸道上的一梦。悠游于书市的乐趣,尽在其中了。 {caption} 所购得《新旧约全书》的内页{end caption} 与 T 君的二三事 T 君主修中国哲学,平时显得有些讷言。而若是谈及他的所知,他就会具有非凡的精神,带着听者在极广博繁杂的层层密网中游走,显示出深邃的思维和游刃有馀的学术功底。我与 T 君在今年因读书和初相识,一来二去间,也渐渐熟络了起来。因为志趣相投,经常一起消遣周末的时间。譬如 T 君对佛教哲学研究深,我则喜欢逛寺庙,我们一拍即合,逛完天津的寺庙又往北京去寻找。 今冬寒潮来临前,气象预报指出会有一场大降雪,身为南方人还从未体验过踏雪寻景,心中雀跃得很。我也知绝少有人愿冒严寒,周转数趟公交,到罕人迹的地方赏不知为何物的景。抱着兴许一试的态度,我给 T 君发送了消息。 「周末好像要下雪,出去散散步?」 「可以。」 莫说相公痴,更有痴似相公者。 只可惜天不与时,当日并未降下半星雪点,但纵是气温陡低也不减二痴人兴致,如常结伴而行。 有湖就看看湖,有芦花荡就攀折一枝芦花。一路上走走停停,行无定处,渐渐靠近了海河边。正打算登堤眺望冬日的海河,一条小河拦住了去路。查阅导航竟提示向后绕过,我们二人都是不愿意走这回头路的,沿小河硬着头向前寻过河处。 看着已经上冻的河面,T 君静静地说:「我们从冰面上过去吧。」时常听闻冬日坠河的新闻,我大为惶恐:「这恐怕冻不牢,还是别走了。」不知继续向前走了多远,始终没有看见过河处,我的内心也有些动摇。说是小河,倒不如说是水沟,水面仅二三米宽。眼巴巴望着咫尺的对岸,再走下去要离目的越来越远了。 T 君跃跃欲试准备渡河。我仍觉不放心,拾来砖块砸了砸冰面,的确很结实。T 君拄一根木棍一脚踏下,咔嚓一声冰面应声而裂,右足陷入冰窟,水没至胫。T 君尝试脱身,左足踏而发力,又是咔嚓一声,整个人直楞楞站在了冷风冰水之中。我赶忙伸手拉他上岸,二人相抚大笑,这真正是重演「公无渡河,公竟渡河」的乐府古题。 {caption} 事故现场之还原{end caption} 观罢寂寥开阔的海河,天色尚早,见地图上指示附近有一处名人坟墓,我们又前往一探究竟。说是名人坟墓,其实不过是近代史上的一条人名,不是什么大人物,最后长眠在一个荒僻的小村庄里。驱散了前来叫阵的野狗,又绕过窄窄弯弯的村路,没有任何指引标识,到了一片榛榛草莽之中,终于见到了一座很是高大的馒头坟。墓地破败得很,墓碑似乎遗失了,碑上的条石凿有双龙戏珠的样式,碎成了两截。立碑处用红砖封堵,显然曾经有盗洞从这里发开封土。唯有墓前的神道碑留存较好,除了少数字被凿去,内容都还可辨。 扑去碑面的污物,我与 T 君就开始辨识碑文,朗声读了起来。与检索到的资料一对照,暗笑执笔者傅粉过多、文过饰非。读罢,在墓前咄之:「这也不是什么好人呐。」 随手翻书 忙碌的一年里,都是在挤着时间看书。 欠的「书债」还是很多,也因为感兴趣的方向很多,这「债」越累越高了。许多书在读毕后酣畅淋漓,六七天内还不断回味,但在十多天后,多半不记得细节了,很是困扰。不敢细数到底读了多少,因为常觉得许多书和没读一样。苦于此,我开始尝试写题纲,记录下关键内容。 最早尝试的记录方式还是传统的纸笔,除了一部分珍贵至不舍污损的书籍需要将原文另相誊抄出来,其馀书籍都可以边读边批注,很是畅快。但在整理内容时就开始头疼了,因为将其电子化实在太费时费力,再由人的惰性在,慢慢就坚持不下去了。 后来我又尝试了适合 Android 端的工具链。用白描做 OCR,将原文扫描成转换成文字;用 flomo 整理素材、汇总笔记,使用起来会很像上世纪历史学者做的卡片;有空的时候将 flomo 上的内容整理至 Notion,梳理文意后做成读书笔记,分类归档。 以上这一套步骤看起来比较理想,但 Notion 的网络问题导致整个方案只能被弃用。Notion 在国内连接时能时不能,几乎令人抓狂,我很难容忍这样的不便。 寻寻觅觅之下,我对思源笔记寄予了厚望。惜其功能还不够完善,实践我的读书笔记计划是我新年里很大的一个愿望。 信手涂鸦 好几年前写过一段时间隶书,后来因为各种原因没能坚持下去。技痒之下,置办了笔墨,又开始复健起来了,这也是今年值得一记的事情。因为中断太久,索性从头开始,从篆书写起。 一整年都在与《王福庵说文部目》《峄山碑》两帖周旋,每天花一两小时,写完一两张纸,不觉间也已经写了 500 张纸了。长时间坚持一件事不容易,练字则有一项优势,当年末抚着这一摞纸时,会欣然得意于时间用在了实处。翻看最初页的最初字(写字人一般羞于看太早前的字,此时大多会瞄一眼就脸红着将纸盖上),再翻看最末一页的最末字,一相比照,点滴所用之功与偷下的懒就都能了然于心了。 其实在以前,我是不太喜欢《峄山碑》这一路的篆书的,总觉得过于板正,更偏好生动的清篆。也是在一年的临习里,逐渐领悟到秦篆点画中的流利婉转之美,甚至打算推翻原来的练习安排,在《峄山碑》完了后直接跟进具相似风格的《三坟记》。秦篆是一个很好的学书切入点,打好基础后可以向后找李阳冰,攻铁线篆一路,也可以再向后找到清代,学大家蜂出的清篆,还可以上溯三代,写各种钟鼎彝器。在我以前的概念中,篆书是一种内容不多的书体,而到真正钻进去了之后,才识乾坤之大。随之而来的一个问题就是,最初的目的是以篆书作为基础,那么我应该换下一种书体还是随喜好继续在篆书上下功夫呢?这个问题可能还会困扰我很长一段时间。 识篆、写篆,自然还不能漏了篆刻。一方青石,两只刻刀,今年我在这上面也花了些时间。从梳理印学史、识别各个流派的风格,到操刀临摹,再到尝试创作。不得不说,金石的质感完全不同于纸墨,对理解「碑」很有帮助,诸碑学大家也从中取法很多。但篆刻的步骤毕竟更繁琐,花费时间更多,在时间有限的情况下,还是将时间让渡给了写字。友人求印,却之不恭,成了我后半年中少许动刀的时候。我也有临摹百方汉印的大愿望,不知来年是否有机会。 {caption} 朋友再三索求后为其治印一方{end caption} 阳台上的农夫 开春的时候准备了一些种子,做起了阳台上的农夫。种下的品种很多,包括番茄、向日葵、菊花等等,但在回忆一整年的打理时,只有番茄和向日葵的结果让我觉得不至于白费工夫。 初春时播种后,满怀期待等待种子萌发就是极有趣的过程。再至小苗抽条时,无论何人都会惊异于一天一变的生长速度。也是在这时,看着一丛丛绿叶,便能将人从每日近重复的碌碌工作中抽离出来,得到一种踏实度过一天的感觉。四五月里植物的生长很快,在番茄长至近一米高时,我就开始着手搭构爬架好让藤蔓有所攀附了。碍于阳台逼仄,我将绳子的一头系在阳台顶晾衣的横杆上,另一头系在根部附近的铁栅栏上,就这样立起了四根竖直的爬绳。 我为番茄的设计果然不负期望,在夏天到来前,已经为我撑起了一面绿幕。这时候我最喜欢做的事就是半蹲在这绿幕下,看阳光穿过一层层绿叶,翻动黄绿的色彩,又在风儿来时,扑楞闪烁。再过一阵子,番茄藤上就开始长出穗子,开出一朵朵小黄花了。这时候更要手脚勤快,为花授粉。每日都要轻轻摇动爬架,让花粉都散落下来,这活原来是交给风也可的,只是我不想将一年的收成全委之于天时。 未着粉的黄花在几天后就会干瘪,从花柄处脱落去。着粉成功的小花虽也会干瘪,却不会脱落,再过上几日,就会长出鼓鼓的圆球来了。气温也会影响植株的授粉,今年夏天尤其炎热,播种时间又迟,盛花时赶上了气温最高的时候,收成大减。 再细心养护上两月,就会见证着果实一点点膨大,再由绿转黄,最后变成讨人喜爱的大红色。透过阳光,晶莹的果肉很是馋人。终于将馋意忍到深秋,一边收拾枯枝败叶,一边也将全部果实都采摘下来。把一年的收获罗列盘中,仅有一握,看来全不能以树上的果实作为回报,其间的乐趣或可作为今岁的冬藏。 {caption} 四株番茄、两个品种、一年的收成{end caption} 些许小收藏 交换明信片、收集各地的邮戳也一直是我的爱好。我无法亲身走遍全国乃至世界,各地的明信片、邮戳能给我带来中一种正在周游世界的慰藉,对方亲手投递的郑重心意也让我很是感怀。 在今年的「邮获」中,特别喜欢从芬兰寄来的两张邮票,很有艺术感,符合一些对芬兰的「刻板印象」。白俄罗斯的邮戳很有特色,上面带有一些图案,不太好辨认,似乎是丰收的作物。粘贴的邮票也都是蔬果一类,大概白俄罗斯也是以农业为本的国家。从立陶宛寄来的这一张明信片是在年初抵达的,这位朋友在信上自哂现在身处俄乌战区,不知道能不能顺利投递,祝愿平安。 也有朋友在各地游玩时给我寄来了明信片,感谢他们常常挂怀于心,帮我丰富这点小爱好。 除此以外,亲身走过的地方也免不了集盖邮戳。西安是旅游业发达的大城市,仅鼓楼下的邮局就有六七枚风景戳。精力所限,只将游览所及的顺手纳入帐中。 琐谈 在动笔以前,冥思苦想了很久,年终总结该是什么样的文体,是一篇报告?还是记叙文?可能每个人都有自己的答案。我在想了很久之后,干脆直接回忆起一整年里印象深刻的事物,把想到的情景记录下来。最后又删删改改几遍就成了这个模样,可能潜意识里也有些效法《朝花夕拾》的影子。 我不愿意用粗笨的数字和长长的清单概括我的这一整年,只是摭取了几个自觉动人的片段。这些片段散落在过去一年之中,我以为生活的滋味也大多蕴藉在诸般琐碎的日常里。追忆这些日常所发生的当下,或快然或怅然,我很有动手将其写成长文的冲动,这当然也是写博客的初衷。可后由于半年里又为俗务缠身,忙忙碌碌之馀就是精疲力尽,实在没有气力再用向案头,这是过去一年里一大遗憾。 每逢年末岁初,许多人都会将去年的计划与今年的结果两相比对,通过一个百分比来衡量今年质量的高与否。诚然,我在文章里也这样做了,但是我还是要提醒自己,一年的总结不是做一整年的盘账,只盯着最终的出纳时,容易错过了相逢的人与事。「疫情时代」的生活教会了许多人如何去珍视自己眼前的、当下的、拥有的,这当然不是「含光浑世贵无名」的遁世哲学,而是在忙忙碌碌的现代生活里寻求究竟在为何而忙碌,这样的忙忙碌碌总好过庸庸碌碌。 旧的遗憾到了新年就不再是遗憾,就是踏着向前的新目标,大踏步向前吧。愿新年胜旧年,愿 2024 年世界和平。感谢今年里光顾敝站的朋友们,也感谢看到这里的读者们,祝愿新年快乐,祝新年日新!就以年末的大雪作结吧。

2023/12/31
articleCard.readMore

Cloudflare + Backblaze 实现免费的博客图床方案

图床一直是困扰 Markdown 以及静态博客用户的麻烦事,Ln's Blog 总结了一些免费图床服务,还分别列出了测试链接,可以比较主观地比较各图床的速度,也可以判断在所处网络环境下该图床是否可用。 我对图床的要求只有访问速度可靠、数据受控几点,遗憾的是尝试过的众多图床服务都不能满足我的要求,唯一适合我的方案只能是使用 OSS 搭建图床。于是我调查了阿里、腾迅等多家厂商提供的 OSS 服务,极复杂的收费规则首先就劝退了我。 辗转之下我发现了 Backblaze 提供的存储服务,B2 云存储提供 10 GB 的免费空间,同时 Cloudflare 与 Backblaze 之间的流量不计费,用作为图床是完全足够了,就算超出免费额度,$0.006 GB/Month 的价格也很合适。 使用 Backblaze B2 作为图床的唯一要求就是拥有一条托管在 Cloudflare 上的域名。若不知道如何将域名转移到 Cloudflare 上,可以参考先前写的迁移教程,完成后就可以按照本文的步骤操作了。 {warn}经网友提醒,目前在 Backblaze 创建公开桶需要充值 $1 用于激活账号,尽管后续的功能仍然免费,但额外需要具有国际支付功能的银行卡已经使这个方案变得尤其繁琐了。读者如若愿意支付费用,仍可按本文的步骤配置。{end warn} 创建桶 打开 Backblaze 官网很容易就能找到 B2 Cloud Storage 产品,完成注册与邮箱验证后,登录即可免费创建 B2 云存储的桶。 {note}Backblaze 提供的部分机翻中文根本看不懂,建议在网站的右下角切换语言为英文。{end note} 选择 Create a Bucket,在 Bucket Unique Name 一栏填入桶名称,桶名决定了源站的 URL,应尽可能复杂避免被他人猜测到。若源站 URL 泄露,绕过 Cloudflare 的直接访问就会产生额外流量了。其余项如下图保持默认即可: 创建完成后,选择 Upload / Download 尝试在桶中上传一张图片,查看图片的详细信息,其中 Friendly URL 一项就是生成的图片链接。 以 f000.backblazeb2.com/file/a-complicated-name/hokciu.jpg 为例,图片链接可以都分成以下几个部分: 主机名 后缀 桶名 图片路径 f000.backblze.com file a-complicated-name hokciu.jpg 因为 Friendly URL 中包含了桶名,不宜直接引用。假设想要将链接改写为 img.leonis.cc/hokciu.jpg,显然要修改主机名、隐藏固定的后缀和桶名,再拼接上图片路径,URL 的改写就通过 Cloudflare 实现。 添加 DNS 记录 改写的目标 URL 必须使用 Cloudflare CDN,打开 Cloudflare 控制台,添加名称为 img 目标为 f000.backblazeb2.com 的 CNAME 记录,并将代理状态设为打开。待 DNS 记录生效后,就实现了 img.leonis.cc → f000.backblazeb2.com 的跳转。 配置转换规则 同样在 Cloudflare 控制台中,找到 规则 - 转换规则 页面并创建新规则,填写规则自定义名称后就来处理 URL 的转换问题。 第一次接触 Cloudflare 的转换规则功能时,我被界面上各个选项弄得很迷糊,所以我在这里介绍一下转换规则各个功能的使用方法,读者理解了就能根据自己的想法配置图片链接了。 规则页面上的「传入请求」是指访客对托管站点发起的请求,例如访客所浏览的页面上有一条 img.leonis.cc/hokciu.jpg 链接,该请求先进入到 Cloudflare 的服务器,再根据设定的规则前往 f000.backblazeb2.com/file/a-complicated-name/hokciu.jpg 取出图片资源,最终呈现在页面上。 前文为了表述简单,说的是将 f000.backblazeb2.com/* 改为 img.leonis.cc/*,实则是我们要设定一个规则,让访客能通过 img.leonis.cc/* 到 f000.backblazeb2.com/* 中取得需要的图片。 在规则页面中的设置项可以参考下图: 该规则筛选得到所有主机名为 img.leonis.cc 的请求,将其 URL 重写到 concat("/file/a-complicated-name", http.request.uri.path),也就是把所有对 img.leonis.cc/* 的请求指向 img.leonis.cc/file/a-complicated-name/*。而因为 img.leonis.cc 已经通过 CNAME 指向了 f000.backblazeb2.com,最终请求都到达 f000.backblazeb2.com/file/a-complicated-name/* 并取得图片资源。 上述请求过程可以表示成 GET: https://img.leonis.cc/hokciu.jpg → https://img.leonis.cc/file/a-complicated-name/hokciu.jpg → https://f000.backblazeb2.com/file/a-complicated-name/hokciu.jpg 需要注意的是,因为这里使用的是重写(rewrite)而非重定向(redirect),请求的改变发生在服务端而非客户端,整个过程中用户都不会看见 URL 发生变化,所以也就达到了隐藏桶名的目的。 若设置全部无误,这时候就可以通过 https://img.leonis.cc/example.jpg 打开先前上传的图片了,由于 Backblaze 只支持 HTTPS,若打开 http://img.leonis.cc/example.jpg 则会弹出无效页面,用户体验不太好,所以接下来我们还需要通过 Cloudflare 页面规则完成 HTTPS 重写和缓存的相关设置。 设置页面规则 回到 Backblaze 找到 Bucket Settings 一项,在 Bucket Info 中填入 {"cache-control":"max-age=720000"},该项将 Cloudflare 回到源站获取资源的周期设定为 720000 s,用于避免回源次数过多导致加载速度过慢。当然,该周期过长也会导致源文件更改后不能及时更新,可以按自己的需求更改。 在 Cloudflare 中打开 规则 - 页面规则,新建一条页面规则,在 URL 一栏中填入 img.leonis.cc/*,按下图设置设置缓存和 HTTPS 即可。 {note}暂时不确定边缘缓存 TTL 和缓存级别两个设置项有什么作用,发现在未设置时图片就能命中缓存。不过既然官方文档提到了这两项配置就先给开启了,回头找找有没有详细些的资料。{end note} 再打开样例图片的链接,查看浏览器的开发者工具,在响应头中有一项 cd-cache-status,其值若为 HIT,则表示 Cloudflare 命中了缓存,该图片是由缓存中取出的。 至此关于 Backblaze + Cloudflare 的图床就设置完了,接下来还可以借助 PicGo 等第三方工具更方便地上传图片并获取图片链接,这部分内容可以根据章节标题向后文寻找。 整合静态资源 由于博客通常会使用到包括图片、字体在内的多种静态资源,我希望将他们都整合到相同的子域名下。当某些静态资源由于各种原由突然挂掉的时候(说的就是 jsDelivr 和 Google Fonts),我就可以直接在 Cloudflare 控制台上将其指向备用服务而不用去网页中一个个修改引用的链接,在管理维护上更方便。如果读者没有此需求,就可以完整跳过这一节了。 在我的设想中,所有静态资源都由 cdn.leonis.cc 分发,通过 URL 路径转向不同的子域名取得目标资源,后面就以图片资源为例实现这个构想。 添加 CDN 子域名 先在 Cloudflare 中添加子域名 cdn.leonis.cc 的 DNS 记录,暂时任意设置一个解析目标,能让 Cloudflare 获取缓存即可。 处理 URL 重定向 接着要实现对 URL 路径的处理,例如将 cdn.leonis.cc/img/* 重定向到 img.leonis.cc/*,这种重定向可以通过 Cloudflare 规则功能下的页面规则或重定向规则实现。 若使用页面规则,可以使用下图中的方案,用通配符实现 URL 解析: 该方案的一个小缺点在于无法将规则应用于 cdn.leonis.cc/img 等不带后一个 / 的页面。使用重定向规则可以解决这个问题,但重定向规则中的正则匹配是收费功能,无法批量处理,每种后缀都必须添加一条规则,配置方案可以参考下图: 表达式 concat("https://img.leonis.cc", substring(http.request.uri.path, 4)) 中的 substring() 用于除去 /img/* 的前 4 个字符,若是用于处理 /js/* 等不同的 URL 则需要根据字符数量更改该数值。以上两种方案各有优劣,读者可以根据自己的需求选择。 设置防盗链 防盗链是用于屏蔽其他站点对静态资源引用的常用手段,倒不是不愿意分享资源,至少本站内的各种照片都可随意使用,而是个人站点的服务容量有限,很难做到再向外提供服务。除此以外,设置防盗链对于避免流量被恶意浪费也很有必要。防盗链的功能可以通过 Cloudflare 的防火墙规则实现,打开 安全性 - WAF 页面即可创建规则。 防盗链功能一般通过请求头中的 Referer 字段判断是否允许请求,例如允许自己的站点引用图片(Referer 为本站 leonis.cc),不允许他人的站点引用图片(Referer 为外站 bing.com)。另外还有一种没有 Referer 的情况,例如直接打开图片、在各种 Markdown 编辑器中使用图片都属于这一类。 为了不影响正常使用,我使用的防盗链规则是允许无 Referer 与白名单站点访问。很棘手的是,Cloudflare 没有提供判断有无 Referer 的功能,所以我使用了比较曲折的方法实现该方案。 首先新建一条防火墙规则,对于静态资源的 URL,阻止所有 Referer 中包含 "http" 的请求: {note}该规则实际上阻止了所有具有 Referer 的请求,由于无法使用通配符才用 "http" 作为匹配内容。需要注意的是,没有 Referer 的请求不在该匹配范围内,设置后仍可访问。{end note} 再新建一条规则,这条规则用于根据 Referer 放行请求,作用等同于白名单,设置项如下: 设置生效后可以发现,先前的图片链接可以直接打开,却不能在其他网站上引用了。Cloudflare 阻止了白名单以外站点的引用请求,在防火墙事件中还可以查看阻止请求的来源 IP 等具体信息。 {note}后来发现在 Cloudflare 控制台中的 Scrape Shield 页面中有一项 Hotlink 保护功能,一键即可开启防盗链,在 Configuration Rules 中添加规则即为白名单,该配置方案更简单,以上 WAF 方案也留作参考。{end note} PicGo 设置 若每次上传图片都要打开 Backblaze 网站终归还是很麻烦,好在 PicGo 能够让整个过程自动化。PicGo 还提供了丰富的插件,可以实现自定义文件路径、文件名哈希化等功能。 设置 PicGo 作为 Backblaze 的图片上传工具,需要先打开 Backblaze Buckets 页面,在桶信息中记录下 Endpoint 的内容: 再在页面中找到 Application Keys 界面,选择 Add a New Application Key,填入 key 的名字: 在 Duration 一项可以设置 key 的有效期,过期后需要重新申请。选择提交后,页面就会给出生成的 keyID 和 applicationKey,将内容复制保存下来,一凡离开该页面就再也无法查看了。 安装好 PicGo 后,搜索并安装 s3 插件,打开 Amazon S3 的设置界面,填入先前保存下的信息,我的设置如下: "aws-s3": { "accessKeyID": "Backblaze keyID", "secretAccessKey": "Backblaze applicationKey", "endpoint": "https://s3.us-west-000.backblazeb2.com", "bucketName": "a-complicated-name", "uploadPath": "{year}/{month}/{sha256}.{extName}", "urlPrefix": "https://cdn.leonis.cc/img/" } 其中比较关键的是 accessKeyID、secretAccessKey、endpoint 三项,确保填写正确,另外不要忘了在 endpoint 前加上 https://。其余项则用于自定义图片路径和得到的 URL,具体配置可以参考插件仓库中的说明。 到这里就大功告成了,下面两张图片都存放在 Backblaze 上,一张是前文手动上传的示例图片,另一张则是通过 PicGo 上传。关于图片的加载速度和链接,不用我多说,诸君查看这两张图片即可自明。 References Deliver Public Backblaze B2 Content Through Cloudflare CDN Backblaze B2 + CloudFlare 搭建图床 - Mitsea Blog 使用 Backblaze B2 + Cloudflare CDN + PicGo 实现可自定义域名的 10G 免费图床解决方案 - winer's Blog

2023/11/17
articleCard.readMore

新服务器必做的基本设置——服务器迁移之记录

最近各大服务器厂商都开始做年末的促销了,不满于先前服务器时断时续的网络质量,我也趁着优惠换了一家供应商租赁了服务器,着手将所有服务迁移到新服务器上来。新购置的服务器空空如也,各种设置不免繁琐,于是我把过程记录下来,在又遇上新服务器时就能方便查阅。 基本设置 网络连通性 我租赁的都是海外服务器,可以免去很多麻烦,也会带来很多麻烦。海外服务器最首要的麻烦就是网络问题,厂商提供的 IP 可能会被防火墙污染,一买来就无法连接。比较方便的方法是在网站 https://ping.pe/ 上输入服务器 IP 检查服务器在全球范围内的连通状态,如果在大陆地区一片飘红,那么就必须联系客服申请更换 IP 了。 我在 RackNerd 上更换过 IP,客服的回应很快,更换也是免费的。但至于其他 IP 余量比较紧张的厂商,可能就会收取少许的额外费用。 服务器参数 GitHub 上可以找到很多用于测试服务器参数的 bash 脚本,我比较常用的是 Bench.sh。使用 SSH 连接并登录 root 用户后,输入 wget -qO- bench.sh | bash,自动下载脚本并开始测试。给出的测试结果包括系统信息、I/O 读写速度、网络速度: -------------------- A Bench.sh Script By Teddysun ------------------- Version : v2023-10-15 Usage : wget -qO- bench.sh | bash ---------------------------------------------------------------------- CPU Model : Intel(R) Xeon(R) CPU E5-2697 v2 @ 2.70GHz CPU Cores : 2 @ 2699.998 MHz CPU Cache : 30720 KB AES-NI : ✓ Enabled VM-x/AMD-V : ✗ Disabled Total Disk : 49.2 GB (1.9 GB Used) Total Mem : 976.2 MB (96.0 MB Used) Total Swap : 1023.0 MB (340.0 KB Used) System uptime : 23 days, 4 hour 40 min Load average : 0.03, 0.01, 0.00 OS : Debian GNU/Linux 11 Arch : x86_64 (64 Bit) Kernel : 5.10.0-8-amd64 TCP CC : Virtualization : Dedicated IPv4/IPv6 : ✓ Online / ✗ Offline Organization : AS35916 MULTACOM CORPORATION Location : Los Angeles / US Region : California ---------------------------------------------------------------------- I/O Speed(1st run) : 133 MB/s I/O Speed(2nd run) : 264 MB/s I/O Speed(3rd run) : 296 MB/s I/O Speed(average) : 231.0 MB/s ---------------------------------------------------------------------- Node Name Upload Speed Download Speed Latency Speedtest.net 917.95 Mbps 911.80 Mbps 0.48 ms Los Angeles, US 917.27 Mbps 906.25 Mbps 1.04 ms Dallas, US 919.58 Mbps 129.57 Mbps 31.06 ms Montreal, CA 792.01 Mbps 674.81 Mbps 72.70 ms Paris, FR 567.70 Mbps 655.98 Mbps 144.64 ms Amsterdam, NL 584.61 Mbps 271.38 Mbps 139.27 ms Shanghai, CN 387.34 Mbps 25.47 Mbps 187.49 ms Chongqing, CN 27.54 Mbps 0.65 Mbps 224.27 ms Hongkong, CN 528.81 Mbps 23.52 Mbps 145.39 ms Mumbai, IN 358.96 Mbps 430.29 Mbps 235.43 ms Singapore, SG 366.75 Mbps 638.25 Mbps 184.59 ms Tokyo, JP 375.91 Mbps 198.06 Mbps 118.29 ms ---------------------------------------------------------------------- Finished in : 6 min 31 sec Timestamp : 2023-11-10 03:01:34 EST ---------------------------------------------------------------------- 在测试结果中主要比对提供的服务器参数是否与购买时的配置清单匹配、网络状况是否满足要求,同时也可对服务器的具体工作状况加深印象。我的测试结果没有什么问题,相比旧服务器硬件有所升级,网络的连接比顺畅很多。新服务器来自于 CloudCone,这款 2 核 1 GB 的服务器售价是每年 $16.5,还是比较划算的。 由于我对 Debian 系统有着特殊的感情,不管 PC 设备还是服务器的首选 Linux OS 都是 Debian。后续涉及的安装软件等操作在不同 OS 上可能有所不同,读者自行留意,不再反复重提。 路由测试 购买海外服务器的用户一般会比较看重线路,即去回程的数据需要经过哪些路由的转发,例如拥有 CN2 GIA 线路的海外服务器在大陆访问也十分通畅,很受追捧。我购买的廉价服务器自然没有这样的线路,不过研究研究数据如何穿越海底光缆到达大洋彼岸也是很有意思的事。 网站 https://tools.ipip.net/traceroute.php 提供了各地区节点,可以查询各地去往服务器的数据线路。另一种方法是使用系统自带的测试工具,可以追踪由本机发出的数据。 后文的例子中,将 8.8.8.8 当作为服务器的 IP 地址,将 1.1.1.1 当作为本地 IP 地址,读者需要根据自己的实际情况修改。 在本地电脑上打开终端,使用 tracert 命令追踪住服务器需要经过的路由: > tracert 8.8.8.8 通过最多 30 个跃点跟踪到 [8.8.8.8] 的路由 1 3 ms 2 ms 1 ms 10.131.192.1 2 3 ms 2 ms 3 ms 202.113.18.233 3 1 ms 1 ms 1 ms 202.113.18.102 4 3 ms 4 ms 2 ms 117.131.219.1 5 6 ms 3 ms 3 ms 117.131.131.13 6 * * * 请求超时。 7 * * * 请求超时。 8 7 ms 8 ms 7 ms 221.183.89.121 9 * * * 请求超时。 10 * * * 请求超时。 11 * * * 请求超时。 12 * * * 请求超时。 13 193 ms 192 ms 192 ms eth-0-19.10g.cr1.ny1.ip.coresite.com [206.223.143.40] 14 * * * 请求超时。 15 195 ms 195 ms 195 ms 8.8.8.8 跟踪完成。 查询一下 IP 的归属就能知道,去程数据先往北京,走寻常不过的 221.183.*.* 的 AS9808 路由再跨过大洋。 在服务器端可以查看回程线路,使用 mtr 命令向本地 IP 传递数据: {note}有的服务器厂商可能没有在 OS 里预装 mtr 工具,可以通过 apt-get install mtr-tiny 安装。{end note} # mtr 1.1.1.1 -r HOST: cc.server Loss% Snt Last Avg Best Wrst StDev 1.|-- undefined.hostname.localh 0.0% 10 9.1 5.3 0.7 15.1 6.1 2.|-- multacom.com 0.0% 10 0.8 0.8 0.7 1.1 0.1 3.|-- 182.54.129.88 0.0% 10 0.5 6.7 0.5 61.5 19.2 4.|-- 218.30.54.189 0.0% 10 5.9 5.3 2.5 7.9 1.8 5.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0 6.|-- 202.97.58.121 0.0% 10 154.4 155.1 152.7 156.3 1.2 7.|-- 202.97.48.209 90.0% 10 175.8 175.8 175.8 175.8 0.0 8.|-- 202.97.108.126 0.0% 10 178.8 175.0 166.5 178.8 5.4 9.|-- 219.150.49.154 20.0% 10 179.2 177.2 172.5 182.1 3.5 10.|-- 221.238.222.118 10.0% 10 169.8 167.8 159.2 175.3 5.1 11.|-- 218.69.12.90 10.0% 10 188.9 184.8 176.4 189.0 5.1 12.|-- ??? 100.0 10 0.0 0.0 0.0 0.0 0.0 线路中的路由主要是 202.97.*.*,也就是传统的 163 骨干网。据说在网络较空闲时,该款服务器线路会动态切换为 CN2,我对此也不是很在意就是了。 {warn}测试结果中的路由 IP 地址会暴露设备所处的地理位置,在社交平台上公开前务必三思。{end warn} 创建新用户 在购买服务器后,除了服务器 IP 地址外,供应商还会提供 root 用户的密码,用户可以通过 SSH 连接服务器。但 root 用户的权限太高,误操作容易造成不可逆的结果。在 Linux 的使用中,不论是服务器还是本地 PC,通常都是新建普通用户供日常使用,在权限不足时通过 sudo 命令提权,完成操作后自动「尽早」地退出 root 模式。 首先是使用 adduser 用户名 新建用户,创建用户的过程中会提示设定并确认密码,按提示输入即可: # adduser leo Adding user `leo' ... Adding new group `leo' (1000) ... Adding new user `leo' (1000) with group `leo' ... Creating home directory `/home/leo' ... Copying files from `/etc/skel' ... New password: Retype new password: passwd: password updated successfully Changing the user information for leo Enter the new value, or press ENTER for the default Full Name []: Leo Room Number []: Work Phone []: Home Phone []: Other []: Is the information correct? [Y/n] y 接着安装 sudo 命令,以后就用 sudo 命令管理 root 权限。安装完成后用 visudo 进入配置文件: # apt-get install sudo # visudo 在配置文件中找到以下片段: # User privilege specification root ALL=(ALL:ALL) ALL leo ALL=(ALL:ALL) ALL 在 root 用户的下一行填上新建用户的用户名,同样填上 ALL=(ALL:ALL) ALL。根据窗口下方的快捷键提示,依次摁 CTRL + O 保存,摁 ENTER 确认,摁 CTRL + X 退出。 SSH 设置 几乎所有远端服务器都是通过 SSH 与用户相连接,当服务器暴露在公网上时,就有无数人尝试爆破 SSH 口令盗取控制权,所以 SSH 的安全是保护服务器的第一道关口。为了避免服务器变成肉鸡,最为基础且最为有效的方法就是修改 SSH 的默认配置。 更改 SSH 端口 SSH 的默认端口是 22,将其改为非常见端口就可以躲过大量定向的爆破。上文中新建的用户名为 leo,通过 ssh leo@8.8.8.8 换用新用户登录 SSH。 打开 SSH 的配置文件: $ sudo vim /etc/ssh/sshd_config 将文件中 Port 一项改为自定义端口,并将 PermitRootLogin 一项改为 no,禁止直接使用 root 用户登录,修改后例如: Port 2222 PermitRootLogin no 最后重启 SSH 服务加载配置: $ sudo service sshd restart 此时可以测试是否可以通过 ssh -p 2222 leo@8.8.8.8 登录,若配置无误,以下两种方式都会失效: ssh leo@8.8.8.8 ssh root@8.8.8.8 禁止密码登录 凡使用密码作为登录口令,终究有被爆破的可能,况且长密码也很难记忆。更为安全有效的方法是禁止使用密码登录 SSH,使用公私钥完成用户的验证。 在服务器上生成公私钥: $ ssh-keygen Generating public/private rsa key pair. Enter file in which to save the key (/home/leo/.ssh/id_rsa): # 此处摁回车,存储在默认位置 Created directory '/home/leo/.ssh'. Enter passphrase (empty for no passphrase): # 输入 passphrase,若不设置则直接摁回车 Enter same passphrase again: # 重复 passphrase Your identification has been saved in /home/leo/.ssh/id_rsa # 私钥保存路径 Your public key has been saved in /home/leo/.ssh/id_rsa.pub # 公钥保存路径 在生成过程中会提示用户输入 passphrase,若设置了该口令,在私钥验证通过后还需要通过该口令的验证。在私钥被他人盗取的情况下,对方不知道该口令也无法登录,安全性更高。 公钥相当于一把锁,存放在服务器上,私钥相当于一把钥匙,存放在本地。服务器上的授权文件则决定了使不使用该公钥完成验证,所以还要按下列步骤为新生成的公钥添加授权: $ cd .ssh $ cat id_rsa.pub >> authorized_keys $ chmod 600 authorized_keys $ chmod 700 ~/.ssh 打开 SSH 的配置文件: $ sudo vim /etc/ssh/sshd_config 找到以下项目,编辑设置开启公钥验证: PubkeyAuthentication yes 使用 cat /home/leo/.ssh/id_rsa 在终端中输出私钥内容,将其复制后写入到本地的记事本中,将文件保存为 id_rsa,存放在自定义的目录下。接着在本地打开终端,尝试使用私钥连接服务器: ssh -p 端口号 -i "私钥路径" 用户名@主机名 {note}相信许多读者使用的是 PuTTY 等更为便捷的 SSH 客户端,在设置项中一定也可以使用私钥的方式完成登录,各种客户端的设置方式不尽相同,就不在此罗列了。 {end note} 成功登录后,SSH 的公私钥设置就没有问题了。再次打开 SSH 配置文件: $ sudo vim /etc/ssh/sshd_config 将密码登录关闭,以后全部使用私钥登录: PasswordAuthentication no 最后重启 SSH 服务,SSH 的设置内容就全部完成了: $ sudo service sshd restart 安装 Fail2Ban 更改 SSH 的默认设置提升了防御等级,但只顾着防守而没有反制措施,暴露在外的防护手段在积年累月的攻击下,始终有被攻破的风险。Fail2Ban 是用于反制非法访问的有力工具,Fail2Ban 能够根据服务器的访问日志找出密码失败次数过多等具有风险的 IP 并自动封禁,是避免暴力攻击的有效手段。 Fail2Ban 亦可设置邮件通知等功能,读者如有兴趣可以自行搜索,在这里仅介绍基础的 SSH 安全设置。首先在服务器上安装 Fail2Ban: $ sudo apt-get install fail2ban Fail2Ban 的默认设置文件为 /etc/fail2ban/jail.conf,一般不改写该文件,而是在同目录下新建 jail.local,其中的设置项会添加入 jail.conf 并覆盖同名设置项。使用 Vim 新建 jail.local 文件: $ sudo vim /etc/fail2ban/jail.local 在文件中写入针对 SSH 服务的封禁规则: [sshd] enabled = true filter = sshd port = 2222 # SSH 服务对应的端口 logpath = /var/log/auth.log # 日志路径 maxretry = 3 # 最大允许试错次数 bantime = -1 # IP 封禁时间(无限) 保存设置后通过 sudo systemctl start fail2ban 启动,Fail2Ban 就开始保护服务器了。以下罗列了在维护时经常需要用到的命令: $ w # 查看当前服务器登录的用户 $ last # 查看过去一段时间的登录用户 $ sudo systemctl enable fail2ban.service # 开机启动 $ sudo systemctl status fail2ban.service # 查看服务运行状态 $ sudo cat /var/log/fail2ban.log # 查看日志文件 $ sudo fail2ban-client status # 查看 fail2ban 的运行状态 $ sudo fail2ban-client status sshd # 查看 sshd 的详细信息,包括封禁 IP 等 $ sudo fail2ban-client set sshd unbanip 1.1.1.1 # 解封指定 IP 1.1.1.1 或许有读者认为,有必要这么麻烦地折腾 SSH 安全吗?也没见有什么人来连接我的服务器。事实并非如此,当服务器以公网 IP 直接接入互联网后,每天都要面临大量连接请求,多亏了厂商默认设置的强密码,将很多隐患挡在了外头。 诸君如若不信,可以通过以下命令查询指定日期的失败访问: $ lastb -s 2023-11-6 -t 2023-11-7​ administ ssh:notty 185.224.128.160 Mon Nov 6 04:53 - 04:53 (00:00) esroot ssh:notty 170.64.161.15 Mon Nov 6 04:53 - 04:53 (00:00) administ ssh:notty 185.224.128.160 Mon Nov 6 04:53 - 04:53 (00:00) admin ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) esroot ssh:notty 170.64.161.15 Mon Nov 6 04:52 - 04:52 (00:00) admin ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) root ssh:notty 180.101.88.222 Mon Nov 6 04:52 - 04:52 (00:00) root ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) root ssh:notty 180.101.88.222 Mon Nov 6 04:52 - 04:52 (00:00) root ssh:notty 180.101.88.222 Mon Nov 6 04:52 - 04:52 (00:00) root ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) odoo ssh:notty 170.64.161.15 Mon Nov 6 04:52 - 04:52 (00:00) odoo ssh:notty 170.64.161.15 Mon Nov 6 04:52 - 04:52 (00:00) Admin ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) Admin ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) root ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) opc ssh:notty 170.64.161.15 Mon Nov 6 04:52 - 04:52 (00:00) Admin ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) opc ssh:notty 170.64.161.15 Mon Nov 6 04:52 - 04:52 (00:00) Admin ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) root ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) user ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) user ssh:notty 185.224.128.160 Mon Nov 6 04:52 - 04:52 (00:00) 我查询了还未修改 SSH 默认设置时的失败访问,这里仅截取了很小一部分结果。可以看见,全世界各地都有人在很频繁地尝试连接,爆破 root、Admin 等常见用户的密码,由此也可见以上安全措施的重要性。 UFW 防火墙设置 UFW 可以用于很方便地管理服务器上的端口,关闭无用的端口也是保证服务器安全的基本措施。安装 UFW 后仅打开需要的服务端口: $ sudo apt-get install ufw $ sudo ufw allow ssh $ sudo ufw allow http $ sudo ufw allow https 千万别忘了我们已经修改了 SSH 的默认端口,再将自定义端口打开并尝看规则是否有误: $ sudo ufw allow 2222 $ sudo ufw status Status: active To Action From -- ------ ---- 22/tcp ALLOW Anywhere 80/tcp ALLOW Anywhere 443 ALLOW Anywhere 2222 ALLOW Anywhere 22/tcp (v6) ALLOW Anywhere (v6) 80/tcp (v6) ALLOW Anywhere (v6) 443 (v6) ALLOW Anywhere (v6) 2222 (v6) ALLOW Anywhere (v6) 用 systemd 打开 UFW 服务并设定自动启动,服务器上的端口就受 UFW 规则控制了: $ sudo systemctl start ufw $ sudo systemctl enable ufw 开启 BBR BBR(Bottleneck Bandwidth and Round-trip propagation time)是 Google 提出的一种拥塞控制算法,能够保证在有丢包率的不良网络环境下的连接,这对于海外服务器是一项比较重要的功能。 有些服务器默认开启了 BBR,可以通过以下命令检查: $ sudo sysctl net.ipv4.tcp_available_congestion_control | grep bbr $ sudo sysctl net.ipv4.tcp_congestion_control | grep bbr 若没有输出,就需要通过以下方式手动开启: $ sudo sh -c 'echo "net.core.default_qdisc=fq" >> /etc/sysctl.conf' $ sudo sh -c 'echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf' $ sudo sysctl -p net.core.default_qdisc = fq net.ipv4.tcp_congestion_control = bbr 至此,新购买服务器的配置就差不多完成了,大部分都是和网络安全相关的设置,虽显得繁琐却又不得不做。若服务器厂商另外提供备份和 DDoS 防御等功能也应选择开启,因为廉价服务器不会提供此类服务且各厂商的设置方法都不相同,这类功能就超出本文的范围了。不过将文中的基础功能配置下来,后续就已经可以在服务器上放心地部署服务了。 References VPS 初体验(一)基础配置 - Kiku 的个人博客 VPS 服务器 安全防护设置 - 老王的自留地 | ivo Blog 购买了 VPS 之后你应该做足的安全措施 - 落格博客

2023/11/11
articleCard.readMore

把博客站点交给了 Cloudflare 托管

因为博客域名是在阿里云购买的,先前一直顺理成章地用着阿里云的 DNS 解析。阿里云的 DNS 解析在各方面的体验都很不错,例如修改配置后就能很快更新、配置平台访问速度快、站点不会被国内的运营商污染等等,这些优点反过来可是说尽是 Cloudflare 的缺点。 但由于 Cloudflare 为网站提供的各种免费服务十分诱人,加之我想利用 Cloudflare 的 CDN 搭建博客图床,终究是把站点交给了 Cloudflare 管理。本文记录了从阿里云迁移站点的过程和一些必要的 Nginx 配置。 Cloudflare 注册站点 打开 Cloudflare 官网,注册帐号后选择添加站点,输入域名后点击继续。 按需选择计划,对于普通的小站点来说,Free 计划足矣。点击继续后,Cloudflare 会检测站点目前已有的部分 DNS 记录,其余未检测出的记录日后再手动添加,最关键的是检查域名指向服务器 IP 地址的 A 记录是否正确。 在「代理状态」一列可以选择该 DNS 记录是否使用 Cloudflare 的 CDN,激活后图标显示一朵黄色的云。Cloudflare 的 CDN 在国内速度很慢,一直被称为减速 CDN,所以我都选择「仅 DNS」。此前我也担心 Cloudflare 的 DNS 解析会不会也像其 CDN 一样龟速,幸好解析速度并不慢,我的担心是多虑了。 提交 DNS 记录后,Cloudflare 会提示删除阿里云的 DNS 服务器,以 Cloudflare 的 DNS 服务器代替之,接着就转到阿里云的控制中心操作。 更换 DNS 服务器 登录阿里云,进入控制台。在云解析 DNS - 域名解析下找到迁移的域名,在解析设置中保存了站点的 DNS 记录。将记录备份,后续要将所有记录导入 Cloudflare。站点交由 Cloudflare 解析后,阿里云中的解析设置也会失效,所以也在解析设置中将所有解析都停用。 在阿里云控制台中来到域名控制台 - 域名列表,选择域名的管理 - DNS 管理 - DNS 修改 - 修改 DNS 服务器,将 Cloudflare 提供的两个 DNS 服务器地址填入其中。 修改 DNS 服务器一般需要 24-48 h 生效,生效后 Cloudflare 会发送邮件通知。如果迟迟没有收到邮件,也可以到 Cloudflare 手动验证网站。验证成功后 Cloudflare 会指引是否开启 Brotli 压缩等功能,按需选择即可。至此,站点已经交由 Cloudflare 托管。如果站点是由 Nginx 搭建的,那么就还需要考虑 Nginx 的 SSL 设置是否与 Cloudflare 兼容。 Nginx 中的 SSL 相关配置 在 Cloudflare 的 SSL/TLS 设置界面可以看到,用户访问由 Cloudflare 托管的站点的过程中有 3 个实体,根据实体间通信安全等级的不同可以分为 4 种模式: 关闭:浏览器-Cloudflare 间和 Cloudflare-服务器间都使用 HTTP; 灵活:浏览器-Cloudflare 间使用 HTTPS,Cloudflare-服务器间使用 HTTP; 完全:浏览器-Cloudflare 间和 Cloudflare-服务器间都使用 HTTPS,需要 SSL 证书; 完全(严格):浏览器-Cloudflare 间和 Cloudflare-服务器间都使用 HTTPS,需要非自签名 SSL 证书。 现在的站点一般都使用了 HTTPS,还在使用 HTTP 的站长快去申请个 SSL 证书吧,同时通过 Nginx 将访问 80 端口的 HTTP 流量强制重定向到 HTTPS 入口。若使用这样的 Nginx 配置又开启的「灵活」模式,用户发起访问请求后,Cloudflare 使用 HTTP 交由 Nginx,Nginx 告知用户重定向为 HTTPS,但Cloudflare 仍使用 HTTP 与 Nginx 通信,该过程无限循环,出现 301 重定向次数过多。 为了保证站点的安全性和避免以上问题,推荐配置好站点的 HTTPS 后,在 Cloudflare 的 SSL/TLS 中使用完全或完全(严格)两种模式。 最后附上我的 Nginx 配置供参考: server { listen 443 ssl http2; server_name leonis.cc; root /home/Leo/web/blog; # SSL 配置 ssl_certificate /etc/nginx/cert/leonis.cc.cer; ssl_certificate_key /etc/nginx/cert/leonis.cc.key; ssl_session_timeout 5m; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_prefer_server_ciphers on; location / { index index.html; } } server { listen 80; server_name leonis.cc # 重定向至 HTTPS,开启 Cloudflare 完全模式后不会访问 80 端口,也不会用上此处的重定向 rewrite ^/(.*)$ https://leonis.cc:443/$1 permanent; } 后记 Cloudflare 总体来说还是很好用的,提供了很多有意思的功能,很便利地就能体验,免去了自己动手配置的烦恼。Cloudflare 的不足仅在于在国内有时访问不畅,添加 DNS 记录后也要等比较长的时间才会更新到国内网络上,若能接受这两点,Cloudflare 的可玩性还是比其他平台更高的。

2023/10/31
articleCard.readMore

RIME 脚本食用方法举隅:以输入苏州码为例

RIME 或称中州韵输入法,另一个更风行的名字是小狼毫输入法,当然这并不准确,因为只有 Windows 平台上的 RIME 才称为小狼毫。不过也无妨,作为一款开源输入法,RIME 可以部署在 Windows、MacOS、Linux、Android 等多个平台上,实现大同小异的功能,大部分配置文件也都通用,用不着很仔细区分。 我很早就听说了 RIME,作为开源输入法,用户可以自己构建码表、输入方案,因而一问世就很受方言、汉字、打字爱好者的青睐。方言爱好者用 RIME 实现各种方言输入方案,汉字爱好者用来输入扩展区汉字,打字爱好者则是用来改进各种音码、形码方案,不一而足。 但早年间 RIME 的 bug 比较多,入门的门槛高,一直只在小圈子内流行。经过数次版本迭代后,现而今的 RIME 可以说是非常好用,哪怕是仅追求不窃取用户资料的「圈外人」也可以轻松体验。 网络上关于配置 RIME 的入门教程很多,我不在此赘言。这篇文章主要谈谈如何用 RIME 的 Lua 脚本实现一些高级输入,也是我最近折腾 RIME 的一些心得。 苏州码 苏州码也称苏州码子、花码等,是中国传统的记数符号,对照如下表所示: 0 1 2 3 4 5 6 7 8 9 〇 〡 〢 〣 〤 〥 〦 〧 〨 〩 在表示数字时,苏州码用一个符号表示一位数,从左向右书写,这与阿拉伯数字的计数方式相同。 苏州码还有一条规则,当「〡」「〢」「〣」中任意两者相邻时,首个用竖式,次一个用横式,再次一个又用回竖式,如此循环。仅「〡」「〢」「〣」三个数字具有横式苏州码,其所谓横式就是汉字的「一」「二」「三」,可以想知这是为了避免「〡」「〢」粘连成「〣」。 知道以上的规则就会识读苏州码了,例如: 18590 ➔ 〡〨〥〩〇 51203 ➔ 〥〡二〇〣 72132 ➔ 〧〢一〣二 再来看几个加上单位的具体例子: 实际使用时,还会将最大数位用汉字着于最高位数字下方,数量单位着于个位数字下方。可以看出,苏州码完美兼容中文直排的书写传统,阅读时从左至右逐列读出即可。在遇到大数时,这种能直接呼读的优势更为明显,例如 〡〨〥〣〤〦〥 可以直接读「一万八千五百三十四块六五」。由于排版不便,苏州码在互联网时代已经难觅踪迹了,但似乎在民间手写的场合还有孑余。 RIME 言归正传,一个个复制输入苏州码太不现实,那么如何优雅地用 RIME 输入苏州码呢? 挂载一个输入方案 从头构建输入方案太过复杂,我们可以通过修改现成的输入方案实现我们的想法。在 RIME 的官方仓库中就能找到很多输入方案,可以下载一个最熟悉的。 以 Windows 平台为例,正确安装 RIME 后,在右下角的任务栏中理应出现 RIME 图标。 右击 RIME 图标,选择 用户文件夹,将下载的输入方案移入该文件夹中,文件夹中应具有许多 .yaml 文件; 右击 RIME 图标,选择 重新部署; 再右击 RIME 图标,选择 输入法设定,就能找到下载的输入方案了。 深入输入方案 输入方案最基本的两个文件是 *.schema.yaml 和 *.dict.yaml: *.schema.yaml 用于实现输入功能,例如模糊音、中英文混打等功能都通过它实现; *.dict.yaml 是码表文件,用户一般不需要动它。 打开输入方案的 *.schema.yaml,可以看到里面有一个名为 translators 的模块,该模块决定了打字时击入的编码如何转化为候选词。 我们要通过 Lua 脚本将输入的数字转为苏州码,在该模块下添加一项 lua_translator@number_translator。lua_translator 告诉 RIME 我们要使用 Lua 生成候选词,number_translator 是函数名称。我修改后的 translators 模块为 translators: - punct_translator - table_translator@custom_phrase - reverse_lookup_translator - script_translator - lua_translator@number_translator {warn}YAML 文件对缩进敏感,一定要检查缩进是否正确。{end warn} Lua 脚本 接着在用户文件夹,即 *.schema.yaml 所在文件夹中新建一个名为 rime.lua 的文件,写入 number_translator = require("number") 上述代码将 number.lua 脚本注册为 number_translator 函数。rime.lua 文件管理着接入 RIME 的所有 Lua 脚本,将相应脚本注释去,其功能就被禁用。 在用户文件夹中新建名为 lua 的文件夹,所有 Lua 脚本就存放在该目录下,在该目录中新建一个 number.lua 文件。如果仅列举关键文件,文件结构应为 RIME ├─*.dict.yaml ├─*.schema.yaml ├─lua │ └─number.lua └─rime.lua 在 number.lua 写入将数字字符串转为苏州码的核心函数: local function contains(array, element) for _, value in pairs(array) do if value == element then return true end end return false end local function num2suzhou(num) local suzhou = {"〇", "〡", "〢", "〣", "〤", "〥", "〦", "〧", "〨", "〩"} local horizontalSuzhou = {"一", "二", "三"} local oneTwoThree = {table.unpack(suzhou, 2, 4)} -- {"〡", "〢", "〣"} local result = "" if num == nil then return "" end -- 遍历整个字符串 for pos = 1, string.len(num) do -- 将每个字符转为数字 digit = tonumber(string.sub(num, pos, pos)) if pos > 1 then -- 数字若为 {"〡", "〢", "〣"} if digit > 0 and digit < 4 then -- 且前一个字符也为 {"〡", "〢", "〣"} -- `-3` 即取末一个汉字,utf-8 中一个汉字 3 字节 if contains(oneTwoThree, string.sub(result, -3)) then -- 就使用横式的 {"一", "二", "三"} result = result .. horizontalSuzhou[digit] goto continue end end end -- 其他情况或其他数字都使用竖式 result = result .. suzhou[digit + 1] ::continue:: end return result end num2suzhou() 实现了前文提到的数字与苏州码映射和横竖式转换两个规则,接下来要将封装成 RIME 的接口: -- 若输入数字带有小数,将其切分为整数、小数点、小数 3 个部分 local function splitNumPart(str) local part = {} part.int, part.dot, part.dec = string.match(str, "^(%d*)(%.?)(%d*)") return part end -- 字符串处理流程 function numberTranslatorFunc(num) -- 切分小数 local numberPart = splitNumPart(num) local result = {} -- 整数和小数部分分别用 num2suzhou() 转换,再将整数、小数点、小数三者连起来 -- 最后将结果存入 result table.insert( result, { -- 候选结果 num2suzhou(numberPart.int) .. numberPart.dot .. num2suzhou(numberPart.dec), -- 候选备注 "〔蘇州碼〕" } ) return result end -- 接入 RIME 引擎 function translator(input, seg) local str, num, numberPart -- 匹配 "S + 数字 + 小数点(可有可无) + 数字(可有可无)" 的模版 if string.match(input, "^(S%d+)(%.?)(%d*)$") ~= nil then -- 去除字符串首的字母 str = string.gsub(input, "^(%a+)", "") numberPart = numberTranslatorFunc(str) if #numberPart > 0 then for i = 1, #numberPart do -- numberTranslatorFunc() yield( Candidate( input, seg.start, seg._end, numberPart[i][1], -- 候选结果 numberPart[i][2] -- 候选备注 ) ) end end end end return translator 处理字符串的过程都写在注释中了,这里仅具体说一下接入 RIME 的 translator() 函数。 translator(input, seg) 接受两个参数,input 为用户击入的字符,seg 推测是分词信息,一般用不到,可以当作固定模版。 正则 "^(S%d+)(%.?)(%d*)$" 用于匹配用户的 input: S 匹配大写字母「S」,作用类似于快捷键,也可以改为自己喜欢的键位; %d+ 匹配一至多个数字; ^ 表示匹配句首,^(S%d+) 就表示只有以「S」和若干数字开头时才会转换; %. 匹配字符「.」,%.? 表示「.」可有可无; %d* 匹配零至多个数字。 用户输入的字符符合匹配规则,字符串经处理后用 yield(Candidate()) 生成候选词。Candidate() 需要填入 5 个参数,不过其实也只用更改后两个参数就好。 完成后仍然要重新部署一下,就可以试试输入效果了~ 了解在 RIME 上套用 Lua 脚本的方法后,相信编写自己的脚本也不觉得困难了,参考模版就能实现自己的奇思妙想。 librime-lua 提供了许多 Lua 脚本,已经实现了很多有意思的想法,供额外参考。 References Suzhou numerals - Wikipedia 李文化 & 陈虹. (2020).《癸亥年更流部》苏州码子释读. 南海学刊(04), 38-46. LEOYoon-Tsaw / Rime_collections - GitHub

2023/9/14
articleCard.readMore

通过 SSH 在 Pycharm 上使用 Docker 容器中的 Python 解释器

配置工程的运行环境一直是一件麻烦事,尽管 Anaconda 等工具提供的虚拟环境能够提够相对隔离的 Python 环境,但在调用更为底层硬件资源时难免会遇到冲突。例如我所遇到的情况是,需要使用的 Mindspore 最高仅支持 CUDA 11.6,而设备上已经安装了 CUDA 11.8,卸载又担心导致先前的项目出问题,这样冲突就只能靠 Docker 来解决了。 我的解决方案很简单,直接从 Docker Hub 上拉取 CUDA 11.6 的 Mindspore 镜像,镜像中已经做好了相应的配置且与宿主机的环境隔离,运行该镜像的容器后就可以运行工程代码。但通过 Docker 运行容器呈现出的内容并非图形化的,都是以命令行形式在终端上展示、交互,调试代码时很不方便。那么是否能用 IDE 连接容器中的 Python 解释器,在图形化界面里调试代码呢? 巧的是 Pycharm 的确提供这个功能,在选择项目的解释器时的确可以选择 Docker,不巧的是在 Pycharm 的工作逻辑中,该配置项只能选择镜像,而不能选择容器。点击运行代码后,Pycharm 先用所选择的镜像构建一个临时容器,再用该容器中的解释器来运行代码。 运行 Docker 容器更为通用的方法是使用 docker run 命令,该命令还可以接收很多其他复杂的参数,例如通过 docker run --gpus all 挂载 GPU 等。Pycharm 略过这个配置项就导致生成的容器存在多多少少的问题,例如无法调用 GPU、没有挂载硬盘等等。 那么是否有通过 IDE 使用容器中的解释器调试代码的方法呢?有的,那就是不使用 Pycharm,而使用 JetBrains Gateway 连接解释器。虽说有些标题党,但 Gateway 与 Pycharm 毕竟是同一家公司的产品,且 Gateway 集成了 Pycharm 的 IDE,完成能达到使用要求。尽管 Gateway 还在 Beta 版本,我试用了很久仍觉得十分好用,我认为这大概是最「优雅」的 Docker 环境使用方式。 在宿主机上安装 Gateway 后,通过 SSH 连接到容器内,Gateway 会在容器中下载后台程序。宿主机上的操作都会经由 SSH 通过后台在容器中执行,所产生的反馈也由 SSH 传达并渲染到宿主机的界面上。所以使用 Gateway 调试、运行容器中代码的感觉就几乎和在本地一样,尽管无声的来去之间已经在 SSH 上交换了无数数据。如果能通过 SSH 连接远程服务器,同样也可以使用 Gateway 调试,十分便捷。 下文就以 Mindspore 为例,介绍在 Linux 上配置 Docker 容器的 SSH 服务并使用 Gateway 连接容器中解释器的方法。Mindspore 是相当麻烦的 AI 框架,如果 Mindspore 都能装上,相信 Pytorch 和 TensorFlow 之类用户友好的框架就完全不成问题了。 安装 JetBrains Gateway 在 JetBrains Gateway 官网下载压缩包,解压后挪到 /opt 目录下,在终端中可以用 /opt/Gateway/bin/gateway.sh 启动。 # 在官网上可以找到最新版的下载链接 $ wget https://download.jetbrains.com/idea/gateway/JetBrainsGateway-2023.2.tar.gz?_gl=1*1b4kr34*_ga*MTkzNDYxNzI1MS4xNjc2Njg1NzQx*_ga_9J976DJZ68*MTY5MTE0NTQwMy4yMC4xLjE2OTExNDc2NzkuNTguMC4w -O Gateway.tar.gz $ tar -zxvf Gateway.tar.gz $ sudo mv -f JetBrainsGateway-232.8660.185 /opt/Gateway 亦可以通过 Gateway 的欢迎界面创建桌面图标: 配置容器 SSH 拉取镜像 # 从 Docker Hub 上拉取需要的镜像(Ubuntu X86) $ docker pull mindspore/mindspore-gpu-11.6:2.0.0-alpha 构建镜像 在空文件夹中新建 Dockerfile 文件,内容如下: # 使用前一步骤拉取的镜像作为基础镜像 FROM mindspore/mindspore-gpu-cuda11.6:2.0.0-alpha # 切换到 root 用户 USER root # 设置 root 用户密码为 12345(连接 SSH 时使用) RUN echo "root:12345"|chpasswd # 安装 vim supervisor openssh-server RUN apt-get update && \ apt-get install -y vim supervisor openssh-server # 修改 SSH 设置,允许使用 root 用户连接 RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config # 设置 supervisor,将 SSH 作为其子进程,用 supervisor 管理 SSH 服务 RUN echo -e \ "[supervisord]\n\ nodaemon=true\n\ \n\ [program:sshd]\n\ command=/usr/sbin/sshd -D\n\ autostart=true\n\ autorestart=true\n\ startsecs=3\n" > /etc/supervisor/conf.d/sshd.conf # 在 Ubuntu 需要创建该文件夹 RUN mkdir -p /var/run/sshd # 将 /usr/bin/supervisord -c /etc/supervisor/supervisord.conf 命令作为容器启动的入口,即让 supervisor 启动 SSH CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"] {warn}在 Ubuntu 系统下,使用 /usr/sbin/sshd -D 命令启动 SSH 服务会出现错误,提示找不到文件夹 Missing privilege separation directory: /var/run/sshd,我检索到的解决方法是用 mkdir -p /var/run/sshd 创建该文件夹,所以在 Dockerfile 中加上了这行命令。我不确定其他系统是否有这个错误,文末附上了关于这个错误的两个链接。{end warn} 用终端进入 Dockerfile 所在文件夹,用下列命令构建镜像: $ docker build -t ms:200a-cu116 . 完成后使用 docker image ls 就能看到构建的镜像: $ docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE ms 200a-cu116 f9029d1ecae2 5 seconds ago 10.7 GB mindspore/mindspore-gpu-cuda11.6 2.0.0-alpha 01db14982624 6 months ago 10.5 GB 连接容器解释器 $ docker run -d -p 2222:22 -v /dev/shm:/dev/shm -v /home/code:/home/code --name=work --runtime=nvidia ms:200a-cu116 -d 参数使容器在后台运行,不打开终端; -p 2222:22 参数令容器的 22 端口(默认的 SSH 端口)映射到宿主机的 2222 端口; -v 参数是将本地的硬盘路径挂载到容器中,其中 -v /dev/shm:/dev/shm 是 Mindspore 的要求,-v /home/code:/home/code 则是将工程文件挂载到容器里,这两个目录双向同步; --runtime=nvidia 参数使容器能够使用宿主机的 GPU 硬件。 {info begin}有时运行调用 GPU 资源的容器会遇到问题,提示 Error response from daemon: Unknown runtime specified nvidia。而我则是更换了内核和驱动版本后,尝试重启容器时出现了类似的错误,提示 Error response from daemon: Cannot restart container or invalid runtime name: nvdia,暂时还不确定原因。在 GitHub 上有关于该问题的讨论,其中的方法都可以尝试一下,将 --runtime=nvidia 参数替换为 --gpus all 普遍可以解决问题。{info end} 容器运行后可以在终端尝试用 SSH 连接容器,输入 yes 再输入用户密码(前文 Dockerfile 中设置为 12345)后若能成功连接就表示 SSH 服务正常。 $ ssh root@127.0.0.1 -p 2222 Are you sure you want to continue connecting (yes/no/[fingerprint])? yes 还可以在连接上的终端中输入 nvidia-smi 检查容器是否连接上 GPU 硬件。 如果在这一步中,没有显示 SSH 成功连接的提示,多半是因为容器中的 SSH 服务没有成功启动。用 docker exec -it work /bin/bash 进入容器的交互界面,用 service ssh status 检查服务是否已经启动。 确保容器一切正常后,打开 Gateway,选择 New Connection,输入用户名、IP 地址和端口号,选择 Check Connection and Continue,Gateway 使用 SHH 成功连接后就可以选择需要的 IDE。 不知道为什么 Linux 上可选择的 IDE 这么少,好在可以通过从官网手动下载安装包的方式安装。例如下载 Pycharm 的安装包后,选择 Installation options - Upload installer file,Gateway 就会在远端(容器中)安装指定的 IDE。 {warn}目前 Gateway 的远端只支持 Linux 系统,所以下载的 IDE 安装包也应为 Linux 版本,这与上文构建的 Linux 镜像匹配。{end warn} 进入 IDE 后需要选择 Python 解释器,注意此时 Gateway 已经连接到容器,local 指的也是容器内,所以要选择的解释器正是本地解释器。Gateway 检测到的 Python 路径可能不正确,需要额外确认一下。在 Docker 中一般直接使用系统的 Python,不需要使用 Anaconda 一类的虚拟环境,可以通过以下命设查找系统 Python 路径: $ which python /usr/local/bin/python 一切设置都正确的话,Gateway 就能读取到 Python 中的包了,此时无论运行还是调试代码,所使用的也都是容器中的 Python。在 Gateway 中打开终端,进入的也是容器中的终端,在终端中检查 Mindspore 是否成功安装: $ python -c "import mindspore;mindspore.set_context(device_target='GPU');mindspore.run_check()" MindSpore version: 2.0.0a0 The result of multiplication calculation is correct, MindSpore has been installed successfully! 输出上述信息即表示在 GPU 平台上成功安装 Mindspore。 容器的关闭与重启 创建容器时指定了 -d 参数,容器只在后台运行,一般也不需要关闭。如果需要开关容器,以下列出一些常用的 Docker 命令: # 列出所有容器,可以查询容器的运行状态、名称和 ID 等信息 $ docker ps -a # 关闭指定容器,停止容器中的进程,内容不会消失 $ docker stop {容器名称或 ID} # 重启容器,例如创建容器时已经指定了运行参数 -d,重启的容器同样在后台运行 $ docker restart {容器名称或 ID} # 删除容器,若删除失败需要确定容易是否在运行 $ docker rm {容器名称或 ID} References 用 ssh 连接 docker 容器 - 博客园 安装使用 supervisor 来启动服务 - 博客园 如何让操作系统为 ubuntu 的 docker 容器在启动时自动重启 sshd 服务? - 知乎 Bug #45234 “Missing privilege separation directory: /var/run/ssh...” - Launchpad Missing privilege separation directory: /var/run/sshd - GitHub

2023/8/5
articleCard.readMore

如何在 X86 设备上使用 Docker 构建 ARM 镜像

最近一直在使用华为 ModelArts 的计算平台,使用这类计算平台的一般流程是先在本地用 Docker 构建镜像,再上传至云端,然后就可以在该环境下部署具体的计算作业了。使用 Docker 构建环境非常方便,基于官方或其他用户提供的基础镜像安装上自己所需要的依赖就可以直接上传使用了,完全不用跟驱动安装等等令人头疼又心累的事情打交道。 但在使用 Docker 构建镜像时,有一个挺棘手的问题:计算平台或是服务器所使用的设备一般是 ARM 架构,个人电脑使用基本上是 X86 架构。由于二者 CPU 指令集不同,尽管可以在 X86 设备上用 docker pull --platform=linux/arm64 拉取用于 ARM 设备的镜像,但无法使用 docker run 或 docker build 运行或是通过构建的方法修改该镜像。 qemu-user-static 去寻找 ARM 设备再使用 Docker 构建镜像就太麻烦了,幸好找到了一个工具 qemu-user-static,专门用于解决这个问题。先来看看仓库中给出的示例: $ uname -m x86_64 $ docker run --rm -t arm64v8/ubuntu uname -m standard_init_linux.go:211: exec user process caused "exec format error" $ docker run --rm --privileged multiarch/qemu-user-static --reset -p yes $ docker run --rm -t arm64v8/ubuntu uname -m aarch64 第一行的 uname -m 用于检测宿主机的架构,终端给出的信息表明这是一台 X86 设备。 第二行命令用 Docker 运行 arm64v8/ubuntu 镜像,并运行同样的 uname -m,当然由于架构不同,无法运行该镜像,给出了 standard_init_linux.go:211: exec user process caused "exec format error" 错误。在使用 Dockerfile 构建镜像时,遇到类似的 exec /bin/bash: exec format error 错误也需要考虑是不是架构的问题。 运行 qemu-user-static 镜像后,arm64v8/ubuntu 就可以成功运行了,终端给出的信息表明 arm64v8/ubuntu 是一个用于 ARM 设备的镜像。 简单来说,qemu-user-static 通过 QEMU 模拟器模拟出了 ARM 设备,从而实现在 X86 设备上运行或是构建 ARM 镜像。当然,qemu-user-static 能模拟的硬件不仅限于 ARM,对于支持的硬件,官网上有更详细的介绍。 qemu-user-static 的安装和使用都可以通过以下命令完成,若本地不存在该镜像,Docker 会自动从云端拉取: $ docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 也有人会让 qemu-user-static 在后台一直运行,我嫌维护起来麻烦,就直接使用上面的命令,如果后台挂掉了,再运行一次就好。 Docker 常用命令 最后再记录几个创建环境时常用的 Docker 命令: # 检查镜像的架构 $ docker inspect {image_name}:{tag} | grep "Architecture" # 用终端交互模式进入镜像的 /bin/bash $ docker run -it {image_name}:{tag} /bin/bash # 使用当前文件夹中的 Dockerfile 构建镜像,不使用缓存并输出详细信息 $ docker build -t {image_name}:{tag} . --progress=plain --no-cache Dockerfile 中记录了配置镜像的所有步骤,其他人也可以通过分享出去的 Dockerfile 构建相同的环境。而在撰写 Dockerfile 时,由于不熟悉基本镜像,一般都需要参考着终端给出的反馈来修改 Dockerfile 中的命令。这时候使用 docker run -it 就很方便,特别是运行 qemu-user-static 后,可以直接进入 ARM 镜像的交互终端中,一步步安装依赖后再保存命令。 上面的方法在简单的镜像中尚可,有的基本镜像做了特别复杂的操作,就算使用 qemu-user-static 也无法执行 docker run,这种情况下就必须根据 docker build 给出的错误信息修改 Dockerfile 了。在对 Dockerfile Debug 时,指定 --progress=plain --no-cache 两个参数能输出更为完整的错误。 References x86 平台利用 qemu-user-static 实现 arm64 平台 docker 镜像的运行和构建 - 博客园

2023/7/28
articleCard.readMore

文献总结|结构诱导的预训练

doi.org/10.1038/s42256-023-00647-z 本文介绍于 2023 年 MIT 研究团队在 Nature Machine Intelligence 发表上的一篇文章,文章原标题为 Structure-inducing pre-training,文章调查了目前广泛应用的多种预训练模型,设计了一种通过图结构在预训练过程中引入显式且深层结构约束的方法。 预训练-微调的学习模式在自然语言处理及其他相关领域都已经得到广泛的应用,预训练通过在隐空间中提取样本的特征,从而提升模型在下游任务上的表现。但目前的预训练模型都没能在潜变量 \(\boldsymbol{z}\) 上添加结构约束,从而获得既显式又深层的特征,这是目前预训练模型的一大缺陷。 方法 对于数据集 \(\boldsymbol{X}_\mathrm{PT}\in\mathcal{X}^{N_\mathrm{PT}}\),预训练的目标就是从学习过程中得到编码器 \(f_\theta:\mathcal{X}\rightarrow\mathcal{Z}\),然后将 \(f_\theta\) 用于各种各样的下游任务。 显式和深层结构约束 显示结构约束:如果能从隐空间 \(\mathcal{Z}\) 中的两个样本 \(\boldsymbol{z}_i\) 与 \(\boldsymbol{z}_j\) 直接推导出两者间的关系(如距离),那么该预训练过程就有显示的结构约束。 深层结构约束:预训练过程中所使用的信息越多(如维数),那么预训练过程所使用的结构约束越深。 目前大部分的预训练模型都无法同时保证显式与深层的结构约束,调查目前超过 90 种的预训模型,其方法可以分为以下几类: 完全不使用样本间的关系,例如 prompt 训练,主要用于文本生成。 使用显式,但浅层的监督预训练目标,例如 BERT 的 Next Sentence Prediction 训练模式。 使用深层,但隐式的无监督或自监督预训练目标,例如通过添加噪声的数据强化方法。 模型 因此文章设计了一种同时使用显式与深层的结构约束的预训练框架,称这种方法为结构诱导的预训练。 首先将预训练问题表示为图 \(G_\mathrm{PT}=(V,E)\),其中结点 \(V\) 表示 \(\boldsymbol{X}_\mathrm{PT}\) 中的预训练样本,\(E\) 表示预先定义的样本间关系。 接着预训练的损失函数就定义为 $$ \mathcal{L}_\mathrm{PT}=(1-\lambda_\mathrm{SI})\mathcal{L}_\mathrm{M}+\lambda_\mathrm{SI}\mathcal{L}_{SI} $$ 其中 \(\mathcal{L}_\mathrm{M}\) 为传统预训练模型所使用的损失函数,\(\mathcal{L}_\mathrm{SI}\) 是定义用于实现结构诱导目标的损失函数,使隐空间的各潜变量满足 \(G_\mathrm{PT}\) 中的边(样本间关系)。 数据 文章使用了 3 类数据用于预训练: Proteins:来自 Stanford tree-of-life 数据集约 150 万条蛋白序列 Abstracts:来自 Microsoft Academic Graph 数据集约 650,000 篇的生物医学相关的文本摘要 Networks:来自文献的 70,000 条蛋白-蛋白相互作用网络的子图 Proteins 与 Abstracts 预训练的编码器是 Transformer 架构,Networks 预训练所使用的模型是具有图同构网络(Graph Isomorphism Network, GIN)编码器的图卷积神经网络(graph convolutional neural network, GNN)。 结果 预训练模型在下游任务上的测试结果如上图所示,Δ 一列中以 ↑ 表示相对传统预训练模型性质的提升,可以看出不管是相对于 per-token 还是 per-sample 的传统预训练策略,文中提出的结构诱导的预训练方法(structure-inducing pre-training, SIPT)在各下游任务上具有更好的表现。 分析 Networks 任务得到的各种预训练模型在下游任务中微调的过程,SIPT 方法相比其他预训练方法得到的特征能够更快收敛,且在最后得到更好的效果。 结论 文章调查了多种预训练模型,分析其训练目标发现大多数都没有引入显式且深层的结构约束,文章设计了一种预训练策略 SIPT,通过预训练图 \(G_\mathrm{PT}\) 在隐空间中加入了显式且深层的结构约束,相比于传统的预训练方法,这种策略在下游任务的层次上提升上模型表现。 文章借鉴了图结构来对样本与样本间的关系建模,但文中并未对得到「显式且深层」的特征做详尽的研究,只能推测这种方法更适用于蛋白-蛋白相互作用等更关注于样本间关系的任务,还不能证明 SIPT 得到的例如分子表示比传统预训练方法得到的分子表示更好。 if (!document.getElementById('mathjaxscript_pelican_#%@#$@#')) { var align = "center", indent = "0em", linebreak = "false"; if (false) { align = (screen.width

2023/6/23
articleCard.readMore

文献总结|MTGL-ADMET:一种通过地位理论与最大流增强并用于 ADMET 预测的多任务图学习框架

doi.org/10.1007/978-3-031-29119-7_6 本文介绍于 2023 年 西北工业大学发表在 RECOMB 2023 上的一篇文章,文章原标题为 MTGL-ADMET: A Novel Multi-task Graph Learning Framework for ADMET Prediction Enhanced by Status-Theory and Maximum Flow,文章通过地位理论与最大流构造了由主要任务与辅助任务构成的多任务模型,相比单任务模型在预测准确性上有很大提高。 对于 ADMET 多种性质的预测,一般的方法是单任务学习,也就是一个模型只完成一种任务(预测一种性质),这种方法不仅繁琐,而且在缺少真实数据的情况下效果不佳。近年来出现的一种新范式是先通过预训练得到分子的通用表示,再将其用于多任务学习,使用一个模型完成所有预测任务,预训练的步骤弥补了缺少真实数据的问题。 文章认为,现有基于多任务的 ADMET 模型都是通过一个模型完成所有预测任务,这样的共同学习很难保证模型能够共同学习到多种性质的信息,导致效果甚至不如单任务学习。文章设想以一个任务为主要任务,多个其他任务作为辅助任务,并通过地位理论找到最佳的任务搭配,改善模型效果。 方法 模型 在文章设计的「一个主要任务,多个辅助任务」模式下,需要通过 3 个步骤找到这最佳的任务搭配,如上图中 a 所示。 首先先以各任务为单任务建立模型,例如对任务 \(t_w\) 与 \(t_k\) 分别建立单任务模型 \(\mathcal{S}_w\) 与 \(\mathcal{S}_k\),再为其建立多任务模型 \(\mathcal{D}_{w,k}\),那么 \(t_w\) 对 \(t_k\) 的影响就可以表示为 $$ \hat{Z}_{w\rightarrow k}=Z^{(d)}_{k|w}-Z^{(s)}_k $$ 其中 \(Z^{(s)}_k\) 就是 \(\mathcal{S}_k\) 模型的表现,\(Z^{(d)}_{k|w}\) 就是 \(\mathcal{D}_{w,k}\) 模型的表现,从而可以得到类似下图中的结果: 接着根据以上结果将相互增强的任务作为同一组的多任务,再通过地位理论决定各组任务中的主要任务,其他任务作为辅助任务。简单来说,地位理论就是将对模型表现提升最多的任务视为主要任务。 最后通过最大流优化所选择的辅助任务。经过以上步骤,就可以将许多 ADMET 性质的预测任务分组,分别建立多任务模型。 多任务模型的预测过程如上图 b 所示,输入的分子通过两层 GCN 提取分子的信息,得到分子 embedding 表示,再在 Task-specific molecular embedding module 中得到适用于特定任务的分子表示。对于辅助任务,分子表示直接通过全连接层得到相应任务的预测结果。对于主要任务,除了针对于本任务的分子表示,还通过 Gating Network 通过可学习的权重融合来自于辅助任务的分子表示(图 c),最后得到预测结果。 数据 模型所使用的 ADMET 数据来源于各文献中收集到的 24 种性质(18 个分类任务,6 个回归任务),共包含 43291 个类药的化合物。 输入模型的分子以图的形式表示,分子图除了原子信息外,还添加了手性、电荷、芳香性、杂化等信息。 结果 MTGL-ADMET 在 24 种性质上的预测结果如上图所示,括号中的数字代表辅助任务的数量。与其他图模型相比,MTGL-ADMET 在 20 个任务上表现最优,另外 4 个任务上表现仅次于最优。 在消融实验中,文章验证了「主要任务+辅助任务」策略的效果,测试结果如上图所示。与单任务(Single)、随机挑选 5 个辅助任务(Ran-5)和不使用地位理论与最大流而仅挑选对模型提升最大的 5 个辅助任务(Top-5)相比,MTGL-ADMET 在所有性质的预测上表现都是最佳的,说明了文章所设计多任务策略的优势。 最后,文章展示了模型的可解释性,下图的案例展示了化合物结构片段与相应性质的相关性。 结论 文章提出了一种用于构建 ADMET 多任务的策略,该策略主要使用地位理论与最大流分析了对主要任务具有增强作用的辅助任务,将主要任务与辅助任务一起构建多任务模型,使模型最后的预测效果好过很多完成类似任务的图模型。 局限:文章只评估了多任务模型中主要任务的预测结果,而没有全面评估模型包括辅助任务在内的多个预测结果,文章中的策略可以找到辅助提升主要任务结果的辅助任务,但这样的多任务模型不一定在多个任务上都表现得很好。文章中所测试的 ADMET 数据较少,在 ADMET 性质种类很多时,在两两任务间寻找是否具有性能提升的步骤就会变得繁琐。 if (!document.getElementById('mathjaxscript_pelican_#%@#$@#')) { var align = "center", indent = "0em", linebreak = "false"; if (false) { align = (screen.width

2023/6/9
articleCard.readMore

文献总结|探测图表示

doi.org/10.48550/arXiv.2303.03951 本文介绍于 2023 年 德国亥姆霍兹信息安全中心研究团队发表在 AISTATS 2023 上的一篇文章,文章原标题为 Probing Graph Representations,文章设计了多种分子表示的探测模型,并通过探测模型研究了图模型在预训练后所编码分子信息。 随着基于图的深度学习模型不断出现,亟需回答的一个问题是「图模型将什么信息编码进了表示中?」为了研究这一问题,文章构建了探测模型测试预训练图模型得到的分子表示。 探测图表示的思路很简单,如果能从图模型输出的分子表示中提取出分子性质,那么就可以认为该性质被编码进分子表示中,所以文章的工作流程是「预训练-预测」(略不同于「预训练-微调」)。通过该流程,文章测试了传统 GNN 与基于 Transformer 的图模型等不同架构、不同数据集、不同优化算法等因素对于模型编码得到的潜变量的影响。 方法 在分子性质预测中,对于分子 \(\boldsymbol{x}\) 与其性质 \(y\),完成该任务的模型就是映射 \(f:\boldsymbol{x}\mapsto y\)。取出 GNN 或图 Transformer 模型中 \(d\) 维的 \(l\) 层输出 \(f_l(\boldsymbol{x})=\boldsymbol{z}\),该潜变量 \(\boldsymbol{z}\) 可以作为输入 \(\boldsymbol{x}\) 的一种表示,进一步得到 \(y\)。 文章使用不同的图模型得到分子表示,再通过另一模型测试分子表示 \(\boldsymbol{z}\) 预测分子性质 \(y\) 的性能,从而对比不同图模型提取特征信息的能力。 所构建的预测分子性质任务包括较为基础的判断是否具有某些官能团、更高层次的毒性、血脑屏障渗透性等。 探测策略 线性探测(Linear Probing):使用最简单的线性层,将分子表示映射为分子性质。 贝叶斯探测(Bayesian Probing):互信息可以用于 \(Z\) 与 \(P\) 两个随机变量之间的依赖程度,文中通过计算潜变量与分子性质间的贝叶斯互信息进行评估。 成对探测(Pairwise Probing):将结构相近而性质差异大的分子构成一对 \((\boldsymbol{x}_i,\boldsymbol{x}'_i)\),通过主成分分析等方法分子潜变量与分子性质之间的关系。 实验 首先使用线性模型用 \(\boldsymbol{z}\) 预测了分子中是否具有某种子结构,结果如上图所示,基于 Transformer 的一类图模型显然具有比 GCN 和 GIN 具有更好的表现,同时 GCN 模型得到的表示又比以 Morgan 指纹作为分子表示更好。 在更高层次的分子性质数据集上测试各种分子表示,结果如上图所示,以 Morgan 指纹作为分子表示的任务效果比部分图模型更好,Morgan 指纹作为一种可以简单获得的分子表示,仍然适合用于许多机器学习模型中完成预测任务。 基于 Transformer 的图模型在更高层次的分子性质数据集上同样具有更好的表现,是具有潜力的新一代分子表示方式。这一点也可以从下图中看出,在左图中,基于 Transformer 图模型的结果都位于右上角,既能表示低层次的子结构信息,也能有效编码高层次的分子性质信息,而其他分子表示则位于左下角。右图使用贝叶斯互信息评估了样本数量与 \(Z\) 和 \(Y\) 之间的依赖程度的关系,就整体趋势而言,仍然是基于 Transformer 图模型效果更好。 最后,文章通过主成分分析评估了相似分子间不同的分子表示,两个相似分子仅在官能团上有所不同,文中选择的官能团为硝基。结果如下图左侧一列所示,with FG 表示含硝基分子,w/o FG 表示去除该官能团的分子,可以明显看出,相比于 GCN,GraphGPS 这一基于 Transformer 的图模型所产生的特征中,两种结构相似的分子也具有较大的区分子,是更好的分子表示。 结论 文章设计探测模型研究了图模型在预训练后编码的分子信息,最终发现相比于使用消息传递聚合信息的传统 GNN 模型,基于 Transformer 的图模型能够学习到更多与化学相关的化学信息,得到更好的分子表示。文章中提出的分析方法为预训练模型的测试以及分子表示的评估提供了指导。 if (!document.getElementById('mathjaxscript_pelican_#%@#$@#')) { var align = "center", indent = "0em", linebreak = "false"; if (false) { align = (screen.width

2023/6/2
articleCard.readMore

四月十二奉新纸一试

常常划拉大字,练字的毛边纸用得很快,加之想多试试不同品种的纸,于是日前购买了一批新纸。从古至今,宣纸的价格都不算便宜,也少有人负担得起用宣纸练字,我所购买的纸也大多是毛边纸。新纸亦属于毛边纸,但与以前所买的毛边纸大不相同,欣然提笔一试,果然令人惊喜。 {caption} 左为旧竹纸,单面粗糙且无帘纹,右为新纸{end caption} 新购之纸明显更为厚实,且颜色偏白,不像竹纸那样黄。取纸在灯下观之,帘纹新晰,料不是机器所制,人工捞纸才有这样的痕迹。向者识别毛边纸的方法是「若纸单面糙,则为机制纸;若双面糙,则为手工纸」,我也尝试用手指轻捻,发现竟两面粗糙。可我购买的的确是价廉的机制纸,取发货单审阅,上面也分明写着「机制」二字。疑惑这余,仔细摩挲再三,才发觉的确一面更滑,两面仅差毫厘。从这几点上看,虽说买的是毛边纸,却有下宣纸一等的做工了。 {caption} 淡墨在新纸上晕开的痕迹,纸面上的帘纹清晰可见{end caption} 竹纸不吸墨,就算使用较稀的墨我也喜欢再加些水。在新纸上一试,墨水骤然晕开,分出浓淡的墨色,竹纸决没有这样的表现力,这种水汽氤氲之感特别适合用来写邓石如的篆书。我又兑上浓墨,虽然纸面抚摸着似乎并没有竹纸粗糙,行笔却要用更大的力气,也就是所谓「吃」得住笔。从浓淡墨的线条来看,新纸干湿两宜,行笔之间的迟滞顺滑又全然不同于竹纸,这种妙趣是在竹纸上完全找不到的。最重要的是这新纸仅比以前用的竹纸贵少许,但仍比宣纸便宜得多,很适合用来日常练字。 在发现好物的欣喜之余,我不由地又惊异于科技的发展。旧时认为宣纸必须借由人工制作,制作过程还需要制纸师傅具有高超的捞纸技术,这些观点似乎正在被改写。费孝通先生在《乡土中国》一书中提到,在人们生于斯而长于斯的「乡土社会」中,一切都是那么的自然,一切生活中行之有效的法则都可以由口耳相传的经验得到,而当进入到原子化的「现代社会」后,无数人在世界范围发生着巨大规模的迁徙,在面对新事物时,那些法则就失效了。我自诩为年轻一代,在科技昌明的环境中成长,对那些流传下来的陋习也是弃如敝履,毫不惋惜,并自矜于终于能与老大帝国的积习切割。当我摩挲纸背发现我的经验失效时,内心竟也划过了一丝惶恐,原来我曾以为的法则也正在失效,我正迷惘地处在乡土社会与现代社会的间隙。一切都在改变,一切又都似也没变,似距离跨出乡土社会还甚遥远。这种种又何尝不是对我趋于「保守」的一种警醒?

2023/5/30
articleCard.readMore

文献总结|可以同时完成分子语言序列回归和生成的 Regression Transformer

doi.org/10.1038/s42256-023-00639-z 本文介绍于 2023 年 IBM 研究团队发表在 Nature Machine Intelligence 上的一篇文章,文章原标题为 Regression Transformer enables concurrent sequence regression and generation for molecular language modelling,文章提出了一种可以同时处理序列中的数值与文本并完成回归与生成的多任务的 Transformer 模型。 基于 Transformer 的模型是化学任务中常用的模型,但由于 Transformer 最早是用于自然语言处理的模型,难以处理回归任务,这些模型只能完成性质预测或条件分子生成,无法同时完成指定结构的生成和性质预测。若要实现有约束的分子生成,即根据指定的性质生成分子,则不得不通过在多个模型间传递参数再得到反馈的方法不断调节并得到目标的分子,如下图中 a 所示。 文章尝试将回归任务融入到文本序列建模的过程中,提出了一种可以同时处理序列中的数值与文本并完成回归与生成的多任务模型,称为 回归 Transformer(Regression Transformer, RT)。在实验部分,文章使用化学领域中常见的分子生成、性质预测、化学反应预测、生物领域中蛋白质性质预测以及自然语言处理中的文本生成等多种任务测试了模型效果,证明 RT 是一种可以通用于多种任务且可以同时完成序列回归和生成的模型。 方法 模型 Transformer 原为由左至右逐次由前一个 token 预测下一个 token 的自回归模型,而在分子语言,如 SMILES 中,序列中各原子的顺序是没有特定意义的,序列中的原子也并非由前一个原子决定,因此文章选择使用非自回归模型。BERT、XLNet 都是 Transformer 的变种,BERT 使用掩码的方式随机掩盖序列中的 token,并根据周围的 token 预测被掩盖的 token,因为这个过程使用周围信息编码掩盖的 token,这类模型称为自编码模型。 XLNet 结合了自回归模型与自编码模型的优势,尽管 XLNet 还是由左至右预测 token,但它使用排列置换的方法将随机选择的待预测 token 放至序列末端,与 BERT 的掩码机制实际上相同,称为排列语言模型(Permutation language modeling, PLM)。文章使用 XLNet 作为主要的模型。 数值编码器 如上图所示,输入的数据格式为 <ESOL>-2.92|SMILES,<ESOL> 标识了预测的性质,-2.92 为该性质的数值。由于 Transformer 无法识别数值,会将其识别为数字字符,文章设计了数值编码器(numeric encoder, NE)获取数值信息。 先将 -2.92 分为 _-_ _2_0_ _._ _9_-1_ _2_-2_ 几个 token,其中的 _-_ 与 _._ 分别表示负号与小数点,数字 9 就以 _9_-1_ 表示,其中 9 表示数值为 9,-1 表示该值位于十分位(10-1)。 对于数值 token \(t_{v,p}\),\(v\) 表示该 token 的数值,\(p\) 表示该 token 数值的位置,词嵌入的第 \(j\) 维按下式计算: $$ \mathrm{NE_{Float}}(v,p,j)=(-1)^j\cdot\frac{v\cdot 10^p}{j+1} $$ 然后与 SMILES 的常规词嵌入一起加上位置编码进入 XLNet 中进行计算。 XLNet 输入 RT 的 \(\boldsymbol{x}\) 是由 \(k\) 个性质 token \([\boldsymbol{x}^p]_k\) 和 \(l\) 个文本 token \([\boldsymbol{x}^t]_l\) 拼接而成,即 $$ \boldsymbol{x}=[\boldsymbol{x}^p,\boldsymbol{x}^t]_T=[x^p_1,\cdots,x^p_k,x^t_1,\cdots,x^t_l] $$ 其中 \(T=k+l\),为整个序列的 token 数量。 PLM objective(\(\mathcal{J}_\mathrm{PLM}\)):在原始的 XLNet 中,输入的序列就要做 \(T!\) 次的排列,将掩盖的 token 放置到序列末端,训练目标是使模型能够预测出掩盖的 token。如上图中 PLM objective 所示,由于这种训练方法是随机选取,打断了整体的 \(\boldsymbol{x}^p\) 或 \(\boldsymbol{x}^t\),因而不适合该任务,仅用于预训练。 Property prediction objective(\(\mathcal{J}_\mathrm{P}\)):对于分子性质预测的回归任务,将表示分子性质的 \(\boldsymbol{x}^p\) 全部掩盖并排列置换位置,使用分子的文本 \(\boldsymbol{x}^t\) 预测被掩盖的分子性质。 Conditional text generation objective(\(\mathcal{J}_\mathrm{G}\)):对于分子生成任务,正与上述过程相反,将表示分子的 \(\boldsymbol{x}^t\) 全部掩盖。 Self-consistency (SC) objective(\(\mathcal{J}_\mathrm{SC}\)):为了使 RT 能够同时完成回归和生成任务,文章设计了该训练目标: $$\mathcal{J}_\mathrm{SC}=\mathcal{J}_\mathrm{G}(\boldsymbol{x})+\alpha\cdot\mathcal{J}_\mathrm{P}(\hat{\boldsymbol{x}})$$ 其中 \(\alpha\) 为权重,\(\hat{\boldsymbol{x}}=[\boldsymbol{x}^p,\hat{\boldsymbol{x}}^t]\) 为生成的样本。该训练任务就是先使用分子性质生成分子,再用生成的分子预测其性质。 数据 使用 SELFIES 作为分子表示,许多研究表明,相比 SMILES,SELFIES 在分子生成任务上更具有优势。 Synthetic QED dataset:由 ChEMBL 得到的约 160 万个分子,约 140 万用于训练,1000 条数据用于验证,10000 条数据用于测试。 实验 文章中使用 RT 在化学反应、蛋白质性质预测等任务上测试了模型性能,这里仅以分子生成与分子性质预测的任务为例。 在 QED 数据集上,先使用 \(\mathcal{J}_\mathrm{PLM}\) 训练模型,至验证集数据的指标收敛后,再每 50 轮用 \(\mathcal{J}_\mathrm{P}\)、\(\mathcal{J}_\mathrm{G}\) 或 \(\mathcal{J}_\mathrm{SC}\) 轮流微调(Alternate),不同模型设定的结果如下图所示。 从实验结果中可以看出,(1)SELFIES 在生成任务上更有优势,但在回归任务上稍逊于 SMILES;(2)不论是回归还是生成任务,预训练使模型的表现提升;(3)设计的数值编码器有利于模型识别数值信息,提升模型表现;(4)在微调阶段轮流使用不同的训练任务,使模型在回归和生成两种任务上的泛化能力更好,在回归和生成单个任务上都具有与单任务模型接近甚至更优的表现。 能够处理回归与生成两种任务的模型也可以用于实现分子的性质优化,具体过程是设定一个 seed 分子以及目标的性质(primer),模型随机掩盖分子中 token 再通过 primer 将 token 预测出来,得到优化后的新分子,再通过新分子计算其性质的预测值,下图展示了在两种不同的数据集上微调得到的模型实现分子性质优化的样例。 结论 文章提出了回归 Transformer(RT)模型,该模型以 XLNet 为主要的结构,文章增加了数值编码器用于获取数值信息,并设计了不同的训练模式使模型在预训练-微调后能够完成数值回归与序列生成两种不同的任务。RT 设计用于数值回归与序列生成,因此也可以用于蛋白性质预测、反应预测等。 if (!document.getElementById('mathjaxscript_pelican_#%@#$@#')) { var align = "center", indent = "0em", linebreak = "false"; if (false) { align = (screen.width

2023/5/27
articleCard.readMore

旧书市场淘书记

不得不说,在北方诸多城市中,天津的二手旧物市场可以说是相当火热的。我猜测的原因有二,一则是天津的老龄人口占比多,古玩旧物收藏有很大的受众;二则是得益于近代天津经济、文化的繁荣,许多官商士绅定居在此,天津民间仍流通有十分具有价值的骨董。 我对古玩是一窍不通,再加之俚谚「多看少买」的教育,更有许多低劣到我都能看出的赝品,常引得我在心中暗笑,所以我对那些地摊上的古玩也一点不感兴趣。但旧物中有一门类却是我的心头好,那就是旧书。 我一向认为书应当是用来读的,次之才是历史等其他价值。由于许多好书由于各种原因不再出版了,或是更改了原来的版本,没有旧版本更好读了,于是有了「藏书」的群体去搜罗这些旧书。所以「藏书」的「藏」不该是像对待金银珠宝那样的「秘藏」,而是作「保存」解。这是我的藏书主张,也是我搜集旧书的信条。 之所以提及以上原则,还是因为天津旧货市场上的旧书实在太多了,近乎可以同逛新书店一般,不带任何想法去,抱回好几摞的书,为了避免这种无谓的金钱开销,必须要有筛选的准绳。前些天在反复告诉自己买书是为了读书后,终于敢大胆淘了几本书,对其中几本实在喜欢得紧,也算小有收获。 《且介亭杂文》《且介亭杂文末编》 人文社 1973 年出版的鲁迅作品集应该是最优良的鲁迅作品版本,因为印量大,价格也不贵,但有几本很少见,凑齐全套并不容易。所以我一般是遇见了品相较好且为手中所无才购买,一切都随缘,并不特意搜集。 此一册《且介亭杂文末编》,是我在一堆未经整理的书堆中翻找出来的,售 5 元。人民文学出版社 1973 年 4 月北京 1 版 1 印,扉枼钤「天津市第一机械工业学校图书舘藏书」,内枼整洁,纸张坚韧泛黄。唯一不美的是封面钤「不外借」圆印且封面有墨渍污损。 此一册《且介亭杂文》,由我在另一书摊上访得,要价 4 元。人民文学出版社 1973 年 6 月山西 1 版 1 印,扉枼有 82 年的购书识记,内枼整洁,纸张洁白,可惜曾遭水浸,整册书都有湿后的压痕。鲁迅冠以「且介亭杂文」为名的集子共有 3 册,那么我还差一册《且介亭杂文二集》就可成一小帙了。 《唐诗选》《汉魏六朝诗选》 人文社的文学类古籍也具有口碑,可这一套《中国古典文学读本丛书》让我颇为困惑。这一套丛书中的书籍都具有类似的封面和题签,古雅简洁,装帧精美,而且编者与注者都是各领域的权威,内容也很精良,我十分喜欢。可是这一套丛书中兼有简体横排本和繁体竖排本,例如《唐诗选》和《汉魏六朝诗选》就都是简体横排,对简体横排介怀者在挑选这一套书时务必留意。能翻看时一看便知,但有时书商将书用塑料纸包装起来,不允翻看内枼,这时可以根据书口方向分辨,书口向右者为简体横排,书口向左者为繁体竖排本。在读古典文学时,我当然更喜欢用繁体竖排,这套丛书夹杂的两种版本让我困惑又纠结。 此一册《唐诗选(上)》,中国社科院文学研究所编,全套为上下两册,因此仅售我 5 元,待有机会再访下册,这套《唐诗选》印量很大,可货比三家,寻找品相好,更适合翻阅的版本。人民文学出版社 1978 年 4 月北京 1 版 1 印,内枼整洁,纸张泛黄。许多人认为这套集子选的诗并不好,但我只以其简体横排为遗憾。唐诗存世量极大,唐朝诗人又如群星璀璨,无论怎么选都有顾此失彼之嫌,这套集子已经尽可能选出唐朝代表性诗人的作品,注释详略得当,限于篇幅可能未选许多代表作,但对于业余的爱好者概览唐诗完全是足够的了。 此一册《汉魏六朝诗选》,余冠英选注,可能由于印量少些,再加上这家书商的眼光比其他家更利,竟要价 10 元,不过余冠英的注本,加之品相不错,这个价格并不亏。人民文学出版社 1979 年 3 月北京 1 版 2 印,内枼整洁,纸张微黄。 《杜甫诗选》《宋诗选注》 同样是《中国古典文学读本丛书》,《杜甫诗选》和《宋诗选注》都是繁体竖排,版式相同,老铅字实在赏心悦目,我十分钟爱这两本。 此一册《杜甫诗选》,冯至选,要价 10 元,还价不允,无奈购下。人民文学出版社 1987 年北京 1 版 11 印,内枼整洁,纸张洁白,摩挲纸面铅字凹痕明显,字画如新,真令人边不释手。《杜甫诗选》并不是最好的杜甫诗集,但又是读杜诗难以绕开的选本。 此一册《宋诗选注》,钱锺书选注,售 2 元,可以说是捡到的最大漏。人民文学出版社 1982 年 7 月北京 1 版重庆 1 印,内枼整洁,纸张洁白柔韧,字画清晰,惜其封面有折痕。这本集子是由钱锺书选、钱锺书选的宋诗,读宋诗的人可能不多,但是这本集子是宋诗最好的选注本。 《唐宋詞選釋》 此一册《唐宋詞選釋》,俞平伯编,售 10 元,叹无人识此宝又恐有人抢购,立马购入。人民文学出版社 1979 年北京 1 版 1 印,扉枼钤「天津自行车二厂图书舘」,内枼整洁,纸张洁白柔韧,铅字的字画虽不如前面两本清晰,但同样令人赏玩不忍释手。俞平伯的《唐宋詞選釋》是读宋词的入门,选、释皆精良,说是读宋词必读并不为过。其实家中已有一本《唐宋詞選釋》,但已经快要脱胶散枼,遇到品相如此好的一本,真令我欣喜! 《唐詩三百首新注》 此一册《唐詩三百首新注》,金性尧注,售 5 元。上海古籍出版社 1980 年上海 1 版 1 印,扉枼有 00 年购书识记,竟购于内蒙而流入我手。内枼如新,纸张洁白柔韧,适合翻阅。看多了总感觉上古的铅字整体比人文的更好,字画更清晰,字形也更优美,但若是真让我哪些细节上有差异则有些困难。《唐诗三百首》是家喻户晓的唐诗集子,中华书局前几年覆刻的《唐诗三百首》是更好的版本,版式古雅且价格低廉,现在也很容易买到,但肯定是激光排印而不是铅印了。这本《唐詩三百首新注》静静躺在一角,封面的金字闪耀动人,立马吸引了我的注意,展卷翻阅,心甚悦之,遂购入。 最后以全部收获的合影作结吧,共计约 50 元,从堆积成山的书堆里挑出这几本,真可谓是如大浪淘沙一般的「淘」书。

2023/5/22
articleCard.readMore

文献总结|一种用于基于结构药物设计的 3D 生成模型

doi.org/10.48550/arXiv.2203.10446 本文介绍于 2021 年彭健课题组发表在 NeurIPS 2021 上的一篇文章,文章原标题为 A 3D Generative Model for Structure-Based Drug Design。 基于结构药物设计中的一个基本问题是针对指定的蛋白结合位点生成分子,目前解决这一问题的深度学习方法可以分为两类:基于字符序列与基于图的方法。但不论是基于字符序列的 1 维模型,还是基于图的 2 维模型,其本质上缺少蛋白质 3 维空间中的信息。为了获取空间信息,目前也出现了在 3D 空间中实现分子生成的模型,但这些模型只能生成较小的分子,无法有效生成类药的更大分子。 因此,文章提出了一种能够针对指定的蛋白生成药物分子的 3D 生成模型,在利用蛋白空间信息的情况下生成分子,实现基于结构的药物设计。 方法 模型 蛋白的结合位点可以定义为原子的集合 \(\mathcal{C}=\{(\boldsymbol{a}_i,\boldsymbol{r}_i)\}^{N_b}_{i=1}\),其中 \(N_b\) 是结合位点原子的数量,\(\boldsymbol{a}_i\) 是第 \(i\) 个原子的特征,\(\boldsymbol{r}_i\) 是其空间坐标。可以将在结合位点生成原子的任务视作为模拟结合位点中各位置 \(\boldsymbol{r}\) 上出现原子的概率,也就是模拟原子在结合位点上出现的概率密度 \(p(e|\boldsymbol{r},\mathcal{C})\),其中 \(e\in\mathcal{E}=\{\mathrm{H},\mathrm{C},\mathrm{O},\cdots\}\) 代表生成分子中的原子。 为了对 \(p(e|\boldsymbol{r},\mathcal{C})\) 建模,文章设计了两个模块: 上下文编码器(Context Encoder):使用图神经网络(graph neural networks, GNN)学习环境 \(\mathcal{C}\) 下各原子的表示; 空间分类器(Spatial Classifier):输入任意位置 \(\boldsymbol{r}\),集合该位置附近所有上下文原子的表示,输出预测结果 \(p(e|\boldsymbol{r},\mathcal{C})\)。 上下文编码器 上下文编码器用于提取特征,获得各原子的表示,在该任务中,对原子表示有两个要求: 原子表示不应只具有本身的信息,还应具有环境中的信息; 在旋转和平移变换后,原子性质的性质不会发生改变,原子表示应具有旋转和平移不变性。 基于以上两点要求,文章使用了旋转平移不变的图神经网络。 首先,针对蛋白结合位点构建 k-近邻图,基于结合位点 \(\mathcal{C}\) 中各原子的距离得到图 \(\mathcal{G}=\langle\mathcal{C},\boldsymbol{A}\rangle\),其中 \(\boldsymbol{A}\) 为邻接矩阵,将 k-近邻中的第 \(i\) 个原子记作 \(N_k(\boldsymbol{r}_i)\)。 接着,编码器将 \(\mathcal{G}\) 中所有结点原子的特征 \(\{\boldsymbol{a}_i\}\) 转化为嵌入表示 \(\{\boldsymbol{h}^{(0)}_i\}\),然后进入消息传递层。 一般的 GNN 消息传递过程定义为 $$ \boldsymbol{h}^{(\ell+1)}_i=\sigma\left(\boldsymbol{W}^\ell_\mathrm{self}\boldsymbol{h}^{(\ell)}_i+\boldsymbol{W}^\ell_\mathrm{nergh}\sum_{j\in\mathcal{N}}\boldsymbol{h}^{(\ell)}_j\right) $$ 其中 \(\boldsymbol{W}\) 为模型需要训练的参数,\(\sigma\) 为激活函数。从上式中可以看出,GNN 的消息传递是在将 \(i\) 结点周围临近的 \(j\) 结点的信息按权重聚集起来。 在文章中所使用的消息传递过程为 $$ \boldsymbol{h}^{(\ell+1)}_i=\sigma\left(\boldsymbol{W}^\ell_0\boldsymbol{h}^{(\ell)}_i+\sum_{j\in N_k(\boldsymbol{r}_i)}\boldsymbol{W}^\ell_\mathrm{1}\boldsymbol{w}(d_{ij})\odot\boldsymbol{W}^\ell_2\boldsymbol{h}^{(\ell)}_j\right) $$ 相比原式,文章在第 2 项中做了一些改动,\(\boldsymbol{w}(\cdot)\) 是一个权重网络,\(d_{ij}\) 为 \(i\) 与 \(j\) 两个结点间的距离。上述过程就是在聚集信息时,根据距离的远近分配权重,逐个原子计算后得到 \(\mathcal{C}\) 中所有原子的嵌入表示集合 \(\{\boldsymbol{h}^{(L)}_i\}\)。 空间分类器 在空间中的任意位置 \(\boldsymbol{r}\) 上,空间分类器聚集由上下文编码器得到的原子的嵌入表示: $$ \boldsymbol{v}=\sum_{j\in N_k(\boldsymbol{r})}\boldsymbol{W}_0\boldsymbol{w}_\mathrm{aggr}(||\boldsymbol{r}-\boldsymbol{r}_j||)\odot\boldsymbol{W}_i\boldsymbol{h}^{(L)}_j $$ 其中 \(\boldsymbol{w}_\mathrm{aggr}(\cdot)\) 同样是一个权重网络。在这一步中,类似地根据任意位置 \(\boldsymbol{r}\) 与周围结点间的距离 \(||\boldsymbol{r}-\boldsymbol{r}_j||\) 分配权重,聚集该位置附近出现过原子的信息,得到特征 \(\boldsymbol{v}\)。 最后通过多层感知机、归一化后得到所求概率分布: $$ \boldsymbol{c}=\mathrm{MLP}(\boldsymbol{v})\\ p(e|\boldsymbol{r},\mathcal{C})=\frac{\exp(\boldsymbol{c}[e])}{1+\sum_{e'\in\mathcal{E}}\exp(\boldsymbol{c}[e'])} $$ 取样 因为 \(p(e|\boldsymbol{r},\mathcal{C})\) 需要指定结合位点 \(\mathcal{C}\) 和位置 \(\boldsymbol{r}\) 得到预测的原子 \(e\),而分子生成需要根据 \(\mathcal{C}\) 自动分配各原子的位置,所以由 \(p(e|\boldsymbol{r},\mathcal{C})\) 导出 $$p(e,\boldsymbol{r}|\mathcal{C})=\frac{\exp(\boldsymbol{c}[e])}{Z}$$ 其中 \(Z\) 为未定的归一化常数。 分子生成的过程为,在 \(t\) 步骤,使用结合位点(环境) \(\mathcal{C}_t\) 由 \(p(e,\boldsymbol{r}|\mathcal{C}_t)\) 得到 \((e_{t+1},\boldsymbol{r}_{t+1})\),将 \((e_{t+1},\boldsymbol{r}_{t+1})\) 加入到环境 \(\mathcal{C}_t\) 得到 \(\mathcal{C_{t+1}}\),再用于预测下一个原子的种类和位置,即 $$ \begin{align} &(e_{t+1},\boldsymbol{r}_{t+1})\sim p(e,\boldsymbol{r}|\mathcal{C}_t)\\ &\mathcal{C}_{t+1}\leftarrow\mathcal{C}_t\cup\{(e_{t+1},\boldsymbol{r}_{t+1})\} \end{align} $$ 再增加一个辅助的分类网络用于判断生成原子是否为末端原子,若为末端原子则结束分子生成过程。 数据 CrossDocked 数据集中有 2.25 千万条对接得到的蛋白-配体对数据,经数据清洗后,使用其中的 100000 条数据训练模型,100 条数据作为测试集。 结果 文章首先测试了模型根据蛋白结合位点生成分子的整体效果。结果如上图所示,模型生成分子的对接打分略差于参考分子,但要更好于同类模型 liGAN,生成分子的 QED 与 SA 甚至好过于参考分子,生成分子的所有指标均好于 liGAN。在各分子性质分布的对比中,相比另两个数据集,生成分子的 QED 向右偏移,具有更好的类药性。 以上结果也可以从生成分子的样例中看出,上图展示了模型针对两个蛋白生成的多个分子,生成分子的对接打分、QED 都要好于参考分子,同时许多生成分子还具有参考分子的类似结构。 最后,文章测试了模型在 linker 预测上的应用。模型不需要经过重新训练或都微调,只需将结合位点与片段作为初始的环境 \(\mathcal{C}_0\),模型就会根据环境补足片段间的 linker。测试结果如上图所示,与设计用于 linker 预测任务的 DeLinker 相比,文章中的模型在各方面都具有优势。 文章也列举了 linker 预测结果的样例,虽然模型不一定能预测并找回参考分子,但预测生成的分子中都包含了指定的片段,同时模型是根据蛋白的 3D 信息生成 linker,这在基于结构的药物设计上可以作为应用工具。 结论 文章使用 GNN 构建了一种用于基于结构药物设计的分子生成模型,该模型使用 GNN 通过设计用于蛋白 3D 信息的消息传递过程提取结合位点中的空间信息,根据配体各原子在结合位点中各位置出现的概率建立模型,从该概率中取样实现分子生成。模型生成的分子具有蛋白的空间信息,在各方面指标上都具有较好的表现。 if (!document.getElementById('mathjaxscript_pelican_#%@#$@#')) { var align = "center", indent = "0em", linebreak = "false"; if (false) { align = (screen.width

2023/5/19
articleCard.readMore