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 언어에 추가합니다:

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

이러한 메서드의 구현은 내장 타입이나 표준 라이브러리 타입에 추가되지 않습니다. 그러나 여러 프로젝트에서 이러한 연산에 권장되는 의미론에 합의했으며, 자세한 내용은 아래의 Intended usage details에서 확인하십시오.

이 연산자가 CPython에서 구현되는 방식에 관한 자세한 내용은 Implementation details을 참조하십시오.

동기

요약

수치 코드에는 Python의 *연산자 사용을 두고 경쟁하는 두 가지 중요한 연산이 있습니다. 요소별 곱셈과 행렬 곱셈입니다. Numeric 라이브러리가 처음 제안된 후 거의 20년 동안 이러한 긴장을 해결하려는 많은 시도가 있었지만 [13], 어느 것도 실제로 만족스럽지는 않았습니다. 현재 대부분의 수치 Python 코드는 요소별 곱셈에 *를 사용하고 행렬 곱셈에는 함수/메서드 구문을 사용합니다. 그러나 이로 인해 흔한 상황에서 코드가 보기 흉하고 읽기 어려워집니다. 문제가 심각하기 때문에 상당한 양의 코드가 계속해서 반대 규약을 사용하고 있으며, 이 규약은 다른 상황에서 보기 흉하고 읽기 어려운 코드를 만든다는 장점이 있습니다. 또한 코드베이스 간 이러한 API 분열은 더 많은 문제를 만들어 냅니다. 현재 Python 구문 내에서 수치 API를 설계하는 문제에 대한 좋은 해결책은 없는 것으로 보이며, 서로 다른 방식으로 좋지 않은 선택지들이 펼쳐져 있을 뿐입니다. 이러한 문제를 해결하기에 충분한 Python 구문의 최소 변경은 행렬 곱셈을 위한 단일 새로운 중위 연산자를 추가하는 것입니다.

행렬 곱셈에는 다른 이항 연산과 구별되는 특징들이 독특하게 결합되어 있으며, 이는 전용 중위 연산자를 추가해야 한다는 매우 강력한 근거를 함께 제공합니다:

  • 기존 수치 연산자와 마찬가지로 수학, 과학, 공학의 모든 분야에서 행렬 곱셈에 중위 표기법을 사용하는 방대한 선례가 존재합니다. @는 Python의 기존 연산자 체계에 있는 빈틈을 조화롭게 채웁니다.
  • @는 실제 코드의 의미를 크게 명확하게 합니다.
  • @는 읽기 어려운 코드와 API 분열로 특히 큰 어려움을 겪는 경험이 적은 사용자에게 더 원활한 입문 경로를 제공합니다.
  • @는 상당하고 지속적으로 증가하는 Python 사용자 커뮤니티의 일부에게 도움이 됩니다.
  • @는 자주 사용될 것입니다. 실제로 //나 비트 연산자보다 더 자주 사용될 수 있다는 증거가 있습니다.
  • @를 통해 Python 수치 커뮤니티는 분열을 줄이고, 마침내 모든 수치 배열 객체를 위한 단일 합의 덕 타입으로 표준화할 수 있습니다.

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

컴퓨터로 수치 계산을 수행할 때 우리는 보통 처리해야 할 숫자가 아주 많습니다. 숫자를 한 번에 하나씩 처리하려고 하면 번거롭고 느립니다. 특히 인터프리터 언어를 사용할 때 그렇습니다. 대신 큰 숫자 모음 전체에 한꺼번에 적용되는 간단한 연산을 작성할 수 있기를 원합니다. n-dimensional array는 이를 가능하게 하기 위해 널리 사용되는 모든 수치 계산 환경에서 사용하는 기본 객체입니다. 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 구문은 현재 단 하나의 곱셈 연산자 *만 허용하므로, 배열과 유사한 객체를 제공하는 라이브러리는 결정해야 합니다. 즉, *를 요소별 곱셈에 사용할지, 아니면 *를 행렬 곱셈에 사용할지 결정해야 합니다. 그리고 안타깝게도 범용 수치 계산에서는 두 연산이 모두 자주 사용되며, 두 경우 모두 함수 호출 구문보다 중위 구문을 사용하는 것이 크게 유리하다는 사실이 드러납니다. 따라서 어느 규칙이 최적인지, 심지어 받아들일 수 있는지조차 전혀 분명하지 않으며, 사례별로 달라지는 경우가 많습니다.

그럼에도 네트워크 효과 때문에 just one 규칙을 선택하는 것이 매우 중요합니다. 예를 들어 numpy는 서로 다른 __mul__ 메서드를 가진 두 가지 타입을 제공하므로, 기술적으로는 규칙 간 전환이 가능합니다. numpy.ndarray 객체에서는 *가 요소별 곱셈을 수행하며, 행렬 곱셈에는 함수 호출(numpy.dot)을 사용해야 합니다. numpy.matrix 객체에서는 *가 행렬 곱셈을 수행하며, 요소별 곱셈에는 함수 구문이 필요합니다. numpy.ndarray를 사용하여 코드를 작성해도 잘 작동합니다. numpy.matrix를 사용하여 코드를 작성해도 역시 잘 작동합니다. 그러나 이 두 코드 조각을 서로 통합하려고 하는 순간 문제가 시작됩니다. ndarray를 예상했는데 matrix를 받거나 그 반대인 코드는 충돌하거나 잘못된 결과를 반환할 수 있습니다. 어떤 함수가 어떤 타입을 입력으로 예상하고 어떤 타입을 출력으로 반환하는지 추적한 다음, 이를 계속 서로 변환하는 일은 엄청나게 번거롭고 어떤 규모에서도 올바르게 처리하기가 불가능합니다. 두 타입을 모두 입력으로 방어적으로 처리하고 적절히 동작하려는 함수는 isinstanceif 문으로 이루어진 늪 속에서 허우적거리게 됩니다.

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 같은 매우 유명한 여러 모듈(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는 전체 Python 세계에서 가장 많이 임포트되는 비표준 라이브러리 모듈일 가능성이 있음을 시사합니다. subprocess, math, pickle, threading과 같은 표준 라이브러리의 대표적인 모듈보다도 더 많이 임포트됩니다. 또한 numpy 사용자는 @ 연산자의 혜택을 받을 더 광범위한 수치 계산 커뮤니티의 일부에 불과합니다. 행렬은 한때 대학 연구실과 군사 클러스터에서 실행되는 Fortran 프로그램에 국한된 틈새 데이터 형식이었을 수 있지만, 그런 시절은 이미 오래전에 지나갔습니다. 수치 계산은 현대 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 용어를 사용합니다. 이는 일반적으로 사용되는 다른 모든 모델의 상위 집합이기 때문입니다. 이 모델에서는 모든 배열의 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)는 shape (2, 4)인 배열을 반환합니다.
  • 1차원 벡터 입력은 shape 앞이나 뒤에 ‘1’을 추가하여 2차원으로 승격하고, 연산을 수행한 다음 출력에서 추가된 차원을 제거합니다. 1은 항상 shape의 “바깥쪽”에 추가합니다. 왼쪽 인수에는 앞에 추가하고, 오른쪽 인수에는 뒤에 추가합니다. 그 결과 matrix @ vector와 vector @ matrix는 모두 유효하며(호환되는 shape라고 가정할 때), 둘 다 1차원 벡터를 반환하고, vector @ vector는 스칼라를 반환합니다. 예를 보면 더 명확합니다.
    • arr(2, 3) @ arr(3, 1)은 일반적인 행렬 곱이며, shape (2, 1)인 배열, 즉 열 벡터를 반환합니다.
    • arr(2, 3) @ arr(3)은 앞의 연산과 동일한 계산을 수행합니다. (즉, 1차원 벡터를 단일 을 포함하는 행렬(shape = (3, 1))로 취급합니다.) 그러나 결과를 shape (2), 즉 1차원 벡터로 반환합니다.
    • arr(1, 3) @ arr(3, 2)은 일반적인 행렬 곱이며, shape (1, 2)인 배열, 즉 행 벡터를 반환합니다.
    • arr(3) @ arr(3, 2)은 앞의 연산과 동일한 계산을 수행합니다. (즉, 1차원 벡터를 단일 을 포함하는 행렬(shape = (1, 3))로 취급합니다.) 그러나 결과를 shape (2), 즉 1차원 벡터로 반환합니다.
    • arr(1, 3) @ arr(3, 1)은 일반적인 행렬 곱이며, shape (1, 1)인 배열, 즉 행렬 형식의 단일 값을 반환합니다.
    • arr(3) @ arr(3)은 앞의 연산과 동일한 계산을 수행하지만, 결과를 shape (), 즉 행렬 형식이 아닌 단일 스칼라 값으로 반환합니다. 따라서 이는 벡터의 표준 내적입니다.

    1차원 벡터에 대한 이 정의의 한 가지 불완전한 점은 일부 경우 @를 결합법칙이 성립하지 않게 만든다는 것입니다((Mat1 @ vec) @ Mat2 != Mat1 @ (vec @ Mat2)). 그러나 이는 실용성이 순수성보다 우선하는 경우인 듯합니다. 결합법칙이 성립하지 않는 현상은 실제로는 작성하지 않을 이상한 표현에서만 발생하고, 그래도 작성된다면 어떤 일이 일어날지 이해할 수 있는 일관된 규칙이 있습니다(Mat1 @ vec @ Mat2(Mat1 @ vec) @ Mat2로 구문 분석되며, a - b - c와 같습니다). 또한 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개의 행렬을 shape (10, 2, 4)인 배열에 함께 반환합니다. 여기서의 직관은 이러한 숫자의 3차원 배열을 마치 행렬로 이루어진 1차원 배열인 것처럼 취급한 다음, 각 ‘요소’가 이제 전체 행렬인 상황에서 요소별 방식으로 행렬 곱셈을 적용한다는 것입니다. 브로드캐스팅은 완벽하게 정렬된 배열로 제한되지 않는다는 점에 유의하십시오. 더 복잡한 경우에는 배열이 서로 정렬되는 방식을 제어할 수 있는 간단하지만 강력한 여러 기법을 사용할 수 있습니다. 자세한 내용은 [10]에서 확인하십시오. (특히 브로드캐스팅을 고려하면 표준 스칼라 * 행렬 곱은 요소별 곱셈 연산자 *의 특수한 경우임을 알 수 있습니다.)

    한 피연산자가 >2d이고 다른 피연산자가 1d인 경우에는 브로드캐스팅 전에 1d->2d 승격을 수행하며, 위 규칙을 변경 없이 적용합니다. 예를 들어, arr(10, 2, 3) @ arr(3)는 먼저 arr(10, 2, 3) @ arr(3, 1)로 승격한 다음, 오른쪽 인자를 브로드캐스트하여 정렬된 연산 arr(10, 2, 3) @ arr(10, 3, 1)을 생성하고, 곱셈을 수행하여 shape (10, 2, 1)인 배열을 얻은 뒤, 마지막으로 추가된 차원을 제거하여 shape (10, 2)인 배열을 반환합니다. 마찬가지로 arr(2) @ arr(10, 2, 3)는 shape (10, 1, 3)인 중간 배열과 shape (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가 해결하려는 기본 문제를 마주하지 않으므로 이 문제는 이들에게 대부분 무관합니다. 표면적으로는 numpy와 같은 프로젝트와 비슷해 보이지만, 실제로는 상당히 다른 작업을 수행합니다. 이들은 행렬 곱셈(그리고 군 작용 등)에 *를 사용하며, 이 PEP가 받아들여지더라도 이를 계속 사용하면서 @를 별칭으로 추가할 수도 있다는 의사를 밝혔습니다. 이러한 프로젝트에는 다음이 포함됩니다.

  • sympy
  • sage

구현 세부 사항

일반적인 의미론에 따라 새로운 함수 operator.matmuloperator.__matmul__이 표준 라이브러리에 추가됩니다.

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

MatMult라는 이름의 새로운 AST 노드와 함께 새로운 토큰 ATEQUAL및 새로운 바이트코드 연산 코드 BINARY_MATRIX_MULTIPLYINPLACE_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는 실제로 정반대의 의미, 즉 행렬/내적이 아니라 원소별 곱셈인 “하다마드 곱” 또는 외적을 나타내는 데 관례적으로 사용됩니다.) 적어도 @는 재미있는 비가환 연산자처럼 looks보인다는 장점이 있습니다. 수학은 알지만 프로그래밍은 모르는 순진한 사용자는 A * BA × B, A * BA B, 또는 A * BA ° B를 보고 어느 것이 일반적인 곱셈이고 어느 것이 특수한 경우인지 추측할 수 없을 것입니다.

마지막으로 여러 문자 토큰을 사용하는 선택지도 있습니다. 선택지로는 다음이 있습니다.

  • Matlab과 Julia는 .* 연산자를 사용합니다. 시각적으로 *와 혼동될 수 있을 뿐 아니라, Matlab과 Julia에서는 *가 행렬 곱셈을 의미하고 .*가 원소별 곱셈을 의미하므로 이것은 우리에게 끔찍한 선택입니다. 따라서 행렬 곱셈에 .*를 사용하면 Matlab과 Julia 사용자들이 예상하는 것과 정확히 반대가 됩니다.
  • APL은 다중 문자 토큰, 혼동을 일으키는 속성 접근과 유사한 . 구문, 유니코드 문자를 결합하는 +.×를 사용했던 것으로 보이며, 이 토큰은 후보 목록에서 U+2603 SNOWMAN보다도 낮은 순위에 있습니다. 행렬 곱셈이 실제로 작동하는 방식을 연상시키는 덧셈 및 곱셈 연산자 결합이라는 아이디어가 마음에 든다면 +* 같은 것을 사용할 수 있습니다. 다만 이것은 단항 + 연산자와 결합한 곱셈일 뿐인 *+와 너무 쉽게 혼동될 수 있습니다.
  • PEP 211에서는 ~*를 제안했습니다. 이는 단항 * 연산자가 단항 ~와 결합된다는 식으로 암시한다는 단점이 있지만, 사용할 수는 있습니다.
  • R은 행렬 곱셈에 %*%를 사용합니다. R에서 이는 %foo% 형식의 모든 토큰이 사용자 정의 이항 연산자인, 일반적인 확장 가능 중위 표기 시스템의 일부를 구성합니다. 시스템은 훔치지 않고 토큰만 훔칠 수 있습니다.
  • 제안된 다른 그럴듯한 후보로는 ><(= 곱셈 기호 ×를 ASCII로 그린 모양), 각주 연산자인 [*] 또는 |*|가 있습니다. 다만 문맥에서 사용될 때 수직 그룹화 기호를 사용하면, 우리가 벗어나려는 함수 구문의 주요 단점 중 하나로 지적되었던 중첩 괄호의 시각적 복잡성이 다시 나타나는 경향이 있습니다. ^*도 있습니다.

따라서 크게 중요하지는 않지만, @는 다른 어떤 대안만큼이나, 또는 그보다 더 좋아 보입니다.

  • 이는 파이썬 사용자들이 데코레이터에서 이미 입력하는 데 익숙한 친숙한 문자이지만, 데코레이터 용법과 수학식 용법은 충분히 달라서 실제로 혼동하기 어렵습니다.
  • 이는 키보드 배열 전반에서 쉽게 사용할 수 있으며, 이메일 주소에서 사용되기 때문에 휴대전화의 키보드처럼 특이한 키보드에서도 그렇습니다.
  • *처럼 둥근 모양입니다.
  • mATrices라는 연상 기호가 귀엽습니다.
  • 소용돌이 모양은 행과 열을 동시에 훑는, 행렬 곱셈을 정의하는 과정을 연상시킵니다.
  • 비대칭성은 비가환적 특성을 연상시킵니다.
  • 어쨌든 무언가는 선택해야 합니다.

우선순위와 결합성

@를 오른쪽 결합으로 할지 왼쪽 결합으로 할지(또는 더 특이한 방식으로 할지 [18])에 관해 긴 논의가 있었습니다 [15]. 파이썬 연산자는 거의 모두 왼쪽 결합이므로 이 관례를 따르는 것이 가장 간단한 접근법이지만, 행렬 곱셈을 특별한 경우로서 오른쪽 결합으로 만드는 것이 가치 있을 수 있음을 시사하는 두 가지 주장이 있었습니다.

첫째, 행렬 곱셈은 함수 적용 및 합성과 개념적으로 밀접하게 연관되어 있으므로, 수학적으로 정교한 많은 사용자는 RSx 같은 식이 오른쪽에서 왼쪽으로 진행된다고 직관적으로 생각합니다. 먼저 S가 벡터 x를 변환하고, 이어서 R이 그 결과를 변환한다는 의미입니다. 이는 보편적으로 합의된 사항은 아니며(이러한 직관을 뒷받침하는 순수 수학적 개념 체계에 익숙하지 않은 수치 계산 전문가도 모두 그런 것은 아닙니다 [16]), 적어도 이러한 직관은 모두가 왼쪽에서 오른쪽으로 읽는 2⋅3⋅4 같은 다른 연산의 경우보다 더 일반적입니다.

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

그러나 이러한 주장에 맞서는 사항은 다음과 같습니다.

효율성 주장과 관련하여, 실제 코드에서 Mat @ Mat @ vec 유형의 식이 실제로 지배적이라는 증거를 경험적으로 전혀 찾지 못했습니다. numpy를 사용하는 여러 대규모 프로젝트를 분석하면서, numpy의 현재 함수 호출 구문 때문에 dot의 중첩 호출에 대한 연산 순서를 선택해야 할 때 사람들이 실제로는 오른쪽 결합 중첩보다 왼쪽 결합 중첩을 약간 자주 사용한다는 사실을 발견했습니다 [17]. 또한, 괄호를 작성하는 것은 그다지 나쁘지 않습니다. 효율성을 중시하는 프로그래머가 어떤 표현식을 평가하는 최선의 방법을 고민하는 수고를 감수한다면, 괄호가 필요한지 여부와 관계없이 괄호를 작성해야 합니다. 그래야 다음 독자에게 연산 순서가 중요하다는 점을 분명히 보여 줄 수 있습니다.

또한 선형대수에 훨씬 더 중점을 두는 언어를 포함한 다른 언어들도 대부분 matmul 연산자를 좌결합으로 만든다는 사실이 밝혀졌습니다. 구체적으로 @에 해당하는 연산자는 R, Matlab, Julia, IDL 및 Gauss에서 좌결합입니다. 저희가 찾은 유일한 예외는 a @ b @ cdot(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 수치 계산 커뮤니티는 행렬 곱셈과 요소별 곱셈 연산 사이의 긴장을 해결할 다양한 방법을 모색해 왔습니다. 2000년에 각각 제안되었고 2008년에 마지막으로 진지하게 논의된 PEP 211PEP 225[9]은 이 문제를 해결하기 위해 새 연산자를 추가하려는 초기 시도였지만 심각한 결함이 있었습니다. 특히 당시에는 Python 수치 계산 커뮤니티가 배열 객체에 적합한 API나 어떤 연산자가 필요하고 유용할 수 있는지에 대해 아직 합의에 이르지 못했습니다(예를 들어 PEP 225는 의미론이 지정되지 않은 새 연산자 6개를 제안합니다). 이후의 경험을 통해 이제 수치 계산 Python과 핵심 Python 모두에 가장 적합한 해결책은 행렬 곱셈을 위한 단일 중위 연산자를 추가하는 것이라는 합의에 이르렀습니다. 이는 @=와 같은 다른 새 연산자도 함께 추가한다는 의미입니다.

여기에서는 거부된 대안 중 일부를 검토합니다.

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

새 연산자를 많이 추가하거나, 중위 연산자를 정의하기 위한 새로운 범용 구문을 추가하십시오: 일반적으로 파이썬답지 않을 뿐 아니라 BDFL의 결정으로 반복해서 거부되어 온 방식이며, 이는 파리를 잡기 위해 큰 망치를 휘두르는 것과 같습니다. 과학 파이썬 커뮤니티에서는 행렬 곱셈을 위한 연산자 하나를 추가하는 것만으로도 다른 방법으로는 해결할 수 없는 유일한 고충을 해결하기에 충분하다는 데 합의하고 있습니다. (돌이켜 보면 우리 모두 PEP 225은 나쁜 아이디어였다고 생각합니다. 아니면 적어도 필요 이상으로 훨씬 복잡했다고 생각합니다.)

일반적인 파이썬에서 다른 의미를 가지는 새로운 @ 연산자(또는 무엇이든)를 추가한 다음, 수치 코드에서 이를 오버로드하십시오: 이는 PEP 211에서 취한 접근 방식으로, @itertools.product와 동등하게 정의하자고 제안했습니다. 이 방식의 문제는 자체적인 의미만 놓고 보더라도 itertools.product에 전용 연산자가 실제로 필요하지 않다는 점이 매우 분명하다는 것입니다. 이는 내장 기능으로 만들 가치조차 있다고 여겨지지 않았습니다. (이 PEP를 논의하는 동안 @를 범용 함수 합성 연산자로 정의하자는 비슷한 제안이 나왔지만, 이 역시 같은 문제를 안고 있습니다. functools.compose는 존재할 만큼 유용하지도 않습니다.) 행렬 곱셈은 중위 연산자로 포함해야 할 매우 독특하고 강력한 근거를 가지고 있습니다. 앞으로 다른 중위 연산자를 파이썬에 추가하는 것을 정당화할 만한 다른 이항 연산은 거의 확실히 존재하지 않습니다.

“의사 중위” A.dot(B) 구문을 사용할 수 있도록 배열 타입에 .dot 메서드를 추가하십시오: 이는 수년 동안 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 또는 무엇이든)를 확인하는 방법을 알고 있어야 합니다. ab가 numpy 객체라면 이는 괜찮지만, 세상에는 numpy가 아닌 배열 유사 객체가 많이 존재합니다. 따라서 이는 비국소적 결합을 필요로 하거나, 덕 타이핑을 깨뜨립니다. 비국소적 결합의 경우 모든 numpy 경쟁 라이브러리가 numpy를 가져온 다음 모든 연산에서 numpy.mul_is_currently_dot을 확인해야 하며, 덕 타이핑이 깨지는 경우에는 위 코드가 ab가 numpy 객체인지 아니면 다른 종류의 객체인지에 따라 근본적으로 다른 작업을 수행하게 됩니다. 둘째, 더 나쁜 점은 with 블록이 어휘적 범위가 아니라 동적 범위를 가진다는 것입니다. 즉, with 블록 안에서 호출되는 모든 함수가 갑자기 자신이 mul_as_dot 세계 안에서 실행되고 있음을 깨닫게 되며, 운이 좋을 때에나 심각하게 충돌하고 망가집니다. 따라서 이는 매우 제한적인 경우(함수 호출 없음)에만 안전하게 사용할 수 있는 구성이고, 아무런 경고 없이 스스로 발등을 찍기 매우 쉽게 만듭니다.

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

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

특수한 “facade” 타입을 사용하여 arr.M * arr와 같은 구문을 지원하십시오: 이는 이전 제안과 매우 유사합니다. .M속성은 기본적으로 arr *dot이 반환할 것과 동일한 객체를 반환하므로, ‘마법 같은’ 동작에 대한 동일한 반론에 부딪힙니다. 이 접근 방식에는 눈에 잘 띄지 않는 복잡성도 있습니다. 예를 들어 arr.M * arr는 배열을 반환해야 하지만, arr.M * arr.Marr * arr.M는 파사드 객체를 반환해야 합니다. 그렇지 않으면 arr.M * arr.M * arrarr * arr.M * arr가 작동하지 않습니다. 그러나 이는 파사드 객체가 다른 배열 객체와 다른 파사드 객체를 모두 인식할 수 있어야 한다는 뜻입니다. (이제 서로의 배열 타입과 파사드 타입을 모두 인식해야 하는 서로 다른 라이브러리의 배열 타입을 상호 운용 가능하게 작성하는 일이 추가로 복잡해집니다.) 또한 사용자가 arr * arr.M또는 arr.M * arr.M를 쉽게 입력하고 배열 객체를 돌려받을 것으로 예상할 수 있지만, 실제로는 사용하려고 할 때 오류를 발생시키는 정체불명의 객체를 받게 되는 함정도 있습니다. 기본적으로 이 접근 방식에서는 사용자가 .M*를 중위 연산자처럼 작동하는 분리할 수 없는 단위로 생각하도록 주의해야 합니다. 중위 연산자와 비슷한 토큰 문자열 중에서는 적어도 *dot*이 더 보기 좋습니다(귀여운 작은 귀를 보십시오!).

이 PEP에 관한 논의

참조를 위해 여기에 모았습니다.

참조 문헌