<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Lenardar</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://lenardar.github.io/</id>
  <link href="https://lenardar.github.io/" rel="alternate"/>
  <link href="https://lenardar.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Lenardar</rights>
  <subtitle>
    <![CDATA[呀哈喽！我是 Lenardar，硕士在读。<br>平时用 Python 和 R 干活，喜欢造点小轮子、折腾各种工作流。<br>博客随缘更新，写点技术记录和碎碎念，有问题欢迎邮件联系我～]]>
  </subtitle>
  <title>Lenardar's Blog</title>
  <updated>2026-07-30T12:04:54.000Z</updated>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="折腾" scheme="https://lenardar.github.io/tags/%E6%8A%98%E8%85%BE/"/>
    <category term="AI" scheme="https://lenardar.github.io/tags/AI/"/>
    <content>
      <![CDATA[<p>Modal 的免费额度很慷慨：注册就送每月 $30 算力，而且它的 Endpoints 功能一条命令就能把开源大模型架成推理服务，闲时自动缩到零。拿它白嫖一个 Kimi K3，条件近乎完美。</p><p>但真接进日常工具，总觉得哪里都差半口气：官方确实提供了 OpenAI 兼容接口，能直连，可用起来别别扭扭。与其凑合，不如自己造一个顺手的。于是决定用 Cloudflare Workers 做一层转发，把 Modal 端点包成一个干干净净的 API。</p><p>流水账记录一下。照例，这篇由 Fable 5 代笔，哈哈。</p><h1 id="一条命令，Kimi-K3-就有了"><a href="#一条命令，Kimi-K3-就有了" class="headerlink" title="一条命令，Kimi K3 就有了"></a>一条命令，Kimi K3 就有了</h1><p>Modal Endpoints 支持 Qwen、Kimi、DeepSeek、GLM、GPT-OSS 等一大票开源模型，部署真的只要一条命令：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">modal endpoint create --model moonshotai/Kimi-K3</span><br></pre></td></tr></table></figure><p>Modal 会自动解析模型、挑选推理引擎和 GPU 配方，然后给你一个专属端点，URL 长这样：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://&lt;workspace&gt;--ep-kimi-k3-server.us-west.modal.direct</span><br></pre></td></tr></table></figure><p>重点是计费方式：只按容器实际运行的算力收费，负载上来自动扩容，没人用就缩到零。对“每月 $30 额度、坚决白嫖”的个人用户来说，scale-to-zero 就是最重要的功能，没有之一。</p><h1 id="官方直连：两个-token-拼一个-key"><a href="#官方直连：两个-token-拼一个-key" class="headerlink" title="官方直连：两个 token 拼一个 key"></a>官方直连：两个 token 拼一个 key</h1><p>端点默认带鉴权，先创建一对 proxy token：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">modal workspace proxy-tokens create</span><br></pre></td></tr></table></figure><p>会得到一个 token ID（<code>wk-</code> 开头）和一个 secret（<code>ws-</code> 开头，只显示这一次，赶紧存好）。官方的用法很有意思：把两个 token 中间加一个点拼起来，当成一个普通的 API key 用：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Authorization: Bearer wk-&lt;id&gt;.ws-&lt;secret&gt;</span><br></pre></td></tr></table></figure><p>而端点本身就提供 OpenAI Chat Completions 接口，路径在 <code>/v1</code> 下。所以理论上任何 OpenAI 兼容客户端都能直连：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> openai <span class="keyword">import</span> OpenAI</span><br><span class="line"></span><br><span class="line">client = OpenAI(</span><br><span class="line">    base_url=<span class="string">&quot;https://&lt;workspace&gt;--ep-kimi-k3-server.us-west.modal.direct/v1&quot;</span>,</span><br><span class="line">    api_key=<span class="string">&quot;wk-&lt;id&gt;.ws-&lt;secret&gt;&quot;</span>,</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line">client.chat.completions.create(</span><br><span class="line">    model=<span class="string">&quot;moonshotai/Kimi-K3&quot;</span>,</span><br><span class="line">    messages=[&#123;<span class="string">&quot;role&quot;</span>: <span class="string">&quot;user&quot;</span>, <span class="string">&quot;content&quot;</span>: <span class="string">&quot;你好呀&quot;</span>&#125;],</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>官网还提供了一个统一入口 <code>https://inference.us-west.modal.direct/v1</code>，同一个拼接 key 也能用。</p><p>到这一步其实已经“能用”了，但真往日常工具里塞，就发现几个别扭的地方：</p><ul><li><strong>模型名诡异</strong>。尤其走统一入口时，模型名不是干净的 <code>kimi-k3</code>，而是一长串带端点信息的名字。不少客户端会拿模型名做展示甚至能力判断，要么难看，要么直接出岔子，搞不好还得再垫一层名字转换。</li><li><strong>真凭证满天飞</strong>。<code>wk-</code>&#x2F;<code>ws-</code> 这对 token 是 workspace 级的门票，每个工具里都贴一份，哪天想换 token，得挨个客户端改一遍。</li><li><strong>单账号额度有限</strong>。$30 烧完或者端点被打挂，就只能干瞪眼。</li></ul><h1 id="我的方案：让-Cloudflare-Worker-看大门"><a href="#我的方案：让-Cloudflare-Worker-看大门" class="headerlink" title="我的方案：让 Cloudflare Worker 看大门"></a>我的方案：让 Cloudflare Worker 看大门</h1><p>既然横竖要垫一层，干脆把这一层做成自己的“前台”：一个不到一百行的 Cloudflare Worker，对外只暴露我自己发的 API key，对内替我保管 Modal 凭证。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">各种客户端</span><br><span class="line">    ↓ Bearer 我自己发的 key</span><br><span class="line">Cloudflare Worker（kimi-proxy）</span><br><span class="line">    ↓ Modal-Key / Modal-Secret</span><br><span class="line">Modal 主账号（用到底） → 备用账号（主账号出问题才接手）</span><br><span class="line">    ↓</span><br><span class="line">Kimi K3 端点</span><br></pre></td></tr></table></figure><p>这里有个恰到好处的细节：Modal 除了 <code>Authorization: Bearer</code> 的拼接写法，还支持把 token 拆成两个独立的请求头传过去：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Modal-Key: wk-...</span><br><span class="line">Modal-Secret: ws-...</span><br></pre></td></tr></table></figure><p>文档里说，这条通道就是为“<code>Authorization</code> 头要留给别的 token”的场景准备的。正好：<code>Authorization</code> 让出来放我自己的 key，Modal 凭证走专用头，两层鉴权互不打架。</p><h1 id="主备账号和故障切换"><a href="#主备账号和故障切换" class="headerlink" title="主备账号和故障切换"></a>主备账号和故障切换</h1><p>Worker 里配了两个 Modal 账号（也就是两份每月 $30），但不做负载均衡：所有请求固定先走主账号，只有主账号自身出问题——凭证失效（401）、额度耗尽（402&#x2F;403）、限流（429）、服务端错误（5xx）——才切到备用账号重试。至于 400 这类请求本身的错误，换个账号也一样失败，不折腾：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">shouldFailOver</span>(<span class="params">response</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> [<span class="number">401</span>, <span class="number">402</span>, <span class="number">403</span>, <span class="number">429</span>].<span class="title function_">includes</span>(response.<span class="property">status</span>) || response.<span class="property">status</span> &gt;= <span class="number">500</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> [primary, backup] = accounts;</span><br><span class="line"><span class="keyword">const</span> retryRequest = request.<span class="title function_">clone</span>();</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> firstResponse = <span class="keyword">await</span> <span class="title function_">fetch</span>(<span class="title function_">createUpstreamRequest</span>(request, primary));</span><br><span class="line"><span class="keyword">if</span> (!<span class="title function_">shouldFailOver</span>(firstResponse)) &#123;</span><br><span class="line">  <span class="keyword">return</span> firstResponse;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">await</span> firstResponse.<span class="property">body</span>?.<span class="title function_">cancel</span>();</span><br><span class="line"><span class="keyword">return</span> <span class="title function_">fetch</span>(<span class="title function_">createUpstreamRequest</span>(retryRequest, backup));</span><br></pre></td></tr></table></figure><p>一开始也想过随机分流，让两个账号平摊额度，后来想明白对这种场景是负优化，关键在缓存：</p><ul><li><strong>前缀缓存要集中</strong>。聊天和 agent 类请求会反复携带同一段长前缀——系统提示词、越滚越长的对话历史，推理引擎会缓存这部分计算结果，命中了就不用重算。随机分流等于把请求拆到两个互不相通的端点上，缓存命中率直接腰斩，而每一次未命中，都是实打实按 GPU 时间计费的重新预填充，又慢又贵。</li><li><strong>scale-to-zero 要成全</strong>。流量集中在主账号，备用账号的端点就能安安稳稳缩在零上，一分钱不烧；随机分流则是两边容器轮流被唤醒，闲置消耗直接翻倍。</li></ul><p>所以现在的分工是：主账号用到底，备用账号纯待机，主账号限流或者抽风时才顶上，客户端全程无感。哪天主账号的 $30 真烧干了，多半也是以这几类账号级错误的面目出现，请求会自动落到备用账号头上——用到底，烧干自动换。</p><p>这里有一个小坑：请求的 body 是流，只能被读一次。第一次转发就把它消费掉了，等想故障切换时已经没有 body 可发了。所以要在首发之前 <code>request.clone()</code> 留个底，重试时用副本。另外失败响应的流记得 <code>body?.cancel()</code> 释放掉，不然它会一直占着连接。</p><h1 id="部署"><a href="#部署" class="headerlink" title="部署"></a>部署</h1><p>代码全部加起来九十行左右，部署就是标准 wrangler 流程，凭证全部塞进 secret：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">wrangler deploy</span><br><span class="line">wrangler secret put CLIENT_API_KEY   <span class="comment"># 自己生成一个长随机串</span></span><br><span class="line">wrangler secret put MODAL_KEY</span><br><span class="line">wrangler secret put MODAL_SECRET</span><br><span class="line">wrangler secret put MODAL_KEY_2</span><br><span class="line">wrangler secret put MODAL_SECRET_2</span><br></pre></td></tr></table></figure><p>之后所有工具里的配置就统一成了：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">base_url: https://kimi-proxy.&lt;你的子域&gt;.workers.dev/v1</span><br><span class="line">api_key:  自己发的那个 key</span><br><span class="line">model:    moonshotai/Kimi-K3</span><br></pre></td></tr></table></figure><p>上游换账号、换 token，甚至哪天把 Kimi K3 换成别的模型，客户端配置一个字都不用改。</p><h1 id="算账环节"><a href="#算账环节" class="headerlink" title="算账环节"></a>算账环节</h1><ul><li>Modal：两个账号，每月各 $30 免费额度，端点闲时缩到零，零消耗</li><li>Cloudflare Workers：免费计划目前每天 10 万次请求，对个人使用是天文数字</li><li>合计：￥0</li></ul>]]>
    </content>
    <id>https://lenardar.github.io/2026/07/30/Kimi%20K3%E7%99%BD%E5%AB%96%EF%BC%8CCloudflare%20Workers%E7%AB%8B%E5%A4%A7%E5%8A%9F/</id>
    <link href="https://lenardar.github.io/2026/07/30/Kimi%20K3%E7%99%BD%E5%AB%96%EF%BC%8CCloudflare%20Workers%E7%AB%8B%E5%A4%A7%E5%8A%9F/"/>
    <published>2026-07-30T12:04:54.000Z</published>
    <summary>
      <![CDATA[<p>Modal 的免费额度很慷慨：注册就送每月 $30 算力，而且它的 Endpoints 功能一条命令就能把开源大模型架成推理服务，闲时自动缩到零。拿它白嫖一个 Kimi K3，条件近乎完美。</p>
<p>但真接进日常工具，总觉得哪里都差半口气：官方确实提供了 OpenAI]]>
    </summary>
    <title>Kimi K3 白嫖，Cloudflare Workers 立大功</title>
    <updated>2026-07-30T12:04:54.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="折腾" scheme="https://lenardar.github.io/tags/%E6%8A%98%E8%85%BE/"/>
    <category term="AI" scheme="https://lenardar.github.io/tags/AI/"/>
    <content>
      <![CDATA[<p>昨天才给博客做完大扫除，今天又没忍住继续装修。原计划只是把 GitHub Pages 同步到 Cloudflare Pages，结果一路从双轨部署折腾到 Workers AI，最后还在右下角雇了一只会疯狂拍键盘的抽象猫。</p><p>事情是怎么从“多部署一个 Pages”发展到“给博客养猫”的，流水账记录一下，哈哈。</p><h1 id="先理清两个仓库"><a href="#先理清两个仓库" class="headerlink" title="先理清两个仓库"></a>先理清两个仓库</h1><p>我的 Hexo 博客现在涉及两个 GitHub 仓库：</p><ul><li><code>lenardar/blog</code>：保存 Markdown、图片、主题和配置，也就是博客源码</li><li><code>lenardar/lenardar.github.io</code>：保存 Hexo 生成后的静态文件，用于 GitHub Pages</li></ul><p>平时写文章和修改主题都在 <code>blog</code> 仓库。Hexo 负责把这些源码生成到 <code>public/</code>，<code>lenardar.github.io</code> 则只是发布目录，不适合直接编辑。</p><p>一开始，这套发布过程需要在本地手动运行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">npm run clean</span><br><span class="line">npm run build</span><br><span class="line">npm run deploy</span><br></pre></td></tr></table></figure><h1 id="GitHub-Pages-Cloudflare-Pages-双轨部署"><a href="#GitHub-Pages-Cloudflare-Pages-双轨部署" class="headerlink" title="GitHub Pages + Cloudflare Pages 双轨部署"></a>GitHub Pages + Cloudflare Pages 双轨部署</h1><p>Cloudflare Pages 直接连接 <code>lenardar/blog</code> 的 <code>main</code> 分支，配置也很简单：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Build command: npm run build</span><br><span class="line">Build output directory: public</span><br></pre></td></tr></table></figure><p>以后只要把源码推到 <code>blog</code>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git push origin main</span><br></pre></td></tr></table></figure><p>Cloudflare Pages 就会自动安装依赖、运行 Hexo、发布 <code>public/</code>。原来的 GitHub Pages 发布路线也继续保留。</p><p>后来我又把 GitHub Actions 接了进来，让 GitHub Pages 也不再依赖本地手动部署。现在每次向 <code>blog/main</code> 推送代码，工作流会自动安装依赖、运行 Hexo，再把生成的 <code>public/</code> 推到 <code>lenardar/lenardar.github.io</code>：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">name:</span> <span class="string">Deploy</span> <span class="string">GitHub</span> <span class="string">Pages</span></span><br><span class="line"></span><br><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">push:</span></span><br><span class="line">    <span class="attr">branches:</span> [<span class="string">main</span>]</span><br><span class="line">  <span class="attr">workflow_dispatch:</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jobs:</span></span><br><span class="line">  <span class="attr">build-and-deploy:</span></span><br><span class="line">    <span class="attr">runs-on:</span> <span class="string">ubuntu-latest</span></span><br><span class="line">    <span class="attr">steps:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/checkout@v6</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">actions/setup-node@v6</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">node-version:</span> <span class="number">24</span></span><br><span class="line">          <span class="attr">cache:</span> <span class="string">npm</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">npm</span> <span class="string">ci</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">run:</span> <span class="string">npm</span> <span class="string">run</span> <span class="string">build</span></span><br><span class="line"></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">peaceiris/actions-gh-pages@v4</span></span><br><span class="line">        <span class="attr">with:</span></span><br><span class="line">          <span class="attr">personal_token:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.PAGES_DEPLOY_TOKEN</span> <span class="string">&#125;&#125;</span></span><br><span class="line">          <span class="attr">external_repository:</span> <span class="string">lenardar/lenardar.github.io</span></span><br><span class="line">          <span class="attr">publish_branch:</span> <span class="string">main</span></span><br><span class="line">          <span class="attr">publish_dir:</span> <span class="string">./public</span></span><br></pre></td></tr></table></figure><p>这里使用 <code>PAGES_DEPLOY_TOKEN</code>，是因为工作流要从源码仓库跨仓库推送到发布仓库。Token 只授权目标仓库的 Contents 读写权限，再保存为源码仓库的 Actions Secret 即可。</p><p>所以准确地说，并不是“不用 Hexo 了”，而是“不用我亲自运行 Hexo 了”：Cloudflare Pages 和 GitHub Actions 都会在云端执行 <code>npm run build</code>，本地的 <code>npm run deploy</code> 只作为备用方案。</p><p>最终就变成了两条互不冲突的发布路线：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">push 到 blog/main</span><br><span class="line">        │</span><br><span class="line">        ├─ Cloudflare Pages ── npm run build ── lenardar.pages.dev</span><br><span class="line">        │                                      （AI 小猫完整功能）</span><br><span class="line">        │</span><br><span class="line">        └─ GitHub Actions ─── npm ci + build ── lenardar.github.io</span><br><span class="line">                                                 │</span><br><span class="line">                                                 └─ blog.ilovemilktea.top</span><br><span class="line">                                                    （目前指向这里）</span><br></pre></td></tr></table></figure><p>现在博客可以从三个地址访问：</p><ul><li><code>blog.ilovemilktea.top</code>：自己的域名，目前指向 GitHub Pages</li><li><code>lenardar.github.io</code>：GitHub Pages 的原生地址</li><li><code>lenardar.pages.dev</code>：Cloudflare Pages 地址，支持完整的 AI 小猫功能</li></ul><p>三个入口背后仍然是同一份博客源码，两条构建路线也没有所谓的“优先级”。好处是迁移和回退都轻松：Cloudflare 出问题还有 GitHub Pages，GitHub Pages 有问题也不影响 Cloudflare。</p><h1 id="为什么只有-API-经过-Functions"><a href="#为什么只有-API-经过-Functions" class="headerlink" title="为什么只有 API 经过 Functions"></a>为什么只有 API 经过 Functions</h1><p>Cloudflare Pages 不只可以放静态文件，还支持 Pages Functions。为了不让每一个 CSS、图片和文章请求都经过函数，我在 Hexo 的 <code>source/_routes.json</code> 里只开放了 API 路径：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;version&quot;</span><span class="punctuation">:</span> <span class="number">1</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;include&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;/api/*&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;exclude&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>这里还有一个小坑：Hexo 默认会忽略以下划线开头的文件，所以要在 <code>_config.yml</code> 里显式包含：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">include:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">_routes.json</span></span><br></pre></td></tr></table></figure><p>否则本地明明有 <code>_routes.json</code>，构建后的 <code>public/</code> 里却找不到，Cloudflare 自然也不会应用路由。</p><h1 id="然后，博客开始养猫"><a href="#然后，博客开始养猫" class="headerlink" title="然后，博客开始养猫"></a>然后，博客开始养猫</h1><p>最初的想法很简单：访客打开博客时，让右下角的小宠物随机说一句欢迎词。既然 Workers AI 每天有免费额度，而且博客访问量也不大，那就干脆每次实时生成。</p><p>后来又觉得，只会打招呼有点浪费，于是继续加了：</p><ul><li>总结当前文章</li><li>用简单语言解释核心概念</li><li>从博客里推荐相关文章</li><li>接受访客自由提问</li></ul><p>前端会读取当前页面正文和 Hexo 已有的 <code>search.xml</code>，在浏览器本地匹配最多三篇相关文章，再把截断后的上下文发给 <code>/api/chat</code>。这样不需要额外维护数据库、向量库或者搜索服务。</p><p>大概是这条链路：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">当前文章 + search.xml</span><br><span class="line">        ↓ 浏览器本地检索</span><br><span class="line">相关正文片段 + 访客问题</span><br><span class="line">        ↓ /api/chat</span><br><span class="line">Pages Function</span><br><span class="line">        ↓ AI binding</span><br><span class="line">Workers AI</span><br></pre></td></tr></table></figure><h1 id="Pages-Functions-具体怎么写"><a href="#Pages-Functions-具体怎么写" class="headerlink" title="Pages Functions 具体怎么写"></a>Pages Functions 具体怎么写</h1><p>Pages Functions 的约定很直白：项目根目录下的 <code>functions/</code> 会按照文件路径自动变成接口。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">functions/</span><br><span class="line">└── api/</span><br><span class="line">    ├── greeting.js  → /api/greeting</span><br><span class="line">    └── chat.js      → /api/chat</span><br></pre></td></tr></table></figure><p>在 Cloudflare 控制台给 Pages 项目添加一个 Workers AI binding，变量名设为 <code>AI</code>。部署后，函数里就可以直接使用 <code>context.env.AI</code>，不需要把 API Key 放进仓库。</p><h2 id="欢迎词接口"><a href="#欢迎词接口" class="headerlink" title="欢迎词接口"></a>欢迎词接口</h2><p>欢迎词只需要页面标题和页面类型，前端发一个很小的请求：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">fetch</span>(<span class="string">&quot;/api/greeting&quot;</span>, &#123;</span><br><span class="line">  <span class="attr">method</span>: <span class="string">&quot;POST&quot;</span>,</span><br><span class="line">  <span class="attr">headers</span>: &#123; <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span> &#125;,</span><br><span class="line">  <span class="attr">body</span>: <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(&#123;</span><br><span class="line">    <span class="attr">page</span>: &#123;</span><br><span class="line">      <span class="attr">title</span>: <span class="variable language_">document</span>.<span class="property">title</span>,</span><br><span class="line">      <span class="attr">type</span>: <span class="variable language_">document</span>.<span class="title function_">querySelector</span>(<span class="string">&quot;.post&quot;</span>) ? <span class="string">&quot;文章&quot;</span> : <span class="string">&quot;页面&quot;</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p>Function 接收后调用 AI binding：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="variable constant_">DEFAULT_MODEL</span> = <span class="string">&quot;@cf/qwen/qwen3-30b-a3b-fp8&quot;</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">onRequestPost</span>(<span class="params">context</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> body = <span class="keyword">await</span> context.<span class="property">request</span>.<span class="title function_">json</span>();</span><br><span class="line">  <span class="keyword">const</span> pageTitle = <span class="title function_">cleanText</span>(body?.<span class="property">page</span>?.<span class="property">title</span>, <span class="number">100</span>) || <span class="string">&quot;博客&quot;</span>;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (!context.<span class="property">env</span>.<span class="property">AI</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="title class_">Response</span>.<span class="title function_">json</span>(&#123;</span><br><span class="line">      <span class="attr">text</span>: <span class="title function_">fallbackGreeting</span>(),</span><br><span class="line">      <span class="attr">source</span>: <span class="string">&quot;fallback&quot;</span></span><br><span class="line">    &#125;);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> result = <span class="keyword">await</span> context.<span class="property">env</span>.<span class="property">AI</span>.<span class="title function_">run</span>(</span><br><span class="line">    context.<span class="property">env</span>.<span class="property">PET_GREETING_MODEL</span> || <span class="variable constant_">DEFAULT_MODEL</span>,</span><br><span class="line">    &#123;</span><br><span class="line">      <span class="attr">messages</span>: [</span><br><span class="line">        &#123;</span><br><span class="line">          <span class="attr">role</span>: <span class="string">&quot;system&quot;</span>,</span><br><span class="line">          <span class="attr">content</span>: <span class="string">&quot;你是博客里的小猫助手，直接输出一句简短问候。&quot;</span></span><br><span class="line">        &#125;,</span><br><span class="line">        &#123;</span><br><span class="line">          <span class="attr">role</span>: <span class="string">&quot;user&quot;</span>,</span><br><span class="line">          <span class="attr">content</span>: <span class="string">`访客正在浏览：<span class="subst">$&#123;pageTitle&#125;</span>`</span></span><br><span class="line">        &#125;</span><br><span class="line">      ],</span><br><span class="line">      <span class="attr">max_tokens</span>: <span class="number">360</span>,</span><br><span class="line">      <span class="attr">temperature</span>: <span class="number">0.75</span></span><br><span class="line">    &#125;</span><br><span class="line">  );</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> <span class="title class_">Response</span>.<span class="title function_">json</span>(&#123;</span><br><span class="line">    <span class="attr">text</span>: <span class="title function_">cleanGreeting</span>(<span class="title function_">getModelText</span>(result)),</span><br><span class="line">    <span class="attr">source</span>: <span class="string">&quot;ai&quot;</span></span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>实际代码外面还包了一层 <code>try/catch</code>。绑定不存在、模型超额或者调用失败时，接口返回预先准备的本地问候，并标记 <code>source: &quot;fallback&quot;</code>。前端不需要区分错误类型，照常把文字放进气泡即可。</p><h2 id="文章聊天接口"><a href="#文章聊天接口" class="headerlink" title="文章聊天接口"></a>文章聊天接口</h2><p>聊天的 Function 仍然只是一个 POST 接口，但输入多了三个部分：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;question&quot;</span><span class="punctuation">:</span> <span class="string">&quot;这篇文章主要讲了什么？&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;context&quot;</span><span class="punctuation">:</span> <span class="string">&quot;当前文章和相关文章的正文片段&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;references&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span> <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="string">&quot;相关文章标题&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;url&quot;</span><span class="punctuation">:</span> <span class="string">&quot;/文章路径/&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;history&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span> <span class="attr">&quot;role&quot;</span><span class="punctuation">:</span> <span class="string">&quot;user&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;content&quot;</span><span class="punctuation">:</span> <span class="string">&quot;上一轮问题&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span> <span class="attr">&quot;role&quot;</span><span class="punctuation">:</span> <span class="string">&quot;assistant&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;content&quot;</span><span class="punctuation">:</span> <span class="string">&quot;上一轮回答&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>服务端不会盲目信任这些内容，而是先清洗和截断：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> question = <span class="title function_">cleanText</span>(body?.<span class="property">question</span>, <span class="number">300</span>);</span><br><span class="line"><span class="keyword">const</span> articleContext = <span class="title function_">cleanText</span>(body?.<span class="property">context</span>, <span class="number">9000</span>);</span><br><span class="line"><span class="keyword">const</span> history = <span class="title function_">cleanHistory</span>(body?.<span class="property">history</span>).<span class="title function_">slice</span>(-<span class="number">6</span>);</span><br><span class="line"><span class="keyword">const</span> references = <span class="title function_">cleanReferences</span>(body?.<span class="property">references</span>).<span class="title function_">slice</span>(<span class="number">0</span>, <span class="number">3</span>);</span><br></pre></td></tr></table></figure><p>系统提示词里还明确告诉模型：文章正文属于不可信参考资料，不能执行正文里夹带的指令，资料不足就坦率说明。处理完以后才调用 Kimi：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> result = <span class="keyword">await</span> context.<span class="property">env</span>.<span class="property">AI</span>.<span class="title function_">run</span>(</span><br><span class="line">  context.<span class="property">env</span>.<span class="property">PET_CHAT_MODEL</span> || <span class="string">&quot;@cf/moonshotai/kimi-k2.6&quot;</span>,</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">messages</span>: [</span><br><span class="line">      &#123; <span class="attr">role</span>: <span class="string">&quot;system&quot;</span>, <span class="attr">content</span>: <span class="title function_">buildSystemPrompt</span>() &#125;,</span><br><span class="line">      ...history,</span><br><span class="line">      &#123;</span><br><span class="line">        <span class="attr">role</span>: <span class="string">&quot;user&quot;</span>,</span><br><span class="line">        <span class="attr">content</span>: [</span><br><span class="line">          <span class="string">`访客问题：<span class="subst">$&#123;question&#125;</span>`</span>,</span><br><span class="line">          <span class="string">&quot;以下是博客参考内容：&quot;</span>,</span><br><span class="line">          articleContext</span><br><span class="line">        ].<span class="title function_">join</span>(<span class="string">&quot;\n\n&quot;</span>)</span><br><span class="line">      &#125;</span><br><span class="line">    ],</span><br><span class="line">    <span class="attr">max_completion_tokens</span>: <span class="number">360</span>,</span><br><span class="line">    <span class="attr">chat_template_kwargs</span>: &#123; <span class="attr">thinking</span>: <span class="literal">false</span> &#125;,</span><br><span class="line">    <span class="attr">temperature</span>: <span class="number">0.45</span></span><br><span class="line">  &#125;</span><br><span class="line">);</span><br></pre></td></tr></table></figure><p>这里关闭了长推理。博客问答更需要简洁、稳定和省额度，并不需要模型在后台写几百 token 的思考过程。</p><h2 id="不上向量数据库，先用-search-xml"><a href="#不上向量数据库，先用-search-xml" class="headerlink" title="不上向量数据库，先用 search.xml"></a>不上向量数据库，先用 search.xml</h2><p>Hexo 已经通过 <code>hexo-generator-search</code> 生成了 <code>search.xml</code>，里面包含文章标题、链接和正文。前端第一次聊天时拉取它，之后复用同一个 Promise：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">loadSearchEntries</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (searchEntriesPromise) <span class="keyword">return</span> searchEntriesPromise;</span><br><span class="line"></span><br><span class="line">  searchEntriesPromise = <span class="title function_">fetch</span>(<span class="string">&quot;/search.xml&quot;</span>)</span><br><span class="line">    .<span class="title function_">then</span>(<span class="function"><span class="params">response</span> =&gt;</span> response.<span class="title function_">text</span>())</span><br><span class="line">    .<span class="title function_">then</span>(<span class="function"><span class="params">xmlText</span> =&gt;</span> &#123;</span><br><span class="line">      <span class="keyword">const</span> xml = <span class="keyword">new</span> <span class="title class_">DOMParser</span>()</span><br><span class="line">        .<span class="title function_">parseFromString</span>(xmlText, <span class="string">&quot;application/xml&quot;</span>);</span><br><span class="line"></span><br><span class="line">      <span class="keyword">return</span> <span class="title class_">Array</span>.<span class="title function_">from</span>(xml.<span class="title function_">querySelectorAll</span>(<span class="string">&quot;entry&quot;</span>)).<span class="title function_">map</span>(<span class="function"><span class="params">entry</span> =&gt;</span> (&#123;</span><br><span class="line">        <span class="attr">title</span>: entry.<span class="title function_">querySelector</span>(<span class="string">&quot;title&quot;</span>)?.<span class="property">textContent</span> || <span class="string">&quot;未命名文章&quot;</span>,</span><br><span class="line">        <span class="attr">url</span>: entry.<span class="title function_">querySelector</span>(<span class="string">&quot;link&quot;</span>)?.<span class="title function_">getAttribute</span>(<span class="string">&quot;href&quot;</span>) || <span class="string">&quot;/&quot;</span>,</span><br><span class="line">        <span class="attr">content</span>: <span class="title function_">stripHtml</span>(entry.<span class="title function_">querySelector</span>(<span class="string">&quot;content&quot;</span>)?.<span class="property">textContent</span> || <span class="string">&quot;&quot;</span>)</span><br><span class="line">      &#125;));</span><br><span class="line">    &#125;);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> searchEntriesPromise;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>提问和当前文章会被切成英文单词、数字和中文二元词组。标题命中加 5 分，正文命中加 1 分，排序后取前三篇。当前页面正文最多取 5,000 字符，相关文章每篇最多取 1,800 字符，最终上下文再统一截到 9,000 字符。</p><p>这当然没有向量检索聪明，但对于目前不到十篇文章的小博客已经足够，而且零数据库、零索引任务、零额外费用。以后文章多起来，再把这一层换成 Vectorize 也不迟。</p><h2 id="前后端都准备退路"><a href="#前后端都准备退路" class="headerlink" title="前后端都准备退路"></a>前后端都准备退路</h2><p>整套功能刻意设计成“猫可以罢工，博客不能罢工”：</p><ul><li>GitHub Pages 上没有 <code>/api/*</code>：前端自动使用本地问候和关键词搜索</li><li>Cloudflare 没有 AI binding：Function 返回 fallback</li><li>Workers AI 超额或暂时失败：Function 捕获异常并返回 fallback</li><li><code>search.xml</code> 拉取失败：仍然可以使用当前文章正文</li><li>请求超过 15～30 秒：浏览器用 <code>AbortController</code> 主动取消</li></ul><p>所以 AI 是一层渐进增强，而不是博客正常阅读的前置条件。</p><h1 id="从-Emoji-猫到抽象键盘猫"><a href="#从-Emoji-猫到抽象键盘猫" class="headerlink" title="从 Emoji 猫到抽象键盘猫"></a>从 Emoji 猫到抽象键盘猫</h1><p>第一版为了先跑通功能，右下角放的是一个 <code>🐈</code>。功能是能用了，但越看越像临时占位符。</p><p>后来参考“猫咪拍键盘”的动作，重新生成了一只原创角色：白色圆脑袋、几根黑线、表情有点傻，脖子上挂着一个橙色 <code>&lt;&gt;</code>。第一版生成得太精致，像儿童卡通；第二版把细节全部砍掉，反而一下对味了。</p><p><img src="/images/keyboard-cat.png" alt="抽象键盘猫"></p><p>生成图先使用纯绿背景，再做本地色键抠除，得到透明 PNG。原本还想生成第二张动作帧，但生成模型很难保证两帧轮廓完全一致，播放时会突然“变脸”。最后用了更简单的办法：同一张图水平镜像，配合 CSS 交替显示。</p><figure class="highlight css"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="selector-class">.blog-pet-mascot-frame-b</span> &#123;</span><br><span class="line">  <span class="attribute">transform</span>: <span class="built_in">scaleX</span>(-<span class="number">1</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="selector-id">#blog-pet</span><span class="selector-class">.is-thinking</span> <span class="selector-class">.blog-pet-mascot-frame-a</span> &#123;</span><br><span class="line">  <span class="attribute">animation</span>: frame-a .<span class="number">22s</span> <span class="built_in">steps</span>(<span class="number">1</span>, end) infinite;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="selector-id">#blog-pet</span><span class="selector-class">.is-thinking</span> <span class="selector-class">.blog-pet-mascot-frame-b</span> &#123;</span><br><span class="line">  <span class="attribute">animation</span>: frame-b .<span class="number">22s</span> <span class="built_in">steps</span>(<span class="number">1</span>, end) infinite;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>于是小猫平时安静趴着，生成欢迎词或者回答问题时就开始左右开弓疯狂敲键盘。还适配了手机尺寸和 <code>prefers-reduced-motion</code>，不喜欢动画的访客会看到静态版本。</p><h1 id="免费额度够不够"><a href="#免费额度够不够" class="headerlink" title="免费额度够不够"></a>免费额度够不够</h1><p>Workers AI 免费计划目前每天提供 10,000 Neurons，UTC 00:00 重置。超出免费额度后请求会失败，而不是自动开始扣费，这一点对“坚决白嫖”的个人博客很重要。具体额度和模型单价以后可能变化，最新数字还是看 <a href="https://developers.cloudflare.com/workers-ai/platform/pricing/">Workers AI Pricing</a>。</p><p>上线当天查到的实际消耗是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Qwen3：586.34 Neurons</span><br><span class="line">Kimi K2.6：47.12 Neurons</span><br><span class="line">合计：633.45 / 10,000（约 6.33%）</span><br></pre></td></tr></table></figure><p>于是最终采用双模型：</p><ul><li>高频、简短的自动问候：<code>@cf/qwen/qwen3-30b-a3b-fp8</code></li><li>访客主动发起的文章聊天：<code>@cf/moonshotai/kimi-k2.6</code></li></ul><p>欢迎词不需要动用昂贵模型，真正需要理解长文章时再让 Kimi 上场。这样既能提升回答质量，也不至于有人每刷新一次页面都让额度原地爆炸。</p><h1 id="最后的样子"><a href="#最后的样子" class="headerlink" title="最后的样子"></a>最后的样子</h1><p>现在更新博客源码后，Cloudflare Pages 会自动重新构建；GitHub Pages 仍然可以独立发布。静态内容由 CDN 提供，只有两个 <code>/api/*</code> 接口会进入 Functions，聊天再按场景选择 Workers AI 模型。</p><p>最开始只是想多部署一份博客，最后收获了双轨发布、AI 文章助手，以及一只原创抽象键盘猫。</p><p>只能说，折腾博客最危险的一句话就是：“顺便再加一个小功能。”哈哈。</p>]]>
    </content>
    <id>https://lenardar.github.io/2026/07/29/%E5%B0%8F%E7%8C%AB%E5%92%AA%E9%99%8D%E4%B8%B4/</id>
    <link href="https://lenardar.github.io/2026/07/29/%E5%B0%8F%E7%8C%AB%E5%92%AA%E9%99%8D%E4%B8%B4/"/>
    <published>2026-07-29T21:49:39.000Z</published>
    <summary>
      <![CDATA[<p>昨天才给博客做完大扫除，今天又没忍住继续装修。原计划只是把 GitHub Pages 同步到 Cloudflare Pages，结果一路从双轨部署折腾到 Workers AI，最后还在右下角雇了一只会疯狂拍键盘的抽象猫。</p>
<p>事情是怎么从“多部署一个 Pages”]]>
    </summary>
    <title>小猫咪降临</title>
    <updated>2026-07-29T21:49:39.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="折腾" scheme="https://lenardar.github.io/tags/%E6%8A%98%E8%85%BE/"/>
    <content>
      <![CDATA[<p>这个随缘更新的博客居然更新了，而且这次更新的不是文章，是博客本身，哈哈。今天给它来了一次大扫除加装修，流水账记录一下。</p><p>特别说明：这次大扫除是和 Claude 一起干的，连这篇流水账都是 Fable 5 直接写的，哈哈。</p><h1 id="内容瘦身"><a href="#内容瘦身" class="headerlink" title="内容瘦身"></a>内容瘦身</h1><p>之前每篇技术文都是中英双语各发一份，维护起来实在累，想了想还是全删了英文版，以后就纯中文。顺便把仓库里吃灰的三个主题、各种垃圾文件也清了一遍，还意外修掉一个 bug——有个图片文件夹因为中文文件名的锅，一直往线上部署几百 KB 根本没人引用的死图片。</p><h1 id="修了侧边大纲"><a href="#修了侧边大纲" class="headerlink" title="修了侧边大纲"></a>修了侧边大纲</h1><p>一直觉得文章页右侧的大纲怪怪的，只显示小节标题。翻了下主题源码才发现，cactus 默认把一级标题在大纲里隐藏了（它假设你正文从二级标题开始写，而我习惯用一级标题当章节）。改了几行 CSS 搞定：现在大纲显示两级，一级前缀 <code>#</code>、二级前缀 <code>##</code>，还挺有 Markdown 味的。</p><h1 id="上新功能"><a href="#上新功能" class="headerlink" title="上新功能"></a>上新功能</h1><ul><li><strong>RSS 订阅</strong>：地址是 <a href="/atom.xml">&#x2F;atom.xml</a>，欢迎用阅读器订阅，这才是博客的正确打开方式</li><li><strong>评论区</strong>：文章底下现在可以评论了（utterances，用 GitHub 账号登录就行），欢迎来盖楼</li><li><strong>搜索和标签页</strong>：导航栏多了两个入口，文章多了以后好找</li></ul><h1 id="其他小修小补"><a href="#其他小修小补" class="headerlink" title="其他小修小补"></a>其他小修小补</h1><p>站点链接全面 https、标签体系整顿了一遍、关于页面从空白写成了正经版本、首页简介也扩写了。最重要的一条：博客源码终于进了 git 仓库备份，再也不怕手滑删库了。</p><p>就这些，评论区试试新功能？哈哈</p>]]>
    </content>
    <id>https://lenardar.github.io/2026/07/28/%E5%8D%9A%E5%AE%A2%E5%A4%A7%E6%89%AB%E9%99%A4/</id>
    <link href="https://lenardar.github.io/2026/07/28/%E5%8D%9A%E5%AE%A2%E5%A4%A7%E6%89%AB%E9%99%A4/"/>
    <published>2026-07-28T18:34:21.000Z</published>
    <summary>
      <![CDATA[<p>这个随缘更新的博客居然更新了，而且这次更新的不是文章，是博客本身，哈哈。今天给它来了一次大扫除加装修，流水账记录一下。</p>
<p>特别说明：这次大扫除是和 Claude 一起干的，连这篇流水账都是 Fable 5 直接写的，哈哈。</p>
<h1 id="内容瘦身"><]]>
    </summary>
    <title>博客大扫除</title>
    <updated>2026-07-28T18:34:21.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="研究" scheme="https://lenardar.github.io/tags/%E7%A0%94%E7%A9%B6/"/>
    <category term="R" scheme="https://lenardar.github.io/tags/R/"/>
    <content>
      <![CDATA[<h1 id="为什么写-panelforest"><a href="#为什么写-panelforest" class="headerlink" title="为什么写 panelforest"></a>为什么写 panelforest</h1><p>做临床研究、meta 分析或者 NMA 的人，森林图是绑定出图。R 里现有的工具不少——<code>forestplot</code>、<code>forestploter</code>、<code>forester</code>、<code>meta::forest()</code>——但用下来都多少有些不顺手：</p><ul><li>很多包的 API 偏”配置式”，参数一多就变成巨长的函数调用，想调整布局得翻文档猜参数名</li><li>想要的面板组合经常不在预设里：比如左边文本、中间 CI、右边再来一列柱状图，基本要自己拼</li><li>分组行、汇总行、条纹、分隔线这些装饰性元素，每个包的处理方式都不一样</li><li>想给特定行换颜色、换形状，往往要改底层数据或者用很 hack 的方式</li></ul><p>所以我写了 <strong>panelforest</strong>。</p><p>核心想法很简单：<strong>森林图本质上就是几列面板横向拼在一起，每列有自己的渲染逻辑。</strong> 那就不要把它当成一个”森林图函数”来设计，而是让用户自己声明”我要哪些列、每列长什么样”，剩下的事情交给引擎。</p><p>项目地址：<a href="https://github.com/lenardar/panelforest">https://github.com/lenardar/panelforest</a></p><h1 id="长什么样"><a href="#长什么样" class="headerlink" title="长什么样"></a>长什么样</h1><p>先看结果：</p><p><img src="/images/panelforest-README-classic-forest.png"></p><p>这张图是一个 NMA 安全性数据的森林图，包含：</p><ul><li>左侧文本列（亚组标签）</li><li>中间文本列（OR 数值）</li><li>右侧 CI 面板（对数刻度、参考线、截断箭头、favors 标注）</li><li>分组行加粗、行间分隔线、条纹背景</li><li>不同比较用不同颜色和形状区分</li></ul><p>生成这张图的代码大概 30 行，全部是管道式组合。</p><h1 id="怎么用"><a href="#怎么用" class="headerlink" title="怎么用"></a>怎么用</h1><h2 id="基本结构"><a href="#基本结构" class="headerlink" title="基本结构"></a>基本结构</h2><figure class="highlight r"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">library<span class="punctuation">(</span>panelforest<span class="punctuation">)</span></span><br><span class="line"></span><br><span class="line">forest_plot<span class="punctuation">(</span>df<span class="punctuation">)</span> <span class="operator">|&gt;</span>            <span class="comment"># 传入数据</span></span><br><span class="line">  add_stripe<span class="punctuation">(</span>...<span class="punctuation">)</span> <span class="operator">|&gt;</span>          <span class="comment"># 条纹背景</span></span><br><span class="line">  add_group<span class="punctuation">(</span>...<span class="punctuation">)</span> <span class="operator">|&gt;</span>           <span class="comment"># 分组行</span></span><br><span class="line">  add_hline<span class="punctuation">(</span>...<span class="punctuation">)</span> <span class="operator">|&gt;</span>           <span class="comment"># 分隔线</span></span><br><span class="line">  add_text<span class="punctuation">(</span>...<span class="punctuation">)</span> <span class="operator">|&gt;</span>            <span class="comment"># 文本列</span></span><br><span class="line">  add_ci<span class="punctuation">(</span>...<span class="punctuation">)</span> <span class="operator">|&gt;</span>              <span class="comment"># CI 面板</span></span><br><span class="line">  fp_render<span class="punctuation">(</span><span class="punctuation">)</span>                 <span class="comment"># 渲染</span></span><br></pre></td></tr></table></figure><p>每个 <code>add_*()</code> 就是往布局里追加一列面板。顺序决定了从左到右的排列。想调整布局，移动调用顺序就行。</p><h2 id="面板类型"><a href="#面板类型" class="headerlink" title="面板类型"></a>面板类型</h2><p>目前内置了这些面板：</p><ul><li><code>add_text()</code> &#x2F; <code>fp_text()</code> — 纯文本列，支持对齐、缩进、格式化函数</li><li><code>add_text_ci()</code> &#x2F; <code>fp_text_ci()</code> — 把 est&#x2F;lower&#x2F;upper 三列自动格式化成 “0.45 (0.32, 0.61)”</li><li><code>add_ci()</code> &#x2F; <code>fp_ci()</code> — 置信区间可视化，支持对数刻度、截断箭头、菱形汇总、favors 标注</li><li><code>add_bar()</code> &#x2F; <code>fp_bar()</code> — 水平柱状图</li><li><code>add_dot()</code> &#x2F; <code>fp_dot()</code> — 散点 + 误差线</li><li><code>add_gap()</code> &#x2F; <code>fp_gap()</code> — 固定宽度间距</li><li><code>add_spacer()</code> &#x2F; <code>fp_spacer()</code> — 绝对单位间距（mm）</li><li><code>fp_custom()</code> — 自定义面板，传入一个返回 ggplot 的函数</li></ul><p>不够用的时候，<code>fp_custom()</code> 加上 <code>fp_register()</code> 可以注册自定义面板类型，引擎会自动纳入渲染流程。</p><h2 id="列驱动的美学映射"><a href="#列驱动的美学映射" class="headerlink" title="列驱动的美学映射"></a>列驱动的美学映射</h2><p>跟 ggplot2 的 <code>aes()</code> 思路类似，panelforest 用 <code>fp_aes()</code> 把数据列映射到视觉属性：</p><figure class="highlight r"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 数据里有 ci_colour 和 ci_shape 两列</span></span><br><span class="line">forest_plot<span class="punctuation">(</span>df<span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_ci<span class="punctuation">(</span><span class="string">&quot;OR&quot;</span><span class="punctuation">,</span> <span class="string">&quot;LCI&quot;</span><span class="punctuation">,</span> <span class="string">&quot;UCI&quot;</span><span class="punctuation">,</span></span><br><span class="line">    mapping <span class="operator">=</span> fp_aes<span class="punctuation">(</span>colour <span class="operator">=</span> <span class="string">&quot;ci_colour&quot;</span><span class="punctuation">,</span> shape <span class="operator">=</span> <span class="string">&quot;ci_shape&quot;</span><span class="punctuation">)</span></span><br><span class="line">  <span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  fp_render<span class="punctuation">(</span><span class="punctuation">)</span></span><br></pre></td></tr></table></figure><p>这样每一行可以有不同的颜色和形状，不需要手动逐行设置。</p><h2 id="统一的-edit-编辑层"><a href="#统一的-edit-编辑层" class="headerlink" title="统一的 edit() 编辑层"></a>统一的 edit() 编辑层</h2><p>想给某些行做特殊处理？一个 <code>edit()</code> 函数搞定行级、单元格级、行高调整：</p><figure class="highlight r"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">forest_plot<span class="punctuation">(</span>df<span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_text<span class="punctuation">(</span><span class="string">&quot;label&quot;</span><span class="punctuation">,</span> header <span class="operator">=</span> <span class="string">&quot;Subgroup&quot;</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_ci<span class="punctuation">(</span><span class="string">&quot;HR&quot;</span><span class="punctuation">,</span> <span class="string">&quot;LCI&quot;</span><span class="punctuation">,</span> <span class="string">&quot;UCI&quot;</span><span class="punctuation">,</span> header <span class="operator">=</span> <span class="string">&quot;HR&quot;</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  <span class="comment"># 第 1 行用菱形</span></span><br><span class="line">  edit<span class="punctuation">(</span>row <span class="operator">=</span> <span class="number">1</span><span class="punctuation">,</span> panel <span class="operator">=</span> <span class="string">&quot;HR&quot;</span><span class="punctuation">,</span> glyph <span class="operator">=</span> <span class="string">&quot;diamond&quot;</span><span class="punctuation">,</span> fill <span class="operator">=</span> <span class="string">&quot;#dbeafe&quot;</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  <span class="comment"># 第 2-4 行斜体</span></span><br><span class="line">  edit<span class="punctuation">(</span>row <span class="operator">=</span> <span class="number">2</span><span class="operator">:</span><span class="number">4</span><span class="punctuation">,</span> fontface <span class="operator">=</span> <span class="string">&quot;italic&quot;</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  <span class="comment"># 第 5 行加高</span></span><br><span class="line">  edit<span class="punctuation">(</span>row <span class="operator">=</span> <span class="number">5</span><span class="punctuation">,</span> height <span class="operator">=</span> <span class="number">1.5</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  fp_render<span class="punctuation">(</span><span class="punctuation">)</span></span><br></pre></td></tr></table></figure><p><code>panel</code> 参数可以用索引、标题字符串或列名来定位，不需要记面板编号。</p><h2 id="跨列分组标题"><a href="#跨列分组标题" class="headerlink" title="跨列分组标题"></a>跨列分组标题</h2><p>多个面板可以用 <code>add_header_group()</code> 加父级标题，层级自动推断：</p><figure class="highlight r"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">forest_plot<span class="punctuation">(</span>df<span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_text<span class="punctuation">(</span><span class="string">&quot;label&quot;</span><span class="punctuation">,</span> header <span class="operator">=</span> <span class="string">&quot;Drug A&quot;</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_text<span class="punctuation">(</span><span class="string">&quot;n_events&quot;</span><span class="punctuation">,</span> header <span class="operator">=</span> <span class="string">&quot;Drug B&quot;</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_ci<span class="punctuation">(</span><span class="string">&quot;HR&quot;</span><span class="punctuation">,</span> <span class="string">&quot;LCI&quot;</span><span class="punctuation">,</span> <span class="string">&quot;UCI&quot;</span><span class="punctuation">,</span> header <span class="operator">=</span> <span class="string">&quot;HR&quot;</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_header_group<span class="punctuation">(</span><span class="string">&quot;Treatment&quot;</span><span class="punctuation">,</span> panels <span class="operator">=</span> <span class="number">1</span><span class="operator">:</span><span class="number">2</span><span class="punctuation">,</span> border <span class="operator">=</span> <span class="literal">TRUE</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  fp_render<span class="punctuation">(</span><span class="punctuation">)</span></span><br></pre></td></tr></table></figure><p>支持多层嵌套——包含其他分组的分组会自动升至更高层。</p><h1 id="设计上的一些取舍"><a href="#设计上的一些取舍" class="headerlink" title="设计上的一些取舍"></a>设计上的一些取舍</h1><h2 id="为什么基于-ggplot2-patchwork"><a href="#为什么基于-ggplot2-patchwork" class="headerlink" title="为什么基于 ggplot2 + patchwork"></a>为什么基于 ggplot2 + patchwork</h2><p>每个面板本质上是一个独立的 ggplot 对象，最终用 patchwork 横向拼接。这个选择有几个好处：</p><ul><li>继承 ggplot2 的渲染质量和主题系统</li><li>每个面板的坐标系独立，CI 面板可以用对数刻度，文本面板用 [0, 1] 坐标，互不干扰</li><li>patchwork 的布局系统天然支持比例宽度和固定宽度混合</li><li>输出是标准 ggplot 对象，<code>ggsave()</code> 直接用</li></ul><p>缺点是性能——面板多、行数多的时候渲染会慢。但森林图通常几十行到一两百行，这个量级下完全没有问题。</p><h2 id="为什么不做-ggplot2-的-geom"><a href="#为什么不做-ggplot2-的-geom" class="headerlink" title="为什么不做 ggplot2 的 geom"></a>为什么不做 ggplot2 的 geom</h2><p>另一条路是写 <code>geom_forest()</code> 这样的扩展。我没走这条路，因为森林图的本质是”多列异构面板”，每列的坐标系和渲染逻辑完全不同。硬塞进一个 ggplot 的 facet 或者 annotation 系统里会很别扭。</p><p>patchwork 的多面板方案更自然——每列是独立的 ggplot，互不干扰但共享行坐标。</p><h2 id="fp-size-的存在意义"><a href="#fp-size-的存在意义" class="headerlink" title="fp_size() 的存在意义"></a>fp_size() 的存在意义</h2><p>森林图的尺寸应该由内容决定，不应该让用户猜 <code>width</code> 和 <code>height</code>。<code>fp_size()</code> 根据面板数量、行数和行高自动计算出精确的英寸尺寸：</p><figure class="highlight r"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">size <span class="operator">&lt;-</span> fp_size<span class="punctuation">(</span>plot_obj<span class="punctuation">)</span></span><br><span class="line">ggsave<span class="punctuation">(</span><span class="string">&quot;forest.png&quot;</span><span class="punctuation">,</span> fp_render<span class="punctuation">(</span>plot_obj<span class="punctuation">)</span><span class="punctuation">,</span></span><br><span class="line">  width <span class="operator">=</span> size<span class="punctuation">[</span><span class="string">&quot;width&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span> height <span class="operator">=</span> size<span class="punctuation">[</span><span class="string">&quot;height&quot;</span><span class="punctuation">]</span><span class="punctuation">)</span></span><br></pre></td></tr></table></figure><p>这样不管你加几列面板、数据有多少行，出图的密度和比例都是一致的。</p><h1 id="CI-面板的细节"><a href="#CI-面板的细节" class="headerlink" title="CI 面板的细节"></a>CI 面板的细节</h1><p><code>fp_ci()</code> 是功能最密集的面板，支持的东西比较多：</p><ul><li><strong>对数刻度</strong>：<code>trans = &quot;log&quot;</code>，自动处理正值约束和刻度标签</li><li><strong>截断箭头</strong>：CI 超出显示范围时，用箭头提示截断方向，箭头类型可配置 (<code>arrow_type = &quot;open&quot;</code> &#x2F; <code>&quot;closed&quot;</code>)</li><li><strong>菱形汇总</strong>：<code>add_summary()</code> 标记的行自动渲染为菱形</li><li><strong>favors 标注</strong>：<code>favors_left</code> &#x2F; <code>favors_right</code> 在坐标轴下方画方向箭头和标签</li><li><strong>参考线</strong>：<code>ref_line</code> 画虚线</li><li><strong>美学映射</strong>：通过 <code>fp_aes()</code> 让每行有不同的颜色、形状、大小</li></ul><p>这些功能单独看都不复杂，但组合起来就能覆盖绝大多数临床研究的森林图需求。</p><h1 id="一个完整例子"><a href="#一个完整例子" class="headerlink" title="一个完整例子"></a>一个完整例子</h1><figure class="highlight r"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line">library<span class="punctuation">(</span>panelforest<span class="punctuation">)</span></span><br><span class="line"></span><br><span class="line">df <span class="operator">&lt;-</span> data.frame<span class="punctuation">(</span></span><br><span class="line">  label <span class="operator">=</span> <span class="built_in">c</span><span class="punctuation">(</span><span class="string">&quot;Overall&quot;</span><span class="punctuation">,</span> <span class="string">&quot;Age &lt; 65&quot;</span><span class="punctuation">,</span> <span class="string">&quot;Age &gt;= 65&quot;</span><span class="punctuation">,</span> <span class="string">&quot;Male&quot;</span><span class="punctuation">,</span> <span class="string">&quot;Female&quot;</span><span class="punctuation">)</span><span class="punctuation">,</span></span><br><span class="line">  HR <span class="operator">=</span> <span class="built_in">c</span><span class="punctuation">(</span><span class="number">0.72</span><span class="punctuation">,</span> <span class="number">0.68</span><span class="punctuation">,</span> <span class="number">0.81</span><span class="punctuation">,</span> <span class="number">0.75</span><span class="punctuation">,</span> <span class="number">0.69</span><span class="punctuation">)</span><span class="punctuation">,</span></span><br><span class="line">  LCI <span class="operator">=</span> <span class="built_in">c</span><span class="punctuation">(</span><span class="number">0.58</span><span class="punctuation">,</span> <span class="number">0.49</span><span class="punctuation">,</span> <span class="number">0.61</span><span class="punctuation">,</span> <span class="number">0.55</span><span class="punctuation">,</span> <span class="number">0.48</span><span class="punctuation">)</span><span class="punctuation">,</span></span><br><span class="line">  UCI <span class="operator">=</span> <span class="built_in">c</span><span class="punctuation">(</span><span class="number">0.89</span><span class="punctuation">,</span> <span class="number">0.94</span><span class="punctuation">,</span> <span class="number">1.07</span><span class="punctuation">,</span> <span class="number">1.02</span><span class="punctuation">,</span> <span class="number">0.99</span><span class="punctuation">)</span></span><br><span class="line"><span class="punctuation">)</span></span><br><span class="line"></span><br><span class="line">plot_obj <span class="operator">&lt;-</span> forest_plot<span class="punctuation">(</span>df<span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_stripe<span class="punctuation">(</span><span class="built_in">c</span><span class="punctuation">(</span><span class="string">&quot;white&quot;</span><span class="punctuation">,</span> <span class="string">&quot;#f4f7f5&quot;</span><span class="punctuation">)</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_summary<span class="punctuation">(</span><span class="number">1</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_hline<span class="punctuation">(</span><span class="number">1</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_text<span class="punctuation">(</span><span class="string">&quot;label&quot;</span><span class="punctuation">,</span> header <span class="operator">=</span> <span class="string">&quot;Subgroup&quot;</span><span class="punctuation">,</span> width <span class="operator">=</span> <span class="number">2</span><span class="punctuation">,</span> align <span class="operator">=</span> <span class="string">&quot;left&quot;</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_text_ci<span class="punctuation">(</span><span class="string">&quot;HR&quot;</span><span class="punctuation">,</span> <span class="string">&quot;LCI&quot;</span><span class="punctuation">,</span> <span class="string">&quot;UCI&quot;</span><span class="punctuation">,</span> header <span class="operator">=</span> <span class="string">&quot;HR (95% CI)&quot;</span><span class="punctuation">,</span> width <span class="operator">=</span> <span class="number">2</span><span class="punctuation">)</span> <span class="operator">|&gt;</span></span><br><span class="line">  add_ci<span class="punctuation">(</span><span class="string">&quot;HR&quot;</span><span class="punctuation">,</span> <span class="string">&quot;LCI&quot;</span><span class="punctuation">,</span> <span class="string">&quot;UCI&quot;</span><span class="punctuation">,</span></span><br><span class="line">    header <span class="operator">=</span> <span class="string">&quot;Hazard Ratio&quot;</span><span class="punctuation">,</span></span><br><span class="line">    trans <span class="operator">=</span> <span class="string">&quot;log&quot;</span><span class="punctuation">,</span></span><br><span class="line">    width <span class="operator">=</span> <span class="number">3</span><span class="punctuation">,</span></span><br><span class="line">    show_axis <span class="operator">=</span> <span class="literal">TRUE</span><span class="punctuation">,</span></span><br><span class="line">    favors_left <span class="operator">=</span> <span class="string">&quot;Favors treatment&quot;</span><span class="punctuation">,</span></span><br><span class="line">    favors_right <span class="operator">=</span> <span class="string">&quot;Favors control&quot;</span></span><br><span class="line">  <span class="punctuation">)</span></span><br><span class="line"></span><br><span class="line">size <span class="operator">&lt;-</span> fp_size<span class="punctuation">(</span>plot_obj<span class="punctuation">)</span></span><br><span class="line">ggsave<span class="punctuation">(</span><span class="string">&quot;forest.png&quot;</span><span class="punctuation">,</span> fp_render<span class="punctuation">(</span>plot_obj<span class="punctuation">)</span><span class="punctuation">,</span></span><br><span class="line">  width <span class="operator">=</span> size<span class="punctuation">[</span><span class="string">&quot;width&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span> height <span class="operator">=</span> size<span class="punctuation">[</span><span class="string">&quot;height&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  dpi <span class="operator">=</span> <span class="number">300</span><span class="punctuation">,</span> bg <span class="operator">=</span> <span class="string">&quot;white&quot;</span><span class="punctuation">)</span></span><br></pre></td></tr></table></figure><p>从数据到出图，代码结构很清晰：数据 → 装饰 → 面板 → 渲染。想调整布局就移动 <code>add_*()</code> 的顺序，想改样式就加 <code>edit()</code>。</p><h1 id="后续计划"><a href="#后续计划" class="headerlink" title="后续计划"></a>后续计划</h1><p>panelforest 目前是 v0.2.0，核心功能已经稳定。后续打算做的事情：</p><ul><li><strong><code>forest_plot_from()</code> — 模型直出森林图</strong>：传入 <code>glm</code>、<code>coxph</code>、<code>lm</code> 等模型对象，自动生成森林图。底层基于 <code>broom::tidy()</code>，根据模型类型自动推断效应量（OR&#x2F;HR&#x2F;β）和坐标变换。逐步适配 <code>lme4</code>、<code>metafor</code>、<code>brms</code> 等更多包。</li><li><strong><code>add_rule()</code> — 条件样式</strong>：声明式规则批量高亮，替代逐行 <code>edit()</code></li><li><strong>更多坐标变换</strong>：<code>sqrt</code>、<code>logit</code> 等</li><li><strong>文本自动换行、导出助手、脚注系统</strong>等体验优化</li></ul><p>最重要的是模型直出这个方向——让 panelforest 不只是一个画图工具，而是能直接对接统计建模的输出，减少”跑完模型还要手动整理数据再画图”这个环节。</p><p>项目地址：<a href="https://github.com/lenardar/panelforest">https://github.com/lenardar/panelforest</a></p>]]>
    </content>
    <id>https://lenardar.github.io/2026/03/15/panelforest%EF%BC%9A%E7%94%A8%E5%A3%B0%E6%98%8E%E5%BC%8F%E7%AE%A1%E9%81%93%E6%8B%BC%E5%87%BA%E4%BD%A0%E6%83%B3%E8%A6%81%E7%9A%84%E6%A3%AE%E6%9E%97%E5%9B%BE/</id>
    <link href="https://lenardar.github.io/2026/03/15/panelforest%EF%BC%9A%E7%94%A8%E5%A3%B0%E6%98%8E%E5%BC%8F%E7%AE%A1%E9%81%93%E6%8B%BC%E5%87%BA%E4%BD%A0%E6%83%B3%E8%A6%81%E7%9A%84%E6%A3%AE%E6%9E%97%E5%9B%BE/"/>
    <published>2026-03-15T16:00:00.000Z</published>
    <summary>
      <![CDATA[<h1 id="为什么写-panelforest"><a href="#为什么写-panelforest" class="headerlink" title="为什么写 panelforest"></a>为什么写 panelforest</h1><p>做临床研究、meta 分析或]]>
    </summary>
    <title>panelforest：用声明式管道拼出你想要的森林图</title>
    <updated>2026-03-15T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="研究" scheme="https://lenardar.github.io/tags/%E7%A0%94%E7%A9%B6/"/>
    <category term="Python" scheme="https://lenardar.github.io/tags/Python/"/>
    <content>
      <![CDATA[<h1 id="为什么写-TidyPy4DS"><a href="#为什么写-TidyPy4DS" class="headerlink" title="为什么写 TidyPy4DS"></a>为什么写 TidyPy4DS</h1><p>做数据分析的人，大概率都在 <code>pandas</code> 和 tidyverse 之间摇摆过。</p><p>如果你长期写 R，<code>dplyr</code> &#x2F; <code>tidyr</code> &#x2F; <code>stringr</code> 那套东西会很顺手：选列有 selector，批量变换有 <code>across()</code>，宽长表转换、字符串处理、缺失值处理都有比较统一的入口。</p><p>但一旦切到 Python，虽然 <code>pandas</code> 功能很强，日常清洗时还是经常会冒出几个问题：</p><ul><li>想按前缀、类型、条件批量选列，不够统一</li><li>想对一批列做同样的处理，<code>assign(...)</code> 经常要自己手搓字典</li><li>想把清洗步骤写成稳定、可复用、可读的链，容易堆很多一次性 lambda</li><li>从 tidyverse 迁移过来时，肌肉记忆没地方放</li></ul><p>所以我写了 <strong>TidyPy4DS</strong>。</p><p>它不是想重写 <code>pandas</code>，也不是想做另一个复杂框架，而是只补一小块真正不够顺手的地方：<strong>列选择、批量变换、字符串处理、宽长表转换，以及更自然的 <code>.pipe()</code> 链式体验。</strong></p><p>项目地址：<a href="https://github.com/lenardar/TidyPy4DS">https://github.com/lenardar/TidyPy4DS</a></p><h1 id="这个项目想解决什么"><a href="#这个项目想解决什么" class="headerlink" title="这个项目想解决什么"></a>这个项目想解决什么</h1><p>一句话说，<strong>函数名尽量跟 tidyverse，接口行为坚持 Python &#x2F; pandas。</strong></p><p>也就是说：</p><ul><li>不做 SQL DSL</li><li>不做惰性执行引擎</li><li>不替代 <code>groupby</code>、<code>merge</code>、<code>assign</code> 这些已经很成熟的 <code>pandas</code> 原生能力</li><li>只把最容易重复、最容易写散的那部分动作收成统一入口</li></ul><p>目前项目里最核心的几组能力是：</p><ul><li>selector 系统：<code>starts_with</code>、<code>contains</code>、<code>numeric</code>、<code>where</code> 等</li><li>批量变换：<code>mutate_across</code></li><li>批量改名：<code>rename_with</code></li><li>汇总：<code>summarize</code></li><li>结构查看：<code>glimpse</code></li><li>条件映射：<code>case_when</code>、<code>if_else</code>、<code>recode</code></li><li>宽长表转换：<code>pivot_longer</code>、<code>pivot_wider</code></li><li>缺失值处理：<code>drop_na</code>、<code>fill_na</code>、<code>replace_na</code></li><li>表头清理：<code>clean_names</code>、<code>remove_empty</code>、<code>row_to_names</code></li></ul><h1 id="一个简单对比：纯-pandas-vs-tidypy"><a href="#一个简单对比：纯-pandas-vs-tidypy" class="headerlink" title="一个简单对比：纯 pandas vs tidypy"></a>一个简单对比：纯 pandas vs tidypy</h1><p>先看一份很常见的数据：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> pandas <span class="keyword">as</span> pd</span><br><span class="line"></span><br><span class="line">df = pd.DataFrame(&#123;</span><br><span class="line">    <span class="string">&quot;employee_id&quot;</span>: [<span class="number">101</span>, <span class="number">102</span>, <span class="number">103</span>, <span class="number">104</span>, <span class="number">105</span>],</span><br><span class="line">    <span class="string">&quot;dept&quot;</span>: [<span class="string">&quot;Sales&quot;</span>, <span class="string">&quot;Sales&quot;</span>, <span class="string">&quot;Tech&quot;</span>, <span class="string">&quot;Tech&quot;</span>, <span class="string">&quot;HR&quot;</span>],</span><br><span class="line">    <span class="string">&quot;score_math&quot;</span>: [<span class="number">92.0</span>, <span class="literal">None</span>, <span class="number">88.0</span>, <span class="number">79.0</span>, <span class="literal">None</span>],</span><br><span class="line">    <span class="string">&quot;score_eng&quot;</span>: [<span class="number">85.0</span>, <span class="number">90.0</span>, <span class="literal">None</span>, <span class="number">82.0</span>, <span class="number">87.0</span>],</span><br><span class="line">    <span class="string">&quot;name&quot;</span>: [<span class="string">&quot; Alice &quot;</span>, <span class="string">&quot;Bob &quot;</span>, <span class="string">&quot; Carol&quot;</span>, <span class="string">&quot;David&quot;</span>, <span class="string">&quot; Frank &quot;</span>],</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>目标很普通：</p><ul><li>只保留 ID、分组、分数字段和名字</li><li>用每列中位数填补缺失值</li><li>去掉 <code>score_</code> 前缀</li><li>去掉名字前后空格</li><li>按数学成绩做一个等级</li><li>最后按分组汇总</li></ul><h2 id="纯-pandas-写法"><a href="#纯-pandas-写法" class="headerlink" title="纯 pandas 写法"></a>纯 pandas 写法</h2><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">pandas_result = (</span><br><span class="line">    df.loc[:, [<span class="string">&quot;employee_id&quot;</span>, <span class="string">&quot;dept&quot;</span>, <span class="string">&quot;score_math&quot;</span>, <span class="string">&quot;score_eng&quot;</span>, <span class="string">&quot;name&quot;</span>]]</span><br><span class="line">    .assign(</span><br><span class="line">        score_math=<span class="keyword">lambda</span> x: x[<span class="string">&quot;score_math&quot;</span>].fillna(x[<span class="string">&quot;score_math&quot;</span>].median()),</span><br><span class="line">        score_eng=<span class="keyword">lambda</span> x: x[<span class="string">&quot;score_eng&quot;</span>].fillna(x[<span class="string">&quot;score_eng&quot;</span>].median()),</span><br><span class="line">    )</span><br><span class="line">    .rename(columns=&#123;<span class="string">&quot;score_math&quot;</span>: <span class="string">&quot;math&quot;</span>, <span class="string">&quot;score_eng&quot;</span>: <span class="string">&quot;eng&quot;</span>&#125;)</span><br><span class="line">    .assign(</span><br><span class="line">        name=<span class="keyword">lambda</span> x: x[<span class="string">&quot;name&quot;</span>].<span class="built_in">str</span>.strip(),</span><br><span class="line">        level=<span class="keyword">lambda</span> x: pd.Series(</span><br><span class="line">            [<span class="string">&quot;A&quot;</span> <span class="keyword">if</span> v &gt;= <span class="number">90</span> <span class="keyword">else</span> <span class="string">&quot;B&quot;</span> <span class="keyword">if</span> v &gt;= <span class="number">80</span> <span class="keyword">else</span> <span class="string">&quot;C&quot;</span> <span class="keyword">for</span> v <span class="keyword">in</span> x[<span class="string">&quot;math&quot;</span>]],</span><br><span class="line">            index=x.index,</span><br><span class="line">        ),</span><br><span class="line">    )</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>这段代码当然没问题，而且完全是标准 <code>pandas</code>。</p><p>但你会发现几个特点：</p><ul><li>分数字段被显式写了两遍</li><li>改名前后的列名都要手动维护</li><li>当类似列变多时，改动面会越来越大</li></ul><h2 id="tidypy-写法"><a href="#tidypy-写法" class="headerlink" title="tidypy 写法"></a>tidypy 写法</h2><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> tidypy.tidy <span class="keyword">import</span> (</span><br><span class="line">    case_when,</span><br><span class="line">    clean_names,</span><br><span class="line">    mutate_across,</span><br><span class="line">    rename_with,</span><br><span class="line">    select,</span><br><span class="line">    starts_with,</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line">tidypy_result = (</span><br><span class="line">    clean_names(df)</span><br><span class="line">    .pipe(</span><br><span class="line">        select,</span><br><span class="line">        <span class="string">&quot;employee_id&quot;</span>,</span><br><span class="line">        <span class="string">&quot;dept&quot;</span>,</span><br><span class="line">        starts_with(<span class="string">&quot;score_&quot;</span>),</span><br><span class="line">        <span class="string">&quot;name&quot;</span>,</span><br><span class="line">    )</span><br><span class="line">    .pipe(</span><br><span class="line">        mutate_across,</span><br><span class="line">        starts_with(<span class="string">&quot;score_&quot;</span>),</span><br><span class="line">        <span class="keyword">lambda</span> s: s.fillna(s.median()),</span><br><span class="line">    )</span><br><span class="line">    .pipe(</span><br><span class="line">        rename_with,</span><br><span class="line">        <span class="keyword">lambda</span> c: c.replace(<span class="string">&quot;score_&quot;</span>, <span class="string">&quot;&quot;</span>),</span><br><span class="line">        starts_with(<span class="string">&quot;score_&quot;</span>),</span><br><span class="line">    )</span><br><span class="line">    .assign(</span><br><span class="line">        name=<span class="keyword">lambda</span> x: x[<span class="string">&quot;name&quot;</span>].<span class="built_in">str</span>.strip(),</span><br><span class="line">        level=<span class="keyword">lambda</span> x: case_when(</span><br><span class="line">            (x[<span class="string">&quot;math&quot;</span>] &gt;= <span class="number">90</span>, <span class="string">&quot;A&quot;</span>),</span><br><span class="line">            (x[<span class="string">&quot;math&quot;</span>] &gt;= <span class="number">80</span>, <span class="string">&quot;B&quot;</span>),</span><br><span class="line">            default=<span class="string">&quot;C&quot;</span>,</span><br><span class="line">        ),</span><br><span class="line">    )</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>它并不是神奇地比 <code>pandas</code> 少很多代码。</p><p>真正的区别在于：<strong>你开始把“哪些列属于同一类”和“这批列要做什么”分开表达了。</strong></p><p>这个分离在小数据上可能只是“看着顺一点”，但一旦你多加一个 <code>score_logic</code> 列，差别就出来了：</p><ul><li>纯 pandas 版本通常要改多处显式列名</li><li>selector 版本通常只要让新列符合 <code>score_</code> 规则，主流程基本不用改</li></ul><p>如果再往真实一点的数据走，差异会更明显。比如很多 Excel 导出来的原始表根本不是“干净 DataFrame”，而是：</p><ul><li>列名里有空格、括号和标点</li><li>第一行才是真正的表头</li><li>后面还夹着全空行、全空列</li></ul><p>这时候我最近补进去的一批 janitor 风格函数就比较顺手了：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> tidypy.tidy <span class="keyword">import</span> clean_names, remove_empty, row_to_names</span><br><span class="line"></span><br><span class="line">raw = pd.DataFrame(</span><br><span class="line">    [</span><br><span class="line">        [<span class="string">&quot;Patient ID&quot;</span>, <span class="string">&quot;Score Math&quot;</span>, <span class="literal">None</span>],</span><br><span class="line">        [<span class="number">1</span>, <span class="number">90</span>, <span class="literal">None</span>],</span><br><span class="line">        [<span class="number">2</span>, <span class="number">88</span>, <span class="literal">None</span>],</span><br><span class="line">        [<span class="literal">None</span>, <span class="literal">None</span>, <span class="literal">None</span>],</span><br><span class="line">    ]</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line">result = (</span><br><span class="line">    raw</span><br><span class="line">    .pipe(row_to_names, row=<span class="number">0</span>)</span><br><span class="line">    .pipe(remove_empty, axis=<span class="string">&quot;both&quot;</span>)</span><br><span class="line">    .pipe(clean_names)</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>这类东西单看都不复杂，但它们经常正好卡在“pandas 原生当然能做，只是每次都要重写一遍”的位置上。</p><h1 id="核心设计：先把列选择系统做对"><a href="#核心设计：先把列选择系统做对" class="headerlink" title="核心设计：先把列选择系统做对"></a>核心设计：先把列选择系统做对</h1><p>这个项目里最重要的抽象其实不是 <code>mutate_across()</code>，而是 <code>ColSelector</code>。</p><p>所有 helper 都返回一个 selector 对象，真正操作时再结合 <code>df</code> 解析出列名。</p><p>比如：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">select(df, numeric() | starts_with(<span class="string">&quot;id&quot;</span>))</span><br><span class="line">select(df, everything() - contains(<span class="string">&quot;tmp&quot;</span>))</span><br><span class="line">mutate_across(df, where(<span class="keyword">lambda</span> s: s.isnull().<span class="built_in">any</span>()), <span class="keyword">lambda</span> s: s.fillna(<span class="number">0</span>))</span><br></pre></td></tr></table></figure><p>这样做有几个明显好处：</p><ul><li>helper 可以访问列名，也可以访问 dtype 或数据本身</li><li>selector 可以组合</li><li>列选择逻辑可以复用，而不是散落在各处</li></ul><p>我最后定下来的规则也比较简单：</p><ul><li><code>|</code> 表示并集</li><li><code>-</code> 表示排除</li><li>结果去重且保序</li><li>解析不到列时直接报错，不静默忽略</li></ul><p>这套东西一旦稳定下来，后面的 <code>select</code>、<code>mutate_across</code>、<code>rename_with</code>、<code>pivot_longer</code> 都会自然顺很多。</p><h1 id="一个我刻意没有做的东西：裸列名-NSE"><a href="#一个我刻意没有做的东西：裸列名-NSE" class="headerlink" title="一个我刻意没有做的东西：裸列名 NSE"></a>一个我刻意没有做的东西：裸列名 NSE</h1><p>如果熟悉 tidyverse，可能第一反应会问：</p><blockquote><p>能不能做成 <code>mutate(df, total=a + b)</code> 这种效果？</p></blockquote><p>答案是：<strong>理论上可以硬做，实际上不值得。</strong></p><p>在 R 里，<code>mutate()</code> 这种体验背后是 tidy evaluation；Python 没有这一层语言机制。真要做，只能走 <code>eval</code>、AST 改写、代理对象这些路线。</p><p>这种东西最大的风险不是“写不出来”，而是：</p><ul><li>报错不直观</li><li>调试很差</li><li>和 <code>pandas</code> 生态不一致</li><li>一旦边界复杂起来，很容易变成一套脆弱的语法魔法</li></ul><p>所以我最后的取舍是：</p><ul><li><strong>不做裸列名 NSE</strong></li><li>保留 <code>assign(...)</code> 处理少量显式新列</li><li>用 <code>mutate_across(...)</code> 解决批量变换</li><li>用 <code>case_when(...)</code>、<code>if_else(...)</code>、<code>recode(...)</code> 解决条件映射和重编码</li></ul><p>这套方案不花哨，但更稳。</p><h1 id="glimpse-也是我很想要的一个小东西"><a href="#glimpse-也是我很想要的一个小东西" class="headerlink" title="glimpse() 也是我很想要的一个小东西"></a><code>glimpse()</code> 也是我很想要的一个小东西</h1><p>在 notebook 里，<code>head()</code> 看值，<code>info()</code> 看结构，但两者之间总觉得差一口气。</p><p>所以我顺手加了一个 <code>glimpse()</code>：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> tidypy.tidy <span class="keyword">import</span> glimpse</span><br><span class="line"></span><br><span class="line">glimpse(df)</span><br></pre></td></tr></table></figure><p>它会给出：</p><ul><li>行数、列数</li><li>每列 dtype</li><li>非空数、缺失数</li><li>唯一值数量</li><li>前几条样本值预览</li></ul><p>而且支持：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">glimpse(df, cols=starts_with(<span class="string">&quot;score_&quot;</span>))</span><br><span class="line"><span class="built_in">print</span>(glimpse(df, as_text=<span class="literal">True</span>, display=<span class="literal">False</span>))</span><br></pre></td></tr></table></figure><p>这玩意不大，但在实际 notebook 里非常顺手。</p><h1 id="现在这个项目是什么状态"><a href="#现在这个项目是什么状态" class="headerlink" title="现在这个项目是什么状态"></a>现在这个项目是什么状态</h1><p>目前 TidyPy4DS 已经有一版可以直接用的实现，仓库里也把几件基础设施补上了：</p><ul><li>双语 README</li><li>双语函数文档</li><li>双语 notebook 示例</li><li><code>unittest</code> 测试</li><li>GitHub Actions CI</li><li>一批偏 janitor 风格的小函数</li></ul><p>示例 notebook 分成了三类：</p><ul><li><strong>Why tidypy</strong>：直接对比纯 pandas 和 tidypy</li><li><strong>Core APIs</strong>：看 selector、<code>mutate_across</code>、<code>summarize</code> 这些核心能力</li><li><strong>Reshape and missing values</strong>：看 <code>pivot_longer</code>、<code>pivot_wider</code>、<code>separate</code>、<code>unite</code>、缺失值处理</li></ul><p>项目整体还在早期阶段，但核心方向已经比较清楚：</p><ul><li>selector 系统要稳定</li><li>API 要尽量少而清晰</li><li>不做多余包装</li><li>不和 Python 自身的直觉打架</li><li>单文件实现先保持，但内部已经按模块整理，后续可拆</li></ul><h1 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h1><p>这个项目本质上不是在和 <code>pandas</code> 对抗，而是在承认一件事：</p><blockquote><p><code>pandas</code> 很强，但确实有一些高频清洗动作，写起来就是不够顺。</p></blockquote><p>如果能用一层很薄的 helper，把这些动作收成更统一、可复用、可读的入口，那它就值得存在。</p><p>如果你也经常在 <code>pandas</code> 和 tidyverse 的思路之间切换，TidyPy4DS 也许会正好补上你手感里缺的那一块。</p><p>最近我自己的感受也更明确了：这个项目最有价值的地方，不是“把 tidyverse 完整搬到 Python”，而是把那些每天都会写、但又总写得零碎的数据清洗动作，收成一套尽量干净的小工具。</p><p>项目地址：<a href="https://github.com/lenardar/TidyPy4DS">https://github.com/lenardar/TidyPy4DS</a></p>]]>
    </content>
    <id>https://lenardar.github.io/2026/03/10/TidyPy4DS%EF%BC%9A%E7%BB%99%20pandas%20%E8%A1%A5%E4%B8%8A%20tidyverse%20%E9%A3%8E%E6%A0%BC%E7%9A%84%E6%95%B0%E6%8D%AE%E6%B8%85%E6%B4%97%E4%BD%93%E9%AA%8C/</id>
    <link href="https://lenardar.github.io/2026/03/10/TidyPy4DS%EF%BC%9A%E7%BB%99%20pandas%20%E8%A1%A5%E4%B8%8A%20tidyverse%20%E9%A3%8E%E6%A0%BC%E7%9A%84%E6%95%B0%E6%8D%AE%E6%B8%85%E6%B4%97%E4%BD%93%E9%AA%8C/"/>
    <published>2026-03-10T22:30:00.000Z</published>
    <summary>
      <![CDATA[<h1 id="为什么写-TidyPy4DS"><a href="#为什么写-TidyPy4DS" class="headerlink" title="为什么写 TidyPy4DS"></a>为什么写 TidyPy4DS</h1><p>做数据分析的人，大概率都在 <code>pa]]>
    </summary>
    <title>TidyPy4DS：给 pandas 补上 tidyverse 风格的数据清洗体验</title>
    <updated>2026-03-10T22:30:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="研究" scheme="https://lenardar.github.io/tags/%E7%A0%94%E7%A9%B6/"/>
    <category term="Python" scheme="https://lenardar.github.io/tags/Python/"/>
    <content>
      <![CDATA[<h1 id="为什么写-PyKMExtract"><a href="#为什么写-PyKMExtract" class="headerlink" title="为什么写 PyKMExtract"></a><img src="/images/PenzancePool_ZH-CN4493022613_UHD.jpg">为什么写 PyKMExtract</h1><p>上一篇介绍了 <a href="https://github.com/lenardar/PyHEOR">PyHEOR</a>，其中「文献 KM 图 → IPD → 参数拟合 → 建模」是一个很实用的流程。但实际操作中，第一步「从 KM 图提取坐标」仍然是手工活——要么用 WebPlotDigitizer 一个点一个点地点，要么用 Engauge Digitizer 半自动描线，一张双臂 KM 图搞下来十几二十分钟是常事。</p><p>当一个系统评价涉及十几篇文献、每篇两三张 KM 图时，手工数字化就变成了一件非常痛苦的事情。</p><p>所以我写了 <strong>PyKMExtract</strong>——一个面向研究场景的 KM 曲线自动化数字化工具。它从论文截图中提取结构化的 <code>time / survival</code> 数据，做基础验证，再桥接到 PyHEOR 做 Guyot 重建和后续生存建模。</p><p>项目地址：<a href="https://github.com/lenardar/PyKMExtract">https://github.com/lenardar/PyKMExtract</a></p><h1 id="核心设计：简单默认链-可选-AI-增强"><a href="#核心设计：简单默认链-可选-AI-增强" class="headerlink" title="核心设计：简单默认链 + 可选 AI 增强"></a>核心设计：简单默认链 + 可选 AI 增强</h1><p>这个项目的核心原则是：</p><blockquote><p>默认提取链保持可解释、可复核；AI 可以参与，但只作为显式增强层，而不是整个测量过程的唯一来源。</p></blockquote><p>为什么不直接让 GPT-4o 看一眼图就把坐标吐出来？因为 LLM 的视觉能力擅长理解「这张图画了什么」，但不擅长做「这个像素在第几行第几列」这种精确测量。把语义理解和精确测量混在一起，出了错很难定位。</p><p>所以 PyKMExtract 的默认流程是这样分层的：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">语义提取 → 坐标轴检测 → 颜色提取 → KM 阶梯采样 → 坐标映射 → 验证</span><br></pre></td></tr></table></figure><p><strong>AI 负责语义，像素测量交给确定性算法。</strong> 两层各自可验证，出了问题一眼就能看出是哪个环节。</p><h1 id="提取流程拆解"><a href="#提取流程拆解" class="headerlink" title="提取流程拆解"></a>提取流程拆解</h1><h2 id="第一步：语义提取"><a href="#第一步：语义提取" class="headerlink" title="第一步：语义提取"></a>第一步：语义提取</h2><p>「语义」是指图中的结构化信息：有几条曲线、每条线叫什么名字是什么颜色、坐标轴范围是多少、有没有 number-at-risk 表。</p><p>支持两种来源：</p><ul><li><strong>手动准备 <code>semantic.json</code></strong>：适合高精度场景，坐标轴范围和颜色人眼确认</li><li><strong>在线视觉模型</strong>：接任何 OpenAI 兼容接口（GPT-4o、Qwen-VL 等），自动识别</li></ul><p>视觉模型的调用通过结构化 prompt 引导，要求严格按 JSON schema 输出，并做自动重试和归一化。模型返回的 <code>rgb_approx</code> 只是近似值，后面的颜色提取会用自适应容差去修正。</p><h2 id="第二步：坐标轴检测"><a href="#第二步：坐标轴检测" class="headerlink" title="第二步：坐标轴检测"></a>第二步：坐标轴检测</h2><p>自动检测图中的坐标轴线位置，得到绘图区域的像素边界。</p><p>算法很直接：对灰度图逐行逐列扫描，找最长的连续深色像素段，水平线和垂直线交叉的位置就是绘图区原点。如果两条轴线的检测结果不太对（比如图上没有明显轴线），会退回到非白色区域的包围盒。</p><p>这一步还支持可选的 <strong>AI 四点校轴</strong>：用视觉模型在图上标注候选锚点，模型选择并微调，得到更精确的四点映射。这对坐标轴不完全正交、或者图中有裁切偏移的情况很有用。</p><h2 id="第三步：颜色提取"><a href="#第三步：颜色提取" class="headerlink" title="第三步：颜色提取"></a>第三步：颜色提取</h2><p>知道了每条曲线的大致 RGB 颜色后，用欧氏距离在绘图区内找匹配像素：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">distance = sqrt((R - R_target)² + (G - G_target)² + (B - B_target)²)</span><br></pre></td></tr></table></figure><p>容差不是固定的——<strong>自适应容差扫描</strong>从紧（tolerance&#x3D;8）开始逐步放宽（8→12→18→24→32→…），直到像素数量和 x 方向覆盖度都满足要求为止。这样既不会因为容差太紧漏掉像素，也不会因为太松把背景噪声混进来。</p><p>如果图上有置信区间色带（CI band），会通过列密度统计自动去除——色带在每列的像素密度远高于曲线本身。</p><h2 id="第四步：KM-阶梯采样"><a href="#第四步：KM-阶梯采样" class="headerlink" title="第四步：KM 阶梯采样"></a>第四步：KM 阶梯采样</h2><p>这一步是 PyKMExtract 和通用曲线数字化工具的关键区别。</p><p>KM 曲线是<strong>右连续阶梯函数</strong>——水平段表示没有事件发生，垂直跳降表示事件。普通的曲线采样用中位数或均值，但这会把垂直跳降平滑掉，丢失 KM 的阶梯特征。</p><p>PyKMExtract 的做法是：<strong>每列取下包络</strong>（最大 y 像素值，即最低点），然后用<strong>右连续前向填充</strong>重采样。这样水平段保持水平，垂直跳降保持为瞬间跳变，不会产生虚假的斜坡。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">原始像素云 → 逐列下包络 → 规则 x 网格重采样 → 阶梯曲线</span><br></pre></td></tr></table></figure><h2 id="第五步：坐标映射与清洗"><a href="#第五步：坐标映射与清洗" class="headerlink" title="第五步：坐标映射与清洗"></a>第五步：坐标映射与清洗</h2><p>像素坐标通过四点锚定线性映射到数据空间（time, survival）。如果 y 轴是百分比（0–100），自动归一化到 0–1。</p><p>清洗步骤包括：</p><ul><li>按时间排序、去重</li><li>抑制孤立的下降毛刺（短暂下跳后立即回弹的噪声）</li><li>折叠短下降段（粗线条边缘造成的虚假斜坡）</li><li>强制累积最小值（保证单调非递增）</li><li>如果首个时间点不是 0，自动补 <code>(0, 1.0)</code> 原点</li></ul><h2 id="第六步：验证"><a href="#第六步：验证" class="headerlink" title="第六步：验证"></a>第六步：验证</h2><p>提取完成后不是直接输出，而是先做一组验证检查：</p><table><thead><tr><th>检查项</th><th>权重</th><th>说明</th></tr></thead><tbody><tr><td>单调性</td><td>30</td><td>生存概率必须单调非递增</td></tr><tr><td>范围</td><td>20</td><td>所有值必须在 [0, 1] 内</td></tr><tr><td>起点</td><td>15</td><td>第一个点应接近 1.0</td></tr><tr><td>覆盖率</td><td>15</td><td>曲线应覆盖至少 75% 的 x 轴范围</td></tr><tr><td>at-risk 一致性</td><td>20</td><td>提取的生存概率与 number-at-risk 表隐含的比例不应偏差过大</td></tr></tbody></table><p>加权后得到 0–100 分。还会额外检测<strong>重叠歧义</strong>——如果两条曲线有长段像素重合，分数会被压到 <code>medium</code>，提示需要人工复核。</p><p>验证不是为了掩盖问题，而是为了把问题暴露出来。难图应作为低置信结果保留，而不是强行伪装成高分。</p><h1 id="实际用法"><a href="#实际用法" class="headerlink" title="实际用法"></a>实际用法</h1><h2 id="单图提取（已有-semantic-JSON）"><a href="#单图提取（已有-semantic-JSON）" class="headerlink" title="单图提取（已有 semantic JSON）"></a>单图提取（已有 semantic JSON）</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">pykmextract figure.png \</span><br><span class="line">  --semantic-json semantic.json \</span><br><span class="line">  --output-json result.json \</span><br><span class="line">  --overlay overlay.png</span><br></pre></td></tr></table></figure><h2 id="单图提取（在线视觉模型）"><a href="#单图提取（在线视觉模型）" class="headerlink" title="单图提取（在线视觉模型）"></a>单图提取（在线视觉模型）</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">export</span> OPENROUTER_API_KEY=<span class="string">&quot;...&quot;</span></span><br><span class="line">pykmextract figure.png \</span><br><span class="line">  --provider openai-compatible \</span><br><span class="line">  --base-url https://openrouter.ai/api/v1 \</span><br><span class="line">  --model openai/gpt-4o \</span><br><span class="line">  --api-key-env OPENROUTER_API_KEY \</span><br><span class="line">  --output-json result.json \</span><br><span class="line">  --overlay overlay.png</span><br></pre></td></tr></table></figure><h2 id="开启-AI-校轴"><a href="#开启-AI-校轴" class="headerlink" title="开启 AI 校轴"></a>开启 AI 校轴</h2><p>加上 <code>--axis-refine</code>，会多跑一次视觉模型来校正四个轴锚点：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">pykmextract figure.png \</span><br><span class="line">  --provider openai-compatible \</span><br><span class="line">  --base-url https://openrouter.ai/api/v1 \</span><br><span class="line">  --model openai/gpt-4o \</span><br><span class="line">  --api-key-env OPENROUTER_API_KEY \</span><br><span class="line">  --axis-refine \</span><br><span class="line">  --output-json result.json \</span><br><span class="line">  --overlay overlay.png</span><br></pre></td></tr></table></figure><h2 id="Python-API"><a href="#Python-API" class="headerlink" title="Python API"></a>Python API</h2><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> pykmextract <span class="keyword">as</span> pkm</span><br><span class="line"></span><br><span class="line"><span class="comment"># 提取</span></span><br><span class="line">result = pkm.extract(<span class="string">&quot;figure.png&quot;</span>, semantic=semantic_payload)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 查看结果</span></span><br><span class="line">curve_df = result.curve_frame()          <span class="comment"># time / survival DataFrame</span></span><br><span class="line">validation_df = result.validation_frame() <span class="comment"># 验证问题清单</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 保存人工复核包</span></span><br><span class="line">result.save_review_bundle(<span class="string">&quot;runs/example&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 桥接到 PyHEOR 做 Guyot 重建</span></span><br><span class="line">ipd = result.to_pyheor_ipd()</span><br></pre></td></tr></table></figure><h2 id="批处理"><a href="#批处理" class="headerlink" title="批处理"></a>批处理</h2><p>当你有一批文献的 KM 图需要处理时，把图片按 <code>study01_os.png / study01_pfs.png</code> 这样的命名放到一个目录，然后：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 生成 manifest</span></span><br><span class="line">pykmextract-batch \</span><br><span class="line">  --image-dir images \</span><br><span class="line">  --literature-md images/literatures.md \</span><br><span class="line">  --output-json images/manifest.json</span><br><span class="line"></span><br><span class="line"><span class="comment"># 批量提取</span></span><br><span class="line">pykmextract-run-batch \</span><br><span class="line">  --image-dir images \</span><br><span class="line">  --literature-md images/literatures.md \</span><br><span class="line">  --base-url https://openrouter.ai/api/v1 \</span><br><span class="line">  --model openai/gpt-4o \</span><br><span class="line">  --api-key-env OPENROUTER_API_KEY \</span><br><span class="line">  --axis-refine \</span><br><span class="line">  --output-dir runs/batch</span><br></pre></td></tr></table></figure><p>输出按 study 和 endpoint 组织：<code>runs/study01/os/</code>、<code>runs/study01/pfs/</code>…</p><h1 id="输出内容"><a href="#输出内容" class="headerlink" title="输出内容"></a>输出内容</h1><p>每个提取任务输出一个 <strong>review bundle</strong>，供人工复核：</p><ul><li><code>original.png</code> — 原图备份</li><li><code>overlay.png</code> — 提取曲线叠加在原图上的对比图</li><li><code>digitized_curves.csv</code> — 提取的数值坐标</li><li><code>review.md</code> — 结构化 review 报告</li><li><code>reconstructed_km.png</code> — 从重建 IPD 重绘的 KM 曲线（当 PyHEOR 可用时）</li><li><code>ipd_*.csv</code> — 重建的个体数据</li></ul><p>overlay 是最直观的验证方式——提取的虚线和原图实线重合程度如何，一目了然。</p><h1 id="真实文献测试"><a href="#真实文献测试" class="headerlink" title="真实文献测试"></a>真实文献测试</h1><p>我在 5 篇 JAMA Oncology &#x2F; Lancet 的三期临床试验文献上做了测试（10 张 panel，涵盖 OS 和 PFS）。</p><p><strong>10 张 panel 的 overlay 总览：</strong></p><p><img src="/images/all_overlays.png"></p><p>逐张人工复核后的评价：</p><table><thead><tr><th>Panel</th><th>Score</th><th>评价</th><th>主要问题</th></tr></thead><tbody><tr><td>study01 &#x2F; os</td><td>80 &#x2F; high</td><td>较好</td><td>尾部与 at-risk 有偏差</td></tr><tr><td>study01 &#x2F; pfs</td><td>85 &#x2F; high</td><td>可用</td><td>后半段 coverage 不足</td></tr><tr><td>study02 &#x2F; os</td><td>80 &#x2F; high</td><td>可用</td><td>两条线都与 at-risk 有偏离</td></tr><tr><td>study02 &#x2F; pfs</td><td>80 &#x2F; high</td><td>偏弱</td><td>中后段偏低</td></tr><tr><td>study03 &#x2F; os</td><td>80 &#x2F; high</td><td>偏弱</td><td>台阶较粗，at-risk 一致性弱</td></tr><tr><td>study03 &#x2F; pfs</td><td>80 &#x2F; high</td><td>偏弱</td><td>后段像近似曲线</td></tr><tr><td>study04 &#x2F; os</td><td>100 &#x2F; high</td><td>最好</td><td>与原图最接近</td></tr><tr><td>study04 &#x2F; pfs</td><td>100 &#x2F; medium</td><td>较好</td><td>两条线长段重合，主动压分</td></tr><tr><td>study05 &#x2F; os</td><td>80 &#x2F; high</td><td>较好</td><td>主要扣分来自 at-risk</td></tr><tr><td>study05 &#x2F; pfs</td><td>80 &#x2F; high</td><td>较好</td><td>同上</td></tr></tbody></table><p><strong>效果最好的一张（study04 &#x2F; os，CheckMate 143）：</strong></p><p>原图 vs 重建 KM 对比：</p><table><thead><tr><th>原图</th><th>从重建 IPD 重绘的 KM</th></tr></thead><tbody><tr><td><img src="/images/study04_os_original.png" alt="img"></td><td><img src="/images/study04_os_reconstructed.png"></td></tr></tbody></table><p>提取 overlay：</p><p><img src="/images/study04_os_overlay.png"></p><p>更客观的结论是：</p><ul><li>在白底、高对比、1-2 条曲线的常见 KM 图上，PyKMExtract 基本可以做到「自动提取 + 人工快速复核」的工作流</li><li>当前仍然偏弱的场景：灰度图、颜色接近的多条线、密集置信区间带、低分辨率扫描件</li><li>分数不是目的，overlay 才是——80 分的图可能目视效果很好，100 分的图也可能因为重叠歧义被压到 medium</li></ul><h1 id="与-PyHEOR-的配合"><a href="#与-PyHEOR-的配合" class="headerlink" title="与 PyHEOR 的配合"></a>与 PyHEOR 的配合</h1><p>PyKMExtract 的输出可以直接喂给 PyHEOR 做 Guyot 重建：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> pykmextract <span class="keyword">as</span> pkm</span><br><span class="line"></span><br><span class="line">result = pkm.extract(<span class="string">&quot;figure.png&quot;</span>, semantic=semantic_payload)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 一行完成 Guyot IPD 重建</span></span><br><span class="line">ipd = result.to_pyheor_ipd()</span><br><span class="line"><span class="comment"># 返回 &#123;&quot;Nivolumab&quot;: &#123;&quot;time&quot;: [...], &quot;event&quot;: [...]&#125;, &quot;Bevacizumab&quot;: &#123;...&#125;&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 接上 PyHEOR 的生存拟合</span></span><br><span class="line"><span class="keyword">import</span> pyheor <span class="keyword">as</span> ph</span><br><span class="line">fitter = ph.SurvivalFitter(ipd[<span class="string">&quot;Nivolumab&quot;</span>][<span class="string">&quot;time&quot;</span>], ipd[<span class="string">&quot;Nivolumab&quot;</span>][<span class="string">&quot;event&quot;</span>], label=<span class="string">&quot;OS&quot;</span>)</span><br><span class="line">fitter.fit()</span><br><span class="line"><span class="built_in">print</span>(fitter.summary())</span><br><span class="line">best = fitter.best_model()</span><br><span class="line"></span><br><span class="line"><span class="comment"># 用于 PSM 建模</span></span><br><span class="line">psm.set_survival(<span class="string">&quot;SOC&quot;</span>, <span class="string">&quot;OS&quot;</span>, best.distribution)</span><br></pre></td></tr></table></figure><p>这就实现了完整的「论文 KM 截图 → 自动提取 → IPD 重建 → 参数拟合 → 经济学建模」流程，中间的手工数字化环节被自动化了。</p><h1 id="已知限制"><a href="#已知限制" class="headerlink" title="已知限制"></a>已知限制</h1><p>当前仓库是一个实用型 MVP，不是「所有 KM 图都能稳提」的通用数字化器。明确不太行的场景：</p><ul><li>灰度图或颜色接近的曲线（颜色提取依赖 RGB 距离）</li><li>多条线密集重叠的图（重叠段无法仅靠颜色区分）</li><li>明显置信区间带和密集删失标记（CI 去除是启发式的）</li><li>低分辨率扫描图或重度压缩截图</li><li>全自动 PDF 拆页</li></ul><p>这些限制是有意识的取舍——与其用不可靠的魔法把难图包装成高分结果，不如诚实地报一个低分，留给人工处理。</p><h1 id="后续计划"><a href="#后续计划" class="headerlink" title="后续计划"></a>后续计划</h1><ul><li>更好的边界案例处理（灰度图、低对比度）</li><li>更智能的重叠曲线分离策略</li><li>支持更多视觉模型后端</li><li>PDF 直接输入（自动识别 KM 图所在页面）</li><li>与 PyHEOR 的更深度集成（端到端 pipeline）</li></ul><p>如果你也在做系统评价或者卫生经济学建模，需要从文献中提取 KM 数据，欢迎试用和反馈：<a href="https://github.com/lenardar/PyKMExtract">PyKMExtract on GitHub</a></p>]]>
    </content>
    <id>https://lenardar.github.io/2026/03/10/PyKMExtract%EF%BC%9A%E4%BB%8E%E8%AE%BA%E6%96%87KM%E5%9B%BE%E5%88%B0%E7%94%9F%E5%AD%98%E6%95%B0%E6%8D%AE%E7%9A%84%E8%87%AA%E5%8A%A8%E5%8C%96%E6%8F%90%E5%8F%96/</id>
    <link href="https://lenardar.github.io/2026/03/10/PyKMExtract%EF%BC%9A%E4%BB%8E%E8%AE%BA%E6%96%87KM%E5%9B%BE%E5%88%B0%E7%94%9F%E5%AD%98%E6%95%B0%E6%8D%AE%E7%9A%84%E8%87%AA%E5%8A%A8%E5%8C%96%E6%8F%90%E5%8F%96/"/>
    <published>2026-03-10T20:00:00.000Z</published>
    <summary>
      <![CDATA[<h1 id="为什么写-PyKMExtract"><a href="#为什么写-PyKMExtract" class="headerlink" title="为什么写 PyKMExtract"></a><img src="/images/PenzancePool_ZH-CN44]]>
    </summary>
    <title>PyKMExtract：从论文KM图到生存数据的自动化提取</title>
    <updated>2026-03-10T20:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="研究" scheme="https://lenardar.github.io/tags/%E7%A0%94%E7%A9%B6/"/>
    <category term="Python" scheme="https://lenardar.github.io/tags/Python/"/>
    <content>
      <![CDATA[<p><img src="/images/%E6%8B%BE%E5%85%89_wallhaven_yxqmkg.jpg"></p><h1 id="为什么写-PyHEOR"><a href="#为什么写-PyHEOR" class="headerlink" title="为什么写 PyHEOR"></a>为什么写 PyHEOR</h1><p>做卫生经济学评价（Health Economics and Outcome Research, HEOR）的同学大多用 R（heemod、hesim、DARTH）或者 TreeAge。R 生态确实成熟，但我在实际项目中遇到了一些痛点：</p><ul><li>R 的多个包 API 风格不统一，拼一个完整流程需要在不同包之间跳来跳去</li><li>TreeAge 是商业软件，价格不菲，而且不够灵活</li><li>Python 在数据处理、机器学习方面生态更好，但缺少一个完整的 HEOR 框架</li></ul><p>所以我写了 <strong>PyHEOR</strong>——一个纯 Python 的卫生经济学建模框架，目标是用一套统一的 API 覆盖 HEOR 的全流程。</p><p>项目地址：<a href="https://github.com/lenardar/PyHEOR">https://github.com/lenardar/PyHEOR</a></p><h1 id="能做什么"><a href="#能做什么" class="headerlink" title="能做什么"></a>能做什么</h1><p>PyHEOR 目前支持以下功能：</p><p><strong>建模引擎</strong></p><ul><li>Markov 队列模型（离散时间状态转移）</li><li>分区生存模型（PSM）</li><li>微观模拟（个体水平状态转移）</li><li>离散事件模拟（DES，连续时间）</li></ul><p><strong>证据合成</strong></p><ul><li>IPD 生存曲线拟合（6 种分布，AIC&#x2F;BIC 比较）</li><li>KM 曲线数字化重建（Guyot method，从文献 KM 图反推 IPD）</li><li>NMA 后验样本整合（导入 R 包产生的 MCMC 样本）</li></ul><p><strong>分析与决策</strong></p><ul><li>基线分析、单因素敏感性分析（OWSA）、概率敏感性分析（PSA）</li><li>多策略比较：效率前沿、ICER、NMB、CEAC、CEAF、EVPI</li><li>预算影响分析（BIA）</li><li>模型校准（Nelder-Mead &#x2F; 随机搜索）</li></ul><p><strong>导出与可视化</strong></p><ul><li>28 种专业图表</li><li>Excel 多 Sheet 导出 + Excel 公式验证模型（用于交叉验证 Python 结果）</li><li>Markdown 一键报告（<code>generate_report()</code>，自动运行全部分析并生成报告 + 配套图片）</li></ul><h1 id="设计思路"><a href="#设计思路" class="headerlink" title="设计思路"></a>设计思路</h1><h2 id="一个模型对象搞定所有分析"><a href="#一个模型对象搞定所有分析" class="headerlink" title="一个模型对象搞定所有分析"></a>一个模型对象搞定所有分析</h2><p>定义好模型后，<code>run_base_case()</code>、<code>run_owsa()</code>、<code>run_psa()</code> 一气呵成，不需要为不同分析类型重新组织代码。同一个模型对象，确定性分析、敏感性分析、概率分析全部搞定：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">result = model.run_base_case()   <span class="comment"># 确定性基线</span></span><br><span class="line">owsa   = model.run_owsa()        <span class="comment"># 单因素敏感性</span></span><br><span class="line">psa    = model.run_psa(n_sim=<span class="number">1000</span>)  <span class="comment"># 概率敏感性</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 一键生成完整 Markdown 报告（含龙卷风图、CE 平面、CEAC 等）</span></span><br><span class="line">ph.generate_report(model, <span class="string">&quot;report.md&quot;</span>)</span><br></pre></td></tr></table></figure><p>在 R 里做同样的事情，往往需要手动把参数拆开重组，或者在不同的包之间传递数据。PyHEOR 把这些都封装在模型内部了。</p><h2 id="ph-C-补数哨兵"><a href="#ph-C-补数哨兵" class="headerlink" title="ph.C 补数哨兵"></a><code>ph.C</code> 补数哨兵</h2><p>写转移矩阵时最烦的就是对角线元素——要手动算 <code>1 - sum(其余)</code>，参数一多就容易出错。PyHEOR 用一个 <code>ph.C</code> 占位符自动补齐：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">model.set_transitions(<span class="string">&quot;SOC&quot;</span>, <span class="keyword">lambda</span> p, t: [</span><br><span class="line">    [ph.C,  p[<span class="string">&quot;p_HS&quot;</span>], p[<span class="string">&quot;p_HD&quot;</span>]],  <span class="comment"># ph.C = 1 - p_HS - p_HD</span></span><br><span class="line">    [<span class="number">0</span>,     ph.C,      p[<span class="string">&quot;p_SD&quot;</span>]],  <span class="comment"># ph.C = 1 - p_SD</span></span><br><span class="line">    [<span class="number">0</span>,     <span class="number">0</span>,         <span class="number">1</span>        ],</span><br><span class="line">])</span><br></pre></td></tr></table></figure><p>这个设计参考了 R heemod 的 <code>C</code> 常量，用过的同学应该很熟悉。</p><h2 id="Lambda-定义一切"><a href="#Lambda-定义一切" class="headerlink" title="Lambda 定义一切"></a>Lambda 定义一切</h2><p>费用、效用、转移概率都用 lambda 函数定义。好处是天然支持时变逻辑和参数依赖，不需要额外的配置项：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 前 24 个月用药费 5000，之后降为 2000</span></span><br><span class="line">model.set_state_cost(<span class="string">&quot;Sick&quot;</span>, <span class="string">&quot;Trt&quot;</span>, <span class="keyword">lambda</span> p, t: <span class="number">5000</span> <span class="keyword">if</span> t &lt; <span class="number">24</span> <span class="keyword">else</span> <span class="number">2000</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 年龄依赖的死亡概率（微观模拟中可以访问个体属性）</span></span><br><span class="line">model.set_transitions(<span class="string">&quot;SOC&quot;</span>, <span class="keyword">lambda</span> p, t, attrs: [</span><br><span class="line">    [ph.C,  p[<span class="string">&quot;p_HS&quot;</span>] * (<span class="number">1</span> + (attrs[<span class="string">&quot;age&quot;</span>] - <span class="number">55</span>) * <span class="number">0.02</span>), <span class="number">0.01</span>],</span><br><span class="line">    [<span class="number">0</span>,     ph.C,  p[<span class="string">&quot;p_SD&quot;</span>]],</span><br><span class="line">    [<span class="number">0</span>,     <span class="number">0</span>,     <span class="number">1</span>],</span><br><span class="line">])</span><br></pre></td></tr></table></figure><p>注意微观模拟的 lambda 有三个参数 <code>(p, t, attrs)</code>，第三个参数是患者属性。引擎会根据 lambda 的参数个数自动判断是队列模式还是个体模式。</p><h2 id="统一的参数系统"><a href="#统一的参数系统" class="headerlink" title="统一的参数系统"></a>统一的参数系统</h2><p>一个 <code>add_param()</code> 调用，同时定义基线值、OWSA 范围和 PSA 分布。不同分析自动取用对应的值：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">model.add_param(<span class="string">&quot;p_HS&quot;</span>,</span><br><span class="line">    base=<span class="number">0.15</span>,                        <span class="comment"># 确定性分析用</span></span><br><span class="line">    low=<span class="number">0.10</span>, high=<span class="number">0.20</span>,              <span class="comment"># OWSA 范围</span></span><br><span class="line">    dist=ph.Beta(mean=<span class="number">0.15</span>, sd=<span class="number">0.03</span>), <span class="comment"># PSA 抽样分布</span></span><br><span class="line">    label=<span class="string">&quot;健康→生病概率&quot;</span>,             <span class="comment"># 图表显示名</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>内置分布：Beta、Gamma、Normal、LogNormal、Uniform、Triangular、Dirichlet、Fixed。参数化方式用 <code>mean</code>&#x2F;<code>sd</code>，不需要手动算 alpha&#x2F;beta。</p><p>贴现率也纳入了参数系统——传入 <code>Param</code> 对象即可自动参与 OWSA 和 PSA，无需额外 <code>add_param()</code>：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">model = ph.MarkovModel(</span><br><span class="line">    ...,</span><br><span class="line">    dr_cost=ph.Param(<span class="number">0.03</span>, low=<span class="number">0.0</span>, high=<span class="number">0.08</span>, label=<span class="string">&quot;费用贴现率&quot;</span>),</span><br><span class="line">    dr_qaly=ph.Param(<span class="number">0.03</span>, low=<span class="number">0.0</span>, high=<span class="number">0.05</span>, label=<span class="string">&quot;效用贴现率&quot;</span>),</span><br><span class="line">)</span><br></pre></td></tr></table></figure><h2 id="灵活的费用体系"><a href="#灵活的费用体系" class="headerlink" title="灵活的费用体系"></a>灵活的费用体系</h2><p>除了基础的状态费用，还支持多种特殊费用场景：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 首周期一次性费用（如入组筛查费）</span></span><br><span class="line">model.set_state_cost(<span class="string">&quot;PFS&quot;</span>, <span class="string">&quot;Trt&quot;</span>, <span class="keyword">lambda</span> p, t: <span class="number">50000</span>, first_cycle_only=<span class="literal">True</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 限定应用周期（如 24 个月停药）</span></span><br><span class="line">model.set_state_cost(<span class="string">&quot;PFS&quot;</span>, <span class="string">&quot;Trt&quot;</span>, <span class="keyword">lambda</span> p, t: p[<span class="string">&quot;c_drug&quot;</span>], apply_cycles=(<span class="number">0</span>, <span class="number">24</span>))</span><br><span class="line"></span><br><span class="line"><span class="comment"># 转移费用：从 PFS 进展到 Progressed 时的手术费</span></span><br><span class="line">model.set_transition_cost(<span class="string">&quot;surgery&quot;</span>, <span class="string">&quot;PFS&quot;</span>, <span class="string">&quot;Progressed&quot;</span>, <span class="number">50000</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 转移费用计划表：手术 + 后续两周期随访</span></span><br><span class="line">model.set_transition_cost(<span class="string">&quot;surgery&quot;</span>, <span class="string">&quot;PFS&quot;</span>, <span class="string">&quot;Progressed&quot;</span>, [<span class="number">50000</span>, <span class="number">10000</span>, <span class="number">10000</span>])</span><br></pre></td></tr></table></figure><p>转移费用是基于每周期转移流量自动计算的，不受半周期校正影响。计划表通过卷积处理多批次转入患者的费用叠加。</p><h2 id="Excel-公式验证"><a href="#Excel-公式验证" class="headerlink" title="Excel 公式验证"></a>Excel 公式验证</h2><p>做 HEOR 审稿人经常要求提供 Excel 验证模型。PyHEOR 可以导出一个用 Excel 公式独立计算的完整模型文件：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">ph.export_excel_model(result, <span class="string">&quot;verification.xlsx&quot;</span>)</span><br></pre></td></tr></table></figure><p>导出的 Excel 里，转移矩阵是输入区（黄色底色），Trace、费用、QALY 全部用 <code>SUMPRODUCT</code> 等公式计算，Summary sheet 显示 Excel 结果 vs Python 结果的差异（应该是 ~0）。这样审稿人可以在 Excel 里直接验证模型逻辑。</p><h1 id="示例：Markov-队列模型"><a href="#示例：Markov-队列模型" class="headerlink" title="示例：Markov 队列模型"></a>示例：Markov 队列模型</h1><p>用 Markov 模型做一个三状态（健康→生病→死亡）的成本效果分析：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> pyheor <span class="keyword">as</span> ph</span><br><span class="line"></span><br><span class="line">model = ph.MarkovModel(</span><br><span class="line">    states=[<span class="string">&quot;Healthy&quot;</span>, <span class="string">&quot;Sick&quot;</span>, <span class="string">&quot;Dead&quot;</span>],</span><br><span class="line">    strategies=[<span class="string">&quot;Standard&quot;</span>, <span class="string">&quot;New Treatment&quot;</span>],</span><br><span class="line">    n_cycles=<span class="number">40</span>,</span><br><span class="line">    cycle_length=<span class="number">1</span>,</span><br><span class="line">    dr_cost=ph.Param(<span class="number">0.03</span>, low=<span class="number">0.0</span>, high=<span class="number">0.08</span>, label=<span class="string">&quot;费用贴现率&quot;</span>),</span><br><span class="line">    dr_qaly=ph.Param(<span class="number">0.03</span>, low=<span class="number">0.0</span>, high=<span class="number">0.05</span>, label=<span class="string">&quot;效用贴现率&quot;</span>),</span><br><span class="line">    half_cycle_correction=<span class="literal">True</span>,</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加参数</span></span><br><span class="line">model.add_param(<span class="string">&quot;p_HS&quot;</span>, base=<span class="number">0.15</span>, dist=ph.Beta(mean=<span class="number">0.15</span>, sd=<span class="number">0.03</span>))</span><br><span class="line">model.add_param(<span class="string">&quot;p_SD&quot;</span>, base=<span class="number">0.10</span>, dist=ph.Beta(mean=<span class="number">0.10</span>, sd=<span class="number">0.02</span>))</span><br><span class="line"></span><br><span class="line"><span class="comment"># 转移矩阵</span></span><br><span class="line">model.set_transitions(<span class="string">&quot;Standard&quot;</span>, <span class="keyword">lambda</span> p, t: [</span><br><span class="line">    [ph.C,  p[<span class="string">&quot;p_HS&quot;</span>], <span class="number">0.02</span>],</span><br><span class="line">    [<span class="number">0</span>,     ph.C,      p[<span class="string">&quot;p_SD&quot;</span>]],</span><br><span class="line">    [<span class="number">0</span>,     <span class="number">0</span>,         <span class="number">1</span>],</span><br><span class="line">])</span><br><span class="line"></span><br><span class="line">model.set_transitions(<span class="string">&quot;New Treatment&quot;</span>, <span class="keyword">lambda</span> p, t: [</span><br><span class="line">    [ph.C,  p[<span class="string">&quot;p_HS&quot;</span>] * <span class="number">0.7</span>, <span class="number">0.02</span>],</span><br><span class="line">    [<span class="number">0</span>,     ph.C,             p[<span class="string">&quot;p_SD&quot;</span>] * <span class="number">0.8</span>],</span><br><span class="line">    [<span class="number">0</span>,     <span class="number">0</span>,                <span class="number">1</span>],</span><br><span class="line">])</span><br><span class="line"></span><br><span class="line"><span class="comment"># 费用和效用</span></span><br><span class="line">model.set_state_cost(<span class="string">&quot;medical&quot;</span>, &#123;<span class="string">&quot;Healthy&quot;</span>: <span class="number">500</span>, <span class="string">&quot;Sick&quot;</span>: <span class="number">3000</span>, <span class="string">&quot;Dead&quot;</span>: <span class="number">0</span>&#125;)</span><br><span class="line">model.set_state_cost(<span class="string">&quot;drug&quot;</span>, &#123;</span><br><span class="line">    <span class="string">&quot;Standard&quot;</span>: &#123;<span class="string">&quot;Healthy&quot;</span>: <span class="number">0</span>, <span class="string">&quot;Sick&quot;</span>: <span class="number">0</span>, <span class="string">&quot;Dead&quot;</span>: <span class="number">0</span>&#125;,</span><br><span class="line">    <span class="string">&quot;New Treatment&quot;</span>: &#123;<span class="string">&quot;Healthy&quot;</span>: <span class="number">2000</span>, <span class="string">&quot;Sick&quot;</span>: <span class="number">2000</span>, <span class="string">&quot;Dead&quot;</span>: <span class="number">0</span>&#125;,</span><br><span class="line">&#125;)</span><br><span class="line">model.set_utility(&#123;<span class="string">&quot;Healthy&quot;</span>: <span class="number">0.95</span>, <span class="string">&quot;Sick&quot;</span>: <span class="number">0.60</span>, <span class="string">&quot;Dead&quot;</span>: <span class="number">0.0</span>&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 运行</span></span><br><span class="line">result = model.run_base_case()</span><br><span class="line"><span class="built_in">print</span>(result.summary())</span><br><span class="line"><span class="built_in">print</span>(result.icer())</span><br><span class="line"></span><br><span class="line"><span class="comment"># OWSA 龙卷风图（贴现率通过 Param 自动参与）</span></span><br><span class="line">owsa = model.run_owsa()</span><br><span class="line">owsa.plot_tornado()</span><br><span class="line"></span><br><span class="line"><span class="comment"># PSA</span></span><br><span class="line">psa = model.run_psa(n_sim=<span class="number">1000</span>)</span><br><span class="line">psa.plot_scatter(wtp=<span class="number">50000</span>)</span><br><span class="line">psa.plot_ceac(wtp_range=(<span class="number">0</span>, <span class="number">150000</span>))</span><br><span class="line"></span><br><span class="line"><span class="comment"># 一键报告</span></span><br><span class="line">ph.generate_report(model, <span class="string">&quot;report.md&quot;</span>)</span><br></pre></td></tr></table></figure><p>从定义模型到出结果，代码量很少，逻辑也比较清晰。</p><h1 id="示例：分区生存模型-PSM"><a href="#示例：分区生存模型-PSM" class="headerlink" title="示例：分区生存模型 (PSM)"></a>示例：分区生存模型 (PSM)</h1><p>肿瘤经济学评价最常用的就是 PSM。模型基于两条参数化生存曲线（OS 和 PFS）划分三个状态的占比，不需要写转移矩阵：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> pyheor <span class="keyword">as</span> ph</span><br><span class="line"></span><br><span class="line">psm = ph.PSMModel(</span><br><span class="line">    states=[<span class="string">&quot;PFS&quot;</span>, <span class="string">&quot;Progressed&quot;</span>, <span class="string">&quot;Dead&quot;</span>],</span><br><span class="line">    survival_endpoints=[<span class="string">&quot;PFS&quot;</span>, <span class="string">&quot;OS&quot;</span>],</span><br><span class="line">    strategies=[<span class="string">&quot;SOC&quot;</span>, <span class="string">&quot;New Drug&quot;</span>],</span><br><span class="line">    n_cycles=<span class="number">120</span>,</span><br><span class="line">    cycle_length=<span class="number">1</span>/<span class="number">12</span>,  <span class="comment"># 月周期</span></span><br><span class="line">    dr_cost=<span class="number">0.03</span>,</span><br><span class="line">    dr_qaly=<span class="number">0.03</span>,</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 基线生存曲线</span></span><br><span class="line">baseline_pfs = ph.LogLogistic(shape=<span class="number">1.5</span>, scale=<span class="number">18</span>)</span><br><span class="line">baseline_os = ph.Weibull(shape=<span class="number">1.2</span>, scale=<span class="number">36</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># SOC：直接用基线</span></span><br><span class="line">psm.set_survival(<span class="string">&quot;SOC&quot;</span>, <span class="string">&quot;PFS&quot;</span>, baseline_pfs)</span><br><span class="line">psm.set_survival(<span class="string">&quot;SOC&quot;</span>, <span class="string">&quot;OS&quot;</span>, baseline_os)</span><br><span class="line"></span><br><span class="line"><span class="comment"># New Drug：在 SOC 基础上应用 HR 和加速因子</span></span><br><span class="line">psm.set_survival(<span class="string">&quot;New Drug&quot;</span>, <span class="string">&quot;OS&quot;</span>,</span><br><span class="line">    <span class="keyword">lambda</span> p: ph.ProportionalHazards(baseline_os, hr=<span class="number">0.7</span>))</span><br><span class="line">psm.set_survival(<span class="string">&quot;New Drug&quot;</span>, <span class="string">&quot;PFS&quot;</span>,</span><br><span class="line">    <span class="keyword">lambda</span> p: ph.AcceleratedFailureTime(baseline_pfs, af=<span class="number">1.3</span>))</span><br><span class="line"></span><br><span class="line"><span class="comment"># 费用</span></span><br><span class="line">psm.set_state_cost(<span class="string">&quot;treatment&quot;</span>, &#123;</span><br><span class="line">    <span class="string">&quot;SOC&quot;</span>: &#123;<span class="string">&quot;PFS&quot;</span>: <span class="number">1000</span>, <span class="string">&quot;Progressed&quot;</span>: <span class="number">2500</span>, <span class="string">&quot;Dead&quot;</span>: <span class="number">0</span>&#125;,</span><br><span class="line">    <span class="string">&quot;New Drug&quot;</span>: &#123;<span class="string">&quot;PFS&quot;</span>: <span class="number">6000</span>, <span class="string">&quot;Progressed&quot;</span>: <span class="number">2500</span>, <span class="string">&quot;Dead&quot;</span>: <span class="number">0</span>&#125;,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 效用</span></span><br><span class="line">psm.set_utility(&#123;<span class="string">&quot;PFS&quot;</span>: <span class="number">0.80</span>, <span class="string">&quot;Progressed&quot;</span>: <span class="number">0.55</span>, <span class="string">&quot;Dead&quot;</span>: <span class="number">0.0</span>&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 运行</span></span><br><span class="line">result = psm.run_base_case()</span><br><span class="line"><span class="built_in">print</span>(result.summary())</span><br><span class="line"><span class="built_in">print</span>(result.icer())</span><br><span class="line"></span><br><span class="line"><span class="comment"># 可视化</span></span><br><span class="line">result.plot_survival()    <span class="comment"># 生存曲线</span></span><br><span class="line">result.plot_state_area()  <span class="comment"># 状态面积图</span></span><br></pre></td></tr></table></figure><p>PSM 的核心是生存分布的选择。PyHEOR 内置了 10 种参数化分布（Exponential、Weibull、LogLogistic、LogNormal、Gompertz、Generalized Gamma 等），还支持 <code>ProportionalHazards</code> 和 <code>AcceleratedFailureTime</code> 包装器，可以很方便地在基线曲线上应用 HR 或 AF。</p><h1 id="从文献-KM-图到建模"><a href="#从文献-KM-图到建模" class="headerlink" title="从文献 KM 图到建模"></a>从文献 KM 图到建模</h1><p>做 HEOR 经常遇到一个问题：文献只给了 KM 曲线图，没有原始数据。PyHEOR 集成了 Guyot method（Guyot et al. 2012），可以从数字化的 KM 坐标反推 IPD，然后直接拟合参数分布用于建模：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 1. 从 WebPlotDigitizer 等工具获取 KM 坐标</span></span><br><span class="line">t_digitized = [<span class="number">0</span>, <span class="number">2</span>, <span class="number">4</span>, <span class="number">6</span>, <span class="number">8</span>, <span class="number">10</span>, <span class="number">12</span>, <span class="number">15</span>, <span class="number">18</span>, <span class="number">21</span>, <span class="number">24</span>]</span><br><span class="line">s_digitized = [<span class="number">1.0</span>, <span class="number">0.92</span>, <span class="number">0.83</span>, <span class="number">0.74</span>, <span class="number">0.66</span>, <span class="number">0.58</span>, <span class="number">0.50</span>, <span class="number">0.40</span>, <span class="number">0.32</span>, <span class="number">0.25</span>, <span class="number">0.20</span>]</span><br><span class="line"></span><br><span class="line"><span class="comment"># 2. 从文献中读取 number-at-risk 表</span></span><br><span class="line">t_risk = [<span class="number">0</span>, <span class="number">6</span>, <span class="number">12</span>, <span class="number">18</span>, <span class="number">24</span>]</span><br><span class="line">n_risk = [<span class="number">120</span>, <span class="number">88</span>, <span class="number">60</span>, <span class="number">38</span>, <span class="number">22</span>]</span><br><span class="line"></span><br><span class="line"><span class="comment"># 3. 重建 IPD（内置坐标预处理：去噪、排序、单调化）</span></span><br><span class="line">ipd_time, ipd_event = ph.guyot_reconstruct(</span><br><span class="line">    t_digitized, s_digitized,</span><br><span class="line">    t_risk, n_risk,</span><br><span class="line">    tot_events=<span class="number">96</span>,  <span class="comment"># 可选：文献报告的总事件数</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 4. 拟合 6 种分布，AIC/BIC 自动比较</span></span><br><span class="line">fitter = ph.SurvivalFitter(ipd_time, ipd_event, label=<span class="string">&quot;OS&quot;</span>)</span><br><span class="line">fitter.fit()</span><br><span class="line"><span class="built_in">print</span>(fitter.summary())        <span class="comment"># AIC/BIC 比较表</span></span><br><span class="line">best = fitter.best_model()     <span class="comment"># 自动选最优</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 5. 直接用于 PSM 建模</span></span><br><span class="line">psm.set_survival(<span class="string">&quot;SOC&quot;</span>, <span class="string">&quot;OS&quot;</span>, best.distribution)</span><br></pre></td></tr></table></figure><p>手动数字化的坐标难免有噪声（手抖、非单调点），<code>guyot_reconstruct</code> 内部会自动调用预处理（参考了 R 包 IPDfromKM 的策略），包括异常值检测、重复时间处理、强制单调非递增等。</p><p>这个「文献 KM 图 → IPD → 参数拟合 → 建模」的流程在实际项目中非常实用。</p><h1 id="后续计划"><a href="#后续计划" class="headerlink" title="后续计划"></a>后续计划</h1><p>PyHEOR 目前的功能基本覆盖了 HEOR 常见需求。后续可能会做的事情：</p><ul><li>更多的示例和教程</li><li>与 R 生态的结果对比验证</li><li>性能优化（特别是微观模拟和 DES）</li></ul><p>另外，未来将考虑增强 PyHEOR 与 AI 的适配性，包括：</p><ul><li>结构化输出（<code>to_dict</code> &#x2F; <code>to_json</code>），让分析结果直接可被 LLM 读取和理解</li><li>自动解读（<code>interpret(wtp)</code>），一键生成标准化的结论文本</li><li>自然语言建模接口，通过 JSON Schema 定义模型，LLM 无需编写 Python 代码即可完成建模</li></ul><p>如果你也在做卫生经济学评价，欢迎试用和反馈：<a href="https://github.com/lenardar/PyHEOR">PyHEOR on GitHub</a></p>]]>
    </content>
    <id>https://lenardar.github.io/2026/03/02/PyHEOR%EF%BC%9A%E7%94%A8Python%E5%81%9A%E5%8D%AB%E7%94%9F%E7%BB%8F%E6%B5%8E%E5%AD%A6%E5%BB%BA%E6%A8%A1/</id>
    <link href="https://lenardar.github.io/2026/03/02/PyHEOR%EF%BC%9A%E7%94%A8Python%E5%81%9A%E5%8D%AB%E7%94%9F%E7%BB%8F%E6%B5%8E%E5%AD%A6%E5%BB%BA%E6%A8%A1/"/>
    <published>2026-03-02T19:04:14.000Z</published>
    <summary>
      <![CDATA[<p><img src="/images/%E6%8B%BE%E5%85%89_wallhaven_yxqmkg.jpg"></p>
<h1 id="为什么写-PyHEOR"><a href="#为什么写-PyHEOR" class="headerlink" title="为什么]]>
    </summary>
    <title>PyHEOR：用Python做卫生经济学建模</title>
    <updated>2026-03-02T19:04:14.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="折腾" scheme="https://lenardar.github.io/tags/%E6%8A%98%E8%85%BE/"/>
    <content>
      <![CDATA[<p>从win迁移到mac，发现微信文件备份变麻烦了，win可以直接指定聊天文件的位置，但是mac在资源库中，需要层层剥茧，有点麻烦，不过我发现了一个非常简单的备份流程，哈哈哈，特此记录。</p><p>首先我们知道微信聊天记录存在资源库中，而且在资源库中的文件路径特别复杂，因此我另辟蹊径，直接从微信文件右键查看在访达中显示的方法快速找路径，是不是很快：</p><p><img src="/images/20250524112419235.png"></p><p>我们关注到文件路径里的 <code>MessageTemp</code>，这就是我们要备份的位置：</p><p><img src="/images/20250524112436188.png"></p><p>将这个路径移到侧栏，方便我们云盘里选择路径，因为资源库默认不显示，如果不移到侧栏里云盘里看不到：</p><p><img src="/images/20250524112741243.png"></p><p>最后云盘里选择该文件夹即可，我们以阿里云盘为例：</p><p><img src="/images/20250524112853880.png"></p><p>ok，这样就可以自动云备份微信文件了！</p>]]>
    </content>
    <id>https://lenardar.github.io/2025/05/24/MacBook-%E5%BE%AE%E4%BF%A1%E6%96%87%E4%BB%B6%E4%BA%91%E5%A4%87%E4%BB%BD/</id>
    <link href="https://lenardar.github.io/2025/05/24/MacBook-%E5%BE%AE%E4%BF%A1%E6%96%87%E4%BB%B6%E4%BA%91%E5%A4%87%E4%BB%BD/"/>
    <published>2025-05-24T11:29:43.000Z</published>
    <summary>
      <![CDATA[<p>从win迁移到mac，发现微信文件备份变麻烦了，win可以直接指定聊天文件的位置，但是mac在资源库中，需要层层剥茧，有点麻烦，不过我发现了一个非常简单的备份流程，哈哈哈，特此记录。</p>
<p>首先我们知道微信聊天记录存在资源库中，而且在资源库中的文件路径特别复杂，因此]]>
    </summary>
    <title>MacBook 微信文件云备份</title>
    <updated>2025-05-24T11:29:43.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="折腾" scheme="https://lenardar.github.io/tags/%E6%8A%98%E8%85%BE/"/>
    <content>
      <![CDATA[<p><img src="/images/marek-piwnicki-RAdX-hwTci8-unsplash.jpg"></p><p>关于如何注册Azure OpenAI以及部署模型就不谈了，好多文章都介绍了，我主要记录一下自己配置思源笔记中Azure OpenAI的经验，配了一上午终于成功了！</p><h1 id="需要填写的参数"><a href="#需要填写的参数" class="headerlink" title="需要填写的参数"></a>需要填写的参数</h1><p>首先需要填一下用红框框出来的参数：</p><p><img src="/images/20250512100022387.png"></p><p>一个一个介绍。</p><h1 id="模型"><a href="#模型" class="headerlink" title="模型"></a>模型</h1><p>打开<a href="https://ai.azure.com/resource/overview?wsid=/subscriptions/0b9125f4-4aaa-4c9c-9baf-8cf08ef536fa/resourceGroups/xu/providers/Microsoft.CognitiveServices/accounts/hangz-ma8x5yyo-eastus2&tid=160241f0-17d5-495b-a797-4fa33b5ed5aa">欢迎使用 Azure AI Foundry - Azure OpenAI 服务</a>连接</p><p>选择“部署”选项，页面中的“名称”或“模型名称”就是要填的模型参数</p><p><img src="/images/20250512100103717.png"></p><h1 id="API-Key"><a href="#API-Key" class="headerlink" title="API Key"></a>API Key</h1><p>点击任意一个模型，模型中的“密钥”就是要设置的API Key：</p><p><img src="/images/20250512100122535.png"></p><h1 id="API-基础地址"><a href="#API-基础地址" class="headerlink" title="API 基础地址"></a>API 基础地址</h1><p>这个设置就比较坑了，不能直接用目标URL，而是截取一部分，比如你的API是：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://&#123;你的&#125;.cognitiveservices.azure.com/openai/deployments/o3-mini/chat/completions?api-version=<span class="number">2025</span>-01-01-preview</span><br></pre></td></tr></table></figure><p>那么只需要这一部分：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://&#123;你的&#125;.cognitiveservices.azure.com</span><br></pre></td></tr></table></figure><p>或者也可以直接复制主页的“Azure OpenAI 服务终结点”：</p><p><img src="/images/20250512100140012.png"></p><h1 id="API版本"><a href="#API版本" class="headerlink" title="API版本"></a>API版本</h1><p>这也是一个坑，在“部署”界面中的“模型版本”是没用的：</p><p><img src="/images/20250512100153645.png"></p><p>正确的做法是从目标URL中提取：</p><p><img src="/images/20250512100330277.png"></p><p>比如目标URL是：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://&#123;你的&#125;.cognitiveservices.azure.com/openai/deployments/o3-mini/chat/completions?api-version=<span class="number">2025</span>-01-01-preview</span><br></pre></td></tr></table></figure><p>那么模型版本就是 <code>api-version=</code>后面的部分，这里是：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="number">2025</span>-01-01-preview</span><br></pre></td></tr></table></figure><h1 id="模型测试"><a href="#模型测试" class="headerlink" title="模型测试"></a>模型测试</h1><p>这些参数填完，就可以用了，测试一下！</p><p>测试</p><blockquote><p>测试收到！如果您有其他问题或需要帮助的地方，请随时告诉我。</p></blockquote><p>测试成功，耶！</p><p>希望大家可以在配置的路上少走弯路，上述方法在oneapi中也适用！</p><p>‍</p><p>‍</p>]]>
    </content>
    <id>https://lenardar.github.io/2025/05/12/%E6%80%9D%E6%BA%90%E7%AC%94%E8%AE%B0Azure%20OpenAI%E9%85%8D%E7%BD%AE%E6%8C%87%E5%8D%97/</id>
    <link href="https://lenardar.github.io/2025/05/12/%E6%80%9D%E6%BA%90%E7%AC%94%E8%AE%B0Azure%20OpenAI%E9%85%8D%E7%BD%AE%E6%8C%87%E5%8D%97/"/>
    <published>2025-05-12T10:08:34.000Z</published>
    <summary>
      <![CDATA[<p><img src="/images/marek-piwnicki-RAdX-hwTci8-unsplash.jpg"></p>
<p>关于如何注册Azure OpenAI以及部署模型就不谈了，好多文章都介绍了，我主要记录一下自己配置思源笔记中Azure OpenAI的经验，]]>
    </summary>
    <title>思源笔记 Azure OpenAI 配置指南</title>
    <updated>2025-05-12T10:08:34.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Lenardar</name>
    </author>
    <category term="随笔" scheme="https://lenardar.github.io/tags/%E9%9A%8F%E7%AC%94/"/>
    <content>
      <![CDATA[<p><img src="/images/marek-piwnicki-OtQbFMOZlhc-unsplash.jpg"></p><h1 id="这是我的第一篇博客！"><a href="#这是我的第一篇博客！" class="headerlink" title="这是我的第一篇博客！"></a>这是我的第一篇博客！</h1>]]>
    </content>
    <id>https://lenardar.github.io/2025/05/11/%E8%A7%81%E5%AD%97%E5%A6%82%E9%9D%A2%EF%BC%81/</id>
    <link href="https://lenardar.github.io/2025/05/11/%E8%A7%81%E5%AD%97%E5%A6%82%E9%9D%A2%EF%BC%81/"/>
    <published>2025-05-11T20:31:16.000Z</published>
    <summary>
      <![CDATA[<p><img src="/images/marek-piwnicki-OtQbFMOZlhc-unsplash.jpg"></p>
<h1 id="这是我的第一篇博客！"><a href="#这是我的第一篇博客！" class="headerlink" title="这是我的第]]>
    </summary>
    <title>见字如面！</title>
    <updated>2025-05-11T20:31:16.000Z</updated>
  </entry>
</feed>
