☰
光子逆设计基准测试:拓扑优化算法的公共赛道与工程实践
2026/10/18 19:56:54 网站建设 项目流程

简介:面向光子逆设计与拓扑优化研究者的 Python 基准测试套件,基于 ceviche 有限差分频域模拟器与 HIPS autograd 自动微分构建,内置波导分束器、模式转换器、波导弯曲和波分复用器等典型集成光子组件,可对不同拓扑优化算法进行横向对比与性能评估。压缩包共 43 个文件,以 35 个 Python 脚本为主,涵盖散射参数计算、模式与基础原语定义、模型封装及测试用例;另附 4 张组件示意图与 README 文档,整体约 772KB。该套件源自已发表论文的配套代码,可复现性强,目前已有 369 人学习下载。借助现有测试脚本可快速搭建环境,模块化目录结构便于替换优化策略或调整组件参数,开展自定义逆向设计实验,下载后从 README.md 即可掌握整体组织与调用方式。

1. 光子逆设计基准测试:为什么你的拓扑优化算法需要一个公共赛道

有一次我用自己写的拓扑优化程序做波导模式转换器,在某个测试算例上收敛得又快又稳,换了一组边界条件就整个发散。排查到最后发现,问题根本不在算法,而在仿真设置:有的挑战给的是点源,有的是模式激发,网格分辨率也不统一。这时候我强烈感觉到,算法之间缺少一个公共“比赛场地”。ceviche-challenges 就是这套 Python 代码的定位:把分束器、模式转换器、波导弯曲、波分复用器这些经典集成光子器件封装成标准逆设计挑战,所有挑战共享同一套有限差分频域(FDFD)模拟接口,并用 autograd 把梯度暴露给优化器。无论你是做拓扑优化算法的人,还是刚进入光子逆向设计的开发者,都可以把它当成公共赛道:用同一把尺子量不同算法的真实水平。

2. 技术栈与工程结构:FDFD模拟器、自动微分和挑战套件的配合

2.1 为什么选FDFD当仿真底座

光子逆设计本质上是把仿真变成可优化的计算图。频域仿真方法有 FDFD、FDTD、本征模展开几种,FDFD 的特别之处在于它把 Maxwell 方程离散成稀疏线性系统,求解一次频域响应,不需要时间步进。对设计参数求梯度时,也能直接对线性系统做灵敏度分析,配合自动微分可以省掉手写伴随求解器的工作量。ceviche 是一个很轻量的 FDFD 实现,yee 网格离散、PML 吸收边界、模式激发都封装好了,特别适合作为逆向设计的底层。

用 ceviche 当底座还有一个实际好处:它的核心操作都是 numpy 原生的,能直接喂给 HIPS autograd。常见做法是把整个仿真流程写成一个大的 numpy 函数,输入是设计密度,输出是散射参数,最后通过一个标量损失驱动梯度下降。因为 FDFD 不需要逐步迭代时间,计算图相对紧凑,反向传播的中间变量也不像 FDTD 那样占内存。实际跑起来,一个普通设计域在单卡 GPU 上做一二百轮优化是完全可接受的。

我比较看重的是它把“可微仿真”做成了标准 API,而不是散落的脚本。工程上这意味着新算法可以直接调用统一的 loss 和 grad 接口,不需要每个研究者都重写底层求解器。再加上 autograd 对 numpy 子集的支持,梯度计算在大多数情况下是“白盒”的,出错了也可以从执行轨迹里排查,而不是把仿真器当成黑匣子。

2.2 目录结构:挑战套件如何分层

解压下载的压缩包后,根目录是 ceviche-challenges-main。核心包是 ceviche_challenges,里面文件按作用可以分成四层。第一层是仿真底座,包括 scattering.py、modes.py、ops.py、units.py;第二层是挑战定义,包括 model_base.py、params.py、primitives.py、defs.py;第三层是四个具体器件模块:beam_splitter、mode_converter、waveguide_bend、wdm;第四层是测试文件,scattering_test.py、modes_test.py、ops_test.py、primitives_test.py、defs_test.py 全部位于 ceviche_challenges 包内,用 pytest 可以直接运行。

目录结构如下:

ceviche-challenges-main/ ├── ceviche_challenges/ │ ├── model_base.py │ ├── params.py │ ├── primitives.py │ ├── ops.py │ ├── units.py │ ├── scattering.py │ ├── modes.py │ ├── defs.py │ ├── beam_splitter/ │ ├── mode_converter/ │ ├── waveguide_bend/ │ ├── wdm/ │ ├── scattering_test.py │ ├── modes_test.py │ ├── ops_test.py │ ├── primitives_test.py │ └── defs_test.py ├── img/ │ ├── beam_splitter.png │ ├── mode_converter.png │ ├── waveguide_bend.png │ └── wavelength_divison_multiplexer.png ├── setup.py └── README.md

这种分层的思路很清晰。仿真底座不动,挑战定义层把器件目标描述成参数空间里的损失函数,测试文件同时承担“环境自检”和“数值正确性验证”两种角色。对二次开发来说,新器件只需要在 defs.py 或新增子模块里实现一个挑战类,不需要动仿真底座。setup.py 负责包安装,README.md 里会写明具体用法,img 目录里的 png 是每个挑战问题的目标版图示例。

2.3 参数体系、单位制与制造约束

逆设计通常把设计区域离散成 Nx×Ny 的像素网格,每个像素对应一个密度值,取值在 0 到 1 之间。0 代表背景材料,通常是二氧化硅;1 代表核心材料,比如硅。params.py 负责维护这个密度数组,并对外暴露形状、边界条件、初始值等属性。modes.py 则负责把端口定义成具体的波导模式,这样仿真时就知道从哪一侧输入、从哪一侧接收。

units.py 里定义了单位换算。FDFD 是在物理尺度上求解,因此波长、波导宽度、设计区域尺寸都必须统一。比如模式转换器的设计域常见尺寸是 2.4μm × 0.9μm,工作波长 1550nm。这套代码跑出的结果可以直接映射到光刻版图,这是我觉得比较省心的一点。很多教学代码用的都是无量纲单位,真到流片环节就翻车,还得重新缩放一堆参数。

制造约束体现在 ops.py 里的投影操作。裸密度优化会出现大量 0.5 左右的中间值,代工厂没法刻写。常见做法是先对密度做高斯模糊,再用双曲正切投影把密度推向 0 或 1。这个投影不是一次性完成的,通常在优化过程中让 beta 参数从 1 慢慢升到几十,早期给算法足够探索空间,后期强制执行二值化。下面这段代码是投影函数最典型的写法:

import numpy as np def projection(density, eta=0.5, beta=1.0): # 将密度投影到接近0或1的二值分布 # eta: 阈值,通常取0.5 # beta: 投影强度,越大越接近二值 numerator = np.tanh(beta * eta) - np.tanh(beta * (density - eta)) denominator = np.tanh(beta * eta) - np.tanh(beta * (1 - eta)) return numerator / denominator

逻辑说明:density 是原始设计变量,取值范围 [0,1];eta 是阈值,默认 0.5;beta 控制投影强度。当 beta 很小时,这个函数近似恒等映射,保留连续密度;当 beta 增大时,小于 eta 的值被压向 0,大于 eta 的值被压向 1。需要注意 beta 太大会导致梯度消失,所以我一般会做 beta 退火:每跑几十轮就把 beta 乘以 1.1,而不是一开始就设成 50。参数解释:density 的形状就是设计域的像素网格形状;eta 不要随意改成接近 0 或 1 的值,否则投影会严重偏置,优化结果容易陷入局部极小。

3. 核心实现拆解:一个挑战问题从定义到梯度下降的完整链路

3.1 model_base.py:统一挑战接口

整个套件能称为“基准测试”,靠的是 model_base.py 里定义的统一基类。每个具体器件子类都继承这个基类,并实现 forward、loss、target 这几个方法。设计者只需要把注意力放在自己的算法上,挑战问题的物理定义全部封装在类内部。

这里我按最常见的写法补一段基类骨架:

import numpy as np from autograd import grad class ChallengeBase: def __init__(self, params, wavelengths, ports=None): self.params = params # 设计变量容器,包含网格形状 self.wavelengths = wavelengths # 工作波长数组 self.ports = ports or [] # 端口定义,来自 modes.py def forward(self, design): # 输入一维设计密度,返回S参数数组 raise NotImplementedError def target(self): # 返回目标S参数,子类必须实现 raise NotImplementedError def loss(self, design): # 均方误差损失 s_actual = self.forward(design) return np.mean((s_actual - self.target()) ** 2) def gradient(self, design): # 用 autograd 对 loss 求梯度 return grad(self.loss)(design)

逻辑说明:forward 是仿真链路入口,内部会调用 ops 把密度转成介电常数,再调用 scattering 仿真得到 S 参数。loss 直接算仿真结果和目标响应的均方误差,gradient 用 autograd 对 loss 自动微分。这个设计的巧妙之处在于子类只需要实现 forward 和 target,梯度是自动拿到的,不会因为每个器件目标不同而重复写伴随求解器。

参数说明:design 是长度为 Nx*Ny 的展平数组,因为 autograd 对高维数组的索引操作有时会拖慢速度,展平后更稳;wavelengths 可以是标量也可以是数组,如果做 WDM 这类多波长器件,通常要传一组波长,loss 里会对多个波长求平均。

3.2 four器件:分束器、模式转换器、波导弯曲与WDM

四个器件分别放在 beam_splitter、mode_converter、waveguide_bend、wdm 子目录里。它们的目标各不相同,但都通过继承 ChallengeBase 来统一接口。以 mode_converter 为例,它的目标是把输入波导的基模 TE0 模式转换到输出波导的 TE1 模式,传统波导没法直接在短距离内完成这种转换,拓扑优化可以在设计域里挖出任意形状的结构。

模式转换器的挑战定义核心是 target() 方法。常见实现是这样的:

class ModeConverter(ChallengeBase): def __init__(self): # 设计域尺寸 2.4um x 0.9um,像素网格 120x45 params = Params(shape=(120, 45), physical_size=(2.4, 0.9)) super().__init__(params=params, wavelengths=[1.55]) def target(self): # 单波长目标:传输端 S21 到 0.95,反射端 S11 到 0.05 return np.array([0.95, 0.05]) def forward(self, design): epsr = ops.density_to_epsr(design, background=1.0, core=12.25) return scattering.simulate_s_params(epsr, self.wavelengths[0], self.mode_ports)

参数说明:params 里的 shape 是像素网格的行列数,physical_size 是微米级的物理尺寸;wavelengths 单位也是微米,1.55 就是 1550nm。target 中的 S21 是传输系数,S11 是反射系数,目标值不是严格 1.0,是为了给工艺误差留余量。beam_splitter 的目标则是让输出两个端口的功率各占一半,S21 和 S31 都接近 0.707;waveguide_bend 要在紧凑转角内维持低反射;wdm 要按波长把光分开到不同端口,所以它的 target 是多波长数组,例如波长 λ1 时端口 A 高透射、端口 B 低透射,波长 λ2 时相反。

3.3 ops.py与primitives.py:可微几何生成

拓扑优化最容易被忽略的是“密度到物理结构”的映射。设计变量只是个 0 到 1 的数组,但仿真需要介电常数分布,而且代工厂需要结构不悬空、不断裂。ops.py 里除了上一章提到的 projection,还有 blur 和高斯滤波。blur 的作用是让相邻像素的密度平滑过渡,避免优化出的结构出现孤立点。

primitives.py 是生成初始结构的地方。比如波导弯曲挑战,输入和输出波导的位置是固定的,设计域中间是空白,算法需要自己生成一个连接两个端口的弯曲形状。primitives 提供直线、矩形、贝塞尔曲线的可微版本,这样初始设计也可以参与优化,而不是一开始就全零。

下面是一个可微模糊滤波的实现思路:

from scipy.ndimage import gaussian_filter from autograd.extend import primitive, defvjp @primitive def blur(density, sigma): # 对密度做高斯模糊,sigma控制平滑半径 return gaussian_filter(density, sigma=sigma) def blur_vjp(ans, density, sigma): # 高斯卷积是对称算子,VJP就是自己对自己 def vjp(g): return gaussian_filter(g, sigma=sigma), None return vjp defvjp(blur, blur_vjp)

逻辑说明:这里把 scipy 的 gaussian_filter 包装成 autograd 的 primitive,并定义了它的向量-雅可比积。因为高斯卷积是自伴随的,反向传播时只需要对上游梯度再卷一次,所以 vjp 函数里直接调用 gaussian_filter。参数说明:sigma 是模糊半径,单位是像素;太小起不到连接作用,太大又会让结构边界模糊,我一般取 1.5 到 2.5。注意如果直接用 scipy 的 gaussian_filter 而不注册 vjp,autograd 会在反向传播时报错,这也是很多新手第一次跑这个套件时最常见的坑。

3.4 测试文件:数值梯度验证与仿真正确性

测试文件是这套代码里被低估的部分。ops_test.py 会验证 blur 和 projection 的数值梯度与 autograd 梯度是否一致,方法是用有限差分法算梯度,然后和自动微分结果对比。scattering_test.py 会自动创建一个直波导,检查 S21 是否接近 1,这一条能一次性验证 FDFD 模拟器、模式激发、端口数据处理整条链路是否正常。

defs_test.py 则会检查每个挑战问题的 target 参数是否在物理合理范围内,比如分束器的两个输出端口功率和是否接近 1。这些测试不是摆设,做逆向设计时最怕底层仿真与梯度不自洽。数值梯度校验如果通不过,后面再花哨的优化算法都跑不出可信结果。我自己的习惯是拿到这份代码后第一件事就是跑 pytest,全绿后才会开始调算法。

4. 运行实战与避坑:从安装到误差控制

4.1 环境准备:用虚拟环境隔离依赖

这套 Python 代码依赖 numpy、scipy、autograd、ceviche 几个包,直接 pip install -e . 会把项目本身安装到当前环境。但 ceviche 在部分 Python 版本下存在编译兼容问题,所以我建议创建独立的虚拟环境,避免污染其他项目。

安装步骤如下:

python -m venv ceviche-env source ceviche-env/bin/activate pip install --upgrade pip pip install -e .

如果执行 pip install -e . 后报找不到 ceviche,需要手动补装:pip install ceviche。装完后再运行 python -c "import ceviche" 确认导入成功。要注意 autograd 与 numpy 的版本组合偶尔会出现 API 兼容问题,常见现象是 import 时 AttributeError,解决方法是把 numpy 降到较旧的稳定版本,比如 1.20 左右,再重新安装 autograd。这个组合对 Python 3.7 到 3.9 都算比较稳。

4.2 跑通基准测试:从pytest到自定义优化脚本

环境准备好之后,先跑测试套件确认所有组件正常:

pip install pytest pytest ceviche_challenges -v

这里会执行 scattering_test、ops_test、primitives_test、defs_test 等所有测试。如果全部通过,说明 FDFD 仿真和自动微分链路是自洽的。接下来我通常写一个最小优化脚本,验证一个挑战问题的 loss 能不能被梯度下降拉低:

import numpy as np from autograd import grad from ceviche_challenges import mode_converter model = mode_converter.Challenge() design = np.random.uniform(0.4, 0.6, size=model.params.shape) loss_fn = model.loss grad_fn = grad(loss_fn) for i in range(100): g = grad_fn(design) design -= 0.05 * g design = np.clip(design, 0, 1) if i % 10 == 0: print(f"iter {i}, loss {loss_fn(design):.6f}")

参数说明:design 初始值取 0.4 到 0.6 之间的随机数,而不是 0 到 1 均匀随机,这样仿真一开始就有一个介电常数不为零的物理结构,避免 FDFD 矩阵奇异。learning rate 0.05 只是经验值,实际要根据损失尺度和梯度范数调整。每一步对设计变量做 clip 是为了满足制造约束。注意这里不能直接用 model.params 里的数据,因为 params 可能被内部类用于缓存,需要单独创建一个 ndarray 传给 loss。

4.3 常见问题排查:依赖冲突、梯度NaN与端口定义

问题一:import 阶段报 AttributeError: module 'numpy' has no attribute 'float_'。现象是 pip install 完成后,导入 autograd 或 ceviche 时报错。原因是最新 numpy 移除了 numpy.float_ 别名,而旧版 ceviche 或 autograd 内部还在引用它。解决方法是把 numpy 降级到 1.23 或更低,或者升级到能兼容的 ceviche 版本。这类问题没有黑匣子可看,直接看 traceback 最后一行就能定位到是哪个包在调用。

问题二:ops_test.py 数值梯度失败,误差在 1e-2 数量级。现象是测试打印有限差分梯度与 autograd 梯度不一致。原因通常是自定义 primitive 的 vjp 写错了,或者模糊半径 sigma 随机初始化导致高斯近似不准确。解决方法是先固定 sigma 为一个较小整数,比如 1.0,再做梯度对比;如果误差仍大,检查 blur_vjp 里是否漏掉了对 sigma 的梯度,虽然 sigma 一般不需要梯度,但 primitive 定义里切不可省略参数占位。

问题三:loss 在优化初期就变成 NaN。现象是第 1 轮迭代 loss 正常,第 5 轮突然全 NaN。原因一般是设计变量某处变成极端值,导致介电常数分布出现零或负值,FDFD 主对角矩阵奇异。解决方法是给投影函数传入一个安全的 epsilon,并在每次迭代后检查 density 的 min 和 max;如果 min < 0 或 max > 1,用 clip 强制拉回,并降低学习率。这个问题我遇到过不止一次,最后发现是 blur 在边界处的 padding 方式导致的,需要用 mode='nearest' 而不是默认的 mode='reflect'。

问题四:不同机器上跑出的拓扑结构差异很大。现象是同样的参数,两台机器跑出的 loss 曲线相近,但最终版图完全不同。原因有两个,一是 numpy 随机种子没有固定,二是 FDFD 的稀疏求解器用到了 OpenBLAS 多线程,线程数不同导致浮点累加顺序变化。解决方法是脚本开头设置 np.random.seed(0),同时设置环境变量 OPENBLAS_NUM_THREADS=1 和 OMP_NUM_THREADS=1,让求解可复现。这类数值稳定性问题在拓扑优化里很常见,不是算法错了,是环境差异。

问题五:S21 大于 1,物理上不合理。现象是某个端口透射系数出现 1.2 之类的值。原因多半是端口模式功率归一化没有做,或者模式激发时没有减去反射功率。解决方法是查看 modes.py 里模式投影的写法,确认使用的是归一化场分布,而不是原始场强。这一条不是套件本身的 bug,而是我改造端口时踩过的坑,如果你自己动了 modes 相关的代码,一定跑一遍 scattering_test 验证直波导透射约等于 1。

5. 二次开发:把一个新的光电器件加入挑战套件

如果你想加入一个现有四个器件之外的挑战,比如 Y 型分束器,关键不是写仿真器,而是写一个继承 ChallengeBase 的子类。先把 Y 型分束器的物理目标量化:输入端口接 TE0 模式,输出端是两个对称分支,目标是在 1550nm 处两个输出端口的功率各占一半,反射系数小于 0.1。在代码里,这就是一个 target 数组为 [0.71, 0.71, 0.1] 的类。

先创建子包 y_branch,在init.py 里写挑战类:

import numpy as np from ..model_base import ChallengeBase from .. import ops, scattering, modes class YBranchChallenge(ChallengeBase): def __init__(self): params = Params(shape=(120, 120), physical_size=(2.4, 2.4)) super().__init__(params, wavelengths=[1.55]) def target(self): return np.array([0.71, 0.71, 0.1]) def forward(self, design): epsr = ops.density_to_epsr(design, background=1.0, core=12.25) s = scattering.simulate_s_params(epsr, self.wavelengths[0], ports) # 假设返回 [S21, S31, S11] return np.array([s[1], s[2], s[0]])

然后写一个测试文件 test_y_branch.py,内容先验证梯度一致性,再验证随机初始设计的 loss 不为 NaN。这样做能保证新挑战的仿真链路和梯度链路都是通的,不会污染原有套件。

从那以后,我每次加新挑战都会先写一个最小优化脚本,把初始设计设成常量,跑 20 步梯度下降,确认 loss 是在下降而不是抖动,然后才去调材料和边界参数。否则你很可能在梯度本身就缺失的情况下,花一整天调优化器参数,最后还以为是算法出了问题。这套路和调卷积网络一样,先跑通一条小曲线,再谈精度。希望这个思路能帮你在自己的器件设计里少走几步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询