ITE4005 · HANYANG UNIV · 2018 SPRING

데이터 사이언스

네 개의 데이터 마이닝 알고리즘이 모두 동작하고 세 개는 정답과 대조한 흔적이 남아 있다. 문제는 코드가 아니라 코드에 대한 설명이다.

파일98
커밋90
기간2018.03–06
코드866 LOC
B종합
소견2 치명적10 중대6 경미합계 18

총평

Apriori, Decision Tree, DBSCAN, SVD 기반 추천 네 과제다. 네 개 모두 실제로 돌아간다. 나는 네 개를 전부 실행했고, 세 개는 레포에 들어 있는 정답 파일과 직접 대조했다. Apriori는 커밋된 출력 파일 두 개를 (집합 원소 순서를 정규화한 뒤) 한 줄도 틀리지 않고 재현했고, DBSCAN은 input3에서 정답 클러스터와 Jaccard 1.0000, Decision Tree는 346행 테스트셋에서 95.09% (다수결 베이스라인 67.92%), SVD는 u1에서 RMSE 0.9542를 냈다. 학부 과제로서 알고리즘 이해도는 분명히 증명됐다.

가장 잘한 것은 assignment4다. train/test가 과제에서 제공된 분리 파일이라 누수가 구조적으로 불가능한데, 그 위에서 cold-start를 실제로 구현했다. u1.test 20,000행 중 32행이 학습에 없던 아이템인데, factorization.pyx:96-108이 이를 감지해 전역 평균 + 사용자 편향으로 폴백한다. 학부 과제에서 이걸 빼먹고 KeyError로 죽는 경우가 훨씬 흔하다.

가장 치명적인 것은 assignment2/dt.py:43calssifier 오타다. README가 두 번째 문단 전체를 할애해 자랑하는 random forest 기능이, 2018-05-19 커밋 909cdbe 이후 아무것도 하지 않는다. 더 나쁜 것은 그 커밋 이전에는 인라인으로 작성된 forest 분기가 실제로 동작했다는 점이다. 리팩터링이 동작하던 기능을 조용히 죽였고, 아무도 알아채지 못한 채 제출됐다.

반복되는 패턴은 명확하다. 코드는 문서보다 뒤처져 있고, 커밋된 결과 파일은 커밋된 코드로 재현되지 않는다. README는 존재하지 않는 gain ratio 메트릭을 광고하고, 기본 메트릭을 gini라고 적어두고 코드는 entropy를 쓰며, 보고서 문서의 모든 수식 이미지는 C:\Users\maybe\... 로컬 경로를 가리킨다. 결정적으로 data/dt_result.txtyesye로 잘린 채 커밋돼 있다 — 같은 커밋에 들어간 test/dt_result.txt는 멀쩡하다.

과제주제핵심 판정등급
A1Apriori출력 정확·재현됨. 단 downward closure 가지치기가 없어 join 단계만 구현됨 (k=4에서 후보 455개 중 23개만 유효)B+
A2Decision Tree트리 자체는 95% 정확. 그러나 random forest는 오타로 죽었고, min_gain 의미가 문서와 반대이며, 커밋된 결과 파일이 손상됨C+
A3DBSCANcore/border/noise 판정 정확, 정답과 Jaccard 0.97–1.00. 이웃 탐색은 순수 파이썬 O(n²) (8,000점에 86초), 노이즈 없는 입력에서 KeyErrorB
A4Recommender (SVD)누수 없음, cold-start 처리 검증됨, RMSE 재현됨. 그러나 베이스라인 비교가 없고 setup.py에 Surprise 라이브러리 흔적이 그대로 남음B

Assignment 1 — Apriori

500개 트랜잭션에서 최소 지지도 이상의 빈발 항목집합을 찾고 연관 규칙과 confidence를 출력한다. 외부 의존성 없이 60줄 순수 파이썬으로 작성됐고, 최소 지지도 5%에서 0.2초 만에 1,066개 규칙을 뽑는다. 커밋된 outputRsupport5.txt·outputRsupport4.txt 두 파일을 모두 재현했다.

중대

Apriori의 가지치기 단계가 없다. join만 하고 downward closure 검사를 건너뛴다

Apriori 알고리즘의 핵심은 두 단계다. (1) 빈발 (k-1)-항목집합을 조인해 k-후보를 만들고, (2) 그 후보의 모든 (k-1)-부분집합이 빈발한지 확인해 아닌 것을 버린다. 이 코드에는 (1)만 있다. 두 개의 빈발 (k-1)-집합의 합집합이 크기 k이기만 하면 무조건 후보가 된다.

공짜로 얻는 성질이 아니다. 실제 input.txt에 최소 지지도 5%를 적용해 계측한 결과, 전체 후보 1,905개 중 552개(29%)가 가지치기로 제거 가능했다. k=4에서는 생성된 455개 중 23개만 모든 3-부분집합이 빈발했다 — 즉 95%가 낭비된 스캔이다. k=5에서는 120개 후보 전부가 무효였다. 입력이 작아 체감되지 않을 뿐, 이건 "Apriori를 구현했다"와 "brute-force에 조인 최적화를 얹었다"의 차이다.

assignment1/apriori.py:29–33
    while candidates:
        filtered = scan()
        result[k - 1] = filtered
        candidates = {i.union(j) for i in filtered for j in filtered if len(i.union(j)) == k}
        k += 1   # ← 조인만. 부분집합이 전부 빈발한지 검사하는 prune 단계가 없음
중대

출력이 실행할 때마다 달라진다

frozenset 순회 순서는 파이썬 해시 시드에 의존한다. ','.join(item)이 그 순서를 그대로 찍기 때문에 같은 입력·같은 코드로 두 번 돌리면 {16,8}{8,16}처럼 다른 파일이 나온다. 실제로 확인했다 — 두 번 실행한 1,066줄 출력에서 908줄이 서로 다른 텍스트였다. 규칙 집합 자체는 동일하다(원소 순서를 정렬하면 완전 일치). 그래서 채점 스크립트가 파싱하지 않고 diff를 뜨면 0점이 나온다.

커밋된 outputRsupport5.txt도 마찬가지 이유로 내 실행 결과와 텍스트로는 570줄이 달랐다. sorted(item) 한 번이면 끝날 문제였다.

assignment1/apriori.py:60
f.write('{{{}}}\t{{{}}}\t{:.2f}\t{:.2f}\n'.format(','.join(item), ','.join(asso), sup, conf))
#                                                  ↑ frozenset 순회 순서 = 실행마다 달라짐
경미

define()transactions 인자는 한 번도 쓰이지 않는다

apriori.py:37에서 선언되고 :56에서 전달되지만 함수 본문 어디에도 등장하지 않는다. support가 이미 freq에 비율로 저장돼 있어 원본 트랜잭션이 필요 없어진 뒤 지우지 않은 잔재다. 노트북 프로토타입(Apriori.ipynb)에서도 같은 시그니처가 그대로 남아 있다.

잘한 것

규칙 생성에서 자기 자신을 포함하는 규칙을 제대로 제외한다. :43이 크기 1..len(item)의 모든 부분집합을 만든 뒤 :45if not remain: continue가 element == item인 경우를 걸러낸다. 실제 출력 1,066줄 전수 검사에서 좌변·우변이 겹치는 규칙은 0건, confidence > 100%인 규칙도 0건이었다. 그리고 getcontext().rounding=ROUND_HALF_UP(:16)은 파이썬 기본 반올림이 banker's rounding이라는 걸 알고 대응한 것으로, 커밋 로그의 "Fix floating point error"(2018-03-26)와 맞아떨어진다.

Assignment 2 — Decision Tree

범주형 속성만 있는 분류 데이터(car evaluation, 1,382행 학습 / 346행 테스트)에 대해 결정 트리를 학습하고 예측 결과를 파일로 쓴다. 나는 entropy·gini·error 세 메트릭을 모두 돌려 정답 파일과 대조했다: entropy 95.09%, gini 89.02%, error 67.92% (다수결 베이스라인이 정확히 67.92%). 트리 자체는 제대로 동작한다. 문제는 그 주변 전부다.

치명적

오타 하나로 random forest가 통째로 죽었다 — 그것도 동작하던 코드를 갈아엎으면서

calssifierclassifier의 오타다. 파이썬은 이 줄에서 새 지역 변수를 만들 뿐 에러를 내지 않는다. 따라서 --forest N을 주면 RandomForest 객체가 생성되고 즉시 버려지며, 바로 아래 :46:41에서 만든 단일 DecisionTree를 학습시킨다. lib/forest.py 54줄 전체가 도달 불가능한 죽은 코드다.

검증했다. --forest 5로 돌린 출력과 인자 없이 돌린 출력이 바이트 단위로 동일했다 (둘 다 95.09%). README의 두 번째 문단 전체 — "Divide learning data randomly, learn each tree, make several weak classifiers, and give the final result through voting" — 는 실행되지 않는 코드에 대한 설명이다.

더 아픈 것은 git 이력이다. git log -S'calssifier'로 추적하면 오타는 2018-05-19 커밋 909cdbe "Update randomforest"에서 들어왔다. 그 직전 리비전(13dcdd5)의 dt.py에는 if args.forest == 1: ... else: 분기가 있었고, else 쪽에서 실제로 트리 리스트를 만들고 np.array_split으로 데이터를 나눠 학습한 뒤 Counter(y).most_common()[0][0]으로 투표까지 했다. 동작하던 기능을 클래스로 추출하는 리팩터링 중에 배선을 놓쳤고, 그 상태로 3주 뒤 학기가 끝났다.

assignment2/dt.py:41–46
    classifier = DecisionTree(METRICS[args.metric], args.feature)
    if args.forest > 1:
        calssifier = RandomForest(DecisionTree, args.forest, METRICS[args.metric], args.feature)
                                  # ↑ 오타. 이 객체는 아래에서 쓰이지 않고 버려진다

    # fit train data to classifier
    classifier.fit(x_train, y_train, args.depth, args.minsize, args.mingain)
치명적

min_gain은 information gain이 아니라 불순도다 — 의미가 문서와 정반대고, 기본값 0.3이 error 메트릭을 무력화한다

_gain은 이름과 달리 가중 불순도의 부호를 뒤집은 값이다. 분할 선택에서는 이게 맞다(argmax = 불순도 최소). 그런데 :68의 조기 종료 조건은 -_gain([left,right]), 즉 자식 노드의 가중 불순도를 min_gain과 비교한다. 부모 불순도에서 빼는 과정이 없으므로 이건 information gain이 아니다.

README는 이렇게 적는다: "by setting min_gain, you can avoid branching if you do not exceed the minimum information gain". 실제 동작은 반대다 — 자식이 충분히 순수해지면 분할을 멈춘다. 스케일도 메트릭마다 다르다. entropy는 0~log2(k) 범위이고 classification error는 0~1 범위인데, 기본값 --mingain 0.3(dt.py:68) 하나를 공유한다.

결과를 측정했다. --metric error로 돌리면 정확도가 67.92%로, 다수결 베이스라인(235/346 = 67.92%)과 소수점까지 일치한다. 즉 루트 분할 직후 불순도가 0.3 아래로 떨어져 트리가 단일 리프로 붕괴한다. 저자 본인의 보고서(docs/Decision Tree.md:75)에 이 67.92%가 "Error 메트릭의 성능"으로 적혀 있지만, 그건 메트릭의 성능이 아니라 트리가 만들어지지 않았다는 뜻이다.

assignment2/lib/tree.py:47, 68
_gain = lambda gs: -sum([self.metric(g) * len(g) / len(list(chain(*gs))) for g in gs])
                   # ↑ 부모 불순도를 빼지 않음 = gain이 아니라 -(가중 불순도)
...
elif depth >= self.max_depth or -_gain([left, right]) < self.min_gain:
    node['left'], node['right'] = _t(left), _t(right)
    # ↑ "불순도가 임계값보다 낮으면 멈춘다". 문서가 설명하는 것과 반대 방향
중대

제출된 결과 파일이 손상돼 있다 — yesye로 잘렸다

assignment2/data/dt_result.txt의 3~5행 클래스 값이 ye다. 줄바꿈 문제가 아니다. 헥스 덤프로 확인했다: 66 61 69 72 09 79 65 0a = "fair\tye\n". 문자 하나가 실제로 없다.

같은 커밋(13dcdd5)에 들어간 assignment2/test/dt_result.txtyes로 정상이다. 두 파일이 같은 커밋에 함께 들어갔는데 하나만 깨져 있다는 것은, 결과를 커밋하기 전에 아무도 열어보지 않았다는 뜻이다. 현재 코드로 같은 명령을 돌리면 정답과 5/5 일치하는 정상 출력이 나온다 — 즉 손상은 과거 버전의 산물이고, 그 뒤로 갱신되지 않은 채 남아 있다.

assignment2/data/dt_result.txt:3 (vs test/dt_answer.txt:3)
커밋된 결과 : <=30	medium	yes	fair	ye
정 답      : <=30	medium	yes	fair	yes
현재 코드 실행 : <=30	medium	yes	fair	yes   ← 지금 돌리면 정상
중대

보고서의 95.66%는 커밋된 코드로 재현되지 않는다. 노트북 프로토타입의 숫자다

docs/Decision Tree.md:75는 dataset1에서 Entropy 95.66%를 보고한다. 커밋된 data/dt_result1.txt를 정답과 대조하면 정확히 331/346 = 95.66%가 나온다. 그런데 같은 디렉터리의 test/dt_result1.txt는 327/346 = 94.51%이고, 현재 dt.py를 기본 인자로 돌리면 329/346 = 95.09%다. 같은 입력에 대해 세 개의 서로 다른 결과가 레포에 공존한다.

원인은 assignment2/Assignment.ipynb에 있다. 이 노트북이 ./data/dt_result1.txt를 직접 쓰는데, 거기서는 DecisionTree(0)(메트릭 자리에 정수 0)에 fit(x_train, y_train, 16, 0) — 즉 depth=16, min_gain 인자 없음 — 으로 학습한다. dt.py의 기본값(depth=32, mingain=0.3)과 다르다. 보고서의 대표 수치가 제출 코드가 아니라 프로토타입에서 나왔고, 그 사실이 어디에도 적혀 있지 않다.

경미

미지의 속성값은 죽지는 않지만, 항상 오른쪽으로 간다

이 부분은 걱정한 것만큼 나쁘지 않았다. dt.py:31-33이 train과 test를 pd.concat한 뒤 함께 factorize하기 때문에, 학습셋에 없던 범주값도 코드가 부여되어 KeyError가 나지 않는다. 실제로 buying=ULTRA, safety=EXTREME이라는 존재하지 않는 값을 넣어 돌려봤고, 예외 없이 unacc로 분류됐다.

다만 대가가 있다. 미지 값은 학습셋의 모든 코드보다 큰 정수를 받으므로 _predictx[node['index']] < node['value']가 모든 노드에서 거짓이 되어 항상 오른쪽 자식으로만 내려간다. 이는 결정이 아니라 인코딩 부작용이다. 또한 인코딩이 테스트셋에 의존한다는 것은 모델이 학습 데이터만의 함수가 아니라는 뜻이다 — 다만 이 데이터셋에서는 모든 범주가 학습셋에 등장하므로 실제 영향은 없었다(테스트 파일 행 순서를 뒤집어 다시 돌렸을 때 346행 중 예측이 바뀐 행은 0건).

assignment2/lib/tree.py:77
tar = node['left'] if x[node['index']] < node['value'] else node['right']
# 미지 값은 최대 코드보다 큼 → 조건이 언제나 거짓 → 항상 right
경미

README가 없는 기능을 광고하고, 기본값을 잘못 적고, 이미지를 로컬 경로로 링크한다

세 건이다. (1) README.md:43"Implement several metrics, gini, information gain and gain ratio"라고 적지만 lib/metric.py:74-78METRICS에는 entropy·error·gini뿐이고 gain ratio는 구현되지 않았다. (2) README.md:7은 기본 메트릭이 gini라고 하지만 dt.py:62default='entropy'다 (클래스 기본값 tree.py:27만 Gini). (3) 제출용 보고서 docs/Decision Tree.md:47-51의 수식 이미지 세 개가 C:\Users\maybe\Documents\Workspace\...를 가리킨다. README 쪽 사본은 교내 GitLab hconnect.hanyang.ac.kr URL이라 외부에서는 역시 깨진다. 즉 모든 보고서의 모든 수식 이미지가 채점자에게 보이지 않았을 가능성이 높다.

덧붙여 tree.py:40-41의 생성자 기본값 self.min_gain = 0; self.min_size = .005fit() 시그니처(:84-85)의 min_size=0, min_gain=.005와 서로 뒤바뀌어 있다. 동작에는 영향이 없지만(항상 fit이 덮어씀) 두 값을 헷갈리고 있었다는 증거다.

잘한 것

Assignment.ipynb 마지막 셀이 sum(test[y_label] == answer[y_label]), len(test[y_label])다. 정답 파일과 실제로 대조했다는 명시적 증거다 — 많은 학부 과제가 "돌아가니까 됐다"에서 멈추는데 여기서는 수치를 봤다. lib/metric.py의 Metric 추상 클래스 + 3종 구현 + METRICS 레지스트리로 --metric 플래그를 연결한 구조도 과제 요구사항을 넘어서는 설계였다(비록 그 중 하나가 기본값 때문에 무력화됐지만).

Assignment 3 — DBSCAN

2D 좌표 데이터(2,000–8,000점)를 DBSCAN으로 군집화하고 상위 n개 클러스터를 파일로 쓴다. 세 입력을 모두 실행해 test/*_ideal.txt와 Jaccard로 대조했다: input3는 네 클러스터 중 셋이 Jaccard 1.0000, 나머지 하나가 0.9980. input1은 상위 여섯 클러스터가 0.87–0.99다. 알고리즘 본체는 교과서대로 정확하다. (파라미터는 레포에 기록이 없어 내가 추정한 값이므로, 낮게 나온 클러스터는 구현 결함이 아니라 내 파라미터 탓일 수 있다.)

중대

이웃 탐색이 전수 스캔이다. 8,000점에 86초

_neighbor는 호출될 때마다 전체 데이터셋을 순회하며 파이썬 레벨에서 np.linalg.norm을 한 점씩 부른다. 공간 인덱스(KD-tree, grid)도, 벡터화된 거리 행렬도 없다. 게다가 core point마다 다시 호출되므로 호출 횟수가 O(n)이고 전체는 O(n²) 파이썬 연산이다.

측정했다: input2(2,000점) 5.7초, input3(2,100점) 6.4초, input1(8,000점) 86초. 점 개수 4배에 시간 15배 — O(n²)가 그대로 드러난다. self.data 전체와의 거리를 np.linalg.norm(self.data - self.data[index], axis=1) 한 줄로 벡터화하는 것만으로도 수십 배가 빨라진다.

추가로 :35neighbors += sub_neighbors는 중복 제거 없이 시드 리스트를 늘린다. 이미 라벨이 붙은 점은 :29-31에서 걸러지지만, 같은 인덱스가 리스트에 여러 번 들어가 메모리와 순회 길이가 불필요하게 커진다.

assignment3/lib/clustering.py:37–39
def _neighbor(self, index):
    condition = lambda pos: np.linalg.norm(self.data[index] - pos) <= self.eps
    return [i for i, pos in enumerate(self.data) if condition(pos)]
    # ↑ 점 하나당 전체 데이터 순회 + 파이썬 루프 안에서 norm 호출
중대

노이즈가 하나도 없는 입력에서 KeyError로 죽는다

self._clustersdefaultdict(list)지만, defaultdict__getitem__에서만 키를 자동 생성한다. __delitem__은 일반 dict와 똑같이 KeyError를 낸다. 모든 점이 어떤 클러스터에 속하면 라벨 -1이 존재하지 않으므로 이 줄에서 크래시한다.

재현했다. 5개 점이 서로 eps 안에 몰려 있는 입력(eps=5, minpts=3)으로 돌리자 KeyError: -1로 종료했다. 밀집된 소규모 데이터나 넉넉한 eps를 쓰는 채점 케이스에서 충분히 발생할 수 있다. clusters.pop(-1, None)이면 끝이다.

    del clusters[-1] # delete outliers
    # → KeyError: -1   (noise가 0개일 때)
중대

시간 측정 모듈이 아무것도 재지 않는다

begin()이 모듈 전역 flag가 아니라 지역 변수에 대입한다. global flag 선언이 없다. 따라서 flag는 영원히 0이고, end()는 구간 소요 시간이 아니라 인터프리터 시작 이후 누적 CPU 시간을 반환한다.

직접 확인했다: begin() 호출 후 0.14초짜리 작업을 하고 end()를 부르면 값이 나오지만 timer.flag는 여전히 0이다. 그래서 출력되는 숫자는 pandas/numpy import 시간까지 전부 포함한다 — input2에서 "7.825488"이 찍혔지만 실제 벽시계 시간은 5.7초였다. 커밋 d91182f "Update lib.timer for time delta"(2018-05-19)가 이 문제를 고치려던 시도로 보이는데, 고쳐지지 않았다. 같은 파일이 assignment4/lib/timer.py에 그대로 복사돼 있어 A4의 성능 수치에도 같은 오차가 실린다.

assignment3/lib/timer.py:3–9 (assignment4/lib/timer.py 동일)
flag = 0

def begin():
    flag = process_time()   # ← 지역 변수. global 선언 없음

def end():
    return process_time() - flag   # ← flag는 항상 0
경미

eps가 정수로 강제된다. 출력 디렉터리도 만들어주지 않는다

clustering.py:50epstype=int로 파싱한다. DBSCAN의 반경은 본질적으로 실수이고 좌표 스케일에 따라 0.5나 1.7 같은 값이 필요할 수 있는데 그런 입력은 argparse 단계에서 거부된다. 이 데이터셋의 좌표 범위가 커서 문제가 드러나지 않았을 뿐이다.

또한 --output으로 존재하지 않는 디렉터리를 주면 군집화를 다 끝낸 뒤(input1이면 86초 후) FileNotFoundError로 죽는다. os.makedirs(exist_ok=True) 한 줄이 없다. 실제로 겪었다.

잘한 것

DBSCAN에서 가장 자주 틀리는 부분 — 경계점(border point) 처리 — 이 정확하다. lib/clustering.py:29-30이 이미 noise(-1)로 표시된 점을 현재 클러스터로 승격시키고, :31-35core point인 이웃에서만 시드를 확장한다(len(sub_neighbors) >= minpts 검사). 경계점은 클러스터에 포함되되 확장의 출발점이 되지 않는다 — 교과서 정의 그대로다. 또 _dbscanif self.labels[index]: continue는 이미 라벨이 붙은 점을 새 시드로 삼지 않는데, noise로 판정된 점은 정의상 core가 될 수 없으므로 이 스킵도 옳다. 경계점 소속 모호성(먼저 도달한 클러스터가 가져감)은 DBSCAN 자체의 성질이지 구현 버그가 아니다.

Assignment 4 — Recommender (SVD)

MovieLens 100K의 5-fold 분할(u1–u5.base/.test)에 대해 편향 항이 붙은 행렬 분해(FunkSVD/Koren 계열)를 SGD로 학습하고 테스트 평점을 예측한다. Cython 확장을 직접 빌드해 u1에 대해 기본 설정(factors=100, epochs=20)으로 실행했고 RMSE 0.9542를 얻었다. README가 보고하는 0.957과 일치하는 범위다. 네 과제 중 가장 잘 쓰인 모듈이다.

중대

베이스라인 비교가 없다. 100차원 잠재 인수가 사주는 것은 2.7%다

README와 보고서에 RMSE 다섯 개(0.957 / 0.943 / 0.936 / 0.933 / 0.933)가 표로 실려 있지만 비교 대상이 없다. 이 숫자가 좋은지 나쁜지 판단할 근거가 보고서 안에 없다.

내가 직접 계산했다. 같은 u1 분할에서 전역 평균만 쓰면 RMSE 1.1537, 사용자 평균 편향 + 아이템 평균 편향이라는 세 줄짜리 베이스라인은 0.9802다. 즉 100차원 × 20 epoch SGD가 사주는 개선은 0.9802 → 0.9542, 2.7%다. 나쁜 결과는 아니지만, 보고서가 이 맥락을 제공하지 않는 탓에 독자는 모델이 실제로 무엇을 기여했는지 알 수 없다. 이 계산은 10초면 끝나는 것이었다.

덧붙여 random_state가 있는데 recommender.py가 한 번도 전달하지 않는다(factorization.pyx:12np.random.mtrand._rand로 폴백). 같은 명령을 두 번 돌린 결과가 0.9542와 0.9527로 달랐다. 보고된 소수점 셋째 자리는 재현되지 않는 숫자다.

중대

SGD 갱신이 표준형과 다르다. 그리고 그 사실을 저자도 알고 있었던 흔적이 있다

행렬 분해 SGD의 표준형은 사용자 인수와 아이템 인수를 같은 시점의 값으로 동시에 갱신한다. 그러려면 갱신 전 값을 임시 변수에 담아야 한다. 이 코드는 :69에서 param_user[u, f]를 먼저 갱신한 뒤 :70에서 이미 갱신된 값으로 아이템 인수를 갱신한다. 비대칭이다.

결정적인 증거는 :39다. cdef double r, err, dot, param_userf, param_itemfparam_userfparam_itemf는 선언만 되고 파일 어디에서도 한 번도 사용되지 않는다. 이 두 변수의 유일한 용도가 바로 "갱신 전 값 보관"이다. 즉 올바른 구조를 알고 변수까지 만들어놓고 본문에서 쓰지 않았다.

다만 영향은 정직하게 보고한다. 내가 .pyx를 표준형으로 고쳐 다시 빌드해 돌린 결과 RMSE는 0.9531 / 0.9525로, 원본의 실행 간 편차(0.9542~0.9527) 안에 들어간다. lr=0.005에서는 실측 차이가 없다. 정확성 결함이지만 성능 결함은 아니다.

cdef double r, err, dot, param_userf, param_itemf   # ← 선언만, 미사용
...
for f in range(self.factors):
    param_user[u, f] += lr * (err * param_item[i, f] - reg * param_user[u, f])
    param_item[i, f] += lr * (err * param_user[u, f] - reg * param_item[i, f])
                                    # ↑ 바로 윗줄에서 갱신된 값을 사용
중대

setup.py에 Surprise 라이브러리의 흔적이 그대로 남아 있고, 출처 표기가 없다

setup.py:76의 console_scripts 항목이 'surprise = surprise.__main__:main'이다. 이 프로젝트에는 surprise라는 패키지도 모듈도 없다. 오픈소스 추천 라이브러리 scikit-surprisesetup.py에서 그대로 복사된 줄이다.

이것만으로 단정하지는 않겠다. 하지만 정황이 겹친다 — (1) 알고리즘 구조가 Surprise의 matrix_factorization.pyx(편향 항 + 잠재 인수, memoryview, 동일한 갱신식 배치)와 같고, (2) 앞서 지적한 미사용 임시 변수 param_userf/param_itemf가 Surprise의 puf/qif와 정확히 같은 역할이며, (3) README·보고서 어디에도 Surprise 언급이 없다. 참고한 것 자체는 문제가 아니지만, 출처를 적지 않은 것은 문제다. 한 줄이면 됐다.

라이선스도 세 갈래로 어긋난다. 레포 루트 LICENSE는 LGPL-3.0, setup.py:59license='GPLv3+', 바로 아래 :63의 classifier는 'License :: OSI Approved :: BSD License'다.

assignment4/setup.py:75–76
entry_points={'console_scripts':
              ['surprise = surprise.__main__:main']},
              # ↑ 이 레포에 surprise 패키지는 존재하지 않는다
경미

Cython을 썼지만 뜨거운 루프가 파이썬 객체를 붙잡고 있다

README는 "matrix operation takes too long, so increase the calculation efficiency using Cython"이라고 한다. 그런데 SVDcdef class가 아니라 평범한 class이고, 가장 안쪽 루프가 파이썬 호출로 가득하다. :56-57np.where(unique_user == u)[0][0]은 평점 × epoch마다 전체 사용자 배열을 선형 스캔한다 — 해시 맵 한 번이면 O(1)인 것을 O(n)으로 푼다. :60sum(... for f in range(self.factors))는 제너레이터 식이라 C 루프로 내려가지 않고, 빌드할 때 Cython이 직접 경고한다: factorization.pyx:60:40: Index should be typed for more efficient access.

그럼에도 u1 전체 학습이 10초에 끝나긴 한다(1 epoch 1.8초). 즉 "Cython으로 가속했다"는 서술이 틀린 것은 아니지만, 남은 성능의 대부분은 여전히 파이썬 쪽에 있다.

경미

예측값을 평점 범위로 자르지 않는다. 그리고 지금은 빌드 자체가 안 된다

u1.test 20,000건 중 115건이 [1, 5] 범위를 벗어난다(최소 -0.111, 최대 5.488). 평점이 1~5인 것은 도메인 상수이므로 클리핑은 공짜 개선이다 — 다만 실측 효과는 RMSE 0.9527 → 0.9523으로 작다. 과제 요구사항을 어기는 것은 아니지만, "-0.11점을 준 추천"은 제출물로서 어색하다.

또한 :34, :87-88np.long_t는 최신 numpy의 Cython 선언에서 제거되어, 지금 환경에서 python setup.py build_extInvalid type으로 실패한다. 내가 np.int64_t로 바꿔야 빌드가 됐다. 2018년 기준으로는 정상이므로 감점 요소는 아니고, 재현성 관점의 기록으로만 남긴다.

잘한 것

cold-start를 실제로 구현했고, 그것이 실제로 발동한다. factorization.pyx:96-108은 사용자와 아이템의 존재 여부를 따로 검사해서, 사용자만 알면 전역 평균+사용자 편향, 둘 다 알아야 잠재 인수 내적을 더한다. u1.test에는 학습셋에 없는 아이템이 참조되는 행이 32건 있는데(사용자 미지는 0건), 이 분기가 없었다면 인덱싱 예외로 죽었을 것이다. 검증했다. 그리고 train/test는 과제가 제공한 분리 파일을 각각 따로 읽으므로(recommender.py:21-22) 데이터 누수의 경로 자체가 없다 — fittrain.values만, predicttest.values[:, :2]만 본다. A2가 train+test를 concat해 인코딩한 것과 대비된다.

반복되는 패턴

  1. 문서가 코드보다 앞서간다. A2의 random forest(오타로 죽음), A2의 gain ratio 메트릭(미구현), A2의 "default gini"(실제로는 entropy), A2의 "min_gain = information gain"(실제로는 불순도 임계값), A4의 "Cython으로 가속"(뜨거운 루프는 여전히 파이썬). 네 과제 중 두 개에서 README의 핵심 주장이 코드로 뒷받침되지 않는다.
  2. 결과 파일을 커밋하되 다시 열어보지 않는다. data/dt_result.txtyesye로 잘린 채, data/dt_result1.txt·test/dt_result1.txt는 서로 다른 정확도(95.66% / 94.51%)로, 그리고 둘 다 현재 코드의 출력(95.09%)과 다른 채로 남아 있다. 결과를 생성한 코드와 커밋된 코드가 일치하는지 확인하는 단계가 없다.
  3. 복사한 파일의 헤더를 고치지 않는다. assignment4/lib/recommender.py:2-7의 docstring은 "Decision tree / assignment#2 / This module implement decision tree"다. 추천 시스템 파일이다. 같은 파일의 self.metric = (algorithm or SVD)(...)도 결정 트리에서 가져온 이름이다. setup.pysurprise entry point도 같은 습관의 산물이다.
  4. 고치려 시도했으나 검증하지 않는다. 커밋 d91182f "Update lib.timer for time delta"는 타이머 버그를 고치려던 것으로 보이나 global 선언이 빠져 여전히 고쳐지지 않았고, 그 상태로 두 과제에 복사됐다. 커밋 909cdbe "Update randomforest"는 동작하던 기능을 리팩터링하면서 오타로 죽였다. 두 경우 모두 커밋 후 한 번만 돌려봤으면 발견됐다.
  5. 성능 주장에 비교 대상이 없다. A2의 정확도 표에도 다수결 베이스라인이 없고(그래서 error 메트릭 67.92%가 "트리가 안 만들어졌다"는 신호인 걸 놓쳤다), A4의 RMSE 표에도 전역 평균이나 편향 베이스라인이 없다. 숫자는 성실하게 모았는데 그 숫자를 해석할 기준선을 만들지 않았다.
  6. 레포 위생. Windows PE32 실행 파일 네 개(assignment1/PA1.exe, assignment2/test/dt_test.exe, assignment3/test/PA3.exe, assignment4/test/PA4.exe)가 커밋돼 있다. 조교가 배포한 검증 도구로 보이지만, 바이너리는 소스 저장소에 들어갈 것이 아니다. 추적 파일 98개 중 18개가 바이너리(exe/png/pdf)이고, assignment4/data의 MovieLens 원본 9.4MB도 그대로 들어 있다.

지금 손본다면

  1. dt.py:43calssifierclassifier로 고친다. 한 글자. README가 자랑하는 기능이 살아난다. 그리고 이 사건이 정확히 린터가 잡아내는 부류(할당 후 미사용 지역 변수)라는 점을 기억할 것.
  2. lib/timer.pybegin()global flag를 추가한다. 한 줄. 두 과제의 모든 성능 수치가 그제서야 의미를 갖는다.
  3. min_gain의 이름을 max_impurity로 바꾸거나, 진짜 information gain(부모 불순도 − 가중 자식 불순도)을 계산한다. 후자가 맞다. 그러면 --metric error가 다수결로 붕괴하지 않고, 임계값 0.3을 메트릭마다 다시 정할 필요도 없어진다.
  4. Apriori에 prune 단계를 넣는다. candidates = {c for c in joined if all(frozenset(s) in prev_frequent for s in combinations(c, k-1))} 한 줄이다. 측정한 입력에서 29%, k=4에서는 95%의 후보 스캔이 사라진다. 알고리즘 이름값을 하게 된다.
  5. DBSCAN의 _neighbor를 벡터화한다. np.where(np.linalg.norm(self.data - self.data[index], axis=1) <= self.eps)[0]. input1의 86초가 수 초로 줄어든다. 그 다음이 KD-tree다.
  6. 보고서마다 베이스라인 한 줄을 추가한다. A2는 다수결 정확도(67.92%), A4는 전역 평균 RMSE(1.1537)와 편향 베이스라인(0.9802). 계산에 각각 10초가 든다. 이게 있었다면 A2의 error 메트릭 붕괴를 본인이 먼저 발견했을 것이다.
  7. setup.py:76의 surprise entry point를 지우고, README에 참고 구현을 명시한다. 그리고 라이선스를 하나로 통일한다(LICENSE=LGPL-3.0 / setup.py=GPLv3+ / classifier=BSD).
  8. 결과 파일을 다시 생성하고 git에서 바이너리를 뺀다. data/dt_result*.txt는 현재 코드로 재생성하고, *.exe 네 개는 .gitignore로 보낸다.