论文:FACTS: Fully Automated Correlated Time Series Forecasting in Minutes
在FACTS这篇论文中,有这么一个逻辑:给定一定数量的模块,随机排列组合,用一定的操作连接。
代码中操作是这样定义的:
python
PRIMITIVES = [
# 'none',
'skip_connect', # 0
'NLinear', # 1
'cnn', # 2
'dcc_1', # 3
'inception', # 4
'gru', # 5
'lstm', # 6
'diff_gcn', # 7
'mix_hop', # 8
'trans', # 9
'informer', # 10
'convformer', # 11
's_trans', # 12
's_informer', # 13
'masked_trans', # 14
]
那么具体如何把模块与操作组织起来呢?
这里用到了拓扑排序。
(以下是对论文代码的个人理解,代码见 exps/collect_seeds.py)
思路分三步:把操作排序,把节点(模块)排序,然后把操作插入节点
1.把操作排序
为操作定义一个数组 comb = [0,3,1,2]
这里表示第0种操作有0个,第1种操作有3个,第2种操作有1个,第三种操作有2个
但是这些操作不一定按这种顺序来,这里就用到了不重复的全排列。
比如comb = [2,1,1],第0种操作有2个,第1种操作有1个,第2种操作有1个,排列后为
0,1,2,0\] \[0,2,1,0\] \[0,0,1,2\] \[0,0,2,1\] \[0,1,0,2\] \[0,2,0,1
具体算法分析可以看这篇文章:python实现简单去重全排列
这就是操作的所有可能
代码
python
def permuteUnique(nums):#去重排列
ans = [[]]
for n in nums:
ans = [l[:i] + [n] + l[i:]
for l in ans
for i in range((l + [n]).index(n) + 1)]
return ans
def get_combs(num_ops, splits):
all_combs = []
for split in splits:
if num_ops < len(split):
continue
fill_length = num_ops - len(split)
filled_split = [0] * fill_length + split
perm_split = permuteUnique(filled_split)
all_combs.extend(perm_split)
return all_combs #所有操作数组
2.把节点排序(找到前驱节点)
论文代码中用了get_all_tops(4),表明默认4个节点
对这四个节点进行排列,这里默认节点都是按顺序编好号的,因此这里用数字0,1,2...分别代表第0个,第1个,第2个节点,相当于已经有了顺序。整个神经网络是有输入输出的,没有环,因此要保证后面的节点前面连着的,一定是编号较小的节点,这里称为前驱节点。
比如:3的前驱节点可以是0,1,2
现在如果有四个节点[0,1,2,3],那么排列可能是这样的:
python
topos = [
[0,0,0],
[0,0,1],
[0,1,0],
[0,1,1],
[0,1,2]
]
注意这里的理解,是指的节点所在的下标要大于在这个下标的节点的编号
然后我们对这个节点前后连接的情况进行保存,保存哪些内容呢?节点、前驱节点和它们之间的操作。我们可以这样表示:(前驱节点,操作)(自身节点,操作)
这两个信息来表达一个节点,比如:
python
geno = [
(0,0), (0,0),
(1,0), (1,0),
(2,0), (2,0),
(3,0)
]
实际上的节点排序就是编号 0 1 2 3 ,其中(0,0)
这里操作先用0来占位,后续是会替换的
代码
python
def get_all_topos(num_nodes):
def get_topos(arr1, arrlist):
if arrlist:
string = []
for x in arr1:
for y in arrlist[0]:
string.append(x + [y])
result = get_topos(string, arrlist[1:])
return result
else:
return arr1
buckets = [list(range(i)) for i in range(1, num_nodes)]
topos = get_topos([buckets[0]], buckets[1:])
return topos #获得所有符合前驱节点的排列
def get_base_genos(topos):
genos = []
for topo in topos:
geno = [(0, 0)]
for i in range(len(topo)):
geno.append((topo[i], 0))
geno.append((i + 1, 0)) #增加占位
genos.append(geno)
return genos
关于为什么要插入占位,gpt这样解释的:
DARTS 风格规则:
-
每个中间节点必须有两条输入边。
-
即使拓扑只给了一个前驱,也要补齐第二条边。
(i+1, 0) 不是节点连自己,而是一个占位符, 保证每个节点统一有两个输入槽位。
既然只是占位,那为什么用i+1呢?其实是无所谓的,因为后面没有用到这个地方,只是在prenode1 = base_geno[2*i-1][0]这里,用2*i-1表示下标与上面的op1 = arch[2*i-1]对应起来(具体见下面的代码)
3.把操作插入节点
也就是把上述的操作0转化为实际的操作序号,并为节点补充好其他的输入节点
代码
python
def get_archs(comb):
op_list = []
for i in range(len(PRIMITIVES)):
op_list += [i] * comb[i]
all_archs = permuteUnique(op_list)
topos = get_all_topos(4)
base_genos = get_base_genos(topos)
all_genos = []
for arch in all_archs: # 一种"操作序列"的候选
for base_geno in base_genos:# 一种"拓扑骨架"的候选
geno = []
for i in range(args.steps): # i=0..steps-1,对应目标节点 1..steps
if i == 0:
op = arch[i] # 取第0个操作
prenode = base_geno[i][0] # 取第0个目标节点的"拓扑前驱"
geno.extend([(prenode, op)]) # 节点1只有一条输入
else:
op1 = arch[2 * i - 1] # 节点(i+1)的第1条边的操作
op2 = arch[2 * i] # 节点(i+1)的第2条边的操作
prenode1 = base_geno[2 * i - 1][0] # 从"拓扑骨架"里取到这一节点的前驱
prenode2 = i # 另一路固定来自"前一个节点" i
geno.extend([(prenode1, op1), (prenode2, op2)])
all_genos.append(geno)
random.shuffle(all_genos)
return all_genos
op_list 是通过将每个操作索引 i 重复 comb[i] 次生成的列表,例如如果 comb = [2, 1],则 op_list = [0, 0, 1]
steps表示中间节点的数目,整个模块包含: 输入节点-中间节点-输出节点。
对于输入节点,是i==0的情况,只有一个前驱输入
对于中间节点,会有两个前驱输入,一个是上文提到的拓扑前驱,一个是来自前一个节点i。这里我理解的是,这里节点的顺序是123,不是从0开始,因此第一个中间节点时,i==1,1对应的第一个节点,也就是刚刚的输入节点。
这里似乎没有单独处理输出节点
最后打乱all_genos,消除顺序偏差,确保评估公平性
整体流程
get_combs(num_ops, splits)
│
└── permuteUnique() # 生成操作数量分配的所有排列
↓
comb (某个操作分配方案)
↓
get_archs(comb)
│
├── permuteUnique(op_list) # 枚举操作顺序
│
└── get_all_topos(num_nodes) # 枚举拓扑连接方式
↓
get_base_genos(topos) # 把拓扑转成占位基因
↓
组合 arch + base_geno
↓
输出 all_genos(完整基因)
这里都是查过gpt后自己的理解,如有错误,欢迎指正!