Когато създаваме модели в 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ред! Нека внедрим оператора | (побитово ИЛИ / "тръба") за всички Modules, за да можем да ги използваме по следния начин:

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>)