[IndexError] NeighborSampler와 n_id

강하예진(Erica)·2023년 5월 4일
0

I Nailed It!

목록 보기
2/4

본문에 앞서, 어디선가 복붙해온 것은 하나도 없으며 모두 직접 작성했음을 미리 알린다.
또한 개념 단어는 한국어, 영어 표기가 혼용되어 있다. 편의상 한국어로 더 많이 쓰고 있다.


1. [인덱스 범위 에러]

1. 코드의 일부 또는 전체 (특히 IndexError가 발생하는 부분):

def train(train_loader):
    model.train()
    total_loss = 0
    for batch_size, n_id, adjs in train_loader:
        edge_index_list = [adj.edge_index.to(device) for adj in adjs]
        edge_attr_list = [data.edge_attr.to(device) for _ in adjs]
        optimizer.zero_grad()
        out = model(data.x[n_id].to(device), edge_index_list, edge_attr_list)
        predictions = out[:batch_size]
        loss = criterion(predictions, data.y[n_id[:batch_size]].to(device))
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(train_loader)

2. 시도한 방법들에 대한 자세한 설명 :

1st 시도 - train, test 로 원본 데이터 프레임을 분할한 후, 그것을 각각 별개의 graph data로 구성하고, 그 다음 그것을 DataLoader로 train 함수에 전달하는 방법 사용.
2st 시도- 회귀 문제를 푸는 것이 task였다면, DataLoader가 아닌 NeighborSampler로 지역적인 노드 정보를 취합해 학습에 반영했어야 한다는 사실을 뒤늦게 알았다. 그래서 train, test 로 원본 데이터 프레임을 분할한 후, 그것을 각각 별개의 graph data로 구성하고, NeighborSampler로 학습 함수에 전달하도록 코드를 수정했다.
3st 시도- 그럼에도 계속 같은 인덱스 에러가 발생. train_loss 부분에서, out =model() 함수에 전달되는 data.x[n_id]가 를 일으킨다.
4st 시도 - train, test 로 원본 데이터 프레임을 분할하는 방법이 아니라, 원본 데이터를 하나의 Graph data로 구성하고, 거기에 train_mask, test_mask를 씌워 데이터를 분할하는 방식으로 코드를 수정했다.

3. IndexError가 발생할 때의 오류 메시지 :

Cell In[38], line 9
      6 epoch_num = 10
      8 for epoch in tqdm(range(1, epoch_num+1)):
----> 9     train_loss = train(train_loader)
     10     print(f'Epoch {epoch:02d}, Train Loss: {train_loss:.4f}')
     11     scheduler.step()
Cell In[36], line 9, in train(train_loader)
      6 edge_attr_list = [data.edge_attr.to(device) for _ in adjs]
      8 optimizer.zero_grad()
----> 9 out = model(data.x[n_id].to(device), edge_index_list, edge_attr_list)
     10 predictions = out[:batch_size]
     11 loss = criterion(predictions, data.y[n_id[:batch_size]].to(device))
IndexError: index 627780 is out of bounds for dimension 0 with size 326697

4. 데이터셋에 관한 정보, 예를 들어 데이터의 구조, 크기 :

data = Data(x=torch.tensor(node_features, dtype=torch.float), edge_index=edge_index, edge_attr=edge_attr, y=y)
Data의 shape : (x=[326697, 30], edge_index=[2, 871393], edge_attr=[871393, 1], y=[871393, 1], train_mask=[871393], test_mask=[871393])

train_indices, test_indices, train_ratings, test_ratings = train_test_split(np.arange(edges.shape[0]), ratings.cpu().numpy(), test_size=0.2, random_state=42)
train_mask = torch.zeros(edges.shape[0], dtype=torch.bool)
test_mask = torch.zeros(edges.shape[0], dtype=torch.bool)
train_mask[train_indices] = True
test_mask[test_indices] = True
data.train_mask = train_mask
data.test_mask = test_mask
train_idx = data.train_mask.nonzero(as_tuple=False).view(-1)
test_idx = data.test_mask.nonzero(as_tuple=False).view(-1)
train_loader = NeighborSampler(data.edge_index, node_idx=train_idx, sizes=[10, 5], batch_size=32, shuffle=True, num_nodes=data.num_nodes)
test_loader = NeighborSampler(data.edge_index, node_idx=test_idx, sizes=[10, 5], batch_size=32, shuffle=False, num_nodes=data.num_nodes)

@@@@@@@@@@@ 문제 해결 방법

  1. 각각의 개념과 변수들의 쓰임을 완벽히 이해했다.

대체 무엇 때문에 에러가 발생한 것인지, 어디서 어긋나고 있는 것인지 파악하니 에러가 해결되었다.
내가 겪은 문제는 결국 n_id의 구성 오류였고, n_id는 NeighborSampler의 인자로 전달되는 node_idx를 제대로 구성해야 했다.
그렇다면 node_idx는 어떻게 해야 제대로 구성되는가?
train, test 데이터셋을 분할해야만 하이퍼 파라미터 튜닝이 가능하므로, 나는 먼저 random split으로 원본 dataset을 나눈 뒤, 각각의 데이터프레임에서 train_user_ids, train_book_ids, test_user_ids, test_book_ids를 찾아냈다. 이것은 각각 trian 데이터셋에 속한 unique한 유저 노드 ID와 도서 노드 ID를 추출한 것이다.
그 다음, train 관련 항목끼리/test 관련 항목끼리 이어붙여 train_node_ids와 test_node_ids로 합쳐냈다. 이렇게 각 데이터프레임의 노드 ID들을 추출해 내면, 필연적으로 edge_list 쌍을 유지하며 노드 ID들이 분할된다. 이것은 매우 중요한데, 내가 하고자 하는 것이 도서 평점을 예측하는 회귀 Task였기 때문이다.
이렇게 만들어낸 train_node_ids와 test_node_ids는 NeighborSampler의 node_idx로 전달하기 위해, tensor 형식으로 변환되어야 한다. 나는 torch.long의 형태로 각각 변환을 수행했다.

  1. 추가로 masking 점검을 진행했다.

Index Error라면 mask 생성에서도 문제가 발생하고 있을 확률이 높다는 것을 깨달았기 때문이다. 나는 원본 데이터를 하나의 graph로 구성한 다음, 그 다음에 mask를 통해 trian/test 세트로 나누는 방식을 차용했다. 이 방식이 메모리 효율성도 높고, 코드도 간결하기 때문이다. node 및 edge feature 정보 유지에도 용이하다.
더 정확히 말하자면, trian/test mask를 생성한 다음에 그래프 data 객체를 만드는 것이 좋은 순서다. train_mask 변수와 test_mask 변수를 Data() 메서드에 함께 전달해야 하기 때문이다.
따라서 나는 train_mask를 만들기 위해, num_nodes를 변수를 추가로 생성했다. (data.num_nodes를 사용하지 못한 건, 아직 data 그래프 객체를 생성하기 전이기 때문이다.) 물론 간단한 작업이다. 전체 노드의 개수를 담은 변수이기 때문이다. train과 test를 나누어야 하는데, 왜 마스크는 전체 노드의 개수로 초기화되어야 할까?
이유는 이렇다. train_mask(그리고 test_mask)를 전체 노드 개수 길이의 torch.bool 텐서로 만들면, 우선은 모든 값이 False로 초기화되어 있다. 다시 말하자면, train_mask는 모든 원소가 False로 초기화된 num_nodes 크기의 boolian 1차원 배열인 것이다. 이제 train_mask[train_node_ids]=True 코드를 통해, train에 속한 노드들에 대해서만 True로 지정하면, Train 마스크는 해당 데이터셋에 속한 노드만을 나타내게 된다. 이제 그 다음엔 data 객체를 생성하고(이때 인자로 mask들을 전달한다), 이후 data.train_mask처럼 생성된 data 그래프에 각각의 mask를 적용하면 된다.

@@@@@@@@@@@ 에러 관련 주요 개념들

1. out =model() 메서드와 data.x[n_id]

data.x[n_id]는 node feature matrix에서, 해당 배치에 포함되는 노드들의 feature를 인덱싱해 가져오는 역할을 한다. 다시 말해 n_id는 NeighborSampler로부터 얻은 인덱스이다. data.x[n_id] 코드를 통해 GNN 모델에 현재 노드들의 feature를 전달할 수 있다.

2. Batch_size=32라면, n_id도 32개일까?

n_id는 NeighborSampler로부터 얻은 노드 인덱스 리스트이다. 앞서 언급한 대로 배치에 포함되는 노드의 인덱스들을 기본적으로 포함하는데, 이때 중요한 것은 NeighborSampler의 특성이다. 이것은 '이웃 노드'들의 정보도 함께 집계(포함)하므로 실제로는 더 많은 노드 인덱스가 포함되고, 다시 말해 n_id에는 32개 이상의 노드 인덱스가 포함된다.

3. out =model() 메서드의 작동

현재 batch에 대한 예측(prediction)을 수행하는 메서드이다. 인자로 node feature와 edge_index_list, 그리고 edge feature를 받아 처리한다. 이 인자들을 바탕으로 모델은 GraphSAGE의 핵심 원리인 node embedding을 수행하고, 최종적으로 회귀 예측 결과를 반환한다. 이 결과는 변수 out에 저장되며 현재 배치에 포함된 노드들에 대한 예측값을 포함한다.

4. edge feature의 전달

edge_index_list와 대응하는 값으로 구성되어야 하므로, edge feature 또한 리스트로 model() 메서드에 전달되어야 한다.

5. 회귀 예측 결과의 형태는 [batch size , 1]이다.

6. predictions = out[:batch_size]의 의미

앞서 언급했듯, out은 '현재 배치에 포함된 노드들에 대한 예측값'을 가지고 있다. 그리고 이 '노드들'이란, 타겟이 되는 노드들과 그들의 이웃 노드들의 합을 말한다. 당연하게도 원하는 것은 타겟 노드의 예측값이기에, 우리는 결과인 out에서 현명한 방법으로 인덱싱을 해야 한다. 그리고 그 현명한 방법이란 batch_size. 애초에 설정한 타겟 노드들의 개수인 것이다.
여기서 왜 out[:n_id]는 안되는지 한번 더 설명하자면, n_id에는 이웃 노드들의 인덱스까지 담겨 있다. 인덱싱을 하나마나한 결과를 얻는다는 의미이다.

7. NeighborSampler와 DataLoader의 차이

DataLoader는 전체 그래프를 사용해 학습하는 경우에 유용하고, Classification Task (노드나 그래프 분류 문제)에 주로 사용된다. 반면 NeighborSampler는 지역적인 그래프 정보를 활용해 학습하고, Regression Task (회귀 문제)에 주로 사용된다.
다시 말해, NeighborSampler는 target node의 주변에 있는 이웃 노드들의 정보를 효과적으로 취합해 반영하고, 예측에 활용한다. 이렇듯 지역적인 노드 정보를 취합하기에 대규모 그래프에서도 효과적으로 학습을 진행할 수 있다.

8. 지역적인 노드 정보 취합 : Local Node Information Aggregation

지역적인 노드 정보 취합을 위해서는, target 노드와 이웃해 있는 노드들 사이의 연결 관계를 고려할 수밖에 없다. 이를 통해 보통은 이웃 집합(aggregation==subgraph)이라는 용어를 사용한다. 다시 말해, 지역적인 노드 정보를 취합한다는 것은, 모델이 그래프 내에서 비슷한 노드들의 정보를 활용하여 예측을 수행한다는 것이다.

9. 비슷한 노드 정의 : 노드 간 유사성

노드 간 유사성을 측정하는 데에는 3가지 방법이 있다.
1. node feature based similarity (노드 특성 기반 유사성) : 유사성을 측정하는 가장 직접적인 방법이다. node feature matrix의 각 row는 노드 1개의 특성 벡터이고, 이 벡터의 값들을 다양한 거리 메트릭 (유클리디안 거리, 코사인 유사도)를 통해 유사성을 계산한다.
2. structure similarity (구조적 유사성) : 노드들이 그래프 내에서 얼마나 비슷한 역할을 지녔는지에 기반해 측정하는 방식이다. 예를 들면, 두 노드가 비슷한 이웃을 지니고 있다면 구조적으로 유사하다고 할 수 있다. 이것은 clustering degree, 국지적 밀도 등의 그래프 이론적 척도를 사용해 계산한다. 좀 더 구체적으로는 sampling기법과 graph convolution 연산을 통해 이웃 노드 정보를 집계하고, 구조적으로 유사한 노드들은 서로에게 영향을 미칠 수 있게 된다.
3. embedding similarity (임베딩 유사성) : 그래프 머신러닝 모델(GCN, GraphSAGE 등)은 각 노드에 대한 임베딩을 생성한다. 정확히 말하자면 노드 특성 기반 방식과 구조적 유사성을 모두 결합해 '노드 간의 임베딩 기반 유사성을 알아낼 수 있는' 최종 임베딩을 생성한다.
이렇게 만들어진 최종 임베딩은 노드의 특성과 그래프 구조를 모두 반영한다. 임베딩에 대해 유사성을 측정하는 방법은, 두 임베딩 벡터 간의 거리(유클리디안 거리, 코사인 유사도)를 사용하는 것이다. 참고로 이 최종 임베딩은 노드 분류, 링크 예측 등 다양한 그래프 기반 작업에 사용될 수 있다.

profile
Recommend System & BackEnd Engineering

0개의 댓글