` 使用 MindSpore 复现其他开源框架实现过的代码是一件很有意思的事儿,但这次的经历我不是很快乐。`
### 一、昇腾Al创新大赛 2022 昇思赛道
昇腾又整活儿了,[昇腾 Al 创新大赛2022](https://www.hiascend.com/developer/AAIC2022) 在昇思赛道就发布了 32 道赛题,从 4 月份左右就开始,11 月初才结束,赛期很长,给选手留了足够多的学习和参与时间。
我很早就注意到这个活动了,但是今年恰逢楼主参加秋招求职,我没有太多的精力来参与。10 月初,手头的事差不多弄完了,暂时没有其他事干,又不想闲着,所以去赛事页面看了看,竟然还有几个赛题没人做,稍微浏览了下各个空余赛题的基本情况,我决定接下来几天弄弄 NLRNet。
我从 10 月 8 号决定正式着手开发,计划三天开发,两天训练,一天交付,最后花了差不多九天时间,但没有取得我期望的结果。就 NLRNet 这个赛题来讲,主办方就有许多需要改进的地方,但是我和他们的交涉没有取得任何效果,权在此写下我的经历,以供自省。
### 二、NLRNet 的复现
[NLRNet: An Efficient Nonlocal Attention ResNet for Pansharpening](https://ieeexplore.ieee.org/document/9392108) 是一篇研究遥感图像的全色图像锐化操作的论文,这篇论文所开源的代码是有争议的,争议部分我放在后面讲,这个部分我讲一讲使用 MindSpore 复现他的过程中所遇到的问题。
从 NLRNet 的 [源码](https://github.com/aaabbbcq/NLRNET) 来看,他的数据预处理方面包含了很多其他网络所需要的预处理操作,有一部分网络是用来做对比实验的,另一部分则是完全无关的,而这对 NLRNet 来讲是不需要的,所以我个人更倾向于在复现过程中删除这些内容,仅保留与 NLRNet 有关的内容,这样假设以后有人访问了我复现的这部分代码,他就可以只关注 NLRNet 的部分了。
我自己有相对熟练的 MindSpore 使用经验,所以简单的复现没有什么难度,复现 NLRNet 的时候遇到了以下几个比较棘手的问题。
#### 1、Cosine_Similarity
MindSpore 1.8.1 没有提供计算两个 Tensor 之间的余弦相似度的算子,或许是我没有找到,不过他现有的和 cosine_similarity 很像的算子用起来实在不顺手,我也懒得看他应该怎么使用了,便自己写了一个。
```python
class Cosine_Similarity(nn.Cell):
def __init__(self, ):
super(Cosine_Similarity, self).__init__()
self.norm = nn.Norm(1, True)
self.reducesum = ops.ReduceSum(keep_dims=False)
self.eps = 1e-08
def construct(self, x1, x2, dim):
output = (x1*x2) / (self.norm(x1)*self.norm(x2) + eps)
return self.reducesum(output, dim)
```
以上是一份没有问题的代码,但是我最初的代码不是这么写的。也因此发现了一个问题,最初的代码如下:
```python
class Cosine_Similarity(nn.Cell):
def __init__(self, ):
super(Cosine_Similarity, self).__init__()
self.reducesum = ops.ReduceSum(keep_dims=False)
self.eps = 1e-08
def construct(self, x1, x2, dim):
output = (x1*x2) / (ops.norm(x1, 1, p=2, keep_dims=True)*ops.norm(x2, 1, p=2, keep_dims=True) + eps)
return self.reducesum(output, dim)
```
我本意是需要计算 x1 和 x2 的二范数,因为 ops 中有这个算子,而且它不像 nn 层里的算子一样需要提前声明才能使用,所以一般情况下我是使用这种的。我在 CPU 版本代码上测试过后没有问题,所以就迁到 GPU 版本代码上使用了,但是没想到遇到了问题。
我觉得这可以算做 MindSpore 最为诟病的问题。通常来讲,MindSpore 所暴露出来的问题有这么几类:算子缺失、(Ascend、GPU、CPU)某个平台上的算子缺失、某个算子在(Ascend、GPU、CPU)平台上的运行结果不一致,其中后者最难受,没有遇到过类似问题的人几乎很少会去测试不同平台上的运行结果。
本次的问题在于 ops.norm 算子在 CPU 版本的代码上运行正常,但在 GPU 版本的代码上计算就出了问题(注:三维及以上维度的 Tensor),导致我的 GPU 版本代码计算 loss 的时候直接 NaN,花了老长时间定位,最后在同门的提醒下换成了 nn.Norm 计算过程才算正常,我已经把这个问题提交给 MindSpore 了,有兴趣的可以[跟踪](https://gitee.com/mindspore/mindspore/issues/I5XVCF?from=project-issue)。
重写余弦相似度计算函数的部分就算是完成了。
#### 2、InstanceNorm2d
这也算是一类典型的问题了,去 issue 版块搜索了一下,一年前就 [有人](https://gitee.com/mindspore/mindspore/issues/I4DFUZ?from=project-issue) 提出来这个问题了,MindSpore 1.8.1 也仅仅是支持在GPU 平台上使用这个算子,其他平台均不支持。
重写算子其实问题不大,我个人也不排斥进行这种工作,因为重写算子意味着我需要了解这些算子的底层计算逻辑,并能和其他类似算子进行类比,进而学习到这一类计算的原理。
这一类的算子有:BatchNorm、InstanceNorm、LayerNorm、GroupNorm。
在查阅了一部分资料后,我将 InstanceNorm2d 的功能实现如下:
```python
class InstanceNorm2d(nn.Cell):
def __init__(self, num_features,
eps=1e-5,
momentum=0.1,
affine=True,
gamma_init='ones',
beta_init='zeros'):
super(InstanceNorm2d, self).__init__()
self.eps = eps
self.gamma = Parameter(initializer(
gamma_init, (1, num_features, 1, 1)), name="gamma", requires_grad=affine)
self.beta = Parameter(initializer(
beta_init, (1, num_features, 1, 1)), name="beta", requires_grad=affine)
self.moments = nn.Moments(axis=(2, 3), keep_dims=True)
self.sqrt = ops.Sqrt()
def construct(self, x):
#InstanceNorm 训练与预测阶段行为一致,都是利用当前 batch 的均值和方差计算
mean, variance = self.moments(x)
results = (x - mean) / self.sqrt(variance + self.eps)
results = self.gamma * results + self.beta
return results
```
这四个算子的计算公式都是:
$$ y = \frac{x - \mathrm{E}[x]}{\sqrt{\mathrm{Var}[x] + \epsilon}} * \gamma + \beta
$$
区别在于针对输入的 Tensor,从什么角度计算其方差和均值。假设输入 Tensor 的 shape 为 (N,C,H,W),则:
BatchNorm 就是 nn.Moments(axis=(0,2, 3), keep_dims=True)
LayerNorm 就是 nn.Moments(axis=(1,2, 3), keep_dims=True)
InstanceNorm 就是 nn.Moments(axis=(2, 3), keep_dims=True)
另外要注意,对于 InstanceNorm,无论是训练还是推理过程,计算时都是利用当前 Batch 数据的均值和方差来进行计算的,因此不需要计算均值和方差的滑动平均值 moving_mean 、moving_var,直接在上述代码中去掉它,虽然让这个实现和另外三个算子显得不统一,但是能略去不必要的计算,从而有可能加速训练。
含有均值和方差的滑动平均值 moving_mean 、moving_var 的算子,在训练时使用当前 Batch 数据的均值和方差计算 results ,在推理时,使用已经得到的 moving_mean 和 moving_var 来计算 results 。
后来我去看了复现 NLRNet 的另外两位选手的实现,第一位选手使用 GroupNorm 将分组数设为和输入通道一样的大小,替代实现了InstanceNorm2d 的功能,这是我没有想到的;第二位选手取消更新 gamma 和 beta(设为无梯度),也即直接计算 results ,在没有损失太大精度的情况下,大幅提升了训练速度,这也是我没有想到的。
我实现完的代码在 3090 上训练 40 epoch 耗时 3 小时左右,但在 Ascend 上训练需要 44+ 小时,这个性能问题我也始终没定位到,虽然赛方没有性能要求,但我觉得一定是有几个算子在 GPU 和 Ascend 上的表现不一样导致了如此大的性能差距。可惜启智社区提供的环境和 ModelArts 都只能用来训练,没法导入 Profiler 进行性能分析。
#### 3、差异
我详细阅读了 [NLRNet: An Efficient Nonlocal Attention ResNet for Pansharpening](https://ieeexplore.ieee.org/document/9392108) 和其附带的 [源码](https://github.com/aaabbbcq/NLRNET) ,经过对比发现,开源的代码和论文描述只能说是大体一致,但仍存在很多不一致的地方,比如 SpecAM 模块的实现、Residual 模块的层数、初始学习率等等,我按照论文描述对代码进行修改后,对于降分辨率的推理测试,从结果来看,只需要 20 多 epoch 就能达到赛方给定的指标了,但在全分辨率测试上,始终达不到赛方要求的效果。
经过查阅资料和分析,我发现了几处具有争议的地方,并开始怀疑论文结果的真实性。
### 三、争议
当我发现我在进行全分辨率推理测试始终达不到赛方指标时,我开始怀疑源码实现有问题。我使用作者 [源码](https://github.com/aaabbbcq/NLRNET) 在 WorldView2 数据集上进行了训练和推理,发现结果和我的代码表现一致,因此我断言该份源码在全分辨率推理结果的评价指标计算上是不正确的,由于我是第一次接触遥感图像处理,很多东西我都不清楚,因此我查阅了一些论文和开源代码,了解针对遥感图像的全色图像锐化操作有哪些评价指标以及如何实现,定位到了最明确的 [论文](https://www.ingentaconnect.com/content/asprs/pers/2008/00000074/00000002/art00003?crawler=true&mimetype=application/pdf) 和 [代码](https://github.com/wasaCheney/IQA_pansharpening_python) 实现。
后续我开始根据参考代码重写全分辨率推理时的评价指标计算过程,从当前已有的代码来看,有很多种实现组合,组合完的结果也可能有很大差异,但我觉得没有什么影响,毕竟提交后的代码还有验收流程,我想着最起码赛方会在统一的评价指标上评判选手的模型效果,可惜我想多了,而这也是我最不能接受的地方,将放在后面吐槽。
全分辨率评价指标主要有三个:D_lambda、D_s、QNR,其中 QNR 是根据 D_lambda、D_s 计算出来的。
我对比了作者 [源码](https://github.com/aaabbbcq/NLRNET) 和 [此处](https://github.com/wasaCheney/IQA_pansharpening_python/blob/master/IQAs.py) 的评价指标计算方式,作者源码的各种评价指标的计算方式很明显是从后者那里拿过来的,我不明白他为什么要注释掉前者的 D_lambda 方式而自己重写一个,这为我不再相信他的代码提供了至少 30% 的占比。
对比作者 [源码](https://github.com/aaabbbcq/NLRNET) 的 D_lambda 计算方式和 [此处](https://github.com/wasaCheney/IQA_pansharpening_python/blob/master/IQAs.py) 的 D_lambda 计算方式,可以发现二者的计算逻辑很明显是类似的,无论是 filter 操作还是后续的 mean 操作,基本上是一致的,不过由于我对这一块了解得非常浅显,我注意到 [此处](https://github.com/wasaCheney/IQA_pansharpening_python/blob/master/IQAs.py) 的 D_lambda 计算方式 和 [论文](https://www.ingentaconnect.com/content/asprs/pers/2008/00000074/00000002/art00003?crawler=true&mimetype=application/pdf) 中描述的基本一致,加上测试后发现作者 [源码](https://github.com/aaabbbcq/NLRNET) 的计算方式会比 [此处](https://github.com/wasaCheney/IQA_pansharpening_python/blob/master/IQAs.py) 的计算在结果上“更好看”一些,再加上作者 [源码](https://github.com/aaabbbcq/NLRNET) 的 D_s 计算直接是错误的等种种原因,我已经在怀疑作者代码的正确性了,我甚至怀疑他为了数据在搞一些小 trick 了,否则直接使用公用的评价指标就行,何必要重新写一版呢?
因此在 D_lambda 指标的评判上,即使我发现作者 [源码](https://github.com/aaabbbcq/NLRNET) 的 D_lambda 计算方式会让我更容易达到赛方指标从而获得优势(注意在此时,我其实并不知道赛方指标是怎么计算出来的),我也在代码中选择了一个数值更高(D_lambda 越低越好)的计算方式,我觉得无论如何,赛方会在同一种评价指标上衡量选手的复现效果,因此选择一种更为严谨的计算方式才能让推理出的结果更站得住脚。至于作者的那种计算方式,是否有效,可以一起商讨,毕竟是要开源出去给社区公开使用的,正确性应该放第一位,能不能达到“论文精度”,倒是次要的了。
D_s 的指标计算,作者的根本跑不了,所以我直接全删了,然后用了[此处](https://github.com/wasaCheney/IQA_pansharpening_python/blob/master/IQAs.py) 的 D_s 计算方式,但是 [此处](https://github.com/wasaCheney/IQA_pansharpening_python/blob/master/IQAs.py) 的 D_s 计算中涉及一个 mtf_resize 操作,对于一个外行人来说,缩略语是真难懂啊,mtf 是啥啊?我只知道他先做了一个 ndimage.filters.correlate, 然后做了一个 cv2.resize,不过看这段代码:
```python
def mtf_resize(img, satellite='QuickBird', scale=4):
# satellite GNyq
scale = int(scale)
if satellite == 'QuickBird':
GNyq = [0.34, 0.32, 0.30, 0.22] # Band Order: B,G,R,NIR
# GNyqPan = 0.15
GNyqPan = 0.11
elif satellite == 'IKONOS':
GNyq = [0.26, 0.28, 0.29, 0.28] # Band Order: B,G,R,NIR
GNyqPan = 0.17
else:
raise NotImplementedError('satellite: QuickBird or IKONOS')
# lowpass
img_ = img.squeeze()
img_ = img_.astype(np.float64)
if img_.ndim == 2: # Pan
H, W = img_.shape
lowpass = GNyq2win(GNyqPan, scale, N=41)
elif img_.ndim == 3: # MS
H, W, _ = img.shape
lowpass = [GNyq2win(gnyq, scale, N=41) for gnyq in GNyq]
lowpass = np.stack(lowpass, axis=-1)
img_ = ndimage.filters.correlate(img_, lowpass, mode='nearest')
# downsampling
output_size = (W // scale, H // scale)
img_ = cv2.resize(img_, dsize=output_size, interpolation=cv2.INTER_NEAREST)
return img_
```
他已有的 ndimage.filters.correlate 操作只针对 QuickBird 和 IKONOS 卫星拍出来的数据集,而赛方提供的是 WorldView2 数据集,是由 WorldView2 卫星拍出来的,直观来讲,这里的 GNyq(根据注释,全称叫 Nyquist frequency,不过我也不是很明确到底是什么) 是不能用的,毕竟都是不同的卫星了,卫星参数都不一样,这里理应有 WorldView2 的 GNyq,不过我实在没找到,因此我略去了 ndimage.filters.correlate 操作,直接进行了 cv2.resize。
最后我算出来的结果是 D_s :0.0683
赛方要求的指标是 D_s :0.0241
验收结果出来后,我联系了前两名同学,阅读了下他们的代码实现,了解到第一名的计算是采用了 satellite == 'IKONOS' 的参数,第二名的计算是采用了 satellite == 'QuickBird' 的参数,我顿时就感觉不对头了,合着赛方的验收就只看代码输出,而不管评价指标的计算逻辑了。
我用先前 D_s :0.0683 的权重重新推理测试,在使用 satellite == 'IKONOS' 参数后, D_s 下降到 0.0183,比第一名的兄弟指标还要优一些。
我感觉这个验收过程完全不合理,我到现在都不认为采用 satellite == 'QuickBird' 或者 satellite == 'IKONOS' 的参数是正确的,包括我自己不采用任何参数的做法是正确的,但我认为所有选手的评价指标计算逻辑应该是需要保持一致才对,不能仅仅为了靠近或超过“论文精度”就随意选择,至少,应该找到确实属于 WorldView2 的 GNyq 参数,因为假设赛方要求使用 QuickBird 数据集,那大概率所有选手这一块的计算上都不会存在争议。这一块的延伸内容,我放在后续吐槽。
### 四、吐槽
这一次的参赛经历,有非常多的槽点。
1、首先是数据集的问题,论文中存在错别字,将 WorldView3 数据集描述成 WordView3,导致赛方在出题时也将 WorldView2 数据集写成了 WordView2,而没有进行更正,这对于一个公开性的活动来讲,显得有点不严谨了,而且我看到这个赛题的时候已经是 10 月初了,距离赛题公布已经过去了三个多月,始终没有更正。赛方在启智社区中上传了一份[数据集](https://git.openi.org.cn/deng/WordView-2)(命名也是 WordView2,建议及时更正),不过我并不知道他们上传过这个数据集,在 WorldView 官网到处寻找,也没找到符合论文描述的数据。还是建议如果提供了数据集的话,在赛题页面标注一下数据集地址。
2、从我三年前接触 MindSpore 至今,它暴露出来的问题就是很多算子不支持,导致用户需要自己来实现,对于一些简单的算子实现起来倒也不麻烦,关键在于有些算子相对复杂,更特别的是,对于这类论文复现赛,很多都是其他领域的选手,不了解 MindSpore、也不了解对应领域(比如遥感、图像分割、NLP 等等),如果要实现这些算子,还需要深入学习,了解对应算子的底层计算逻辑,这一块儿最大的成本是时间方面。
3、第三个槽点是同一个算子,有三个应用平台(Ascend、GPU、CPU),我不清楚他们的开发流程是怎么样的,但总能遇到某些算子只能在其中一些平台上运行,在另外的平台上不支持,建议尽量能保证一个算子在推出时,能适应所有平台的使用。
4、第四点是同一个算子能兼容所有应用平台,但是它的使用却很不正常,我已经遇到很多次类似情况了,比如一个算子在 GPU 平台上跑得好好的,一切正常,在 Ascend 上运行计算就是错误的,而这次遇到的是在 CPU 平台上运行正常,在 GPU 上计算就是错误的。也多亏我有过类似的经历,能够尽快地定位到这些问题,但总会耗掉我一些时间,这对于竞速赛来讲,是不利的。
5、第五点是我写完的代码在 NVIDIA GeForce RTX 3090 上完成训练只需要 3+ 小时,但是在 Ascend 上完成训练需要 44+ 小时,我知道其中肯定出现了问题,但是具体的算子耗时却没有顺利定位到,这不免成为一个遗憾。
6、第六点是赛方没有提供异议质询渠道,所有的验收由赛方进行,由且仅由他们保证验收正确性,如果赛方能有一个合理的验收制度倒可以接受,但从这次的经历来看,很明显没有。另外,除非参赛选手主动公开代码,否则我们没办法查看其他选手的代码(我们并不是要抄袭,当实现存在争议,特别是评价指标这一最不应该出现争议的地方存在争议时,应该有一个更合理的衡量标准,选手是最清楚哪些地方会存在争议的,赛方自己了解每个赛题的实现细节的情况应该很少会发生),当我提出这一质疑时,赛方仍然没有提供后续解决方案,而是按验收结果继续下面的流程了。而验收的这一过程,是我最想要吐槽的地方了。从我的视角来看,他们的验收完全是以结果为导向,所有选手各自提交一份代码原件和自验文件,他们按照文件中的描述,找个环境将代码训练完,然后推理一下并记录输出的指标数据,一切正常的话验收就算结束了,至于是否会去核验网络实现、指标计算方式等细节,外界也不知道。毕竟到后期的时候,他们收集到的提交内容已经非常多了,能顺利按这种方式验收完拿到数据就已经很不错了,毕竟有些网络需要训练好几天呢。
7、验收。我个人认为,MindSpore 推出论文复现赛,一方面是为了让更多的选手有更多的渠道接触他的使用,另一方面是为了在选手使用的过程中,发现 MindSpore 本身存在的一些问题并进行修复,通过促进选手和开源社区的交流,扩充 MindSpore 对前沿进展的支持,使后来的用户能有更趁手的工具。而论文复现,不仅仅是要保证 MindSpore 能复现出论文的精度,还要保证新增的实现能体现论文的思想,当存在部分算子缺少的情况时,应该尽量实现出原汁原味的操作。而验收的过程,就更不能仅仅是跑一遍训练、推理,记录一下推理的精度就可以了。从论文中设计的网络结构、训练Pipeline、以及推理精度计算,都应该有一个相对仔细的核验,像图像分割中的 mIoU、图像去噪中的 PSNR、图像分类中的 Accuracy 等等,由于常见,核验起来便快,而对于容易存在争议的评价指标,就算暂时找不到从事对应领域的专业人员进行验收,其核验过程也应谨慎再三。至少确定统一的评价指标,否则不同的选手选择不同的计算方式(在作者源代码计算方式错误的情况下),其推理结果又有何意义?毕竟论文的创新不体现在评价指标上,推理阶段不过是计算一下推理结果的优劣,当计算方式本身就是错误的时候,得到的结果也很难站得住脚。
### 五、结语
这次的参赛经历前期还是很刺激的,3天时间陆陆续续解决很多问题,指出了MindSpore 本身存在的一些问题,也实现了一些 MindSpore 暂时不支持的算子,希望能对后来的 MindSpore 用户提供一些帮助。
我也从其他选手那里学习到一些技巧,很是受用。
但我至今仍不觉得赛方的验收过程合理,这种处理方式让我很难信服。我也不觉得作者 [源码](https://github.com/aaabbbcq/NLRNET) 的全分辨率推理的评价指标计算那块儿是正确的。当我的实现和论文保持非常好的一致性(修复源码中的问题),代码也很规范,只用到源码中一半的 epoch 数就能使网络收敛到论文精度,单从评价指标的问题上判定我失败,我是无法接受的。
关于论文中提到的那些数据集的实验结果,我将在后面找时间逐一测验,如果有人正好有 [NLRNet](https://ieeexplore.ieee.org/document/9392108) 中实验用到的 WorldView-3 和 QuickBird 数据集并且有条件公开的话,欢迎致信我的[邮箱](luxuff@foxmail.com),非常感谢。未来我应该很少会参加 MindSproe 的活动了,我计划去另外的赛道看看。
给其他参加类似竞赛的选手一个小小的建议吧,当遇到像我上面描述的那些问题时,提交的代码尽量保证自己的优势吧,正确性什么的,可以在把奖项拿到手之后再调整,要是提前给自己找事儿,就得不偿失了。往小了说,你影响不了赛方的验收流程,只要他们验收通过了,就算后面有争议,你也可以再改,又不是不改;往大点说,为了确保开源出去的代码的正确性,你可以在比赛结束后、代码开源前进行调整,就算不调整,也没人能发现(bushi)。
祝愿 MindSpore 越来越好吧,希望后来的兄弟都有趁手的算子!
我从不惧怕失败,但我反感有人将我的严谨和努力看作是对别人成功之后的嫉妒与气急败坏。

MindSpore复现NLRNet