# requires\_grad 속성과 중간 연산 추적

PyTorch에서 텐서 연산을 다룰 때, `requires_grad` 속성은 자동 미분 기능을 활성화하는 중요한 역할을 한다. `requires_grad=True`로 설정된 텐서는 모든 연산 과정을 추적하며, 이를 통해 손쉽게 그레이디언트를 계산할 수 있다. 이 속성을 이해하고 적절하게 사용하는 것은 PyTorch에서 신경망을 구현하거나 학습을 최적화하는 데 필수적이다.

### requires\_grad 속성의 역할

일반적으로 `requires_grad` 속성은 텐서가 학습 가능한 파라미터인지 여부를 결정한다. 예를 들어, 신경망의 가중치나 편향과 같은 파라미터들은 학습 과정에서 그레이디언트가 필요하므로 `requires_grad=True`로 설정된다. 반대로 학습과 무관한 값들은 `requires_grad=False`로 설정하여 연산 추적 및 메모리 사용을 방지할 수 있다.

다음은 간단한 예이다:

```python
import torch

# requires_grad=True로 설정된 텐서
x = torch.tensor([2.0, 3.0], requires_grad=True)
y = x ** 2  # 연산이 추적된다.
```

위 코드에서 ( x )는 `requires_grad=True`로 설정되어 있으므로, 이후 연산인 ( y = x^2 )도 그 연산의 그래프가 자동으로 생성되고 추적된다.

### 중간 연산과 그래프 추적

PyTorch에서 `requires_grad=True`로 설정된 텐서로 연산이 이루어지면, 각 연산은 그래프에 기록되고, 이 그래프는 연산의 흐름을 표현한다. 이를 통해 역방향 그래디언트 계산 시에 각 연산의 영향을 자동으로 추적하여 미분을 수행할 수 있다.

아래의 그림은 간단한 중간 연산 추적을 나타낸다:

{% @mermaid/diagram content="graph TD;
A\[x (requires\_grad=True)] --> B\[y = x^2];
B --> C\[z = 2 \* y];
C --> D\[backward()]" %}

위의 그래프에서 ( x )가 `requires_grad=True`로 설정되어 있고, 이를 이용한 연산 ( y ), ( z )는 모두 추적되어 역방향 연산에 사용된다. 이러한 그래프는 연산을 수행할 때 동적으로 생성되며, 필요 시 해제되어 메모리를 절약할 수 있다.

### 연산 그래프의 동적 생성

연산 그래프는 동적으로 생성되며, 한 번 연산이 완료된 이후에도 새로운 텐서 연산이 발생할 때마다 새롭게 생성된다. 예를 들어:

```python
x = torch.tensor([2.0, 3.0], requires_grad=True)
y = x ** 2
z = 2 * y + 3
```

여기서 ( y )와 ( z )는 각각 ( x )의 중간 연산의 결과이며, PyTorch는 이 과정에서 자동으로 연산 그래프를 생성하여 모든 중간 연산을 추적한다.

수식으로 나타내면 다음과 같다:

\[ \mathbf{x} = \begin{bmatrix} 2.0 \ 3.0 \end{bmatrix}, \quad \mathbf{y} = \mathbf{x}^2, \quad \mathbf{z} = 2\mathbf{y} + 3 ]

이 연산의 결과로 생성된 그래프는 역방향으로 미분할 때 자동으로 그래디언트를 계산한다.

### 연산 결과와 requires\_grad 속성

연산의 결과로 나온 새로운 텐서는 기존의 `requires_grad` 속성을 상속받는다. 만약 원래의 입력 텐서가 `requires_grad=True`였다면, 모든 중간 연산 결과들도 자동으로 `requires_grad=True`를 가지게 된다. 이러한 점은 학습 중에 불필요한 연산을 피하는 데 유용하다.

예를 들어, 학습 과정에서 입력 데이터를 전처리할 때 `requires_grad`를 꺼두면, PyTorch는 해당 연산을 추적하지 않으며 메모리와 계산 비용을 절약할 수 있다.

```python
x = torch.tensor([1.0, 2.0], requires_grad=True)
with torch.no_grad():
    y = x * 2  # 추적하지 않음
```

위의 코드에서 `torch.no_grad()` 컨텍스트 내부의 연산은 `requires_grad`와 관계없이 추적되지 않는다. 이는 파라미터를 업데이트하지 않는 테스트 과정 등에서 유용하게 사용된다.

### 연산 추적과 메모리 관리

PyTorch에서 연산 그래프는 역전파를 위해 생성되며, 그 과정에서 각 연산의 중간 결과와 연결 정보를 메모리에 저장한다. 그러나 이러한 연산 추적은 메모리를 많이 사용하기 때문에 필요하지 않은 연산에 대해서는 추적을 피하는 것이 좋다.

연산 추적을 중단하거나 메모리를 절약하는 방법은 다음과 같다:

1. **`torch.no_grad()`**: 이 컨텍스트 매니저는 블록 내의 모든 연산이 추적되지 않도록 설정한다. 학습 과정이 아닌 추론(inference) 단계에서 주로 사용된다.
2. **`.detach()`**: 특정 텐서에서 연산 그래프를 분리하여 추적을 방지한다. 새로운 텐서를 반환하지만, 원래 텐서와 동일한 값을 가지면서도 연산 그래프에서는 분리된다.
3. **`retain_graph=True` 옵션**: `backward()`를 호출할 때, 기본적으로 사용된 그래프는 해제된다. 그래프를 유지하고 싶다면 `retain_graph=True`를 명시해야 한다. 이 옵션은 연속해서 역방향 계산이 필요한 경우 유용하지만, 메모리를 더 사용하게 된다.

### 예제: 연산 그래프 해제

일반적으로 `backward()` 함수가 호출되면 연산 그래프는 해제되어 메모리를 확보한다. 그러나 특정 시점에서 동일한 그래프를 반복적으로 사용하여 여러 번의 그레이디언트를 계산해야 할 경우 `retain_graph=True`를 사용하여 그래프를 유지할 수 있다.

```python
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2
y.backward(torch.tensor([1.0, 1.0, 1.0]), retain_graph=True)  # 그래프 유지
y.backward(torch.tensor([1.0, 1.0, 1.0]))  # 두 번째 호출 시에도 동작
```

위의 코드에서 `retain_graph=True`가 설정되어 있어, `y.backward()`를 여러 번 호출해도 그래프가 유지된다. 하지만 이 옵션은 메모리 사용량을 증가시킬 수 있으므로 필요할 때만 사용해야 한다.

### 그래디언트 흐름 차단과 detach()의 사용

텐서의 연산 중 특정 연산이 그래디언트를 필요로 하지 않는 경우 `detach()` 메서드를 사용하여 그래디언트 흐름을 차단할 수 있다. 예를 들어:

```python
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x ** 2
z = y.detach()  # z는 requires_grad=False로 설정됨
```

위 코드에서 `z`는 `y`와 동일한 값을 가지지만, 연산 그래프에서 분리되어 그래디언트 추적이 이루어지지 않는다. 이 방식은 데이터 변형 중에 불필요한 연산을 제거할 때 유용하게 사용할 수 있다.

수식으로 나타내면:

\[ \mathbf{y} = \mathbf{x}^2, \quad \mathbf{z} = \mathbf{y}\_{\text{detach}} ]

여기서 (\mathbf{z})는 더 이상 역방향 미분을 위한 그래프를 추적하지 않는다.

### 연산 추적과 requires\_grad의 중요성

모델 학습 과정에서 주의해야 할 점은 연산 추적이 자동으로 일어나지만, 모든 텐서가 필요하지 않다는 것이다. 예를 들어, 입력 데이터 또는 단순 상수 텐서는 `requires_grad=False`로 설정하여 불필요한 메모리 사용을 줄일 수 있다.

특히 파이토치의 자동 미분은 다음과 같은 상황에서 연산 그래프를 활용한다:

* **역전파 (Backpropagation)**: `backward()` 함수를 호출할 때, `requires_grad=True`로 설정된 텐서의 연산 그래프를 따라가며 모든 연산에 대한 미분을 자동으로 계산한다.
* **그레이디언트 업데이트**: 미분 값을 이용해 파라미터를 업데이트하며, 이 과정에서 사용된 연산의 추적을 통해 그레이디언트가 계산된다.

코드로 보자면:

```python
w = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
b = torch.tensor([0.5], requires_grad=True)
output = w * 3 + b
output.sum().backward()
```

이 코드에서 `w`와 `b`는 `requires_grad=True`로 설정되었고, 연산의 결과는 `output`을 통해 최종 손실 함수로 이어진다. `backward()` 호출 시, 파라미터 `w`와 `b`의 그레이디언트가 자동으로 계산된다.

### 중간 연산의 그래디언트 흐름

PyTorch에서 `requires_grad=True`인 텐서가 연산에 참여하면, 그 텐서를 이용한 중간 연산들은 자동으로 연산 그래프의 일부가 된다. 이로 인해 연산이 여러 단계로 이루어졌을 때, 각 단계마다의 그래디언트 흐름을 자동으로 추적할 수 있게 된다. 이는 신경망의 역전파 과정에서 필수적인 기능이다.

예를 들어, 다음과 같은 계산 과정을 살펴보자:

```python
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
z = 3 * y + 4
w = z ** 2
w.backward()
```

이 코드에서 `x`로부터 시작된 연산은 여러 단계로 이어지며, 모든 중간 연산이 그래프에 기록된다. 연산 과정을 수식으로 나타내면:

\[ y = \mathbf{x}^2, \quad z = 3y + 4, \quad w = z^2 ]

연산 그래프는 다음과 같다:

{% @mermaid/diagram content="graph TD;
A\[x (requires\_grad=True)] --> B\[y = x^2];
B --> C\[z = 3y + 4];
C --> D\[w = z^2];
D --> E\[backward()]" %}

### 역전파 과정에서의 미분 계산

`backward()` 함수가 호출되면, 연산 그래프의 끝에서부터 시작하여 역방향으로 각 노드의 미분이 자동으로 계산된다. 이는 체인 룰(chain rule)에 의해 이루어지며, 각 연산 단계에서 미분 값이 누적되어 최종 입력 텐서의 그래디언트를 계산하게 된다.

위의 예제를 바탕으로 구체적인 미분 과정을 살펴보자:

\[ \frac{\partial w}{\partial z} = 2z, \quad \frac{\partial z}{\partial y} = 3, \quad \frac{\partial y}{\partial x} = 2x ]

따라서, 연쇄 법칙을 적용하여 다음과 같이 ( \frac{\partial w}{\partial x} )를 구할 수 있다:

\[ \frac{\partial w}{\partial x} = \frac{\partial w}{\partial z} \cdot \frac{\partial z}{\partial y} \cdot \frac{\partial y}{\partial x} = 2z \cdot 3 \cdot 2x ]

이러한 미분 계산은 PyTorch 내부적으로 자동으로 수행되며, 사용자는 `backward()` 함수를 호출하는 것만으로 최종 결과를 얻을 수 있다. 코드의 마지막 줄에서 `w.backward()`를 호출하면, `x.grad`에는 ( \frac{\partial w}{\partial x} )의 값이 저장된다.

### retain\_graph와 detach 사용의 예

학습 과정에서는 주로 한 번의 역전파로 그래디언트를 계산하고, 그 그래프는 해제된다. 하지만 때로는 동일한 연산 그래프를 사용하여 여러 번의 역전파를 해야 하는 상황이 발생할 수 있다. 이때 `retain_graph=True`를 사용하거나, 중간 연산의 그래디언트를 필요로 하지 않을 경우 `detach()`를 사용하여 메모리 낭비를 방지할 수 있다.

#### 예시 1: retain\_graph 사용

```python
x = torch.tensor([1.0], requires_grad=True)
y = x ** 2
y.backward(retain_graph=True)  # 그래프 유지
y.backward()  # 두 번째 backward 호출 가능
```

이 코드에서 `retain_graph=True`가 설정되어 있어, 첫 번째 `backward()` 이후에도 그래프가 해제되지 않는다. 따라서 동일한 그래프를 다시 사용하여 두 번째 미분을 수행할 수 있다.

#### 예시 2: detach 사용

```python
a = torch.tensor([2.0, 3.0], requires_grad=True)
b = a * 2
c = b.detach()  # b의 그래프에서 분리된 텐서 생성
d = c ** 2  # c는 requires_grad=False 상태로 연산 수행
```

여기서 `c`는 `b.detach()`에 의해 그래프에서 분리되었으므로, `d`를 구할 때에는 자동 미분 추적이 수행되지 않는다. 이는 신경망의 특정 부분이 고정되거나, 역전파 계산이 필요 없는 연산을 포함할 때 유용하다.

### 연산 중단과 입력-출력 간의 관계

실제로 신경망 학습 시에는 특정 연산의 추적을 중단하거나, 입력 데이터가 신경망으로 들어올 때 연산의 흐름을 명확하게 제어하는 것이 중요하다. 예를 들어, 모델 학습 중간에 평가 단계가 들어간다면, 불필요한 연산을 막기 위해 `torch.no_grad()` 컨텍스트를 사용하여 추적을 비활성화할 수 있다.

```python
with torch.no_grad():
    output = model(test_input)  # 학습 중이 아닌 상태로 추론 수행
```

이와 같이 `torch.no_grad()` 블록을 사용하면 모든 텐서의 `requires_grad` 속성은 임시로 `False`가 되어, 메모리 사용과 연산 속도가 최적화된다.
