- 开发工具
- 数据可视化
【免费下载链接】penrose
Create beautiful diagrams just by typing notation in plain text.
Penrose 的核心是一个"把数学符号变成图表"的编译器:Substance 描述对象、Style 定义布局规则,最终所有布局规则都会被翻译成一个可微的能量函数,交给数值优化器去求解。@penrose/core暴露的 Optimization API 正是这套底层能力的程序化入口,允许你在 TypeScript 中直接构造优化变量、约束与目标函数,并运行优化器求解。读完本文,你将掌握variable、problem、start/run/converged的完整用法,理解约束如何被编码为能量函数、优化器如何收敛,并能像 engine 单元测试 一样写出自己的最小可运行优化程序。
本文主体对应仓库文档 optimization-api.md,并结合 constraints.md 与packages/core源码展开深入讲解。
一、优化 API 的定位:从 Style 语言到 TypeScript 底层
在 Penrose 中,一张"好图"被量化为所有约束与目标函数的能量之和,优化过程就是不断降低这个能量。日常使用中你通过.style文件间接声明约束与目标(如ensure、encourage),而 Optimization API 则把这些能力直接暴露为 TypeScript 函数,用于程序化地构造和求解优化问题——适合构建自定义布局引擎、批量生成变体,或为 Penrose 本身贡献新的约束/目标函数。
该 API 由核心包导出,入口位于 api.ts:
export { compile, ops, polyRootsImpl, problem } from "./engine/Autodiff.js";也就是说,你可以直接这样引入:
import { variable, problem, ops, add, sub, pow, mul, inverse } from "@penrose/core";所有核心实现在 engine/Autodiff.ts 与 engine/Optimizer.ts 中,类型定义集中在 types/ad.ts。
二、variable:优化问题中的可变参数
variable(val: number)创建优化问题中的一个变量——一个在优化过程中可以被优化器改变的数值。它的唯一参数是初始值val。
源码实现非常直观(Autodiff.ts#L120):
export const variable = (val: number): ad.Var => ({ tag: "Var", val });对应的类型定义(types/ad.ts#L17-L20):
export interface Var { tag: "Var"; val: number; }ad.Var是"自动微分计算图"中的叶子节点:val保存当前数值(初始值),而tag: "Var"标记它是一等优化变量。使用要点:
val是初始值,优化开始后由优化器驱动更新;- 同一个
variable可以被多个约束/目标函数引用,它们的梯度会正确累加; - 普通
number也会被自动视为常量节点(ad.Num = number | Var | ...),因此add(5, 3)、add(x, 3)都合法。
const x = variable(10); // 初始值 10,后续可被优化器改变 const y = variable(0); // 初始值 0三、problem:组装并求解优化问题
problem(desc)接受一个优化问题描述,返回一个Promise<Problem>。描述类型为Description(types/ad.ts#L174-L179):
export interface Description { /** zero by default */ objective?: Num; /** empty by default */ constraints?: Num[]; }即:objective是要最小化的目标表达式(默认 0),constraints是约束表达式列表(默认为空)。二者都是Num——即可微计算图中的节点。
3.1 创建问题后必须先start()
problem()返回的Problem只有start(config)一个方法(types/ad.ts#L201-L203)。start(config)会基于当前变量初值初始化优化器状态,并返回一个Run对象(types/ad.ts#L186-L192):
export interface Run { converged: boolean; /** doesn't include frozen */ vals: Map<Var, number>; /** returns a new `Run`, leaving this one unchanged */ run(opts: Options): Run; } export interface Options { /** always false by default */ until?(): boolean; } export interface Config { /** uses `val` field by default */ vals?(x: Var): number; /** always false by default */ freeze?(x: Var): boolean; }Run的三个核心成员:
run(until?):推进优化。until是可选的停止条件回调;不传则一直优化到收敛。converged:布尔标志,表示优化器是否已收敛。vals:Map<Var, number>,记录优化后每个变量的取值(不含被freeze冻结的变量)。优化完成后需要手动回填:for (const [z, a] of run.vals) z.val = a;。
3.2 官方示例完整展开
原文档给出的最小示例是:最小化 $x$ 使得 $(x-5)^2$ 最小,初始值 $x=10$。完整可运行版本如下:
import { variable, pow, sub, problem } from "@penrose/core"; const x = variable(10); const problem = await problem({ constraints: [pow(sub(x, 5), 2)] }); const { vals } = problem.start({}).run({}); console.log(problem.converged); // true console.log(x); // a value closer to 5逐步拆解:
variable(10)创建初值为 10 的变量x;sub(x, 5)计算 $x - 5$,pow(sub(x, 5), 2)计算 $(x-5)^2$——注意这里不能写(x - 5) ** 2,必须使用可微算子sub/pow;problem({ constraints: [...] })组装问题(此处也可以把表达式放进objective,效果类似,见下文 §5);start({})用默认配置(所有变量可动、初值取自val)初始化;run({})不传until,优化器会一路运行直到收敛;converged为true,x的值被优化到接近 5。
3.3 真实测试用例佐证
引擎自带的单元测试 Autodiff.test.ts 完整演示了"no freeze"与"freeze"两种场景:
describe("problem tests", () => { const f = (x: ad.Num, y: ad.Num) => add(squared(sub(x, y)), squared(x)); test("no freeze", async () => { const x = variable(1); const y = variable(2); const p = await problem({ objective: f(x, y) }); const run = p.start({}).run({}); expect(run.converged).toBe(true); for (const [z, a] of run.vals) z.val = a; expect(x.val).toBeCloseTo(0); expect(y.val).toBeCloseTo(0); }); test("freeze", async () => { const x = variable(1); const y = variable(2); const p = await problem({ objective: f(x, y) }); const run = p.start({ freeze: (z) => z === x }).run({}); expect(run.converged).toBe(true); for (const [z, a] of run.vals) z.val = a; expect(x.val).toBe(1); expect(y.val).toBeCloseTo(1); }); });这个例子同时示范了两个实用细节:
freeze配置:start({ freeze: (z) => z === x })冻结变量x,优化时保持其初值 1 不动,只有y参与优化,最终y收敛到 1(因为 $f=(x-y)^2+x^2$ 在 $x=1$ 时对 $y$ 的最优解是 $y=1$);- 结果回填:
run.vals是Map<Var, number>,需要手动写回z.val = a,之后x.val才反映优化后的值。
3.4until停止条件与start的底层行为
until在底层由 Autodiff.ts#L1121-L1151 的run实现驱动:每次迭代前先执行until(),返回true则提前终止(stop标志),否则继续,直到isConverged(after)为真。因此你可以实现"定时停止""步数限制""能量阈值"等自定义停止策略:
let steps = 0; const run = problem.start({}).run({ until: () => ++steps >= 100, // 最多迭代 100 步 });start(config)的config.vals允许自定义变量初值来源(默认读x.val),config.freeze控制哪些变量保持不动(Autodiff.ts#L1102-L1111):
const run = problem.start({ vals: (z) => (z === x ? 42 : z.val), // 给 x 一个自定义初值 freeze: (z) => z === x, // 并冻结它 });注意run()返回的是新的Run对象,不会修改原来的Run(类型注释明确写着 "returns a newRun, leaving this one unchanged"),因此可以安全地对同一个起点执行多次不同策略的优化。
四、底层原理:Autodiff 计算图、L-BFGS 与两阶段收敛
4.1 从表达式到编译后的梯度函数
problem()的核心工作分四步(Autodiff.ts#L1064-L1160):
- 拓扑排序:用
topsort对目标与约束表达式做拓扑排序,收集所有tag === "Var"的输入变量并编号; - 构建能量函数:约束先经过
penalty()变成惩罚项(默认越界越"贵"),再与目标相加,形成带权重 $w$ 的复合能量 $\Phi = \text{obj} + w \cdot \sum \text{penalty}(\text{constr})$; - 反向模式自动微分:通过 rose 的
vjp(vector-Jacobian product)一次性同时算出能量 $\phi$ 与梯度 $\nabla \Phi$; - 编译:
rose.compile把计算图编译为可重复调用的函数f,供优化器每步求值。
4.2 优化器:外点法 + L-BFGS 两阶段
优化驱动位于 Optimizer.ts。stepUntil实现了**外点法(exterior point method)**与 L-BFGS 的组合,状态机分为两个阶段:
UnconstrainedRunning/UnconstrainedConverged(UO 阶段):在固定惩罚权重 $w$ 下,用 L-BFGS 最小化 $\Phi$,当梯度范数低于阈值时进入"无约束已收敛";- EP 检查:每轮 UO 收敛后,检查相邻两轮 EP 状态/能量是否足够接近(
epConverged),收敛则置optStatus = "EPConverged";否则把惩罚权重乘以weightGrowthFactor进入下一轮(惩罚越来越重,约束被越来越严格地满足)。
因此converged标志并非"某一步"的结果,而是整个外点法迭代收敛的最终状态。收敛判定在 Autodiff.ts#L1061-L1062:
const isConverged = (params: Params): boolean => params.optStatus === "EPConverged";4.3 约束如何变成能量:零基准不等式
为什么示例里把 $(x-5)^2$ 放进constraints也能工作?因为 Penrose 把一切约束统一编码为能量函数:约束写成零基准不等式 $p(x) \le 0$ 的形式,$p(x) > 0$ 时按违反程度产生正惩罚,$p(x) \le 0$ 时视为满足、惩罚为 0 或负值。因此:
- 目标函数:输出"越差越大"的数值,鼓励优化器寻找局部极小;
- 约束函数:本质是"违规惩罚",违规越多惩罚越高。
这也是 constraints.md 强调的两条规则:
- 把 $f(x) \le c$ 改写为零基准不等式 $f(x) - c \le 0$;
- 能量函数取 $E(x) = f(x) - c$:大于 0 当且仅当约束被违反,违反越严重能量越高。
基于此,你可以把"让圆 $A$ 含于圆 $B$"这类几何关系写成能量 $d - (r_1 - r_2)$($d$ 为两圆心距),详见 constraints.md 的完整推导。
五、算术函数:用可微算子替代原生运算
原文档在"All the arithmetic functions"一节标注了Coming soon。事实上该函数库已在源码中完整实现,位置在 engine/AutodiffFunctions.ts,且文档化的函数清单可查阅 Function Library。为了自动微分,必须用这些预定义算子替代+、-、*、/、Math.pow等原生运算,因为优化器需要追踪所有数值操作以计算梯度。
按操作数数量可分成几类(实现见 AutodiffFunctions.ts#L3-L125):
| 类别 | 函数 | 数学含义 |
|---|---|---|
| 二元 | add(v, w)/sub(v, w)/mul(v, w)/div(v, w) | $v+w$ / $v-w$ / $vw$ / $v/w$ |
| 二元 | pow(v, w)/max(v, w)/min(v, w)/atan2(y, x) | $v^w$ / 最大值 / 最小值 / 二参反正切 |
| 一元 | neg(v)/squared(v)/sqrt(v)/inverse(v) | $-v$ / $v^2$ / $\sqrt v$ / $1/v$ |
| 一元 | absVal(v)、sin/cos/tan、exp/log、floor/ceil等 | 绝对值、三角、指数对数、取整等 |
| n 元 | addN(xs)/maxN(xs)/minN(xs) | 对数组求和/取最大/取最小 |
向量(ops) | ops.vadd/ops.vnorm/ops.vdist/ops.vdistsq/ops.mmadd等 | 向量加、范数、欧氏距离、距离平方、矩阵加 |
例如 Autodiff.ts#L125-L137 中:
export const ops = { norm: (c1: ad.Num, c2: ad.Num): ad.Num => ops.vnorm([c1, c2]), dist: (c1: ad.Num, c2: ad.Num): ad.Num => ops.vnorm([c1, c2]), vadd: (v1: ad.Num[], v2: ad.Num[]): ad.Num[] => { /* 向量加法 */ }, // ... };结合 constraints.md 中经典的repel目标函数,可以直观看到这些算子的组合用法——让两个圆互相排斥(鼓励远离):
const repel = (s1: Circle<Num>, s2: Circle<Num>, weight: Num = 10e6) => { const epsDenom = 10e-6; // 避免除零 const res = inverse( add(ops.vdistsq(s1.center.contents, s2.center.contents), epsDenom), ); return mul(res, weight); };其数学本质是 $\frac{1}{d^2}$:两圆距离 $d$ 越小,惩罚越大;再乘以weight = 10e6放大梯度信号。这里还示范了两个常见模式:
shape.field.contents:通过shapeName.propertyName.contents读取形状字段的可微值(如c.r.contents返回Num类型的半径);weight参数:当某个能量函数量级过小时,乘以大权重放大它对优化的影响。
再看 constraints.md 给出的"两圆不相交"约束及其带padding的版本,注意约束要写成能量(违规为正、满足为负/零):
/* d(c1, c2) + r1 + r2 >= 0 */ const disjoint = (s1: Circle<Num>, s2: Circle<Num>) => { const res = add(s1.r.contents, s2.r.contents); return sub(res, ops.vdist(s1.center.contents, s2.center.contents)); }; /* d(c1, c2) + r1 + r2 >= padding */ const disjointPadding = (s1: Circle<Num>, s2: Circle<Num>, padding: Num) => { const res = add(add(s1.r.contents, s2.r.contents), padding); return sub(res, ops.vdist(s1.center.contents, s2.center.contents)); };六、完整实战:把"最小化 $(x-5)^2$"玩出花样
把前文知识串起来,下面给出一个同时使用objective、constraints、until、freeze的综合示例,可直接在 Node/TS 环境运行:
import { variable, problem, ops, add, sub, mul, pow, squared, inverse, } from "@penrose/core"; // 1) 单变量最小化:min (x-5)^2 const x = variable(10); const p1 = await problem({ constraints: [pow(sub(x, 5), 2)] }); const r1 = p1.start({}).run({}); console.log("converged:", r1.converged); // true for (const [z, a] of r1.vals) z.val = a; console.log("x ≈ 5:", x.val); // 接近 5 // 2) 同时使用目标与约束:min (x-2)^2,同时要求 x >= 0(惩罚编码) const y = variable(-3); const p2 = await problem({ objective: squared(sub(y, 2)), constraints: [mul(-1, y)], // -y <= 0,即 y >= 0 }); const r2 = p2.start({}).run({ until: () => false /* 忽略收敛,交由优化器自行收敛 */ }); for (const [z, a] of r2.vals) z.val = a; console.log("y ≈ 2:", y.val); // 3) 向量操作:最小化两个二维点之间的距离 const a = variable(0); const b = variable(0); const c = variable(1); const d = variable(1); const p3 = await problem({ objective: ops.vdistsq([a, b], [c, d]), // || (a,b) - (c,d) ||^2 }); const r3 = p3.start({}).run({}); for (const [z, v] of r3.vals) z.val = v; // (a,b) 与 (c,d) 重合,距离趋于 0 console.log(a.val, b.val, c.val, d.val);要点提醒:
problem()是异步的,必须await(底层需要编译计算图);- 运行优化前必须调用
start(),否则没有可用的run/converged; - 优化结果在
run.vals中,记得回填到变量,才能读到x.val; - 所有参与优化的数值必须用可微算子构造,原生
+ - * /与Math.pow会破坏梯度计算。
七、更多阅读路径
- 约束与目标的写法与原理、能量函数推导:Writing Constraints & Objectives
- 全部内置函数(约束/目标/计算)的文档化清单:Style Functions
- API 的 TypeScript 类型定义(
Description、Run、Config、Problem):types/ad.ts - 核心实现(
variable、problem、ops、收敛判定):engine/Autodiff.ts - 优化器状态机与收敛逻辑(UO/EP 两阶段):engine/Optimizer.ts
- 可微算子库:
add、sub、pow、inverse、ops.vdist等:engine/AutodiffFunctions.ts - 可运行的单元测试示例:engine/Autodiff.test.ts
- 包级导出入口:api.ts
- 开发工具
- 数据可视化
【免费下载链接】penrose
Create beautiful diagrams just by typing notation in plain text.
相关推荐
3步搭建私人AI研究助手:SurfSense完全部署指南
3步搭建私人AI研究助手:SurfSense完全部署指南 还在为信息过载而烦恼吗?SurfSense是一款开源的AI研究助手,专为技术爱好者和普通用户设计,能够
人工智能AI 应用后端AI Agent网页爬虫RAG深度研究MCP 服务前端终极实战指南:如何用torchdiffeq构建可微分ODE求解应用
终极实战指南:如何用torchdiffeq构建可微分ODE求解应用 欢迎来到torchdiffeq的实战指南!🔥 这是一个基于PyTorch的常微分方程(OD
深度学习科学计算如何优化Android构建速度:Transform API实战指南
如何优化Android构建速度:Transform API实战指南 在Android开发过程中,构建速度慢是许多开发者面临的常见问题。尤其是在大型项目中,每次构
文档知识库教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考