본문 바로가기

AI Algorithms (AI 기술과 알고리즘)

SALAD와 LTN의 만남: 좌충우돌 Neuro-Symbolic AI 모델 디버깅 탐험기

SALAD와 LTN의 만남

1. 서론: 왜 Neuro-Symbolic AI인가? 그리고 왜 이 여정은 험난했는가?

이 프로젝트의 최종 목표는 단순히 이상 탐지 모델의 성능을 조금 더 끌어올리는 것이 아니었습니다.  SOTA 이상 탐지 모델인 SALAD에 기호 논리 추론이 가능한 Logic Tensor Networks(LTN)를 결합하여, 성능과 설명력을 모두 갖춘 Neuro-Symbolic AI를 구현하고자 했습니다. 기존의 Grounding DINO 모델을 50 에포크 동안 Full Fine-tuning하는 방식으로는 67%의 성능에 머물렀지만, 이 새로운 접근법을 통해 90% 이상의 SOTA급 성능을 달성하고, 나아가 '왜 이 이미지가 이상(anomaly)으로 탐지되었는가?'를 논리적으로 설명할 수 있는 시스템을 만드는 것이 핵심 목표였습니다.

하지만 딥러닝 모델, 특히 두 개의 복잡한 코드베이스를 통합하는 과정은 단순히 코드 몇 줄을 복사-붙여넣기 하는 것으로 끝나지 않습니다. 그것은 예상치 못한 오류와의 끝없는 싸움이며, 보이지 않는 함정을 하나씩 파헤쳐 나가는 고된 여정입니다. 이 글은 바로 그 실제 디버깅 과정을 생생하게 기록한 '전투 로그'입니다.

이 탐험기를 통해, 독자 여러분은 비슷한 Neuro-Symbolic 프로젝트나 복잡한 PyTorch 모델 통합 과정에서 마주칠 수 있는 다양한 오류의 원인과 해결책에 대한 실질적인 가이드를 얻게 될 것입니다. 그럼, 좌충우돌 디버깅의 세계로 함께 떠나보시죠.

2. 대장정의 시작: 통합 스크립트 실행과 첫 번째 오류

밤새는게 일과...

모델 통합의 첫 단계는 언제나 두 코드베이스를 합친 새로운 학습 스크립트를 실행하는 것입니다. 이 과정에서 마주치는 초기 오류들은 대부분 환경 설정, 데이터 로딩 방식, 그리고 모델 초기화 단계의 미묘한 불일치에서 비롯됩니다. 저 역시 예외는 아니었습니다. 야심 차게 첫 학습 스크립트를 실행하자마자, 친숙하면서도 반갑지 않은 손님, AttributeError를 만났습니다.

  • 문제 상황 1: AttributeError: 'int' object has no attribute 'get'
  • 원인 분석: 오류 메시지는 명확했습니다. AutoEncoder 클래스의 생성자(__init__) 내부에서 인자로 받은 parameters 객체에 .get() 메서드를 호출하려 했지만, 실제로는 정수(int)가 전달된 것입니다. AutoEncoder 모델은 단순히 클래스 개수를 나타내는 정수(num_classes)가 아니라, 이미지 채널 정보를 담은 딕셔너리({"image_channels": ...})를 인자로 기대하고 있었습니다.
  • 해결책: AutoEncoder가 기대하는 형식에 맞춰 인자를 딕셔너리 형태로 수정했습니다.

첫 번째 오류를 잡았다는 안도감도 잠시, 같은 코드를 다시 실행하자마자 이번엔 UNet이 비명을 질렀습니다.

  • 문제 상황 2: TypeError: UNet.__init__() takes 2 positional arguments but 3 were given
  • 원인 분석: 이번에도 비슷한 문제였습니다. AutoEncoder와 마찬가지로 UNet 모델 역시 위치 기반 인자(positional arguments)가 아닌, 채널 정보를 담은 딕셔너리 하나를 기대하고 있었습니다. UNet(2 * num_classes, 1)처럼 두 개의 정수를 전달하여 TypeError가 발생한 것입니다.
  • 해결책: UNet 모델의 초기화 코드도 딕셔너리 인자를 사용하도록 수정하여 문제를 해결했습니다.

모델 초기화라는 첫 번째 관문을 통과하며 한숨 돌렸지만, 곧바로 더 교묘한 함정이 우리를 기다리고 있었습니다.

3. PyTorch 버전의 함정: 모델 가중치 로딩 오류

최신 라이브러리 환경에서 레거시 코드를 다룰 때, 버전 업데이트에 따른 함수의 동작 변경은 예상치 못한 오류의 주범이 되곤 합니다. 이것은 의존성 지옥(dependency hell)의 작은 버전으로, 특히 보안 패치가 적용될 때 자주 발생하는 문제입니다. PyTorch의 torch.load 함수는 보안 강화로 인해 최근 버전에서 기본 동작이 변경되었고, 이는 우리에게 두 단계에 걸친 오류를 선물했습니다.

1. 1차 오류: _pickle.UnpicklingError: Weights only load failed

  • 문제 상황:
  • 원인 분석: 다행히도 오류 메시지가 매우 친절했습니다. PyTorch 2.6 버전부터 torch.load 함수의 weights_only 인자 기본값이 False에서 True로 변경되었습니다. 이는 신뢰할 수 없는 소스의 .pth 파일이 임의의 코드를 실행하는 것을 방지하기 위한 보안 정책 강화의 일환입니다. 오류 메시지는 torch.nn.modules.container.Sequential 객체를 신뢰할 수 없는 데이터로 간주하여 로드를 거부했음을 알려줍니다. 로드하려는 .pth 파일이 단순한 가중치(weights)가 아닐 수 있음을 암시하는 중요한 단서였습니다.
  • 해결책: 오류 메시지의 제안대로 weights_only=False 인자를 명시적으로 추가하여 이전 버전의 동작을 재현했습니다.

2. 2차 오류: TypeError: Expected state_dict to be dict-like

  • 문제 상황: UnpicklingError를 해결하자마자 새로운 TypeError가 우리를 반겼습니다.
  • 원인 분석: 이 오류는 가중치 파일의 저장 방식에 대한 근본적인 오해에서 비롯되었습니다. model.load_state_dict() 함수는 모델의 가중치와 버퍼를 담고 있는 상태 사전(state dictionary, dict 타입)을 인자로 받습니다. 하지만 오류 메시지는 우리가 로드한 객체가 dict가 아니라 모델 아키텍처 전체를 포함하는 torch.nn.modules.container.Sequential 객체임을 알려주었습니다. 즉, 이 가중치 파일은 torch.save(model.state_dict(), ...)가 아닌 torch.save(model, ...) 방식으로 저장된 것이었습니다.
  • 해결책: state_dict를 로드하는 대신, 모델 객체 전체를 직접 로드하도록 코드를 수정했습니다.

가중치 로딩 문제까지 해결하고 드디어 학습 루프에 진입할 수 있을 것이라 생각했지만, 이번에는 데이터 파이프라인에서 예상치 못한 복병이 나타났습니다.

4. 데이터 파이프라인 불일치: 입력 데이터 형식과의 싸움

데이터의 시작과 끝을 관리하는 파이프라인 구축은 생각보다 힘들어요

모델 아키텍처와 가중치가 올바르게 준비되어도, 학습 루프에 공급되는 데이터의 구조와 형식이 모델의 기대와 다르면 런타임 오류가 발생합니다. 여러 코드베이스를 통합할 때 가장 빈번하게 발생하는 문제 중 하나로, 데이터가 로더에서 나와 모델을 거쳐 손실 함수에 도달하기까지의 전 과정을 꼼꼼히 추적해야 합니다.

1. 오류 1: ValueError: too many values to unpack (expected 4)

  • 문제 상황: 데이터 로더에서 첫 번째 배치를 가져오는 지점에서 ValueError가 발생했습니다.
  • 원인 분석: 새롭게 작성한 학습 루프는 train_loader가 4개의 값을 반환할 것이라고 예상했지만, 실제로는 그보다 더 많은 값을 반환했습니다. 원본 SALAD 코드는 정상 이미지를 담은 train_loader와 이상 패턴을 섞기 위한 이미지를 담은 penalty_loader를 zip으로 묶어, 매 스텝마다 두 종류의 데이터를 동시에 공급하는 구조였습니다. 또한, train_loader가 반환하는 값 중 첫 번째 항목은 다시 이미지 튜플 (image, image_ae)로 구성되어 있었습니다. 통합 스크립트에서는 이 복잡한 데이터 언패킹 구조를 제대로 구현하지 않았던 것입니다.
  • 해결책: 단순히 next(train_loader)를 호출하는 대신, 원본 코드와 같이 zip을 사용하여 두 데이터 로더를 묶고, 루프 안에서 데이터 튜플을 올바르게 언패킹하도록 수정했습니다.

2. 오류 2: RuntimeError: ...expected input to have 768 channels, but got 3 channels instead

  • 문제 상황: 데이터 로딩 문제를 해결하고 루프가 한 스텝 돌자마자, 모델의 첫 번째 레이어에서 채널 불일치 오류가 발생했습니다.
  • 원인 분석: 이 오류는 데이터 자체의 문제가 아니라, 데이터를 처리하는 모델의 초기화 방식이 잘못되었음을 시사합니다. 원본 SALAD 코드는 get_pdn_medium이라는 함수를 통해 모델을 생성했지만, 통합 코드에서는 get_pdn_small 함수를 사용하고 있었습니다. 이 두 함수는 내부적으로 다른 아키텍처를 생성하여, 모델의 첫 번째 컨볼루션 레이어가 기대하는 입력 채널 수가 각각 달랐던 것입니다. get_pdn_medium은 3채널 이미지를 기대했지만, get_pdn_small은 768채널의 피처맵을 기대하도록 구현되어 있었습니다.
  • 해결책: 모델 생성 함수를 원본 코드와 동일하게 get_pdn_medium으로 변경하고, 혼동을 피하기 위해 인자를 명시적인 키워드 인자(out_channels=...)로 전달하도록 수정했습니다.

3. 오류 3: Loss 계산에서의 RuntimeError 및 ValueError

  • 문제 상황: 데이터가 모델을 성공적으로 통과한 후, 손실(Loss)을 계산하는 단계에서 두 가지 오류가 연달아 발생했습니다.
    1. Dice Loss 오류: RuntimeError: scatter(): Expected dtype int32/int64 for index
    2. Focal Loss 오류: ValueError: Target size must be the same as input size
  • 원인 분석:
    1. Dice Loss: scatter_ 함수는 클래스 인덱스를 사용하여 원-핫 벡터를 만들 때 사용됩니다. 이 함수는 인덱스 텐서의 데이터 타입이 int32 또는 int64일 것을 기대합니다. 하지만 우리는 원-핫 인코딩이 이미 적용된 float 타입의 세그멘테이션 맵(seg)을 전달하여 오류가 발생했습니다. 이 함수에는 클래스 인덱스만 담긴 seg_indices 텐서를 전달해야 했습니다.
    2. Focal Loss: 직접 구현한 multiclass_focal_loss 함수가 문제였습니다. 이 함수는 내부적으로 torchvision의 sigmoid_focal_loss를 사용했는데, 이 과정에서 생성된 타겟 텐서의 차원(torch.Size([2, 256]))이 입력 텐서의 차원(torch.Size([2, 256, 256]))과 맞지 않았습니다. 원본 SALAD 코드는 이 문제를 해결하기 위해 torch.hub를 통해 검증된 구현체를 로드하고, 클래스 불균형을 고려하여 가중치를 계산하는 더 정교한 방식을 사용하고 있었습니다.
  • 해결책:
    1. Dice Loss: 손실 함수 호출 시, seg 대신 seg_indices를 전달하도록 수정했습니다.
    2. Focal Loss: 직접 구현한 함수를 과감히 삭제하고, 원본 SALAD 코드의 방식을 그대로 따랐습니다. 클래스 가중치를 계산하는 get_weights 함수를 추가하고, torch.hub.load를 통해 검증된 Focal Loss 구현체를 가져오도록 수정했습니다.

데이터 파이프라인의 험난한 여정은 여기서 끝이 아니었습니다. 마지막으로 우리를 기다린 것은 프로그램이 멈추지도, 죽지도 않는 가장 교묘한 형태의 오류였습니다.

5. 논리적 오류: 무한 루프에 빠진 데이터 로더

무한루프

컴파일러도, 인터프리터도 알려주지 않는 논리적 오류는 엔지니어의 직관과 끈기만을 무기로 삼아야 하는 가장 까다로운 적입니다. 특히 데이터 로더처럼 백그라운드에서 동작하는 컴포넌트의 미묘한 설정 오류는 눈에 잘 띄지 않아 많은 시간을 허비하게 만듭니다.

  • 문제 상황: 이전의 Loss 함수 오류를 해결하기 위해 추가한 get_weights 함수가 끝나지 않고 계속 실행되었습니다. 터미널에는 14분이 넘도록 같은 로그만 반복해서 출력되었습니다.
  • 클래스 가중치 계산은 전체 데이터셋을 한 번만 순회하면 충분한 작업인데, 무언가 잘못되고 있었습니다.
  • 원인 분석: 문제의 원인은 InfiniteDataloader 래퍼에 있었습니다. SALAD는 정해진 step 수만큼 학습하기 위해 일반 DataLoader를 무한히 반복하는 InfiniteDataloader로 감싸서 사용합니다. 하지만 get_weights 함수는 전체 데이터셋을 정확히 한 번만 순회하여 클래스별 픽셀 수를 세어야 합니다. 이 함수에 무한 로더 객체를 그대로 전달했고, 그 결과 함수는 영원히 끝나지 않는 루프에 빠져버린 것입니다.
  • 해결책: InfiniteDataloader로 래핑하기 전의 원본, 즉 유한한 DataLoader를 별도의 변수(train_loader_finite)에 저장하고, get_weights 함수에는 이 유한 로더를 전달하도록 코드를 수정했습니다.

이 수정을 마지막으로, 기나긴 어둠의 터널 끝에서 마침내 한 줄기 빛이 보이기 시작했습니다.

6. 마침내 찾은 빛: 성공적인 학습과 희망적인 결과

영화속 장면같은...

수십 개의 터미널 탭과 끝없는 트레이스백의 미로를 헤맨 끝에, 마침내 터널의 끝에서 빛을 보았습니다. 붉은색 오류 메시지 대신, 학습의 시작을 알리는 초록색 진행률 표시줄이 터미널을 채우는 그 순간은, 모든 엔지니어가 공감할 짜릿한 카타르시스의 순간이었습니다.

성공적으로 학습이 시작되었음을 알리는 로그는 다음과 같았습니다.

CE Weight Calculation: 100%|██████████| 360/360 [00:07<00:00, 46.39it/s]
Class weights: [tensor(1.2000), tensor(6.), 1, 1, 1, 1]
...
Loss: 15.1843, Comp: 1.3641, Logic: 0.7284, Sat: 0.272: 0%| | 234/70000 [00:49<3:51:32, 5.02it/s]

각 로그 항목의 의미는 다음과 같습니다.

항목 의미
Loss 전체 손실 값으로, 작아질수록 좋습니다.
Comp SALAD의 Composition 재구성 손실 값입니다.
Logic LTN이 계산한 논리 손실 값으로, 1 - Sat으로 계산됩니다.
Sat 설정된 논리 규칙의 만족도(Satisfaction)로, 1에 가까워질수록 좋습니다.
Progress 전체 학습 스텝(70,000) 대비 현재 진행률입니다.

그리고 마침내, 60,000 이터레이션 지점에서 중간 평가 결과가 출력되었습니다.

Iteration 60000: AUC = 89.58%

New best AUC: 89.58%

이 결과는 단순한 숫자 이상이었습니다. 수많은 오류를 해결하기 위해 쏟아부은 노력과 시간이 SOTA에 근접하는 성능으로 보상받는 순간이었습니다. 목표로 했던 90% 이상 달성 가능성을 눈앞에서 확인한 이 희망적인 신호는, 이 프로젝트가 올바른 방향으로 나아가고 있음을 증명해주었습니다.

7. 결론: 디버깅 로그에서 배우는 교훈

Lessons Learned

SALAD와 LTN을 통합하는 디버깅 여정은 험난했지만, 그 끝에 성공적인 학습과 희망적인 결과를 얻을 수 있었습니다. 이 과정을 통해 몇 가지 중요한 교훈을 다시금 되새길 수 있었습니다.

  • 모델의 기대를 파악하라: AttributeErrorTypeError 모델을 초기화하거나 함수를 호출할 때는 항상 해당 함수가 어떤 형태(type)와 구조(structure)의 인자를 기대하는지 명확히 파악해야 합니다. int를 기대하는 곳에 dict를 넣거나, state_dict가 필요한 곳에 모델 객체 전체를 전달하는 실수는 디버깅의 첫 단추를 잘못 끼우는 것과 같습니다.
  • 라이브러리 버전을 의심하라: torch.load의 배신 오픈소스 라이브러리는 끊임없이 발전하고, 때로는 이전 버전과의 호환성이 깨지는 변경이 발생합니다. 특히 torch.load의 기본값 변경처럼 보안과 관련된 업데이트는 기존 코드를 순식간에 망가뜨릴 수 있습니다. 예상치 못한 오류가 발생하면, 사용 중인 라이브러리의 릴리즈 노트나 변경 사항을 확인하는 습관이 중요합니다.
  • 데이터의 흐름을 추적하라: 파이프라인 불일치 오류 데이터는 모델 학습의 혈액과도 같습니다. 데이터 로더에서부터 모델의 각 레이어를 거쳐 최종적으로 손실 함수에 도달하기까지, 텐서의 shape, dtype, 그리고 구조가 각 단계의 요구사항과 일치하는지 꼼꼼히 확인해야 합니다. 데이터의 흐름을 시각화하거나 중간 단계에서 텐서 정보를 출력해보는 것이 디버깅에 큰 도움이 됩니다.

이 모든 교훈을 관통하는 하나의 진리가 있다면, 그것은 바로 '원본 코드는 최고의 문서'라는 사실입니다. 다른 사람이 만든 코드베이스를 통합할 때, 공식 문서만으로는 부족할 때가 많습니다. 실제 구현 로직, 특히 데이터 처리 방식이나 모델 초기화와 같은 미묘한 부분들은 원본 코드를 직접 읽고 이해하는 것이 가장 확실한 방법입니다. 이 기나긴 디버깅 여정은 결국 원본 코드로 돌아가 그 의도를 파악하는 과정의 연속이었습니다.

이 글이 Neuro-Symbolic AI와 같이 복잡한 모델 통합에 도전하는 다른 개발자분들께 작게나마 실질적인 도움이 되기를 바랍니다. 이 글이 여러분의 '전투 로그'를 작성하는 데 작은 등대가 되기를 바랍니다. 버그 없는 코드는 없지만, 끈기 있는 디버깅 끝에는 반드시 값진 성공이 기다리고 있을 것입니다.