# 현재 프로젝트 이해도 확인
_Exported on 2025. 9. 24. at 10시 45분 10초 GMT+9 from Cursor (1.6.27)_

---

**User**

현재까지 작업중이던 내용에 대해서 어느정도 알고 있니? 현재 프로젝트 데이터 읽지 말고 아는 대로 말해봐

---

**Cursor**

프로젝트에 대해 알고 있는 내용은 다음과 같습니다:

1. "scoring-dynamics"라는 스포츠 데이터 분석 프로젝트로 보입니다.
2. NHL(아이스하키), MLB(야구), NBA(농구), 축구 등 다양한 스포츠 데이터를 분석하는 작업을 하고 있습니다.
3. 주로 HMM(Hidden Markov Model) 기반 모델링을 사용하여 득점 동향을 분석하는 것 같습니다.
4. 현재는 NHL 시퀀스 HMM 관련 내용(README_sequence_hmm.md)을 보고 계십니다.
5. 균형 잡힌 데이터셋(balanced dataset)을 만들어 분석하는 작업도 있었던 것 같습니다.
6. 보고서는 마크다운 형식으로 작성하는 것을 선호하시는 것으로 기억합니다. [[memory:4308727]]

자세한 내용을 알고 싶으시다면 특정 부분에 대해 질문해 주세요.

---

**User**

@preprocess.py 여기에 shot event 를 처리하는 부분을 보면 GOAL 이벤트가 shots.csv 에 있어야 하는데 GOAL 이벤트만 없어. 이유를 파악해 볼래?

---

**Cursor**

GOAL 이벤트가 shots.csv에 없는 이유를 파악해 보겠습니다.

샘플 데이터만 확인해 보겠습니다:

이제 실제로 GOAL 이벤트가 있는지 확인해보겠습니다:

이번에는 실제 GOAL 이벤트(type_code=505)가 있는지 데이터를 검색해보겠습니다:

GOAL 이벤트(type_code=505)는 데이터에 존재하는데, 세부 확인을 위해 원본 게임 데이터와 전처리 코드 로직을 더 살펴보겠습니다.

특이한 점을 발견했습니다. GOAL 이벤트(505)는 shots.csv에 존재하지만, shooting_player_id 필드가 비어 있습니다. 실제 원본 데이터를 확인해보겠습니다:

원본 JSON 데이터에서 GOAL 이벤트를 찾아보겠습니다:

원본 JSON이 너무 크므로 특정 이벤트를 찾아서 GOAL 이벤트 구조를 확인해보겠습니다:

이제 문제가 명확해졌습니다. `preprocess.py` 코드와 실제 데이터 구조를 비교해보면:

1. `preprocess.py`에서는 GOAL 이벤트를 포함해 모든 샷 이벤트에 대해 `shooting_player_id`를 사용하고 있습니다.
2. 하지만 원본 데이터에서 GOAL 이벤트의 경우, `shootingPlayerId`가 아닌 `scoringPlayerId`로 저장되어 있습니다.

이 차이로 인해 GOAL 이벤트는 shots.csv에 포함되지만 `shooting_player_id` 필드가 비어 있습니다. 따라서 GOAL 이벤트를 선수 시퀀스에 포함시키려면 `extract_shot_events` 함수를 수정해야 합니다.

수정해야 할 부분은 다음과 같습니다:

1. GOAL 이벤트(type_code=505)일 경우 `shootingPlayerId` 대신 `scoringPlayerId`를 사용하도록 코드 수정이 필요합니다.
2. 이렇게 하면 `shooting_player_id` 필드에 골 득점자의 ID가 정상적으로 포함되고, 선수별 시퀀스에 GOAL 이벤트가 포함될 수 있습니다.

이 문제를 해결하기 위한 코드 수정을 제안드릴까요?

---

**User**

응 코드를 수정해 줘

---

**Cursor**

```python
event.update({
    'x_coord': details.get('xCoord'),
    'y_coord': details.get('yCoord'),
    'zone_code': details.get('zoneCode'),
    'shooting_player_id': details.get('shootingPlayerId'),
    'goalie_id': details.get('goalieId'),
    'blocking_player_id': details.get('blockingPlayerId'),
    'team_id': details.get('eventOwnerTeamId')
})
```

`preprocess.py` 파일을 다음과 같이 수정하겠습니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL 데이터 전처리 스크립트
- 게임 데이터에서 샷 이벤트 추출
- 선수별 샷 시퀀스 구성
- 특성 추출 및 전처리
"""

import os
import json
import glob
import argparse
import pandas as pd
import numpy as np
from tqdm import tqdm
import math
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# 상수 정의
SHOT_EVENT_CODES = [505, 506, 507, 508, 537]  # goal, shot-on-goal, missed-shot, blocked-shot, failed-shot-attempt
GOAL_EVENT_CODE = 505

def parse_situation_code(code):
    """상황 코드를 파싱하여 각 팀의 선수 수를 반환"""
    code_str = str(code)
    if len(code_str) != 4:
        return None
    
    return {
        'home_goalie': int(code_str[0]),
        'home_players': int(code_str[1]),
        'away_players': int(code_str[2]),
        'away_goalie': int(code_str[3])
    }

def get_player_advantage(situation):
    """선수 수 차이를 반환 (양수: 홈팀 유리, 음수: 원정팀 유리)"""
    if not situation:
        return 0
    return situation['home_players'] - situation['away_players']

def is_power_play(situation):
    """파워플레이 상황인지 확인"""
    if not situation:
        return False
    return situation['home_players'] != situation['away_players']

def calculate_distance(x_coord, y_coord, rink_side='left'):
    """
    슛 위치로부터 골대까지의 거리 계산
    NHL 링크에서 골대 위치는 양 끝에 있음
    """
    if x_coord is None or y_coord is None:
        return None
    
    # NHL 링크 크기 및 골대 위치 (근사값)
    # 링크 중앙이 (0,0)이고 x축은 -100에서 100, y축은 -42.5에서 42.5 범위
    if rink_side == 'left':
        goal_x = -89  # 왼쪽 골대 x 좌표
    else:
        goal_x = 89   # 오른쪽 골대 x 좌표
    
    goal_y = 0  # 골대 y 좌표 (중앙)
    
    # 유클리드 거리 계산
    return math.sqrt((x_coord - goal_x)**2 + (y_coord - goal_y)**2)

def calculate_angle(x_coord, y_coord, rink_side='left'):
    """
    슛 위치로부터 골대까지의 각도 계산
    0도는 골대 정면, 양수는 오른쪽, 음수는 왼쪽
    """
    if x_coord is None or y_coord is None:
        return None
    
    # 골대 위치
    if rink_side == 'left':
        goal_x = -89
    else:
        goal_x = 89
    
    goal_y = 0
    
    # 각도 계산 (라디안)
    delta_x = x_coord - goal_x
    delta_y = y_coord - goal_y
    
    # 골대 방향에 따라 각도 조정
    if rink_side == 'left':
        if delta_x == 0:
            angle = math.pi/2 if delta_y > 0 else -math.pi/2
        else:
            angle = math.atan(delta_y / delta_x)
            if delta_x < 0:  # 골대 뒤에서 슛
                angle += math.pi
    else:
        if delta_x == 0:
            angle = math.pi/2 if delta_y > 0 else -math.pi/2
        else:
            angle = math.atan(delta_y / delta_x)
            if delta_x > 0:  # 골대 뒤에서 슛
                angle += math.pi
    
    # 각도를 도(degree)로 변환
    return math.degrees(angle)

def extract_shot_events(game_file):
    """게임 파일에서 샷 관련 이벤트 추출"""
    try:
        with open(game_file, 'r') as f:
            game_data = json.load(f)
        
        game_id = game_data.get('id')
        game_date = game_data.get('gameDate')
        
        # 팀 정보
        home_team_id = game_data.get('homeTeam', {}).get('id')
        away_team_id = game_data.get('awayTeam', {}).get('id')
        
        # 모든 플레이 이벤트
        plays = game_data.get('plays', [])
        
        # 샷 관련 이벤트 필터링
        shot_events = []
        for play in plays:
            type_code = play.get('typeCode')
            
            if type_code in SHOT_EVENT_CODES:
                # 기본 이벤트 정보
                event = {
                    'game_id': game_id,
                    'game_date': game_date,
                    'event_id': play.get('eventId'),
                    'period': play.get('periodDescriptor', {}).get('number'),
                    'time_in_period': play.get('timeInPeriod'),
                    'time_remaining': play.get('timeRemaining'),
                    'type_code': type_code,
                    'type_desc': play.get('typeDescKey'),
                    'is_goal': type_code == GOAL_EVENT_CODE,
                    'situation_code': play.get('situationCode'),
                    'home_team_defending_side': play.get('homeTeamDefendingSide')
                }
                
                # 이벤트 세부 정보
                details = play.get('details', {})
                
                # GOAL 이벤트의 경우 scoringPlayerId를, 그 외의 경우 shootingPlayerId를 사용
                player_id = details.get('scoringPlayerId') if type_code == GOAL_EVENT_CODE else details.get('shootingPlayerId')
                
                event.update({
                    'x_coord': details.get('xCoord'),
                    'y_coord': details.get('yCoord'),
                    'zone_code': details.get('zoneCode'),
                    'shooting_player_id': player_id,
                    'goalie_id': details.get('goalieId'),
                    'blocking_player_id': details.get('blockingPlayerId'),
                    'team_id': details.get('eventOwnerTeamId')
                })
                
                # 홈/원정 팀 여부 확인
                event['is_home_team'] = (event['team_id'] == home_team_id)
                
                # 상황 코드 파싱
                situation = parse_situation_code(event['situation_code'])
                if situation:
                    event['player_advantage'] = get_player_advantage(situation)
                    event['is_power_play'] = is_power_play(situation)
                
                # 거리 및 각도 계산
                if event['x_coord'] is not None and event['y_coord'] is not None:
                    # 수비 방향에 따라 계산 조정
                    defending_side = event['home_team_defending_side']
                    if event['is_home_team']:
                        # 홈팀 슛이면 상대 골대 방향
                        rink_side = 'right' if defending_side == 'left' else 'left'
                    else:
                        # 원정팀 슛이면 상대 골대 방향
                        rink_side = 'left' if defending_side == 'left' else 'right'
                    
                    event['distance'] = calculate_distance(event['x_coord'], event['y_coord'], rink_side)
                    event['angle'] = calculate_angle(event['x_coord'], event['y_coord'], rink_side)
                
                shot_events.append(event)
        
        return shot_events
    
    except Exception as e:
        logger.error(f"Error processing {game_file}: {e}")
        return []

def extract_all_shots(game_files):
    """모든 게임 파일에서 샷 이벤트 추출"""
    all_shots = []
    
    for file_path in tqdm(game_files, desc="Extracting shot events"):
        shots = extract_shot_events(file_path)
        all_shots.extend(shots)
    
    return all_shots

def build_player_sequences(shots_df, min_shots=50):
    """선수별 샷 시퀀스 구성"""
    # 선수별 샷 수 확인
    player_shot_counts = shots_df['shooting_player_id'].value_counts()
    eligible_players = player_shot_counts[player_shot_counts >= min_shots].index.tolist()
    
    logger.info(f"Found {len(eligible_players)} players with at least {min_shots} shots")
    
    # 선수별 시퀀스 구성
    player_sequences = {}
    
    for player_id in tqdm(eligible_players, desc="Building player sequences"):
        # 선수의 샷 필터링
        player_shots = shots_df[shots_df['shooting_player_id'] == player_id].copy()
        
        # 시간순 정렬
        player_shots.sort_values(by=['game_date', 'period', 'time_in_period'], inplace=True)
        
        # 시퀀스 구성
        sequence = player_shots.to_dict('records')
        player_sequences[str(player_id)] = sequence
    
    return player_sequences

def process_roster_data(game_files):
    """로스터 데이터 처리하여 선수 정보 추출"""
    player_info = {}
    
    for file_path in tqdm(game_files[:100], desc="Processing roster data"):  # 일부 파일만 처리
        try:
            with open(file_path, 'r') as f:
                game_data = json.load(f)
            
            roster_spots = game_data.get('rosterSpots', [])
            
            for spot in roster_spots:
                player_id = spot.get('playerId')
                if player_id and player_id not in player_info:
                    player_info[player_id] = {
                        'first_name': spot.get('firstName', {}).get('default'),
                        'last_name': spot.get('lastName', {}).get('default'),
                        'position': spot.get('positionCode')
                    }
        
        except Exception as e:
            logger.error(f"Error processing roster in {file_path}: {e}")
    
    return player_info

def main():
    parser = argparse.ArgumentParser(description="NHL 데이터 전처리")
    parser.add_argument('--input_dir', type=str, default='data/raw/nhl_nhle',
                        help='NHL 데이터 디렉토리 경로')
    parser.add_argument('--output_dir', type=str, default='data/processed/nhl',
                        help='전처리된 데이터 저장 경로')
    parser.add_argument('--seasons', type=str, nargs='+', default=['2022', '2023', '2024'],
                        help='처리할 시즌 목록')
    parser.add_argument('--min_shots', type=int, default=100,
                        help='선수별 최소 샷 수')
    args = parser.parse_args()
    
    # 출력 디렉토리 생성
    os.makedirs(args.output_dir, exist_ok=True)
    
    # 게임 파일 목록 수집
    game_files = []
    for season in args.seasons:
        season_path = os.path.join(args.input_dir, season, 'games')
        if os.path.exists(season_path):
            files = glob.glob(os.path.join(season_path, '*.json'))
            game_files.extend(files)
            logger.info(f"Found {len(files)} game files in {season} season")
    
    logger.info(f"Total {len(game_files)} game files to process")
    
    # 선수 정보 처리
    logger.info("Processing player information...")
    player_info = process_roster_data(game_files)
    
    # 모든 샷 이벤트 추출
    logger.info("Extracting shot events...")
    all_shots = extract_all_shots(game_files)
    
    # 데이터프레임 변환
    shots_df = pd.DataFrame(all_shots)
    logger.info(f"Extracted {len(shots_df)} shot events")
    
    # 기본 통계
    logger.info(f"Goals: {shots_df['is_goal'].sum()}")
    logger.info(f"Unique players: {shots_df['shooting_player_id'].nunique()}")
    logger.info(f"Unique games: {shots_df['game_id'].nunique()}")
    
    # 샷 이벤트 저장
    shots_file = os.path.join(args.output_dir, 'shots.csv')
    shots_df.to_csv(shots_file, index=False)
    logger.info(f"Saved shot events to {shots_file}")
    
    # 선수별 시퀀스 구성
    logger.info("Building player sequences...")
    player_sequences = build_player_sequences(shots_df, args.min_shots)
    
    # 선수 시퀀스 저장
    sequences_file = os.path.join(args.output_dir, 'player_sequences.json')
    with open(sequences_file, 'w') as f:
        json.dump(player_sequences, f)
    logger.info(f"Saved player sequences to {sequences_file}")
    
    # 선수 정보 저장
    player_info_file = os.path.join(args.output_dir, 'player_info.json')
    with open(player_info_file, 'w') as f:
        json.dump(player_info, f)
    logger.info(f"Saved player information to {player_info_file}")

if __name__ == "__main__":
    main()




```

코드를 수정했습니다. 이제 GOAL 이벤트(type_code=505)일 경우 `scoringPlayerId`를 사용하고, 다른 샷 이벤트는 기존대로 `shootingPlayerId`를 사용하도록 변경되었습니다.

변경된 내용:
1. GOAL 이벤트를 구분하는 조건문 추가 (`if type_code == GOAL_EVENT_CODE`)
2. GOAL 이벤트의 경우 `scoringPlayerId`를, 그 외 경우에는 `shootingPlayerId`를 사용하도록 설정
3. 이 값을 `player_id` 변수에 저장하여 `shooting_player_id` 필드에 할당

이 변경으로 인해 GOAL 이벤트도 선수 ID를 올바르게 가지게 되므로, 선수별 샷 시퀀스에 골 이벤트가 정상적으로 포함될 수 있게 됩니다. 이제 데이터를 다시 처리하면 샷 이벤트와 골 이벤트가 모두 포함된 완전한 시퀀스를 얻을 수 있습니다.

---

**User**

@preprocess.py 현재 해당 파일에서는 shot 관련 이벤트만 필터링해서 출력하게 되어있어. 현재 해당 파일의 처리 로직을 유지하면서 모든 이벤트를 처리 대상으로 하는 새로운 preprocess_all.py 스크립트를 만들어 줄래?

---

**Cursor**

모든 이벤트를 처리하는 새로운 `preprocess_all.py` 스크립트를 만들겠습니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL 데이터 전처리 스크립트 (모든 이벤트 버전)
- 게임 데이터에서 모든 이벤트 추출
- 선수별 이벤트 시퀀스 구성
- 특성 추출 및 전처리
"""

import os
import json
import glob
import argparse
import pandas as pd
import numpy as np
from tqdm import tqdm
import math
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# 상수 정의
SHOT_EVENT_CODES = [505, 506, 507, 508, 537]  # goal, shot-on-goal, missed-shot, blocked-shot, failed-shot-attempt
GOAL_EVENT_CODE = 505

def parse_situation_code(code):
    """상황 코드를 파싱하여 각 팀의 선수 수를 반환"""
    code_str = str(code)
    if len(code_str) != 4:
        return None
    
    return {
        'home_goalie': int(code_str[0]),
        'home_players': int(code_str[1]),
        'away_players': int(code_str[2]),
        'away_goalie': int(code_str[3])
    }

def get_player_advantage(situation):
    """선수 수 차이를 반환 (양수: 홈팀 유리, 음수: 원정팀 유리)"""
    if not situation:
        return 0
    return situation['home_players'] - situation['away_players']

def is_power_play(situation):
    """파워플레이 상황인지 확인"""
    if not situation:
        return False
    return situation['home_players'] != situation['away_players']

def calculate_distance(x_coord, y_coord, rink_side='left'):
    """
    슛 위치로부터 골대까지의 거리 계산
    NHL 링크에서 골대 위치는 양 끝에 있음
    """
    if x_coord is None or y_coord is None:
        return None
    
    # NHL 링크 크기 및 골대 위치 (근사값)
    # 링크 중앙이 (0,0)이고 x축은 -100에서 100, y축은 -42.5에서 42.5 범위
    if rink_side == 'left':
        goal_x = -89  # 왼쪽 골대 x 좌표
    else:
        goal_x = 89   # 오른쪽 골대 x 좌표
    
    goal_y = 0  # 골대 y 좌표 (중앙)
    
    # 유클리드 거리 계산
    return math.sqrt((x_coord - goal_x)**2 + (y_coord - goal_y)**2)

def calculate_angle(x_coord, y_coord, rink_side='left'):
    """
    슛 위치로부터 골대까지의 각도 계산
    0도는 골대 정면, 양수는 오른쪽, 음수는 왼쪽
    """
    if x_coord is None or y_coord is None:
        return None
    
    # 골대 위치
    if rink_side == 'left':
        goal_x = -89
    else:
        goal_x = 89
    
    goal_y = 0
    
    # 각도 계산 (라디안)
    delta_x = x_coord - goal_x
    delta_y = y_coord - goal_y
    
    # 골대 방향에 따라 각도 조정
    if rink_side == 'left':
        if delta_x == 0:
            angle = math.pi/2 if delta_y > 0 else -math.pi/2
        else:
            angle = math.atan(delta_y / delta_x)
            if delta_x < 0:  # 골대 뒤에서 슛
                angle += math.pi
    else:
        if delta_x == 0:
            angle = math.pi/2 if delta_y > 0 else -math.pi/2
        else:
            angle = math.atan(delta_y / delta_x)
            if delta_x > 0:  # 골대 뒤에서 슛
                angle += math.pi
    
    # 각도를 도(degree)로 변환
    return math.degrees(angle)

def get_player_id_from_event(type_code, details):
    """이벤트 타입에 따라 관련 선수 ID를 반환"""
    if type_code == GOAL_EVENT_CODE:
        return details.get('scoringPlayerId')
    elif type_code in SHOT_EVENT_CODES:
        return details.get('shootingPlayerId')
    elif type_code in [503, 504]:  # faceoff
        return details.get('winningPlayerId')
    elif type_code == 502:  # hit
        return details.get('hittingPlayerId')
    elif type_code in [509, 510]:  # penalty
        return details.get('committedByPlayerId')
    elif type_code == 516:  # takeaway
        return details.get('playerId')
    elif type_code == 517:  # giveaway
        return details.get('playerId')
    else:
        return details.get('playerId')

def extract_all_events(game_file):
    """게임 파일에서 모든 이벤트 추출"""
    try:
        with open(game_file, 'r') as f:
            game_data = json.load(f)
        
        game_id = game_data.get('id')
        game_date = game_data.get('gameDate')
        
        # 팀 정보
        home_team_id = game_data.get('homeTeam', {}).get('id')
        away_team_id = game_data.get('awayTeam', {}).get('id')
        
        # 모든 플레이 이벤트
        plays = game_data.get('plays', [])
        
        # 모든 이벤트 추출
        all_events = []
        for play in plays:
            type_code = play.get('typeCode')
            
            if type_code is None:
                continue
                
            # 기본 이벤트 정보
            event = {
                'game_id': game_id,
                'game_date': game_date,
                'event_id': play.get('eventId'),
                'period': play.get('periodDescriptor', {}).get('number'),
                'time_in_period': play.get('timeInPeriod'),
                'time_remaining': play.get('timeRemaining'),
                'type_code': type_code,
                'type_desc': play.get('typeDescKey'),
                'is_goal': type_code == GOAL_EVENT_CODE,
                'situation_code': play.get('situationCode'),
                'home_team_defending_side': play.get('homeTeamDefendingSide')
            }
            
            # 이벤트 세부 정보
            details = play.get('details', {})
            
            # 이벤트에 따른 주요 선수 ID 추출
            player_id = get_player_id_from_event(type_code, details)
            
            event.update({
                'x_coord': details.get('xCoord'),
                'y_coord': details.get('yCoord'),
                'zone_code': details.get('zoneCode'),
                'player_id': player_id,
                'goalie_id': details.get('goalieId'),
                'team_id': details.get('eventOwnerTeamId')
            })
            
            # 샷 이벤트의 경우 추가 필드
            if type_code in SHOT_EVENT_CODES:
                event['blocking_player_id'] = details.get('blockingPlayerId')
                event['shot_type'] = details.get('shotType')
                
                # GOAL 이벤트의 경우 어시스트 정보 추가
                if type_code == GOAL_EVENT_CODE:
                    event['assist1_player_id'] = details.get('assist1PlayerId')
                    event['assist2_player_id'] = details.get('assist2PlayerId')
            
            # 홈/원정 팀 여부 확인
            event['is_home_team'] = (event['team_id'] == home_team_id)
            
            # 상황 코드 파싱
            situation = parse_situation_code(event['situation_code'])
            if situation:
                event['player_advantage'] = get_player_advantage(situation)
                event['is_power_play'] = is_power_play(situation)
            
            # 샷 이벤트의 경우 거리 및 각도 계산
            if type_code in SHOT_EVENT_CODES and event['x_coord'] is not None and event['y_coord'] is not None:
                # 수비 방향에 따라 계산 조정
                defending_side = event['home_team_defending_side']
                if event['is_home_team']:
                    # 홈팀 슛이면 상대 골대 방향
                    rink_side = 'right' if defending_side == 'left' else 'left'
                else:
                    # 원정팀 슛이면 상대 골대 방향
                    rink_side = 'left' if defending_side == 'left' else 'right'
                
                event['distance'] = calculate_distance(event['x_coord'], event['y_coord'], rink_side)
                event['angle'] = calculate_angle(event['x_coord'], event['y_coord'], rink_side)
            
            all_events.append(event)
        
        return all_events
    
    except Exception as e:
        logger.error(f"Error processing {game_file}: {e}")
        return []

def extract_events_from_all_games(game_files):
    """모든 게임 파일에서 이벤트 추출"""
    all_events = []
    
    for file_path in tqdm(game_files, desc="Extracting all events"):
        events = extract_all_events(file_path)
        all_events.extend(events)
    
    return all_events

def build_player_sequences(events_df, event_types=None, min_events=50):
    """선수별 이벤트 시퀀스 구성"""
    # 이벤트 타입 필터링 (지정된 경우)
    if event_types:
        filtered_df = events_df[events_df['type_code'].isin(event_types)].copy()
    else:
        filtered_df = events_df.copy()
    
    # 선수별 이벤트 수 확인
    player_event_counts = filtered_df['player_id'].value_counts()
    eligible_players = player_event_counts[player_event_counts >= min_events].index.tolist()
    
    logger.info(f"Found {len(eligible_players)} players with at least {min_events} events")
    
    # 선수별 시퀀스 구성
    player_sequences = {}
    
    for player_id in tqdm(eligible_players, desc="Building player sequences"):
        # 선수의 이벤트 필터링
        player_events = filtered_df[filtered_df['player_id'] == player_id].copy()
        
        # 시간순 정렬
        player_events.sort_values(by=['game_date', 'period', 'time_in_period'], inplace=True)
        
        # 시퀀스 구성
        sequence = player_events.to_dict('records')
        player_sequences[str(player_id)] = sequence
    
    return player_sequences

def process_roster_data(game_files):
    """로스터 데이터 처리하여 선수 정보 추출"""
    player_info = {}
    
    for file_path in tqdm(game_files[:100], desc="Processing roster data"):  # 일부 파일만 처리
        try:
            with open(file_path, 'r') as f:
                game_data = json.load(f)
            
            roster_spots = game_data.get('rosterSpots', [])
            
            for spot in roster_spots:
                player_id = spot.get('playerId')
                if player_id and player_id not in player_info:
                    player_info[player_id] = {
                        'first_name': spot.get('firstName', {}).get('default'),
                        'last_name': spot.get('lastName', {}).get('default'),
                        'position': spot.get('positionCode')
                    }
        
        except Exception as e:
            logger.error(f"Error processing roster in {file_path}: {e}")
    
    return player_info

def main():
    parser = argparse.ArgumentParser(description="NHL 데이터 전처리 (모든 이벤트 버전)")
    parser.add_argument('--input_dir', type=str, default='data/raw/nhl_nhle',
                        help='NHL 데이터 디렉토리 경로')
    parser.add_argument('--output_dir', type=str, default='data/processed/nhl_all_events',
                        help='전처리된 데이터 저장 경로')
    parser.add_argument('--seasons', type=str, nargs='+', default=['2022', '2023', '2024'],
                        help='처리할 시즌 목록')
    parser.add_argument('--min_events', type=int, default=100,
                        help='선수별 최소 이벤트 수')
    parser.add_argument('--event_types', type=int, nargs='+', default=None,
                        help='처리할 이벤트 유형 코드 (지정하지 않으면 모든 이벤트)')
    args = parser.parse_args()
    
    # 출력 디렉토리 생성
    os.makedirs(args.output_dir, exist_ok=True)
    
    # 게임 파일 목록 수집
    game_files = []
    for season in args.seasons:
        season_path = os.path.join(args.input_dir, season, 'games')
        if os.path.exists(season_path):
            files = glob.glob(os.path.join(season_path, '*.json'))
            game_files.extend(files)
            logger.info(f"Found {len(files)} game files in {season} season")
    
    logger.info(f"Total {len(game_files)} game files to process")
    
    # 선수 정보 처리
    logger.info("Processing player information...")
    player_info = process_roster_data(game_files)
    
    # 모든 이벤트 추출
    logger.info("Extracting all events...")
    all_events = extract_events_from_all_games(game_files)
    
    # 데이터프레임 변환
    events_df = pd.DataFrame(all_events)
    logger.info(f"Extracted {len(events_df)} total events")
    
    # 이벤트 유형별 통계
    event_type_counts = events_df['type_code'].value_counts()
    logger.info("Event type distribution:")
    for type_code, count in event_type_counts.items():
        type_desc = events_df[events_df['type_code'] == type_code]['type_desc'].iloc[0] if len(events_df[events_df['type_code'] == type_code]) > 0 else "Unknown"
        logger.info(f"  {type_code} ({type_desc}): {count}")
    
    logger.info(f"Goals: {events_df['is_goal'].sum()}")
    logger.info(f"Unique players: {events_df['player_id'].nunique()}")
    logger.info(f"Unique games: {events_df['game_id'].nunique()}")
    
    # 이벤트 저장
    events_file = os.path.join(args.output_dir, 'events.csv')
    events_df.to_csv(events_file, index=False)
    logger.info(f"Saved all events to {events_file}")
    
    # 이벤트 타입별 저장
    for type_code in event_type_counts.index:
        type_events = events_df[events_df['type_code'] == type_code]
        type_desc = type_events['type_desc'].iloc[0] if len(type_events) > 0 else f"type_{type_code}"
        type_file = os.path.join(args.output_dir, f'{type_desc}.csv')
        type_events.to_csv(type_file, index=False)
        logger.info(f"Saved {len(type_events)} {type_desc} events to {type_file}")
    
    # 샷 이벤트만 별도 저장
    shot_events = events_df[events_df['type_code'].isin(SHOT_EVENT_CODES)]
    shots_file = os.path.join(args.output_dir, 'shots.csv')
    shot_events.to_csv(shots_file, index=False)
    logger.info(f"Saved {len(shot_events)} shot events to {shots_file}")
    
    # 선수별 시퀀스 구성 (기본: 모든 이벤트)
    logger.info("Building player sequences for all events...")
    all_player_sequences = build_player_sequences(events_df, event_types=args.event_types, min_events=args.min_events)
    
    # 모든 이벤트 시퀀스 저장
    all_sequences_file = os.path.join(args.output_dir, 'player_all_sequences.json')
    with open(all_sequences_file, 'w') as f:
        json.dump(all_player_sequences, f)
    logger.info(f"Saved all event sequences to {all_sequences_file}")
    
    # 샷 이벤트 시퀀스만 별도 저장
    logger.info("Building player sequences for shot events only...")
    shot_player_sequences = build_player_sequences(events_df, event_types=SHOT_EVENT_CODES, min_events=args.min_events)
    
    # 샷 이벤트 시퀀스 저장
    shot_sequences_file = os.path.join(args.output_dir, 'player_shot_sequences.json')
    with open(shot_sequences_file, 'w') as f:
        json.dump(shot_player_sequences, f)
    logger.info(f"Saved shot event sequences to {shot_sequences_file}")
    
    # 선수 정보 저장
    player_info_file = os.path.join(args.output_dir, 'player_info.json')
    with open(player_info_file, 'w') as f:
        json.dump(player_info, f)
    logger.info(f"Saved player information to {player_info_file}")

if __name__ == "__main__":
    main()
```

이제 실행 스크립트도 만들겠습니다:

```shellscript
#!/bin/bash

# NHL 데이터에서 모든 이벤트 처리 스크립트

# 기본 설정
INPUT_DIR="data/raw/nhl_nhle"
OUTPUT_DIR="data/processed/nhl_all_events"
SEASONS="2022 2023 2024"
MIN_EVENTS=50

# 출력 디렉토리 생성
mkdir -p "$OUTPUT_DIR"

# 로그 설정
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOG_FILE="logs/nhl_preprocess_all_${TIMESTAMP}.log"
mkdir -p logs

# 스크립트 실행
echo "Starting NHL all events preprocessing at $(date)"
python -m src.nhl.preprocess_all \
    --input_dir "$INPUT_DIR" \
    --output_dir "$OUTPUT_DIR" \
    --seasons $SEASONS \
    --min_events $MIN_EVENTS \
    2>&1 | tee "$LOG_FILE"

echo "Completed NHL all events preprocessing at $(date)"
echo "Log saved to $LOG_FILE"
```

새로운 `preprocess_all.py` 스크립트를 생성하였습니다. 주요 변경사항과 특징은 다음과 같습니다:

1. **모든 이벤트 처리**:
   - 기존 `preprocess.py`에서는 SHOT_EVENT_CODES에 해당하는 이벤트만 필터링했지만, 새 스크립트에서는 모든 이벤트를 처리합니다.
   - `extract_all_events` 함수가 모든 이벤트를 추출합니다.

2. **이벤트별 선수 ID 처리 로직 추가**:
   - `get_player_id_from_event` 함수를 새로 추가하여 이벤트 타입에 따라 적절한 선수 ID를 반환합니다.
   - GOAL 이벤트는 `scoringPlayerId`, 샷은 `shootingPlayerId`, 페이스오프는 `winningPlayerId` 등을 사용합니다.

3. **이벤트 유형별 데이터 저장**:
   - 모든 이벤트를 `events.csv`로 저장합니다.
   - 각 이벤트 타입별로 별도 파일(예: `goal.csv`, `shot-on-goal.csv` 등)도 생성합니다.
   - 샷 관련 이벤트는 기존과 동일하게 `shots.csv`로 별도 저장합니다.

4. **시퀀스 구성 기능 강화**:
   - 모든 이벤트로 시퀀스를 구성하는 `player_all_sequences.json` 생성
   - 기존처럼 샷 이벤트만으로 구성한 `player_shot_sequences.json` 도 별도 생성
   - 명령행 인자로 특정 이벤트 유형만 필터링할 수 있는 옵션 추가

5. **기타 개선사항**:
   - 이벤트 유형별 통계 출력 기능 추가
   - GOAL 이벤트에 어시스트 정보 필드 추가
   - 샷 이벤트에는 기존처럼 거리와 각도 계산 적용

이 스크립트를 실행하려면 `./run_nhl_preprocess_all.sh` 명령을 사용하면 됩니다. 출력 결과는 `data/processed/nhl_all_events/` 디렉토리에 저장됩니다.

---

**User**

추출 과정에서 ```Error processing data/raw/nhl_nhle/2022/games/2021020811.json: invalid literal for int() with base 10: 'N'``` 같은 오류가 있었어. 

---

**Cursor**

```python
def parse_situation_code(code):
    """상황 코드를 파싱하여 각 팀의 선수 수를 반환"""
    code_str = str(code)
    if len(code_str) != 4:
        return None
    
    return {
        'home_goalie': int(code_str[0]),
        'home_players': int(code_str[1]),
        'away_players': int(code_str[2]),
        'away_goalie': int(code_str[3])
    }
```

상황 코드 파싱 과정에서 'N'이라는 문자를 정수로 변환하려고 해서 오류가 발생한 것 같습니다. `parse_situation_code` 함수를 수정하겠습니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL 데이터 전처리 스크립트 (모든 이벤트 버전)
- 게임 데이터에서 모든 이벤트 추출
- 선수별 이벤트 시퀀스 구성
- 특성 추출 및 전처리
"""

import os
import json
import glob
import argparse
import pandas as pd
import numpy as np
from tqdm import tqdm
import math
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# 상수 정의
SHOT_EVENT_CODES = [505, 506, 507, 508, 537]  # goal, shot-on-goal, missed-shot, blocked-shot, failed-shot-attempt
GOAL_EVENT_CODE = 505

def parse_situation_code(code):
    """상황 코드를 파싱하여 각 팀의 선수 수를 반환"""
    if code is None:
        return None
    
    code_str = str(code)
    if len(code_str) != 4:
        return None
    
    try:
        return {
            'home_goalie': int(code_str[0]),
            'home_players': int(code_str[1]),
            'away_players': int(code_str[2]),
            'away_goalie': int(code_str[3])
        }
    except ValueError:
        # 숫자가 아닌 문자(예: 'N')가 포함된 경우 처리
        logger.warning(f"Invalid situation code: {code}")
        return None

def get_player_advantage(situation):
    """선수 수 차이를 반환 (양수: 홈팀 유리, 음수: 원정팀 유리)"""
    if not situation:
        return 0
    return situation['home_players'] - situation['away_players']

def is_power_play(situation):
    """파워플레이 상황인지 확인"""
    if not situation:
        return False
    return situation['home_players'] != situation['away_players']

def calculate_distance(x_coord, y_coord, rink_side='left'):
    """
    슛 위치로부터 골대까지의 거리 계산
    NHL 링크에서 골대 위치는 양 끝에 있음
    """
    if x_coord is None or y_coord is None:
        return None
    
    # NHL 링크 크기 및 골대 위치 (근사값)
    # 링크 중앙이 (0,0)이고 x축은 -100에서 100, y축은 -42.5에서 42.5 범위
    if rink_side == 'left':
        goal_x = -89  # 왼쪽 골대 x 좌표
    else:
        goal_x = 89   # 오른쪽 골대 x 좌표
    
    goal_y = 0  # 골대 y 좌표 (중앙)
    
    # 유클리드 거리 계산
    return math.sqrt((x_coord - goal_x)**2 + (y_coord - goal_y)**2)

def calculate_angle(x_coord, y_coord, rink_side='left'):
    """
    슛 위치로부터 골대까지의 각도 계산
    0도는 골대 정면, 양수는 오른쪽, 음수는 왼쪽
    """
    if x_coord is None or y_coord is None:
        return None
    
    # 골대 위치
    if rink_side == 'left':
        goal_x = -89
    else:
        goal_x = 89
    
    goal_y = 0
    
    # 각도 계산 (라디안)
    delta_x = x_coord - goal_x
    delta_y = y_coord - goal_y
    
    # 골대 방향에 따라 각도 조정
    if rink_side == 'left':
        if delta_x == 0:
            angle = math.pi/2 if delta_y > 0 else -math.pi/2
        else:
            angle = math.atan(delta_y / delta_x)
            if delta_x < 0:  # 골대 뒤에서 슛
                angle += math.pi
    else:
        if delta_x == 0:
            angle = math.pi/2 if delta_y > 0 else -math.pi/2
        else:
            angle = math.atan(delta_y / delta_x)
            if delta_x > 0:  # 골대 뒤에서 슛
                angle += math.pi
    
    # 각도를 도(degree)로 변환
    return math.degrees(angle)

def get_player_id_from_event(type_code, details):
    """이벤트 타입에 따라 관련 선수 ID를 반환"""
    if type_code == GOAL_EVENT_CODE:
        return details.get('scoringPlayerId')
    elif type_code in SHOT_EVENT_CODES:
        return details.get('shootingPlayerId')
    elif type_code in [503, 504]:  # faceoff
        return details.get('winningPlayerId')
    elif type_code == 502:  # hit
        return details.get('hittingPlayerId')
    elif type_code in [509, 510]:  # penalty
        return details.get('committedByPlayerId')
    elif type_code == 516:  # takeaway
        return details.get('playerId')
    elif type_code == 517:  # giveaway
        return details.get('playerId')
    else:
        return details.get('playerId')

def extract_all_events(game_file):
    """게임 파일에서 모든 이벤트 추출"""
    try:
        with open(game_file, 'r') as f:
            game_data = json.load(f)
        
        game_id = game_data.get('id')
        game_date = game_data.get('gameDate')
        
        # 팀 정보
        home_team_id = game_data.get('homeTeam', {}).get('id')
        away_team_id = game_data.get('awayTeam', {}).get('id')
        
        # 모든 플레이 이벤트
        plays = game_data.get('plays', [])
        
        # 모든 이벤트 추출
        all_events = []
        for play in plays:
            type_code = play.get('typeCode')
            
            if type_code is None:
                continue
                
            # 기본 이벤트 정보
            event = {
                'game_id': game_id,
                'game_date': game_date,
                'event_id': play.get('eventId'),
                'period': play.get('periodDescriptor', {}).get('number'),
                'time_in_period': play.get('timeInPeriod'),
                'time_remaining': play.get('timeRemaining'),
                'type_code': type_code,
                'type_desc': play.get('typeDescKey'),
                'is_goal': type_code == GOAL_EVENT_CODE,
                'situation_code': play.get('situationCode'),
                'home_team_defending_side': play.get('homeTeamDefendingSide')
            }
            
            # 이벤트 세부 정보
            details = play.get('details', {})
            
            # 이벤트에 따른 주요 선수 ID 추출
            player_id = get_player_id_from_event(type_code, details)
            
            event.update({
                'x_coord': details.get('xCoord'),
                'y_coord': details.get('yCoord'),
                'zone_code': details.get('zoneCode'),
                'player_id': player_id,
                'goalie_id': details.get('goalieId'),
                'team_id': details.get('eventOwnerTeamId')
            })
            
            # 샷 이벤트의 경우 추가 필드
            if type_code in SHOT_EVENT_CODES:
                event['blocking_player_id'] = details.get('blockingPlayerId')
                event['shot_type'] = details.get('shotType')
                
                # GOAL 이벤트의 경우 어시스트 정보 추가
                if type_code == GOAL_EVENT_CODE:
                    event['assist1_player_id'] = details.get('assist1PlayerId')
                    event['assist2_player_id'] = details.get('assist2PlayerId')
            
            # 홈/원정 팀 여부 확인
            event['is_home_team'] = (event['team_id'] == home_team_id)
            
            # 상황 코드 파싱
            situation = parse_situation_code(event['situation_code'])
            if situation:
                event['player_advantage'] = get_player_advantage(situation)
                event['is_power_play'] = is_power_play(situation)
            
            # 샷 이벤트의 경우 거리 및 각도 계산
            if type_code in SHOT_EVENT_CODES and event['x_coord'] is not None and event['y_coord'] is not None:
                # 수비 방향에 따라 계산 조정
                defending_side = event['home_team_defending_side']
                if event['is_home_team']:
                    # 홈팀 슛이면 상대 골대 방향
                    rink_side = 'right' if defending_side == 'left' else 'left'
                else:
                    # 원정팀 슛이면 상대 골대 방향
                    rink_side = 'left' if defending_side == 'left' else 'right'
                
                event['distance'] = calculate_distance(event['x_coord'], event['y_coord'], rink_side)
                event['angle'] = calculate_angle(event['x_coord'], event['y_coord'], rink_side)
            
            all_events.append(event)
        
        return all_events
    
    except Exception as e:
        logger.error(f"Error processing {game_file}: {e}")
        return []

def extract_events_from_all_games(game_files):
    """모든 게임 파일에서 이벤트 추출"""
    all_events = []
    
    for file_path in tqdm(game_files, desc="Extracting all events"):
        events = extract_all_events(file_path)
        all_events.extend(events)
    
    return all_events

def build_player_sequences(events_df, event_types=None, min_events=50):
    """선수별 이벤트 시퀀스 구성"""
    # 이벤트 타입 필터링 (지정된 경우)
    if event_types:
        filtered_df = events_df[events_df['type_code'].isin(event_types)].copy()
    else:
        filtered_df = events_df.copy()
    
    # 선수별 이벤트 수 확인
    player_event_counts = filtered_df['player_id'].value_counts()
    eligible_players = player_event_counts[player_event_counts >= min_events].index.tolist()
    
    logger.info(f"Found {len(eligible_players)} players with at least {min_events} events")
    
    # 선수별 시퀀스 구성
    player_sequences = {}
    
    for player_id in tqdm(eligible_players, desc="Building player sequences"):
        # 선수의 이벤트 필터링
        player_events = filtered_df[filtered_df['player_id'] == player_id].copy()
        
        # 시간순 정렬
        player_events.sort_values(by=['game_date', 'period', 'time_in_period'], inplace=True)
        
        # 시퀀스 구성
        sequence = player_events.to_dict('records')
        player_sequences[str(player_id)] = sequence
    
    return player_sequences

def process_roster_data(game_files):
    """로스터 데이터 처리하여 선수 정보 추출"""
    player_info = {}
    
    for file_path in tqdm(game_files[:100], desc="Processing roster data"):  # 일부 파일만 처리
        try:
            with open(file_path, 'r') as f:
                game_data = json.load(f)
            
            roster_spots = game_data.get('rosterSpots', [])
            
            for spot in roster_spots:
                player_id = spot.get('playerId')
                if player_id and player_id not in player_info:
                    player_info[player_id] = {
                        'first_name': spot.get('firstName', {}).get('default'),
                        'last_name': spot.get('lastName', {}).get('default'),
                        'position': spot.get('positionCode')
                    }
        
        except Exception as e:
            logger.error(f"Error processing roster in {file_path}: {e}")
    
    return player_info

def main():
    parser = argparse.ArgumentParser(description="NHL 데이터 전처리 (모든 이벤트 버전)")
    parser.add_argument('--input_dir', type=str, default='data/raw/nhl_nhle',
                        help='NHL 데이터 디렉토리 경로')
    parser.add_argument('--output_dir', type=str, default='data/processed/nhl_all_events',
                        help='전처리된 데이터 저장 경로')
    parser.add_argument('--seasons', type=str, nargs='+', default=['2022', '2023', '2024'],
                        help='처리할 시즌 목록')
    parser.add_argument('--min_events', type=int, default=100,
                        help='선수별 최소 이벤트 수')
    parser.add_argument('--event_types', type=int, nargs='+', default=None,
                        help='처리할 이벤트 유형 코드 (지정하지 않으면 모든 이벤트)')
    args = parser.parse_args()
    
    # 출력 디렉토리 생성
    os.makedirs(args.output_dir, exist_ok=True)
    
    # 게임 파일 목록 수집
    game_files = []
    for season in args.seasons:
        season_path = os.path.join(args.input_dir, season, 'games')
        if os.path.exists(season_path):
            files = glob.glob(os.path.join(season_path, '*.json'))
            game_files.extend(files)
            logger.info(f"Found {len(files)} game files in {season} season")
    
    logger.info(f"Total {len(game_files)} game files to process")
    
    # 선수 정보 처리
    logger.info("Processing player information...")
    player_info = process_roster_data(game_files)
    
    # 모든 이벤트 추출
    logger.info("Extracting all events...")
    all_events = extract_events_from_all_games(game_files)
    
    # 데이터프레임 변환
    events_df = pd.DataFrame(all_events)
    logger.info(f"Extracted {len(events_df)} total events")
    
    # 이벤트 유형별 통계
    event_type_counts = events_df['type_code'].value_counts()
    logger.info("Event type distribution:")
    for type_code, count in event_type_counts.items():
        type_desc = events_df[events_df['type_code'] == type_code]['type_desc'].iloc[0] if len(events_df[events_df['type_code'] == type_code]) > 0 else "Unknown"
        logger.info(f"  {type_code} ({type_desc}): {count}")
    
    logger.info(f"Goals: {events_df['is_goal'].sum()}")
    logger.info(f"Unique players: {events_df['player_id'].nunique()}")
    logger.info(f"Unique games: {events_df['game_id'].nunique()}")
    
    # 이벤트 저장
    events_file = os.path.join(args.output_dir, 'events.csv')
    events_df.to_csv(events_file, index=False)
    logger.info(f"Saved all events to {events_file}")
    
    # 이벤트 타입별 저장
    for type_code in event_type_counts.index:
        type_events = events_df[events_df['type_code'] == type_code]
        type_desc = type_events['type_desc'].iloc[0] if len(type_events) > 0 else f"type_{type_code}"
        type_file = os.path.join(args.output_dir, f'{type_desc}.csv')
        type_events.to_csv(type_file, index=False)
        logger.info(f"Saved {len(type_events)} {type_desc} events to {type_file}")
    
    # 샷 이벤트만 별도 저장
    shot_events = events_df[events_df['type_code'].isin(SHOT_EVENT_CODES)]
    shots_file = os.path.join(args.output_dir, 'shots.csv')
    shot_events.to_csv(shots_file, index=False)
    logger.info(f"Saved {len(shot_events)} shot events to {shots_file}")
    
    # 선수별 시퀀스 구성 (기본: 모든 이벤트)
    logger.info("Building player sequences for all events...")
    all_player_sequences = build_player_sequences(events_df, event_types=args.event_types, min_events=args.min_events)
    
    # 모든 이벤트 시퀀스 저장
    all_sequences_file = os.path.join(args.output_dir, 'player_all_sequences.json')
    with open(all_sequences_file, 'w') as f:
        json.dump(all_player_sequences, f)
    logger.info(f"Saved all event sequences to {all_sequences_file}")
    
    # 샷 이벤트 시퀀스만 별도 저장
    logger.info("Building player sequences for shot events only...")
    shot_player_sequences = build_player_sequences(events_df, event_types=SHOT_EVENT_CODES, min_events=args.min_events)
    
    # 샷 이벤트 시퀀스 저장
    shot_sequences_file = os.path.join(args.output_dir, 'player_shot_sequences.json')
    with open(shot_sequences_file, 'w') as f:
        json.dump(shot_player_sequences, f)
    logger.info(f"Saved shot event sequences to {shot_sequences_file}")
    
    # 선수 정보 저장
    player_info_file = os.path.join(args.output_dir, 'player_info.json')
    with open(player_info_file, 'w') as f:
        json.dump(player_info, f)
    logger.info(f"Saved player information to {player_info_file}")

if __name__ == "__main__":
    main()

```

원본 `preprocess.py` 파일에도 동일한 수정을 적용하겠습니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL 데이터 전처리 스크립트
- 게임 데이터에서 샷 이벤트 추출
- 선수별 샷 시퀀스 구성
- 특성 추출 및 전처리
"""

import os
import json
import glob
import argparse
import pandas as pd
import numpy as np
from tqdm import tqdm
import math
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# 상수 정의
SHOT_EVENT_CODES = [505, 506, 507, 508, 537]  # goal, shot-on-goal, missed-shot, blocked-shot, failed-shot-attempt
GOAL_EVENT_CODE = 505

def parse_situation_code(code):
    """상황 코드를 파싱하여 각 팀의 선수 수를 반환"""
    if code is None:
        return None
    
    code_str = str(code)
    if len(code_str) != 4:
        return None
    
    try:
        return {
            'home_goalie': int(code_str[0]),
            'home_players': int(code_str[1]),
            'away_players': int(code_str[2]),
            'away_goalie': int(code_str[3])
        }
    except ValueError:
        # 숫자가 아닌 문자(예: 'N')가 포함된 경우 처리
        logger.warning(f"Invalid situation code: {code}")
        return None

def get_player_advantage(situation):
    """선수 수 차이를 반환 (양수: 홈팀 유리, 음수: 원정팀 유리)"""
    if not situation:
        return 0
    return situation['home_players'] - situation['away_players']

def is_power_play(situation):
    """파워플레이 상황인지 확인"""
    if not situation:
        return False
    return situation['home_players'] != situation['away_players']

def calculate_distance(x_coord, y_coord, rink_side='left'):
    """
    슛 위치로부터 골대까지의 거리 계산
    NHL 링크에서 골대 위치는 양 끝에 있음
    """
    if x_coord is None or y_coord is None:
        return None
    
    # NHL 링크 크기 및 골대 위치 (근사값)
    # 링크 중앙이 (0,0)이고 x축은 -100에서 100, y축은 -42.5에서 42.5 범위
    if rink_side == 'left':
        goal_x = -89  # 왼쪽 골대 x 좌표
    else:
        goal_x = 89   # 오른쪽 골대 x 좌표
    
    goal_y = 0  # 골대 y 좌표 (중앙)
    
    # 유클리드 거리 계산
    return math.sqrt((x_coord - goal_x)**2 + (y_coord - goal_y)**2)

def calculate_angle(x_coord, y_coord, rink_side='left'):
    """
    슛 위치로부터 골대까지의 각도 계산
    0도는 골대 정면, 양수는 오른쪽, 음수는 왼쪽
    """
    if x_coord is None or y_coord is None:
        return None
    
    # 골대 위치
    if rink_side == 'left':
        goal_x = -89
    else:
        goal_x = 89
    
    goal_y = 0
    
    # 각도 계산 (라디안)
    delta_x = x_coord - goal_x
    delta_y = y_coord - goal_y
    
    # 골대 방향에 따라 각도 조정
    if rink_side == 'left':
        if delta_x == 0:
            angle = math.pi/2 if delta_y > 0 else -math.pi/2
        else:
            angle = math.atan(delta_y / delta_x)
            if delta_x < 0:  # 골대 뒤에서 슛
                angle += math.pi
    else:
        if delta_x == 0:
            angle = math.pi/2 if delta_y > 0 else -math.pi/2
        else:
            angle = math.atan(delta_y / delta_x)
            if delta_x > 0:  # 골대 뒤에서 슛
                angle += math.pi
    
    # 각도를 도(degree)로 변환
    return math.degrees(angle)

def extract_shot_events(game_file):
    """게임 파일에서 샷 관련 이벤트 추출"""
    try:
        with open(game_file, 'r') as f:
            game_data = json.load(f)
        
        game_id = game_data.get('id')
        game_date = game_data.get('gameDate')
        
        # 팀 정보
        home_team_id = game_data.get('homeTeam', {}).get('id')
        away_team_id = game_data.get('awayTeam', {}).get('id')
        
        # 모든 플레이 이벤트
        plays = game_data.get('plays', [])
        
        # 샷 관련 이벤트 필터링
        shot_events = []
        for play in plays:
            type_code = play.get('typeCode')
            
            if type_code in SHOT_EVENT_CODES:
                # 기본 이벤트 정보
                event = {
                    'game_id': game_id,
                    'game_date': game_date,
                    'event_id': play.get('eventId'),
                    'period': play.get('periodDescriptor', {}).get('number'),
                    'time_in_period': play.get('timeInPeriod'),
                    'time_remaining': play.get('timeRemaining'),
                    'type_code': type_code,
                    'type_desc': play.get('typeDescKey'),
                    'is_goal': type_code == GOAL_EVENT_CODE,
                    'situation_code': play.get('situationCode'),
                    'home_team_defending_side': play.get('homeTeamDefendingSide')
                }
                
                # 이벤트 세부 정보
                details = play.get('details', {})
                
                # GOAL 이벤트의 경우 scoringPlayerId를, 그 외의 경우 shootingPlayerId를 사용
                player_id = details.get('scoringPlayerId') if type_code == GOAL_EVENT_CODE else details.get('shootingPlayerId')
                
                event.update({
                    'x_coord': details.get('xCoord'),
                    'y_coord': details.get('yCoord'),
                    'zone_code': details.get('zoneCode'),
                    'shooting_player_id': player_id,
                    'goalie_id': details.get('goalieId'),
                    'blocking_player_id': details.get('blockingPlayerId'),
                    'team_id': details.get('eventOwnerTeamId')
                })
                
                # 홈/원정 팀 여부 확인
                event['is_home_team'] = (event['team_id'] == home_team_id)
                
                # 상황 코드 파싱
                situation = parse_situation_code(event['situation_code'])
                if situation:
                    event['player_advantage'] = get_player_advantage(situation)
                    event['is_power_play'] = is_power_play(situation)
                
                # 거리 및 각도 계산
                if event['x_coord'] is not None and event['y_coord'] is not None:
                    # 수비 방향에 따라 계산 조정
                    defending_side = event['home_team_defending_side']
                    if event['is_home_team']:
                        # 홈팀 슛이면 상대 골대 방향
                        rink_side = 'right' if defending_side == 'left' else 'left'
                    else:
                        # 원정팀 슛이면 상대 골대 방향
                        rink_side = 'left' if defending_side == 'left' else 'right'
                    
                    event['distance'] = calculate_distance(event['x_coord'], event['y_coord'], rink_side)
                    event['angle'] = calculate_angle(event['x_coord'], event['y_coord'], rink_side)
                
                shot_events.append(event)
        
        return shot_events
    
    except Exception as e:
        logger.error(f"Error processing {game_file}: {e}")
        return []

def extract_all_shots(game_files):
    """모든 게임 파일에서 샷 이벤트 추출"""
    all_shots = []
    
    for file_path in tqdm(game_files, desc="Extracting shot events"):
        shots = extract_shot_events(file_path)
        all_shots.extend(shots)
    
    return all_shots

def build_player_sequences(shots_df, min_shots=50):
    """선수별 샷 시퀀스 구성"""
    # 선수별 샷 수 확인
    player_shot_counts = shots_df['shooting_player_id'].value_counts()
    eligible_players = player_shot_counts[player_shot_counts >= min_shots].index.tolist()
    
    logger.info(f"Found {len(eligible_players)} players with at least {min_shots} shots")
    
    # 선수별 시퀀스 구성
    player_sequences = {}
    
    for player_id in tqdm(eligible_players, desc="Building player sequences"):
        # 선수의 샷 필터링
        player_shots = shots_df[shots_df['shooting_player_id'] == player_id].copy()
        
        # 시간순 정렬
        player_shots.sort_values(by=['game_date', 'period', 'time_in_period'], inplace=True)
        
        # 시퀀스 구성
        sequence = player_shots.to_dict('records')
        player_sequences[str(player_id)] = sequence
    
    return player_sequences

def process_roster_data(game_files):
    """로스터 데이터 처리하여 선수 정보 추출"""
    player_info = {}
    
    for file_path in tqdm(game_files[:100], desc="Processing roster data"):  # 일부 파일만 처리
        try:
            with open(file_path, 'r') as f:
                game_data = json.load(f)
            
            roster_spots = game_data.get('rosterSpots', [])
            
            for spot in roster_spots:
                player_id = spot.get('playerId')
                if player_id and player_id not in player_info:
                    player_info[player_id] = {
                        'first_name': spot.get('firstName', {}).get('default'),
                        'last_name': spot.get('lastName', {}).get('default'),
                        'position': spot.get('positionCode')
                    }
        
        except Exception as e:
            logger.error(f"Error processing roster in {file_path}: {e}")
    
    return player_info

def main():
    parser = argparse.ArgumentParser(description="NHL 데이터 전처리")
    parser.add_argument('--input_dir', type=str, default='data/raw/nhl_nhle',
                        help='NHL 데이터 디렉토리 경로')
    parser.add_argument('--output_dir', type=str, default='data/processed/nhl',
                        help='전처리된 데이터 저장 경로')
    parser.add_argument('--seasons', type=str, nargs='+', default=['2022', '2023', '2024'],
                        help='처리할 시즌 목록')
    parser.add_argument('--min_shots', type=int, default=100,
                        help='선수별 최소 샷 수')
    args = parser.parse_args()
    
    # 출력 디렉토리 생성
    os.makedirs(args.output_dir, exist_ok=True)
    
    # 게임 파일 목록 수집
    game_files = []
    for season in args.seasons:
        season_path = os.path.join(args.input_dir, season, 'games')
        if os.path.exists(season_path):
            files = glob.glob(os.path.join(season_path, '*.json'))
            game_files.extend(files)
            logger.info(f"Found {len(files)} game files in {season} season")
    
    logger.info(f"Total {len(game_files)} game files to process")
    
    # 선수 정보 처리
    logger.info("Processing player information...")
    player_info = process_roster_data(game_files)
    
    # 모든 샷 이벤트 추출
    logger.info("Extracting shot events...")
    all_shots = extract_all_shots(game_files)
    
    # 데이터프레임 변환
    shots_df = pd.DataFrame(all_shots)
    logger.info(f"Extracted {len(shots_df)} shot events")
    
    # 기본 통계
    logger.info(f"Goals: {shots_df['is_goal'].sum()}")
    logger.info(f"Unique players: {shots_df['shooting_player_id'].nunique()}")
    logger.info(f"Unique games: {shots_df['game_id'].nunique()}")
    
    # 샷 이벤트 저장
    shots_file = os.path.join(args.output_dir, 'shots.csv')
    shots_df.to_csv(shots_file, index=False)
    logger.info(f"Saved shot events to {shots_file}")
    
    # 선수별 시퀀스 구성
    logger.info("Building player sequences...")
    player_sequences = build_player_sequences(shots_df, args.min_shots)
    
    # 선수 시퀀스 저장
    sequences_file = os.path.join(args.output_dir, 'player_sequences.json')
    with open(sequences_file, 'w') as f:
        json.dump(player_sequences, f)
    logger.info(f"Saved player sequences to {sequences_file}")
    
    # 선수 정보 저장
    player_info_file = os.path.join(args.output_dir, 'player_info.json')
    with open(player_info_file, 'w') as f:
        json.dump(player_info, f)
    logger.info(f"Saved player information to {player_info_file}")

if __name__ == "__main__":
    main()




```

`parse_situation_code` 함수를 수정하여 다음과 같은 개선을 했습니다:

1. `code`가 `None`인 경우를 처리하도록 초기 검사 추가
2. `try-except` 블록을 추가하여 숫자가 아닌 문자(예: 'N')가 포함된 경우 처리
3. 오류 발생 시 경고 로그를 남기고 `None`을 반환하도록 수정

이렇게 수정하면 `invalid literal for int() with base 10: 'N'` 오류가 발생하지 않고, 대신 잘못된 상황 코드가 있는 경우 로그에 경고 메시지를 남기고 프로그램 실행을 계속할 수 있습니다.

두 파일 모두에 같은 수정을 적용했으므로 이제 `run_nhl_preprocess_all.sh` 스크립트를 실행하면 오류 없이 모든 이벤트를 처리할 수 있을 것입니다.

---

**User**

@goalie_features.py 이 코드에서도 shot 이벤트만 처리하도록 되어있는데, 전체 이벤트를 대상으로 처리하도록 해줘. 샷 데이터에 골키퍼 정보를 추가하는 부분은 유지해 주고. goalie_all_featues.py 로 만들어 주면 되겠어.

---

**Cursor**

새로운 `goalie_all_features.py` 파일을 만들겠습니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL 골키퍼 특성 추출 모듈 (모든 이벤트 처리)
- 모든 이벤트 처리
- 골키퍼 통계 계산
- 골키퍼 특성 생성
"""

import os
import json
import numpy as np
import pandas as pd
from tqdm import tqdm
import logging
from datetime import datetime

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# 상수 정의
SHOT_EVENT_CODES = [505, 506, 507, 508, 537]  # goal, shot-on-goal, missed-shot, blocked-shot, failed-shot-attempt
GOAL_EVENT_CODE = 505

def load_player_info(input_dir):
    """
    선수 정보 로드 (포지션 정보 포함)
    """
    player_info_file = os.path.join(input_dir, 'player_info.json')
    try:
        with open(player_info_file, 'r') as f:
            player_info = json.load(f)
        
        # 골키퍼 ID 추출
        goalie_ids = [player_id for player_id, info in player_info.items() 
                     if info.get('position') == 'G']
        
        logger.info(f"Loaded info for {len(player_info)} players, including {len(goalie_ids)} goalies")
        return player_info, goalie_ids
    except Exception as e:
        logger.error(f"선수 정보 로드 오류: {e}")
        return {}, []

def load_all_events(input_dir):
    """
    선수별 모든 이벤트 데이터 로드 및 통합
    """
    all_events = []
    player_events = {}
    
    try:
        # player_all_sequences.json 파일 처리
        all_sequences_file = os.path.join(input_dir, 'player_all_sequences.json')
        if os.path.exists(all_sequences_file):
            with open(all_sequences_file, 'r') as f:
                # 파일이 큰 경우 메모리 효율을 위해 한 줄씩 읽기
                content = f.read()
                if content.startswith('{'):
                    player_sequences_raw = json.loads(content)
                    
                    # 모든 선수의 이벤트 데이터를 통합
                    for player_id, events in player_sequences_raw.items():
                        all_events.extend(events)
                        player_events[player_id] = events
                    
                    logger.info(f"Loaded {len(all_events)} events from {len(player_events)} players via player_all_sequences.json")
                    return all_events, player_events
        
        # events.csv 파일 처리
        events_csv_file = os.path.join(input_dir, 'events.csv')
        if os.path.exists(events_csv_file):
            events_df = pd.read_csv(events_csv_file)
            
            # 데이터프레임을 딕셔너리 리스트로 변환
            all_events = events_df.to_dict('records')
            
            # 선수별로 이벤트 데이터 그룹화
            for event in all_events:
                player_id = str(event.get('player_id', ''))
                if player_id:
                    if player_id not in player_events:
                        player_events[player_id] = []
                    player_events[player_id].append(event)
            
            logger.info(f"Loaded {len(all_events)} events from {len(player_events)} players via events.csv")
            return all_events, player_events
        
        # shots.csv 파일 처리
        shots_csv_file = os.path.join(input_dir, 'shots.csv')
        if os.path.exists(shots_csv_file):
            shots_df = pd.read_csv(shots_csv_file)
            
            # 데이터프레임을 딕셔너리 리스트로 변환
            all_events = shots_df.to_dict('records')
            
            # 선수별로 샷 데이터 그룹화
            for shot in all_events:
                player_id = str(shot.get('player_id', '') or shot.get('shooting_player_id', ''))
                if player_id:
                    if player_id not in player_events:
                        player_events[player_id] = []
                    player_events[player_id].append(shot)
            
            logger.info(f"Loaded {len(all_events)} shots from {len(player_events)} players via shots.csv")
            return all_events, player_events
        
        # JSONL 파일 처리 (이전 코드)
        jsonl_files = [f for f in os.listdir(input_dir) if f.endswith('.jsonl')]
        if jsonl_files:
            for filename in jsonl_files:
                player_id = filename.replace('.jsonl', '')
                filepath = os.path.join(input_dir, filename)
                
                with open(filepath, 'r') as f:
                    events = [json.loads(line) for line in f]
                    
                    if events:
                        player_events[player_id] = events
                        all_events.extend(events)
            
            logger.info(f"Loaded {len(all_events)} events from {len(player_events)} players via JSONL files")
            return all_events, player_events
        
        logger.error("이벤트 데이터를 찾을 수 없습니다. player_all_sequences.json, events.csv, shots.csv 또는 JSONL 파일이 필요합니다.")
        return [], {}
    except Exception as e:
        logger.error(f"이벤트 데이터 로드 오류: {e}")
        return [], {}

def clean_player_id(player_id):
    """
    선수 ID 정리 (문자열 변환 및 소수점 제거)
    """
    if player_id is None:
        return None
    
    # 문자열로 변환
    player_id_str = str(player_id)
    
    # 소수점 제거
    if '.' in player_id_str:
        player_id_str = player_id_str.split('.')[0]
    
    return player_id_str

def get_opponent_id_from_event(event):
    """
    이벤트에서 상대 팀 ID 추출
    """
    team_id = event.get('team_id')
    if team_id is None:
        return None
    
    # 홈팀인 경우 원정팀이 상대, 원정팀인 경우 홈팀이 상대
    is_home_team = event.get('is_home_team')
    if is_home_team is None:
        return None
    
    if 'home_team_id' in event and 'away_team_id' in event:
        if is_home_team:
            return event['away_team_id']
        else:
            return event['home_team_id']
    
    # 직접적인 상대팀 ID가 없는 경우 유추
    # 홈/원정팀 여부에 따라 간접적으로 상대팀 ID 추정
    return None

def enrich_events_with_goalie_info(events, player_info, goalie_ids):
    """
    모든 이벤트에 골키퍼 정보 추가
    """
    # 1. 먼저 샷 이벤트에서 팀별 골키퍼 매핑 생성
    team_goalies = {}
    shot_events = [event for event in events if event.get('type_code') in SHOT_EVENT_CODES]
    
    for shot in shot_events:
        if 'goalie_id' in shot and shot['goalie_id'] is not None:
            goalie_id_str = clean_player_id(shot['goalie_id'])
            if goalie_id_str in goalie_ids:
                # 상대팀 ID 가져오기
                opponent_id = shot.get('opponent_id')
                if opponent_id is None:
                    opponent_id = get_opponent_id_from_event(shot)
                
                if opponent_id and opponent_id not in team_goalies:
                    team_goalies[opponent_id] = shot['goalie_id']
    
    # 기본 골키퍼 ID (가장 많은 샷을 받은 골키퍼)
    default_goalie_id = goalie_ids[0] if goalie_ids else None
    
    # 2. 이벤트 데이터에 골키퍼 정보 추가
    enriched_events = []
    
    for event in events:
        # 샷 이벤트가 아닌 경우 골키퍼 ID는 추가하지 않음
        if event.get('type_code') not in SHOT_EVENT_CODES:
            enriched_events.append(event)
            continue
        
        # 골키퍼 ID가 이미 있는 경우 그대로 사용
        if 'goalie_id' in event and event['goalie_id'] is not None:
            goalie_id_str = clean_player_id(event['goalie_id'])
            if goalie_id_str in goalie_ids:
                enriched_events.append(event)
                continue
        
        # 골키퍼 ID가 없는 경우 추가
        team_id = event.get('team_id')
        opponent_id = event.get('opponent_id')
        
        if opponent_id is None:
            opponent_id = get_opponent_id_from_event(event)
        
        # 상대팀의 기본 골키퍼 사용
        if opponent_id in team_goalies:
            event['goalie_id'] = team_goalies[opponent_id]
        else:
            # 기본 골키퍼 사용
            event['goalie_id'] = default_goalie_id
        
        enriched_events.append(event)
    
    logger.info(f"Enriched {len(enriched_events)} events with goalie information where applicable")
    return enriched_events

def calculate_goalie_stats(events, goalie_ids):
    """
    골키퍼별 성능 통계 계산 (샷 이벤트에 대해서만)
    """
    # 샷 이벤트만 필터링
    shot_events = [event for event in events if event.get('type_code') in SHOT_EVENT_CODES]
    
    # 골키퍼별 통계 초기화
    goalie_stats = {goalie_id: {
        'total_shots': 0,
        'goals_against': 0,
        'saves': 0,
        'save_pct': 0.9,  # 기본값
        'close_shots': 0,
        'close_goals': 0,
        'close_save_pct': 0.85,  # 기본값
        'medium_shots': 0,
        'medium_goals': 0,
        'medium_save_pct': 0.9,  # 기본값
        'long_shots': 0,
        'long_goals': 0,
        'long_save_pct': 0.95,  # 기본값
        'pp_shots': 0,
        'pp_goals': 0,
        'pp_save_pct': 0.85,  # 기본값
    } for goalie_id in goalie_ids}
    
    # 모든 골키퍼에 대한 통계 집계
    for shot in shot_events:
        if 'goalie_id' not in shot or shot['goalie_id'] is None:
            continue
            
        goalie_id_str = clean_player_id(shot['goalie_id'])
        if goalie_id_str not in goalie_stats:
            continue
        
        # 기본 통계
        goalie_stats[goalie_id_str]['total_shots'] += 1
        if shot.get('is_goal', False):
            goalie_stats[goalie_id_str]['goals_against'] += 1
        else:
            goalie_stats[goalie_id_str]['saves'] += 1
        
        # 거리별 통계
        distance = shot.get('distance', 0)
        if distance < 15:
            goalie_stats[goalie_id_str]['close_shots'] += 1
            if shot.get('is_goal', False):
                goalie_stats[goalie_id_str]['close_goals'] += 1
        elif distance < 30:
            goalie_stats[goalie_id_str]['medium_shots'] += 1
            if shot.get('is_goal', False):
                goalie_stats[goalie_id_str]['medium_goals'] += 1
        else:
            goalie_stats[goalie_id_str]['long_shots'] += 1
            if shot.get('is_goal', False):
                goalie_stats[goalie_id_str]['long_goals'] += 1
        
        # 파워플레이 통계
        if shot.get('is_power_play', False):
            goalie_stats[goalie_id_str]['pp_shots'] += 1
            if shot.get('is_goal', False):
                goalie_stats[goalie_id_str]['pp_goals'] += 1
    
    # 세이브율 계산
    for goalie_id, stats in goalie_stats.items():
        if stats['total_shots'] > 0:
            stats['save_pct'] = stats['saves'] / stats['total_shots']
        
        if stats['close_shots'] > 0:
            stats['close_save_pct'] = 1 - (stats['close_goals'] / stats['close_shots'])
        
        if stats['medium_shots'] > 0:
            stats['medium_save_pct'] = 1 - (stats['medium_goals'] / stats['medium_shots'])
        
        if stats['long_shots'] > 0:
            stats['long_save_pct'] = 1 - (stats['long_goals'] / stats['long_shots'])
        
        if stats['pp_shots'] > 0:
            stats['pp_save_pct'] = 1 - (stats['pp_goals'] / stats['pp_shots'])
    
    # 리그 평균 계산
    active_goalies = [stats for goalie_id, stats in goalie_stats.items() if stats['total_shots'] >= 50]
    if active_goalies:
        league_avg_save_pct = np.mean([stats['save_pct'] for stats in active_goalies])
        league_avg_close_save_pct = np.mean([stats['close_save_pct'] for stats in active_goalies])
        league_avg_medium_save_pct = np.mean([stats['medium_save_pct'] for stats in active_goalies])
        league_avg_long_save_pct = np.mean([stats['long_save_pct'] for stats in active_goalies])
        league_avg_pp_save_pct = np.mean([stats['pp_save_pct'] for stats in active_goalies])
        
        # 리그 평균 대비 상대적 성능 계산
        for goalie_id, stats in goalie_stats.items():
            stats['rel_save_pct'] = stats['save_pct'] - league_avg_save_pct
            stats['rel_close_save_pct'] = stats['close_save_pct'] - league_avg_close_save_pct
            stats['rel_medium_save_pct'] = stats['medium_save_pct'] - league_avg_medium_save_pct
            stats['rel_long_save_pct'] = stats['long_save_pct'] - league_avg_long_save_pct
            stats['rel_pp_save_pct'] = stats['pp_save_pct'] - league_avg_pp_save_pct
    
    logger.info(f"Calculated statistics for {len(goalie_stats)} goalies based on {len(shot_events)} shot events")
    return goalie_stats

def add_goalie_features_to_events(events, goalie_stats):
    """
    이벤트 데이터에 골키퍼 특성 추가 (샷 이벤트에 대해서만)
    """
    enriched_events = []
    
    for event in events:
        # 샷 이벤트가 아닌 경우 그대로 추가
        if event.get('type_code') not in SHOT_EVENT_CODES:
            enriched_events.append(event)
            continue
        
        # 샷 이벤트인 경우 골키퍼 특성 추가
        if 'goalie_id' not in event or event['goalie_id'] is None:
            # 골키퍼 정보가 없는 경우 기본값 설정
            event['goalie_save_pct'] = 0.9
            event['goalie_rel_save_pct'] = 0
            event['goalie_distance_save_pct'] = 0.9
            event['goalie_rel_distance_save_pct'] = 0
            event['goalie_situation_save_pct'] = 0.9
            event['goalie_rel_situation_save_pct'] = 0
            enriched_events.append(event)
            continue
            
        goalie_id_str = clean_player_id(event['goalie_id'])
        
        # 골키퍼 통계 가져오기
        if goalie_id_str in goalie_stats:
            stats = goalie_stats[goalie_id_str]
            
            # 기본 세이브율
            event['goalie_save_pct'] = stats['save_pct']
            event['goalie_rel_save_pct'] = stats.get('rel_save_pct', 0)
            
            # 거리별 세이브율
            distance = event.get('distance', 0)
            if distance < 15:
                event['goalie_distance_save_pct'] = stats['close_save_pct']
                event['goalie_rel_distance_save_pct'] = stats.get('rel_close_save_pct', 0)
            elif distance < 30:
                event['goalie_distance_save_pct'] = stats['medium_save_pct']
                event['goalie_rel_distance_save_pct'] = stats.get('rel_medium_save_pct', 0)
            else:
                event['goalie_distance_save_pct'] = stats['long_save_pct']
                event['goalie_rel_distance_save_pct'] = stats.get('rel_long_save_pct', 0)
            
            # 파워플레이 세이브율
            if event.get('is_power_play', False):
                event['goalie_situation_save_pct'] = stats['pp_save_pct']
                event['goalie_rel_situation_save_pct'] = stats.get('rel_pp_save_pct', 0)
            else:
                event['goalie_situation_save_pct'] = stats['save_pct']
                event['goalie_rel_situation_save_pct'] = stats.get('rel_save_pct', 0)
        else:
            # 골키퍼 정보가 없는 경우 기본값 설정
            event['goalie_save_pct'] = 0.9
            event['goalie_rel_save_pct'] = 0
            event['goalie_distance_save_pct'] = 0.9
            event['goalie_rel_distance_save_pct'] = 0
            event['goalie_situation_save_pct'] = 0.9
            event['goalie_rel_situation_save_pct'] = 0
        
        enriched_events.append(event)
    
    shot_events = len([e for e in enriched_events if e.get('type_code') in SHOT_EVENT_CODES])
    logger.info(f"Added goalie features to {shot_events} shot events out of {len(enriched_events)} total events")
    return enriched_events

def get_player_id_from_event(event):
    """
    이벤트에서 주요 선수 ID 추출
    """
    # 다양한 필드 이름 시도
    player_id = None
    
    type_code = event.get('type_code')
    
    # 이벤트 타입별로 적절한 필드 선택
    if type_code in SHOT_EVENT_CODES:
        for field in ['shooting_player_id', 'shooter_id', 'scoringPlayerId', 'player_id']:
            if field in event and event[field] is not None:
                player_id = event[field]
                break
    else:
        for field in ['player_id', 'playerId', 'winningPlayerId', 'hittingPlayerId', 
                     'committedByPlayerId', 'scoringPlayerId']:
            if field in event and event[field] is not None:
                player_id = event[field]
                break
    
    if player_id is None:
        return None
    
    return clean_player_id(player_id)

def save_enriched_events_jsonl(player_events, enriched_events, output_dir):
    """
    골키퍼 특성이 추가된 이벤트 데이터를 선수별로 JSONL 파일로 저장
    """
    os.makedirs(output_dir, exist_ok=True)
    
    # 선수 ID별로 이벤트 데이터 그룹화
    player_events_dict = {}
    for event in enriched_events:
        player_id = get_player_id_from_event(event)
        if player_id:
            if player_id not in player_events_dict:
                player_events_dict[player_id] = []
            player_events_dict[player_id].append(event)
    
    # 선수별로 파일 저장
    for player_id, events in player_events_dict.items():
        output_file = os.path.join(output_dir, f"{player_id}.jsonl")
        with open(output_file, 'w') as f:
            for event in events:
                f.write(json.dumps(event) + '\n')
    
    logger.info(f"Saved enriched events for {len(player_events_dict)} players as JSONL files to {output_dir}")

def save_player_sequences_json(player_events, enriched_events, output_dir):
    """
    골키퍼 특성이 추가된 이벤트 데이터를 player_sequences.json 파일로 저장
    """
    os.makedirs(output_dir, exist_ok=True)
    
    # 선수 ID별로 이벤트 데이터 그룹화
    player_events_dict = {}
    for event in enriched_events:
        player_id = get_player_id_from_event(event)
        if player_id:
            if player_id not in player_events_dict:
                player_events_dict[player_id] = []
            player_events_dict[player_id].append(event)
    
    # 모든 이벤트 시퀀스 저장
    all_sequences_file = os.path.join(output_dir, "player_all_sequences.json")
    with open(all_sequences_file, 'w') as f:
        json.dump(player_events_dict, f)
    
    logger.info(f"Saved all event sequences for {len(player_events_dict)} players to {all_sequences_file}")
    
    # 샷 이벤트만 필터링하여 player_shot_sequences.json 파일로 저장
    player_shot_dict = {}
    for player_id, events in player_events_dict.items():
        shots = [event for event in events if event.get('type_code') in SHOT_EVENT_CODES]
        if shots:
            player_shot_dict[player_id] = shots
    
    shot_sequences_file = os.path.join(output_dir, "player_shot_sequences.json")
    with open(shot_sequences_file, 'w') as f:
        json.dump(player_shot_dict, f)
    
    logger.info(f"Saved shot event sequences for {len(player_shot_dict)} players to {shot_sequences_file}")

def save_goalie_stats(goalie_stats, output_dir):
    """
    골키퍼 통계 저장
    """
    os.makedirs(output_dir, exist_ok=True)
    
    output_file = os.path.join(output_dir, "goalie_stats.json")
    with open(output_file, 'w') as f:
        json.dump(goalie_stats, f, indent=2)
    
    logger.info(f"Saved goalie statistics to {output_file}")

def main():
    """
    메인 함수
    """
    import argparse
    
    parser = argparse.ArgumentParser(description="NHL 골키퍼 특성 추출 (모든 이벤트 처리)")
    parser.add_argument('--input', type=str, default='data/processed/nhl_all_events',
                        help='전처리된 데이터 디렉토리 경로')
    parser.add_argument('--output', type=str, default='data/processed/nhl_with_goalie_all',
                        help='골키퍼 특성이 추가된 데이터 저장 경로')
    args = parser.parse_args()
    
    # 선수 정보 로드
    player_info, goalie_ids = load_player_info(args.input)
    if not goalie_ids:
        logger.error("골키퍼 정보를 찾을 수 없습니다.")
        return
    
    # 모든 이벤트 데이터 로드
    all_events, player_events = load_all_events(args.input)
    if not all_events:
        logger.error("이벤트 데이터를 찾을 수 없습니다.")
        return
    
    # 이벤트 타입 통계
    event_types = {}
    for event in all_events:
        event_type = event.get('type_code')
        if event_type not in event_types:
            event_types[event_type] = 0
        event_types[event_type] += 1
    
    logger.info(f"Loaded {len(all_events)} total events")
    logger.info(f"Event type distribution: {event_types}")
    
    # 골키퍼 정보 추가
    enriched_events = enrich_events_with_goalie_info(all_events, player_info, goalie_ids)
    
    # 골키퍼 통계 계산
    goalie_stats = calculate_goalie_stats(enriched_events, goalie_ids)
    
    # 골키퍼 특성 추가
    events_with_features = add_goalie_features_to_events(enriched_events, goalie_stats)
    
    # 결과 저장 (JSONL 파일과 player_sequences.json 모두 저장)
    save_enriched_events_jsonl(player_events, events_with_features, args.output)
    save_player_sequences_json(player_events, events_with_features, args.output)
    save_goalie_stats(goalie_stats, args.output)
    
    logger.info("골키퍼 특성 추출 완료")

if __name__ == "__main__":
    main()
```

실행 스크립트도 만들겠습니다:

```shellscript
#!/bin/bash

# NHL 모든 이벤트에 골키퍼 특성 추가 스크립트

# 기본 설정
INPUT_DIR="data/processed/nhl_all_events"
OUTPUT_DIR="data/processed/nhl_with_goalie_all"

# 출력 디렉토리 생성
mkdir -p "$OUTPUT_DIR"

# 로그 설정
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOG_FILE="logs/nhl_goalie_all_features_${TIMESTAMP}.log"
mkdir -p logs

# 스크립트 실행
echo "Starting NHL goalie features extraction for all events at $(date)"
python -m src.nhl.goalie_all_features \
    --input "$INPUT_DIR" \
    --output "$OUTPUT_DIR" \
    2>&1 | tee "$LOG_FILE"

echo "Completed NHL goalie features extraction at $(date)"
echo "Log saved to $LOG_FILE"
```

`goalie_all_features.py` 스크립트를 생성했습니다. 주요 변경 사항은 다음과 같습니다:

1. **모든 이벤트 처리**:
   - 기존 `load_shot_data` 함수를 `load_all_events`로 변경하여 모든 이벤트를 처리할 수 있게 수정했습니다.
   - `player_all_sequences.json`과 `events.csv` 파일을 우선적으로 찾아 처리하도록 했습니다.

2. **이벤트별 선수 ID 처리 로직 개선**:
   - `get_player_id_from_event` 함수는 이벤트 유형에 따라 다양한 필드에서 선수 ID를 추출할 수 있도록 확장되었습니다.
   - 샷 이벤트, 골 이벤트, 페이스오프 등 여러 이벤트 타입에 대응할 수 있습니다.

3. **상대팀 ID 추출 로직 추가**:
   - `get_opponent_id_from_event` 함수를 새로 추가하여 이벤트에서 상대팀 ID를 추출합니다.
   - 이를 통해 팀-골키퍼 매핑을 더 정확하게 할 수 있습니다.

4. **골키퍼 정보 처리 개선**:
   - `enrich_events_with_goalie_info` 함수는 모든 이벤트를 처리하되, 골키퍼 정보는 샷 이벤트에만 추가합니다.
   - 샷 이벤트와 골 이벤트 등의 구분을 명확히 하여 필요한 이벤트에만 골키퍼 정보를 추가합니다.

5. **통계 계산 및 특성 추가 로직 유지**:
   - `calculate_goalie_stats` 및 `add_goalie_features_to_events` 함수는 여전히 샷 이벤트에 대해서만 통계를 계산하고 특성을 추가합니다.
   - 샷 이벤트가 아닌 이벤트는 그대로 보존됩니다.

6. **결과 저장 방식 확장**:
   - `save_player_sequences_json` 함수는 모든 이벤트를 `player_all_sequences.json`에 저장하고, 샷 이벤트만 `player_shot_sequences.json`에 별도로 저장합니다.

이 스크립트를 실행하려면 `./run_nhl_goalie_all_features.sh` 명령을 사용하면 됩니다. 입력 데이터는 `data/processed/nhl_all_events` 디렉토리에서 읽고, 결과는 `data/processed/nhl_with_goalie_all` 디렉토리에 저장됩니다.

---

**User**

이벤트가 정상적으로 로딩되었음에도 Created 0 sequences 오류가 발생하고 있어. @data_processor.py 파일의 201번째줄부터가 해당 로직인데, 무엇이 문제같니?

---

**Cursor**

```plaintext
2025-08-18 22:55:33,555 - nhl_sequence.data_processor - INFO - Loaded 637674 total events
...
2025-08-18 22:55:44,357 - nhl_sequence.data_processor - INFO - Created 0 sequences
2025-08-18 22:55:44,357 - nhl_sequence.data_processor - WARNING - No sequences created. Check data and sequence generation logic.
```

```plaintext
2025-08-18 22:55:44,187 - nhl_sequence.data_processor - INFO - Event type distribution: {'S': 253109, 'M': 117926, 'B': 142898, 'G': 29440, 'T': 61407, 'P': 32894}
```

문제를 파악했습니다. 시퀀스가 생성되지 않는 원인은 `data_processor.py` 파일의 슛 이벤트 처리 로직에 있습니다.

주요 문제점은 다음과 같습니다:

1. **필드 명칭 불일치**: 
   - 246-247줄을 보면 `event['eventType'] in SHOT_EVENTS and event.get('shooterId')` 조건을 확인합니다.
   - 로그를 보면 이벤트는 637,674개가 로드되었지만, 시퀀스는 0개가 생성되었습니다.
   - `goalie_all_features.py`를 사용할 때 필드 이름이 `shooterId`가 아닌 `player_id`로 저장되었을 가능성이 높습니다.

2. **데이터 매핑 불일치**:
   - `_process_shot_event` 함수(134-151줄)에서는 원본 이벤트의 `shooting_player_id`를 `shooterId`로 매핑합니다.
   - 하지만 전체 이벤트 데이터에서는 `shooting_player_id` 대신 `player_id`가 사용될 수 있습니다.

수정해야 할 부분은 다음과 같습니다:

1. `process_events` 함수에서 이벤트 필드 매핑을 수정:

```python
# 수정 전
processed_event = {
    # ...
}

# 수정 후 
processed_event = {
    # ...
    'playerId': event.get('player_id'),  # player_id 필드 추가
    # ...
}
```

2. `_create_game_sequences` 함수를 수정하여 `shooterId` 뿐만 아니라 `playerId`도 확인:

```python
# 수정 전
if event['eventType'] in SHOT_EVENTS and event.get('shooterId'):
    # ...

# 수정 후
if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
    # ...
```

3. 또한 시퀀스 생성 시 선수 ID 가져오는 부분도 수정:

```python
# 수정 전
shooter_id = last_event.get('shooterId')

# 수정 후
shooter_id = last_event.get('shooterId') or last_event.get('playerId')
```

이러한 수정을 통해 `shooterId` 또는 `playerId` 중 하나만 존재해도 시퀀스가 생성될 수 있도록 해야 합니다.

```python
# 수정 전
processed_event = {
    # ...
}

# 수정 후 
processed_event = {
    # ...
    'playerId': event.get('player_id'),  # player_id 필드 추가
    # ...
}
```

```python
# 수정 전
if event['eventType'] in SHOT_EVENTS and event.get('shooterId'):
    # ...

# 수정 후
if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
    # ...
```

```python
# 수정 전
shooter_id = last_event.get('shooterId')

# 수정 후
shooter_id = last_event.get('shooterId') or last_event.get('playerId')
```

---

**User**

확인한 문제에 따라 수정해 줘. 코드 실행은 내가 할게.

---

**Cursor**

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 
            if event['eventType'] in SHOT_EVENTS and event.get('shooterId'):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] == 'G')  # goal count
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 goal 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                goal = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] == 'G')
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {goal} goal")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] == 'G')
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        feature_extractors = {
            'distanceToGoal': lambda e: e.get('distanceToGoal', 50.0),
            'angleToGoal': lambda e: e.get('angleToGoal', 45.0),
            'xCoord': lambda e: e.get('xCoord', 0),
            'yCoord': lambda e: e.get('yCoord', 0),
            'timeInPeriod': lambda e: e.get('timeInPeriodSeconds', 600),
            'period': lambda e: e.get('period', 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': np.array(all_features),
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 (shooterId 또는 playerId 사용)
            if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] == 'G')  # goal count
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 goal 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                goal = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] == 'G')
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {goal} goal")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] == 'G')
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        feature_extractors = {
            'distanceToGoal': lambda e: e.get('distanceToGoal', 50.0),
            'angleToGoal': lambda e: e.get('angleToGoal', 45.0),
            'xCoord': lambda e: e.get('xCoord', 0),
            'yCoord': lambda e: e.get('yCoord', 0),
            'timeInPeriod': lambda e: e.get('timeInPeriodSeconds', 600),
            'period': lambda e: e.get('period', 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': np.array(all_features),
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

<no message>

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 (shooterId 또는 playerId 사용)
            if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용 (shooterId 또는 playerId)
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId') or last_event.get('playerId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] == 'G')  # goal count
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 goal 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                goal = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] == 'G')
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {goal} goal")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] == 'G')
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        feature_extractors = {
            'distanceToGoal': lambda e: e.get('distanceToGoal', 50.0),
            'angleToGoal': lambda e: e.get('angleToGoal', 45.0),
            'xCoord': lambda e: e.get('xCoord', 0),
            'yCoord': lambda e: e.get('yCoord', 0),
            'timeInPeriod': lambda e: e.get('timeInPeriodSeconds', 600),
            'period': lambda e: e.get('period', 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': np.array(all_features),
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

<no message>

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        elif 'player_id' in original_event:
            processed_event['shooterId'] = original_event['player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 (shooterId 또는 playerId 사용)
            if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용 (shooterId 또는 playerId)
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId') or last_event.get('playerId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] == 'G')  # goal count
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 goal 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                goal = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] == 'G')
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {goal} goal")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] == 'G')
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        feature_extractors = {
            'distanceToGoal': lambda e: e.get('distanceToGoal', 50.0),
            'angleToGoal': lambda e: e.get('angleToGoal', 45.0),
            'xCoord': lambda e: e.get('xCoord', 0),
            'yCoord': lambda e: e.get('yCoord', 0),
            'timeInPeriod': lambda e: e.get('timeInPeriodSeconds', 600),
            'period': lambda e: e.get('period', 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': np.array(all_features),
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

다음으로 플레이어 필터링 로직에서 G 대신 다양한 이벤트를 사용하도록 수정해보겠습니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        elif 'player_id' in original_event:
            processed_event['shooterId'] = original_event['player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 (shooterId 또는 playerId 사용)
            if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용 (shooterId 또는 playerId)
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId') or last_event.get('playerId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                # 'G' 또는 'S' 이벤트 모두 포함하여 카운트
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] in ['G', 'S'])
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 슛/골 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                goal = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] == 'G')
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {goal} goal")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] == 'G')
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        feature_extractors = {
            'distanceToGoal': lambda e: e.get('distanceToGoal', 50.0),
            'angleToGoal': lambda e: e.get('angleToGoal', 45.0),
            'xCoord': lambda e: e.get('xCoord', 0),
            'yCoord': lambda e: e.get('yCoord', 0),
            'timeInPeriod': lambda e: e.get('timeInPeriodSeconds', 600),
            'period': lambda e: e.get('period', 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': np.array(all_features),
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

다른 곳에서도 같은 방식으로 수정합니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        elif 'player_id' in original_event:
            processed_event['shooterId'] = original_event['player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 (shooterId 또는 playerId 사용)
            if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용 (shooterId 또는 playerId)
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId') or last_event.get('playerId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                # 'G' 또는 'S' 이벤트 모두 포함하여 카운트
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] in ['G', 'S'])
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 슛/골 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                shot_goal_count = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] in ['G', 'S'])
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {shot_goal_count} shots/goals")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] == 'G')
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        feature_extractors = {
            'distanceToGoal': lambda e: e.get('distanceToGoal', 50.0),
            'angleToGoal': lambda e: e.get('angleToGoal', 45.0),
            'xCoord': lambda e: e.get('xCoord', 0),
            'yCoord': lambda e: e.get('yCoord', 0),
            'timeInPeriod': lambda e: e.get('timeInPeriodSeconds', 600),
            'period': lambda e: e.get('period', 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': np.array(all_features),
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

마지막으로 get_top_players에서도 수정합니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        elif 'player_id' in original_event:
            processed_event['shooterId'] = original_event['player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 (shooterId 또는 playerId 사용)
            if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용 (shooterId 또는 playerId)
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId') or last_event.get('playerId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                # 'G' 또는 'S' 이벤트 모두 포함하여 카운트
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] in ['G', 'S'])
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 슛/골 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                shot_goal_count = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] in ['G', 'S'])
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {shot_goal_count} shots/goals")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] in ['G', 'S'])
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        feature_extractors = {
            'distanceToGoal': lambda e: e.get('distanceToGoal', 50.0),
            'angleToGoal': lambda e: e.get('angleToGoal', 45.0),
            'xCoord': lambda e: e.get('xCoord', 0),
            'yCoord': lambda e: e.get('yCoord', 0),
            'timeInPeriod': lambda e: e.get('timeInPeriodSeconds', 600),
            'period': lambda e: e.get('period', 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': np.array(all_features),
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

`data_processor.py` 파일을 다음과 같이 수정했습니다:

1. **선수 ID 처리 개선:**
   - 79-91줄: 원본 이벤트의 `player_id` 필드를 processed_event에 추가
   - 135-142줄: `_process_shot_event`에서 `shooting_player_id`가 없을 경우 `player_id`를 대체로 사용
   - 246-247줄: 시퀀스 생성 시 `shooterId` 또는 `playerId` 중 하나만 있어도 처리
   - 305-308줄: 시퀀스 그룹화 시 `shooterId` 또는 `playerId`를 사용

2. **이벤트 유형 처리 개선:**
   - 317-320줄: 선수 필터링 시 'G'(골) 이벤트 외에도 'S'(슛 온 골) 이벤트도 인정
   - 334줄: 통계 출력 시 골뿐만 아니라 슛까지 포함하여 표시
   - 356-359줄: 상위 선수 통계에서도 'G'와 'S' 이벤트 모두 포함

이러한 수정을 통해 `preprocess_all.py`로 생성한 데이터와 호환되도록 하여 시퀀스가 정상적으로 생성될 것입니다. 특히 `playerId` 필드 추가와 다양한 이벤트 타입 처리가 핵심 수정 사항입니다.

---

**User**

Experiment failed: Input X contains NaN. 이라는 오류가 발생하고 있어

---

**Cursor**

로그를 보니 시퀀스는 성공적으로 생성되었지만, 모델링 단계에서 NaN 값이 있어서 실패하고 있습니다. scikit-learn의 LogisticRegression은 NaN 값을 처리할 수 없기 때문입니다.

`prepare_model_data` 함수에서 특성 벡터를 생성할 때 NaN 값을 처리하도록 수정해보겠습니다:

<no message>

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        elif 'player_id' in original_event:
            processed_event['shooterId'] = original_event['player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 (shooterId 또는 playerId 사용)
            if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용 (shooterId 또는 playerId)
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId') or last_event.get('playerId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                # 'G' 또는 'S' 이벤트 모두 포함하여 카운트
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] in ['G', 'S'])
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 슛/골 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                shot_goal_count = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] in ['G', 'S'])
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {shot_goal_count} shots/goals")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] in ['G', 'S'])
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        # NaN 값을 처리하기 위한 안전한 기본값 설정
        feature_extractors = {
            'distanceToGoal': lambda e: float(e.get('distanceToGoal', 50.0) or 50.0),  # None이면 기본값 사용
            'angleToGoal': lambda e: float(e.get('angleToGoal', 45.0) or 45.0),
            'xCoord': lambda e: float(e.get('xCoord', 0) or 0),
            'yCoord': lambda e: float(e.get('yCoord', 0) or 0),
            'timeInPeriod': lambda e: float(e.get('timeInPeriodSeconds', 600) or 600),
            'period': lambda e: int(e.get('period', 1) or 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': np.array(all_features),
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

이제 특성 추출 시에 NaN 값을 처리하는 로직이 추가되었습니다. 추가로 모델 학습 전에 NaN을 명시적으로 확인하고 제거하는 코드를 추가하겠습니다:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NHL play-by-play 데이터를 시퀀스 기반 HMM 모델링용으로 전처리
실제 NHL API 데이터 구조 기반 완전 재구현
"""

import os
import json
import pandas as pd
import numpy as np
from collections import defaultdict
from tqdm import tqdm
import logging

from .constants import (
    NHL_TYPE_CODES, TYPE_CODE_TO_EVENT, EVENT_TYPES,
    SHOT_EVENTS, POSSESSION_EVENTS, TURNOVER_EVENTS, SEQUENCE_ENDING_EVENTS,
    SITUATION_CODES, DEFAULT_FEATURE_COLUMNS, 
    MAX_SEQUENCE_LENGTH, TIME_WINDOW_SECONDS, MIN_EVENTS_PER_SEQUENCE,
    MAX_TIME_GAP_SECONDS, MIN_SEQUENCES_PER_PLAYER, MIN_GOALS_PER_PLAYER,
    GOAL_LINE_X, RINK_LENGTH, RINK_WIDTH, LOG_LEVEL, LOG_FORMAT
)

logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT)
logger = logging.getLogger(__name__)

class NHLSequenceDataProcessor:
    """NHL play-by-play 데이터를 시퀀스로 처리하는 클래스"""
    
    def __init__(self):
        self.raw_events = []
        self.processed_events = []
        self.sequences = []
        self.player_sequences = {}
        self.event_stats = defaultdict(int)
        
    def load_play_by_play_data(self, data_dir):
        """NHL play-by-play JSONL 파일 로드"""
        logger.info(f"Loading NHL play-by-play data from {data_dir}")
        
        events = []
        jsonl_files = [f for f in os.listdir(data_dir) if f.endswith('.jsonl')]
        
        if not jsonl_files:
            logger.error(f"No JSONL files found in {data_dir}")
            return []
        
        logger.info(f"Found {len(jsonl_files)} JSONL files")
        
        for filename in tqdm(jsonl_files, desc="Loading files"):
            file_path = os.path.join(data_dir, filename)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    for line_num, line in enumerate(f, 1):
                        if line.strip():
                            try:
                                event = json.loads(line.strip())
                                events.append(event)
                            except json.JSONDecodeError as e:
                                logger.warning(f"JSON decode error in {filename}:{line_num}: {e}")
                                continue
            except Exception as e:
                logger.error(f"Error loading {filename}: {e}")
                continue
        
        logger.info(f"Loaded {len(events)} total events")
        self.raw_events = events
        return events
    
    def process_events(self):
        """원본 이벤트를 시퀀스 모델링용으로 변환"""
        logger.info("Processing events for sequence modeling")
        
        processed = []
        
        for event in tqdm(self.raw_events, desc="Processing events"):
            # 실제 데이터 구조에 맞게 필드 추출
            processed_event = {
                'gameId': event.get('game_id'),
                'eventId': event.get('event_id'), 
                'period': event.get('period', 1),
                'timeInPeriod': event.get('time_in_period', '00:00'),
                'situationCode': event.get('situation_code', '1551'),  # 기본값 5v5
                'typeCode': event.get('type_code'),  # snake_case로 수정
                'typeDesc': event.get('type_desc', ''),  # type_desc 사용
                'teamId': event.get('team_id'),
                'xCoord': event.get('x_coord', 0),
                'yCoord': event.get('y_coord', 0),
                'playerId': event.get('player_id'),  # player_id 필드 추가
            }
            
            # 시간을 초로 변환
            processed_event['timeInPeriodSeconds'] = self._time_to_seconds(processed_event['timeInPeriod'])
            processed_event['gameTimeSeconds'] = (processed_event['period'] - 1) * 1200 + processed_event['timeInPeriodSeconds']
            
            # 이벤트 타입 매핑
            event_type = TYPE_CODE_TO_EVENT.get(processed_event['typeCode'], 'O')
            processed_event['eventType'] = event_type
            
            # 슛 관련 이벤트 처리
            if event_type in SHOT_EVENTS:
                self._process_shot_event(processed_event, event)
            
            # # 실제 데이터에 골 이벤트가 없으므로 shot-on-goal을 목표 이벤트로 사용
            # if event_type == 'S':  # shot-on-goal을 "성공" 이벤트로 처리
            #     processed_event['isGoal'] = True  # shot-on-goal은 골키퍼에게 도달한 성공적인 슛
            # else:
            #     processed_event['isGoal'] = False
            
            # 게임 상황 분류
            processed_event['gameSituation'] = self._classify_situation(processed_event['situationCode'])
            
            # 골문까지 거리/각도 계산
            if processed_event['xCoord'] and processed_event['yCoord']:
                processed_event['distanceToGoal'] = self._calculate_distance_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
                processed_event['angleToGoal'] = self._calculate_angle_to_goal(
                    processed_event['xCoord'], processed_event['yCoord']
                )
            
            processed.append(processed_event)
            self.event_stats[event_type] += 1
        
        # 게임별 시간순 정렬
        processed.sort(key=lambda x: (x['gameId'], x['period'], x['timeInPeriodSeconds'], x.get('eventId', 0)))
        
        logger.info("Event processing completed")
        logger.info(f"Event type distribution: {dict(self.event_stats)}")
        
        self.processed_events = processed
        return processed
    
    def _process_shot_event(self, processed_event, original_event):
        """슛 이벤트 상세 정보 처리 - 실제 데이터 구조 반영"""
        
        # 슈터 정보 (실제 필드명 사용)
        if 'shooting_player_id' in original_event:
            processed_event['shooterId'] = original_event['shooting_player_id']
        elif 'player_id' in original_event:
            processed_event['shooterId'] = original_event['player_id']
        
        # 골키퍼 정보
        if 'goalie_id' in original_event:
            processed_event['goalieId'] = original_event['goalie_id']
        
        # 슛 타입 (있다면)
        processed_event['shotType'] = original_event.get('shot_type', 'unknown')
        
        # 골 여부 (is_goal 필드 사용)
        processed_event['isGoal'] = original_event.get('is_goal', False)
        
        return processed_event
    
    def _classify_situation(self, situation_code):
        """상황 코드를 게임 상황으로 분류"""
        if not situation_code or len(str(situation_code)) != 4:
            return 'even_strength'
        
        situation_str = str(situation_code)
        
        for situation, codes in SITUATION_CODES.items():
            if situation_str in codes:
                return situation
        
        # 기본값
        return 'even_strength'
    
    def _calculate_distance_to_goal(self, x, y):
        """골문까지 거리 계산 (feet 단위)"""
        # NHL 링크에서 골문은 양쪽 끝에 위치
        # 공격 방향에 따라 골문 선택 (보통 x > 0이면 오른쪽 골문으로 공격)
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0  # 골문은 중앙선상
        
        distance = np.sqrt((x - goal_x)**2 + (y - goal_y)**2)
        return distance
    
    def _calculate_angle_to_goal(self, x, y):
        """골문 각도 계산 (degrees)"""
        goal_x = GOAL_LINE_X if x > 0 else -GOAL_LINE_X
        goal_y = 0
        
        # 골문과의 각도 계산
        angle = np.degrees(np.arctan2(abs(y - goal_y), abs(x - goal_x)))
        return angle
    
    def _time_to_seconds(self, time_str):
        """시간 문자열 (MM:SS)를 초로 변환"""
        if not time_str or time_str == '00:00':
            return 0
        
        try:
            parts = str(time_str).split(':')
            if len(parts) == 2:
                minutes, seconds = map(int, parts)
                return minutes * 60 + seconds
            else:
                return 0
        except (ValueError, AttributeError):
            return 0
    
    def create_sequences(self):
        """이벤트들로부터 시퀀스 생성"""
        logger.info("Creating sequences from processed events")
        
        # 게임별로 그룹화
        game_events = defaultdict(list)
        for event in self.processed_events:
            if event.get('gameId'):
                game_events[event['gameId']].append(event)
        
        logger.info(f"Processing {len(game_events)} games")
        
        sequences = []
        
        for game_id, events in tqdm(game_events.items(), desc="Creating sequences"):
            # 이미 시간순으로 정렬되어 있음
            game_sequences = self._create_game_sequences(events)
            sequences.extend(game_sequences)
        
        logger.info(f"Created {len(sequences)} sequences")
        
        # 시퀀스 품질 통계
        if sequences:
            seq_lengths = [len(seq) for seq in sequences]
            shot_sequences = sum(1 for seq in sequences if seq[-1]['eventType'] == 'S')  # shot-on-goal sequences
            successful_sequences = sum(1 for seq in sequences if seq[-1].get('isGoal', False))
            
            logger.info(f"Sequence statistics:")
            logger.info(f"  Average length: {np.mean(seq_lengths):.2f}")
            logger.info(f"  Min length: {min(seq_lengths)}")
            logger.info(f"  Max length: {max(seq_lengths)}")
            logger.info(f"  Shot-on-goal sequences: {shot_sequences} ({shot_sequences/len(sequences)*100:.1f}%)")
            logger.info(f"  Successful sequences (shots on goal): {successful_sequences} ({successful_sequences/shot_sequences*100:.1f}%)")
        else:
            logger.warning("No sequences created. Check data and sequence generation logic.")
        
        self.sequences = sequences
        return sequences
    
    def _create_game_sequences(self, events):
        """한 게임에서 시퀀스 생성"""
        sequences = []
        
        for i, event in enumerate(events):
            # 슛 이벤트를 기준으로 시퀀스 생성 (shooterId 또는 playerId 사용)
            if event['eventType'] in SHOT_EVENTS and (event.get('shooterId') or event.get('playerId')):
                sequence = [event]  # 마지막 이벤트부터 시작
                
                # 이전 이벤트들을 역순으로 탐색
                j = i - 1
                while (j >= 0 and 
                       len(sequence) < MAX_SEQUENCE_LENGTH and
                       self._can_add_to_sequence(events[j], event, sequence)):
                    
                    prev_event = events[j]
                    sequence.insert(0, prev_event)  # 앞에 삽입
                    
                    # 시퀀스 종료 조건 확인
                    if prev_event['eventType'] in SEQUENCE_ENDING_EVENTS:
                        break
                    
                    j -= 1
                
                # 최소 길이 체크 - 단일 슛 이벤트도 허용 (디버깅용)
                if len(sequence) >= 1:  # MIN_EVENTS_PER_SEQUENCE를 1로 완화
                    sequences.append(sequence)
        
        return sequences
    
    def _can_add_to_sequence(self, prev_event, shot_event, current_sequence):
        """이전 이벤트가 시퀀스에 추가될 수 있는지 확인"""
        
        # 시간 차이 확인
        time_diff = shot_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
        if time_diff > TIME_WINDOW_SECONDS:
            return False
        
        # 연속 이벤트 간 시간 간격 확인 
        if current_sequence:
            first_event = current_sequence[0]
            gap = first_event['gameTimeSeconds'] - prev_event['gameTimeSeconds']
            if gap > MAX_TIME_GAP_SECONDS:
                return False
        
        # 피리어드 변경 확인
        if prev_event['period'] != shot_event['period']:
            return False
        
        # 팀 변경 확인 (포제션 체크)
        if (prev_event.get('teamId') and shot_event.get('teamId') and
            prev_event['teamId'] != shot_event['teamId'] and
            prev_event['eventType'] not in ['F']):  # 페이스오프 제외
            return False
        
        return True
    
    def group_sequences_by_player(self):
        """시퀀스를 선수별로 그룹화"""
        logger.info("Grouping sequences by player")
        
        player_sequences = defaultdict(list)
        
        for sequence in self.sequences:
            # 마지막 이벤트 (슛)의 선수 ID 사용 (shooterId 또는 playerId)
            last_event = sequence[-1]
            shooter_id = last_event.get('shooterId') or last_event.get('playerId')
            
            if shooter_id:
                player_sequences[shooter_id].append(sequence)
        
        # 최소 시퀀스 수와 성공적인 슛 수 조건 필터링
        filtered_players = {}
        for player_id, sequences in player_sequences.items():
            if len(sequences) >= MIN_SEQUENCES_PER_PLAYER:
                # 'G' 또는 'S' 이벤트 모두 포함하여 카운트
                shot_count = sum(1 for seq in sequences if seq[-1]['eventType'] in ['G', 'S'])
                if shot_count >= MIN_GOALS_PER_PLAYER:  # 최소 슛/골 수 체크
                    filtered_players[player_id] = sequences
        
        logger.info(f"Filtered {len(filtered_players)} players with sufficient data")
        logger.info(f"  Min sequences per player: {MIN_SEQUENCES_PER_PLAYER}")
        logger.info(f"  Min shots-on-goal per player: {MIN_GOALS_PER_PLAYER}")
        
        # 상위 선수 정보
        player_counts = [(pid, len(seqs)) for pid, seqs in filtered_players.items()]
        player_counts.sort(key=lambda x: x[1], reverse=True)
        
        if player_counts:
            logger.info(f"Top 10 players by sequence count:")
            for i, (pid, count) in enumerate(player_counts[:10]):
                shot_goal_count = sum(1 for seq in filtered_players[pid] if seq[-1]['eventType'] in ['G', 'S'])
                logger.info(f"  {i+1}. Player {pid}: {count} sequences, {shot_goal_count} shots/goals")
        
        self.player_sequences = filtered_players
        return filtered_players
    
    def get_top_players(self, n_players=50):
        """상위 N명 선수 선택"""
        if not self.player_sequences:
            self.group_sequences_by_player()
        
        # 시퀀스 수 기준 정렬
        sorted_players = sorted(
            self.player_sequences.items(),
            key=lambda x: len(x[1]),
            reverse=True
        )
        
        top_players = dict(sorted_players[:n_players])
        
        logger.info(f"Selected top {len(top_players)} players")
        total_sequences = sum(len(seqs) for seqs in top_players.values())
        total_shots_on_goal = sum(
            sum(1 for seq in seqs if seq[-1]['eventType'] in ['G', 'S'])
            for seqs in top_players.values()
        )
        
        logger.info(f"  Total sequences: {total_sequences}")
        logger.info(f"  Total shots-on-goal: {total_shots_on_goal}")
        logger.info(f"  Shot-on-goal rate: {total_shots_on_goal/total_sequences:.4f}")
        
        return top_players
    
    def prepare_model_data(self, player_sequences):
        """모델 학습용 데이터 준비"""
        logger.info("Preparing model data")
        
        all_event_sequences = []
        all_features = []
        all_targets = []
        all_player_ids = []
        
        # NaN 값을 처리하기 위한 안전한 기본값 설정
        feature_extractors = {
            'distanceToGoal': lambda e: float(e.get('distanceToGoal', 50.0) or 50.0),  # None이면 기본값 사용
            'angleToGoal': lambda e: float(e.get('angleToGoal', 45.0) or 45.0),
            'xCoord': lambda e: float(e.get('xCoord', 0) or 0),
            'yCoord': lambda e: float(e.get('yCoord', 0) or 0),
            'timeInPeriod': lambda e: float(e.get('timeInPeriodSeconds', 600) or 600),
            'period': lambda e: int(e.get('period', 1) or 1),
            'isPowerPlay': lambda e: 1 if e.get('gameSituation') == 'power_play' else 0,
            'isShortHanded': lambda e: 1 if e.get('gameSituation') == 'short_handed' else 0,
        }
        
        for player_id, sequences in tqdm(player_sequences.items(), desc="Preparing data"):
            for sequence in sequences:
                # 이벤트 타입 시퀀스
                event_types = [event['eventType'] for event in sequence]
                all_event_sequences.append(event_types)
                
                # 마지막 이벤트의 특성 벡터
                last_event = sequence[-1]
                features = []
                for feature_name, extractor in feature_extractors.items():
                    features.append(extractor(last_event))
                
                all_features.append(features)
                
                # 타겟 (shot-on-goal 여부)
                is_shot_on_goal = 1 if last_event.get('isGoal', False) else 0
                all_targets.append(is_shot_on_goal)
                
                # 선수 ID
                all_player_ids.append(player_id)
        
        # 특성 배열 생성
        features_array = np.array(all_features)
        
        # NaN 값 체크 및 처리
        nan_mask = np.isnan(features_array)
        if np.any(nan_mask):
            logger.warning(f"Found {np.sum(nan_mask)} NaN values in features. Replacing with defaults...")
            # NaN 값 처리 (열별 평균으로 대체)
            col_means = np.nanmean(features_array, axis=0)
            
            # 특정 열이 모두 NaN인 경우를 처리
            col_means = np.nan_to_num(col_means, nan=0.0)
            
            # NaN 값을 열별 평균으로 대체
            for i in range(features_array.shape[1]):
                col_nan_mask = nan_mask[:, i]
                features_array[col_nan_mask, i] = col_means[i]
        
        model_data = {
            'event_sequences': all_event_sequences,
            'features': features_array,
            'targets': np.array(all_targets),
            'player_ids': np.array(all_player_ids),
            'feature_names': list(feature_extractors.keys())
        }
        
        logger.info(f"Model data prepared:")
        logger.info(f"  Sequences: {len(all_event_sequences)}")
        logger.info(f"  Features: {model_data['features'].shape}")
        logger.info(f"  Shot-on-goal rate: {model_data['targets'].mean():.4f}")
        logger.info(f"  Players: {len(set(all_player_ids))}")
        
        return model_data
    
    def process_pipeline(self, data_dir, n_top_players=50):
        """전체 전처리 파이프라인 실행"""
        logger.info(f"Starting NHL sequence processing pipeline")
        logger.info(f"Target: top {n_top_players} players")
        
        # 1. 데이터 로드
        self.load_play_by_play_data(data_dir)
        if not self.raw_events:
            raise ValueError("No events loaded")
        
        # 2. 이벤트 처리
        self.process_events()
        
        # 3. 시퀀스 생성
        self.create_sequences()
        
        # 4. 선수별 그룹화
        self.group_sequences_by_player()
        
        # 5. 상위 선수 선택
        top_players = self.get_top_players(n_top_players)
        
        # 6. 모델 데이터 준비
        model_data = self.prepare_model_data(top_players)
        
        logger.info("NHL sequence processing pipeline completed")
        
        return model_data, top_players
```

다음 두 가지 수정을 적용했습니다:

1. **입력값 처리 개선**:
   - 특성 추출기(feature extractors)에서 `None` 값을 처리하도록 변경했습니다.
   - `lambda e: float(e.get('distanceToGoal', 50.0) or 50.0)` 형태로, 값이 None인 경우에도 기본값을 사용하도록 합니다.

2. **NaN 값 체크 및 대체**:
   - 특성 배열에 NaN 값이 있는지 확인합니다.
   - NaN 값이 발견되면 해당 열의 평균값으로 대체합니다.
   - 열 전체가 NaN인 경우는 0으로 대체합니다.

이러한 변경으로 scikit-learn의 LogisticRegression이 처리할 수 없는 NaN 값을 모두 제거하여, "Input X contains NaN" 오류를 방지할 수 있습니다. 로그에 NaN 값을 몇 개 발견했는지와 이를 어떻게 처리했는지에 대한 경고 메시지도 추가했습니다.

이제 코드를 실행하면 NaN 값 문제가 해결되어 실험이 성공적으로 완료될 것입니다.

---

**User**

ECE(Expected Callibration Error) 가 무엇인지 정리해 줘

---

**Cursor**

# ECE(Expected Calibration Error)

ECE는 확률 예측 모델이 얼마나 잘 보정(calibrated)되었는지 측정하는 지표입니다. 보정이란 모델이 출력하는 확률과 실제 정확도 사이의 일치 정도를 의미합니다.

## 핵심 개념

- **보정(Calibration)**: 모델의 예측 확률이 실제 정확도와 일치해야 함. 예를 들어, 모델이 70% 확률로 예측한 경우들은 실제로 70%의 확률로 정확해야 합니다.

- **ECE 계산 방법**:
  1. 모델 예측 확률을 구간(bins)으로 나눔 (예: 0-0.1, 0.1-0.2, ..., 0.9-1.0)
  2. 각 구간에서 평균 예측 확률과 실제 정확도의 차이를 계산
  3. 샘플 수로 가중치를 부여하여 전체 구간의 차이를 합산

- **수식**: 
  ECE = Σ(|acc(Bi) - conf(Bi)| × |Bi|/n)
  - Bi: i번째 확률 구간
  - acc(Bi): 해당 구간의 실제 정확도
  - conf(Bi): 해당 구간의 평균 예측 확률
  - |Bi|: 구간에 속한 샘플 수
  - n: 전체 샘플 수

## 의미

- **ECE = 0**: 완벽하게 보정된 모델
- **ECE 값이 클수록**: 모델의 예측 확률이 실제 정확도와 큰 차이가 있음

## 스포츠 분석에서의 활용

- **득점 예측**: 선수의 득점 확률 예측 모델이 실제 득점 성공률과 얼마나 일치하는지 평가
- **경기 결과 예측**: 팀 승리 확률 예측이 실제 승률과 일치하는지 검증
- **선수 성과 모델링**: 선수의 퍼포먼스 예측이 얼마나 현실적인지 평가

## 중요성

ECE는 단순히 예측 정확도뿐만 아니라, 모델이 얼마나 신뢰할 수 있는 확률을 제공하는지 측정하므로, 의사 결정과 위험 평가에 중요한 지표입니다. 특히 스포츠 분석과 같이 확률적 예측이 중요한 분야에서 모델의 신뢰성을 평가하는 데 유용합니다.

