Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 465 – 행렬 곱셈 전용 중위 연산자

Author:
Nathaniel J. Smith <njs at pobox.com>
Status:
Final
Type:
Standards Track
Created:
20-Feb-2014
Python-Version:
3.5
Post-History:
13-Mar-2014
Resolution:
Python-Dev message

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

이 PEP는 행렬 곱셈에 사용할 새로운 이항 연산자 @를 제안합니다. (기억법: @는 mATrices(행렬)를 위한 *입니다.)

명세

Python 언어에 새로운 이항 연산자가 대응하는 제자리(in-place) 버전과 함께 추가됩니다:

연산자 우선순위/결합성 메서드
@ *와 동일 __matmul__, __rmatmul__
@= 해당 없음 __imatmul__

내장 타입이나 표준 라이브러리 타입에는 이 메서드들의 구현이 추가되지 않습니다. 그러나 여러 프로젝트가 이 연산들의 권장 의미론에 대해 합의에 이르렀습니다. 자세한 내용은 아래의 Intended usage details를 참조하십시오.

이 연산자가 CPython에서 어떻게 구현될지에 대한 자세한 내용은 Implementation details를 참조하십시오.

동기

핵심 요약

수치 코드에서는 Python의 * 연산자를 두고 경쟁하는 두 가지 중요한 연산이 있습니다. 바로 요소별 곱셈과 행렬 곱셈입니다. Numeric 라이브러리가 처음 제안된 이후 거의 20년 동안 이 긴장을 해소하려는 시도가 많이 있었지만 [13], 어느 것도 진정으로 만족스럽지는 않았습니다. 현재 대부분의 수치 Python 코드는 요소별 곱셈에 *를, 행렬 곱셈에는 함수/메서드 문법을 사용합니다. 그러나 이 방식은 흔한 상황에서 보기 흉하고 읽기 어려운 코드로 이어집니다. 이 문제는 상당히 심각해서 상당한 양의 코드가 여전히 반대 관례(다른 상황에서 보기 흉하고 읽기 어려운 코드를 만들어 낸다는 장점이 있는 관례)를 사용하고 있으며, 이렇게 코드베이스마다 API가 파편화되면서 또 다른 문제들이 생겨납니다. 현재 Python 문법 안에서 수치 API를 설계하는 문제에는 좋은 해결책이 없는 것으로 보입니다. 저마다 다른 방식으로 나쁜 선택지들만 펼쳐져 있을 뿐입니다. 이러한 문제를 해결하기에 충분한 최소한의 Python 문법 변경은 행렬 곱셈을 위한 새로운 중위 연산자 하나를 추가하는 것입니다.

행렬 곱셈에는 다른 이항 연산과 구별되는 특징들이 독특하게 결합되어 있으며, 이 특징들이 함께 전용 중위 연산자를 추가해야 할 유례없이 설득력 있는 근거가 됩니다:

  • 기존의 수치 연산자와 마찬가지로, 수학, 과학, 공학의 모든 필드에는 행렬 곱셈에 중위 표기법을 사용하는 것을 뒷받침하는 방대한 선행 사례가 존재합니다. @는 Python의 기존 연산자 체계에 있는 빈자리를 조화롭게 메웁니다.
  • @는 실제 코드를 훨씬 명료하게 만듭니다.
  • @는 읽기 어려운 코드와 API 파편화로 특히 큰 피해를 보는, 경험이 적은 사용자들에게 더 순탄한 진입로를 제공합니다.
  • @는 상당하고 점점 늘어나는 Python 사용자 커뮤니티의 일부에 이득이 됩니다.
  • @는 자주 사용될 것입니다 – 실제로 여러 증거에 따르면 //나 비트 연산자보다 더 자주 사용될 수도 있습니다.
  • @를 통해 Python 수치 커뮤니티는 파편화를 줄이고, 마침내 모든 수치 배열 객체에 대해 합의된 단일 덕 타입으로 표준화할 수 있습니다.

배경: 현재 상황의 문제는 무엇입니까?

컴퓨터로 수치 계산을 할 때는 보통 다뤄야 할 숫자가 아주 많습니다. 이 숫자들을 한 번에 하나씩 처리하려고 하면 번거롭고 느립니다. 인터프리터 언어를 사용할 때는 특히 그렇습니다. 그 대신 우리는 큰 숫자 모음 전체에 한꺼번에 적용되는 간단한 연산을 작성할 수 있기를 바랍니다. n차원 배열은 널리 쓰이는 모든 수치 계산 환경이 이를 가능하게 하려고 사용하는 기본 객체입니다. Python에는 이러한 배열을 제공하는 라이브러리가 여러 개 있으며, 현재 가장 대표적인 것은 numpy입니다.

n차원 배열을 다룰 때 곱셈을 정의하는 방식은 두 가지가 있을 수 있습니다. 하나는 요소별 곱셈이고:

[[1, 2],     [[11, 12],     [[1 * 11, 2 * 12],
 [3, 4]]  x   [13, 14]]  =   [3 * 13, 4 * 14]]

다른 하나는 matrix multiplication입니다:

[[1, 2],     [[11, 12],     [[1 * 11 + 2 * 13, 1 * 12 + 2 * 14],
 [3, 4]]  x   [13, 14]]  =   [3 * 11 + 4 * 13, 3 * 12 + 4 * 14]]

요소별 곱셈은 느리고 번거로운 for 루프를 작성하지 않고도 큰 값 모음에 대해 많은 곱셈을 쉽고 빠르게 수행할 수 있게 해 주므로 유용합니다. 그리고 이는 매우 일반적인 스키마의 일부로 동작합니다. numpy나 다른 수치 라이브러리가 제공하는 배열 객체를 사용할 때, 모든 Python 연산자는 모든 차원의 배열에 대해 요소별로 동작합니다. 그 결과 변수를 단순한 값인 것처럼 다루면서 a * b + c / d 같은 직관적인 코드로 함수를 작성할 수 있고, 이 함수를 곧바로 사용하여 큰 값 모음에 대해 이 계산을 효율적으로 수행할 수 있으며, 그러면서도 값들은 당면한 문제에 가장 적합한 배열 구조로 정리해 둘 수 있습니다. 그 구조가 아무리 복잡해도 상관없습니다.

행렬 곱셈은 좀 더 특수한 경우입니다. 행렬 곱셈은 2차원 배열(“행렬”이라고도 합니다)에서만 정의되며, 중요한 “행렬” 버전이 있는 연산은 곱셈뿐입니다. “행렬 덧셈”은 요소별 덧셈과 같고, “행렬 비트 OR”이나 “행렬 버림 나눗셈” 같은 것은 존재하지 않으며, “행렬 나눗셈”과 “행렬 거듭제곱”은 정의할 수는 있지만 그다지 유용하지 않습니다. 그럼에도 행렬 곱셈은 모든 수치 응용 분야에서 여전히 매우 많이 사용되며, 수학적으로도 가장 근본적인 연산 중 하나입니다.

현재 Python 문법은 곱셈 연산자로 * 하나만 허용하므로, 배열과 유사한 객체를 제공하는 라이브러리는 *를 요소별 곱셈에 사용할지, 아니면 *를 행렬 곱셈에 사용할지 결정해야 합니다. 그런데 안타깝게도 범용 수치 계산에서는 두 연산이 모두 자주 사용되며, 두 경우 모두 함수 호출 문법보다 중위 표기법을 사용하는 것이 크게 유리하다는 사실이 드러납니다. 따라서 어느 규약이 최적인지, 심지어 받아들일 만한지조차 전혀 분명하지 않으며, 사례마다 달라지는 경우가 많습니다.

그렇지만 네트워크 효과를 고려하면 단 하나의 규약을 고르는 것이 매우 중요합니다. 예를 들어 numpy는 서로 다른 __mul__ 메서드를 가진 두 가지 타입을 제공하므로, 기술적으로는 두 규약을 오가며 사용할 수 있습니다. numpy.ndarray 객체에서는 *가 요소별 곱셈을 수행하며, 행렬 곱셈에는 함수 호출(numpy.dot)을 사용해야 합니다. numpy.matrix 객체에서는 *가 행렬 곱셈을 수행하며, 요소별 곱셈에는 함수 문법이 필요합니다. numpy.ndarray를 사용하여 코드를 작성하면 잘 동작합니다. numpy.matrix를 사용하여 코드를 작성해도 역시 잘 동작합니다. 하지만 이 두 코드를 하나로 합치려는 순간부터 문제가 시작됩니다. ndarray를 기대하는데 matrix를 받는 코드나 그 반대인 코드는 비정상 종료되거나 잘못된 결과를 반환할 수 있습니다. 어떤 함수가 어떤 타입을 입력으로 기대하고 어떤 타입을 출력으로 반환하는지 추적하면서 끊임없이 타입을 이리저리 변환하는 일은 엄청나게 번거로우며, 규모와 상관없이 올바르게 해내기가 불가능합니다. 두 타입을 모두 입력으로 받아 방어적으로 올바르게 처리(DTRT)하려는 함수는 결국 isinstance와 if 문의 늪에서 허우적거리게 됩니다.

PEP 238은 /를 /와 //라는 두 연산자로 나누었습니다. 만약 그 대신 int를 __div__가 버림 나눗셈을 구현하는 classic_int와 __div__가 참 나눗셈을 구현하는 new_int라는 두 가지 타입으로 나누었다면 어떤 혼란이 생겼을지 상상해 보십시오. 정도는 덜하지만, 이것이 바로 현재 Python으로 수치 계산을 하는 사람들이 처한 상황입니다.

실제로는 대다수의 프로젝트가 요소별 곱셈에는 *를, 행렬 곱셈에는 함수 호출 문법을 사용하는 관례로 정착했습니다(예: numpy.matrix 대신 numpy.ndarray 사용). 이 관례 덕분에 API 파편화로 인한 문제는 줄어들지만, 완전히 사라지지는 않습니다. 행렬 곱셈에 중위 표기법을 쓰고 싶은 요구가 강하기 때문에, 여러 특수 목적 배열 라이브러리(예: scipy.sparse, pyoperators, pyviennacl)는 그로 인한 문제에도 불구하고 반대 관례를 계속 사용하고 있으며, numpy.matrix 자체도 여전히 입문 프로그래밍 강좌에서 쓰이고 StackOverflow 답변에도 자주 등장하는 등 계속 사용되고 있습니다. 따라서 잘 작성된 라이브러리는 계속해서 두 가지 타입의 객체를 모두 다룰 준비가 되어 있어야 하며, 물론 행렬 곱셈에는 불편한 함수 호출 문법을 사용할 수밖에 없습니다. 거의 20년에 걸친 시도 끝에도, 수치 커뮤니티는 현재 Python 문법의 제약 안에서 이러한 문제를 해결할 방법을 여전히 찾지 못했습니다(아래 Rejected alternatives to adding a new operator 참고).

이 PEP는 우리가 이 늪에서 벗어날 수 있게 해 줄, 효과적인 최소한의 Python 문법 변경을 제안합니다. /에 대해 했던 것과 마찬가지로 *를 두 연산자로 나눕니다. 원소별 곱셈에는 *를, 행렬 곱셈에는 @를 사용합니다. (왜 반대로 하지 않습니까? 이 방식이 기존의 합의와 호환되고, 모든 내장 수치 연산자가 배열에도 원소별로 적용된다는 일관된 규칙을 제공하기 때문입니다. 반대 관례를 따르면 특수한 경우가 더 많아집니다.)

이것이 행렬 곱셈이 그냥 *를 사용하지 않으며, 사용할 수도 없는 이유입니다. 이제 이 절의 나머지 부분에서는 그럼에도 행렬 곱셈이 새 연산자를 추가하기 위한 높은 기준을 충족하는 이유를 설명하겠습니다.

행렬 곱셈은 왜 중위 연산이어야 합니까?

현재 Python의 대부분의 수치 코드는 행렬 곱셈을 수행하기 위해 numpy.dot(a, b)나 a.dot(b) 같은 문법을 사용합니다. 이 방식은 분명히 잘 동작하는데, 사람들은 왜 API 파편화와 호환성 수렁을 만들어 내면서까지 이 문제로 그토록 소란을 피우는 것입니까?

행렬 곱셈은 숫자에 대한 덧셈이나 곱셈 같은 일반적인 산술 연산과 두 가지 특징을 공유합니다. (a) 수치 프로그램에서 매우 빈번하게, 종종 코드 한 줄에 여러 번 사용되며, (b) 중위 문법으로 작성하는 오래되고 보편적으로 채택된 전통이 있습니다. 이는 일반적인 수식에서 이 표기법이 어떤 함수 호출 문법보다도 훨씬 더 읽기 쉽기 때문입니다. 이를 보여 주는 예를 살펴보겠습니다:

통계적 가설을 검정하는 가장 유용한 도구 중 하나는 OLS 회귀 모델에 대한 선형 가설 검정입니다. 방금 말한 용어들이 무슨 뜻인지는 사실 중요하지 않습니다. 이것을 구현해야 하는 상황이 되면 우리는 관련 교과서나 논문을 찾아볼 것이고, 다음과 같은 수학 공식을 많이 접하게 될 것입니다:

S = (Hβ − r)T(HVHT) − 1(Hβ − r)

여기서 각 변수는 모두 벡터나 행렬입니다(자세한 내용이 궁금한 분은 [5]를 참조하십시오).

이제 이 계산을 수행하는 코드를 작성해야 합니다. 현재 numpy에서 행렬 곱셈은 함수 호출 문법이나 메서드 호출 문법 중 하나를 사용하여 수행할 수 있습니다. 어느 쪽도 이 수식을 그다지 읽기 쉽게 옮겨 주지 못합니다:

import numpy as np
from numpy.linalg import inv, solve

# Using dot function:
S = np.dot((np.dot(H, beta) - r).T,
           np.dot(inv(np.dot(np.dot(H, V), H.T)), np.dot(H, beta) - r))

# Using dot method:
S = (H.dot(beta) - r).T.dot(inv(H.dot(V).dot(H.T))).dot(H.dot(beta) - r)

@ 연산자를 사용하면 위 수식을 그대로 옮긴 코드는 다음과 같습니다:

S = (H @ beta - r).T @ inv(H @ V @ H.T) @ (H @ beta - r)

이제 원래 수식의 기호와 이를 구현하는 코드 사이에 투명한 일대일 매핑이 존재한다는 점에 주목하십시오.

물론 숙련된 프로그래머라면 이것이 이 식을 계산하는 가장 좋은 방법이 아니라는 점을 알아챌 것입니다. Hβ − r의 반복 계산은 따로 빼내는 편이 좋을 것이며, dot(inv(A), B) 형태의 식은 거의 항상 수치적으로 더 안정적인 solve(A, B)로 바꿔야 합니다. @를 사용할 때 이 두 가지 리팩터링을 적용하면 다음과 같습니다:

# Version 1 (as above)
S = (H @ beta - r).T @ inv(H @ V @ H.T) @ (H @ beta - r)

# Version 2
trans_coef = H @ beta - r
S = trans_coef.T @ inv(H @ V @ H.T) @ trans_coef

# Version 3
S = trans_coef.T @ solve(H @ V @ H.T, trans_coef)

각 단계를 쌍으로 비교해 보면 정확히 무엇이 바뀌었는지 매우 쉽게 알 수 있다는 점에 주목하십시오. .dot 메서드를 사용하는 코드에 같은 변환을 적용하면, 변경 사항을 읽어 내거나 정확성을 검증하기가 훨씬 어려워집니다:

# Version 1 (as above)
S = (H.dot(beta) - r).T.dot(inv(H.dot(V).dot(H.T))).dot(H.dot(beta) - r)

# Version 2
trans_coef = H.dot(beta) - r
S = trans_coef.T.dot(inv(H.dot(V).dot(H.T))).dot(trans_coef)

# Version 3
S = trans_coef.T.dot(solve(H.dot(V).dot(H.T)), trans_coef)

가독성은 중요합니다! @를 사용하는 문장은 더 짧고, 공백이 더 많으며, 서로는 물론 교과서의 공식과도 직접 쉽게 비교할 수 있고, 의미 있는 괄호만 포함합니다. 이 마지막 요점은 가독성에 특히 중요합니다. 함수 호출 문법을 사용하면 모든 연산마다 필요한 괄호가 시각적 혼잡을 만들어, 이처럼 비교적 단순한 수식조차도 눈으로 수식의 전체 구조를 파악하기가 매우 어려워집니다. 사람의 눈은 비정규 언어를 파싱하는 데 매우 서툽니다. 저는 위의 ‘dot’ 공식을 작성하면서 많은 오류를 저질렀고 또 잡아냈습니다. 그래도 여전히 적어도 하나, 어쩌면 그 이상의 오류가 남아 있다는 것을 알고 있습니다. (연습 문제: 그 오류를 찾아보십시오. 혹은 그 오류들을 찾아보십시오.) 반면에 @ 예제는 올바를 뿐만 아니라, 한눈에 보아도 명백히 올바릅니다.

우리가 더 숙련된 프로그래머이고 재사용될 것으로 예상하는 코드를 작성한다면, 속도나 수치 정확도를 고려하여 특정한 평가 순서를 선호하게 될 수도 있습니다. @를 사용하면 불필요한 괄호를 생략할 수 있으므로, 우리가 (H @ V) @ H.T와 같은 것을 실제로 작성한다면 독자들은 그 괄호가 어떤 의미 있는 목적을 이루기 위해 의도적으로 추가되었음을 알게 될 것이라고 확신할 수 있습니다. dot 예제에서는 어떤 중첩 결정이 중요하고 어떤 것이 임의적인지 알 수가 없습니다.

중위 @는 프로그래머가 상호작용하는 모든 단계에서 행렬 코드의 사용성을 극적으로 향상시킵니다.

비전문 프로그래머에게는 투명한 문법이 특히 중요합니다

과학 코드의 상당 부분은 자기 분야의 전문가이지만 프로그래밍 전문가는 아닌 사람들이 작성합니다. 그리고 매년 “사회과학자를 위한 데이터 분석”과 같은 제목으로 운영되는 대학 강좌가 많은데, 이러한 강좌는 프로그래밍 배경지식이 없다고 가정하고 수학적 기법, 프로그래밍 입문, 그리고 이러한 수학적 기법을 구현하기 위한 프로그래밍 활용을 조합하여 모두 10~15주 안에 가르칩니다. 이러한 강좌는 R이나 Matlab 같은 특수 목적 언어보다 Python으로 진행되는 경우가 점점 더 많아지고 있습니다.

프로그래밍 지식이 탄탄하지 않은 이런 사용자들에게는 수식과 코드 사이에 투명한 매핑이 존재하는지가 그 코드를 작성하는 데 성공하느냐 아예 실패하느냐를 가르는 경우가 많습니다. 이 점은 매우 중요하기 때문에, 이러한 수업에서는 *를 행렬 곱셈으로 정의하는 numpy.matrix 타입을 흔히 사용합니다. 이 타입은 버그가 많고, 그것이 야기하는 파편화 때문에 numpy 커뮤니티의 나머지 구성원들이 사용하지 말 것을 강력히 권고하는데도 말입니다. 사실 이러한 교육적 사용 사례가 numpy.matrix가 여전히 numpy에서 지원되는 유일한 이유입니다. @를 추가하면 초보 사용자와 숙련 사용자 모두 더 나은 문법의 혜택을 누리게 됩니다. 더 나아가 두 집단이 처음부터 같은 표기법으로 표준화할 수 있게 되어, 전문성으로 가는 길이 더 순탄해집니다.

하지만 행렬 곱셈은 상당히 틈새적인 요구 사항이 아닙니까?

세상은 연속적인 데이터로 가득 차 있으며, 컴퓨터는 점점 더 정교한 방식으로 이러한 데이터를 다루도록 요구받고 있습니다. 배열은 금융, 기계 학습, 3D 그래픽, 컴퓨터 비전, 로봇 공학, 운영 연구, 계량 경제학, 기상학, 전산 언어학, 추천 시스템, 신경 과학, 천문학, 생물 정보학(유전학, 암 연구, 신약 개발 등 포함), 물리 엔진, 양자 역학, 지구 물리학, 네트워크 분석, 그리고 그 밖의 많은 응용 분야의 공용어입니다. 이 분야들의 대부분 혹은 전부에서 Python은 빠르게 지배적인 위치를 차지하고 있으며, 그 큰 이유는 전통적인 이산 자료 구조(해시 테이블, 문자열 등)를 현대적인 수치 데이터 타입 및 알고리즘과 대등한 위치에서 우아하게 섞어 쓸 수 있는 능력에 있습니다.

우리는 모두 각자의 작은 하위 커뮤니티 안에서 살아가기 때문에, 일부 Python 사용자는 Python이 수치 계산에 얼마나 광범위하게 사용되는지 알고 놀랄 수도 있습니다. 특히 이 하위 커뮤니티의 활동 상당 부분이 전통적인 Python/FOSS 채널 밖에서 이루어지기 때문입니다. 그래서 수치 계산을 하는 Python 프로그래머가 실제로 얼마나 많은지 대략적으로 알 수 있도록 두 가지 수치를 제시합니다. 2013년에는 수치 Python만을 주제로 조직된 국제 콘퍼런스가 7개 있었습니다 [3] [4]. PyCon 2014에서는 튜토리얼의 약 20%가 행렬 사용과 관련된 것으로 보입니다 [6].

이를 더 정량화하기 위해, 우리는 Github의 “search” 함수를 사용하여 광범위한 실제 코드(즉, Github에 있는 모든 코드)에서 실제로 어떤 모듈이 임포트되는지 살펴보았습니다. 우리는 널리 쓰이는 여러 표준 라이브러리 모듈, 다양한 수치 지향 모듈, 그리고 django와 lxml(후자는 PyPI에서 다운로드 수 1위 패키지입니다) 같은 매우 유명한 여러 모듈의 임포트 여부를 확인했습니다. 별표가 붙은 줄은 이 PEP가 승인되면 @를 채택할 배열 또는 행렬 형태의 객체를 내보내는 패키지를 나타냅니다:

Count of Python source files on Github matching given search terms
                 (as of 2014-04-10, ~21:00 UTC)
================ ==========  ===============  =======  ===========
module           "import X"  "from X import"    total  total/numpy
================ ==========  ===============  =======  ===========
sys                 2374638            63301  2437939         5.85
os                  1971515            37571  2009086         4.82
re                  1294651             8358  1303009         3.12
numpy ************** 337916 ********** 79065 * 416981 ******* 1.00
warnings             298195            73150   371345         0.89
subprocess           281290            63644   344934         0.83
django                62795           219302   282097         0.68
math                 200084            81903   281987         0.68
threading            212302            45423   257725         0.62
pickle+cPickle       215349            22672   238021         0.57
matplotlib           119054            27859   146913         0.35
sqlalchemy            29842            82850   112692         0.27
pylab *************** 36754 ********** 41063 ** 77817 ******* 0.19
scipy *************** 40829 ********** 28263 ** 69092 ******* 0.17
lxml                  19026            38061    57087         0.14
zlib                  40486             6623    47109         0.11
multiprocessing       25247            19850    45097         0.11
requests              30896              560    31456         0.08
jinja2                 8057            24047    32104         0.08
twisted               13858             6404    20262         0.05
gevent                11309             8529    19838         0.05
pandas ************** 14923 *********** 4005 ** 18928 ******* 0.05
sympy                  2779             9537    12316         0.03
theano *************** 3654 *********** 1828 *** 5482 ******* 0.01
================ ==========  ===============  =======  ===========

이 수치는 상당히 걸러서 받아들여야 합니다(논의는 각주를 참조하십시오: [12]). 하지만 신뢰할 수 있는 범위 안에서 보면, 이 수치는 numpy가 파이썬 세계 전체에서 가장 많이 임포트되는 비표준 라이브러리 모듈일 수도 있음을 시사합니다. 심지어 subprocess, math, pickle, threading 같은 표준 라이브러리의 터줏대감들보다도 더 많이 임포트됩니다. 게다가 numpy 사용자는 @ 연산자의 혜택을 받을 더 넓은 수치 계산 커뮤니티의 일부에 불과합니다. 한때 행렬은 대학 연구실과 군사용 클러스터에서 돌아가는 포트란 프로그램에만 쓰이는 틈새 데이터 타입이었을지도 모르지만, 그런 시절은 지난 지 오래입니다. 수치 계산은 현대 Python 사용에서 주류를 이루는 부분입니다.

또한 더 특수한 산술 연산을 처리하기 위해 중위 연산자를 추가한 선례도 어느 정도 있습니다. 바닥 나눗셈 연산자 //는 비트 연산자와 마찬가지로 이산적인 값에 대해 정확한 계산을 수행할 때 특정 상황에서 매우 유용합니다. 하지만 //(또는 비트 연산자)를 사용할 이유가 한 번도 없었던 Python 프로그래머도 많을 것으로 보입니다. @는 //보다 더 틈새 기능이 아닙니다.

그렇다면 @가 행렬 수식에 좋다는 것은 알겠지만, 그런 수식이 실제로 얼마나 흔합니까?

우리는 @가 전문가와 비전문가 모두에게 행렬 수식을 훨씬 더 다루기 쉽게 만들어 준다는 점, 행렬 수식이 많은 중요한 응용 분야에 등장한다는 점, 그리고 numpy 같은 수치 라이브러리가 Python 사용자층의 상당 부분에서 사용된다는 점을 살펴보았습니다. 하지만 수치 라이브러리가 행렬 수식만 다루는 것은 아니며, 중요하다고 해서 반드시 코드에서 큰 비중을 차지하는 것도 아닙니다. 평균적인 수치 계산 프로젝트에서 행렬 수식이 한두 군데에만 등장한다면, 여전히 새 연산자를 추가할 가치는 없을 것입니다. 그렇다면 행렬 곱셈은 실제로 얼마나 흔합니까?

상황이 어려워지면 강한 자는 실증 데이터를 찾습니다. @ 연산자가 얼마나 유용할지 대략적으로 추정하기 위해, 아래 표는 표준 라이브러리와 두 개의 유명한 수치 패키지, 즉 scikit-learn 머신 러닝 라이브러리와 nipy 신경 영상 라이브러리에서 여러 Python 연산자가 실제로 사용되는 빈도를 소스 코드 줄 수(SLOC)로 정규화하여 보여 줍니다. 행은 세 코드베이스를 모두 합친 ‘combined’ 열을 기준으로 정렬되어 있습니다. 따라서 combined 열은 두 프로젝트를 합친 것보다 훨씬 큰 표준 라이브러리 쪽으로 크게 치우쳐 있습니다(표준 라이브러리: 411575 SLOC, scikit-learn: 50924 SLOC, nipy: 37078 SLOC). [7]

dot 행(******로 표시)은 각 코드베이스에서 행렬 곱셈 연산이 얼마나 흔한지를 집계한 것입니다.

====  ======  ============  ====  ========
  op  stdlib  scikit-learn  nipy  combined
====  ======  ============  ====  ========
   =    2969          5536  4932      3376 / 10,000 SLOC
   -     218           444   496       261
   +     224           201   348       231
  ==     177           248   334       196
   *     156           284   465       192
   %     121           114   107       119
  **      59           111   118        68
  !=      40            56    74        44
   /      18           121   183        41
   >      29            70   110        39
  +=      34            61    67        39
   <      32            62    76        38
  >=      19            17    17        18
  <=      18            27    12        18
 dot ***** 0 ********** 99 ** 74 ****** 16
   |      18             1     2        15
   &      14             0     6        12
  <<      10             1     1         8
  //       9             9     1         8
  -=       5            21    14         8
  *=       2            19    22         5
  /=       0            23    16         4
  >>       4             0     0         3
   ^       3             0     0         3
   ~       2             4     5         2
  |=       3             0     0         2
  &=       1             0     0         1
 //=       1             0     0         1
  ^=       1             0     0         0
 **=       0             2     0         0
  %=       0             0     0         0
 <<=       0             0     0         0
 >>=       0             0     0         0
====  ======  ============  ====  ========

이 두 수치 계산 패키지에서만 행렬 곱셈이 약 780회 사용됩니다. 이 패키지들 안에서는 행렬 곱셈이 대부분의 비교 연산자(< != <= >=)보다 더 많이 사용됩니다. 비교 대상에 표준 라이브러리를 포함하여 이 수치를 희석하더라도, 전체적으로 행렬 곱셈은 여전히 어떤 비트 연산자보다도 자주 사용되며 //보다 2배 더 자주 사용됩니다. 상당한 양의 정수 산술을 포함하면서 행렬 연산은 전혀 없는 표준 라이브러리가 합친 코드베이스의 80% 이상을 차지하는데도 이는 사실입니다.

우연하게도 ‘combined’ 코드베이스에서 수치 라이브러리가 차지하는 비율은 PyCon 2014 튜토리얼 일정에서 수치 관련 튜토리얼이 차지하는 비율과 거의 같으며, 이는 ‘combined’ 열이 새로운 Python 코드 전반을 크게 벗어나지는 않을 수 있음을 시사합니다. 확실히 알 수는 없지만, 이 데이터로 보면 현재 작성되고 있는 모든 Python 코드 전체에서 행렬 곱셈이 이미 //나 비트 연산보다 더 자주 사용되고 있을 가능성이 충분합니다.

하지만 표준 라이브러리에서 쓰이지 않는 연산자를 추가하는 것은 이상하지 않습니까?

분명 흔한 일은 아닙니다(다만 확장 슬라이싱은 내장 타입이 이를 지원하기 전에도 한동안 존재했고, Ellipsis는 여전히 표준 라이브러리 안에서 사용되지 않는 등의 예가 있습니다). 하지만 중요한 것은 소프트웨어를 어디서 내려받느냐가 아니라 변경이 사용자에게 도움이 되느냐입니다. 위의 내용으로 볼 때 @가 사용될 것이고, 그것도 많이 사용될 것임은 분명합니다. 그리고 이 PEP는 Python 수치 커뮤니티가 마침내 모든 배열 유사 객체에 대한 표준 덕 타입에 합의할 수 있게 해 주는 핵심 요소를 제공하며, 이는 언젠가 표준 라이브러리에 수치 배열 타입을 추가하기 위한 필수 전제 조건입니다.

호환성 고려 사항

현재 Python 코드에서 @ 토큰의 유일한 합법적 용도는 데코레이터에서 문장의 시작 부분에 쓰는 것입니다. 새 연산자는 둘 다 중위 연산자이며, 이들이 절대 나타날 수 없는 유일한 위치가 바로 문의 시작 부분입니다. 따라서 이 연산자들을 추가해도 기존 코드가 깨지지 않으며, 데코레이터의 @와 새 연산자 사이에 구문 분석상의 모호성이 생길 여지도 없습니다.

또 다른 중요한 종류의 호환성은 이 변경 이후 사용자가 Python 언어에 대한 이해를 갱신하기 위해 치르는 정신적 비용이며, 특히 행렬을 다루지 않아 아무런 이득을 얻지 못하는 사용자에게 그렇습니다. 여기서도 @가 미치는 영향은 미미합니다. 포괄적인 튜토리얼과 레퍼런스라도 수치 계산을 하지 않는 독자를 위해 이 PEP의 변경 사항을 완전히 문서화하려면 한두 문장만 추가하면 됩니다.

의도된 사용법 세부 사항

이 절은 규범이 아닌 참고용 내용입니다. 배열 또는 행렬과 유사한 객체를 제공하는 여러 라이브러리가 @ 연산자를 어떻게 구현할지에 관해 합의한 내용을 기록합니다.

이 절에서는 임의의 다차원 데이터 배열을 설명할 때 numpy 용어를 사용합니다. numpy 용어는 흔히 사용되는 다른 모든 모델을 포괄하는 상위 집합이기 때문입니다. 이 모델에서 모든 배열의 형태(shape)는 정수 튜플로 표현됩니다. 행렬은 2차원이므로 len(shape) == 2이고, 1차원 벡터는 len(shape) == 1이며, 스칼라는 shape == (), 즉 “0차원”입니다. 모든 배열은 총 prod(shape)개의 원소를 담습니다. prod(()) == 1이라는 점에 주목하십시오(sum(()) == 0인 것과 같은 이유입니다). 스칼라는 특수한 경우가 아니라 평범한 배열의 한 종류일 뿐입니다. 또한 단일 스칼라 값(shape == (), 1에 해당), 원소 하나만 담은 벡터(shape == (1,), [1]에 해당), 원소 하나만 담은 행렬(shape == (1, 1), [[1]]에 해당) 등을 구별한다는 점에도 주목하십시오. 따라서 모든 배열의 차원 수는 항상 명확하게 정의됩니다. 표현 방식이 더 제한적인 다른 라이브러리(예: 2차원 배열만 지원하는 라이브러리)는 여기에서 설명하는 기능 중 일부만 구현할 수도 있습니다.

의미론

다양한 입력에 대해 권장하는 @ 연산자의 의미론은 다음과 같습니다.

  • 2차원 입력은 일반적인 행렬이므로 의미론은 자명합니다. 일반적인 행렬 곱셈을 적용합니다. 임의의 2x3 배열을 arr(2, 3)으로 표기한다면, arr(2, 3) @ arr(3, 4)는 형태가 (2, 4)인 배열을 반환합니다.
  • 1차원 벡터 입력은 형태의 앞이나 뒤에 ‘1’을 붙여 2차원으로 승격한 뒤 연산을 수행하고, 추가했던 차원은 출력에서 제거합니다. 1은 항상 형태의 “바깥쪽”에 추가됩니다. 왼쪽 인자에는 앞에 붙이고, 오른쪽 인자에는 뒤에 붙입니다. 그 결과 행렬 @ 벡터와 벡터 @ 행렬은 모두 허용되며(형태가 호환된다고 가정할 때), 둘 다 1차원 벡터를 반환합니다. 벡터 @ 벡터는 스칼라를 반환합니다. 예시를 보면 더 명확합니다.
    • arr(2, 3) @ arr(3, 1)은 일반적인 행렬 곱이며, 형태가 (2, 1)인 배열, 즉 열 벡터를 반환합니다.
    • arr(2, 3) @ arr(3)은 앞의 예와 같은 계산을 수행하지만(즉, 1차원 벡터를 열 하나를 담은 shape = (3, 1)인 행렬로 취급합니다), 결과는 형태가 (2,)인 배열, 즉 1차원 벡터로 반환합니다.
    • arr(1, 3) @ arr(3, 2)는 일반적인 행렬 곱이며, 형태가 (1, 2)인 배열, 즉 행 벡터를 반환합니다.
    • arr(3) @ arr(3, 2)는 앞의 예와 같은 계산을 수행하지만(즉, 1차원 벡터를 행 하나를 담은 shape = (1, 3)인 행렬로 취급합니다), 결과는 형태가 (2,)인 배열, 즉 1차원 벡터로 반환합니다.
    • arr(1, 3) @ arr(3, 1)은 일반적인 행렬 곱이며, 형태가 (1, 1)인 배열, 즉 행렬 형태의 단일 값을 반환합니다.
    • arr(3) @ arr(3)은 앞의 예와 같은 계산을 수행하지만, 결과는 형태가 ()인 값, 즉 행렬 형태가 아닌 단일 스칼라 값으로 반환합니다. 따라서 이것은 벡터의 표준 내적입니다.

    1차원 벡터에 대한 이 정의의 아쉬운 점은 일부 경우에 @ 연산자가 결합 법칙을 따르지 않게 된다는 것입니다((Mat1 @ vec) @ Mat2 != Mat1 @ (vec @ Mat2)). 하지만 이는 실용성이 순수성을 이기는 경우로 보입니다. 결합 법칙이 성립하지 않는 경우는 실제로는 결코 작성하지 않을 이상한 표현식에서만 생깁니다. 그런 표현식을 굳이 작성하더라도 어떤 일이 일어날지 이해할 수 있는 일관된 규칙이 있습니다(a - b - c와 마찬가지로 Mat1 @ vec @ Mat2는 (Mat1 @ vec) @ Mat2로 파싱됩니다). 그리고 1차원 벡터를 지원하지 않으면 실제로 매우 흔하게 나타나는 중요한 사용 사례를 많이 배제하게 됩니다. 가장 단순한 선형 시스템을 자명한 방식으로 풀려면 왜 inv(A) @ b 대신 (inv(A) @ b[:, np.newaxis]).flatten()을 입력해야 하는지, 또는 일반적인 최소 제곱 회귀를 수행하려면 왜 solve(X.T @ X, X @ y) 대신 solve(X.T @ X, X @ y[:, np.newaxis]).flatten()을 입력해야 하는지 새로운 사용자에게 설명하고 싶은 사람은 아무도 없습니다. 내적을 계산할 때마다 a @ b 대신 (a[np.newaxis, :] @ b[:, np.newaxis])[0, 0]을 입력하거나, 일반적인 이차 형식에 a @ Mat @ b 대신 (a[np.newaxis, :] @ Mat @ b[:, np.newaxis])[0, 0]을 입력하고 싶은 사람은 아무도 없습니다. 게다가 sage와 sympy(아래 참조)는 중위 행렬 곱셈 연산자(이들은 * 연산자를 사용합니다)에 이처럼 결합 법칙이 성립하지 않는 의미론을 적용하고 있으며, 이로 인해 문제를 겪은 적이 없다고 보고합니다.

  • 2차원보다 많은 차원을 가진 입력의 경우, 마지막 두 차원을 곱할 행렬의 차원으로 취급하고 나머지 차원에 걸쳐 ‘브로드캐스트’합니다. 이를 통해 한 번의 연산으로 많은 행렬 곱을 빠르게 계산할 수 있습니다. 예를 들어, arr(10, 2, 3) @ arr(10, 3, 4)는 10개의 개별 행렬 곱셈을 수행하는데, 각 곱셈은 2x3 행렬과 3x4 행렬을 곱하여 2x4 행렬을 만들고, 그 결과로 나온 10개의 행렬을 모아 형태가 (10, 2, 4)인 배열로 반환합니다. 여기서의 직관은 이러한 3차원 숫자 배열을 마치 행렬로 이루어진 1차원 배열인 것처럼 취급한 다음, 이제 각 ‘요소’가 하나의 행렬 전체인 상태에서 행렬 곱셈을 요소별로 적용한다는 것입니다. 브로드캐스팅은 완벽하게 정렬된 배열에만 국한되지 않는다는 점에 유의하십시오. 더 복잡한 경우에는 배열끼리 서로 어떻게 정렬될지를 제어하는 간단하지만 강력한 몇 가지 기법을 사용할 수 있습니다. 자세한 내용은 [10]을 참고하십시오. (특히 브로드캐스팅을 고려하면, 표준적인 스칼라 * 행렬 곱은 요소별 곱셈 연산자 *의 특수한 경우임이 드러납니다.)

    한 피연산자가 2차원을 초과하고 다른 피연산자가 1차원이면 위의 규칙이 그대로 적용되며, 1차원->2차원 승격은 브로드캐스팅 전에 수행됩니다. 예를 들어, arr(10, 2, 3) @ arr(3)은 먼저 arr(10, 2, 3) @ arr(3, 1)로 승격되고, 그다음 오른쪽 인자를 브로드캐스트하여 정렬된 연산 arr(10, 2, 3) @ arr(10, 3, 1)을 만들며, 곱셈을 수행하여 형태가 (10, 2, 1)인 배열을 얻고, 마지막으로 추가했던 차원을 제거하여 형태가 (10, 2)인 배열을 반환합니다. 마찬가지로, arr(2) @ arr(10, 2, 3)은 형태가 (10, 1, 3)인 중간 배열을 만들고, 최종적으로 형태가 (10, 3)인 배열을 만듭니다.

  • 0차원(스칼라) 입력은 오류를 발생시킵니다. 스칼라 * 행렬 곱셈은 행렬 @ 행렬 곱셈과는 수학적으로나 알고리즘적으로나 별개의 연산이며, 이미 요소별 * 연산자로 처리할 수 있습니다. 따라서 스칼라 @ 행렬을 허용하면 불필요한 특수 사례가 필요해질 뿐만 아니라 TOOWTDI 원칙에도 어긋납니다.

채택

우리는 배열 또는 행렬과 유사한 타입을 제공하는 기존 Python 프로젝트를 요소별 곱셈과 행렬 곱셈에 현재 어떤 API를 사용하는지에 따라 분류합니다.

현재 요소별 곱셈에 * 연산자를 사용하고, 행렬 곱셈에 함수/메서드 호출을 사용하는 프로젝트:

다음 프로젝트의 개발자들은 자신들의 배열 유사 타입에 위의 의미 체계에 따라 @ 연산자를 구현할 의향을 밝혔습니다:

  • numpy
  • pandas
  • blaze
  • theano

다음 프로젝트들에는 이 PEP의 존재를 알렸지만, PEP가 수락될 경우 어떻게 할 계획인지는 아직 알려지지 않았습니다. 하지만 여기서 제안하는 모든 내용이 이들 프로젝트가 이미 일을 처리하는 방식과 일치하므로, 이들이 반대할 것으로 예상하지는 않습니다:

  • pycuda
  • panda3d

현재 행렬 곱셈에 * 연산자를 사용하고, 요소별 곱셈에 함수/메서드 호출을 사용하는 프로젝트:

다음 프로젝트들은 이 PEP가 수락되면 현재 API에서 요소별-*, matmul-@ 관례로 이전할 의향을 밝혔습니다(즉, 이 PEP가 수락되면 API 파편화가 해소될 가능성이 높은 프로젝트 목록입니다):

  • numpy (numpy.matrix)
  • scipy.sparse
  • pyoperators
  • pyviennacl

다음 프로젝트들에는 이 PEP의 존재를 알렸지만, PEP가 수락될 경우 어떻게 할 계획인지는 알려지지 않았습니다(즉, 이 PEP가 수락되면 API 파편화가 해소될 수도 있고 해소되지 않을 수도 있는 프로젝트 목록입니다):

  • cvxopt

현재 행렬 곱셈에 * 연산자를 사용하며, 행렬의 요소별 곱셈에는 별로 관심이 없는 프로젝트:

행렬 타입을 구현하는 프로젝트가 몇 가지 있지만, 이들은 위에서 논의한 수치 라이브러리와는 매우 다른 관점에서 행렬을 다룹니다. 이러한 프로젝트는 행렬을 처리해야 할 숫자로 가득 찬 커다란 자루로 보기보다는 추상적인 수학적 객체(즉, 환 위의 자유 모듈 사이의 선형 사상)로 보고, 이를 분석하는 계산 방법에 초점을 맞춥니다. 그리고 추상 수학의 관점에서는 애초에 원소별 연산이 그다지 쓸모가 없습니다. 위의 배경 절에서 논의했듯이, 원소별 연산은 숫자 자루 접근 방식에서 비롯된 것입니다. 따라서 이 프로젝트들은 이 PEP가 해결하려는 근본적인 문제를 겪지 않으므로, 이 PEP는 이들과 대체로 무관합니다. 이 프로젝트들은 겉으로는 numpy 같은 프로젝트와 비슷해 보이지만, 실제로는 상당히 다른 일을 하고 있습니다. 이 프로젝트들은 행렬 곱셈(그리고 군 작용 등)에 *를 사용합니다. 이 PEP가 채택되더라도 계속 그렇게 하되 @를 별칭으로 추가할 수도 있다는 의사를 밝혔습니다. 이러한 프로젝트는 다음과 같습니다.

  • sympy
  • sage

구현 세부 사항

통상적인 의미를 갖는 새 함수 operator.matmul과 operator.__matmul__이 표준 라이브러리에 추가됩니다.

이에 대응하는 PyObject* PyObject_MatrixMultiply(PyObject *o1, PyObject *o2) 함수가 C API에 추가됩니다.

이름이 MatMult인 새 AST 노드가 추가되며, 이와 함께 새 토큰 ATEQUAL, 그리고 새 바이트코드 옵코드 BINARY_MATRIX_MULTIPLY 및 INPLACE_MATRIX_MULTIPLY가 추가됩니다.

두 개의 새 타입 슬롯이 추가됩니다. 이를 PyNumberMethods에 추가할지, 새 PyMatrixMethods 구조체에 추가할지는 아직 정해지지 않았습니다.

명세 세부 사항의 근거

연산자 선택

왜 다른 표기가 아니라 @입니까? 이 연산자의 이름을 어떻게 정해야 하는지에 대해서는 다른 프로그래밍 언어들 사이에서 아무런 합의가 없습니다 [11]. 여기서는 여러 선택지를 논의합니다.

미국 영어 키보드에 있는 기호로만 한정하면, Python 표현식 문맥에서 아직 의미가 없는 문장 부호 문자는 @, 백틱, $, !, ?입니다. 이 선택지 중에서는 @가 분명히 가장 좋습니다. !와 ?는 프로그래밍 문맥에서 이미 여기에 맞지 않는 의미를 잔뜩 지고 있습니다. 백틱은 BDFL의 선언으로 Python에서 금지되었습니다(PEP 3099 참조). $는 더 보기 흉하고, *나 ⋅과는 훨씬 더 달라 보이며, Perl/PHP의 부담까지 안고 있습니다. 그래도 이 중에서는 아마 $가 두 번째로 좋은 선택지일 것입니다.

미국 영어 키보드에 없는 기호는 시작부터 상당히 불리합니다(모든 수치 계산 Python 튜토리얼의 첫머리에서 키보드 배열을 설명하는 데 5분씩 써야 하는 것은 누구도 원하지 않는 번거로움입니다). 게다가 어떻게든 입력 문제를 극복한다 하더라도, 실제로 @보다 나은 기호가 있는지는 분명하지 않습니다. 제안된 선택지로는 다음과 같은 것들이 있습니다.

  • U+00D7 MULTIPLICATION SIGN(곱셈 기호): A × B
  • U+22C5 DOT OPERATOR(점 연산자): A ⋅ B
  • U+2297 CIRCLED TIMES: A ⊗ B
  • U+00B0 DEGREE: A ° B

하지만 우리에게 필요한 것은 “스칼라/원소별 곱셈이 아닌 행렬 곱셈”을 뜻하는 연산자입니다. 프로그래밍에서도 수학에서도 이런 의미를 지닌 관례적인 기호는 없으며, 두 분야 모두에서 이러한 연산은 보통 문맥으로 구별합니다. (게다가 U+2297 CIRCLED TIMES는 관례적으로 정반대의 의미로 쓰입니다. 우리 연산자처럼 행렬곱/내적을 뜻하는 것이 아니라 원소별 곱셈(“아다마르 곱”)이나 외적을 뜻합니다.) @는 적어도 특이한 비가환 연산자처럼 보인다는 장점이 있습니다. 수학은 알지만 프로그래밍은 모르는 초보 사용자는 A * B와 A × B, A * B와 A ⋅ B, 또는 A * B와 A ° B를 보고 어느 쪽이 일반적인 곱셈이고 어느 쪽이 특수한 경우인지 짐작할 수 없을 것입니다.

마지막으로, 여러 문자로 이루어진 토큰을 사용하는 선택지가 있습니다. 몇 가지 선택지는 다음과 같습니다.

  • Matlab과 Julia는 .* 연산자를 사용합니다. 이 연산자는 *와 시각적으로 혼동될 수 있다는 점 외에도 우리에게는 끔찍한 선택입니다. Matlab과 Julia에서는 *가 행렬 곱셈을, .*가 원소별 곱셈을 뜻하므로, 행렬 곱셈에 .*를 사용하면 Matlab과 Julia 사용자가 기대하는 것과 정확히 반대가 되기 때문입니다.
  • APL은 +.×를 사용했던 것으로 보입니다. 이 표기는 여러 문자로 된 토큰, 속성 접근처럼 보여 혼란스러운 . 문법, 유니코드 문자를 함께 쓰기 때문에 우리 후보 목록에서 U+2603 SNOWMAN보다도 아래에 놓입니다. 덧셈 연산자와 곱셈 연산자를 결합한 모양이 행렬 곱셈이 실제로 동작하는 방식을 연상시킨다는 아이디어가 마음에 든다면 +* 같은 것을 사용할 수도 있습니다 – 다만 이는 단순히 곱셈과 단항 + 연산자를 결합한 *+와 너무 쉽게 혼동될 수 있습니다.
  • PEP 211은 ~*를 제안했습니다. 이 방식은 단항 ~와 결합되는 단항 * 연산자가 있는 것처럼 어느 정도 암시한다는 단점이 있지만, 쓸 수는 있을 것입니다.
  • R은 행렬 곱셈에 %*%를 사용합니다. R에서 이는 %foo% 형태의 모든 토큰이 사용자 정의 이항 연산자가 되는, 일반적이고 확장 가능한 중위 표기 체계의 일부를 이룹니다. 체계는 가져오지 않고 토큰만 훔쳐 올 수도 있습니다.
  • 그 밖에 제안된 그럴듯한 후보로는 다음이 있습니다. ><(곱셈 기호 ×를 ASCII로 그린 것), 각주 연산자 [*] 또는 |*|(다만 실제 문맥에서 쓰면 세로 묶음 기호 때문에 중첩된 괄호가 만드는 시각적 혼잡이 다시 생기기 쉽습니다. 이 혼잡은 우리가 벗어나려는 함수 문법의 주요 단점 중 하나로 지적된 것입니다), ^*.

그러니 크게 중요하지는 않지만, @는 다른 어떤 대안과 비교해도 그만큼 좋거나 더 나아 보입니다:

  • 파이썬 사용자들이 이미 데코레이터에서 입력하는 데 익숙한 친숙한 문자이지만, 데코레이터 용법과 수식 용법은 충분히 달라서 실제로 둘을 혼동하기는 어려울 것입니다.
  • 다양한 키보드 배열에서 폭넓게 입력할 수 있습니다(그리고 이메일 주소에 쓰이는 덕분에, 휴대전화 키보드 같은 특이한 키보드에서도 마찬가지입니다).
  • *나 ⋅처럼 둥근 모양입니다.
  • mATrices(행렬)라는 연상 기호가 귀엽습니다.
  • 소용돌이 모양은 행렬 곱셈을 정의하는, 행과 열을 동시에 훑어 나가는 동작을 연상시킵니다
  • 비대칭적인 모양은 교환 법칙이 성립하지 않는 성질을 연상시킵니다.
  • 어쨌든, 무언가는 골라야 합니다.

우선순위와 결합성

@가 오른쪽 결합이어야 하는지 왼쪽 결합이어야 하는지(혹은 더 특이한 무언가여야 하는지 [18])에 대해 긴 논의 [15]가 있었습니다. 거의 모든 Python 연산자는 왼쪽 결합이므로 이 관례를 따르는 것이 가장 간단한 접근 방식이지만, 행렬 곱셈만은 특별한 경우로 오른쪽 결합으로 만들 가치가 있을 수도 있다는 두 가지 인자(논거)가 제기되었습니다.

첫째, 행렬 곱셈은 함수 적용/합성과 개념적으로 밀접하게 연관되어 있어서, 수학적 소양이 깊은 많은 사용자는 RSx 같은 식이 오른쪽에서 왼쪽으로 진행된다고, 즉 먼저 S가 벡터 x를 변환하고 그다음 R이 그 결과를 변환한다고 직관적으로 생각합니다. 이 의견에 모두가 동의하는 것은 아니지만(그리고 숫자를 다루는 모든 사람이 이러한 직관의 바탕이 되는 순수 수학의 개념적 프레임워크에 익숙한 것도 아닙니다 [16]), 적어도 이러한 직관은 모든 사람이 왼쪽에서 오른쪽으로 읽는 2⋅3⋅4 같은 다른 연산의 경우보다는 더 흔합니다.

둘째, Mat @ Mat @ vec 같은 식이 코드에 자주 나타난다면, 이를 (Mat @ Mat) @ vec처럼 평가할 때보다 Mat @ (Mat @ vec)로 평가할 때 프로그램이 더 빠르게 실행될 것입니다(그리고 효율을 중시하는 프로그래머는 괄호를 더 적게 쓸 수 있을 것입니다).

그러나 이러한 인자(논거)에 반하는 근거는 다음과 같습니다.

효율성 인자(논거)와 관련하여, 경험적으로 보았을 때 실제 코드에서 Mat @ Mat @ vec 형태의 표현식이 실제로 우세하다는 증거는 전혀 찾을 수 없었습니다. numpy를 사용하는 대규모 프로젝트 여러 개를 파싱해 보니, numpy의 현재 함수 호출 문법 때문에 dot을 중첩 호출하는 연산 순서를 직접 정해야 할 때 사람들은 실제로 오른쪽 결합 중첩보다 왼쪽 결합 중첩을 약간 더 자주 사용했습니다 [17]. 그리고 어쨌든 괄호를 쓰는 것이 그리 나쁘지는 않습니다 – 효율을 중시하는 프로그래머가 어떤 식을 평가하는 최선의 방법을 굳이 고민할 정도라면, 연산 순서가 중요하다는 점을 다음 독자에게 분명히 보여 주기 위해서라도 괄호가 필요한지와 관계없이 아마 괄호를 적어 두어야 할 것입니다.

게다가 선형대수에 훨씬 더 중점을 두는 언어를 포함하여, 다른 언어들도 압도적으로 행렬 곱셈 연산자를 왼쪽 결합으로 만든다는 사실이 밝혀졌습니다. 구체적으로, R, Matlab, Julia, IDL, Gauss에서 @에 해당하는 연산자는 왼쪽 결합입니다. 저희가 찾은 유일한 예외는 a @ b @ c가 dot(a, b, c)로 비결합적으로 파싱되는 Mathematica와, 모든 연산자가 오른쪽 결합인 APL입니다. @는 오른쪽 결합으로, *는 왼쪽 결합으로 만든 언어는 존재하지 않는 것으로 보입니다. 그리고 이러한 결정은 논란거리도 아닌 것으로 보입니다. 저는 이 다른 언어들의 이 특정한 측면에 대해 불평하는 사람을 본 적이 없으며, 행렬 곱셈에 *를 사용하는 기존 Python 라이브러리의 사용자들도 *의 왼쪽 결합성을 불편해하지 않는 것으로 보입니다. 따라서 적어도 이로부터 @를 왼쪽 결합으로 만드는 것이 결코 재앙을 일으키지 않으리라는 결론을 내릴 수 있습니다. 반면에 @를 오른쪽 결합으로 만드는 것은 새롭고 불확실한 영역을 탐험하는 일이 될 것입니다.

그리고 왼쪽 결합성의 또 다른 장점은, @가 *처럼 동작한다는 것을 배우고 기억하는 편이, 먼저 @가 오른쪽 결합이라는 점에서 다른 Python 연산자들과 다르다는 것을 기억하고, 거기에 더해 *보다 더 강하게 결합하는지 더 약하게 결합하는지까지 기억해야 하는 것보다 훨씬 쉽다는 점입니다. (오른쪽 결합성을 택하면 우선순위를 정해야만 하는데, 어느 우선순위가 더 타당한지에 대한 직관은 거의 반반으로 갈렸습니다. 따라서 이는 저희가 어느 쪽을 선택하든 아무도 그것을 추측하거나 기억하지 못할 것임을 시사합니다.)

그러므로 종합하면, 수치 계산 커뮤니티의 전반적인 합의는 행렬 곱셈이 다소 특별한 경우이기는 하지만 규칙을 깰 만큼 특별하지는 않으며, @는 *와 같은 방식으로 파싱되어야 한다는 것입니다.

내장 타입에 대한 정의(하지 않음)

내장 숫자 타입(float, int 등)이나 numbers.Number 계층에는 __matmul__이나 __matpow__가 정의되지 않습니다. 이러한 타입은 스칼라를 나타내며, @의 합의된 의미는 스칼라에 대해 오류를 발생시켜야 한다는 것이기 때문입니다.

저희는 여러 이유로 – 당분간은 – 표준 memoryview나 array.array 객체에 __matmul__ 메서드를 정의하지 않습니다. 물론 누군가 원한다면 이를 추가할 수 있겠지만, 이러한 타입을 수치 작업에 사용할 수 있으려면 __matmul__ 외에도 상당한 추가 작업이 필요하며 – 예를 들어, 이 타입들은 덧셈이나 스칼라 곱셈을 할 방법조차 없습니다! – 그러한 기능을 추가하는 것은 이 PEP의 범위를 벗어납니다. 게다가 품질 좋은 행렬 곱셈 구현을 제공하는 것은 결코 간단한 일이 아닙니다. 단순한 중첩 루프 구현은 매우 느리며, 그러한 구현을 CPython에 포함해 배포하는 것은 사용자에게 함정을 만드는 일일 뿐입니다. 하지만 그 대안 – 현대적이고 경쟁력 있는 행렬 곱셈을 제공하는 것 – 은 CPython이 BLAS 라이브러리에 링크할 것을 요구하며, 이는 일련의 새로운 복잡한 문제를 가져옵니다. 특히 널리 쓰이는 몇몇 BLAS 라이브러리(OS X에 기본으로 포함되어 배포되는 것 포함)는 현재 multiprocessing을 사용할 수 없게 만듭니다 [8]. 이러한 고려 사항들을 종합하면, 이 타입들에 __matmul__을 추가하는 것은 비용 대비 이득이 없으므로, 당분간은 이러한 문제를 numpy와 관련 라이브러리들에 계속 위임하고, 보다 체계적인 해결책은 향후 제안으로 미루겠습니다.

__mul__을 정의하는 숫자가 아닌 Python 내장 타입(str, list, …)도 있습니다. 이러한 타입에도 __matmul__을 정의하지 않습니다. 도대체 그렇게 할 이유가 있겠습니까?

행렬 거듭제곱을 정의하지 않음

이 PEP의 이전 버전에서는 **에 대응하는 행렬 거듭제곱 연산자 @@도 제안했습니다. 그러나 추가로 검토한 결과, 그 유용성이 충분히 불분명하므로 당분간은 빼 두고, @에 대한 경험이 더 쌓인 뒤에 @@가 정말로 아쉽다고 판명될 경우에만 이 문제를 다시 검토하는 편이 낫다고 결정했습니다. [14]

새 연산자 추가에 대한 기각된 대안

지난 수십 년 동안 Python 수치 계산 커뮤니티는 행렬 곱셈과 원소별 곱셈 연산 사이의 긴장을 해소하기 위한 다양한 방법을 모색해 왔습니다. PEP 211과 PEP 225는 둘 다 2000년에 제안되어 2008년에 마지막으로 진지하게 논의되었으며 [9], 이 문제를 해결하기 위해 새 연산자를 추가하려던 초기 시도였지만 심각한 결함이 있었습니다. 특히 당시 Python 수치 계산 커뮤니티는 배열 객체에 적합한 API에 대해서도, 어떤 연산자가 필요하거나 유용할지에 대해서도 아직 합의에 이르지 못한 상태였습니다(예를 들어, PEP 225는 의미가 명시되지 않은 새 연산자 6개를 제안합니다). 그 이후의 경험을 통해 이제는 수치 계산 Python과 코어 Python 모두에 가장 좋은 해결책이 행렬 곱셈을 위한 단일 중위 연산자를 (이에 따라 필요해지는 @= 같은 다른 새 연산자들과 함께) 추가하는 것이라는 합의에 이르렀습니다.

여기서는 기각된 대안 몇 가지를 검토합니다.

__mul__을 행렬 곱셈으로 정의하는 두 번째 타입 사용: 앞에서 논의한 것처럼(Background: What’s wrong with the status quo?), 이 방법은 numpy.matrix 타입(그리고 Numeric과 numarray에 있던 그 전신들)을 통해 수년간 시도되었습니다. 그 결과 numpy 개발자와 다운스트림 패키지 개발자 모두 사이에서, 배열에 대해 서로 충돌하는 덕 타입이 존재함으로써 생기는 문제 때문에 numpy.matrix 타입은 사실상 절대 사용해서는 안 된다는 강력한 합의가 이루어졌습니다. (물론 그렇다면 __mul__을 오직 행렬 곱셈으로만 정의해야 한다고 주장할 수도 있겠지만, 그러면 원소별 곱셈에서 똑같은 문제가 생깁니다.) numpy.matrix 타입을 완전히 제거하려는 시도가 여러 차례 있었으며, 유일한 반대 인자(논거)는 초보자를 위해 수학 표기법과 코드 사이에 간단하고 명확한 매핑을 제공해야 할 필요성이 그 문제점보다 더 크다고 보는 교육자들에게서 나왔습니다(Transparent syntax is especially crucial for non-expert programmers 참조). 하지만 물론, 초보자를 선호되지 않는 문법으로 시작하게 한 뒤 나중에 전환하기를 기대하는 것은 그 나름의 문제를 일으킵니다. 두 타입을 두는 해결책은 병보다 더 나쁜 약입니다.

새 연산자를 대거 추가하거나, 중위 연산자를 정의하는 새로운 범용 문법을 추가하기: 이 방안은 대체로 파이썬답지 않고 BDFL의 결단으로 거듭 거부되었을 뿐만 아니라, 파리 한 마리를 잡으려고 대형 망치를 휘두르는 격입니다. 과학 분야 Python 커뮤니티에서는 다른 방법으로는 고칠 수 없는 단 하나의 골칫거리를 해결하는 데 행렬 곱셈용 연산자 하나를 추가하는 것으로 충분하다는 합의가 이루어져 있습니다. (돌이켜 보면 우리 모두 PEP 225도 나쁜 아이디어였다고, 적어도 필요 이상으로 훨씬 복잡했다고 생각합니다.)

일반 Python에서는 다른 의미를 갖는 새 @ 연산자(또는 다른 연산자)를 추가하고, 수치 코드에서 이를 오버로드하기: 이는 PEP 211이 택한 접근 방식으로, 이 PEP는 @를 itertools.product와 동등한 것으로 정의하자고 제안했습니다. 이 방식의 문제는, 그 자체로만 놓고 보면 itertools.product에 전용 연산자가 실제로 필요하지 않다는 점이 꽤 분명하다는 것입니다. 이것은 내장 함수로 둘 가치조차 없다고 여겨졌습니다. (이 PEP를 논의하는 과정에서 @를 범용 함수 합성 연산자로 정의하자는 비슷한 제안도 나왔지만, 이 역시 같은 문제를 안고 있습니다. functools.compose는 존재할 만큼 유용하지도 않습니다.) 행렬 곱셈에는 중위 연산자로 포함될 만한, 유례없이 강력한 근거가 있습니다. 앞으로 Python에 또 다른 중위 연산자를 추가할 만한 이항 연산은 거의 확실히 존재하지 않습니다.

배열 타입에 .dot 메서드를 추가하여 “유사 중위” A.dot(B) 문법을 허용하기: 이 방식은 몇 년 전부터 numpy에 있었으며, 많은 경우 dot(A, B)보다 낫습니다. 하지만 여전히 진정한 중위 표기법보다 가독성이 훨씬 떨어지며, 특히 괄호가 극도로 많아지는 문제를 여전히 안고 있습니다. 위의 Why should matrix multiplication be infix?를 참고하십시오.

‘with’ 블록을 사용하여 단일 코드 블록 안에서 * 의 의미를 전환하기: 예를 들어, numpy가 특별한 컨텍스트 객체를 정의하면 다음과 같이 작성할 수 있습니다.:

c = a * b   # element-wise multiplication
with numpy.mul_as_dot:
    c = a * b  # matrix multiplication

하지만 이 방식에는 두 가지 심각한 문제가 있습니다. 첫째, 모든 배열 유사 타입의 __mul__ 메서드가 어떤 전역 상태(numpy.mul_is_currently_dot 등)를 확인하는 방법을 알아야 합니다. a와 b가 numpy 객체라면 괜찮지만, 세상에는 numpy가 아닌 배열 유사 객체가 많이 있습니다. 따라서 이 방식은 비지역적 결합을 요구하거나(numpy와 경쟁하는 모든 라이브러리가 numpy를 임포트한 다음 모든 연산마다 numpy.mul_is_currently_dot를 확인해야 합니다), 그렇지 않으면 덕 타이핑을 깨뜨려서 위 코드가 a와 b가 numpy 객체인지 다른 종류의 객체인지에 따라 완전히 다르게 동작하게 됩니다. 둘째, 더 나쁜 점은 with 블록이 렉시컬 스코프가 아니라 동적 스코프를 가진다는 것입니다. 즉, with 블록 안에서 호출되는 모든 함수는 갑자기 자신이 mul_as_dot 세계 안에서 실행되고 있음을 알게 되고, 끔찍하게 망가져 버립니다. 그나마 운이 좋다면 말입니다. 따라서 이것은 상당히 제한된 경우(함수 호출이 없는 경우)에만 안전하게 사용할 수 있는 구문이며, 아무런 경고 없이 스스로 발등을 찍기가 매우 쉬워집니다.

수치 계산 지향 연산자와 어쩌면 다른 문법까지 추가하는 언어 전처리기를 사용하기: (최근 BDFL의 제안에 따라: [1]) 이 제안은 수치 코드에 매우 다양한 문법 추가가 필요하다는 생각에 기반한 것으로 보입니다. 실제로는 @만 있으면 대부분의 수치 계산 사용자에게는 다른 연산자나 문법이 필요하지 않습니다. @는 다른 수단으로는 해결할 수 없으며 더 넓은 생태계 전반에 고통스러운 파장을 일으키는, 정말로 고통스러운 단 하나의 문제를 해결합니다. 단 하나의 이항 연산자를 지원하려고 새 언어를 정의하는 것은(아마도 Python의 파서와 계속 동기화해야 하는 자체 파서 등이 필요할 것입니다) 실용적이지도 바람직하지도 않습니다. 수치 계산 분야에서 Python의 경쟁 상대는 특수 목적 수치 언어(Matlab, R, IDL 등)입니다. 이들과 비교할 때 Python의 결정적인 장점은 바로 특화된 수치 코드를 XML 파싱, 웹 페이지 생성, 데이터베이스 접근, 네트워크 프로그래밍, GUI 라이브러리 등을 위한 코드와 섞어 쓸 수 있다는 점입니다. 또한 Python을 사용하는 매우 다양한 튜토리얼, 참고 자료, 입문 강좌 등에서도 큰 이점을 얻습니다. “수치 계산용 Python”을 “진짜 Python”에서 갈라놓는 것은 큰 혼란을 불러올 것입니다. 이 PEP의 주요 동기 중 하나는 분열을 줄이는 것입니다. 전처리기를 설정해야 한다는 것은 숙련되지 않은 사용자에게 특히 감당하기 어려운 복잡성이 될 것입니다. 그리고 우리는 Python을 좋아하기 때문에 Python을 사용합니다! 우리는 Python과 거의 같지만 완전히 같지는 않은 언어를 원하지 않습니다.

잘 알려진 Python 레시피처럼 오버로딩 핵을 사용해 *dot* 같은 “새 중위 연산자” 정의하기: ([2] 참조) 아름다운 것이 추한 것보다 낫습니다. 이것은… 아름답지 않습니다. 그리고 파이썬답지도 않습니다. 그리고 특히 초보자에게 불친절합니다. 초보자들은 자신이 배우고 있는 이 마법 주문들 뒤에 일관된 기반 체계가 있다는 생각을 겨우 이해하려고 애쓰고 있는데, 바로 그때 이런 사악한 핵이 등장하기 때문입니다. 이 핵은 그 체계를 위반하고, 실수로 잘못 사용하면 기이한 오류 메시지를 만들어 내며, 그 기반 메커니즘은 객체 지향 시스템이 어떻게 동작하는지에 대한 깊은 지식 없이는 이해할 수 없습니다.

arr.M * arr 같은 문법을 지원하기 위해 특별한 “퍼사드(facade)” 타입 사용: 이 방식은 .M 속성이 기본적으로 arr *dot이 반환하는 것과 같은 객체를 반환한다는 점에서 앞의 제안과 매우 비슷하며, 따라서 ‘마법 같음’에 대한 같은 반대 의견을 피할 수 없습니다. 이 접근 방식에는 쉽게 드러나지 않는 복잡성도 있습니다. 예를 들어 arr.M * arr 연산은 배열을 반환해야 하지만 arr.M * arr.M 연산과 arr * arr.M 연산은 퍼사드 객체를 반환해야 합니다. 그렇지 않으면 arr.M * arr.M * arr 연산과 arr * arr.M * arr 연산이 동작하지 않습니다. 그런데 이는 퍼사드 객체가 다른 배열 객체와 다른 퍼사드 객체를 모두 인식할 수 있어야 한다는 뜻입니다(이 때문에 서로 다른 라이브러리에서 상호 운용되는 배열 타입을 작성하기가 더 복잡해집니다. 이제 각 라이브러리는 서로의 배열 타입과 퍼사드 타입을 모두 인식해야 하기 때문입니다). 또한 사용자가 arr * arr.M 또는 arr.M * arr.M 형태로 쉽게 입력하고 배열 객체를 돌려받으리라 기대하는 함정도 생깁니다. 실제로는 사용하려고 하면 오류를 일으키는 정체불명의 객체를 받게 됩니다. 결국 이 접근 방식에서 사용자는 .M* 기호를 중위 연산자로 동작하는 나눌 수 없는 단위로 생각하도록 주의해야 합니다. 그리고 중위 연산자처럼 생긴 토큰 문자열로 치자면 적어도 *dot*이 더 보기 좋습니다(귀여운 작은 귀를 보십시오!).

이 PEP에 대한 논의

참고용으로 여기에 모아 두었습니다:

참고 문헌