2023年4月

在ArcGIS Pro中,打开Revit的rvt格式数据,默认是没有坐标系,且位置会放置在原点位置(0,0),在实际使用过程中,需要对rvt数据进行地理配准,包括平移、旋转等操作将bim数据放置在正确的位置。

本文示例使用:软件:ArcGIS Pro3.0.1

操作步骤:

1、加载数据:ArcGIS Pro可以直接打开rvt格式添加到场景中查看bim数据(需要先新建局部场景,然后通过Catalog中添加或者通过Map下Add Data的Data添加rvt数据)

添加后会显示两个图层分组,一个与文件同名,一个在文件名后添加后缀_Floorplan:

常rvt格式数据加载到arcgis pro中并没有坐标系,在加载的过程中会提示:

2、输入偏移坐标:加载完成后,使用 Map菜单下的GO TO XY,确定bim数据要移动到的位置:

输入坐标,点击手型图标Pan To Location,将视图移动到指定位置:

3、地理配准:选择一个图层(要选到具体图层,可以随便选择一个,不能选择图层组),然后在BIM Data菜单下,选择Georeference(地理配准)

如果bim数据没有坐标系(没有配准过),则会弹出一个提示,点击确定,会将当前地图的坐标作为bim数据的坐标系:

此时会生成一个bim数据同名的.prj格式数据:

同时会打开一个Georeference(地理配准)菜单,选择菜单下的Move to display(移至显示),会将指定图层移动到指定位置,另外可通过Move、Scale、Rotate进行微调:

调整完后点击Save(保存)按钮,这是会生成另外一个与bim数据同名的.wld3格式数据

4、关闭地理配准:选择Close Georeference,会发现rvt 所有图层都到新位置了:

只需调整一个图层,其他图层都会跟着变化,此时若还需要对模型位置进行微调,还可以再打开地理配准(重复第3步)进行操作,同样只要选中其中一个图层即可。

相同基准点的多个rvt BIM模型数据的地理配准:

如果提供相同基准点的多个rvt BIM模型数据配准后的bim模型数据包含多个rvt格式数据,这些数据具有相同的基准点,不需要所有的rvt数据都进行以上操作,可以选择一份相对小的数据,按照上述步骤生成prj和wld3文件,然后复制重命名prj和wld3文件和其他rvt数据同名,再加载即已经配准定位。

如示例中,当建筑rvt的模型数据通过以上步骤完成地理配准后,生成了一个同名的prj和wld3文件,然后复制一份改名为另外一个rvt数据同名,比如暖通和给排水的,这时加载暖通和给排水bim数据就已经配准定位好了,这样就不用重复进行地理配准了。否则每个rvt都重复进行地理配准,就算第二步输入的偏移坐标一样,也可能导致两份数据没法完全重合。

注意:CAD和 BIM 文件需要具有有效的 Esri 坐标系 (.prj),并且可能需要可选的坐标变换信息文件 (.wld),以确定应在地球表面上定位 CAD 或 BIM 数据中的坐标的方式。

参考:

1、CAD 和 BIM 数据的地理空间位置:
https://pro.arcgis.com/zh-cn/pro-app/latest/help/data/revit/geospatial-position-of-cad-and-bim-data.htm

2、地理配准 BIM 数据:
https://pro.arcgis.com/zh-cn/pro-app/2.9/help/data/revit/georeference-bim-data.htm

                

使用vSphere Update Manager 升级 ESXi 主机

vSphere Update Manager

vSphere Update Manager
是用于升级、迁移、更新和修补群集主机、虚拟机和客户机操作系统的软件。
vSphere Update Manager
可协调主机和虚拟机的升级。如果站点使用
vSphere Update Manager
,VMware 建议您使用
vSphere Update Manager

升级目标:ESXi 6.0、6.5  >> ESXi 6.7

ESXi 主机升级过程概览

实操过程

1、下载 ESXi 6.7 映像文件,官网地址:
https://customerconnect.vmware.com/cn/patch

2、如下图,登录vcenter,菜单处打开
Update Manager

3、导入ESXi 映像文件:ESXi映像 >> 导入 >>选择下载好的iso文件,等待上传和转储完成 >> 新建基准

4、创建基准,自定义名称 >> 内容(升级) >> 下一页

5、选择映像 >> 完成

6、附加基准:选择需要升级的ESXi主机 >> 更新 >>附加 >> 附加基准或基准组

注意:升级前先把ESXi主机所有虚拟机迁移或关机,并进入维护模式。

7、选择基准 >> 修复

8、修复前预检查,如果发现严重阻止修复完成的问题,需要先处理掉问题

9、等待升级完成(约半个小时)


10、升级完成,vcenter自动将ESXi主机接管回来;如果没有需要右键>连接

11、升级完成之后,还需要检查一下许可是否为评估许可证,如果是,请重新分配许可证。

参考链接:
关于 VMware ESXi 升级

1. 失败处理策略

失败处理策略 XXL-Job Elastic-Job
失败重试 支持,最多重试三次。重试时间间隔可配置。 支持,最多重试十次。重试时间间隔可配置。
失败告警 支持,可配置告警接收人和方式。可通过邮件、短信等方式发送告警信息。 支持,可配置告警接收人和方式。可通过邮件、短信等方式发送告警信息。
失败转移 不支持。 支持,可以将任务转移到另外一个作业执行。例如,任务在执行器A上执行失败,可以将任务转移到执行器B上执行。
执行器失效转移 不支持。 支持,任务会被转移到另一个可用的执行器节点上执行。例如,执行器A因故障无法执行任务,任务会被转移到执行器B上执行。
自定义任务异常处理器 不支持。 支持,可以实现自定义的任务异常处理器。例如,对于某些特殊的任务,可以自定义异常处理器进行处理。

Elastic-Job 的失败处理策略比 XXL-Job 更加丰富,具有更强的容错性和可靠性。例如,Elastic-Job 支持失败转移和执行器失效转移,可以将任务转移到其他执行器上执行,保证任务的正常执行;Elastic-Job 还支持自定义任务异常处理器,可以针对某些特殊的任务进行特定的异常处理。而 XXL-Job 在这些方面的支持则比较有限。

2. 集群部署支持

集群部署支持 XXL-Job Elastic-Job
执行器集群 支持,可以横向扩展执行器节点。 支持,可以横向扩展执行器节点。
调度中心集群 支持,可以横向扩展调度中心节点,实现高可用。 支持,可以横向扩展调度中心节点,实现高可用。
任务分片 支持,可以将一个任务分片成多个子任务,分配到不同的执行器节点上执行。 支持,可以将一个任务分片成多个子任务,分配到不同的执行器节点上执行。
任务路由 支持,可以根据不同的条件将任务路由到不同的执行器节点上执行。 支持,可以根据不同的条件将任务路由到不同的执行器节点上执行。

虽然两个框架都支持集群部署,但在实现方式上有所不同。XXL-Job 的执行器节点通过向调度中心注册来实现任务的调度和执行,调度中心负责任务的分配和调度。而 Elastic-Job 的执行器节点是独立的进程,需要与调度中心进行通信,调度中心只负责调度,不负责任务的具体执行。这种实现方式可以提高系统的并发处理能力和灵活性,但同时也增加了系统的复杂度。

3. 日志可追溯情况

日志可追溯情况 XXL-Job Elastic-Job
任务日志 支持,可以查看任务的执行情况和日志。 支持,可以查看任务的执行情况和日志。
调度日志 支持,可以查看任务的调度情况和日志。 支持,可以查看任务的调度情况和日志。
执行器日志 支持,可以查看执行器节点的日志。 支持,可以查看执行器节点的日志。
异常日志 支持,可以查看任务执行过程中的异常日志。 支持,可以查看任务执行过程中的异常日志。

两个框架在实现日志追溯方面有所不同。XXL-Job 的任务和调度日志记录在数据库中,执行器日志和异常日志记录在执行器节点的本地文件系统中。而 Elastic-Job 的任务和调度日志、执行器日志和异常日志都记录在调度中心的数据库中。这种实现方式可以方便地查看任务的执行情况和日志信息,但同时也需要更高的系统性能和可用性。

4. 多节点部署时任务不能重复执行情况

多节点部署时任务不能重复执行情况 XXL-Job Elastic-Job
重复执行问题 存在,需要通过分布式锁来避免同一任务在多个节点重复执行。 存在,但已经在2.1.5版本中解决了该问题。
解决方案 支持通过分布式锁来避免同一任务在多个节点重复执行。 通过实现分布式协调器来解决任务重复执行问题。
分布式锁实现方式 支持基于Redis、Zookeeper、MySQL、MongoDB等多种方式实现分布式锁。 支持基于Zookeeper、Redis、Mesos等多种方式实现分布式协调器。

从表格中可以看出,XXL-Job 和 Elastic-Job 都存在多节点部署时任务不能重复执行的问题。XXL-Job 通过实现分布式锁的方式来避免同一任务在多个节点重复执行,而 Elastic-Job 则通过实现分布式协调器来解决任务重复执行问题。
需要注意的是,XXL-Job 支持多种分布式锁实现方式,可以根据具体业务需求选择合适的实现方式。而 Elastic-Job 只支持少量的分布式协调器实现方式,需要根据实际情况来选择合适的方式。此外,Elastic-Job 在2.1.5版本中已经解决了任务重复执行问题,但旧版本仍存在该问题。

5. 监控告警情况

监控告警情况 XXL-Job Elastic-Job
监控功能 支持任务执行监控和调度中心监控,可查看任务的执行情况和任务的调度信息。 支持任务执行监控和作业运行状况监控,可查看任务的执行情况和作业的运行状态。
告警功能 支持告警邮件、钉钉、企业微信等多种方式,可自定义告警模板和告警规则。 支持告警邮件、钉钉、企业微信等多种方式,可自定义告警模板和告警规则。
监控数据存储 监控数据存储在数据库中,支持MySQL、Oracle、SQLServer等多种数据库。 监控数据存储在Zookeeper中,可通过REST API获取监控数据。

从表格中可以看出,XXL-Job 和 Elastic-Job 在监控告警方面功能较为相似,都支持任务执行监控、告警邮件、钉钉、企业微信等多种监控方式,并且都可以自定义告警模板和告警规则。但是,两者的监控数据存储方式有所不同,XXL-Job的监控数据存储在数据库中,而Elastic-Job的监控数据存储在Zookeeper中,并且可以通过REST API获取监控数据。
需要注意的是,监控数据存储方式的不同可能会对监控数据的可用性、可靠性和查询效率产生影响,因此在选择分布式任务调度框架时需要综合考虑各方面因素。

6. 弹性扩容缩容情况

弹性扩容缩容情况 XXL-Job Elastic-Job
扩容方式 支持手动扩容,需要在管理平台手动增加调度器节点。 支持手动扩容,需要在注册中心增加作业节点。同时也支持根据任务队列长度自动扩容。
缩容方式 支持手动缩容,需要在管理平台手动删除调度器节点。 支持手动缩容,需要在注册中心删除作业节点。同时也支持根据任务队列长度自动缩容。
扩缩容灵活性 扩缩容需要手动操作,不够灵活。 支持根据任务队列长度自动扩缩容,能够更加灵活地应对任务的变化。
扩缩容延迟性 手动扩缩容需要一定时间完成,可能会导致任务延迟。 自动扩缩容能够更加快速地响应任务变化,减少任务延迟

XXL-Job 和 Elastic-Job 都支持手动扩缩容,并且扩缩容的方式比较相似,都需要在管理平台或注册中心手动增加或删除节点。但是,Elastic-Job 相比于 XXL-Job,具备更好的弹性扩容缩容能力。Elastic-Job 支持根据任务队列长度自动扩缩容,能够更加灵活地应对任务的变化,并且能够更加快速地响应任务变化,减少任务延迟。

7. 支持并行调度情况

支持并行调度情况 XXL-Job Elastic-Job
支持度 支持任务并行执行。 支持任务并行执行。
并行度限制 支持设置任务的并行度。 支持设置任务的分片总数和每个分片的并发数。
分片机制 不支持分片机制。 支持分片机制,能够将任务分片后分配到多个节点并行执行。
动态扩容缩容 不支持动态扩容缩容。 支持根据任务分片数动态调整作业节点数量,实现动态扩容缩容。

XXL-Job 和 Elastic-Job 都支持任务并行执行,但是 Elastic-Job 相比于 XXL-Job,在并行度限制和分片机制上具备更好的功能。Elastic-Job 支持设置任务的分片总数和每个分片的并发数,能够更加灵活地控制任务的并行度。同时,Elastic-Job 支持分片机制,能够将任务分片后分配到多个节点并行执行,进一步提高任务的并行度和执行效率。另外,Elastic-Job 还支持根据任务分片数动态调整作业节点数量,实现动态扩容缩容,进一步提高了任务的并行度和执行效率。

8. 高可用策略

高可用策略 XXL-Job Elastic-Job
支持度 支持高可用集群部署,支持主备模式和多节点模式。 支持高可用集群部署,支持主备模式和分布式协调模式。
主备模式 支持主备模式,需要手动切换主备节点。 支持主备模式,自动切换主备节点。
多节点模式 支持多节点模式,任务通过分配不同的节点来执行,支持负载均衡。 不支持多节点模式。
分布式协调模式 不支持分布式协调模式。 支持分布式协调模式,通过 ZooKeeper、etcd 等分布式协调工具实现高可用。

XXL-Job 和 Elastic-Job 都支持高可用集群部署,并且都支持主备模式,但是 Elastic-Job 相比于 XXL-Job,在高可用策略上具备更好的功能。Elastic-Job 支持主备模式自动切换主备节点,不需要手动干预;同时支持分布式协调模式,能够通过 ZooKeeper、etcd 等分布式协调工具实现高可用。此外,Elastic-Job 还支持分布式协调模式,通过分配不同的节点来执行任务,支持负载均衡,进一步提高了系统的可用性和稳定性。

9. 动态分片策略

动态分片策略 XXL-Job Elastic-Job
支持度 支持动态分片,支持多种分片策略。 支持动态分片,支持多种分片策略。
分片数调整 支持手动调整分片数,需要重启任务才能生效。 支持自动调整分片数,根据作业实时状态动态调整分片数,无需手动干预。
分片策略 支持多种分片策略,包括平均分配、按任务参数、按固定值等。 支持多种分片策略,包括平均分配、按任务参数、按任务属性等。
分片监听 不支持分片监听。 支持分片监听,可以在分片变化时进行通知。

XXL-Job 和 Elastic-Job 在动态分片策略方面有相似之处,都支持多种分片策略。但是 Elastic-Job 相比于 XXL-Job,在动态分片策略上具备更好的功能。Elastic-Job 支持自动调整分片数,根据作业实时状态动态调整分片数,无需手动干预;同时支持分片监听,可以在分片变化时进行通知。这些功能的支持,使得 Elastic-Job 在动态分片策略方面更加灵活和智能。

10. 综合对比

综合对比下来,哪个调度框架更好取决于具体的使用场景和需求。以下是一些参考因素:

  • 功能需求:如果需要动态分片、作业流式处理、灵活的任务分配方式,那么 Elastic-Job 可能更适合。
  • 部署环境:如果在 Java Web 应用中使用,且需要与 Spring 集成(两个框架支持都可以),那么 XXL-Job 可能更适合。
  • 调度可靠性:如果任务调度的可靠性是首要关注点,那么 Elastic-Job 的分布式作业调度和分片机制可以提供更好的保障。
  • 社区活跃度:两个框架都有着活跃的社区,但 Elastic-Job 的社区似乎更加广泛和活跃,能够提供更多的支持和解决方案。

总体而言,XXL-Job 和 Elastic-Job 都是比较成熟的 Java 调度框架,都有其独特的优势和适用场景。开发者可以根据具体的业务需求和实际情况选择适合自己的框架。

原创:扣钉日记(微信公众号ID:codelogs),欢迎分享,非公众号转载保留此声明。

上个月,我们一个java服务上线后,偶尔会发生内存OOM(Out Of Memory)问题,但由于OOM导致服务不响应请求,健康检查多次不通过,最后部署平台kill了java进程,这导致定位这次OOM问题也变得困难起来。

最终,在多次review代码后发现,是SQL意外地查出大量数据导致的,如下:

<sql id="conditions">
    <where>
        <if test="outerId != null">
            and `outer_id` = #{outerId}
        </if>
        <if test="orderType != null and orderType != ''">
            and `order_type` = #{orderType}
        </if>
        ...
    </where>
</sql>

<select id="queryListByConditions" resultMap="orderResultMap">
    select * from order <include refid="conditions"/> 
</select>

查询逻辑类似上面的示例,在Service层有个根据outer_id的查询方法,然后直接调用了Mapper层一个通用查询方法queryListByConditions。

但我们有个调用量极低的场景,可以不传outer_id这个参数,导致这个通用查询方法没有添加这个过滤条件,导致查了全表,进而导致OOM问题。

我们内部对这个问题进行了复盘,考虑到OOM问题还是蛮常见的,所以给大家也分享下。

事前

在OOM问题发生前,为什么测试阶段没有发现问题?

其实在编写技术方案时,是有考虑到这个场景的,但在提测时,忘记和测试同学沟通此场景,导致遗漏了此场景的测试验证。

关于测试用例不全面,其实不管是疏忽问题、经验问题、质量意识问题或人手紧张问题,从人的角度来说,都很难彻底避免,人没法像机器那样很听话的、不疏漏的执行任何指令。

既然人做不到,那就让机器来做,这就是单元测试、自动化测试的优势,通过逐步积累测试用例,可覆盖的场景就会越来越多。

当然,实施单元测试等方案,也会增加不少成本,需要权衡质量与研发效率谁更重要,毕竟在需求不能砍的情况下,质量与效率只能二选其一,这是任何一本项目管理的书都提到过的。

事中

在感知到OOM问题发生时,由于进程被部署平台kill,导致现场丢失,难以快速定位到问题点。

一般java里面是推荐使用
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/dump/
这种JVM参数来保存现场的,这两个参数的意思是,当JVM发生OOM异常时,自动dump堆内存到文件中,但在我们的场景中,这个方案难以生效,如下:

  1. 在堆占满之前,会发生很多次FGC,jvm会尽最大努力腾挪空间,导致还没有OOM时,系统实际已经不响应了,然后被kill了,这种场景无dump文件生成。
  2. 就算有时幸运,JVM发生了OOM异常开始dump,由于dump文件过大(我们约10G),导致dump文件还没保存完,进程就被kill了,这种场景dump文件不完整,无法使用。

为了解决这个问题,有如下2种方案:

方案1:利用k8s容器生命周期内的Hook

我们部署平台是套壳k8s的,k8s提供了preStop生命周期钩子,在容器销毁前会先执行此钩子,只要将
jmap -dump
命令放入preStop中,就可以在k8s健康检查不通过并kill容器前将内存dump出来。

要注意的是,正常发布也会调用此钩子,需要想办法绕过,我们的办法是将健康检查也做成脚本,当不通过时创建一个临时文件,然后在preStop脚本中判断存在此文件才dump,preStop脚本如下:

if [ -f "/tmp/health_check_failed" ]; then
    echo "Health check failed, perform dumping and cleanups...";
    pid=`ps h -o pid --sort=-pmem -C java|head -n1|xargs`;
    if [[ $pid ]]; then
        jmap -dump:format=b,file=/home/work/logs/applogs/heap.hprof $pid
    fi
else
    echo "No health check failure detected. Exiting gracefully.";
fi 

注:也可以考虑在堆占用高时才dump内存,效果应该差不多。

方案2:容器中挂脚本监控堆占用,占用高时自动dump

#!/bin/bash

while sleep 1; do
    now_time=$(date +%F_%H-%M-%S)
    pid=`ps h -o pid --sort=-pmem -C java|head -n1|xargs`;
    [[ ! $pid ]] && { unset n pre_fgc; sleep 1m; continue; }
    data=$(jstat -gcutil $pid|awk 'NR>1{print $4,$(NF-2)}');
    read old fgc <<<"$data";
    echo "$now_time: $old $fgc";
    if [[ $(echo $old|awk '$1>80{print $0}') ]]; then
        (( n++ ))
    else
        (( n=0 ))
    fi
    if [[ $n -ge 3 || $pre_fgc && $fgc -gt $pre_fgc && $n -ge 1 ]]; then
        jstack $pid > /home/dump/jstack-$now_time.log;
        if [[ "$@" =~ dump ]];then
            jmap -dump:format=b,file=/home/dump/heap-$now_time.hprof $pid;
        else
            jmap -histo $pid > /home/dump/histo-$now_time.log;
        fi
        { unset n pre_fgc; sleep 1m; continue; }
    fi
    pre_fgc=$fgc
done

每秒检查老年代占用,3次超过80%或发生一次FGC后还超过80%,记录jstack、jmap数据,此脚本保存为jvm_old_mon.sh文件。

然后在程序启动脚本中加入
nohup bash jvm_old_mon.sh dump &
即可,添加dump参数时会执行
jmap -dump
导全部堆数据,不添加时执行
jmap -histo
导对象分布情况。

事后

为了避免同类OOM case再次发生,可以对查询进行兜底,在底层对查询SQL改写,当发现查询没有limit时,自动添加limit xxx,避免查询大量数据。
优点:对数据库友好,查询数据量少。
缺点:添加limit后可能会导致查询漏数据,或使得本来会OOM异常的程序,添加limit后正常返回,并执行了后面意外的处理。

我们使用了Druid连接池,使用Druid Filter实现的话,大致如下:

public class SqlLimitFilter extends FilterAdapter {
    // 匹配limit 100或limit 100,100
    private static final Pattern HAS_LIMIT_PAT = Pattern.compile(
            "LIMIT\\s+[\\d?]+(\\s*,\\s*[\\d+?])?\\s*$", Pattern.CASE_INSENSITIVE);
    private static final int MAX_ALLOW_ROWS = 20000;

    /**
     * 若查询语句没有limit,自动加limit
     * @return 新sql
     */
    private String rewriteSql(String sql) {
        String trimSql = StringUtils.stripToEmpty(sql);
        // 不是查询sql,不重写
        if (!StringUtils.lowerCase(trimSql).startsWith("select")) {
            return sql;
        }
        // 去掉尾部分号
        boolean hasSemicolon = false;
        if (trimSql.endsWith(";")) {
            hasSemicolon = true;
            trimSql = trimSql.substring(0, trimSql.length() - 1);
        }
        // 还包含分号,说明是多条sql,不重写
        if (trimSql.contains(";")) {
            return sql;
        }
        // 有limit语句,不重写
        int idx = StringUtils.lowerCase(trimSql).indexOf("limit");
        if (idx > -1 && HAS_LIMIT_PAT.matcher(trimSql.substring(idx)).find()) {
            return sql;
        }
        StringBuilder sqlSb = new StringBuilder();
        sqlSb.append(trimSql).append(" LIMIT ").append(MAX_ALLOW_ROWS);
        if (hasSemicolon) {
            sqlSb.append(";");
        }
        return sqlSb.toString();
    }

    @Override
    public PreparedStatementProxy connection_prepareStatement(FilterChain chain, ConnectionProxy connection, String sql)
            throws SQLException {
        String newSql = rewriteSql(sql);
        return super.connection_prepareStatement(chain, connection, newSql);
    }
    //...此处省略了其它重载方法
}

本来还想过一种方案,使用MySQL的流式查询并拦截jdbc层
ResultSet.next()
方法,在此方法调用超过指定次数时抛异常,但最终发现MySQL驱动在
ResultSet.close()
方法调用时,还是会读取剩余未读数据,查询没法提前终止,故放弃之。

Ceres 自动求导解析-从原理到实践

1.0 前言

Ceres 有一个自动求导功能,只要你按照Ceres要求的格式写好目标函数,Ceres会自动帮你计算
精确
的导数(或者雅克比矩阵),这极大节约了算法开发者的时间,但是笔者在使用的时候一直觉得这是个黑盒子,特别是之前在做深度学习的时候,神经网络本事是一个很盒模型了,再加上
pytorch
的自动求导,简直是黑上加黑。现在转入视觉SLAM方向,又碰到了 Ceres 的自动求导,是时候揭开其真实的面纱了。知其然并知其所以然才是一名算法工程师应有的基本素养。

2.0 Ceres求导简介

Ceres 一共有三种求导的方式提供给开发者,分别是:

  • 解析求导,也就是手动计算出导数的解析形式。

    例如有如下函数;


    \[y = \frac{b_1}{(1+e^{b_2-b_3x})^{1/b_4}}
    \]

    构建误差函数:


    \[\begin{split}\begin{align}
    E(b_1, b_2, b_3, b_4)
    &= \sum_i f^2(b_1, b_2, b_3, b_4 ; x_i, y_i)\\
    &= \sum_i \left(\frac{b_1}{(1+e^{b_2-b_3x_i})^{1/b_4}} - y_i\right)^2\\
    \end{align}\end{split}
    \]

    对待优化变量的导数为:


    \[\begin{split}\begin{align}
    D_1 f(b_1, b_2, b_3, b_4; x,y) &= \frac{1}{(1+e^{b_2-b_3x})^{1/b_4}}\\
    D_2 f(b_1, b_2, b_3, b_4; x,y) &=
    \frac{-b_1e^{b_2-b_3x}}{b_4(1+e^{b_2-b_3x})^{1/b_4 + 1}} \\
    D_3 f(b_1, b_2, b_3, b_4; x,y) &=
    \frac{b_1xe^{b_2-b_3x}}{b_4(1+e^{b_2-b_3x})^{1/b_4 + 1}} \\
    D_4 f(b_1, b_2, b_3, b_4; x,y) & = \frac{b_1 \log\left(1+e^{b_2-b_3x}\right) }{b_4^2(1+e^{b_2-b_3x})^{1/b_4}}
    \end{align}\end{split}
    \]

  • 数值求导,当对变量增加一个微小的增量,然后观察此时的残差和原先残差的下降比例即可,其实就是导数的定义。


    \[Df(x) = \lim_{h \rightarrow 0} \frac{f(x + h) - f(x)}{h}
    \]

    当然其实也有两种形式对导数进行数值上的近似,第一种是Forward Differences:


    \[Df(x) \approx \frac{f(x + h) - f(x)}{h}
    \]

    第二种是 Central Differences:


    \[Df(x) \approx \frac{f(x + h) - f(x - h)}{2h}
    \]

    Ceres 的官方文档上是认为第二种比第一种好的,但是其实官方还介绍了第三种,这里就不详说了,感兴趣的可以去看官方文档:
    Ridders’ Method

    这里有三种数值微分方法的效果对比,从右向左看:

image

效果依次是
\(Ridders > Central > Forwad\)

  • 第三种则是今天要介绍的主角,自动求导。

3.0 Ceres 自动求导原理

3.1 官方解释

其实官方对自动求导做出了解释,但是笔者觉得写的不够直观,比较抽象,不过既然是官方出品,还是非常有必要去看一看的。
http://ceres-solver.org/automatic_derivatives.html

3.2 自我理解

\(\quad\)
这里笔者根据网上和官方的资料整理了一下自己的理解。Ceres 自动求导的核心是运算符的重载与Ceres自有的 Jet 变量。

举一个例子:

函数
\(\mathrm{f}(\mathrm{x})=\mathrm{h}(\mathrm{x}) * \mathrm{~g}(\mathrm{x})\)
, 他的目标函数值为
\(\mathrm{h}(\mathrm{x}) * \mathrm{~g}(\mathrm{x})\)
, 导数为

\[\mathrm{f}^{\prime}(\mathrm{x})=\mathrm{h}^{\prime}(\mathrm{x}) \mathrm{g}(\mathrm{x})+\mathrm{h}(\mathrm{x}) \mathrm{g}^{\prime}(\mathrm{x})
\]

其中
\(h(x)\)
,
\(g(x)\)
都是标量函数.
如果我们定义一种数据类型,

\[Data \{ double\ \ value, double\ \ derived \}
\]

并且对于数据类型 Data,重载乘法运算符

\[data1*data2=\begin{bmatrix}
data1.value*data2.value \\
data1.derived*data2.value+data1.value*data2.derived
\end{bmatrix}
\]


\(h(x) =[h(x),{h(x)}' ] , g(x)=[g(x),{g(x)' }]\)

\(f(x)=h(x) * g(x)\)
, 那么
f_x.derived
就是
\(f(x)\)
的导数,
f_x.value
即为
\(f(x)\)
的数值。value 储存变量的函数值, derived 储存变量对
\(\mathrm{x}\)
的导数。类似,如果我们对数据类型 Data 重载所有可能用到的运算符. “
\(+- * / \log , \exp , \cdots\)
” 。那么在变量
\(h(x),g(x)\)
经过任意次运算后,
\(result=h(x)+g(x)*h(x)+exp(h(x))…\)
, 任然能获得函数值
result.value
和他的导数值
result.derived
,这就是Ceres 自动求导的原理。

上面讲的都是单一自变量的自动求导,对于多元函数
\(f(x_i)\)
。对于n 元函数,Data 里面的 double derived 就替换为 double* derived,derived[i] 为对于第i个自变量的导数值。

并且对于数据类型 Data,乘法运算符重载为

\[data1*data2=\begin{bmatrix}
data1.value*data2.value \\
derived[i]=data1.derived[i]*data2.value+data1.value*data2.derived[i]
\end{bmatrix}
\]

其余的运算符重载方法也做相应改变。这样对多元函数的自动求导问题也就解决了。Ceres 里面的Jet 数据类型类似于 这里Data 类型,并且Ceres 对Jet 数据类型进行了几乎所有数学运算符的重载,以达到自动求导的目的。

4.0 实践

4.1 Jet 的实现

这里我们模仿 Ceres 实现了 Jet ,并准备了两个具体的示例程序,Jet 具体代码在
ceres_jet.hpp
中,包装成了一个头文件,在使用的时候进行调用即可。这里也包含了一个
ceres_rotation.hpp
的头文件,是为了我们的第二个例子实现。具体代码如下:

  • ceres_jet.hpp
#ifndef _CERES_JET_HPP__
#define _CERES_JET_HPP__
#include <math.h>
#include <stdio.h>

#include <eigen3/Eigen/Core>
#include <eigen3/Eigen/Dense>
#include <eigen3/Eigen/Sparse>
#include "eigen3/Eigen/Eigen"
#include "eigen3/Eigen/SparseQR"
#include <fstream>
#include <iostream>
#include <map>
#include <queue>
#include <set>
#include <vector>
#include "ceres_rotation.hpp"

#include "algorithm"
#include "stdlib.h"

template <int N>
struct jet
{
  Eigen::Matrix<double, N, 1> v;
  double a;
  jet() : a(0.0) {}
  jet(const double& value) : a(value) { v.setZero(); }
  EIGEN_STRONG_INLINE jet(const double& value,
                          const Eigen::Matrix<double, N, 1>& v_)
      : a(value), v(v_)
  {
  }
  jet(const double value, const int index)
  {
    v.setZero();
    a = value;
    v(index, 0) = 1.0;
  }
  void init(const double value, const int index)
  {
    v.setZero();
    a = value;
    v(index, 0) = 1.0;
  }
};
/****************jet overload******************/
// for the camera BA,the autodiff only need overload the operator :jet+jet
// number+jet -jet jet-number jet*jet number/jet jet/jet sqrt(jet) cos(jet)
// sin(jet)  +=(jet) overload jet + jet
template <int N>
inline jet<N> operator+(const jet<N>& A, const jet<N>& B)
{
  return jet<N>(A.a + B.a, A.v + B.v);
}  // end jet+jet

// overload number + jet
template <int N>
inline jet<N> operator+(double A, const jet<N>& B)
{
  return jet<N>(A + B.a, B.v);
}  // end number+jet

template <int N>
inline jet<N> operator+(const jet<N>& B, double A)
{
  return jet<N>(A + B.a, B.v);
}  // end number+jet

// overload jet-number
template <int N>
inline jet<N> operator-(const jet<N>& A, double B)
{
  return jet<N>(A.a - B, A.v);
}
// overload number * jet because jet *jet need A.a *B.v+B.a*A.v.So the number
// *jet is required
template <int N>
inline jet<N> operator*(double A, const jet<N>& B)
{
  return jet<N>(A * B.a, A * B.v);
}
template <int N>
inline jet<N> operator*(const jet<N>& A, double B)
{
  return jet<N>(B * A.a, B * A.v);
}
// overload -jet
template <int N>
inline jet<N> operator-(const jet<N>& A)
{
  return jet<N>(-A.a, -A.v);
}
template <int N>
inline jet<N> operator-(double A, const jet<N>& B)
{
  return jet<N>(A - B.a, -B.v);
}
template <int N>
inline jet<N> operator-(const jet<N>& A, const jet<N>& B)
{
  return jet<N>(A.a - B.a, A.v - B.v);
}
// overload jet*jet
template <int N>
inline jet<N> operator*(const jet<N>& A, const jet<N>& B)
{
  return jet<N>(A.a * B.a, B.a * A.v + A.a * B.v);
}
// overload number/jet
template <int N>
inline jet<N> operator/(double A, const jet<N>& B)
{
  return jet<N>(A / B.a, -A * B.v / (B.a * B.a));
}
// overload jet/jet
template <int N>
inline jet<N> operator/(const jet<N>& A, const jet<N>& B)
{
  // This uses:
  //
  //   a + u   (a + u)(b - v)   (a + u)(b - v)
  //   ----- = -------------- = --------------
  //   b + v   (b + v)(b - v)        b^2
  //
  // which holds because v*v = 0.
  const double a_inverse = 1.0 / B.a;
  const double abyb = A.a * a_inverse;
  return jet<N>(abyb, (A.v - abyb * B.v) * a_inverse);
}
// sqrt(jet)
template <int N>
inline jet<N> sqrt(const jet<N>& A)
{
  double t = std::sqrt(A.a);

  return jet<N>(t, 1.0 / (2.0 * t) * A.v);
}
// cos(jet)
template <int N>
inline jet<N> cos(const jet<N>& A)
{
  return jet<N>(std::cos(A.a), -std::sin(A.a) * A.v);
}
template <int N>
inline jet<N> sin(const jet<N>& A)
{
  return jet<N>(std::sin(A.a), std::cos(A.a) * A.v);
}
template <int N>
inline bool operator>(const jet<N>& f, const jet<N>& g)
{
  return f.a > g.a;
}

#endif //_CERES_JET_HPP__
  • ceres_rotation.hpp
#ifndef CERES_ROTATION_HPP_
#define CERES_ROTATION_HPP_
#include <iostream>

template <typename T>
inline T DotProduct(const T x[3], const T y[3])
{
  return (x[0] * y[0] + x[1] * y[1] + x[2] * y[2]);
}

template <typename T>
inline void AngleAxisRotatePoint(const T angle_axis[3], const T pt[3],
                                 T result[3])
{
  const T theta2 = DotProduct(angle_axis, angle_axis);
  if (theta2 > T(std::numeric_limits<double>::epsilon()))
  {
    // Away from zero, use the rodriguez formula
    //
    //   result = pt costheta +
    //            (w x pt) * sintheta +
    //            w (w . pt) (1 - costheta)
    //
    // We want to be careful to only evaluate the square root if the
    // norm of the angle_axis vector is greater than zero. Otherwise
    // we get a division by zero.
    //
    const T theta = sqrt(theta2);
    const T costheta = cos(theta);
    const T sintheta = sin(theta);
    const T theta_inverse = T(1.0) / theta;

    const T w[3] = {angle_axis[0] * theta_inverse,
                    angle_axis[1] * theta_inverse,
                    angle_axis[2] * theta_inverse};

    // Explicitly inlined evaluation of the cross product for
    // performance reasons.
    const T w_cross_pt[3] = {w[1] * pt[2] - w[2] * pt[1],
                             w[2] * pt[0] - w[0] * pt[2],
                             w[0] * pt[1] - w[1] * pt[0]};
    const T tmp =
        (w[0] * pt[0] + w[1] * pt[1] + w[2] * pt[2]) * (T(1.0) - costheta);

    result[0] = pt[0] * costheta + w_cross_pt[0] * sintheta + w[0] * tmp;
    result[1] = pt[1] * costheta + w_cross_pt[1] * sintheta + w[1] * tmp;
    result[2] = pt[2] * costheta + w_cross_pt[2] * sintheta + w[2] * tmp;
  }
  else
  {
    // Near zero, the first order Taylor approximation of the rotation
    // matrix R corresponding to a vector w and angle w is
    //
    //   R = I + hat(w) * sin(theta)
    //
    // But sintheta ~ theta and theta * w = angle_axis, which gives us
    //
    //  R = I + hat(w)
    //
    // and actually performing multiplication with the point pt, gives us
    // R * pt = pt + w x pt.
    //
    // Switching to the Taylor expansion near zero provides meaningful
    // derivatives when evaluated using Jets.
    //
    // Explicitly inlined evaluation of the cross product for
    // performance reasons.
    const T w_cross_pt[3] = {angle_axis[1] * pt[2] - angle_axis[2] * pt[1],
                             angle_axis[2] * pt[0] - angle_axis[0] * pt[2],
                             angle_axis[0] * pt[1] - angle_axis[1] * pt[0]};

    result[0] = pt[0] + w_cross_pt[0];
    result[1] = pt[1] + w_cross_pt[1];
    result[2] = pt[2] + w_cross_pt[2];
  }
}

#endif  // CERES_ROTATION_HPP_

4.2 多项式函数自动求导

这里我们准备了两个实践案例,一个是对下面的函数进行自动求导,求在
\(f(1,2)\)
处的导数。

\[f(x,y)=2x^2+3y^3+3
\]

代码如下:

#include <eigen3/Eigen/Core>
#include <eigen3/Eigen/Dense>

#include "ceres_jet.hpp"

int main(int argc, char const *argv[])
{
  /// f(x,y) = 2*x^2 + 3*y^3 + 3
  /// 残差的维度,变量1的维度,变量2的维度
  const int N = 1, N1 = 1, N2 = 1;
  Eigen::Matrix<double, N, N1> jacobian_parameter1;
  Eigen::Matrix<double, N, N2> jacobian_parameter2;
  Eigen::Matrix<double, N, 1> jacobi_residual;

  /// 模板参数为向量的维度,一定要是 N1+N2
  /// 也就是总的变量的维度,因为要存储结果(残差)
  /// 对于每个变量的导数值
  /// 至于为什么有 N1 个 jet 表示 var_x
  /// 假设变量 1 的维度为 N1,则残差对该变量的导数的维度是一个 N*N1 的矩阵
  /// 一个 jet<N1 + N2> 只能表示变量中的某一个在当前点的导数和值
  jet<N1 + N2> var_x[N1];
  jet<N1 + N2> var_y[N2];
	jet<N1 + N2> residual[N];
	/// 假设我们求上面的方程在 (x,y)->(1.0,2.0) 处的导数值
	double var_x_init_value[N1] = {1.0};
	double var_y_init_value[N1] = {2.0};

  for (int i = 0; i < N1; i++)
  {
    var_x[i].init(var_x_init_value[i], i);
  }
  for (int i = 0; i < N2; i++)
  {
    var_y[i].init(var_y_init_value[i], i + N1);
  }
	/// f(x,y) = 2*x^2 + 3*y^3 + 3
	/// f_x` = 4x
	/// f_y` = 9 * y^2
	residual[0] = 2.0 * var_x[0] * var_x[0]  + 3.0 * var_y[0] * var_y[0] * var_y[0] + 3.0;
	std::cout << "residual: " << residual[0].a << std::endl;
	std::cout << "jacobian: " << residual[0].v.transpose() << std::endl;
	/// residual: 29
	/// jacobian:  4 36
  return 0;
}
  • 输出结果,读者可以自己求导算一下,是正确的。

    residual: 29
     jacobian:  4 36
    

4.3 BA 问题中的自动求导

这里是用的 Bal 数据集中的某个观测构建的误差项求导

#include "ceres_jet.hpp"

class costfunction
{
 public:
  double x_;
  double y_;
  costfunction(double x, double y) : x_(x), y_(y) {}
  template <class T>
  void Evaluate(const T* camera, const T* point, T* residual)
  {
    T result[3];
    AngleAxisRotatePoint(camera, point, result);
    result[0] = result[0] + camera[3];
    result[1] = result[1] + camera[4];
    result[2] = result[2] + camera[5];
    T xp = -result[0] / result[2];
    T yp = -result[1] / result[2];
    T r2 = xp * xp + yp * yp;
    T distortion = 1.0 + r2 * (camera[7] + camera[8] * r2);
    T predicted_x = camera[6] * distortion * xp;
    T predicted_y = camera[6] * distortion * yp;
    residual[0] = predicted_x - x_;
    residual[1] = predicted_y - y_;
  }
};

int main(int argc, char const* argv[])
{
  const int N = 2, N1 = 9, N2 = 3;
  Eigen::Matrix<double, N, N1> jacobi_parameter_1;
  Eigen::Matrix<double, N, N2> jacobi_parameter_2;
  Eigen::Matrix<double, N, 1> jacobi_residual;
  costfunction* costfunction_ = new costfunction(-3.326500e+02, 2.620900e+02);
  jet<N1 + N2> cameraJet[N1];
  jet<N1 + N2> pointJet[N2];
  double params_1[N1] = {
      1.5741515942940262e-02,  -1.2790936163850642e-02, -4.4008498081980789e-03,
      -3.4093839577186584e-02, -1.0751387104921525e-01, 1.1202240291236032e+00,
      3.9975152639358436e+02,  -3.1770643852803579e-07, 5.8820490534594022e-13};
  double params_2[N2] = {-0.612000157172, 0.571759047760, -1.847081276455};
  for (int i = 0; i < N1; i++)
  {
    cameraJet[i].init(params_1[i], i);
  }
  for (int i = 0; i < N2; i++)
  {
    pointJet[i].init(params_2[i], i + N1);
  }
  jet<N1 + N2>* residual = new jet<N1 + N2>[N];
  costfunction_->Evaluate(cameraJet, pointJet, residual);
  for (int i = 0; i < N; i++)
  {
    jacobi_residual(i, 0) = residual[i].a;
  }
  for (int i = 0; i < N; i++)
  {
    jacobi_parameter_1.row(i) = residual[i].v.head(N1);
    jacobi_parameter_2.row(i) = residual[i].v.tail(N2);
  }
  /* 
  real result:
  jacobi_parameter_1: 
   -283.512    -1296.34    -320.603     551.177 0.000204691    -471.095   -0.854706    -409.362    -490.465
    1242.05      220.93    -332.566 0.000204691     551.177       376.9     0.68381     327.511     392.397
  jacobi_parameter_2: 
  545.118 -5.05828 -478.067
  2.32675  557.047  368.163
  jacobi_residual: 
  -9.02023
    11.264
   */
  std::cout << "jacobi_parameter_1: \n" << jacobi_parameter_1 << std::endl;
  std::cout << "jacobi_parameter_2: \n" << jacobi_parameter_2 << std::endl;
  std::cout << "jacobi_residual: \n" << jacobi_residual << std::endl;
  delete (residual);
  return 0;
}

  • 输出结果

    jacobi_parameter_1: 
       -283.512    -1296.34    -320.603     551.177 0.000204691    -471.095   -0.854706    -409.362    -490.465
        1242.05      220.93    -332.566 0.000204691     551.177       376.9     0.68381     327.511     392.397
    jacobi_parameter_2: 
     545.118 -5.05828 -478.067
     2.32675  557.047  368.163
    jacobi_residual: 
    -9.02023
      11.264
    

Reference