nn.Linear(...)?

저를 포함하여 PyTorch를 사용하는 대부분은 아래처럼 필요한 torch 관련 패키지를 import 하여 사용하는 것에 아주 익숙할 것입니다

import torch
from torch import nn

m = nn.Linear(20, 30)
input = torch.randn(128, 20)
output = m(input)
print(output.size())

nn 패키지에서는 Linear 뿐만 아니라 PyTorch에서 제공하는 다양한 Layer (e.g., Dropout, BatchNorm 등)과 Loss (e.g., KLD) 그리고 Container (ModuleList) 등을 사용할 수 있는데요. 어느 날 회사 업무 중 PyTorch 내부 코드 및 구조를 살펴볼 일이 있었고, 그 과정에서 약간은 의아한 부분을 확인할 수 있었습니다

분명히 저희는 nn.Linear와 같은 형태로 필요한 레이어를 import 하고 있는데, 실제로 PyTorch의 nn 디렉터리 구조를 확인했을 때 Linear, Conv2d와 같은 코드는 찾아볼 수가 없습니다. 그렇다면 어떻게 우리는 간단하게 nn.Linear와 같이 필요한 레이어를 가져와서 사용할 수 있는 것일까요? 해당 내용을 설명하기 위해서는 우선 Python에서의 packaging 방식인 Regular package와 Namespace package에 대해서 먼저 설명해야 할 것 같습니다

Regular package vs. namespace package

Python 공식 문서에서 설명하는 package 챕터의 설명을 가져와 보겠습니다

Packages are a way of structuring Python’s module namespace by using “dotted module names”

파이썬에서 패키지는 모듈의 네임스페이스를 dot 구분자를 이용해서 구성하는 방식이라고 얘기할 수 있겠습니다. 예를 들어 A.B는 A 패키지의 B라는 서브 모듈을 얘기하는 것이라고 할 수 있습니다. 공식 문서에서 설명하는 예제는 음향 관련 처리를 하는 패키지의 예제 구조이고 각 계층 구조마다 우리는 __init__.py가 포함되어있다는 점을 생각해 두면 좋겠습니다

sound/                          Top-level package
      __init__.py               Initialize the sound package
      formats/                  Subpackage for file format conversions
              __init__.py
              wavread.py
              wavwrite.py
              aiffread.py
              aiffwrite.py
              auread.py
              auwrite.py
              ...
      effects/                  Subpackage for sound effects
              __init__.py
              echo.py
              surround.py
              reverse.py
              ...
      filters/                  Subpackage for filters
              __init__.py
              equalizer.py
              vocoder.py
              karaoke.py
              ...

우리는 위와 같이 소스 코드 (*.py)를 포함하는 디렉터리가 __init__.py를 함께 포함하는 경우 Regular package라고 합니다

A traditional package, such as a directory containing an __init__.py file.

반대로 __init__.py를 포함하지 않고, 단순히 서브 패키지의 컨테이너로 사용하는 방식을 Namespace package라고 합니다

A PEP 420 package which serves only as a container for subpackages. Namespace packages may have no physical representation, and specifically are not like a regular package because they have no __init__.py file.

Python 3.3에서 도입된 Namespace package 이전에는 Regular package에서 요구하는 것처럼 각 디렉터리마다 __init__.py가 존재하지 않으면 Module Import Error가 발생했었습니다. 즉, 다시 말해서 Python 3.3부터는 __init__.py가 존재하지 않더라도 우리가 흔히 사용하는 방식의 import A.B와 같이 모듈을 import 할 수 있게되었습니다. 여기까지만 보면 결국 __init__.py 무엇인가 역할을 한다는 것을 유추해볼 수 있습니다. 추가로 __init__.py에 대해서 더 자세히 알아보기 전에 Python에서 권고하는 방식에 대해서 한 번 더 짚고 가면 좋겠습니다 link

Technically, you can also create Python packages without an __init__.py file, but those are called namespace packages and considered an advanced topic (not covered in this tutorial). If you are only getting started with Python packaging, it is recommended to stick with regular packages and init.py (even if the file is empty).

https://peps.python.org/pep-0420/#rationale 의 예시와 같이 드물게 Namespace package가 유리할 때가 있다는 점만 우선은 기억하고 넘어가겠습니다

__init__.py?

패키지의 __init__.py는 다양한 용도로 사용할 수가 있는데요
점프 투 파이썬 (link)의 예제가 잘 되어 있으므로 해당 예제를 인용하여 설명하겠습니다

가장 간단하게는 package 수준의 변수나 함수를 __init__.py에 정의할 수 있습니다

# C:/doit/game/__init__.py
VERSION = 3.5

def print_version_info():
    print(f"The version of this game is {VERSION}.")

위와 같이 VERSION 변수와 print_version_info()는 패키지 레벨의 변수와 함수이며, game 패키지를 import 후 사용할 수 있습니다

>>> import game
>>> print(game.VERSION)
3.5
>>> game.print_version_info()
The version of this game is 3.5.

두 번째가 저희의 궁금증인 nn.Linear와 관련 있는 부분인데요, 패키지 내 모듈을 미리 import 하는 목적으로 사용할 수 있습니다

# C:/doit/game/__init__.py
from .graphic.render import render_test

VERSION = 3.5

def print_version_info():
    print(f"The version of this game is {VERSION}.")

위와 같이 game의 __init__.py에서 하위 패키지인 graphic.render의 render_test를 미리 임포트를 하면 사용자는 아래와 같이
game 패키지만 import 하고 render_test() 메서드를 바로 호출 할 수 있습니다

import game
game.render_test()

세 번째는 패키지 초기화 시 사용할 로직이나 작업을 포함할 수 있습니다. 자세한 내용은 레퍼런스의 점프 투 파이썬을 참고하세요
마지막으로 __all__에 대한 내용을 이해하면 nn.Linear의 동작 원리를 알 수 있을 것 같습니다

>>> from game.sound import *
Initializing game ...
>>> echo.echo_test()
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
NameError: name 'echo' is not defined

다음과 같이 import * 형태로 특정 패키지 아래의 모든 모듈을 import 하고 싶을 수 있습니다
해당 로직이 정상적으로 동작하려면 해당 디렉터리의 __init__.py__all__ 변수의 값을 채워줘야합니다

# C:/doit/game/sound/__init__.py
__all__ = ['echo']

__init__.py에서 하위 패키지의 모듈을 미리 import 할 수 있다, 그리고 필요하다면 __all__ 변수를 적절히 활용할 수 있다는 점을 기억하면 이제 궁금했던 내용이 해결될 것 같습니다

Back to nn.Linear

앞부분에서 __init__.py의 다채로운 용도를 확인했으니, 이제 우리는 어떻게 nn.Linear 형태의 임포트가 가능한지 어느 정도 알 수 있을 것 같은데요. 직접 nn 디렉터리의 __init__.py를 확인해보겠습니다

from .modules import *  # noqa: F403
from .parameter import (
    Parameter as Parameter,
    UninitializedParameter as UninitializedParameter,
    UninitializedBuffer as UninitializedBuffer,
)
from .parallel import DataParallel as DataParallel
from . import init
from . import functional
from . import utils
from . import attention

위와 같이 __init__.py에서 여러 서브 모듈들을 사전에 import 하는 것을 확인할 수 있습니다. 아마도 우리가 주로 사용하는 Layer 들은 modules 하위에 있을 것 같고, import * 이므로 __all__에 적절히 정의되어 있을 것 같습니다. 그 외에 parameter 하위의 Parameter, UninitializedParameter, UninitializedBuffer 역시 __init__.py에서 임포트하므로 nn.UninitializedBuffer와 같이 사용할 수 있음을 눈치채셨을 것 같네요. 이제 다시 modules 디렉터리를 좀 더 살펴보겠습니다

예상했던대로 modules 하위에 conv.py, linear.py와 같은 모듈들이 존재하고 있음을 확인했습니다. modules의 __init__.py는 다음과 같이 정의되어있네요

from .module import Module
from .linear import Identity, Linear, Bilinear, LazyLinear
# 이하 중략

__all__ = [
    'Module', 'Identity', 'Linear', 'Conv1d', 'Conv2d', 'Conv3d', 'ConvTranspose1d',
    # ... 중략
]

__all__에 클래스들의 이름이 정의되어있고, 이를 통해 우리는 위의 from .modules import *에서 쉽게 Linear를 포함한 레이어 클래스들을 가져올 수 있다는 것을 이제 확실히 알게 되었습니다! 디렉터리 구조가 깊어지면 import 구문도 마찬가지로 길어지고, 이는 휴먼 에러를 만들 수 있는데요. 이런식으로 패키지와 __init__.py를 잘 활용하면 간결한 import가 가능하도록 만들 수 있습니다

Conclusion

이번 포스트를 통해서 아래와 같은 내용들을 다뤄봤습니다

  • Regular package vs. Namespace package
  • __init__.py의 용도
  • PyTorch 패키지의 디렉터리 구조와 원리

아래에는 해당 포스트에서 인용한 레퍼런스들을 첨부했으니 필요하다면 해당 링크들을 참고하세요

References