普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月18日范叶亮的博客

深度学习优化算法

2018年2月24日 08:00

在构建神经网络模型的时候,除了网络结构设计以外,选取合适的优化算法也对网络起着至关重要的作用,本文将对神经网络中常用的优化算法进行简单的介绍和对比,本文部分参考了 Ruder 的关于梯度下降优化算法一文 1。首先,我们对下文中使用的符号进行同意说明:网络中的参数同一表示为 $\theta$,网络的假设函数为 $h_{\boldsymbol{\theta}}\left(\boldsymbol{x}\right)$,网络的损失函数为 $J\left(\boldsymbol{\theta}\right)$,学习率为 $\alpha$,假设训练数据中共包含 $m$ 个样本,网络参数个数为 $n$

梯度下降

在梯度下降算法中,常用的主要包含 3 种不同的形式,分别是批量梯度下降 (Batch Gradient Descent, BGD),随机梯度下降 (Stochastic Gradient Descent, SGD) 和小批量梯度下降 (Mini-Batch Gradient Descent, MBGD)。一般情况下,我们在谈论梯度下降时,更多的是指小批量梯度下降。

BGD

BGD 为梯度下降算法中最基础的一个算法,其损失函数定义如下:

$$ J \left(\boldsymbol{\theta}\right) = \dfrac{1}{2m} \sum_{i=1}^{m}{\left(h_{\boldsymbol{\theta}}\left(x^{\left(i\right)}\right) - y^{\left(i\right)}\right)} $$

针对任意参数 $\theta_j$ 我们可以求得其梯度为:

$$ \nabla_{\theta_j} = \dfrac{\partial J\left(\boldsymbol{\theta}\right)}{\partial \theta_j} = - \dfrac{1}{m} \sum_{i=1}^{m}{\left(y^{\left(i\right)} - h_{\boldsymbol{\theta}} \left(x^{\left(i\right)}\right)\right) x_j^{\left(i\right)}} $$

之后,对于任意参数 $\theta_j$ 我们按照其负梯度方向进行更新:

$$ \theta_j = \theta_j + \alpha \left[\dfrac{1}{m} \sum_{i=1}^{m}{\left(y^{\left(i\right)} - h_{\boldsymbol{\theta}} \left(x^{\left(i\right)}\right)\right) x_j^{\left(i\right)}}\right] $$

整个算法流程可以表示如下:

    
\begin{algorithm}
\caption{BGD 算法}
\begin{algorithmic}
\FOR{$epoch = 1, 2, ...$}
    \FOR{$j = 1, 2, ..., n$}
        \STATE $J \left(\boldsymbol{\theta}\right) = \dfrac{1}{2m} \sum_{i=1}^{m}{\left(h_{\boldsymbol{\theta}}\left(x^{\left(i\right)}\right) - y^{\left(i\right)}\right)}$
        \STATE $\theta_j = \theta_j - \alpha \dfrac{\partial J\left(\boldsymbol{\theta}\right)}{\partial \theta_j}$
    \ENDFOR
\ENDFOR
\end{algorithmic}
\end{algorithm}

  

从上述算法流程中我们可以看到,BGD 算法每次计算梯度都使用了整个训练集,也就是说对于给定的一个初始点,其每一步的更新都是沿着全局梯度最大的负方向。但这同样是其问题,当 $m$ 太大时,整个算法的计算开销就很高了。

SGD

SGD 相比于 BGD,其最主要的区别就在于计算梯度时不再利用整个数据集,而是针对单个样本计算梯度并更新权重,因此,其损失函数定义如下:

$$ J \left(\boldsymbol{\theta}\right) = \dfrac{1}{2} \left(h_{\boldsymbol{\theta}}\left(x^{\left(i\right)}\right) - y^{\left(i\right)}\right) $$

整个算法流程可以表示如下:

    
\begin{algorithm}
\caption{SGD 算法}
\begin{algorithmic}
\FOR{$epoch = 1, 2, ...$}
    \STATE Randomly shuffle dataset
    \FOR{$i = 1, 2, ..., m$}
        \FOR{$j = 1, 2, ..., n$}
            \STATE $J \left(\boldsymbol{\theta}\right) = \dfrac{1}{2} \left(h_{\boldsymbol{\theta}}\left(x^{\left(i\right)}\right) - y^{\left(i\right)}\right)$
            \STATE $\theta_j = \theta_j - \alpha \dfrac{\partial J\left(\boldsymbol{\theta}\right)}{\partial \theta_j}$
        \ENDFOR
    \ENDFOR
\ENDFOR
\end{algorithmic}
\end{algorithm}

  

SGD 相比于 BGD 具有训练速度快的优势,但同时由于权重改变的方向并不是全局梯度最大的负方向,甚至相反,因此不能够保证每次损失函数都会减小。

MBGD

针对 BGD 和 SGD 的问题,MBGD 则是一个折中的方案,在每次更新参数时,MBGD 会选取 $b$ 个样本计算的梯度,设第 $k$ 批中数据的下标的集合为 $B_k$,则其损失函数定义如下:

$$ \nabla_{\theta_j} = \dfrac{\partial J\left(\boldsymbol{\theta}\right)}{\partial \theta_j} = - \dfrac{1}{|B_k|} \sum_{i \in B_k}{\left(y^{\left(i\right)} - h_{\boldsymbol{\theta}} \left(x^{\left(i\right)}\right)\right) x_j^{\left(i\right)}} $$

整个算法流程可以表示如下:

    
\begin{algorithm}
\caption{MBGD 算法}
\begin{algorithmic}
\FOR{$epoch = 1, 2, ...$}
    \FOR{$k = 1, 2, ..., m / b$}
        \FOR{$j = 1, 2, ..., n$}
            \STATE $J \left(\boldsymbol{\theta}\right) = \dfrac{1}{|B_k|} \sum_{i \in B_k}{\left(y^{\left(i\right)} - h_{\boldsymbol{\theta}} \left(x^{\left(i\right)}\right)\right) x_j^{\left(i\right)}}$
            \STATE $\theta_j = \theta_j - \alpha \dfrac{\partial J\left(\boldsymbol{\theta}\right)}{\partial \theta_j}$
        \ENDFOR
    \ENDFOR
\ENDFOR
\end{algorithmic}
\end{algorithm}

  

Momentum

当梯度沿着一个方向要明显比其他方向陡峭,我们可以形象的称之为峡谷形梯度,这种情况多位于局部最优点附近。在这种情况下,SGD 通常会摇摆着通过峡谷的斜坡,这就导致了其到达局部最优值的速度过慢。因此,针对这种情况,Momentum 2 方法提供了一种解决方案。针对原始的 SGD 算法,参数每 $t$ 步的变化量可以表示为

$$ \boldsymbol{v}_t = - \alpha \nabla_{\boldsymbol{\theta}} J \left(\boldsymbol{\theta}_t\right) $$

Momentum 算法则在其变化量中添加了一个动量分量,即

$$ \begin{equation} \begin{split} \boldsymbol{v}_t &= - \alpha \nabla_{\boldsymbol{\theta}} J \left(\boldsymbol{\theta}_t\right) + \gamma \boldsymbol{v}_{t-1} \\ \boldsymbol{\theta}_t &= \boldsymbol{\theta}_{t-1} + \boldsymbol{v}_t \end{split} \end{equation} $$

对于添加的动量项,当第 $t$ 步和第 $t-1$ 步的梯度方向相同时,$\boldsymbol{\theta}$ 则以更快的速度更新;当第 $t$ 步和第 $t-1$ 步的梯度方向相反时,$\boldsymbol{\theta}$ 则以较慢的速度更新。利用 SGD 和 Momentum 两种方法,在峡谷行的二维梯度上更新参数的示意图如下所示

NAG

NAG (Nesterov Accelerated Gradient) 3 是一种 Momentum 算法的变种,其核心思想会利用“下一步的梯度”确定“这一步的梯度”,当然这里“下一步的梯度”并非真正的下一步的梯度,而是指仅根据动量项更新后位置的梯度。Sutskever 4 给出了一种更新参数的方法:

$$ \begin{equation} \begin{split} \boldsymbol{v}_t &= - \alpha \nabla_{\boldsymbol{\theta}} J \left(\boldsymbol{\theta}_t + \gamma \boldsymbol{v}_{t-1}\right) + \gamma \boldsymbol{v}_{t-1} \\ \boldsymbol{\theta}_t &= \boldsymbol{\theta}_{t-1} + \boldsymbol{v}_t \end{split} \end{equation} $$

针对 Momentum 和 NAG 两种不同的方法,其更新权重的差异如下图所示:

AdaGrad

AdaGrad 5 是一种具有自适应学习率的的方法,其对于低频特征的参数选择更大的更新量,对于高频特征的参数选择更小的更新量。因此,AdaGrad算法更加适用于处理稀疏数据。Pennington 等则利用该方法训练 GloVe 6 词向量,因为对于出现次数较少的词应当获得更大的参数更新。

因为每个参数的学习速率不再一样,则在 $t$ 时刻第 $i$ 个参数的变化为

$$ \theta_{t, i} = \theta_{t-1, i} - \alpha \nabla_{\theta} J \left(\theta_{t-1, i}\right) $$

根据 AdaGrad 方法的更新方式,我们对学习率做出如下变化

$$ \theta_{t, i} = \theta_{t-1, i} - \dfrac{\alpha}{\sqrt{G_{t, i}} + \epsilon} \nabla_{\theta} J \left(\theta_{t-1, i}\right) $$

其中,$G_t$ 表示截止到 $t$ 时刻梯度的平方和;$\epsilon$ 为平滑项,防止除数为零,一般设置为 $10^{-8}$。AdaGrad 最大的优势就在于其能够自动调节每个参数的学习率。

Adadelta

上文中 AdaGrad 算法存在一个缺点,即其用于调节学习率的分母中包含的是一个梯度的平方累加项,随着训练的不断进行,这个值将会越来越大,也就是说学习率将会越来越小,最终导致模型不会再学习到任何知识。Adadelta 7 方法针对 AdaGrad 的这个问题,做出了进一步改进,其不再计算历史所以梯度的平方和,而是使用一个固定长度 $w$ 的滑动窗口内的梯度。

因为存储 $w$ 的梯度平方并不高效,Adadelta 采用了一种递归的方式进行计算,定义 $t$ 时刻梯度平方的均值为

$$ E \left[g^2\right]_t = \rho E \left[g^2\right]_{t-1} + \left(1 - \rho\right) g^2_{t} $$

其中,$g_t$ 表示 $t$ 时刻的梯度;$\rho$ 为一个衰减项,类似于 Momentum 中的衰减项。在更新参数过程中我们需要其平方根,即

$$ \text{RMS} \left[g\right]_t = \sqrt{E \left[g^2\right]_t + \epsilon} $$

则参数的更新量为

$$ \Delta \theta_t = - \dfrac{\alpha}{\text{RMS} \left[g\right]_t} g_t $$

除此之外,作者还考虑到上述更新中更新量和参数的假设单位不一致的情况,在上述更新公式中添加了一个关于参数的衰减项

$$ \text{RMS} \left[\Delta \theta\right]_t = \sqrt{E \left[\Delta \theta^2\right]_t + \epsilon} $$

其中

$$ E \left[\Delta \theta^2\right]_t = \rho E \left[\Delta \theta^2\right]_{t-1} + \left(1 - \rho\right) \Delta \theta_t^2 $$

在原始的论文中,作者直接用 $\text{RMS} \left[\Delta \theta^2\right]_t$ 替换了学习率,即

$$ \Delta \theta_t = - \dfrac{\text{RMS} \left[\Delta \theta\right]_{t-1}}{\text{RMS} \left[g\right]_t} g_t $$

而在 Keras 源码中,则保留了固定的学习率,即

$$ \Delta \theta_t = - \alpha \dfrac{\text{RMS} \left[\Delta \theta\right]_{t-1}}{\text{RMS} \left[g\right]_t} g_t $$

RMSprop

RMSprop 8 是由 Hinton 提出的一种针对 AdaGrad 的改进算法。参数的更新量为

$$ \Delta \theta_t = - \dfrac{\alpha}{\text{RMS} \left[g\right]_t} g_t $$

Adam

Adam (Adaptive Moment Estimation) 9 是另一种类型的自适应学习率方法,类似 Adadelta,Adam 对于每个参数都计算各自的学习率。Adam 方法中包含一个一阶梯度衰减项 $m_t$ 和一个二阶梯度衰减项 $v_t$

$$ \begin{equation} \begin{split} m_t &= \beta_1 m_{t-1} + \left(1 - \beta_1\right) g_t \\ v_t &= \beta_2 v_{t-1} + \left(1 - \beta_2\right) g_t^2 \end{split} \end{equation} $$

算法中,$m_t$$v_t$ 初始化为零向量,作者发现两者会更加偏向 $0$,尤其是在训练的初始阶段和衰减率很小的时候 (即 $\beta_1$$\beta_2$ 趋近于1的时候)。因此,对其偏差做如下校正

$$ \begin{equation} \begin{split} \hat{m}_t &= \dfrac{m_t}{1 - \beta_1^t} \\ \hat{v}_t &= \dfrac{v_t}{1 - \beta_2^t} \end{split} \end{equation} $$

最终得到 Adam 算法的参数更新量如下

$$ \Delta \theta = - \dfrac{\alpha}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t $$

Adamax

在 Adam 中参数的更新方法利用了 $L_2$ 正则形式的历史梯度 ($v_{t-1}$) 和当前梯度 ($|g_t|^2$),因此,更一般的,我们可以使用 $L_p$ 正则形式,即

$$ \begin{equation} \begin{split} v_t &= \beta_2^p v_{t-1} + \left(1 - \beta_2^p\right) |g_t|^p \\ &= \left(1 - \beta_2^p\right) \sum_{i=1}^{t} \beta_2^{p\left(t-i\right)} \cdot |g_t|^p \end{split} \end{equation} $$

这样的变换对于值较大的 $p$ 而言是很不稳定的,但对于极端的情况,当 $p$ 趋近于无穷的时候,则变为了一个简单并且稳定的算法。则在 $t$ 时刻对应的我们需要计算 $v_t^{1/p}$,令 $u_t = \lim_{p \to \infty} \left(v_t\right)^{1/p}$,则有

$$ \begin{equation} \begin{split} u_t &= \lim_{p \to \infty} \left(\left(1 - \beta_2^p\right) \sum_{i=1}^{t} \beta_2^{p\left(t-i\right)} \cdot |g_t|^p\right)^{1/p} \\ &= \lim_{p \to \infty} \left(1 - \beta_2^p\right)^{1/p} \left(\sum_{i=1}^{t} \beta_2^{p\left(t-i\right)} \cdot |g_t|^p\right)^{1/p} \\ &= \lim_{p \to \infty} \left(\sum_{i=1}^{t} \beta_2^{p\left(t-i\right)} \cdot |g_t|^p\right)^{1/p} \\ &= \max \left(\beta_2^{t-1} |g_1|, \beta_2^{t-2} |g_2|, ..., \beta_{t-1} |g_t|\right) \end{split} \end{equation} $$

写成递归的形式,则有

$$ u_t = \max \left(\beta_2 \cdot u_{t-1}, |g_t|\right) $$

则 Adamax 算法的参数更新量为

$$ \Delta \theta = - \dfrac{\alpha}{u_t} \hat{m}_t $$

Nadam

Adam 算法可以看做是对 RMSprop 和 Momentum 的结合:历史平方梯度的衰减项 $v_t$ (RMSprop) 和 历史梯度的衰减项 $m_t$ (Momentum)。Nadam (Nesterov-accelerated Adaptive Moment Estimation) 10 则是将 Adam 同 NAG 进行了进一步结合。我们利用 Adam 中的符号重新回顾一下 NAG 算法

$$ \begin{equation} \begin{split} g_t &= \nabla_{\theta} J \left(\theta_t - \gamma m_{t-1}\right) \\ m_t &= \gamma m_{t-1} + \alpha g_t \\ \theta_t &= \theta_{t-1} - m_t \end{split} \end{equation} $$

NAG 算法的核心思想会利用“下一步的梯度”确定“这一步的梯度”,在 Nadam 算法中,作者在考虑“下一步的梯度”时对 NAG 进行了改动,修改为

$$ \begin{equation} \begin{split} g_t &= \nabla_{\theta} J \left(\theta_t\right) \\ m_t &= \gamma m_{t-1} + \alpha g_t \\ \theta_t &= \theta_{t-1} - \left(\gamma m_t + \alpha g_t\right) \end{split} \end{equation} $$

对于 Adam,根据

$$ \hat{m}_t = \dfrac{\beta_1 m_{t-1}}{1 - \beta_1^t} + \dfrac{\left(1 - \beta_1\right) g_t}{1 - \beta_1^t} $$

则有

$$ \begin{equation} \begin{split} \Delta \theta &= - \dfrac{\alpha}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t \\ &= - \dfrac{\alpha}{\sqrt{\hat{v}_t} + \epsilon} \left(\dfrac{\beta_1 m_{t-1}}{1 - \beta_1^t} + \dfrac{\left(1 - \beta_1\right) g_t}{1 - \beta_1^t}\right) \end{split} \end{equation} $$

上式中,仅 $\dfrac{\beta_1 m_{t-1}}{1 - \beta_1^t}$ 和动量项相关,因此我们类似上文中对 NAG 的改动,通过简单的替换加入 Nesterov 动量项,最终得到 Nadam 方法的参数的更新量

$$ \Delta \theta = - \dfrac{\alpha}{\sqrt{\hat{v}_t} + \epsilon} \left(\dfrac{\beta_1 m_{t-1}}{1 - \beta_1^{t+1}} + \dfrac{\left(1 - \beta_1\right) g_t}{1 - \beta_1^t}\right) $$

AMSGrad

对于前面提到的 Adadelta,RMSprop,Adam 和 Nadam 方法,他们均采用了平方梯度的指数平滑平均值迭代产生新的梯度,但根据观察,在一些情况下这些算法并不能收敛到最优解。Reddi 等提出了一种新的 Adam 变体算法 AMSGrad 11,在文中作者解释了为什么 RMSprop 和 Adam 算法无法收敛到一个最优解的问题。通过分析表明,为了保证得到一个收敛的最优解需要保留过去梯度的“长期记忆”,因此在 AMSGrad 算法中使用了历史平方梯度的最大值而非滑动平均进行更新参数,即

$$ \begin{equation} \begin{split} m_t &= \beta_1 m_{t-1} + \left(1 - \beta_1\right) g_t \\ v_t &= \beta_2 v_{t-1} + \left(1 - \beta_2\right) g_t^2 \\ \hat{v}_t &= \max \left(\hat{v}_{t-1}, v_t\right) \\ \Delta \theta &= - \dfrac{\alpha}{\sqrt{\hat{v}_t} + \epsilon} m_t \end{split} \end{equation} $$

作者在一些小数据集和 CIFAR-10 数据集上得到了相比于 Adam 更好的效果,但与此同时一些其他的 实验 却得到了相比与 Adam 类似或更差的结果,因此对于 AMSGrad 算法的效果还有待进一步确定。

算法可视化

正所谓一图胜千言,Alec Radford 提供了 2 张图形象了描述了不同优化算法之间的区别

左图为 Beale Function 在二维平面上的等高线,从图中可以看出 AdaGrad,Adadelta 和 RMSprop 算法很快的找到正确的方向并迅速的收敛到最优解;Momentum 和 NAG 则在初期出现了偏离,但偏离之后调整了方向并收敛到最优解;而 SGD 尽管方向正确,但收敛速度过慢。

右图为包含鞍点的一个三维图像,图像函数为 $z = x^2 - y^2$,从图中可以看出 AdaGrad,Adadelta 和 RMSprop 算法能够相对很快的逃离鞍点,而 Momentum,NAG 和 SGD 则相对比较困难逃离鞍点。

很不幸没能找到 Alec Radford 绘图的原始代码,不过 Louis Tiao 在 博客 中给出了绘制类似动图的方法。因此,本文参考该博客和 Keras 源码中对不同优化算法的实现重新绘制了 2 张类似图像,详细过程参见 源代码,动图如下所示:


  1. Ruder, Sebastian. “An overview of gradient descent optimization algorithms.” arXiv preprint arXiv:1609.04747 (2016). ↩︎

  2. Qian, Ning. “On the momentum term in gradient descent learning algorithms.” Neural networks 12.1 (1999): 145-151. ↩︎

  3. Nesterov, Yurii. “A method for unconstrained convex minimization problem with the rate of convergence O (1/k^2).” Doklady AN USSR. Vol. 269. 1983. ↩︎

  4. Sutskever, Ilya. “Training recurrent neural networks.” University of Toronto, Toronto, Ont., Canada (2013). ↩︎

  5. Duchi, John, Elad Hazan, and Yoram Singer. “Adaptive subgradient methods for online learning and stochastic optimization.” Journal of Machine Learning Research 12.Jul (2011): 2121-2159. ↩︎

  6. Pennington, Jeffrey, Richard Socher, and Christopher Manning. “Glove: Global vectors for word representation.” Proceedings of the 2014 conference on empirical methods in natural language processing (EMNLP). 2014. ↩︎

  7. Zeiler, Matthew D. “ADADELTA: an adaptive learning rate method.” arXiv preprint arXiv:1212.5701 (2012). ↩︎

  8. Hinton, G., Nitish Srivastava, and Kevin Swersky. “Rmsprop: Divide the gradient by a running average of its recent magnitude.” Neural networks for machine learning, Coursera lecture 6e (2012). ↩︎

  9. Kingma, Diederik P., and Jimmy Ba. “Adam: A method for stochastic optimization.” arXiv preprint arXiv:1412.6980 (2014). ↩︎

  10. Dozat, Timothy. “Incorporating nesterov momentum into adam.” (2016). ↩︎

  11. Reddi, Sashank J., Satyen Kale, and Sanjiv Kumar. “On the convergence of adam and beyond.” International Conference on Learning Representations. 2018. ↩︎

昨天以前范叶亮的博客

部署 Matrix 服务器 Synapse

2026年4月11日 08:00

环境信息

Matrix 是一种用于实时通讯的开放、去中心化协议,专注于安全、加密的文字、语音和视频聊天。它允许不同服务器上的用户互通,类似于邮件系统,并支持端到端加密,使用户能完全控制数据,不受单一实体限制。

Matrix 联邦服务器之间连接的客户端
Matrix 联邦服务器之间连接的客户端

Synapse 是一个使用 Python/Twisted 和 Rust 编写的开源 Matrix 服务器实现。本教程将介绍使用 Docker 容器部署 Matrix 服务器 Synapse。本教程涉及到的软件信息如下:

软件 Docker 镜像 版本
PostgreSQL mixdeve/postgres-zhparser:18 18
Redis redis:8 8
Synapse matrixdotorg/synapse:latest 1.151.0

注意

将后续命令中的 example.com 替换为实际域名。

准备工作

数据库

为了支持中文搜索,在此选择内置 zhparser 分词功能的 PostgreSQL 镜像。在适当位置创建 PostgreSQL 的存储目录,例如 postgresql。运行如下命令生成配置:

docker run -d \
  --name postgresql \
  --restart always \
  -v $(pwd)/postgresql:/var/lib/postgresql \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_PASSWORD=<密码> \
  -e ALLOW_IP_RANGE=0.0.0.0/0 \
  -p 5432:5432 \
  mixdeve/postgres-zhparser:18

相关参数说明如下:

参数 说明
POSTGRES_USER 数据库用户名,默认 postgres
POSTGRES_PASSWORD 数据库密码
POSTGRES_DB 默认数据库名,默认 postgres
ALLOW_IP_RANGE 允许访问的 IP 范围

进入数据库执行如下 SQL 进行配置:

-- 创建用户
CREATE USER synapse WITH PASSWORD '<密码>';

-- 创建数据库
CREATE DATABASE synapse WITH OWNER = synapse ENCODING = 'UTF8' LC_COLLATE = 'C' LC_CTYPE = 'C' TEMPLATE = template0;

-- 授权
GRANT ALL PRIVILEGES ON DATABASE synapse TO synapse;

缓存

为了提升服务性能,在此开启 Redis 缓存。在适当位置创建 Redis 的存储目录,例如 redis。运行如下命令启动 Redis 容器:

docker run -d \
  --name redis \
  --restart always \
  -v $(pwd)/redis:/data \
  -p 6379:6379 \
  redis:8 \
  redis-server --save 60 1 --appendonly yes

配置文件

进入服务器在适当位置创建 Synapse 的存储目录,例如 synapse。运行如下命令生成配置:

docker run -it --rm \
  -v $(pwd)/synapse:/data \
  -e SYNAPSE_SERVER_NAME=example.com \
  -e SYNAPSE_REPORT_STATS=no \
  -e SYNAPSE_HTTP_PORT=8008 \
  -e UID=1000 \
  -e GID=1000 \
  matrixdotorg/synapse:latest generate

相关参数说明如下:

参数 说明
SYNAPSE_SERVER_NAME 服务器域名
SYNAPSE_REPORT_STATS 是否上报统计信息,默认 yes
SYNAPSE_HTTP_PORT HTTP 端口,默认 8008
SYNAPSE_CONFIG_DIR 配置目录,默认 /data
SYNAPSE_CONFIG_PATH 配置文件路径,默认 <SYNAPSE_CONFIG_DIR>/homeserver.yaml
SYNAPSE_DATA_DIR 数据目录,默认 /data
UID 用户 ID,默认 991
GID 组 ID,默认 991

修改配置文件服务部分如下:

public_baseurl: https://matrix-homeserver.example.com
serve_server_wellknown: true

修改配置文件数据库部分如下:

database:
  name: psycopg2
  txn_limit: 10000
  args:
    user: synapse
    password: <密码>
    dbname: synapse
    host: <数据库地址>
    port: 5432
    cp_min: 5
    cp_max: 10

修改配置文件缓存部分如下:

redis:
  enabled: true
  host: <数据库地址>
  port: 6379
  dbid: 0

启动服务

运行如下命令启动服务:

docker run -d \
  --name synapse \
  --restart always \
  -v $(pwd)/synapse:/data \
  -u 1000:1000 \
  -p 8008:8008 \
  matrixdotorg/synapse:latest

在服务商中配置 DNS 将 matrix-homeserver.example.com 解析至 Docker 服务器的 IP 地址。通过浏览器访问 http://matrix-homeserver.example.com:8008 即可查看 Synapse 服务是否启动成功。

中文搜索

提示

如果不需要中文搜索服务,可跳过本节。

在 PostgreSQL 数据中运行如下 SQL 安装 zhparser 扩展并配置中文搜索:

-- 创建 zhparser 扩展
CREATE EXTENSION IF NOT EXISTS zhparser;

-- 创建中文搜索配置
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese ADD MAPPING FOR n,v,a,i,e,l WITH simple;

-- 添加中文向量列
ALTER TABLE event_search ADD COLUMN IF NOT EXISTS chinese_vector tsvector;

-- 对已有数据进行中文分词处理
UPDATE event_search
SET chinese_vector =
  CASE
    WHEN event_search.key = 'content.body' AND TRIM(event_json.json::jsonb->'content'->>'body') != ''
      THEN to_tsvector('chinese', event_json.json::jsonb->'content'->>'body')
    WHEN event_search.key = 'content.name' AND TRIM(event_json.json::jsonb->'content'->>'name') != ''
      THEN to_tsvector('chinese', event_json.json::jsonb->'content'->>'name')
    WHEN event_search.key = 'content.topic' AND TRIM(event_json.json::jsonb->'content'->>'topic') != ''
      THEN to_tsvector('chinese', event_json.json::jsonb->'content'->>'topic')
    ELSE NULL
  END
FROM
  event_json
WHERE
  event_search.event_id = event_json.event_id
  AND (
    event_search.chinese_vector IS NULL
    OR event_search.chinese_vector::text = ''
  );

-- 创建中文索引
CREATE INDEX CONCURRENTLY event_search_chinese_vector_idx ON event_search USING GIN (chinese_vector);

将修改后的 search.py 文件映射至 Synapse 容器,删除之前的容器,运行如下命令重新创建容器:

docker run -d \
  --name synapse \
  --restart always \
  -v $(pwd)/synapse:/data \
  -v $(pwd)/synapse/search.py:/usr/local/lib/python3.13/site-packages/synapse/storage/databases/main/search.py \
  -u 1000:1000 \
  -p 8008:8008 \
  matrixdotorg/synapse:latest

注意

更新 Synapse 版本后,请注意原始 search.py 是否发生变化,如有则需要重新修改支持中文的 search.py。同时请注意 Dockerfile 中镜像基于的系统环境和 Python 版本是否发生变化,如有则需要对应调整将修改后 search.py 文件的映射路径。

发现服务

提示

如果希望使用主域名 example.com 成为 Matrix 服务域名(例如:@user:example.com),则需要配置发现服务。如果希望使用子域名 matrix-homeserver.example.com 成为 Matrix 服务域名(例如:@user:matrix-homeserver.example.com),则可以跳过本节。

发现服务是 Matrix 网络发现服务器位置的一种方式。因为实际服务运行在子域名 matrix-homeserver.example.com 上,需要让其他服务器和客户端知道我们使用主域名,因此需要提供 /.well-known/matrix 信息。因此需要将 https://example.com/.well-known/matrix 映射到 https://matrix-homeserver.example.com/.well-known/matrix,相关细节请参考官方文档

注册用户

Synapse 服务默认是禁止自助注册用户的,运行如下命令进入 Synapse 容器:

docker exec -it synapse /bin/bash

运行如下命令创建用户:

register_new_matrix_user http://localhost:8008 \
  -c /data/homeserver.yaml -a \
  -u "<用户名>" \
  -p "<密码>"

开始使用

在浏览器上打开 https://app.element.io,单击 切换 Homeserver 到 example.com,输入用户名和密码即可登录。

部署 frp 内网穿透服务

2026年4月10日 08:00

环境信息

frp 是一款高性能的反向代理应用,专注于内网穿透。它支持多种协议,包括 TCP、UDP、HTTP、HTTPS 等,并且具备 P2P 通信功能。使用 frp,您可以安全、便捷地将内网服务暴露到公网,通过拥有公网 IP 的节点进行中转 1

frp 架构图
frp 架构图

本教程将介绍在具有公网 IP 的服务器和内网路由器上部署 frp 内网穿透服务。本教程涉及到的环境信息如下:

环境 系统
服务端 外网服务器系统 Ubuntu 24.04
客户端 内网路由器系统 OpenWRT 24.10

注意

将后续命令中的 example.com 替换为实际域名。

证书申请

frp 证书

在本地创建 cert 目录,将 OpenSSL 配置文件复制到该目录。通常情况下 Linux 系统位于 /etc/pki/tls/openssl.cnf,macOS 系统位于 /System/Library/OpenSSL/openssl.cnf

运行如下命令生成 ca 证书:

openssl genrsa -out ca.key 2048
openssl req -x509 -new -nodes -key ca.key -subj "/CN=example.com" -days 36500 -out ca.crt

运行如下命令生成服务端证书:

openssl genrsa -out server.key 2048
openssl req -new -sha256 -key server.key \
  -subj "/C=XX/ST=DEFAULT/L=DEFAULT/O=DEFAULT/CN=example.com" \
  -reqexts SAN \
  -config <(cat openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:localhost,IP:127.0.0.1,DNS:*.example.com")) \
  -out server.csr
openssl x509 -req -days 36500 -sha256 \
  -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1,DNS:example.com") \
  -out server.crt

运行如下命令生成客户端证书:

openssl genrsa -out client.key 2048
openssl req -new -sha256 -key client.key \
  -subj "/C=XX/ST=DEFAULT/L=DEFAULT/O=DEFAULT/CN=example.com" \
  -reqexts SAN \
  -config <(cat openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:localhost,IP:127.0.0.1,DNS:*.example.com")) \
  -out client.csr
openssl x509 -req -days 36500 -sha256 \
  -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1,DNS:example.com") \
  -out client.crt

由于 server.crtclient.crt 都是由 ca 签发的,因此他们对于 ca 来说都是合法的。

Nginx 证书

在服务器上运行如下命令安装 acme.sh:

curl https://get.acme.sh | sh -s email=my@example.com

安装脚本将执行如下动作:

  1. $HOME 目录创建 .acme.sh 目录并安装 acme.sh。
  2. 创建别名 acme.sh 指向 $HOME/.acme.sh/acme.sh
  3. 创建每日定时任务以便更新证书。

以阿里云域名管理为例,在 https://ram.console.aliyun.com/ 页面创建用户,并为用户赋予 DNS 相关管理权限。为用户创建 AccessKey,并设置如下环境变量:

export Ali_Key="xxx"
export Ali_Secret="xxx"

运行如下命令申请证书:

acme.sh --issue --dns dns_ali -d example.com -d *.example.com

frp 配置

注意

将后续命令中的 /path/to 替换为对应文件的实际路径,将 xxx 替换为实际内容。

服务端

在服务端创建 frp 目录,配置 frps.toml 文件参数如下,更多参数设置请参阅 frp 文档

bindAddr = "0.0.0.0"
bindPort = 7000
quicBindPort = 7000
vhostHTTPPort = 8080
vhostHTTPSPort = 8443
tcpmuxHTTPConnectPort = 5002

auth.method = "token"
auth.token = "xxx"

transport.tls.certFile = "/path/to/server.crt"
transport.tls.keyFile = "/path/to/server.key"
transport.tls.trustedCaFile = "/path/to/ca.crt"

webServer.addr = "0.0.0.0"
webServer.port = 7500
webServer.user = "xxx"
webServer.password = "xxx"

log.to = "/path/to/frps.log"
log.level = "info"
log.maxDays = 3

注意

请确保将上述配置中的端口针对 0.0.0.0/0 关闭防火墙拦截。

/etc/systemd/system 路径创建 frps.service 服务,配置内容如下:

[Unit]
Description = frp server
After = network.target syslog.target
Wants = network.target

[Service]
Type = simple
ExecStart = /path/to/frps -c /path/to/frps.toml

[Install]
WantedBy = multi-user.target

运行如下命令管理 frps 服务:

# 自启动 frps
sudo systemctl enable frps

# 启动 frps
sudo systemctl start frps

# 停止 frps
sudo systemctl stop frps

# 重启 frps
sudo systemctl restart frps

# 查看 frps 状态
sudo systemctl status frps

添加如下内容至 Nginx 配置文件中:

server {
    listen 80;
    listen [::]:80;

    server_name *.example.com;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name *.example.com;

    ssl_certificate /path/to/example.com.cer;
    ssl_certificate_key /path/to/example.com.key;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_redirect off;
        proxy_ssl_server_name on;

        proxy_set_header Host $host:80;
        proxy_set_header Referer $http_referer;
        proxy_set_header Cookie $http_cookie;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

运行如下命令重启 Nginx:

sudo systemctl restart nginx

客户端

注意

将后续命令中的 x.x.x.x 替换为本地服务对应的 IP 地址。

在客户端创建 frp 目录,配置 frpc.toml 文件参数如下,更多参数设置请参阅 frp 文档

auth.method = "token"
auth.token = "xxx"

user = "xxx"

serverAddr = "frps.example.com"
serverPort = 7000

transport.protocol = "quic"
transport.proxyProtocolVersion = "v2"
transport.tls.certFile = "/path/to/client.crt"
transport.tls.keyFile = "/path/to/client.key"
transport.tls.trustedCaFile = "/path/to/ca.crt"

webServer.addr = "0.0.0.0"
webServer.port = 7400
webServer.user = "xxx"
webServer.password = "xxx"

log.to = "/path/to/frpc.log"
log.level = "info"
log.maxDays = 3

[[proxies]]
name = "example-http"
type = "http"
localIP = "x.x.x.x"
localPort = 80
customDomains = ["example-http.example.com"]

[[proxies]]
name = "example-ssh"
type = "tcpmux"
multiplexer = "httpconnect"
localIP = "x.x.x.x"
localPort = 22
customDomains = ["example-ssh.example.com"]

其中,example-http 采用了通过自定义域名的方式访问内网 Web 服务,更多示例请参考 frp 文档example-ssh 采用了多个 SSH 服务复用同一端口的方式连接内网 SSH 服务,更多示例请参考 frp 文档

/etc/init.d 路径下创建 frpc 服务,配置内容如下:

#!/bin/sh /etc/rc.common

# 使用 procd
USE_PROCD=1
# 启动顺序
START=95
# 停止顺序
STOP=15

# frpc
FRPC=/path/to/frpc
CONF=/path/to/frpc.toml

start_service() {
    procd_open_instance "frpc"
    procd_set_param command $FRPC -c $CONF
    procd_set_param respawn
    procd_set_param stdout 1
    procd_set_param stderr 1
    procd_set_param pidfile /var/run/frpc.pid
    procd_close_instance
}

service_triggers() {
    procd_add_reload_mount_trigger $CONF
}

运行如下命令管理 frpc 服务:

# 赋予执行权限
chmod +x /etc/init.d/frpc

# 自启动 frpc
/etc/init.d/frpc enable

# 启动 frpc
/etc/init.d/frpc start

# 停止 frpc
/etc/init.d/frpc stop

# 重启 frpc
/etc/init.d/frpc restart

# 查看 frpc 状态
/etc/init.d/frpc status

在服务商中配置 DNS 将 example-http.example.comexample-ssh.example.com 解析至 frp 服务器的 IP 地址。

连接

Web

此时,通过浏览器访问 https://example-http.example.com 即可实现访问内网 IP 地址 x.x.x.x 在端口 80 上的 Web 服务。

SSH

提示

请先在本地机器上安装 socat 工具。

此时,通过如下命令即可实现访问内网 IP 地址 x.x.x.x 在端口 22 上的 SSH 服务:

ssh -o 'proxycommand socat - PROXY:frps.example.com:%h:%p,proxyport=5002' test@example-ssh.example.com

本地部署大模型服务

2026年4月5日 08:00

环境信息

本教程将介绍在 macOS 和 Windows 环境下部署本地大模型服务。如无特殊说明,macOS 系统下需在终端中执行命令,Windows 系统下需要在 PowerShell 中执行命令。本教程涉及到的软件和模型信息如下:

软件 版本
ollama 0.20.2
LM Studio 0.4.9+1
vllm 0.19.0
vllm-metal v0.1.0-20260404-164341
vllm-mlx 0.2.7
oMLX 0.3.4
名称 架构 量化 内存 / 显存 能力 链接
gemma-4-31B-it 稠密 4bit 32 GB 及以上 GGUF / GGUF / MLX
GGUF / GGUF / MLX
gemma-4-26B-A4B-it MoE 4bit 32 GB 及以上 GGUF / GGUF / MLX
GGUF / GGUF / MLX
Qwen3.5-27B 稠密 4bit 32 GB 及以上 GGUF / GGUF / MLX
GGUF / GGUF / MLX
Qwen3.5-35B-A3B MoE 4bit 32 GB 及以上 GGUF / GGUF / MLX
GGUF / GGUF / MLX
CoPaw-Flash-9B 稠密 4bit 16 GB 及以上 GGUF
GGUF

为了加速从 Hugging Face 模型仓库下载模型,可以运行如下命令配置相关环境变量:

echo "HF_ENDPOINT=https://hf-mirror.com" >> ~/.bash_profile
[Environment]::SetEnvironmentVariable("HF_ENDPOINT", "https://hf-mirror.com", "User")

更多使用方式可参考 HF-Mirror 官方网站。

ollama

推荐在终端运行如下命令安装 ollama:

curl -fsSL https://ollama.com/install.sh | sh
irm https://ollama.com/install.ps1 | iex

运行如下命令可以显示当前安装的版本号:

ollama --version
# ollama version is 0.20.2

ollama 当前采用 ollama pull MODEL 命令下载模型,除了使用官方模型库中的模型名称外(例如:qwen3.5:27b-nvfp4),还可以使用 Hugging Face 的模型链接(例如:https://huggingface.co/lmstudio-community/Qwen3.5-27B-GGUF),运行如下命令下载模型:

ollama pull qwen3.5:27b-nvfp4
ollama pull https://huggingface.co/lmstudio-community/Qwen3.5-27B-GGUF

ollama 当前仅支持通过环境变量配置监听地址和端口,运行如下命令进行配置:

echo "OLLAMA_HOST=0.0.0.0:11434" >> ~/.bash_profile
[Environment]::SetEnvironmentVariable("OLLAMA_HOST", "0.0.0.0:11434", "User")

运行如下命令启动模型服务:

ollama run <模型名称>
ollama run <模型名称>

提示

ollama 默认会选择最适合的运行库,如果需要切换可以手动指定 LLM 运行库,运行如下命令表示使用 CPU 进行推理:

OLLAMA_LLM_LIBRARY="cpu_avx2" ollama serve <模型名称>

注意

在 Settings 中将 Context length 设置为最大值,以确保在后续使用过程中不会因上下文长度不足而导致效果下降。

打开 ollama 主页面,选择对应的模型,即可开始对话:

模型服务运行在 http://127.0.0.1:11434,API 文档详见:https://docs.ollama.com/api/introduction。运行如下命令以 OpenAI 兼容的接口测试服务:

curl http://127.0.0.1:11434/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d '{
        "model": "<模型名称>",
        "messages": [
            {
                "role": "user",
                "content": "你好"
            }
        ]
    }'
iwr -Uri http://127.0.0.1:11434/v1/chat/completions `
    -Method Post `
    -ContentType "application/json" `
    -Body '{
        "model": "<模型名称>",
        "messages": [
            {
                "role": "user",
                "content": "你好"
            }
        ]
    }'

LM Studio

建议 Windows 系统测试使用

推荐从 LM Studio 官网下载安装包,并运行安装 LM Studio。如果只需要安装 LM Studio 核心,不需要 GUI 界面,则可以在终端运行如下命令:

curl -fsSL https://lmstudio.ai/install.sh | bash
irm https://lmstudio.ai/install.ps1 | iex

下载并安装所需的 Runtime,macOS 系统支持 GGUF 和 MLX 两种格式,Windows 系统仅支持 GGUF 格式。

注意

在 Settings - Model Defaults 中将 Default Context Length 设置为 Model Maximum,以确保在后续使用过程中不会因上下文长度不足而导致效果下降。

打开 LM Studio 主页面,选择对应的模型,即可开始对话:

单击左侧的 按钮,单击 Local Server,将 Status 滑动至 Running 状态。模型服务运行在 http://127.0.0.1:1234,API 文档详见:https://lmstudio.ai/docs/developer。运行如下命令以 OpenAI 兼容的接口测试服务:

curl http://127.0.0.1:1234/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d '{
        "model": "<模型名称>",
        "messages": [
            {
                "role": "user",
                "content": "你好"
            }
        ]
    }'
iwr -Uri http://127.0.0.1:1234/v1/chat/completions `
    -Method Post `
    -ContentType "application/json" `
    -Body '{
        "model": "<模型名称>",
        "messages": [
            {
                "role": "user",
                "content": "你好"
            }
        ]
    }'

oMLX

建议 macOS 系统测试使用

推荐从 oMLX 官网下载安装包,并运行安装 oMLX。安装完毕后启动,并从 macOS 菜单栏或 Windows 系统托盘单击 oMLX 图标,选择 Start Server 启动服务。待服务启动后,单击 Admin Panel 从浏览器打开管理面板,初次登录需要设置 API Key。在 Models - Downloader 中可以直接从 Hugging Face 和 ModelScope 模型仓库下载模型。

提示

建议国内用户切换至 ModelScope 标签,复制上文 ModelScope 模型连接的尾部(例如:mlx-community/gemma-4-31b-it-nvfp4)至 REPOSITORY ID 中下载模型。

在 Settings - Model Settings 中单击对应模型 STATUS 中的按钮载入模型。

注意

在 Settings - Global Settings 中将 Max Context Window 和 Max Tokens 设置为合适的值,以确保在后续使用过程中不会因上下文长度不足而导致效果下降。

单击 Chat 进入对话页面,选择对应的模型,即可开始对话:

模型服务运行在 http://127.0.0.1:8000。运行如下命令以 OpenAI 兼容的接口测试服务:

curl http://127.0.0.1:8000/v1/chat/completions \
    -H "Authorization: Bearer <API_KEY>" \
    -H "Content-Type: application/json" \
    -d '{
        "model": "<模型名称>",
        "messages": [
            {
                "role": "user",
                "content": "你好"
            }
        ]
    }'

vllm

建议 macOS 系统生产使用

提示

vllm 官方仅支持 macOS 和 Linux 系统,暂无 GUI 界面。在此以 macOS 系统为例进行安装。

在 macOS 系统上,使用 vllm-metal 库安装 vllm 服务,运行如下命令:

curl -fsSL https://raw.githubusercontent.com/vllm-project/vllm-metal/main/install.sh | bash

这会在 ~/.venv-vllm-metal 路径下创建一个 Python 虚拟环境,并安装 vllm 服务。运行如下命令即可删除安装:

rm -rf ~/.venv-vllm-metal

运行如下命令激活 Python 虚拟环境:

source ~/.venv-vllm-metal/bin/activate

运行如下命令启动 vllm 服务:

vllm serve <模型名称|模型路径>

模型服务运行在 http://127.0.0.1:8000,更多环境变量设置请参考:https://github.com/vllm-project/vllm-metal,更多命令行参数设置请参考:https://docs.vllm.ai/en/stable/api/。运行如下命令以 OpenAI 兼容的接口测试服务:

curl http://127.0.0.1:8000/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d '{
        "model": "<模型名称>",
        "messages": [
            {
                "role": "user",
                "content": "你好"
            }
        ]
    }'

AI 时代的生产力和生产关系

2026年3月29日 08:00

那只🦞

AI 的发展想必不用多说,速度之快远超我们的想象。将 AI 大事记和标普 500 1 整合绘制折线图如下:

AI 大事记 vs. 标普 500

从图中可以看出,自 2015 年 OpenAI 成立以来,随着标普 500 指数的不断增高,AI 也在以倍速迅猛发展。毋庸置疑在这个时代,如果你不了解 AI,不紧跟 AI,那么你可能会很快就会被社会淘汰。随着一股“龙虾热”的到来,更是把 AI 的关注度推到了风口浪尖。积极的去拥抱新的技术,亲自下场去体验,这都是很好的事,但也大可不必因为 FOMO(Fear of Missing Out,错失恐惧症)而太过盲从。即使你全身心 100% 的投入,在 AI 的浪潮中依然会有你错失的,停下来根据自身的实际情况多一些思考,才是争取不被拍死在沙滩上的最好选择。Anyway,一人开发者的周末项目,在短短不到 5 个月的时间内登顶 GitHub,这里面所预示的变革也绝不是我这些许思考所能妄言定论的,让我们且行且悟吧。

前不久在组织内部(大部分成员并非技术背景)讨论 AI 应用的时候(彼时公司已经提供了内部免费版的龙虾供大家试用),我抛出了如下两个问题:

  1. 有多少人尝试过私有化部署龙虾?
  2. 如果由你自己承担龙虾的费用,你是否还会像当前这样使用龙虾?

第一个问题要从前不久如火如荼的排队装龙虾开始说起,从安装龙虾,再到卸载龙虾,每个时代都有每个时代的鸡蛋要领。晚点有篇文章 2 不错,提到网上流传着的一个悖论:“如果你需要托人帮你安装龙虾,那么你可能就不太需要龙虾。”这句话多少有些偏激了,但意思就是那么个意思,如果连这部分技术都不了解,那么在使用龙虾的过程中一旦遇到一些复杂情况,你就会陷入“问虾你为什么不行”-“虾说因为 XXX”-“你说你自己不能解决码?”-“虾说 balabalabalabala”的死循环当中。或者换个角度,如果龙虾真的无论是从可用还是易用角度做到了极致,那么此时此刻是否还需要你这个人去用它呢?这时最重要的问题或许是我们这些碳基生物应该如何避免被硅基生物消灭吧?

第二个问题会比较实在些,天天在谈 ROI,公司出于更长远的战略考虑给到一定的免费额度,但如果需要实打实的花自己钱的时候你真的还会随便用吗?只能说当下很多人还没有被 AI 替代是因为 AI 的成本比我们这些牛马的工资还是要高的。我相信随着技术的不断发展,AI 的成本也会越来越低,此时我们更需要思考些更适合 AI 去做,哪些更适合我们去做,那些 AI 暂时还替代不了我们去做的才是我们的核心竞争力,再用 AI 把自己加持下才是最妙的。

生产力

生产力

生产力是改造和影响自然并使之适应社会需要的客观物质力量。一定的生产力决定了一定的生产关系,二者组成特定的生产方式。

AI 是一种先进的生产力,这个应该不太会有人否认吧。我个人总结其主要先进在两个方面:

  1. 效率。AI 在效率提升上面的效果是有目共睹的,无论你的工作类型是什么样的,哪怕你是体力劳动者。只能说对脑力工作者的提效会更多些,尤其是那些流程化、重复性的工作,以运营类工作尤为明显。对我而言更好的代码补全也着实提高了我的开发效率。
  2. 创新。对于一些复杂的不确定性工作,AI 可以给到我很多新奇的点子,启发我把复杂的任务成功解决。很难说这是真的“创新”,也许在 AI 的脑子中这并非新鲜事物,但之于我自己而言,确实给到我眼前一亮的惊喜。前不久 Anthropic 团队使用 16 个智能体从零开始构建了一个基于 Rust 的 C 语言编译器 3。我很惊讶这么底层的能力就这样被自动实现了(我并没有去体验,单纯的惊讶),但也有人质疑这只是“临摹”而非“创作”。毕竟 AI 吃过的盐比我吃过的饭都要多得多得多。因此无须太纠结这是不是真的创新,之于你是,之于你在做的事儿是,那就是。

在谈及 AI 作为一种先进的生产力时,它与瓦特的蒸汽机、爱迪生的电灯泡、亦或是宾夕法尼亚大学的埃尼阿克并无差异,本质上都是社会生产环节中的一个辅助工具。

生产关系

生产关系

生产关系是人们在社会生产中形成的一种一定的、必然的、不以他们的意志为转移的关系。生产关系基于一定生产力,反过来又会限制生产力。

根据定义来看,AI 确实“不是”生产关系,生产关系探讨的主要是“人”与“物”以及“人”与“人”的关系,例如:生产资料所有制,生产中的地位和相互关系,产品的分配方式等。但 AI 作为生产力却可以影响生产关系,我认为此时用“重塑”会更适合些,因为这波 AI 带来的冲击确实过于迅猛。

之前听过付鹏的几期演讲和相关的播客,他将套利 4 的核心归纳为价差、利差、汇差,而在这些之前,还会有信息差、认知差、执行差、竞争差、资源差等等。我个人不是很懂投资,买过的股票基金啥的大多也是赔的,听的这些投资内容更多是出于对经济和风险的进一步理解。AI 在帮助我们缩小信息差、认知差、执行差、竞争差、资源差这些方面比之前变得更有可能,尤其是对于我们这些屁民而言。试想一下,之前在一个未知领域遇到了一个问题,Google 一下,搜索引擎玩儿的溜的能多获取一些有用的信息,玩儿不转的就只能凭人品看看有没有靠谱的朋友帮你解惑一二了。如果你有钱那就另说了,此时你又营造了资源差,但 AI 在帮助大家提升认知上貌似是公平的,是容易的。那么当答案获取变得便宜之后,什么又变得昂贵了呢?勇气,执行力,还是?

前不久比较火的一个词应该是“一人公司”(OPC,One Person Company),一个由 AI 技术驱动产生的全新组织范式。如果把一人公司做到极致,貌似你只需要同你的 AI 进行直接交流,AI 可以帮你去对接客户,AI 可以帮你去做产品,AI 可以帮你去销售。此时“人”-“人”的关系就转变成了“人”-“AI”-“人”的关系,甚至是“人”-“AI”-“AI”-“人”的关系,谁知道你的 AI 对接的客户是不是也是一个 AI 呢?从内部视角来看也是一样,从养一只虾到养多只虾,从管多只虾到管一只管多只虾的虾。

  flowchart TD
    A[你] --> B(🦞#1)
  flowchart TD
    A[你] --> B(🦞#1)
    A[你] --> C(🦞#2)
    A[你] --> D(🦞#3)
  flowchart TD
    A[你] --> B(🦞#01)
    B --> C(🦞#11)
    B --> D(🦞#12)
    B --> E(🦞#13)

你时不时的 PUA 下你的虾,你的虾时不时的摸会儿鱼。作为基层管理者的监管虾忙着分配任务和验收结果,而作为大头兵的牛马虾快乐小狗般地研究是巧克力味儿的屎好还是屎味儿的巧克力好,最后把监管虾看得着急的不要不要的,还不敢和你真实汇报。等你下场去检查的时候,屎山已经高的望不到顶了。这不就是一个活脱脱的赛博职场吗?此时我只想说对你的虾好一点儿,交代任务时加个“请”字,可能比你说他笨更有用些,真的。

旧世界的神(各种差)力量正在减弱,新世界的神(AI)已然崛起 5。生产力的跃升总是先行的,随后才是生产关系的重构,当生产力的“安装期”接近尾声时, 生产关系的变革将成为主线 6,此时你的信仰又该何去何从?

技术平权

最后我想再谈一点儿技术平权,感觉“龙虾热”把技术平权又双叒叕一次拿到了台面上。有两个问题,我的观点分别如下:

  1. 技术平权好吗?我认为技术平权整体来看是好的,如果技术平权存在的话。技术平权可以让更先进的技术平等的惠及每一个人,整体上会极大的提高生产力。但这也会引发一定的焦虑,当技术稀缺时,焦虑只发生在少数人之间,当它开始民主化后,焦虑会迅速扩散,这在组织推进 AI 落地时需要格外关注。
  2. 技术平权存在吗?我认为技术平权不存在。上个世纪还在用打孔卡进行编程,高性能计算机的普及依旧没有让编程变成普惠技术。也许你会说技术进步的还不够,看看现在的 Vibe Coding,那我会说 Vibe Coding 真的不错,你真的用了吗?你用它写出来的东西应用在生产环境了吗?厉害的人在 Vibe Coding 中更上层楼,不行的人只是换了一种方式堆屎山。

在这里我并没有为我们这些所谓的“工程师”粉饰门面,也没有打消大家探索新世界积极性的意思。想表达的是在科技洪流中,我们更需要思辨,去伪存真,鸿沟永远存在,找到一条更好提升自己的路,平权不平权不重要,干得过别人才重要。周末读到一篇文章,在此分享出来:《谁在制造“龙虾热”?当我们穿过一场名为“技术平权”的幻梦》,观点因人而异,但希望可以缓解部分人的焦虑。

最后的最后

最后的最后,改编一下我比较喜欢的胡适先生的话「大胆假设,小心求证」,AI 时代的我们可以「积极拥抱(这是态度),审慎思考(这是行为)」。AI 作为生产力终究不能自发地改变现有的生产关系,仍需要人的主观能动性。如果未来有一天硅基生命崛起,彼时的人类又是否会变成如当下的 AI 一样,成为一种“工具”,只不过是一种作为被奴役的稀有的资源般存在的“工具”。我不想这天的到来,至少在我有生之年。

业余无线电入门

2025年10月25日 08:00

缘起

从军事领域中的通信,到末世题材影视中的通联,再到越野中的车队通话,都让我对「无线电」这项技术感到高级和炫酷。可能在更多人的眼中无线电或是《永不消逝的电波》中发报机,亦或是保安大哥手中的对讲机。当你深入去了解的时候会发现,这东西不会那么遥不可及,当然也没有想象中那么简单,差评君的一期视频可以带你更好的了解什么是无线电。

那么什么又是业余无线电?莫非我们只配玩儿些不专业的东西?这里所说的“业余”并非技术角度的专业和不专业,而是指业余业务。业余无线电可以简单的理解为提供给我们这些业余爱好者使用的无线电。业余无线电的英文为 Amateur Radio,业余无线电爱好者称为 Radio Amateur,世界上也普遍称之为 HAM,所以“火腿”也就成为了业余无线电爱好者的代名词。

考试

想要合法合规的进行业余无线电操作,首先需要通过业余无线电台操作技术能力验证,类似开车一样你需要先考一个驾照。业务无线电台操作技术能力验证有不同等级,A 类、B 类和 C 类的区别如下:

操作类型 适用范围
A 类 可以申请设置、使用工作在 30-3000MHz 频段且最大发射功率不大于 25 瓦的业余无线电台。
B 类 可以申请设置、使用工作在 30MHz 以下频段且最大发射功率小于 15 瓦,
或者工作在 30MHz 以上频段且最大发射功率不大于 25 瓦的业余无线电台。
C 类 可以申请设置、使用工作在 30MHz 以下频段且最大发射功率小于 1000 瓦,
或者工作在 30MHz 以上频段且最大发射功率不大于 25 瓦的业余无线电台。

申请人初次申请业余无线电台操作技术能力考核,须首先参加 A 类业余无线电台操作技术能力考试。取得 A 类操作证书六个月后可以申请参加 B 类操作技术能力考试。取得 B 类操作证书并且设置 B 类业余无线电台二年后,可以申请参加 C 类操作技术能力考试。

不同于考驾照,业务无线电台操作技术能力验证仅有理论考试,各地的考试报名方式不尽相同,更多的考试细节和题库可以参考业余无线电模拟考试平台。通过考试后,就可以收到业务无线电台操作技术能力验证证书了。

设备

拿到操作证书后,你就可以选择一台心怡的设备了,买完设备后你还需要去验机。这里最重要的一点就是你购买的设备型号必须有国家颁发的核准代码,没有核准代码的设备是无法通过验机的。

针对新手小白的对讲机选购可以参考下面这个视频,只能说没有最好的,从价格、性能、颜值等角度选择适合自己的就好。

我最终选择的是「如意通 6900 DMR」双模对讲机。之所以选择它是早些时间有了解到有 MMDVM 盒子这么一个东西,也想着入坑玩一下,所以就选择了一款有数字模式的对讲机。验机完毕后,就会收到一张中华人民共和国无线电台执照。

相比操作证书,无线电台执照简陋了不少,就是一张纸,所以可以去淘宝买一个保护套装上。此时你就得到了正式的呼号,现在就可以打开对讲机,开始你的第一次联通了:

CQ, CQ, CQ, this is BD1CZP. BRAVO, DELTA, ONE, CHARLIE, ZULU, PAPA. BRAVO, DELTA, ONE, CHARLIE, ZULU, PAPA. Calling CQ and standing by.

CQ,CQ,CQ,这里是 BD1CZP。BRAVO,DELTA,ONE,CHARLIE,ZULU,PAPA。BRAVO,DELTA,ONE,CHARLIE,ZULU,PAPA。呼叫 CQ 并等待回答。

我的呼号是 BD1CZP。翻阅了下资料,我国的呼号由 4 部分构成:

  1. 第一位的 B 代表中国。
  2. 第二位表示电台种类,A、D、G、H、I 代表业余电台,J 代表业余信标台和卫星业余电台,R 代表业余中继台。
  3. 第三位表示地区:
    • 1:北京、卫星业余业务
    • 2:黑龙江、辽宁、吉林
    • 3: 天津、河北、内蒙古、山西
    • 4: 上海、山东、江苏
    • 5: 浙江、江西、福建
    • 6: 安徽、河南、湖北
    • 7: 湖南、广东、广西、海南
    • 8: 四川、重庆、贵州、云南
    • 9: 陕西、甘肃、宁夏、青海
    • 0:新疆、西藏
  4. 后续几位则为分配的后缀。

报完呼号后为啥又来了 BRAVO, DELTA, ONE, CHARLIE, ZULU, PAPA 这么一段?这是因为为了避免在信号传播过程受到干扰导致失真或衰减,使得对一些字母的听辨产生困难,在通信中会使用字母解释法复述呼号。

字母解释法
字母解释法

除了标准解释以外,有时候也会使用其他单词替代:

字母 标准解释 其他解释
A ALFA AMERICA
B BRAVO BOSTON
C CHARLIE CANADA
D DELTA DENMARK
E ECHO ENGLAND
F FOXTROT FLORIDA
G GOLF GERMANY
H HOTEL HONOLULU
I INDIA ITALY
J JULIETT JAPAN
K KILO KILOWATT
L LIMA LONDON
M MIKE MEXICO
N NOVEMBER NORWAY
O OSCAR ONTARIO
P PAPA PETER
Q QUEBEC QUEEN
R ROMEO RADIO
S SIERRA SUGAR
T TANGO TOKYO
U UNIFORM UNITED
V VICTOR VIRGINIA
W WHISKEY WASHINGTON
X X-RAY
Y YANKEE YOKOHAMA
Z ZULU ZANZIBAR

QSL

QSL 在通信用的 Q 简语中是“收妥了”“给收据”的意思。QSL 卡片是业余电台特有的一种确认联络或收听的凭证,每个电台都应该有自己的 QSL 卡片。QSL 卡片一般长为 14-15 厘米,宽为 9-10 厘米,除了必须印有醒目的本台呼号外,还应该包含如下内容:

  1. 接收方的呼号。
  2. 确认是通联还是收听。
  3. 联络的日期和时间。
  4. 联络时所用的频率。
  5. 联络时所用的模式。
  6. 对方的信号情况。
  7. 设备情况:型号、功率、天线等。
  8. 操作者签名。

设计了一个自己的 QSL 卡片,如有需要源文件可单击下载:正面 & 反面

QSL 卡片正面
QSL 卡片正面
QSL 卡片反面
QSL 卡片反面

重构

2025年10月19日 08:00

十一之前看了原子能 UP 主的一期视频,最开始只是有感于当前工作中的代码开发,后来又有感于工作和生活,想着记录下来,又担心酸腐味太重就没写。十一回来,玩儿了两天,出了趟差,愈发感觉还是应该记录下,待回头看时至少可供自嘲。

之于代码

视频中讲到了一个“鸿沟理论”,高科技产品在市场营销过程中遭遇的最大障碍就是早期市场和主流市场之间存在的鸿沟,能否顺利的跨越鸿沟进入主流市场成功赢得实用主义者的支持决定了一个产品的成败。还有个“巴斯扩散模型”和这个理论很类似,原来还基于它做过一些预测模型。

鸿沟理论
鸿沟理论

早期市场中的产品就是我们通常说的 MVP。MVP 最重要的是速度,需要快速在市场中得到验证。所以从风险和收益的角度出发,在 MVP 开发的时候大家会快速地实现产品功能,一些细节(例如代码质量)往往就没那么重要。这一点我很认同,但是这里有个前提是面向市场的产品,在我的工作中,会有不少“就是要做”的事情,一定程度上是“刚需”,做得好做的差都得用。那这个时候大家又会怎么选择呢?我观察到的情况是大部分人还是会选择快速上线,因为要“拿结果”,虽没有市场去评价你,但你的老板会去评价你。

视频中给到的结论是:任何时候都不适合重构。经过早期市场的验证,当多个玩家都在拼命渡河赢得主流市场时,如果你慢了就很容易被赢家通吃,所以此时此刻你仍需要同时间赛跑。当终于在主流市场站稳脚跟后是不是真的就可以重构了,答案是依旧不行,因为这个时候已经积累了足够的技术债,重构的成本已经远远高于 MVP 时期。此时在风险和收益之间的人们往往不会为了一个收益不明朗的价值回报去承担一个不小的成本。尤其是在当前的环境下,没有铁饭碗的人不会想着为别人做嫁衣,有铁饭碗的人不会没事儿给自己找麻烦。

我给到的观点是:任何时候都适合重构,而且我认为这并不偏激。重构不一定代表需要大刀阔斧,有些时候你的改动甚至可以其他人都无感,只要对你的后续开发和维护有益,你认同这一点,就有动力去做。如果你没打算在团队长待,或者你就想当个甩手掌柜,亦或是你并非正编,那权当我没说(这三种情况我认为存在且普遍)。当真的需要启动治理需要推翻重来时,我们就需要现实些,此时可能需要更量化的风险披露,更好的上价值,更好的汇报,拉更多的人下水,事情才更容易如你所愿。

重构是有意义的,好一句废话,但我还是会时不时的想一想,不想连去做的勇气都不再会有。做多少,何时做,如何做,这里面的“度”每个人的想法就千差万别了,别因为此太过影响自己的绩效,那就尝试做一些,如果你超会上价值,也全当我多虑了。还有一点我认为需要多践行工程师文化,多一些思考,多一些规范,多 Review 一下自己的代码,这些不会太影响和时间赛跑的。我不图未来接手我代码的人夸我写的漂亮,只求不会骂我的代码是屎山。

之于工作

之前的博客中有聊到自己的第二次职业焦虑,一年过去了,焦虑缓解了,但新的问题或者说想法变多了。谈及自己我都会说我是一个风险厌恶的人,这点到现在也没有太变(所以说改变一个人的一些想法真的挺难的),但我对风险有了新的认知。一次出差飞机上三个小时听了关于塔勒布的一个播客,还没来得及更深入的去了解,但让我记住了一个词“勇气”。对于工作而言,重构往小了说可以是内部职责的一次调整,往大了说可以是寻求一个新的外部机会,我能找出促使我要去这么做的千万个理由,但确没有太多去这么做的“勇气”。

我会更在意失去的,好比上面说的,风险是确定的(失去的),但收益是不明朗的(确定不会是一个更深的坑)。相比一年前,我最大的改变在于动起来了,确实缓解焦虑最好的方式就是行动起来。我们需要更加深入地剖析要不要做出变动,但不应持续地陷入剖析而不自拔。动起来,担心失去就先“小”动起来,不用让人看见(让人看见可能对你也没好处),自我发起的改变也不应是一蹴而就的,多些准备会让你少些担忧。

这段时间刷到短视频上的一句话:听说过上班累死的,没听说过不上班饿死的。我希望可以更加阳光的理解这句话,拼搏奋斗仍应是我们要去追求的,只是说哪怕做错了什么,你又能失去多少呢,相信只要能远离黄赌毒,就不至于把自己玩儿死。

之于生活

最近一年的感觉自己有些腐朽味,四年前的我还能一个人去摩旅,今年的我连近郊都没怎么去,貌似生活也进入了急需重构的阶段。但还不如工作,都还谈不上有没有动力去改变,连想要的生活的样子似乎都有些模糊了。最近看到豆瓣上也可以听播客了,之前都是在苹果自带的 APP 上听(简洁又没广告),但豆瓣推给我的一个播单甚得我意,有点豆瓣书籍和影视年度榜单的味道。随即发了个朋友圈分享了下,一个朋友回复到“这岂不是会天天蹿到你出去玩”,我回复到“出去玩就不听了,出不去才只能听听人家的过过瘾”。这一刻,我有在想是没了出去的勇气,还是连出去的想法都没了?

昨晚在短视频上刷到一个在澳大利亚的华人,记录了一些和当地人的生活日常,去过一次澳大利亚,那种地广人稀的氛围自己还是比较喜欢的。无论是播客还是短视频,难道真的是娱乐至死,或许可以从减少些刷短视频开始,立马走出去不现实,但把这两年没怎么读的书补一补还是可以试一试的。

前几天和部门里面年轻的同事聊到大学的专业(怎么就聊到这想不起来了,上了年纪确实记性不好),还能记得管理学老师的一句话:读万卷书不如行万里路,行万里路不如交万名友,交万名友不如名师之路。名师指路可遇不可求,庆幸求学期和工作期间能遇到几位带我的好师傅,行万里路真的可以体验更多(人生百态皆在其中),不过经济性不如读书。真的有好久没有好好的读几本书了,之前可以挑灯夜读,很想搞清楚为什么现在不行了,想了想也没太想明白,也许没那么重要搞清楚,有这时间去读更好些。那天我还说他们这些年轻人正值好时候(回头想想这话有点儿爹味儿),自己已经有些老了,感觉在重构生活的路上,借口多了太多,虽然每个人的情况各不相同,所以到底在害怕失去什么呢?这个答案就留在我完成重构那天再补上吧,希望有这一天,希望这一天不要太晚。

提升图片分辨率和质量

2025年7月5日 08:00

本节将介绍如何将一张低分辨率的图片转换成一张高分辨率高质量的图片。

为了对比提升分辨率后的图片质量,我们先下载一张原始的高清图片 1,通过 ImageMagick 命令将其转换为一个低分辨率低质量的图片,未来基于这张转换后的图片进行分辨率和质量的提升,再与原始图片进行对比验证提升的效果。

magick toucan-raw.jpg -resize 512x -quality 10 toucan-low-res.jpg
原始图片
原始图片
低分辨率图片
低分辨率图片

基础操作

单击左侧 按钮进入 Upscaling 界面,将上面生成的低分辨率图片拖入 Assets 中,之后再拖入到 Upscale 面板的图片区域。

基础操作
基础操作

安装的 SDXL 模型包中包含一个 SwinIR 分辨率提升模型。选择该模型并添加如下的正向和负向提示词:

正向提示词
a vibrant bird, detailed, a high contrast, inviting warmth, sunlit elements, dynamic composition, 35mm lens, 1/2.8, environmental context, detailed har photography
负向提示词
blurry, out of focus, over saturated, text+++

CreativityStructure 保持默认值 0,单击 按钮生成图片。生成完毕后在 Assets 中的低分辨图片上右键,单击 按钮选择进行对比,单击 Images 选项卡回到新生成的图片页面查看对比效果。

对比图片
对比图片

从如下的对比中可以看出,生成的图片具有更多的细节,这样我们就可以在更高的分辨率下获得更加清晰锐利的图像。

单击画布上方的 Exit Compare 退出对比,在生成的图片上右键,单击 按钮可以在新窗口中打开图片,之后则可以使用鼠标进行放大来观察图片细节。

进阶操作

除了基础的分辨率提升以外,还有一些高级选项可以用来控制分辨率提升后的图片与原始图片的相似程度以及在提升分辨率过程中的创意性。Creativity 参数用于设置提示词控制图像生成的创意性,从而控制与原始图像的差异程度,值越大表示越具有创意性。Structure 参数用于确保分辨率提升过程中图像中的元素与原始图像中的元素的形状和位置匹配,值越大表示越会严格遵守原始图片的结构。

Upscale
Upscale

同时也可以调整生成的模型及其相关参数,例如:SchedulerCFG Scale 等。

风格调整

如果在提升分辨率的过程中还希望调整图片的风格,你可以尝试使用不同的提示词,例如下面是一个绘画风格的提示词:

a painting of a vibrant bird, oil painting, deep impasto, glazed brushstrokes

我们适当增加 Creativity 的值到 8,减少 Structure 的值到 -4,这样可以给到模型更多的自由度重新构思一些细节。最后我们来综合对比原始图片、低分辨率图片、提升分辨率后的图片以及调整风格的图片:

至此,我们 Invoke AI 101 系列的 6 节课程就全部结束了,希望大家能够通过这 6 节课程对 Invoke AI 的功能有一个基础的认知。也期待大家能够进一步探索 Invoke AI 的高级功能,去创造更多自己喜欢的图片。

原始图片
原始图片
低分辨率图片
低分辨率图片
提升分辨率图片
提升分辨率图片
提升分辨率图片(风格调整)
提升分辨率图片(风格调整)

使用画布创建和组合生成新的图片

2025年6月28日 08:00

本节将介绍在使用画布进行创建和组合生成新的图片的过程中使用到的核心工具。

首先,我们使用 Juggernaut XL v9 模型和如下提示词生成一张基础图片留作备用:

基础图片
基础图片

提示词模板

Environment Art

正向提示词

futuristic terraced structure built into a mountain at dusk, twilight hues, lush greenery illuminated by soft glowing lights, multiple levels, pathways, vast mountain range, distant winding roads, glowing city lights below, towering otherworldly rock formations

为了保证可复现性,在生成这张图片时可以将随机数种子 Seed 固定,此处设置为 42

边界框

将生成的图片拖入画布并创建一个新的 Raster Layer。在画布上单击 Bbox 按钮,此时图片的周围将显示一个边界框,使用鼠标按住可以拖动边界框的位置,放在边界框的四角可以调整边界框的大小。

将边界框移动到画布的一个完全空白的区域,此时边界框中没有任何 Raster Layer 的内容,单击 会生成一张新的图片。

生成前
生成前
生成后
生成后

将边界框移动到画布的一个包含部分 Raster Layer 内容的区域,单击 会将空白的部分补全,通常称之为 Out Painting 或 Infilling。

生成前
生成前
生成后
生成后

如果边界框和 Raster Layer 完全重合,单击 将会基于当前 Raster Layer 中的内容重新生成新的图片,通常称之为图像到图像(Image2Image)。

修复蒙版

修复蒙版(Inpaint Mask)用于控制在边界框中哪些区域会被修改。在图层中单击 + 新建一个 Inpaint Mask 图层。单击画布上的 按钮后,则可以在画布上绘制所需要修改的区域。

修复蒙版
修复蒙版

此时可以将边界框进行缩放并移动到关注的指定区域。从左侧的 Image 面板中可以看到边界框的大小为 320x320

边界框缩放
边界框缩放

但在图片生成过程中,仍然会以 1024x1024 分辨率进行生成,再通过缩放填充到边界框中。由于先生成了分辨率更高的图片,再进行的缩放,此时生成的部分可以具有更多的细节。这使得我们可以在不牺牲图片质量的前提下,对于图片的复杂区域进行优化。

修复蒙版可以让我们很方便的在图片中添加、移除和改变元素。例如,我们希望在楼梯台阶上添加两个人,可以按照如下步骤进行操作:

  1. 将边界框调整到一个合适的大小和位置。
  2. 创建一个 Inpaint Mask 图层,使用画笔勾勒出需要修改的区域。
  3. 返回 Raster Layer,选择一个颜色,使用画笔勾勒出两个人的大概位置。
  4. 在提示词的前面添加 two people
  5. 选择一个合适的 Denoising Strength,此处设置为 0.7,单击 按钮启动生成。
生成前
生成前
生成后
生成后

可以看出,在修复蒙版区域内,根据 Raster Layer 的修改和提示词的修改成功的在台阶上添加了两个人。

探索 AI 模型和概念适配器

2025年6月20日 08:00

本节将介绍图片生成使用到的 AI 模型和概念适配器,我们将讨论提示词和模型的训练方式是如何最终决定提示词对生成图片的有效性。

在图片生成过程中你需要认识到提示词并非在任何时候都是有效的。相同的提示词在一个模型上可以获得很好的生成效果,但在另一个模型上可能无法生成所需的图片。这是由于模型训练所使用的图片和图片的标注文本不同所导致的,因此模型的提示词指南会格外重要。这也是为什么训练自己的模型会更好,因为你完全清楚在训练模型时所使用的标注文本。

在本节教程中我们不会讨论如何训练模型,我们将重点介绍不同模型之间的差异,展示它们如何影响图片生成的过程。除此之外我们还会讨论概念适配器(通常也称为 LoRA),来帮助你更好的理解相关工具。

模型比较

进入模型管理页面,在添加模型选项卡处选择 HuggingFace,输入 cagliostrolab/animagine-xl-4.0 下载 Animagine XL 4.0 模型。Animagine XL 模型与其他的通用模型有很大的不同,Animagine XL 模型是一个动漫主题的 SDXL 微调模型。Animagine XL 4.0 基于 Stable Diffusion XL 1.0 进行重新训练所得,它使用了 840 万张不同来源不同动漫风格的图片进行微调。

Animagine XL 模型使用了一套自有的数据标注方法进行训练,提示词指南如下图所示:

Animagine 提示词指南
Animagine 提示词指南

整个提示词包含 6 个部分:

  1. 性别。例如:1girl/1boy/1other
  2. 角色。例如:remilia scarlet
  3. 来源作品。例如:touhou
  4. 分级。例如:safe, sensitive, nsfw, explicit
  5. 一般标签。
  6. 质量标签。例如:masterpiece, high score, great score, absurdres

建议的负向提示词如下:

lowres, bad anatomy, bad hands, text, error, missing finger, extra digits, fewer digits, cropped, worst quality, low quality, low score, bad score, average score, signature, watermark, username, blurry

Animagine XL 模型支持一些特殊的标签用于控制图片生成。质量标签直接影响生成图像的整体质量和细节水平。可用的质量标签有:masterpiece,best quality,low quality,worst quality

masterpiece, best quality
masterpiece, best quality
low quality, worst quality
low quality, worst quality

与质量标签相比,分数标签可以对图像质量进行更细致的控制。在 Animagine XL 模型中,它们对输出质量的影响更大。可用的分数标签有:high score,great score,good score,average score,bad score,low score

high score, great score
high score, great score
bad score, low score
bad score, low score

时间标签允许根据特定时间段或年份来控制生成图片的艺术风格。这对于生成具有特定时代艺术特征的图像非常有用。支持的年份标签有:year 2005,year {n},year 2025

year 2007
year 2007
year 2023
year 2023

利用如下正向和负向提示词分别使用 Animagine XL 4.0 模型和 Juggernaut XL v9 模型生成图片。

正向提示词
1boy, black hoodie, white spiky punk hair, nose piercing, standing against a brick wall, masterpiece, best quality, high score, great score
负向提示词
lowres, bad anatomy, bad hands, text, error, missing finger, extra digits, fewer digits, cropped, worst quality, low quality, low score, bad score, average score, signature, watermark, username, blurry

同时,为了比较提示词在不同模型中对生成图片的影响,再利用如下正向和负向提示词分别使用 Animagine XL 4.0 模型和 Juggernaut XL v9 模型生成图片。

正向提示词
1boy, black hoodie, white spiky punk hair, nose piercing, standing against a brick wall
负向提示词
lowres, bad anatomy, bad hands, text, error, missing finger, extra digits, fewer digits, cropped, signature, watermark, username, blurry

为了确保可复现,手动将随机数种子固定设置为 42,生成的图片如下:

Animagine XL 4.0
Animagine XL 4.0
Juggernaut XL v9
Juggernaut XL v9
Animagine XL 4.0 (删除部分提示词)
Animagine XL 4.0 (删除部分提示词)
Juggernaut XL v9 (删除部分提示词)
Juggernaut XL v9 (删除部分提示词)

不难看出,从正向和负向提示词中删除关于图片质量的关键词后,Animagine XL 4.0 模型生成的图片有显著的画质降低,而 Juggernaut XL v9 模型生成的图片质量变化并不大。实验说明提示词在不同的模型中效果存在差异,你必须了解模型的训练过程才能更好的使用提示词生成所需的图片。

概念适配器

上述问题就导致了概念适配器的诞生,通过自己训练概念适配器,你可以完全掌握对于模型的修改。进入模型管理页面,在添加模型选项卡处选择 HuggingFace,输入 nerijs/pixel-art-xl 下载 Pixel Art XL 概念适配器。利用如下正向和负向提示词分别使用 Animagine XL 4.0 模型和 Juggernaut XL v9 模型生成图片。

正向提示词
1boy, black hoodie, white spiky punk hair, nose piercing, standing against a brick wall, masterpiece, best quality, high score, great score, pixel art style
负向提示词
lowres, bad anatomy, bad hands, text, error, missing finger, extra digits, fewer digits, cropped, worst quality, low quality, low score, bad score, average score, signature, watermark, username, blurry

Generation 中添加概念适配器 pixel-art-xl 并启用:

概念适配器
概念适配器

Pixel Art XL 概念适配器用于生成像素风格的图片,生成的图片如下:

Animagine XL 4.0 (Pixel Art XL)
Animagine XL 4.0 (Pixel Art XL)
Juggernaut XL v9 (Pixel Art XL)
Juggernaut XL v9 (Pixel Art XL)

需要注意,LoRA 这类概念适配器与用于训练的原始模型之间存在关联。概念适配器可以理解为一个模型的上层封装,其扩展和增强了某些概念。将其放在另一个模型上,其仍可以将这个概念应用到新的模型上,但质量可能会有所下降。这是因为训练概念适配器的底层模型与当前应用的模型可能有着不同的架构和假设。也就是说 LoRA 这类模型并不是一个完全独立的模型,而是一个专门为某个基础模型构建的适配器,但如果两个模型本质上非常相似,则 LoRA 具有一定的可移植性。

理解图像到图像和降噪过程

2025年6月14日 08:00

本节将介绍「 图像到图像」和「降噪」两个重要概念,帮助大家更好的理解 Invoke 中的画布是如何工作的,或者说生成式 AI 图片生成是如何工作的。

在之前的图像生成示例中,单击 按钮后,整个图像生成过程会从一张静态噪声图像开始,模型会将噪声逐步转化为最终图片,整个过程如下图所示:

将噪声图像转化成最终图片的过程称之为降噪(Denoising)。

将示例图片拖拽至画布上,选择 New Raster Layer,示例图片如下 1

示例图片
示例图片

在图层中,可以找到 Denosing Strength 参数:

Denoising Strength
Denoising Strength

Denosing Strength 用于控制初始图片或 Raster Layer 在降噪过程中影响最终输出图片的程度。设置较高的值会使降噪过程从一个具有更多噪声数据的图片开始,此时模型会具有更高的自由度根据提示词生成新的内容。

图像到图像和文本到图像的最主要区别在于图像生成的起点。文本到图像时从纯噪声开始,并根据提示词逐步细化。图像到图像则会根据 Denosing Strength 跳过前面的一些步骤,使用提供的图像作为起点。

a porcelain teacup on a table 作为提示词,选择 Photography (General) 作为提示词模板,分别将 Denosing Strength 设置为 0.2 和 0.8,生成的图片和原始图片对比如下:

原始图片
原始图片
低 Denosing Strength
低 Denosing Strength
高 Denosing Strength
高 Denosing Strength

可以看出,较高 Denosing Strength 值可以让模型具有更高的自由度生成图片,而设置较低的 Denosing Strength 值时生成的图片仅有少量的变化。除此之外,在控制层和参考图片上也可以设置 Denosing Strength 来取得不同的效果。

使用控制层和指示控制图片的生成

2025年6月7日 08:00

本节将介绍如何使用控制层和指示来精确控制图片的生成。

控制网络

控制层通常用作控制网络(ControlNet)在图片生成中以线或结构的形式来提供参考。在 SDXL 中,常用的预训练控制网络有 1

Contour Detection (scribble)

Scribble 模型可以根据输入的图片生成线条画,处理后的图片作为原始图片的简化版本可以精准的捕捉图片的轮廓。

输入
输入
分析
分析
生成
生成

Depth Map

Depth Map 可以生成图片的深度图,在图片生成中模拟深度效果,从而创建更加逼真的 3D 图片。

输入
输入
分析
分析
生成
生成

Hard Edge Detection (canny)

Canny 边缘检测的原理是通过寻找强度突变来识别图片中的边缘。它能够准确的检测边缘并减少噪声和伪边缘,通过降低阈值可以识别更多的信息。采用 Canny 模型时,将会生成与检测到的边缘匹配的图片。

输入
输入
分析
分析
生成
生成

Pose Detection (openpose)

Openpose 模型可以检测身体、手部、面部等多个部位的关键点,在生成图片时可以控制人体的姿态。

输入
输入
分析
分析
生成
生成

Soft Edge Detection (softedge)

Softedge 模型与 Scribble 模型类似,处理后的图片作为原始图片的简化版本仅保留形状的柔和边缘和一些浅阴影。

Tile

Tile 模型的主要功能可以归结为如下两点:

  1. 可以重新解读图片中的特定细节,并创建全新的元素。
  2. 当全局指令与图片的局部或特定部分存在差异时,可以忽略这些指令。在这种情况下,它可以使用局部上下文指示图片生成。

Tile 模型可以作为增强图片质量和细节的工具。如果在图片中存在一些不足的地方,例如调整图片大小导致的模糊,Tile 模型可以有效的消除此类问题从而获得更加清晰锐利的图片。此外 Tile 模型还可以添加更多细节,从而提升图片的整体质量。

控制层

在图层中单击 + Control Layer 添加控制层,或者在图片库中将图片拖拽至画布上,选择 New Control Layer

创建控制层
创建控制层

示例图片及其生成的提示词如下:

示例图片
示例图片

正向提示词

futuristic terraced structure built into a mountain at dusk, twilight hues, lush greenery illuminated by soft glowing lights, multiple levels, pathways, vast mountain range, distant winding roads, glowing city lights below, towering otherworldly rock formations, dreamy sky with soft clouds

负向提示词

painting, digital art, sketch, blurry
控制层
控制层

本教程以 Hard Edge Detection (canny) 模型为例,单击 Filter 中的 Advanced 可以进行更多的调整:

Canny Filter - Default
Canny Filter - Default
Canny Filter - Advanced
Canny Filter - Advanced

调整完毕各项参数后,单击 Apply 即可生成控制图片。

控制图片
控制图片

控制图片提供的线条可以为图片生成提供参考。在画布中,可以使用 等工具对控制图片进行添加或删除等微调操作。接下来对控制层进行设置:

  1. Weight:用于设置控制图层在图片生成过程中的权重,值越大则图片生成时越会严格遵守控制线条。
  2. Begin/End %:用于设置在图片生成过程中在何时使用控制图层,将其设置为 0% 至 100% 表示从图片生成开始至结束一直使用控制图层。调整开始和结束的百分比可以给到模型更多的灵活性,从而获得更好的生成图片。
  3. Control Mode:包含多种控制模式用于调节控制层的相关性(CFG scale):
    • Balanced:提示词和控制层同等重要。
    • Prompt:提示词更加重要。
    • Control:控制层更加重要。

不同的 Control Mode 的生成示例如下 2

输入
输入
Balanced
Balanced
Prompt
Prompt
Control
Control

单击 开始生成图片。此时可以看到模型在控制线条的指示下开始生成图片。

生成图片
生成图片

由于将结束百分比设置为了 70%,生成的图片也具有一定的灵活创意。单击 会将生成的图片添加到 Raster Layer。单击 可以将画布保存到图片库中。新生成的图片和用于生成控制层的图片对比如下:

新生成的图片
新生成的图片
生成控制层的图片
生成控制层的图片

参考图片

接下来我们将添加一个参考图片,也称为 IP Adapter。模型会根据参考图片的风格和结构来生成图片。首先将参考图片添加到图片库,本教程中使用的图片如下 3

参考图片
参考图片

将图片拖拽至左侧 Reference Image 上。

创建参考图片
创建参考图片

在左侧将会创建一个 Reference Image,并可以对其进行相关设置。

Global Reference Image
Global Reference Image
  1. 模型:在安装 SDXL 相关模型的情况下,选择 Standard Reference (IP Adapter ViT-H) 模型,这里可以选择 ViT-HViT-GViT-L 多种变种。
  2. ModeStyle and Composition 表示参考样式和结构,Style 表示仅参考样式,Composition 表示仅参考结构。
  3. Weight:类比控制层。
  4. Begin/End %:类比控制层。

在本教程中,将参考图片的 Weight 设置为 0.7,将结束百分比设置为 70%,从而给到模型更多的自由度。删除之前生成的 Raster Layer,单击 开始生成图片。应用参考图片生成的图片和之前生成的图片对比如下:

应用参考图片生成的图片
应用参考图片生成的图片
之前生成的图片
之前生成的图片

可以看出新生成的图片在遵循控制层的前提下同时应用了参考图片的白色风格。

局部指示

在新生成的图片中,可以看到左侧背景中的山峰也被应用了参考图片中的建筑风格。为了实现更精确的控制,可以选择局部参考图片,将图片拖拽至画布上,选择 New Regional Reference Image

创建局部参考图片
创建局部参考图片

在画布上选择 ,设置合适的笔刷宽度,在画布上勾勒出需要应用参考的区域,这里我们仅勾勒出建筑所在的区域。

勾勒需要应用参考的区域
勾勒需要应用参考的区域

单击 开始生成图片。应用局部参考图片生成的图片和应用参考图片生成的图片对比如下:

应用局部参考图片生成的图片
应用局部参考图片生成的图片
应用参考图片生成的图片
应用参考图片生成的图片

可以看出新生成的图片仅在勾勒的区域内与参考图片的白色风格保持了一致,背后的山峰仍根据提示词生成相应的风格。

基于文本的指示

除了基于图片的指示以外,还可以创建基于文本的局部指示。在图层中单击 +,选择 Reginal Guidance 创建局部指示。在画布上选择 ,设置合适的笔刷宽度,在画布上勾勒出需要应用参考的区域,这里我们勾勒出建筑后面的一座山峰。在 Reginal Guidance 中选择 + Prompt 并添加 lush greenery 提示词。

创建局部文本指示
创建局部文本指示

单击 开始生成图片。应用局部基于文本的指示的图片和应用局部参考图片生成的图片对比如下:

应用局部基于文本的指示的图片
应用局部基于文本的指示的图片
应用局部参考图片生成的图片
应用局部参考图片生成的图片

可以看出新生成的图片在勾勒的山峰区域增加了郁郁葱葱的树木。

在 OpenWrt 和群晖中自动申请和部署证书

2025年5月25日 08:00

为了在本地局域网环境中摆脱 IP 用上域名(纯属闲来无事瞎鼓捣),购入了 leovan.dev 域名。想着把各种服务都映射到不同的二级域名上,这样就可以不用 IP 和端口了,岂不完美。然,问题这就来了。

acme.sh

域名是在 Cloudflare 上申请的,在 Cloudflare 上使用 Page 服务部署网站就可以白嫖他家的证书,还能自动帮你续期,比如当前的站点就是使用 Page 进行部署的。但你要是想生成证书下载下来使用,就会很麻烦,因为证书的有效期只有三个月,手动续期再加上各种替换操作就不太方便了。

这时候就要请出我们的 acme.sh 了,除了支持各种桌面和服务器操作系统外,还支持 OpenWrt 路由器系统。

Cloudflare

使用 acme.sh 申请免费证书需要使用 DNS 验证对域名的所有权,本文以 Cloudflare 为例,其它 DNS 请参考官方文档。Cloudflare 支持两种方式,一种是使用 API Token,另一种是使用全局 API Key,这里我们以 API Token 为例。

进入 API Token 页面,单击 按钮,在 API 令牌模板中选择 编辑区域 DNS,单击 按钮。在 权限 中添加 区域 - DNS - 编辑区域 - 区域 - 读取 的权限,在 区域资源 中根据你的需求选择对应的 特定区域,例如 leovan.dev,或为了省事选择 所有区域 也可以,如下图所示:

创建 API Token
创建 API Token

创建完毕后会生成 Token,将 Token 保存为 CF_Token="xxxxxxxxx",注意该 Token 在 Cloudflare 中不会再展示。

之后从 Cloudflare 账户主页进入对应的域名详情页面,在右下角可以找到 API 的区域 ID 和账户 ID 两个代码,如下图所示:

区域 ID 和账户 ID
区域 ID 和账户 ID

将区域 ID 保存为 CF_Zone_ID="xxxxxxxxx",将账户 ID 保存为 CF_Account_ID="xxxxxxxxx"

OpenWrt

Opwnert 使用 uHTTPd 作为默认的 Web 服务器。正如官网上说的,这是一个轻量极了的 Web 服务器,以至于不支持反向代理。

警告

那就安装一个 Nginx 吧,不是说不行,只是 Nginx 和 uHTTPd 存在冲突,你需要把路由器的 LuCI 也切换到 Nginx 上,麻烦不说,后续如果有更新还有可能又会变回 uHTTPd。自己搞了下,差点登录不进去 Web 页面,遂放弃。

但这不影响我们先把路由器的域名 router.leovan.dev 映射到 192.168.100.1 上先用起来。

acme.sh

通过 系统 - 软件包 或命令行安装 acme.sh 相关软件包:

opkg install acme acme-acmesh-dnsapi luci-app-acme luci-i18n-acme-zh-cn luci-ssl-openssl

安装完毕后可以在 服务 菜单下找到 ACME 证书 子菜单,进入后在 ACME 全局配置 中输入 电子邮件帐户,勾选 启用调试日志记录,如下图所示:

ACME 证书 - ACME 全局配置
ACME 证书 - ACME 全局配置

证书配置 中删除默认的配置,在下方输入框中输入配置名称,例如 leovan_dev,如下图所示:

ACME 证书 - 证书配置
ACME 证书 - 证书配置

单击 按钮打开配置对话框。在 常规设置 中勾选 已启用,输入所需的域名,选择验证方式为 DNS,其它保持默认,如下图所示:

ACME 证书 - 证书配置 - 常规设置
ACME 证书 - 证书配置 - 常规设置

DNS 质询验证 中选择对应的 DNS API(本文使用 CloudFlare.com),并将上文中的 CF_Token="xxxxxxxxx"CF_Zone_ID="xxxxxxxxx"CF_Account_ID="xxxxxxxxx" 填写到对应的位置,其它保持默认,如下图所示:

ACME 证书 - 证书配置 - DNS 质询验证
ACME 证书 - 证书配置 - DNS 质询验证

高级设置 中根据自己的需求选择 密钥长度(本文使用 ECC 256 位),其它保持默认,如下图所示:

ACME 证书 - 证书配置 - 高级设置
ACME 证书 - 证书配置 - 高级设置

单击 按钮,并在 ACME 证书 页面单击 按钮。

稍等片刻后,如果运行正常则可以在 证书 中看到对应域名的证书,如下图所示:

ACME 证书 - 证书
ACME 证书 - 证书

同时,系统会启动自动续签,在 系统 - 计划任务 中可以看到添加了如下一条记录:

0 0 * * * /etc/init.d/acme renew

uHTTPd

通过 系统 - 软件包 或命令行安装 uHTTPd 的管理界面:

opkg install luci-app-uhttpd luci-i18n-uhttpd-zh-cn

安装完毕后可以在 服务 菜单下找到 uHTTPd 子菜单,进入后在 MAIN - 常规设置 中添加 HTTPS 监听,如下图所示:

uHTTPd - HTTPS 监听
uHTTPd - HTTPS 监听

HTTPS 证书 中选择上文中生成的证书(本文为 /etc/ssl/acme/leovan.dev.fullchain.crt),如下图所示:

uHTTPd - HTTPS 证书
uHTTPd - HTTPS 证书

HTTPS 私钥 中选择上文中生成的私钥(本文为 /etc/ssl/acme/leovan.dev.key),如下图所示:

uHTTPd - HTTPS 证书
uHTTPd - HTTPS 证书

uHTTPd 页面单击 按钮。

在 Cloudflare 中将 router.leovan.dev 解析到 192.168.100.1 上后,分别通过 http://192.168.100.1https://192.168.100.1https://router.leovan.dev 访问路由器,如下图所示:

路由器 - IP 和 域名访问
路由器 - IP 和 域名访问

可以看出通过 router.leovan.dev 域名进行访问已经实现了 HTTPS 安全访问。

群晖

由于在 OpenWrt 上搞 Nginx 有些麻烦,此时此刻,恰巧手里还有一台群晖的 NAS,恰巧群晖默认支持反向代理服务器,这一切的一切不就又双叒叕完美了。

acme.sh

稍显遗憾的是在群晖中没有像 OpenWrt 那样的工具可以直接使用,这里就只能用脚本的方式手搓部署了。首先通过命令行 SSH 登录群晖,并切换到 root 用户:

sudo su
cd /root

由于群晖没有 crontab,因此需要使用如下命令强制安装,根据实际情况修改命令中的电子邮箱:

curl https://get.acme.sh | sh -s email=my@example.com --force

当控制台显示 Install success! 后表示安装成功。进入 /root/.acme.sh 目录,修改 account.conf 文件:

cd /root/.acme.sh
vi account.conf

account.conf 文件示例如下,请根据上文中的内容修改 CF_TokenCF_Zone_IDCF_Account_ID 配置项:

export CF_Token="xxxxxxxxx"
export CF_Zone_ID="xxxxxxxxx"
export CF_Account_ID="xxxxxxxxx"

LOG_FILE="/root/.acme.sh/acme.sh.log"
LOG_LEVEL=1

AUTO_UPGRADE="1"

ACCOUNT_EMAIL="my@example.com"
UPGRADE_HASH="xxxxxxxxx"

运行如下命令申请证书:

./acme.sh --set-default-ca --server letsencrypt
./acme.sh --issue --dns dns_cf --keylength ec-256 -d leovan.dev -d *.leovan.dev

正常情况下,申请的证书将保存在 /root/.acme.sh/leovan.dev_ecc 目录下。

运行如下命令将证书部署到群晖系统中:

export SYNO_USE_TEMP_ADMIN=1
./acme.sh --deploy --deploy-hook synology_dsm -d leovan.dev -d *.leovan.dev

此时进入群晖的 控制面板 - 安全性 - 证书 中,可以看到 leovan.dev 证书已经部署到系统中并作为默认证书,如下图所示:

控制面板 - 安全性 - 证书
控制面板 - 安全性 - 证书

在群晖中创建计划任务来实现自动更新并部署证书,在 控制面板 - 计划任务 中选择 新建 - 计划的任务 - 用户自定的脚本。在 常规 中设置 任务名称,选择 用户账号root,如下图所示:

计划任务 - 常规
计划任务 - 常规

计划 中设置执行的周期,由于 acme.sh 在证书到期前一个月会发起重新申请,因此可以将计划任务周期设置为每周,如下图所示:

计划任务 - 计划
计划任务 - 计划

任务设置 中设置 用户自定义的脚本

/root/.acme.sh/acme.sh --cron --home /root/.acme.sh

根据个人需要可以勾选 通过电子邮件发送运行详情,如下图所示:

计划任务 - 任务设置
计划任务 - 任务设置

反向代理

在 Cloudflare 中将 nas.leovan.dev 解析到 192.168.100.10 上后,在群晖的 登录门户 - 高级 单击 按钮打开对话框,单击 按钮,根据下图添加配置:

反向代理
反向代理

分别通过 http://192.168.100.10:500 和 https://nas.leovan.dev 访问群晖,如下图所示:

群晖 - IP 和 域名访问
群晖 - IP 和 域名访问

可以看出通过 nas.leovan.dev 域名进行访问已经实现了 HTTPS 安全访问。

提示

针对局域网其它机器上的 Web 服务,可以先将域名解析到群晖的 IP 上,再利用群晖的反向代理转发到对应机器的 Web 服务上。对于非 Web 服务,将域名直接解析到对应的机器上即可。

重定向和管道

2024年5月12日 08:00

输入输出文件描述符

在 Linux 启动后,init 进程会创建 3 个特殊的文件描述符分配给输入输出。

文件描述符 英文描述 中文描述
0 stdin 标准输入
1 stdout 标准输出
2 stderr 标准错误

默认情况下,程序经由标准输入(stdin)从键盘读取数据,并将标准输出(stdout)和标准错误(stderr)显示在屏幕上。

在 Linux 中,init 是所有进程的父进程,所有子进程均会继承父进程的文件描述符。因此在 Linux 中执行的所有程序都可以从 stdin 获取输入,并将结果打印到 stdout 中,同时将错误信息打印到 stderr 中。

重定向

当我们不希望从键盘获取标准输入或将标准输出和标准错误显示在屏幕上时,则需要采用重定向。

输出重定向

输出重定向的使用方式如下:

cmd [1-n]> [文件/文件描述符/设备等]

假设当前目录下存在一个名为 yes.txt 的文件,且不存在名为 no.txt 的文件。执行如下命令:

ls yes.txt no.txt

由于 yes.txt 存在,这部分结果将输出到 stdout,同时由于 no.txt 不存在,这部分结果将输出到 stderr。命令的输出结果为:

ls: cannot access 'no.txt': No such file or directory
yes.txt

执行如下命令:

ls yes.txt no.txt 1> success.log 2> fail.log

此时屏幕上将不再显示任何信息,当前目录下会生成 success.logfail.log 两个文件。其中 1> success.log 表示将 stdout 重定向至 success.log2> fail.log 表示将 stderr 重定向至 fail.log。因此 success.log 中的内容为 yes.txtfail.log 中的内容为 ls: cannot access 'no.txt': No such file or directory

重定向过程中,stdout 的文件描述符 1 可以省略,但 stderr 的文件描述符 2 不可以省略。因此,当只重定向 stdout 时,可简写为:

ls yes.txt no.txt > success.log

此时屏幕上依旧会显示 stderr 的内容 ls: cannot access 'no.txt': No such file or directory,而 stdout 的内容则被重定向至 success.log 文件中。

在 Linux 中 &-/dev/null 是两个特殊的输出设备,均表示为空,输出到该设备相当于抛弃输出。因此如下两行命令分别会抛弃 stdout 和 stderr 的内容:

ls yes.txt no.txt 1>&-
ls yes.txt no.txt 2> /dev/null

& 可以表示当前进程中已经存在的描述符,&1 表示 stdout,&2 表示 stderr。因此我们可以将 stdout 和 stderr 重定向到相同文件:

ls yes.txt no.txt > out.log 2> out.log
ls yes.txt no.txt > out.log 2>&1

在上述两种方式中,第一种会导致 out.log 文件被打开两次,stdout 和 stderr 内容会相互覆盖。第二种由于 stderr 重定向给了 stdout,stdout 重定向给了 out.log,因此 out.log 仅被打开了一次。

使用 > 进行输出重定向时会先判断文件是否存在,如果存在会先删除再创建,不存在则直接创建,无论命令是否执行成功均会创建。使用 >> 进行重定向时,如果文件存在则会以添加方式打开,不存在则直接创建。

输入重定向

输入重定向的使用方式如下:

cmd [1-n]< [文件/文件描述符/设备等]

例如:

cat > out.txt < in.txt

此时命令将从 in.txt 文件中获取输入而非 stdin,并将结果重定向到 out.txt 文件中。

Here Document

Here Document 是一种特殊的重定向方式,可以用来将多行输入传递给命令,使用方式如下:

cmd << delimiter
    ...
delimiter

这会将中间的内容 ... 传递给命令。需要注意结尾处的 delimiter 的前后均不能包含任何字符,起始处的 delimiter 的前后空白字符将被忽略。最为常用的 delimiterEOF,但这不是必须的,例如:

wc -l << SOMETHING
第一行
第二行
第三行
SOMETHING

上述命令的输出结果为:

3

管道

管道 | 可以将一个命令的 stdout 作为下一个命令的 stdin 使用,但无法对 stderr 进行处理。因此管道也可以理解为重定向的一种特殊形式。

假设存在一个如下内容的 test.txt 文档:

Here is a test in line 1.
Here is another test in line 2.
Here is something else in line 3.

利用如下命令可以过滤出包含 test 字符的行并显示行号:

cat test.txt | grep -n "test"

上述命令的输出结果为:

1:Here is a test in line 1.
2:Here is another test in line 2.

如果希望同时将 stdout 和 stderr 重定向到下一个命令的 stdin,可以采用如下方式:

ls yes.txt no.txt 2>&1 | grep "No such file or directory"

上述命令的输出结果为:

ls: no.txt: No such file or directory

上述命令也可以简写为:

ls yes.txt no.txt |& grep "No such file or directory"

CSS 布局和定位

2023年5月3日 08:00

CSS 中的布局 display 和定位 position 可以说是两个最基本的属性,其控制着元素在网页中的显示方式。之前对布局和定位可谓是一知半解,最终奏不奏效全凭一顿乱试 😂,想了想还是应该细致地了解下,后面虽不妄想写起代码来事半功倍,但至少不会再暴力遍历破解了。

盒模型

在介绍布局和定位之前,首先回顾一下 CSS 的盒模型。CSS 盒模型从外到内由外边距 margin边框 border内边距 padding内容 content 共 4 部分组成,如下图所示:

CSS 盒模型
CSS 盒模型

元素的宽度 width 为内容的宽度 + 左边框 + 有边框 + 左内边距 + 右内边距,上例中为 $360+10+10+10+10=400$;元素的的高度 height 为内容的高度 + 上边框 + 下边框 + 上内边距 + 下内边距,上例中为 $240+10+10+20+20=300$。在实际中,我们并不能直接设定内容的宽度和高度,只能设置元素的宽度和高度,而显示区域的宽度和高度则通过计算自动设定。

在 CSS 中广泛使用的有两种盒子模型:块级盒子(block box) 和 内联盒子(inline box)1

块级盒子有如下表现行为:

  • 盒子会在内联方向上扩展并占据父容器在该方向上的所有可用空间,在绝大数情况下意味着盒子会和父容器一样宽。
  • 每个盒子都会换行。
  • widthheight 属性可以发挥作用。
  • 内边距、外边距和边框会将其他元素从当前盒子周围“推开”。

除非特殊指定,诸如标题 (<h1> 等) 和段落 (<p>) 默认情况下都是块级的盒子。

内联盒子有如下表现行为:

  • 盒子不会产生换行。
  • widthheight 属性将不起作用。
  • 垂直方向的内边距、外边距以及边框会被应用但是不会把其他处于 inline 状态的盒子推开。
  • 水平方向的内边距、外边距以及边框会被应用且会把其他处于 inline 状态的盒子推开。

<a><span><em> 以及 <strong> 都是默认处于 inline 状态的。

布局

在 CSS 中使用 display 属性控制元素的布局方式,上文中的 blockinline 是最常用的两种布局方式。除此之外还有一种介于块级盒子和内联盒子之间的布局方式,即 inline-block,其具有如下表现行为:

  • 盒子不会产生换行。
  • widthheight 属性可以发挥作用。
  • 内边距、外边距和边框会将其他元素从当前盒子周围“推开”。

这是一段包含 span 元素的文本。display: inline 的 span 元素的 width 和 height 属性无法发挥作用。

这是一段包含 span 元素的文本。display: inline-block 的 span 元素的 width 和 height 属性可以发挥作用。

上图分别展示了 display: inlinedisplay: inline-block 两种布局 span 元素的显示差异。

弹性布局

本节内容主要参考自:A Complete Guide to Flexbox

弹性布局(Flexbox Layout,Flexible Box Layout) 旨在提供一种更加有效的方式来布局、对齐和分配容器中元素之间的空间,即使元素的大小是未知或动态的,这也就是称为“弹性”的原因。

弹性布局是一套完整的模块而非一个单一的属性,其中一些属性要设置在父元素(flex container) 上,一些属性要设置在子元素(flex items) 上。常规布局是基于块级元素和内联元素的的流向,而弹性布局是基于弹性流向(flex-flow directions)。下图展示了弹性布局的基本思想:

Flexbox 基本思想
Flexbox 基本思想

父元素属性

display

该属性启用弹性容器,为其子元素开启弹性上下文。

.container {
  display: flex; /* 或 inline-flex */
}
flex-direction

该属性定义了弹性流向,即基本思想中的 main-axis

.container {
  flex-direction: row | row-reverse | column | column-reverse;
}
  • row(默认):ltr 时从左至右,rtl 时从右至左
  • row-reverseltr 时从右至左,rtl 时从左至右
  • column:从上至下
  • column-reverse:从下至上
flex-wrap

默认情况下会将子元素放置在一行中,该属性用于设置换行模式。

.container {
  flex-wrap: nowrap | wrap | wrap-reverse;
}
  • nowarp(默认):所有子元素放置在一行中。
  • wrap:允许换行,从上至下。
  • wrap-reverse:允许换行,从下至上。
flex-flow

该属性是 flex-directionflex-wrap 两个属性的简写。

.container {
  flex-flow: column wrap;
}
justify-content

该属性用于设置主轴(main axis)方向的对齐方式。

.container {
  justify-content: flex-start | flex-end | center | space-between | space-around | space-evenly | start | end | left | right ... + safe | unsafe;
}
  • flex-start(默认):将子元素排列在 flex-direction 起始位置。
  • flex-end:将子元素排列在 flex-direction 结束位置。
  • center:将子元素沿着 flex-direction 方向居中排列。
  • space-between:将子元素沿着 flex-direction 方向均匀排列,第一个子元素位于起始位置,最后一个子元素位于结束位置。
  • space-around:将子元素沿着 flex-direction 方向均匀排列,每个子元素周围分配相同的空间。
  • space-evenly:将子元素沿着 flex-direction 方向均匀排列,每个子元素之间的间隔相同。
align-items

该属性用于设置交叉轴(cross axis)方向的对齐方式。

.container {
  align-items: stretch | flex-start | flex-end | center | baseline | first baseline | last baseline | start | end | self-start | self-end + ... safe | unsafe;
}
  • stretch(默认):拉伸并填充容器(仍遵守 min-widthmax-width)。
  • flex-start / start / self-start:子元素被放置在交叉轴的起始位置。
  • flex-end / end / self-end:子元素被放置在交叉轴的结束位置。
  • center:子元素在交叉轴上居中对齐。
  • baseline:子元素沿着他们的基线对齐。
align-content

该属性用于设置当交叉轴上有额外的空间时容器多行的内部对齐方式,类似 justify-content 设置主轴上子元素的对齐方式。

警告

该属性仅对包含多行子元素的容器有效。

.container {
  align-content: flex-start | flex-end | center | space-between | space-around | space-evenly | stretch | start | end | baseline | first baseline | last baseline + ... safe | unsafe;
}
  • normal(默认):子元素被放置到容器的默认位置。
  • flex-start / start:子元素被放置到容器的起始位置。
  • flex-end / end:子元素被放置到容器的结束位置。
  • center:子元素被放置到容器的居中位置。
  • space-between:子元素均匀分布,第一行在容器的起始位置,最后一行在容器的结束位置。
  • space-around:子元素均匀分布,每行元素周围分配相同的空间。
  • space-evenly:子元素均匀分布,每行元素之间的间隔相同。
  • stretch:子元素拉伸占用剩余空间。
gap, row-gap, column-gap

该属性用于控制子元素之间的间距,其仅用于非边缘子元素之间的间距。

.container {
  display: flex;
  ...
  gap: 10px;
  gap: 10px 20px; /* row-gap column gap */
  row-gap: 10px;
  column-gap: 20px;
}

该属性产生的行为可以认为是子元素之间的最小间距。

子元素属性

order

默认情况下,子元素按照代码顺序排列。该属性可以控制子元素在容器中的顺序。

.item {
  order: 5; /* 默认为 0 */
}
flex-grow

该属性定义了子元素在必要时的扩张能力,其接受一个整数比例值用于设定子元素占用容器的空间。如果所有子元素的 flew-grow 都设置为 1,则所有子元素将评分容器的剩余空间;如果一个子元素的 flex-grow 设置为 2,则该子元素将尝试占用其他子元素 2 倍大小的空间。

.item {
  flex-grow: 4; /* 默认为 0 */
}
flex-shrink

该属性定义了子元素在必要时的收缩能力。

.item {
  flex-shrink: 3; /* 默认为 1 */
}
flex-basis

该属性定义了分配剩余空间之前子元素的默认大小。其可以为例如 20%5rem 之类的长度或一个关键字。

.item {
  flex-basis:  | auto; /* 默认为 auto */
}
flex

该属性是 flex-growflex-shrinkflex-basis 三个属性的简写。

.item {
  flex: none | [ <'flex-grow'> <'flex-shrink'>? || <'flex-basis'> ]
}
align-self

该属性可以覆盖由 align-items 指定的对齐方式。

.item {
  align-self: auto | flex-start | flex-end | center | baseline | stretch;
}

网格布局

本节内容主要参考自:A Complete Guide to CSS Grid

网格布局(Grid Layout)是一种基于网格的布局系统,相比于沿轴线 一维布局 的弹性布局,网格布局可以看做是一种 二维布局

核心概念

网格容器

网格容器即属性 displaygrid 的元素,其为所有网格项目的直接父级。如下示例中,container 即为网格容器:

<div class="container">
  <div class="item item-1"> </div>
  <div class="item item-2"> </div>
  <div class="item item-3"> </div>
</div>
网格项目

网格项目为网格容器的直接后代。如下示例中,item 即为网格项目,但 sub-item 不是:

<div class="container">
  <div class="item"> </div>
  <div class="item">
    <p class="sub-item"> </p>
  </div>
  <div class="item"> </div>
</div>
网格线

网格线即构成网格结构的分界线。其可以是位于行或列任意一侧的垂直或水平线。如下示例中,黄色的线为一条列网格线:

网格单元

网格单元即两个相邻行和两个相邻列之间的区域。如下示例中,黄色区域为行网格线 1 和 2 以及列网格线 2 和 3 之间的单元格:

网格轨道

网格轨道即 2 条相邻网格线之间的区域,可以将其视为网格的行或列。如下示例中,黄色区域为第 2 行和第 3 行网格线之间的网格轨道:

网格区域

网格区域即 4 条网格线包围的区域,一个网格区域可以由任意数量的网格单元组成。如下示例中,黄色区域为行网格线 1 和 3 以及列网格线 1 和 3 之间的网格区域:

父元素属性

display

该属性启用网格容器,为其子元素开启网格上下文。

.container {
  display: grid | inline-grid;
}
grid-template-columns, grid-template-rows

该属性通过空格分隔的值列表定义网格的列和行,值代表轨道的大小。值列表包括:

  • <track-size>:轨道大小,可以为长度、百分比等。
  • <line-name>:网格线名称,可以为任意值。
.container {
  grid-template-columns: ...  ...;
  /* 例如:
      1fr 1fr
      minmax(10px, 1fr) 3fr
      repeat(5, 1fr)
      50px auto 100px 1fr
  */
  grid-template-rows: ... ...;
  /* 例如:
      min-content 1fr min-content
      100px 1fr max-content
  */
}

网格线默认将会被分为正整数(-1 作为最后一个的替代值)。

同时也可以明确指定这些线的名称,请注意括号命名语法:

.container {
  grid-template-columns: [first] 40px [line2] 50px [line3] auto [col4-start] 50px [five] 40px [end];
  grid-template-rows: [row1-start] 25% [row1-end] 100px [third-line] auto [last-line];
}

请注意,一个行或列可以有多个名称:

.container {
  grid-template-rows: [row1-start] 25% [row1-end row2-start] 25% [row2-end];
}

使用 repeat() 可以简化重复项:

.container {
  grid-template-columns: repeat(3, 20px [col-start]);
}

上述代码等效于:

.container {
  grid-template-columns: 20px [col-start] 20px [col-start] 20px [col-start];
}

如果多行或多列共享相同的名称,可以通过行名或列名和计数来引用它们:

.item {
  grid-column-start: col-start 2;
}

fr 单位允许将轨道的大小设置为网格容器可用空间的一定比例。例如,如下示例将每个项目设置为容器宽度的三分之一:

.container {
  grid-template-columns: 1fr 1fr 1fr;
}

可用空间是在所有非弹性项目之后计算得到。在上述示例中,fr 单位的可用空间总量不包括 50px

.container {
  grid-template-columns: 1fr 50px 1fr 1fr;
}
grid-template-areas

该属性通过引用网格区域的名称 grid-area 来定义网格。重复网格区域名称会导致内容跨越这些单元格。句点表示一个空单元格。语法本身提供了网格结构的可视化。

  • <grid-area-name>:网格区域的名称。
  • .:空网格单元。
  • none:未定义的网格区域。
.container {
  grid-template-areas:
    "<grid-area-name> | . | none | ..."
    "...";
}
.item-a {
  grid-area: header;
}
.item-b {
  grid-area: main;
}
.item-c {
  grid-area: sidebar;
}
.item-d {
  grid-area: footer;
}

.container {
  display: grid;
  grid-template-columns: 50px 50px 50px 50px;
  grid-template-rows: auto;
  grid-template-areas:
    "header header header header"
    "main main . sidebar"
    "footer footer footer footer";
}

上述示例将创建一个 4 列 3 行的网格。整个顶部为 header 区域,中间一行由 mainsidebar 两个区域和一个空单元格组成,最后一行为 footer

声明中的每一行都需要有相同数量的单元格。可以使用任意数量的句点声明一个空单元格,只要句点之间没有空格,就代表一个单元格。

注意使用此语法仅可以命名区域,不可命名线。使用此语法时,区域两端的线会自动命名,如果网格区域名称为 foo,那么该区域的起始行线和起始列线名称为 foo-start,该区域的终止行线和终止列线名称为 foo-end。这意味着某些线可能有多个名称,上述示例中最左边的行线将有 3 个名称:header-startmain-startfooter-start

grid-template

该属性是 grid-template-rowsgrid-template-columnsgrid-template-areas 三个属性的简写。

.container {
  grid-template: none | <grid-template-rows> / <grid-template-columns>;
}

其接受更复杂但更方便的语法来指定这三个值,例如:

.container {
  grid-template:
    [row1-start] "header header header" 25px [row1-end]
    [row2-start] "footer footer footer" 25px [row2-end]
    / auto 50px auto;
}

上述代码等效于:

.container {
  grid-template-rows: [row1-start] 25px [row1-end row2-start] 25px [row2-end];
  grid-template-columns: auto 50px auto;
  grid-template-areas:
    "header header header"
    "footer footer footer";
}

由于 grid-template 并不会重置网格的隐含属性(grid-auto-columnsgrid-auto-rowsgrid-auto-flow)。因此,建议使用 grid 属性而非 grid-template

column-gap, row-gap, grid-column-gap, grid-row-gap

该属性用于指定网格线的大小,你可以将其看做列和行之间的间距。

.container {
  /* standard */
  column-gap: <line-size>;
  row-gap: <line-size>;

  /* old */
  grid-column-gap: <line-size>;
  grid-row-gap: <line-size>;
}
.container {
  grid-template-columns: 100px 50px 100px;
  grid-template-rows: 80px auto 80px;
  column-gap: 10px;
  row-gap: 15px;
}

间距仅在列和行之间创建,不在边缘创建。注意,带有 grid- 前缀的属性将被废弃。

gap, grid-gap

该属性为 row-gapcolumn-gap 两个属性的简写。

.container {
  /* standard */
  gap: <grid-row-gap> <grid-column-gap>;

  /* old */
  grid-gap: <grid-row-gap> <grid-column-gap>;
}
.container {
  grid-template-columns: 100px 50px 100px;
  grid-template-rows: 80px auto 80px;
  gap: 15px 10px;
}

如果未指定 row-gap,则它将被设置为与 column-gap 相同的值。注意,带有 grid- 前缀的属性将被废弃。

justify-items

沿 inline(行)轴对齐网格项(与沿 block(列)轴对齐 align-items 相反)。该属性将应用于容器内所有网格项。

  • stretch(默认值):将网格项填充至整个单元格宽度。
  • start:将网格项与单元的起始边缘对齐。
  • end:将网格项与单元的结束边缘对齐。
  • center:将网格项与单元的中心对齐。
.container {
  justify-items: stretch | start | end | center;
}
.container {
  justify-items: stretch;
}
.container {
  justify-items: start;
}
.container {
  justify-items: end;
}
.container {
  justify-items: center;
}
align-items

沿 block(列)轴对齐网格项(与沿 inline(行)轴对齐 align-items 相反)。该属性将应用于容器内所有网格项。

  • stretch(默认值):将网格项填充至整个单元格高度。
  • start:将网格项与单元的起始边缘对齐。
  • end:将网格项与单元的结束边缘对齐。
  • center:将网格项与单元的中心对齐。
  • baseline:将网格项沿文本基线对齐。
.container {
  align-items: stretch | start | end | center;
}
.container {
  align-items: stretch;
}
.container {
  align-items: start;
}
.container {
  align-items: end;
}
.container {
  align-items: center;
}

通过 align-self 属性可以在单个网格项上覆盖由 align-items 指定的对齐方式。

place-items

该属性在单次声明中同时设置 align-itemsjustify-items 属性。

  • <align-items> / <justify-items>:省略第二个值则将第一个值分配给两个属性。
.center {
  display: grid;
  place-items: center;
}
justify-content

当所有网格项均使用非弹性的单位(例如 px)来确定大小,则网格的总大小可能小于网格容器的大小。在这种情况下,可以在网格容器内设置网格的对齐方式。该属性沿 inline(行)轴(与沿 block(列)轴对齐 align-content 相反)对齐网格。

  • start:将网格与网格容器的起始边缘对齐。
  • end:将网格与网格容器的结束边缘对齐。
  • center:将网格与网格容器的中心对齐。
  • stretch :调整网格项的大小使网格填充网格容器的整个宽度。
  • space-around:每个网格项均匀分布,每个网格项周围分配相同的空间。
  • space-between:每个网格项均匀分布,第一个网格项在起始位置,最后一个网格项在结束位置。
  • space-evenly:每个网格项均匀分布,每个网格项之间的间隔相同。
.container {
  justify-content: start | end | center | stretch | space-around | space-between | space-evenly;
}
.container {
  justify-content: start;
}
.container {
  justify-content: end;
}
.container {
  justify-content: center;
}
.container {
  justify-content: stretch;
}
.container {
  justify-content: space-around;
}
.container {
  justify-content: space-between;
}
.container {
  justify-content: space-evenly;
}
align-content

当所有网格项均使用非弹性的单位(例如 px)来确定大小,则网格的总大小可能小于网格容器的大小。在这种情况下,可以在网格容器内设置网格的对齐方式。该属性沿 block(列)轴(与沿 inline(行)轴对齐 justify-content 相反)对齐网格。

  • start:将网格与网格容器的起始边缘对齐。
  • end:将网格与网格容器的结束边缘对齐。
  • center:将网格与网格容器的中心对齐。
  • stretch :调整网格项的大小使网格填充网格容器的整个高度。
  • space-around:每个网格项均匀分布,每个网格项周围分配相同的空间。
  • space-between:每个网格项均匀分布,第一个网格项在起始位置,最后一个网格项在结束位置。
  • space-evenly:每个网格项均匀分布,每个网格项之间的间隔相同。
.container {
  align-content: start | end | center | stretch | space-around | space-between | space-evenly;
}
.container {
  align-content: start;
}
.container {
  align-content: end;
}
.container {
  align-content: center;
}
.container {
  align-content: stretch;
}
.container {
  align-content: space-around;
}
.container {
  align-content: space-between;
}
.container {
  align-content: space-evenly;
}
place-content

该属性在单次声明中同时设置 align-contentjustify-content 属性。

  • <align-content> / <justify-content>:省略第二个值则将第一个值分配给两个属性。
grid-auto-columns, grid-auto-rows

该属性指定自动生成的网格轨道(也称为隐式网格轨道)的大小。当网格项多于网格中的单元格或当网格项放置在显示网格之外时,将创建隐式网格轨道。

  • <track-size>:可以为长度、百分比或可用空间的比例(使用 fr 单位)。
.container {
  grid-auto-columns: <track-size> ...;
  grid-auto-rows: <track-size> ...;
}
.container {
  grid-template-columns: 60px 60px;
  grid-template-rows: 90px 90px;
}

上述代码将生成一个 2x2 的网格:

使用 grid-columngrid-row 来定位网格项:

.item-a {
  grid-column: 1 / 2;
  grid-row: 2 / 3;
}
.item-b {
  grid-column: 5 / 6;
  grid-row: 2 / 3;
}

.item-b 从第 5 列线开始到第 6 列线结束,但由于并未定义第 5 列线和第 6 列线,因此创建了宽度为 0 的隐式轨道用于填充间隙。使用 grid-auto-columnsgrid-auto-rows 可以指定这些隐式轨道的宽度:

.container {
  grid-auto-columns: 60px;
}
grid-auto-flow

如果有未明确放置在网格中的网格项目,自动放置算法会自动放置这些网格项目。此属性用于控制自动放置算法的工作方式。

  • row(默认):依次填充每一行,并根据需要添加新行。
  • column:依次填充每一列,并根据需要添加新列。
  • dense:将可能较晚出现的较小的网格项优先填充在网格中。
.container {
  grid-auto-flow: row | column | row dense | column dense;
}

注意 dense 仅会改变网格项目的视觉顺序,这可能导致顺序混乱且不利于访问。

考虑如下示例:

<section class="container">
  <div class="item-a">item-a</div>
  <div class="item-b">item-b</div>
  <div class="item-c">item-c</div>
  <div class="item-d">item-d</div>
  <div class="item-e">item-e</div>
</section>

定义一个包含 5 列和 2 行的网格,并将 grid-auto-flow 设置为 row

.container {
  display: grid;
  grid-template-columns: 60px 60px 60px 60px 60px;
  grid-template-rows: 30px 30px;
  grid-auto-flow: row;
}

将网格项目放置在网格中时,只需要为其中两个指定位置:

.item-a {
  grid-column: 1;
  grid-row: 1 / 3;
}
.item-e {
  grid-column: 5;
  grid-row: 1 / 3;
}

因为将 grid-auto-flow 设置为了 row,未放置的三个网格项目(item-bitem-citem-d)如下所示:

.container {
  display: grid;
  grid-template-columns: 60px 60px 60px 60px 60px;
  grid-template-rows: 30px 30px;
  grid-auto-flow: column;
}

如果将 grid-auto-flow 设置为 column,未放置的三个网格项目(item-bitem-citem-d)如下所示:

grid

该属性为 grid-template-rowsgrid-template-columnsgrid-template-areasgrid-auto-rowsgrid-auto-columnsgrid-auto-flow 属性的简写。

  • none:将所有子属性设置为初始值。
  • <grid-template>:同 grid-template
  • <grid-template-rows> / [ auto-flow && dense? ] <grid-auto-columns>?:设置 grid-template-rows 为指定值。如果使用 auto-flow 关键字,则设置 grid-auto-flowcolomn。如果额外使用 dense 关键字,则自动放置算法将使用 dense 算法。如果省略 grid-auto-columns,则其被设置为 auto
  • [ auto-flow && dense? ] <grid-auto-rows>? / <grid-template-columns>:设置 grid-template-columns 为指定值。如果使用 auto-flow 关键字,则设置 grid-auto-flowrow。如果额外使用 dense 关键字,则自动放置算法将使用 dense 算法。如果省略 grid-auto-rows,则其被设置为 auto

如下示例中的代码是等效的:

.container {
  grid: 100px 300px / 3fr 1fr;
}

.container {
  grid-template-rows: 100px 300px;
  grid-template-columns: 3fr 1fr;
}
.container {
  grid: auto-flow / 200px 1fr;
}

.container {
  grid-auto-flow: row;
  grid-template-columns: 200px 1fr;
}
.container {
  grid: auto-flow dense 100px / 1fr 2fr;
}

.container {
  grid-auto-flow: row dense;
  grid-auto-rows: 100px;
  grid-template-columns: 1fr 2fr;
}
.container {
  grid: 100px 300px / auto-flow 200px;
}

.container {
  grid-template-rows: 100px 300px;
  grid-auto-flow: column;
  grid-auto-columns: 200px;
}

它还接受更复杂但更方便的语法来一次性设置所有内容。如下示例中的代码是等效的:

.container {
  grid: [row1-start] "header header header" 1fr [row1-end]
        [row2-start] "footer footer footer" 25px [row2-end]
        / auto 50px auto;
}

.container {
  grid-template-areas:
    "header header header"
    "footer footer footer";
  grid-template-rows: [row1-start] 1fr [row1-end row2-start] 25px [row2-end];
  grid-template-columns: auto 50px auto;
}

子元素属性

grid-column-start, grid-column-end, grid-row-start, grid-row-end

该属性通过网格线来设置网格项在网格中的位置。grid-column-startgrid-row-start 为网格项起始的线,grid-column-endgrid-row-end 为网格项结束的线。

  • <line>:指代网格线的数字编号或名称。
  • span <number>:该网格项跨越的网格轨道数。
  • span <name>:该网格项跨越直到它抵达该名称网格线的下一个网格线。
  • auto:表示自动放置、自动跨度或一个默认跨度。
.item {
  grid-column-start: <number> | <name> | span <number> | span <name> | auto;
  grid-column-end: <number> | <name> | span <number> | span <name> | auto;
  grid-row-start: <number> | <name> | span <number> | span <name> | auto;
  grid-row-end: <number> | <name> | span <number> | span <name> | auto;
}
.item-a {
  grid-column-start: 2;
  grid-column-end: five;
  grid-row-start: row1-start;
  grid-row-end: 3;
}
.item-b {
  grid-column-start: 1;
  grid-column-end: span col4-start;
  grid-row-start: 2;
  grid-row-end: span 2;
}

如果 grid-column-endgrid-row-end 未声明,则该网格项将默认跨越一个轨道。网格项目之间可以相互重叠,使用 z-index 可以控制它们的重叠次序。

grid-column, grid-row

分别是 grid-column-start + grid-column-endgrid-row-start+ grid-row-end 的简写。

  • <start-line> / <end-line>:接受非简写版本相同的值,包括 span
.item {
  grid-column: <start-line> / <end-line> | <start-line> / span <value>;
  grid-row: <start-line> / <end-line> | <start-line> / span <value>;
}
.item-c {
  grid-column: 3 / span 2;
  grid-row: third-line / 4;
}

如果未设置结束线的值,则该网格项将默认跨越一个轨道。

grid-area

为一个网格项命名以便它可以使用 grid-template-areas 属性创建的模板引用。此属性可以作为 grid-row-start + grid-column-start + grid-row-end + grid-column-end 的简写。

  • <name>:选用的名称。
  • <row-start> / <column-start> / <row-end> / <column-end>:可以为数字编号或线名称。

用作为网格项分配名称:

.item-d {
  grid-area: header;
}

用作 grid-row-start + grid-column-start + grid-row-end + grid-column-end 的简写:

.item-d {
  grid-area: 1 / col4-start / last-line / 6;
}
justify-self

沿 inline(行)轴对齐单元格内的网格项(与沿 block(列)轴对齐 align-self 相反)。该属性仅应用于单个单元格内的网格项。

  • stretch(默认):填充单元格的整个宽度。
  • start:将网格项与单元格的起始边缘对齐。
  • end:将网格项与单元格的结束边缘对齐。
  • center:将网格项与单元格的中心对齐。
.item {
  justify-self: stretch | start | end | center;
}
.item-a {
  justify-self: stretch;
}
.item-a {
  justify-self: start;
}
.item-a {
  justify-self: end;
}
.item-a {
  justify-self: center;
}

通过 justify-items 属性可以为容器中所有的网格项设置对齐方式。

align-self

沿 block(列)轴对齐单元格内的网格项(与沿 inline(行)轴对齐 justify-self 相反)。该属性将仅应用于单个单元格内的网格项。

  • stretch(默认):填充单元格的整个高度。
  • start:将网格项与单元格的起始边缘对齐。
  • end:将网格项与单元格的结束边缘对齐。
  • center:将网格项与单元格的中心对齐。
.item {
  align-self: stretch | start | end | center;
}
.item-a {
  align-self: stretch;
}
.item-a {
  align-self: start;
}
.item-a {
  align-self: end;
}
.item-a {
  align-self: center;
}
place-self

place-self 可以在单次声明中同时设置 align-selfjustify-self

  • auto:默认对齐方式。
  • <align-self> / <justify-self>:省略第二个值则将第一个值分配给两个属性。
.item-a {
  place-self: center;
}
.item-a {
  place-self: center stretch;
}

定位

本节内容主要参考自:定位技术

定位允许我们将一个元素放置在网页的指定位置上。定位并非是一种用来做主要布局的方式,而是一种用于微调布局的手段。通过 position 属性在特定的布局中修改元素的定位方式,该属性有 staticrelativefixedabsolutesticky 共 5 种可选值。

为了展示不同 position 的效果,在此采用相同的 HTML 进行比较:

<h1>XXX 定位</h1>

<p>这是一个基本块元素。</p>
<p class="position">这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>

默认样式为:

body {
  width: 400px;
  margin: 0 auto;
}

h1 {
  text-align: center;
}

p {
  margin: 10px;
  padding: 10px;
  background-color: #916cad;
  border: 2px #523874 solid;
  border-radius: 3px;
}

静态定位

静态定位static)是 position 属性的 默认值,它表示将元素放置在文档布局流的默认位置上。

静态定位样式为:

.position {
  position: static;
}

渲染效果如下:

相对定位

相对定位relative)表示相对于 静态定位 的默认位置进行偏移,其需要搭配 topbottomleftright 四个属性使用。

相对定位样式为:

.position {
  position: relative;
  top: 30px;
  left: 30px;
  background-color: #c7fba5cc;
  border: 2px #adf182cc solid;
}

渲染效果如下:

绝对定位

绝对定位absolute)表示相对于 上级元素 的位置进行偏移,其需要搭配 topbottomleftright 四个属性使用。绝对定位的定位基点不能为 static 定位,否则定位基点将变成网页根元素 html

绝对定位样式为:

.position {
  position: absolute;
  top: 30px;
  left: 30px;
  background-color: #c7fba5cc;
  border: 2px #adf182cc solid;
}

渲染效果如下:

固定定位

固定定位fixed)表示相对于 视窗(viewport,即浏览器窗口)进行偏移,其需要搭配 topbottomleftright 四个属性使用。利用固定定位可以实现元素位置不随页面滚动而发生变化。

为了演示固定定位,修改 HTML 代码如下:

<h1>固定定位</h1>

<p>这是一个基本块元素。</p>
<p class="position">固定</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>

固定定位样式为:

.position {
  position: fixed;
  top: 30px;
  left: 30px;
  background-color: #c7fba5cc;
  border: 2px #adf182cc solid;
}

渲染效果如下:

粘性定位

粘性定位sticky)可以理解为 静态定位static)和 固定定位fixed)的 混合。当指定一个元素的 position 属性为 sticky 后,它会在正常布局流中滚动,直至它出现在设定的相对于容器的位置,此时它会停止滚动,表现为固定定位。

为了演示粘性定位,修改 HTML 代码如下:

<h1>粘性定位</h1>

<p>这是一个基本块元素。</p>
<p class="position">这是一个粘性定位元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>
<p>这是一个基本块元素。</p>

粘性定位样式为:

.position {
  position: sticky;
  top: 30px;
  left: 30px;
  background-color: #c7fba5cc;
  border: 2px #adf182cc solid;
}

渲染效果如下:

文学编程和可重复性研究

2023年3月11日 08:00

文学编程

文学式编程(Literate Programming)是由高德纳提出的编程方法,希望能用来取代结构化编程范型。正如高德纳所构想的那样,文学编程范型不同于传统的由计算机强加的编写程序的方式和顺序,而代之以让程序员用他们自己思维内在的逻辑和流程所要求的顺序开发程序。文学编程自由地表达逻辑,而且它用人类日常使用的语言写出来,就好像一篇文章一样,文章里包括用来隐藏抽象的巨集和传统的源代码。文学编程工具用来从文学源文件中获得两种表达方式,一种用于计算机进一步的编译和执行,称作“绕出”(tangled)的代码,一种用于格式化文档,称作从文学源代码中“织出”(woven)。虽然第一代文学编程工具特定于计算机语言,但后来的工具可以不依赖具体语言,并且存在于比编程语言更高的层次中 1

如高德纳在论文 2 中所示,相同的源文件经过“tangle”可以编译为机器代码,经过“weave”可以编译为文档。

文学编程历史

从高德纳提出文学编程的概念后,各家各派都在将这个编程范式付诸实践。我接触文学编程已经比较晚了,算是从 R Markdown 和 knitr 开始,开始时写写分析报告和做做幻灯片,慢慢的在更多场景我发现这很适合。

WEB, CWEB & noweb

WEB 是一种计算机编程语言系统,它由高德纳设计,是第一种实现他称作“文学编程”的语言。WEB 包含了 2 个主要程序:TANGLE,从源文本生成可编译的 Pascal 代码,以及 WEAVE,使用 TeX 生成格式漂亮可打印的文档。CWEB 是 WEB 的 C 语言新版本,noweb 是另外一种借鉴了 WEB 的文学编程工具,同时与语言无关 3

wc.nw 为例,其为 Unix 单词统计程序 wc 的 noweb 版本重写,原始的 CWEB 版本可以在高德纳的《文学编程》一书中找到。noweb 源代码中包含 TeX 代码和 C 语言代码,每个 C 语言代码片段都以一个 <<代码片段名称>>= 开头,以 @ 结尾,程序的入口为 <<*>>=。在某个代码片段中调用其他代码片段只需要输入 <<代码片段名称>> 即可。

安装 noweb,通过如下命令可以将 wc.nw 编译为 C 语言代码 wc.c

notangle -L wc.nw > wc.c

通过如下命令可以将 wc.nw 编译为 TeX 源代码:

noweave -autodefs c -index wc.nw > wc.tex

Org Mode

Org Mode 是由 Carsten Dominik 于 2003 年发明的用于文本编辑器 Emacs 的一种支持内容分级显示的编辑模式。这种模式下可以创建待办列表,日志管理,做笔记,做工程计划或者写网页。Org Mode 通常启用于后缀名为 org 的纯文本文件,使用星号标记有层次的内容(如文章大纲、话题与子话题、嵌套代码),并提供一组函数用于读取并展示这类标记以及操纵内容(如折叠大纲内容、移动元素、更改待办项状态)4

在 Org Mode 中使用 #+BEGIN_SRC#+END_SRC 来标记代码块,在 #+BEGIN_SRC 后指定嵌入的代码类型,例如嵌入 C 语言源代码:

#+BEGIN_SRC c
int main(void) {
  return 0;
}
#+END_SRC

更多关于在 Org Mode 中的文学编程应用可以参见 lujun9972/emacs-document

Sweave & knitr

Sweave 是 R 语言的 WEB 实现,为什么是 Sweave 而不是 Rweave,没有仔细去找解释,但我猜测是由于 R 语言的前身为 S 语言吧。既然有了 Sweave 为什么没有 Stangle 呢?也是猜测,或许 Sweave 的作者在创作之初就更侧重于将 R 代码及其运行结果嵌入,“织出”最终阅读友好的文档吧。当然,由于 R 是一门统计分析语言,将所有 R 代码提取出来编译成可执行文件并不是它的优势,我猜这应该也是没有 Stangle 的一个原因吧。当然,也并不是没有人打算这么做,fusen 是一个基于 R Markdown 直接生成 R 扩展包的扩展包,从一定程度上应该算是 tangle 的理念实现吧。

Sweave 是基于 R 和 LaTeX 的实现,但 LaTeX 的学习曲线相对比较陡峭,knitr 的出现拓展了 Sweave 的功能,例如:内容方面增加支持了 Markdown 等,代码方面增加支持了 Python 等。除此之外,也衍生出了多种多样的文档格式,例如:幻灯片(xaringan),图书(bookdown)和博客(blogdown)等等。

在 R Markdown 中使用如下方式嵌入代码,在 {} 中指定嵌入代码的类型,例如嵌入并执行 R 语言源代码:

```{r}
add <- function(a, b) {
  return(a + b)
}

print(add(1, 1))
```

在同一个 R Markdown 文件中可以同时插入 R 和 Python 等多种不同语言的源代码,通过 reticulate 甚至可以实现 R 和 Python 之间的数据交互。

Jupyter

Jupyter 是从 IPython Notebook 发展而来,基于 Python 语言的强大优势,其在业界迅速占领了一大片应用市场,后来 Jupyter 也逐渐支持其他语言。虽然现在 R Markdown 也支持在 RStuido 等编辑器中逐行运行,但个人认为 Jupyter 的最大优势就在于边写边运行,这也使得 Jupyter 在教育等需要实时运行的领域应用最为广泛。

Jupyter 仍以 .ipynb 为扩展名,其底层为 JSON 格式的文本文件。原生 Jupyter 针对一个文件仅支持一种 Kernel,即运行一种类型的代码,当通过一些技巧也可以实现同时运行多种类型的代码。

Quarto

Quarto 是 Posit(RStudio 的新公司名)开发的一个基于 Pandoc 的开源技术出版系统。Quarto 的目标是改进科学和技术文档的创建和协作过程,其希望将 R Markdown、bookdowndistillxaringan 等功能统一到一个系统中。

Quarto 的工作流程同 R Markdown 类似,如下图所示:

所以,Quarto 的到来是否意味着 R Markdown 的消失呢?官方 FAQ 给到了否定的答案。不过我认为 Quarto 「一统天下」的野心还是有的,只是基于现状可能这条路还需要再走一阵子。如下是我从当先(2023 年初)现状和个人的一些需求,认为 Quarto 和 R Markdown 之间存在的一些区别:

  • 博客方面,个人需要动态输出的场景不多,blogdown 是基于 Hugo 的实现,动态文档是利用 knitr 将 R Markdown 直接渲染为 HTML 再交由 Hugo 处理。支持 Hugo 的自动化部署(例如:Cloudflare PagesNetlifyVercel 等)对比 Quarto 的自动化部署选择要更多些。
  • 幻灯片方面,xaringan 是基于 remark.js 实现的,Quarto 是基于 reveal.js。两者没有孰优孰劣,接触 remark.js 更久一些,更熟悉一些,可能就更偏好一些,不过 remark.js 目前处于非活跃开发状态,这可能是 Quarto 选择 reveal.js 的一个原因吧。
  • 书籍方面,这个不得不说 Quarto 真的是赞了。我认为书籍输出格式是所有格式中最复杂的一个,这也使得在代码执行参数扩展组件 等方面比 bookdown 支持更灵活的 Quarto 在实践中更好用些。

还是很希望 Quarto 在未来能够做更好的统一,这也会让我们面对不同输出场景中复用更多相同的知识和技巧。

当我谈文学编程时我谈些什么

高德纳提出了文学编程的理念,Peter Seibel 也存在不同的看法:编码并非文学。其实这两者并不是对立的,只是角度不同而已。我认为文学编程更适合数据分析型工程,针对功能系统型工程确实很难融入文学编程。以当下的实践来看,从 R Markdown,到 Jupyter,再到 Quarto,无一例外是针对技术和科学等场景提供数据分析功能,而针对系统工程开发,更多还是遵循着产品文档和工程代码分离。

文学编程是一种理念,类似一门新的语言,从客观上能解决一些特定领域的问题,也能在某些场景中提高效率。但整个生态的发展离不开真正「喜欢」的人参与,不断改善和大力推广才能保证生态的持续发展。除了商业团队的主推以外,我认为开源精神和社区参与也很重要,真正的繁华从来不是一家独大而是全民参与。

可重复性研究

可重复性研究的范畴要比文学编程更广泛,文学编程主要围绕计算机相关科学展开,可重复性研究则是面向全部科学的。可重复性研究指的是科学结果应该在其推论完全透明的方式记录下来 5

图片来源:https://github.com/mickaeltemporao/reproducible-research-in-python
图片来源:https://github.com/mickaeltemporao/reproducible-research-in-python

上图生动地描述了可重复性研究的重要性。在此我们依旧围绕计算机相关科学讨论可重复性研究。文学编程通过将代码嵌入文档中实现了代码结果的可重复性动态生成,但除了代码之外,可重复性研究还需要关注代码的运行环境和使用的数据等,这些同样会影响研究的最终结果。

运行环境

硬件、内核、操作系统、语言、扩展包等代码运行环境都会对最终的研究结果产生影响,如下图所示:

图片来源:https://github.com/MozillaFoundation/2017-fellows-sf
图片来源:https://github.com/MozillaFoundation/2017-fellows-sf

硬件问题在苹果推出基于 arm 架构的 M1 芯片时一度带来了不少的麻烦,虽然 macOS 提供了转义工具,但在推出的早起仍出现大量软件兼容问题。不过随着这几年的发展,软件的兼容性问题已经得到了极大的改善,因此在硬件这一层几乎不再会有太多问题。

内核和操作系统可以粗略的认为是同一层级,这也是在日常研究中会经常遇到的问题。有时候在自己电脑系统上跑地好好的代码,拿到别人电脑上就会出现各种问题。在工程部署阶段,通过 docker 等虚拟化技术是可以保证代码运行的系统环境是相同的,但在分析研究阶段这并不好用。在这个层面感觉比较好的解决方案就是使用多系统兼容的软件、语言、扩展包等,如果确实需要使用指定系统的工具,在代码层面实现兼容或提示兼容问题会是不错的选择。

语言和扩展包层面的问题在真实场景中遇到的并不多,我们不必非要在 Python 和 R 中二选一,也不必非要在 PyTorch 和 Tensorflow 中二选一。但至少要保证使用相关研究领域中常用的工具、语言和扩展包,当然这些最好甚至应该是开源的,这样其他人才能够无障碍的获取相关代码依赖。

数据公开

在可重复性研究中,数据公开也很重要,没有研究的输入数据,哪怕分析代码全部公开,也无法得到相同的研究结果。最理想的情况就是完全公开所用的原始数据,但这个在涉及到私有域数据时往往又是不现实的。针对这个问题有多种可以尝试解决的方案:

  1. 数据脱敏。例如:针对涉及隐私的 ID 可以转换为无意义的 ID,一般情况不会对研究产生影响。例如:针对涉及商业机密的价格或销量可以添加扰动量或进行分箱处理,但这会对研究产生一定的影响。
  2. 人造数据。针对所需的数据格式完全人工创造虚拟数据,不过在复杂场景下其成本较高,甚至无法实现。

  1. https://zh.wikipedia.org/zh-hans/文学编程 ↩︎

  2. Knuth, Donald Ervin. “Literate programming.” The computer journal 27.2 (1984): 97-111. ↩︎

  3. https://zh.wikipedia.org/zh-hans/WEB ↩︎

  4. https://zh.wikipedia.org/zh-hans/Org-模式 ↩︎

  5. https://en.wikipedia.org/wiki/Reproducibility ↩︎

评分和排名算法

2022年5月22日 08:00

在之前的博客「投票公平合理吗?」中已经得到了一个令人沮丧的结论:只有道德上的相对民主,没有制度上的绝对公平。投票是对不同选项或个体的排序,在投票中我们关注更多是相对位置这样定性的结论,例如:积分前三名的同学才能进入下一环节。但有的时候我们不光想知道不同选项之间的先后顺序,还想了解不同选项之间的差异大小,这时我们就需要设计更精细的方法进行定量分析。

基础评分和排名

直接评分

从小到大被评分最多的应该就是考试了,100,120 或是 150,这三个数字应该从小学一年级一直“陪”我们走过十几载青春。考试的评分算法简单且容易区分,整个系统设置了一个总分,根据不同的表现进行加分或扣分,统计最终得分作为最后的评分。一般情况下成绩是一个近似正态分布的偏态分布,如下图所示。

如果成绩近似正态分布(如上图-中),则说明本次考试难度分布较为均衡;如果成绩分布整体向左偏(如上图-左),则说明本次考试较为困难,学生成绩普遍偏低;如果成绩分布整体向右偏(如上图-右),则说明本次考试较为容易,学生成绩普遍偏高。

除此之外,也可能出现双峰分布,以及峰的陡峭和平缓都能反应考试的不同问题,在此就不再一一展开说明。一般情况下,考试的最终成绩已经能够很好地对学生的能力进行区分,这也正是为什么一般情况我们不会对考试分数做二次处理,而是直接使用。

加权评分

在现实生活中,不同的问题和任务难易程度不同,为了保证「公平」,我们需要赋予困难的任务更多的分数。这一点在试卷中也会有体现,一般而言判断题会比选择题分数更低,毕竟随机作答,判断题仍有 50% 的概率回答正确,但包含四个选项的选择题却仅有 25% 概率回答正确。

加权评分在问题和任务的难易程度与分值之间通过权重进行平衡,但权重的制定并不是一个容易的过程,尤其是在设置一个兼顾客观、公平、合理等多维度的权重时。

考虑时间的评分和排名

Delicious

最简单直接的方法是在一定的时间内统计投票的数量,得票数量高的则为更好的项目。在旧版的 Delicious 中,热门书签排行榜则是根据过去 60 分钟内被收藏的次数进行排名,每 60 分钟重新统计一次。

这种算法的优点是:简单、容易部署、更新快;缺点是:一方面,排名变化不够平滑,前一个小时还排名靠前的内容,往往第二个小时就一落千丈,另一方面,缺乏自动淘汰旧项目的机制,某些热门内容可能会长期占据排行榜前列。

Hacker News

Hacker News 是一个可以发布帖子的网络社区,每个帖子前面有一个向上的三角形,如果用户觉得这个内容好,点击一下即可投票。根据得票数,系统自动统计出热门文章排行榜。

Hacker News 使用分数计算公式如下:

$$ Score = \dfrac{P - 1}{\left(T + 2\right)^G} \label{eq:hacker-news} $$

其中,$P$ 表示帖子的得票数,减去 $1$ 表示忽略发帖人的投票;$T$ 表示当前距离发帖的时间(单位为小时),加上 $2$ 是为了防止最新的帖子分母过小;$G$ 为重力因子,即将帖子排名被往下拉的力量,默认值为 $1.8$。

在其他条件不变的情况下,更多的票数可以获得更高的分数,如果不希望“高票数”帖子和“低票数”帖子之间差距过大,可以在式 $\ref{eq:hacker-news}$ 的分子中添加小于 $1$ 的指数,例如:$\left(P - 1\right)^{0.8}$。在其他条件不变的情况下,随着时间不断流逝,帖子的分数会不断降低,经过 24 小时后,几乎所有帖子的分数都将小于 $1$。重力因子对于分数的影响如下图所示:

不难看出,$G$ 值越大,曲线越陡峭,排名下降的速度越快,意味着排行榜的更新速度越快。

Reddit

不同于 Hacker News,Reddit 中的每个帖子前面都有向上和向下的箭头,分别表示"赞成"和"反对"。用户点击进行投票,Reddit 根据投票结果,计算出最新的热点文章排行榜。

Reddit 关于计算分数的代码可以简要总结如下:

from datetime import datetime, timedelta
from math import log

epoch = datetime(1970, 1, 1)

def epoch_seconds(date):
    td = date - epoch
    return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000)

def score(ups, downs):
    return ups - downs

def hot(ups, downs, date):
    s = score(ups, downs)
    order = log(max(abs(s), 1), 10)
    sign = 1 if s > 0 else -1 if s < 0 else 0
    seconds = epoch_seconds(date) - 1134028003
    return round(order + sign * seconds / 45000, 7)

分数的计算过程大致如下:

  1. 计算赞成票和反对票的差值,即: $$ s = ups - downs $$
  2. 利用如下公式计算中间分数,即: $$ order = \log_{10} \max\left(\left|s\right|, 1\right) $$ 其中,取 $\left|s\right|$ 和 $1$ 的最大值是为了避免当 $s = 0$ 时,无法计算 $\log_{10}{\left|s\right|}$。赞成票与反对票差值越大,得分越高。取以 $10$ 为底的对数,表示当 $s = 10$ 时,这部分为 $1$,只有 $s = 100$ 时才为 $2$,这样设置是为了减缓差值增加对总分的影响程度。
  3. 确定分数的方向,即: $$ sign = \begin{cases} 1 & \text{如果} \ s > 0 \\ 0 & \text{如果} \ s = 0 \\ -1 & \text{如果} \ s < 0 \end{cases} $$
  4. 计算发贴时间距离 2005 年 12 月 8 日 7:46:43(Reddit 的成立时间?)的秒数,即: $$ seconds = \text{timestamp}\left(date\right) - 1134028003 $$
  5. 计算最终分数,即: $$ score = order + sign \times \dfrac{seconds}{45000} $$ 将时间除以 $45000$ 秒(即 12.5 个小时),也就是说当前天的帖子会比昨天的帖子多约 $2$ 分。如果昨天的帖子想要保持住之前的排名,则 $s$ 值需要增加 $100$ 倍才可以。

Reddit 评分排名算法决定了 Reddit 是一个符合大众口味的社区,而不是一个适合展示激进想法的地方。因为评分中使用的是赞成票和反对票的差值,也就是说在其他条件相同的情况下,帖子 A 有 1 票赞成,0 票反对;帖子 B 有 1000 票赞成,1000 票反对,但讨论火热的帖子 B 的得分却比 帖子 A 要少。

Stack Overflow

Stack Overflow 是世界排名第一的程序员问答社区。用户可以在上面提出各种关于编程的问题,等待别人回答;可以对问题进行投票(赞成票或反对票),表示这个问题是不是有价值;也可以对这个回答投票(赞成票或反对票),表示这个回答是不是有价值。

在 Stack Overflow 的页面上,每个问题前面有三个数字,分别为问题的得分、回答的数量和问题的浏览次数。

创始人之一的 Jeff Atwood 公布的评分排名的计算公式如下:

$$ \dfrac{4 \times \log_{10}{Q_{views}} + \dfrac{Q_{answers} \times Q_{score}}{5} + \sum \left(A_{scores}\right)}{\left(\left(Q_{age} + 1\right) - \left(\dfrac{Q_{age} - Q_{updated}}{2}\right)\right)^{1.5}} $$

其中:

  1. $4 \times \log_{10}{Q_{views}}$ 表示问题的浏览次数越多,得分越高,同时利用 $\log_{10}$ 减缓了随着浏览量增大导致得分变高的程度。
  2. $\dfrac{Q_{answers} \times Q_{score}}{5}$ 表示问题的得分(赞成票和反对票之差)越高,回答的数量越多,分数越高。采用乘积的形式意味着即使问题本身的分数再高,没有人回答的问题也算不上热门问题。
  3. $\sum \left(A_{scores}\right)$ 表示问题回答的总分数。回答总分采用了简单的加和,但实际上一个正确的回答要胜过多个无用的回答,简答的加和无法很好的区分这两种不同的情况。
  4. $\left(\left(Q_{age} + 1\right) - \left(\dfrac{Q_{age} - Q_{updated}}{2}\right)\right)^{1.5}$ 可以改写为 $\left(\dfrac{Q_{age}}{2} + \dfrac{Q_{updated}}{2} + 1\right)^{1.5}$$Q_{age}$$Q_{updated}$ 分别表示问题和最近一次回答的时间(单位为小时),也就是说问题时间越久远,最近一次回答时间约久远,分母就会越大,从而得分就会越小。

Stack Overflow 的评分排名算法考虑了参与程度(问题浏览次数和回答次数)、质量(问题分数和回答分数)、时间(问题时间和最近一次回答时间)等多个维度。

不考虑时间的评分和排名

上文中介绍的评分和排名方法多适用于具有时效性的信息,但是对于图书、电影等无需考虑时间因素的情况而言,则需要其他方法进行衡量。

威尔逊区间算法

在不考虑时间的情况下,以「赞成」和「反对」两种评价方式为例,通常我们会有两种最基础的方法计算得分。第一种为绝对分数,即:

$$ \text{评分} = \text{赞成票} - \text{反对票} $$

但这种计算方式有时会存在一定问题,例如:A 获得 60 张赞成票,40 张反对票;B 获得 550 张赞成票,450 张反对票。根据上式计算可得 A 的评分为 20,B 的评分为 100,所以 B 要优于 A。但实际上,B 的好评率仅有 $\dfrac{550}{550 + 450} = 55\%$,而 A 的好评率为 $\dfrac{60}{60 + 40} = 60\%$,因此实际情况应该是 A 优于 B。

这样,我们就得到了第二种相对分数,即:

$$ \text{评分} = \dfrac{\text{赞成票}}{\text{赞成票} + \text{反对票}} $$

这种方式在总票数比较大的时候没有问题,但总票数比较小时就容易产生错误。例如:A 获得 2 张赞成票,0 张反对票;B 获得 100 张赞成票,1 张反对票。根据上式计算可得 A 的评分为 $100\%$,B 的评分为 $99\%$。但实际上 B 应该是优于 A 的,由于 A 的总票数太少,数据不太具有统计意义。

对于这个问题,我们可以抽象出来:

  1. 每个用户的投票都是独立事件。
  2. 用户只有两个选择,要么投赞成票,要么投反对票。
  3. 如果投票总人数为 $n$,其中赞成票为 $k$,则赞成票的比例 $p = \dfrac{k}{n}$

不难看出,上述过程是一个二项实验。$p$ 越大表示评分越高,但是 $p$ 的可信性取决于投票的人数,如果人数太少,$p$ 就不可信了。因此我们可以通过计算 $p$ 的置信区间对评分算法进行调整如下:

  1. 计算每个项目的好评率。
  2. 计算每个好评率的置信区间。
  3. 根据置信区间的下限值进行排名。

置信区间的本质就是对可信度进行修正,弥补样本量过小的影响。如果样本足够多,就说明比较可信,则不需要很大的修正,所以置信区间会比较窄,下限值会比较大;如果样本比较少,就说明不一定可信,则需要进行较大的修正,所以置信区间会比较宽,下限值会比较小。

二项分布的置信区间有多种计算公式,最常见的「正态区间」方法对于小样本准确性较差。1927 年,美国数学家 Edwin Bidwell Wilson 提出了一个修正公式,被称为「威尔逊区间」,很好地解决了小样本的准确性问题。置信区间定义如下:

$$ \frac{1}{1+\frac{z^{2}}{n}}\left(\hat{p}+\frac{z^{2}}{2 n}\right) \pm \frac{z}{1+\frac{z^{2}}{n}} \sqrt{\frac{\hat{p}(1-\hat{p})}{n}+\frac{z^{2}}{4 n^{2}}} $$

其中,$\hat{p}$ 表示样本好评率,$n$ 表示样本大小,$z$ 表示某个置信水平的 z 统计量。

贝叶斯平均算法

在一些榜单中,有时候会出现排行榜前列总是那些票数最多的项目,新项目或者冷门的项目很难有出头机会,排名可能会长期靠后。以世界最大的电影数据库 IMDB 为例,观众可以对每部电影投票,最低为 1 分,最高为 10 分,系统根据投票结果,计算出每部电影的平均得分。

这就出现了一个问题:热门电影与冷门电影的平均得分,是否真的可比?例如一部好莱坞大片有 10000 个观众投票,一部小成本的文艺片可能只有 100 个观众投票。如果使用威尔逊区间算法,后者的得分将被大幅拉低,这样处理是否公平,是否能反映电影的真正质量呢?在 Top 250 榜单中,IMDB 给到的评分排名计算公式如下:

$$ WR = \dfrac{v}{v + m} R + \dfrac{m}{v + m} C $$

其中,$WR$ 为最终的加权得分,$R$ 为该电影用户投票的平均得分,$v$ 为该电影的投票人数,$m$ 为排名前 250 电影的最低投票数,$C$ 为所有电影的平均得分。

从公式中可以看出,分量 $m C$ 可以看作为每部电影增加了评分为 $C$$m$ 张选票。然后再根据电影自己的投票数量 $v$ 和投票平均分 $R$ 进行修正,得到最终的分数。随着电影投票数量的不但增加 $\dfrac{v}{v + m} R$ 占的比重将越来越大,加权得分也会越来越接近该电影用户投票的平均分。

将公式写为更一般的形式,有:

$$ \bar{x}=\frac{C m+\sum_{i=1}^{n} x_{i}}{C+n} $$

其中,$C$ 为需要扩充的投票人数规模,可以根据投票人数总量设置一个合理的常数,$n$ 为当前项目的投票人数,$x$ 为每张选票的值,$m$ 为总体的平均分。这种算法称为「贝叶斯平均」。在这个公式中,$m$ 可以视为“先验概率”,每新增一次投票,都会对最终得分进行修正,使其越来越接近真实的值。

比赛评分和排名

Kaggle 积分

Kaggle 是一个数据建模和数据分析竞赛平台。企业和研究者可在其上发布数据,统计学者和数据挖掘专家可在其上进行竞赛以产生最好的模型。用户以团队形式参加 Kaggle 的比赛,团队可以仅包含自己一人,根据在每场比赛中的排名不断获取积分,用做 Kaggle 网站中的最终排名

早期 Kaggle 对于每场比赛的积分按如下方式计算:

$$ \left[\dfrac{100000}{N_{\text {teammates }}}\right]\left[\text{Rank}^{-0.75}\right]\left[\log _{10}\left(N_{\text {teams }}\right)\right]\left[\dfrac{2 \text { years - time }}{2 \text { years }}\right] $$

在 2015 年对新的排名系统做了调整,新的比赛积分计算公式调整为:

$$ \left[\dfrac{100000}{\sqrt{N_{\text {teammates }}}}\right]\left[\text{Rank}^{-0.75}\right]\left[\log _{10}\left(1+\log _{10}\left(N_{\text {teams }}\right)\right)\right]\left[e^{-t / 500}\right] $$

其中,$N_{\text{teammates}}$ 为团队成员的数量,$\text{Rank}$ 为比赛排名,$N_{\text{teams}}$ 为参赛的团队数量,$t$ 为从比赛结束之日起过去的时间。

第一部分可以视为基础分,团队成员越少,所获得的基础分越多。从调整的文档来看,Kaggle 认为团队合作每个人的贡献程度会大于 $1 / N_{\text {teammates}}$,为了鼓励大家团队合作,Kaggle 减少了对团队人数的基础分惩罚力度。

第二部分则是根据用户在比赛中的排名得到一个小于等于 1 的系数。下图显示了不同的指数以及 $1 / \text{Rank}$ 之间的区别:

从图中可以看出,通过调节指数的大小可以控制系数随排名下降而下降的速度。整体来说,Kaggle 更加重视前几名,对于 10 名开外的选手,系数均小于 $0.2$,且差异不大。

第三部分可以理解为通过参赛的队伍数量来衡量比赛的受欢迎程度(或是在众多参赛队伍中脱颖而出的难易程度)。以 100 和 1000 支参赛队伍对比为例,根据之前的计算公式,这一部分为:

$$ \begin{equation} \begin{aligned} \log_{10} \left(100\right) &= 2 \\ \log_{10} \left(1000\right) &= 3 \end{aligned} \end{equation} $$

但随着 Kaggle 本身比赛流行度越来越高,官方认为赢得一场 1000 人的比赛并不需要比赢得一场 100 人的比赛需要多 $50\%$ 的技能,因此通过调整后的算法,这个比例调整至大约为 $25\%$

$$ \begin{equation} \begin{aligned} \log_{10} \left(\log_{10} \left(100\right) + 1\right) &\approx 0.47 \\ \log_{10} \left(\log_{10} \left(1000\right) + 1\right) &\approx 0.6 \end{aligned} \end{equation} $$

第四部份为时间衰减项,调整为新的计算公式后可以消除原来通过设置 2 年时限导致的积分断崖。如果任何一对个体都没有采取任何进一步的行动,那么排名不应该在任何一对个体之间发生变化。换句话说,如果整个 Kaggle 用户群停止参加比赛,他们的相对排名应该随着时间的推移保持不变。选择 $1 / 500$ 的原因是可以将旧的 2 年断崖延长到更长的时间范围,并且永远不会变为 0。

Elo 评分系统

Elo 评分系统(Elo Rating System)是由匈牙利裔美国物理学家 Arpad Elo 创建的一个衡量各类对弈活动水平的评价方法,是当今对弈水平评估公认的权威标准,且被广泛用于国际象棋、围棋、足球、篮球等运动。网络游戏的竞技对战系统也常采用此评分系统。

Elo 评分系统是基于统计学的一个评估棋手水平的方法。Elo 模型原先采用正态分布,但实践显明棋手的表现并非正态分布,所以现在的评分计分系统通常使用的是逻辑分布。

假设棋手 A 和 B 的当前评分分别为 $R_A$$R_B$,则按照逻辑分布,A 对 B 的胜率期望值为:

$$ E_{A}=\frac{1}{1+10^{\left(R_{B}-R_{A}\right) / 400}} $$

类似的有 B 对 A 的胜率期望值为:

$$ E_{B}=\frac{1}{1+10^{\left(R_{A}-R_{B}\right) / 400}} $$

假如一位棋手在比赛中的真实得分 $S_{A}$(胜 1 分,和 0.5 分,负 0 分)和他的胜率期望值 $E_{A}$ 不同,则他的评分要作相应的调整:

$$ R_{A}^{\prime} = R_{A} + K\left(S_{A}-E_{A}\right) $$

公式中 $R_{A}$$R_{A}^{\prime }$ 分别为棋手调整前后的评分。$K$ 值是一个极限值,代表理论上最多可以赢一个玩家的得分和失分,$K / 2$ 就是相同等级的玩家其中一方胜利后所得的分数。国际象棋大师赛中,$K = 16$;在大部分的游戏规则中,$K = 32$。通常水平越高的比赛中其 $K$ 值越小,这样做是为了避免少数的几场比赛就能改变高端顶尖玩家的排名。$E_A$$E_B$ 中的 $400$ 是让多数玩家积分保持标准正态分布的值,在 $K$ 相同的情况下,分母位置的值越大,积分变化值越小。

Glicko 评分系统

Glicko 评分系统(Glicko Rating System)及 Glicko-2 评分系统(Glicko-2 Rating System)是评估选手在比赛中(如国际象棋及围棋)的技术能力方法之一。此方法由马克·格利克曼发明,原为国际象棋评分系统打造,后作为评分评分系统的改进版本广泛应用 1

Elo 评分系统的问题在于无法确定选手评分的可信度,而 Glicko 评分系统正是针对此进行改进。假设两名评分均为 1700 的选手 A 和 B 在进行一场对战后 A 获得胜利,在美国国际象棋联赛的 Elo 评分系统下,A 选手评分将增长 16,对应的 B 选手评分将下降 16。但是假如 A 选手是已经很久没玩,但 B 选手每周都会玩,那么在上述情况下 A 选手的 1700 评分并不能十分可信地用于评定其实力,而 B 选手的 1700 评分则更为可信。

Glicko 算法的主要贡献是“评分可靠性”(Ratings Reliability),即评分偏差(Ratings Deviation)。若选手没有评分,则其评分通常被设为 1500,评分偏差为 350。新的评分偏差($RD$)可使用旧的评分偏差($RD_0$)计算:

$$ RD = \min \left(\sqrt{RD_0^2 + c^2 t}, 350\right) $$

其中,$t$ 为自上次比赛至现在的时间长度(评分期),常数 $c$ 根据选手在特定时间段内的技术不确定性计算而来,计算方法可以通过数据分析,或是估算选手的评分偏差将在什么时候达到未评分选手的评分偏差得来。若一名选手的评分偏差将在 100 个评分期间内达到 350 的不确定度,则评分偏差为 50 的玩家的常数 $c$ 可通过解 $350 = \sqrt{50^2 + 100 c^2}$,则有 $c = \sqrt{\left(350^2 - 50^2\right) / 100} \approx 34.6$

在经过 $m$ 场比赛后,选手的新评分可通过下列等式计算:

$$ r=r_{0}+\frac{q}{\frac{1}{R D^{2}}+\frac{1}{d^{2}}} \sum_{i=1}^{m} g\left(R D_{i}\right)\left(s_{i}-E\left(s \mid r_{0}, r_{i}, R D_{i}\right)\right) $$

其中:

$$ \begin{equation*} \begin{aligned} & g\left(R D_{i}\right) = \frac{1}{\sqrt{1+\frac{3 q^{2}\left(R D_{i}^{2}\right)}{\pi^{2}}}} \\ & E\left(s \mid r, r_{i}, R D_{i}\right) = \frac{1}{1+10\left(\frac{g\left(R D_{i}\right)\left(r_{0}-r_{i}\right)}{-400}\right)} \\ & q = \frac{\ln (10)}{400}=0.00575646273 \\ & d^{2} = \frac{1}{q^{2} \sum_{i=1}^{m}\left(g\left(R D_{i}\right)\right)^{2} E\left(s \mid r_{0}, r_{i}, R D_{i}\right)\left(1-E\left(s \mid r_{0}, r_{i}, R D_{i}\right)\right)} \end{aligned} \end{equation*} $$

$r_i$ 表示选手个人评分,$s_i$ 表示每场比赛后的结果。胜利为 $1$,平局为 $1 / 2$,失败为 $0$

原先用于计算评分偏差的函数应增大标准差值,进而反应模型中一定非观察时间内,玩家的技术不确定性的增长。随后,评分偏差将在几场游戏后更新:

$$ R D^{\prime}=\sqrt{\left(\frac{1}{R D^{2}}+\frac{1}{d^{2}}\right)^{-1}} $$

Glicko-2 评分系统

Glicko-2 算法与原始 Glicko 算法类似,增加了一个评分波动率 $\sigma$,它根据玩家表现的不稳定程度来衡量玩家评分的预期波动程度。例如:当一名球员的表现保持稳定时,他们的评分波动性会很低,如果他们在这段稳定期之后取得了异常强劲的成绩,那么他们的评分波动性就会增加 1

Glicko-2 算法的简要步骤如下:

计算辅助量

在一个评分周期内,当前评分为 $\mu$ 和评分偏差为 $\phi$ 的玩家与 $m$ 个评分为 $\mu_1, \cdots, \mu_m$ 和评分偏差为 $\phi_1, \cdots, \phi_m$ 的玩家比赛,获得的分数为 $s_1, \cdots, s_m$,我们首先需要计算辅助量 $v$$\Delta$

$$ \begin{aligned} v &= \left[\sum_{j=1}^{m} g\left(\phi_{j}\right)^{2} E\left(\mu, \mu_{j}, \phi_{j}\right)\left\{s_{j}-E\left(\mu, \mu_{j}, \phi_{j}\right)\right\}\right]^{-1} \\ \Delta &= v \sum_{j=1}^{m} g\left(\phi_{j}\right)\left\{s_{j}-E\left(\mu, \mu_{j}, \phi_{j}\right)\right\} \end{aligned} $$

其中:

$$ \begin{equation*} \begin{aligned} &g(\phi)=\frac{1}{\sqrt{1+3 \phi^{2} / \pi^{2}}}, \\ &E\left(\mu, \mu_{j}, \phi_{j}\right)=\frac{1}{1+\exp \left\{-g\left(\phi_{j}\right)\left(\mu-\mu_{j}\right)\right\}} \end{aligned} \end{equation*} $$

确定新的评分波动率

选择一个小的常数 $\tau$ 来限制时间的波动性,例如:$\tau = 0.2$(较小的 $\tau$ 值可以防止剧烈的评分变化),对于:

$$ f(x)=\frac{1}{2} \frac{e^{x}\left(\Delta^{2}-\phi^{2}-v^{2}-e^{x}\right)}{\left(\phi^{2}+v+e^{x}\right)^{2}}-\frac{x-\ln \left(\sigma^{2}\right)}{\tau^{2}} $$

我们需要找到满足 $f\left(A\right) = 0$ 的值 $A$。解决此问题的一种有效方法是使用 Illinois 算法,一旦这个迭代过程完成,我们将新的评级波动率 $\sigma'$ 设置为:

$$ \sigma' = e^{\frac{A}{2}} $$

确定新的评分偏差和评分

之后得到新的评分偏差:

$$ \phi^{\prime} = \dfrac{1}{\sqrt{\dfrac{1}{\phi^{2}+\sigma^{\prime 2}}+\dfrac{1}{v}}} $$

和新的评分:

$$ \mu^{\prime} = \mu+\phi^{\prime 2} \sum_{j=1}^{m} g\left(\phi_{j}\right)\left\{s_{j}-E\left(\mu, \mu_{j}, \phi_{j}\right)\right\} $$

需要注意这里的评分和评分偏差与原始 Glicko 算法的比例不同,需要进行转换才能正确比较两者。

TrueSkill 评分系统

TrueSkill 评分系统是基于贝叶斯推断的评分系统,由微软研究院开发以代替传统 Elo 评分系统,并成功应用于 Xbox Live 自动匹配系统。TrueSkill 评分系统是 Glicko 评分系统的衍伸,主要用于多人游戏中。TrueSkill 评分系统考虑到了个别玩家水平的不确定性,综合考虑了各玩家的胜率和可能的水平涨落。当各玩家进行了更多的游戏后,即使个别玩家的胜率不变,系统也会因为对个别玩家的水平更加了解而改变对玩家的评分 2

在电子竞技游戏中,特别是当有多名选手参加比赛的时候需要平衡队伍间的水平,让游戏比赛更加有意思。这样的一个参赛选手能力平衡系统通常包含以下三个模块:

  1. 一个包含跟踪所有玩家比赛结果,记录玩家能力的模块。
  2. 一个对比赛成员进行配对的模块。
  3. 一个公布比赛中各成员能力的模块。

能力计算和更新

TrueSkill 评分系统是针对玩家能力进行设计的,以克服现有排名系统的局限性,确保比赛双方的公平性,可以在联赛中作为排名系统使用。TrueSkill 评分系统假设玩家的水平可以用一个正态分布来表示,而正态分布可以用两个参数:平均值和方差来完全描述。设 Rank 值为 $R$,代表玩家水平的正态分布的两个参数平均值和方差分别为 $\mu$$\sigma$,则系统对玩家的评分即 Rank 值为:

$$ R = \mu - k \times \sigma $$

其中,$k$ 值越大则系统的评分越保守。

上图来自 TrueSkill 网站,钟型曲线为某个玩家水平的可能分布,绿色区域是排名系统的信念,即玩家的技能在 15 到 20 级之间。

下表格给出了 8 个新手在参与一个 8 人游戏后 $\mu$$\sigma$ 的变化。

姓名 排名 赛前 $\mu$ 赛前 $\sigma$ 赛后 $\mu$ 赛后 $\sigma$
Alice 1 25 8.3 36.771 5.749
Bob 2 25 8.3 32.242 5.133
Chris 3 25 8.3 29.074 4.943
Darren 4 25 8.3 26.322 4.874
Eve 5 25 8.3 23.678 4.874
Fabien 6 25 8.3 20.926 4.943
George 7 25 8.3 17.758 5.133
Hillary 8 25 8.3 13.229 5.749

第 4 名 Darren 和第 5 名 Eve,他们的 $\sigma$ 是最小的,换句话说系统认为他们能力的可能起伏是最小的。这是因为通过这场游戏我们对他们了解得最多:他们赢了3 和 4 个人,也输给了 4 和 3 个人。而对于第 1 名 Alice,我们只知道她赢了 7 个人。

定量分析可以先考虑最简单的两人游戏情况:

$$ \begin{aligned} &\mu_{\text {winner }} \longleftarrow \mu_{\text {winner }}+\frac{\sigma_{\text {winner }}^{2}}{c} * v\left(\frac{\mu_{\text {winner }}-\mu_{\text {loser }}}{c}, \frac{\varepsilon}{c}\right) \\ &\mu_{\text {loser }} \longleftarrow \mu_{\text {loser }}-\frac{\sigma_{\text {loser }}^{2}}{c} * v\left(\frac{\mu_{\text {winner }}-\mu_{\text {loser }}}{c}, \frac{\varepsilon}{c}\right) \\ &\sigma_{\text {winner }}^{2} \longleftarrow \sigma_{\text {uninner }}^{2} *\left[1-\frac{\sigma_{\text {winner }}^{2}}{c} * w\left(\frac{\mu_{\text {winner }}-\mu_{\text {loser }}}{c}, \frac{\varepsilon}{c}\right)\right. \\ &\sigma_{\text {loser }}^{2} \longleftarrow \sigma_{\text {loser }}^{2} *\left[1-\frac{\sigma_{\text {loser }}^{2}}{c} * w\left(\frac{\mu_{\text {winner }}-\mu_{\text {loser }}}{c}, \frac{\varepsilon}{c}\right)\right. \\ &c^{2}=2 \beta^{2}+\sigma_{\text {winner }}^{2}+\sigma_{\text {loser }}^{2} \end{aligned} $$

其中,系数 $\beta^2$ 代表的是所有玩家的平均方差,$v$$w$ 是两个函数,比较复杂,$\epsilon$ 是平局参数。简而言之,个别玩家赢了 $\mu$ 就增加,输了 $\mu$ 减小;但不论输赢,$\sigma$ 都是在减小,所以有可能出现输了涨分的情况。

对手匹配

势均力敌的对手能带来最精彩的比赛,所以当自动匹配对手时,系统会尽可能地为个别玩家安排可能水平最为接近的对手。TrueSkill 评分系统采用了一个值域为 $(0, 1)$ 的函数来描述两个人是否势均力敌:结果越接近 0 代表差距越大,越接近 1 代表水平越接近。

假设有两个玩家 A 和 B,他们的参数为 $(\mu_A, \sigma_A)$$(\mu_B, \sigma_B)$,则函数对这两个玩家的返回值为:

$$ e^{-\frac{\left(\mu_{A}-\mu_{B}\right)^{2}}{2 c^{2}}} \sqrt{\frac{2 \beta^{2}}{c^{2}}} $$

$c$ 的值由如下公式给出:

$$ c^{2}=2 \beta^{2}+\mu_{A}^{2}+\mu_{B}^{2} $$

如果两人有较大几率被匹配在一起,仅是平均值接近还不行,还需要方差也比较接近才可以。

在 Xbox Live 上,系统为每个玩家赋予的初值是 $\mu = 25$$\sigma = \dfrac{25}{3}$$k = 3$。所以玩家的起始 Rank 值为:

$$ R=25-3 \frac{25}{3}=0 $$

相较 Elo 评价系统,TrueSkill 评价系统的优势在于:

  1. 适用于复杂的组队形式,更具一般性。
  2. 有更完善的建模体系,容易扩展。
  3. 继承了贝叶斯建模的优点,如模型选择等。

本文主要参考了阮一峰的系列文章「基于用户投票的排名算法」和钱魏的「游戏排名算法:Elo、Glicko、TrueSkill」。

小记这一波裁员浪潮

2022年4月10日 08:00

一眨眼已经四月了,第一季度穿插着反反复复的疫情、说干就干的战争和悲痛万分的空难就这么过去了。还在用生活不易、活着真好的话宽慰自己的时候,互联网迎来了一大波裁员浪潮。说实话其实每年都在陆陆续续的传出各大厂的裁员消息,可今年这一波着实动静大了些。翻看已经有两三篇待完成的博客躺在草稿箱中有个把月了,工作和生活的各种乱七八糟的事情扰得自己完全没有动笔的欲望。算是为了不让博客列表页有太长断档吧,小记一下最近的心情,也让自己冷静下来思考二三。

人都去哪儿了?

在这波裁员浪潮之前,工作上除了项目本身以外,最重要的就是招聘。从最初组建团队一直到现在招聘就像一个看似永远完不成的项目。摆在我面前的招聘三大问题:我看上的人家不一定看得上我,看得上我的我不一定看得上人家,都看上了的因为各种各样的不可控因素最后还是没谈成。

有时候从源头上就捞不到人,没有简历,约不上面,不可否认一些岗位的门槛和稀缺性注定这就是一场艰难的战役。但从个人主观感受上来看,逃离互联网的人可能正在增长。这个观点没有任何调研为依据,单纯的从身边的案例有感,不具备统计显著性,但也不接受反驳。为什么要逃离,或许逃离这个词用的就不好,应该说是「离开」更好些,不然显得当下的互联网有多么水深火热一样。因为大环境、家庭、亦或是躺平的心态,都很难说,自己没处在这么一个情景中,也说不出个四五到六来。

很难说未来自己会不会离开互联网这个圈子,如果离开也很难说这个时点有多近还是多远,无论主动还是被动。只能说当下的互联网仍旧在不断给予我机会和挑战,在没有被困难打倒之前,我应该还是会在这条路上拼杀几载吧。

每天都在忙些啥?

我属于一个不太喜欢开会的人,可能我的层次还不太高吧,我认为大部分事情都没法在相对正式的会议上达成很好的决策,我更喜欢非正式的沟通和正式的记录配合达成目标。所以排除开会,每天到底都在忙些啥?

想要的太多,不舍得放手的太多

人总是贪婪的,虽说给永远比拿快乐,但有时候就喜欢圈起来个一亩三分地,当然圈的越多也越好。其实道理自己都清楚,东西太多可能哪个都做不好,但有时候执念上来了,拦都拦不住。和当年的关不掉的浏览器标签页一样,不舍得放手。还是得多学会放手,一方面是给自己减负,另一方面也是给别人机会,这怎么说得我好像有多大权力似的 😂。

时间管理

为真,是一个需要相对长远看待的问题,没有哪个真理是三两天就弄出来的。为需,才是第一重要的事情,光想着要做的多大多完美,不想想再不做点为所需的东西,团队都快没了,还谈什么理想。以一个明星项目养活俩仨探索项目,是我有时候会偷偷使用的小伎俩,先把本职的活儿干好,如果再时不时的整两个惊喜出来岂不美哉。就算没弄出来惊喜,只要没搞出来惊吓,至少我把分内的事儿做的没啥大毛病,老板也不会过来挑你啥。

但我坚信,创新才是第一动力,太本分不好。

努力不被「淘汰」?

有时候说被「淘汰」未免过分了些,组织淘汰人是「组织」认为「你」不再适合「组织」了,就像搞对象一样,「我」说「你」不是「我」喜欢的,还是有主观性的,没准儿人家到了别处还是顶梁柱嘞。怎么才能成为一代海王呢,个人观点,永无止境的学习是很重要的。

学啥?大点儿来说别学坏就好,当然好与坏,这又是个问题。我想表达的意思是,学些和工作有关的和学些和工作无关的都挺好的。两者之于工作都是有正向作用的,就比如这一年,我的这辆小摩托帮我结交了几个不错的朋友,帮我在不开心的时候变开心,开心的时候变得更开心,这还不够吗?学些和工作相关的技能那就更有益了,自己这点做的还是不太好,虽然能跟得上前沿的脚步,但也都是略知皮毛,开拓深耕些不熟悉的地方还是挺费力费时的。

怎么学?还记得之前大学时候管理学课上老师总说的一句话:读万卷书不如行万里路,行万里路不如交万名友。对于社牛症患者,Social 大可不必多说。对于有交通便利的朋友(比如有辆小摩托 🏍 的我 😎),多和大自然接触接触还是舒服的,切记,安全第一。所有里面,效果相对不足的可能就是读书了,读书真的是成本很低的事情,时间可能是最需要消耗的东西,读书又确确实实是一件性价比很高的事情。这一年读的不多,暂且就拿客观因素当当借口吧。对于读书有一点我很受用,就是总结整理,吸进来些东西,消化消化,再创造些东西,不一定非要有多深的思考,哪怕是简单的归纳整理,写出来感觉一下子就不一样了。

活到老,学到老,这话在理。

万一万一,退路在哪里?

我是一个不太喜欢承受高风险的人,所以往往做事之前我都会尽可能想到最差的情况,多想想退路,好像也没什么毛病,是吧?万一万一,命运不济,有此一劫,我又该如何度过?一些具有高不可替代性的人不知道会不会思考这个问题,他们出现万一万一的概率太小了,我自认为还不是他们那种人,换句话,公司没了我照样转,往小了说,部门没了我照样转,最多卡个把月。

真正的退路也是需要很多客观条件支持的,比如雄厚的家庭资产,那我会选择回家继承我的百亩良田。再比如另一门硬手艺,大不了我换个行业,依旧可以做的风风火火。很不幸,这俩我都不符合,之前有时会也会问自己,如果有一天不能写代码了,我还能干点啥?我的菜属实做的还不错,开个饭馆没准儿能凑合,然而你抵挡不了天灾,疫情对餐饮、旅游、文娱的冲击真的不小。

那怎么办,没能力躺平,没实力单干,就只能坐等幸运女神眷顾了吗?作为一个「普通」人,我想也还是有些法子的,至少能让我们在遇见万一万一的时候可以更加从容的面对,快速顺利的找到下家,度过去,这不也是一条好路吗。时刻保持警惕感、夯实自己的核心能力、关注行业前沿动态、拓展个人认知范围,几点看似废话的东西我认为挺有用的,重点看你怎么去落实了。

光说不练假把式。

❌
❌