Когато създаваме модели в PyTorch, често се оказваме, че правим „това“:
def forward(self, x): return self.layer5(self.layer4(self.layer3(self.layer2(self.layer1(x)))))
Ясно е, че това е несъстоятелно. Какво ще правите, когато имате 10, 20 или 50 слоя! (nn.Sequential
, казвате? Съжалявам, вие се разделяте там.) Можете, разбира се, да го форматирате като това:
def forward(self, x): return (self.layer5 (self.layer4 (self.layer3 (self.layer2 (self.layer1 (x))))))
Красиво, нали? Но все пак не се чувства... достатъчно. Можем да се справим по-добре. Знаем, че можем да се справим по-добре.
(Да, това ще бъде публикация за неподлежащ на сертифициране проблем, чието решение съм прекомерно разработил и се чувствам в правото си да споделя с целия свят.)
Нека днес се забавляваме с претоварването на оператора, става ли?
Малко магия
Нека създадем няколко невъобразимо наименувани слоя и малко фалшиви данни, за да започнем.
import torch from torch import nn tensor = torch.randn(size=[6]) linear = nn.Linear(in_features=6, out_features=10) relu = nn.ReLU() linear2 = nn.Linear(in_features=10, out_features=1)
За да трансформираме данните, ние ги предаваме на първия слой, след което предаваме изхода на следващия слой, yada yada.
linear2(relu(linear(tensor))) >>> tensor([0.0859], grad_fn=<AddBackward0>)
Сега има няколко проблема с това. Първо, данните текат отвътре навън, което е напълно неинтуитивно и неелегантно. И освен това, този синтаксис принуждава мълчаливите визбангове да флоупват и рекуцват в slyntack-tango shuggle, докато funkadoodles frobnizzicate през байтовото жужене на тъпото ръмжене на compligobble.
Добре, има само един проблем. Полупроблем.
Това, което бихме искали, е лесен начин за свързване на слоеве на PyTorch, така че входът да тече през този тръбопровод по начин отляво надясно Sequential
.
За щастие, Python е един от онези удивителни... различни от Java езици, където можете да предефинирате вградените оператори за вашите персонализирани класове. Тогава нека го направим истински pipe
ред! Нека внедрим оператора |
(побитово ИЛИ / "тръба") за всички Module
s, за да можем да ги използваме по следния начин:
tensor | linear | relu | linear2
Начинът, по който претоварвате операторите в Python, е чрез внедряване на съответния метод ✨__magic__
✨ във вашия клас. Например, ето как бихте добавили поддръжка за добавяне към персонализиран клас:
class Love: def __init__(self, intensity=1): self.intensity = intensity def __add__(self, other): if isinstance(other, Love): total_intensity = self.intensity + other.intensity if total_intensity > 13: raise ValueError("There's only so much you can love someone, and anyone who tells you otherwise is a liar.") return Love(total_intensity) else: raise TypeError("Love can only be added to Love!") def __repr__(self): return self.intensity * "❤️" love1 = Love(5) love2 = Love(3) love1 + love2 >>> ❤️❤️❤️❤️❤️❤️❤️❤️
Страхотно, нали?
love1 + 2 >>> TypeError: Love can only be added to Love!
Както се очаква.
2 + love1 >>> TypeError: unsupported operand type(s) for +: 'int' and 'Love'
Хм… Неподдържани типове операнди?
Причината е, че __add__
, подобно на любовта, не е автоматично двустранно. Когато стартирате 2 + love1
, Python първо се опитва да извика магическия метод __add__
на първия операнд, т.е.
(2).__add__(love1)
Това се проваля, защото int
не знае какво да прави с Love
. Но преди да хвърли кърпата, Python прави последно усилие да спаси тази любов: той извиква __radd__
(„обратно добавяне“) на втория операнд:
love1.__radd__(2)
... което също се проваля, защото не го внедрихме. Надеждата умира последна, но тя умира.
Но погледнете от добрата страна – открихме два начина за внедряване на синтаксиса на тръбопровода. Или прилагаме __or__
за Tensors
, или прилагаме __ror__
за Modules
. Намирам последното за по-концептуално елегантно и освен това можем да направим крачка напред и също да приложим композиция на слоеве.
Преди да направим това, нека се уверим, че __ror__
не е внедрено за Modules
.
nn.Module.__ror__(0) >>> NotImplemented
уф. Поне няма да изхвърляме никакви съществуващи функции.
Сега, тъй като Python се въвежда динамично, можем да добавим нашия метод директно към класа Module
и да дадем на всичките му подкласове незабавно включване!
nn.Module.__ror__ = lambda self, other: self(other)
И готово!
tensor | linear | relu | linear2 >>> tensor([0.0859], grad_fn=<AddBackward0>)
Това е следващото голямо малко нещо, дами и господа. Точно така ние „направихме света по-добро място“.
Уловката (винаги има уловка) е, че ако първият операнд имплементира __or__
, той ще бъде извикан вместо това.
class Tesnor(torch.Tensor): def __or__(self, other): return "nuh-uh" tesnor = Tesnor(tensor) tesnor >>> Tesnor([-8.1880e-03, -8.1195e-04, 8.5833e-01, -6.7059e-01, 7.9056e-01, -7.9478e-01]) tesnor | linear >>> 'nuh-uh'
Всъщност булевите тензори вече използват |
като по елемент ИЛИ точно както в NumPy:
torch.tensor([True, False, True, False, False, True]) | (tensor != tensor) >>> tensor([ True, False, True, False, False, True])
За щастие, не мисля, че слоевете на невронни мрежи поддържат булеви тензори, така че не е голяма работа (но не очаквайте PR скоро).
Ако е някаква утеха, разширеното присвояване се поддържа и в двата случая по подразбиране, което ни позволява да направим това:
tensor |= linear tensor >>> tensor([-0.2337, -1.0014, -0.1931, 0.1506, -0.2764, 0.0143, 0.0510, 0.1922, 0.2969, -0.2488], grad_fn=<AddBackward0>) tesnor |= linear tesnor >>> 'nuh-uh'
Доста спретнато, струва ми се. Постигнахме първоначалната си цел, но можем да направим още.
Още малко магия
Друг очевиден кандидат за тръбен оператор е знакът за по-голямо от, >
. В Python обаче няма метод __rgt__
; вместо това трябва да отменим __lt__
. self
в този случай се отнася до по-малкия операнд (този, към който „сочи” символът за неравенство).
nn.Module.__lt__ = lambda self, other: self(other) tensor = torch.randn(size=[6]) tensor > linear >>> tensor([-1.1307, -1.5550, -1.5707, 1.2605, 0.5125, 0.1492, 0.1589, -0.5909, 0.9456, -1.2206], grad_fn=<AddBackward0>)
Обратният ред също работи, което като кандидат-лингвист и любител на етимологията просто намирам за възхитително.
linear < tensor >>> tensor([-1.1307, -1.5550, -1.5707, 1.2605, 0.5125, 0.1492, 0.1589, -0.5909, 0.9456, -1.2206], grad_fn=<AddBackward0>)
Пукнатини започват да се показват, когато се опитате да прехвърлите данни през два или повече слоя:
tensor > linear > relu >>> TypeError: '>' not supported between instances of 'Tensor' and 'Linear'
Проблемът е, че Python толкова удобно интерпретира това оковано сравнение по начина, по който би било в математиката, т.е.
tensor > linear and linear > relu >>> TypeError: '>' not supported between instances of 'Tensor' and 'Linear'
Това означава, че всъщност се опитваме да оценим (multi-element tensor) and (something)
, което веднага хвърля RuntimeError
. Все още невалидният linear > relu
дори не се оценява; операторът and
е късо съединение.
За да работи този синтаксис, трябва да добавим скоби:
((tensor > linear) > relu) > linear2 >>> tensor([0.4103], grad_fn=<AddBackward0>)
Гадост. Ето как алчността убива красотата, приятели. Не искам да навлизам в приоритета на оператора и асоциативността сега, когато имаме два допълнителни начина за извикване на модули.
Състав на модула
Няма ли да е страхотно, ако можем да използваме същия синтаксис, за да направим тръбопроводи за многократна употреба? Ще можем да създаваме невронни мрежи в една линия!
dense_nn = nn.Linear(32, 64) | nn.Tanh() | nn.LazyLinear(10) | nn.Softmax(dim=1)
Това очевидно винаги е желателно и по никакъв начин не засяга четливостта или поддръжката. Само ако имаше начин да се съхраняват последователности от модули като един модул, който да работи добре с останалата част от PyTorch...
Е, случайно знам „точния инструмент“ за работата:
def chain(self: nn.Module, other: nn.Module | torch.Tensor): try: return self(other) # assume other is a Tensor except TypeError: try: return nn.Sequential(*other, self) # flatten nested Sequentials except TypeError: return nn.Sequential(other, self) # other is not a list, pass directly nn.Module.__ror__ = chain linear | relu | linear2 >>> Sequential( (0): Linear(in_features=6, out_features=10, bias=True) (1): ReLU() (2): Linear(in_features=10, out_features=1, bias=True) )
Тъй като Sequential
само по себе си е Module
, можем да го наречем така:
tensor | linear | relu | linear2 >>> tensor([0.4103], grad_fn=<AddBackward0>)
… или така:
(linear | relu | linear2)(tensor) >>> tensor([0.4103], grad_fn=<AddBackward0>)
О, добре. Цялото това снобско Sequential
-разправяне само за да стане синтактична захар накрая. Каква загуба на време на всички.
Но сериозно, въпреки че това може да не революционизира дълбокото обучение, то добавя нотка на елегантност и удобство. Готино е. Има „легитимни аргументи“ срещу претоварването на операторите, особено в производствения код, но беше страхотно да се опита. Обикновено това е причината да трябва да направите нещо.
А сега, за нещо наистина прокълнато...
# stack = nn.Sequential(linear, relu, linear2) stack = linear | relu | linear2 stack(tensor) >>> tensor([0.4103], grad_fn=<AddBackward0>) tensor(stack) >>> TypeError: 'Tensor' object is not callable torch.Tensor.__call__ = lambda self, other: other(self) tensor(stack) >>> tensor([0.4103], grad_fn=<AddBackward0>)