Чивиня (Multi-layer Parkinson)
42 subscribers
16 photos
3 videos
3 files
45 links
Обсуждение: https://t.me/chivinyachat
Download Telegram
Запускал обучение сетки из 1 млн. линейных слоёв. Взял LR побольше: 3e-7. результаты сильно хуже:
Test set: Average loss: 1.8179, Accuracy: 4412/10000 (44%)
Test set: Average loss: 1.7524, Accuracy: 4307/10000 (43%)
Test set: Average loss: 1.7991, Accuracy: 3730/10000 (37%)
Test set: Average loss: 1.7636, Accuracy: 3763/10000 (38%)
Test set: Average loss: 1.8692, Accuracy: 2959/10000 (30%)
Решил воспользоваться идеей @kraidiky по периодическому поиску оптимальной скорости обучения. Только искать не один learning rate, но ещё и weight decay. Причём отдельно для свёрток и отдельно для MLP, а также отдельно для весов и отдельно для сдвигов. Получилось 4 пары LR и WD. В пределе можно было бы искать их для каждого слоя, но у меня сетка примитивная. Прототипом в очередной раз послужил код из примеров Торча: https://github.com/pytorch/examples/tree/main/mnist . Если брать сети вроде ResNet-ов и EfficientNet-ов, то там очень большое пространство подбора и надо подумать эффективно его делать. Возможно внутри блока свёрток будут разные LR и WD, и возможно они будут разными у каждой группы блоков, работающих с одним разрешением.

Исходный скрипт имеет оптимизатор Adadelta, который судя по его описанию сам подбирает оптимальный LR для каждого веса. С другими учится сильно хуже. Проблема данной архитектуры сети в том, что для MLP нужен LR раз в 10-20 больше, чем для свёрток. Так происходит потому, что архитектура сети нестандартная, в ней нет пулинга полностью схлопывающего ширину и высоту:
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv1 = nn.Conv2d(1, 32, 3, 1)
self.conv2 = nn.Conv2d(32, 64, 3, 1)
self.dropout1 = nn.Dropout(0.25)
self.dropout2 = nn.Dropout(0.5)
self.fc1 = nn.Linear(9216, 128)
self.fc2 = nn.Linear(128, 10)

def forward(self, x):
x = self.conv1(x)
x = F.relu(x)
x = self.conv2(x)
x = F.relu(x)
x = F.max_pool2d(x, kernel_size=2)
x = self.dropout1(x)
x = torch.flatten(x, start_dim=1)
x = self.fc1(x)
x = F.relu(x)
x = self.dropout2(x)
x = self.fc2(x)
return x


Перед каждой эпохой я добавил поиск оптимальных learning rate и weight decay. Спомощью Optuna я обучал 30 сетей, клонированных из текущей, и подбирал 4 пары LR и WD, которые потом использовал для обучения одной эпохи. Всего было 100 эпох. На MNIST-е отработало быстро.

Исходный код давал accuracy 99.25% на 14 эпохах. Если учить дольше, то получалось иногда 99.3%.
С подбором оптимальных LR и WD после 66-ой эпохе получилось достичь 99.43%, что не мало. И само обучение шло в начале быстрее.

Вот таблица с данными, полученными при обучении 100 эпох. Там же есть разные графики. https://docs.google.com/spreadsheets/d/1PrkH42b10jFQ8ExgRhFixocoS3n8hALLLVwOU7NOLzE/edit?usp=sharing .

Ещё один важный момент: сеть не переобучается. Т.е. фактически переобучение происходит тогда, когда мы используем неподходящие LR и WD. Возможно в этом утверждении я не прав, но на графиках переобучения не видно.

У подхода есть и минусы. Возможно существуют такие LR и WD, которые дают на одной эпохе плохое значение метрики, но на нескольких эпохах - значительно лучшее. И это никак не узнать.
👍1
Повторил вчерашний эксперимент и добавил данные с графиками туда же на отдельный лист: https://docs.google.com/spreadsheets/d/1PrkH42b10jFQ8ExgRhFixocoS3n8hALLLVwOU7NOLzE/edit?usp=sharing

Обучилось хуже. И ИМХО видны причины такого обучения: это высокий LR у bias-ов свёрток.

Сейчас интервал поиска нового параметра меняется от 1/3 до 3 от старого значения. Возможно стоит этот интервал увеличить, чтобы не происходило подобного залипания на высоком LR, а могло скатиться к маленькому. Хотя тогда графики станут более дёрганными.

Переобучения, кстати, опять не видно. Может, конечно, сетка очень маленькая...
Во время криво настроенных экспериментов выяснилось, что сетка вполне себе может обучаться с learning rate 1e+16 и weight decay 5e+23! Конечно она пришла к таким огромным величинам не сразу, но пришла. И при них вполне себе удерживала accuracy 99.33% на MNIST-е .

Из этого можно сделать вывод, что неверный LR можно компенсировать WD.
🤔21🔥1
Провёл ещё 3 эксперимента с подбором оптимального LR и WD каждую эпоху. Данные и графики добавил на отдельные листы https://docs.google.com/spreadsheets/d/1PrkH42b10jFQ8ExgRhFixocoS3n8hALLLVwOU7NOLzE/edit?usp=sharing .

После каждой эпохи сеть обучалась 100 раз на одну эпоху и выбирались оптимальные значения LR и WD. Т.е. в последние два эксперимента было обучено по 100 тыс. эпох. Эффективнее всего оказалось не смотреть на предыдущие значения LR и WD, как я делал до этого, а выбирать их каждый заново из заранее определённого интервала. Я использовал интервал от 1e-8 до 1e-2. Графики выходят ужасными и пока не понял, как их сгладить в гугл-докс, но зато сеть быстро достигает наилучших значений метрики.

И если посмотреть на графики всех пяти экспериментов, то снова видно полное отсутствие переобучения. Для MNIST-а 1000 эпох вполне достаточно, чтобы оно проявилось хоть как-то.
Было время пообучать сетку в миллион линейных слоёв. Добавил данные по LR=3e-8:
lr=3e-7:
Test set: Average loss: 1.8179, Accuracy: 4412/10000 (44%)
Test set: Average loss: 1.7524, Accuracy: 4307/10000 (43%)
Test set: Average loss: 1.7991, Accuracy: 3730/10000 (37%)
Test set: Average loss: 1.7636, Accuracy: 3763/10000 (38%)
Test set: Average loss: 1.8692, Accuracy: 2959/10000 (30%)

lr=1e-7:
Test set: Average loss: 1.5966, Accuracy: 5306/10000 (53%)
Test set: Average loss: 1.5637, Accuracy: 5415/10000 (54%)
Test set: Average loss: 1.5002, Accuracy: 5640/10000 (56%)

lr=3e-8:
Test set: Average loss: 1.5202, Accuracy: 5493/10000 (55%)
Test set: Average loss: 1.4754, Accuracy: 5743/10000 (57%)
Test set: Average loss: 1.4550, Accuracy: 5795/10000 (58%)
Test set: Average loss: 1.4516, Accuracy: 5940/10000 (59%)
Test set: Average loss: 1.4456, Accuracy: 5930/10000 (59%)
Test set: Average loss: 1.4228, Accuracy: 6075/10000 (61%)
Test set: Average loss: 1.4007, Accuracy: 6107/10000 (61%)
Подумалось...
L1-регуляризация обычно делается через лос-функцию примерно так:
l1_loss = sum(p.abs().sum() for p in model.parameters())

Потом этот лосс прибавляют с каким-то коэффициентом к тому лосу, который используется для обучения, считают значения градиентов и оптимизатором меняют веса.

Но ведь L1-лос можно взять отдельно и считать его тоже отдельно, например, после обновления весов. Очевидно, что значения градиентов от такого лоса будут равны единице. И можно вообще обойтись без расчёта градиентов, а просто пройтись по всем весам и сделать так:
w -= 1e-6

Точнее с учётом того, что значения весов могут быть отрицательными и меньше 1e-6, то вот так:
w -= w.sign()*min(w.abs(), 1e-6)

где 1e-6 - это аналог описанного выше коэффициента у L1-лоса, помноженного на LR, который использовался бы в оптимизаторе.

Возможно я ошибаюсь, то такая L1-регуляризация даст в итоге больше нулевых весов, не будет мешать обучению, направляя оптимизатор в ненужную сторону, быстрее варианта через лос-функцию.
👍1
Пробую через self supervised learning (как это делают в DINO https://arxiv.org/pdf/2104.14294 ) учить несколько подряд идущих свёрток. Но не как в DINO - всю сеть целиком, а каждую по отдельности.
Оказалось, что учить всю сеть целиком на пару порядков быстрее, чем отдельно её кусочки.
Оказалось, что так же как и в целиковой сети - сначала учатся последние слои. Видимо входной сигнал сильно сложнее, чем он же, но прошедший через несколько случайно инициализированных свёрток. Изначально у меня было предположение, что будет наоборот или же все начнут учиться одновременно.
Оказалось, что оптимальный LR сильно отличается в каждой свёртке. Но общее в том, что при LR=3e-4 (рекомендованный Andrej Karpathy) обычно начинает обучаться, а уже потом оптимальный LR обычно падает. Оптимальный - этот тот, на котором loss на тренировочной выборке в среднем падает.
Подтвердилось, что градиенты у bias-ов на пару порядков больше, чем у weight и потому для bias-ов LR надо в 100 раз меньше делать. А более общем случае надо конечно разделять свёртки на два слоя: в одном свёртка без bias-а, во втором - только bias для каждого канала.
👍1
Подумалось, что основной отличительной чертой настоящего ИИ будет не так всеми ожидаемая мудрость, а возможность оперировать со сложностью, недоступной человеку. Как калькулятор смог быстро работать с большими числами, так и ИИ сможет посчитать количество листьев на дереве, получив на то устную команду.
А истинная мудрость долго останется за человеком. Хотя находясь рядом с ИИ человеку будет проще отделить её от суррогатов.
👍1
Росс пишет про эволюцию в обучении свёрточных сетей: https://huggingface.co/blog/rwightman/mobilenet-baselines . Как видно, заметную часть составляют изменения того, что никто никогда и не думал менять.
👍2
Прежде я очень смутно понимал, зачем в блоках свёртки наращивают количество каналов, а потом уменьшают. Тогдашняя трактовка звучала примерно так: "Чтобы можно было заколодовать в весах более сложные связи".

Текущее понимание такое: нейросети проще обучиться. Ей легче подогнать свои веса. Первая фаза запоминания датасета проходит быстрее. А в фазе обобщения изменение одних весов слабее влияют на изменение других. И проще найти те веса, которые быстрее приведут к уменьшению лоса. И сами эти две фазы видны глазами.

Всё это легко представить. Берём плоскость и на ней несколько точек, к которым нейросеть должна при обучении максимально приблизиться. И изначально так инициализируем веса, чтобы нейросеть на плоскости рисовала прямую линию (это не обязательно, но для визуализации полезно). И начинаем наше обучение и с очень низким LR. Мы увидим, как наша прямая линия начинает ломаться в нескольких точках и становится ломанной (если активация у нас ReLU). Далее становится очевидным, что чем больше переломов на этой линии, тем проще подогнать веса так, чтобы ломанная проходила через заданные точки на плоскости. Если отдельные наши точки сгруппированы в какой-то одной области, а там переломов недостаточно, то нейросети будет тяжело сжаться именно под эту группу точек. Она уже подогналась под другие и они не пускают её.

Так же и после обучения у нас получается ломанная. Она проходит через заданные точки, но и между ними изгибается, хотя от неё этого не требовали. Прунинг с файнтюнингом как раз и убирает эти лишние изгибы, приближая нас к решению, когда число параметров нейросети близко к количеству заданных точек.
👍2
Вдогонку к предыдущему посту...
Чтобы ломанная стала кривой достаточно в начале сети добавить какую-то активацию с криволинейным графиком, например swish. Остльные активации можно оставить ReLU. Тогда учится чуть лучше, ибо у сети добавляется ещё одно свободное измерение: она может пытаться искривлять кривую и это позволяет проще сжимать или растягивать пространство.
Провожу сейчас эксперимент.

Свёрточная сеть, которая состоит из одной свёртки с ядром 3х3, которая применяется в цикле, уменьшая размер входа на два пикселя каждый раз. Когда размер уменьшится до 7х7, делается flatten() и потом линейный слой. После каждой эпохи подбирается LR для каждого weight и bias, дающий минимальной лосс на валидации. Без этого подбора сеть невозможно обучить, ибо через свёртку сигнал проходит очень много раз, а через линейный слой - единожды и их LR в теории должны отличаться. На практике вышло иначе.

Вот что заметил: значения LR скачут вверх-вниз, часто стараясь приблизиться к нижней границе вроде 1e-10. Т.е. фактически происходит полноценное обучения только отдельных слоёв, а обучение других сильно замедляется.

Можно выдвинуть гипотезу: для ускорения обучения нужно учить не всю сеть, а только её отдельные её слои, периодически меняя их. Интересный вопрос: как понять, какой слой сейчас учить, а какой нет. Может W*W.grad тут поможет тоже, как при прунинге?

Вторая гипотеза: наверное можно научиться рассчитывать оптимальный LR для каждого слоя. А может и для каждого веса в отдельности.
🔥3👍1
Придумал возможно неплохую замену пулинга.
Одна свёртка с ядром 3х3, которая применяется до тех пор, пока размер её выхода не станет N*N. Если вход такой свёртки меньше, чем N+2*N+2, то предварительно дополняем вход с нужных сторон нулями. Потом делаем Flatten() и далее линейный слой Linear(N*N*C, num_classes). Такая конструкция позволяет линейному слою видеть отдельные пиксели, что очень полезно. И при этом сохраняется способность свёрточных сетей работать с любым разрешением, хотя это надо проверять или специально учить на разных разрешениях.
N можно брать равным 7-8, например.

Проблема только в текущей архитектуре большинства свёрточных сетей. Они в распушаются ближе к концу на пару-тройку тысяч каналов. Получится, что надо сначала снизить число каналов до 64 хотя бы, например свёрткой с ядром 1х1.
В слое свёртки есть число групп. Это то, на сколько групп делится вход свёртки, прежде чем применяется свёртка. Потом выходы конкатенируются. Группы сильно ускоряют свёртки и активно применяются в свёрточных сетях для мобильных устройств.

Мне странно, что до сих пор никто не предложил реализовать группы для обычного линейного слоя. Так же разбиваем вход на несколько частей по последнему измерению, делаем линейное преобразование (разное для каждой группы) и потом конкатенируем выходы.

Линейный слой с группами позволяет работать с входом больших размеров, не сильно потребляя ресурсы.

Я провёл несколько экспериментов с resnet18 на датасете Imagewoof2 ( https://github.com/fastai/imagenette?tab=readme-ov-file#imagewoof ). Там 10 классов с похожими породами собак, взятыми из Imagenet-а. У resnet18 выход размера (batch, 512, 7, 7).
Сравнивал три типа пулингов:
1) обычный пулинг avg (обёртка вокруг torch.nn.functional.adaptive_avg_pool2d());
2) замену его на линейный слой linear (b,c,h,w решейпим в b, c*h*w и передаём в nn.Linear(7*7*512, 512));
3) замену пулинга на линейный слой с 16 группами linear-groups (b,c,h,w решейпим в b, c*h*w , потом делим на 16 частей и передаём в 16 слоёв nn.Linear(7*7*512//16, 512//16), потом torch.cat(outputs, dim=1)).
После всех видов пулинга шёл обычный линейный слой nn.Linear(512, 10). Слоя активации между слоями пулинга и линейным слоем не было. Я его забыл. 😊

Пулинг с linear-groups чуть хуже показал себя при заморозке основной сети. Видимо какие-то важные фичи оказывались в разных группах, не смогли просочиться через снижение размерности и дойти до FC. Но gулинг с linear-groups оказался лучшим, когда сеть тренировалась с нуля. Файнтюнить не пока пробовал. И не совсем ясно, как интерпретировать результаты после файнтюнинга.

Что меня удивило, просто линейный слой увеличил количество весов у сети почти вдвое, но при обучении всей сети с нуля, это слабо повлияло качество. А при обучении только пулинга и FC - дало лучший результат. Как будто такому пулингу не хватало при обучении разнообразия данных.

При обучении использовал train.py из timm, меняя аргумент скриптв —gp . Гиперпараметры брал из https://arxiv.org/abs/2110.00476 : python3 train.py imagewoof2-320/ --model resnet18 --amp --batch 192 --lr 3.3e-3 --epochs 3000 --weight-decay 0.01 --sched cosine --train-interpolation bicubic --crop-pct 0.95 --smoothing 0.1 --warmup-epochs 5 --aa rand-m7-n3-mstd1.0-inc1 --seed 0 --opt adamp --warmup-lr 1e-6 --drop-path 0.05 --drop 0.1 --reprob 0.35 --mixup .2 --cutmix 1.0 --bce-loss --num-classes 10
resnet18

Учились только пулинг и FC. 600 эпох

avg
params:11181642
"best": {
"epoch": 223,
"train": {
"loss": 0.22416554137747338
},
"validation": {
"loss": 0.3067730319245106,
"top1": 92.87350753780296,
"top5": 99.56732228998314
}
}

linear
params:24027210
"best": {
"epoch": 577,
"train": {
"loss": 0.2400615208960594
},
"validation": {
"loss": 0.369677066992609,
"top1": 93.07712204628059,
"top5": 99.46551471728652
}
}

linear-groups
params: 11984970
"best": {
"epoch": 518,
"train": {
"loss": 0.2248994378333396
},
"validation": {
"loss": 0.31120124888969275,
"top1": 92.7207971418987,
"top5": 99.56732154432585
}
}


Училось всё с самого начала. 600 эпох

avg
params:11181642
"best": {
"epoch": 598,
"train": {
"loss": 0.18741627560651047
},
"validation": {
"loss": 0.3714630286804014,
"top1": 90.50649261086247,
"top5": 98.62560719805353
}
}

linear
params:24027210
"best": {
"epoch": 586,
"train": {
"loss": 0.19136825647759945
},
"validation": {
"loss": 0.37503323835797453,
"top1": 90.22652338600304,
"top5": 98.54925189135972
}
}

linear-groups
params: 11984970
"best": {
"epoch": 539,
"train": {
"loss": 0.2023189218437418
},
"validation": {
"loss": 0.37809756763808505,
"top1": 90.55739677003942,
"top5": 98.82922085213218
}
}

Училось всё с самого начала. 3000 эпох.

avg
params:11181642
"best": {
"epoch": 2410,
"train": {
"loss": 0.17291559152146604
},
"validation": {
"loss": 0.3654449778557066,
"top1": 91.39730455335936,
"top5": 98.4219927983176
}
}

linear
params:24027210
"best": {
"epoch": 2824,
"train": {
"loss": 0.1703560750218148
},
"validation": {
"loss": 0.37178307608600547,
"top1": 91.37185314175554,
"top5": 98.11657157154234
}
}

linear-groups
"best": {
"epoch": 2524,
"train": {
"loss": 0.16850097937152742
},
"validation": {
"loss": 0.3603533955590056,
"top1": 91.60091879775003,
"top5": 98.2183781344947
}
}

linear-groups + swish
"best": {
"epoch": 2336,
"train": {
"loss": 0.17537949963452967
},
"validation": {
"loss": 0.36147605745535705,
"top1": 91.19369144298916,
"top5": 98.47289642932064
}
}
Вот работающий код линейного слоя с группами. И функция для примерного расчёта количества групп при пулинге в зависимости от размера входного тензора:

class Linear(torch.nn.Module):
def __init__(
self,
in_features: int,
out_features: int,
groups: int = 1,
bias: bool = True,
device=None,
dtype=None
) -> None:

if groups <= 0:
raise ValueError('groups must be a positive integer')
if in_features % groups != 0:
raise ValueError('in_features must be divisible by groups')
if out_features % groups != 0:
raise ValueError('out_features must be divisible by groups')

factory_kwargs = {'bias':bias, 'device': device, 'dtype': dtype}

super().__init__()
self.in_features = in_features
self.out_features = out_features
self.groups = groups
self.linears = torch.nn.ModuleList([torch.nn.Linear(in_features//groups, out_features//groups, **factory_kwargs) for i in range(groups)])
self.reset_parameters()

def reset_parameters(self) -> None:
for linear in self.linears:
linear.reset_parameters()

def forward(self, input: torch.Tensor) -> torch.Tensor:
outputs = []
step = self.in_features//self.groups
for i, linear in enumerate(self.linears):
start = i*step
outputs.append(linear(input[:, start:start + step]))

return torch.cat(outputs, dim=1)

def extra_repr(self) -> str:
return f'in_features={self.in_features}, out_features={self.out_features}, groups={self.groups}, bias={self.bias is not None}'

def groups(c,h,w):
for i in range(1, c):
if c/i == c//i:
if c*h*w/i <= 2048:
return i

return 1
Добавил после пулинга linear-groups слой активации swish и обучил всю сетку с нуля на 3000 эпох. Может это статистические флуктуации, но и accuracy и loss на валидации оказались хуже, чем без активации. Цифры добавил в конец в пост https://t.me/chivinya/61

Но если активация в данном случае и вправду чуть портит сеть, то польза забывчивости и невнимательности при экспериментах несомненна. 😊
👏1👌1
Naver выпустил свою версию DenseNet под названием Revitalized DenseNets (RDNet) https://arxiv.org/pdf/2403.19588 . Они придумали фактически новую архитектуру свёрточных сетей.

Раньше сеть у нас выглядела как этакая расширяющаяся по числу каналов труба (VGG). Потом придумали блоки свёрток, увеличивающие внутреннюю размерность и потом тут же уменьшающие её (MobileNet), труба покрылась пилообразными выступами. В Naver придумали более общий вариант блоков (DenceBlock), когда размерность медленно увеличивается (у них три таких увеличения происходит), потом схлопывается. И так много раз. И при расширении каналы конкатенируются к входному сигналу. Внутри вместо свёрток применяются блоки свёрток. Т.е. dance-блоки из свёрточных блоков, группирующиеся в stages. :-)

Но основная идея - это постепенно увеличивать размерность сигнала через конкатенацию и потом схлопывать его. И так много раз. Труба трубы в трубе, короче 😊 При этом удаётся избежать сильного расширения размерности, т.е. числа каналов.