<?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，还在读书，方向是 HEOR。<br>平时用 Python 和 R 干活，喜欢造点小轮子、折腾各种工作流。<br>博客随缘更新，写点技术记录和碎碎念，有问题欢迎邮件联系我～]]>
  </subtitle>
  <title>Lenardar's Blog</title>
  <updated>2026-07-28T18:34:21.000Z</updated>
  <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/%E6%B0%B4%E4%B8%80%E7%AF%87%EF%BC%9A%E7%BB%99%E5%8D%9A%E5%AE%A2%E5%A4%A7%E6%89%AB%E9%99%A4%20+%20%E8%A3%85%E4%BF%AE/</id>
    <link href="https://lenardar.github.io/2026/07/28/%E6%B0%B4%E4%B8%80%E7%AF%87%EF%BC%9A%E7%BB%99%E5%8D%9A%E5%AE%A2%E5%A4%A7%E6%89%AB%E9%99%A4%20+%20%E8%A3%85%E4%BF%AE/"/>
    <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>
