{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "7403f1af",
   "metadata": {},
   "source": [
    "# OneClickAI 통합 시계열 예측\n",
    "이 노트북은 Google Colab 환경에서 실행하도록 작성되었습니다.\n",
    "웹앱의 **통합 시계열 예측**과 같은 방법으로 학습합니다.\n",
    "\n",
    "시간 순서대로 된 표에서 지난 **입력 창**칸의 값을 보고 바로 다음을 맞힙니다.\n",
    "출력 열마다 **회귀**(수치, 앞으로 출력 창 칸의 값)나 **분류**(카테고리, 다음 한 칸의 종류)를 정해 한 번에 학습합니다.\n",
    "모델은 웹앱과 같은 **LSTM**입니다. 창의 칸을 차례로 읽으며 게이트 4개로 기억을 고칩니다.\n",
    "\n",
    "**순서**\n",
    "1. 파일 업로드\n",
    "2. 열 역할과 종류 정하기\n",
    "3. 창(window) 만들기\n",
    "4. 모델 학습 (LSTM + 출력 열마다 헤드)\n",
    "5. 결과 확인, 예측, 이어서 예측"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5da3a786",
   "metadata": {},
   "source": [
    "## 1. 파일 업로드\n",
    "\n",
    "엑셀 파일(`.xlsx`)을 업로드해 주세요. 두 가지 모양을 읽습니다.\n",
    "\n",
    "- **시트 하나**: 웹앱의 `데이터 저장(.xlsx)` 버튼으로 받은 파일입니다. 첫 행은 열 이름, 첫 열은 시간(연도, 월, 일자)이고 행은 시간 순서대로입니다.\n",
    "- **원인/결과 두 시트**: 기존 시계열 예측 페이지의 엑셀입니다. 두 시트를 행 순서대로 옆으로 붙입니다.\n",
    "  결과 시트의 열이 원인 시트에도 같은 이름과 같은 값으로 있으면 하나로 합치고 **입력+출력**으로 둡니다.\n",
    "\n",
    "웹앱은 행을 999개까지 쓰지만 이 노트북에는 제한이 없습니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "02e320f9",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\n",
    "# 불러오기\n",
    "# ==========================================\n",
    "import math\n",
    "import unicodedata\n",
    "\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "import keras\n",
    "from google.colab import files\n",
    "\n",
    "# ==========================================\n",
    "# 파일 업로드\n",
    "# ==========================================\n",
    "print('데이터 파일(.xlsx)을 업로드 해주세요.\\n')\n",
    "uploaded = files.upload()\n",
    "\n",
    "if len(uploaded.keys()) == 0:\n",
    "    raise ValueError('파일이 업로드되지 않았습니다.')\n",
    "\n",
    "DATA_FILE_PATH = list(uploaded.keys())[0]\n",
    "\n",
    "if not DATA_FILE_PATH.endswith(('.xls', '.xlsx')):\n",
    "    raise ValueError('지원하지 않는 파일 형식입니다. .xlsx 파일을 업로드 해주세요.')\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 칸 값 다루기 (웹앱과 같은 규칙)\n",
    "# ==========================================\n",
    "def is_blank(value):\n",
    "    if value is None:\n",
    "        return True\n",
    "    if isinstance(value, float) and math.isnan(value):\n",
    "        return True\n",
    "    return isinstance(value, str) and value.strip() == ''\n",
    "\n",
    "\n",
    "def js_number(n):\n",
    "    \"\"\"숫자를 웹앱(자바스크립트)과 같은 글자로 쓴다. 1.0 은 '1'\"\"\"\n",
    "    n = float(n)\n",
    "    if n.is_integer() and abs(n) < 1e21:\n",
    "        return str(int(n))\n",
    "    return repr(n)\n",
    "\n",
    "\n",
    "def js_string(value):\n",
    "    \"\"\"칸 값을 자바스크립트의 String(값) 처럼 글자로 바꾼다\"\"\"\n",
    "    if value is None:\n",
    "        return ''\n",
    "    if isinstance(value, (bool, np.bool_)):\n",
    "        return 'true' if value else 'false'\n",
    "    if isinstance(value, (int, float, np.integer, np.floating)):\n",
    "        return js_number(value) if math.isfinite(value) else ''\n",
    "    if hasattr(value, 'strftime'):   # 엑셀의 날짜 칸\n",
    "        return value.strftime('%Y-%m-%d')\n",
    "    return str(value)\n",
    "\n",
    "\n",
    "def to_cell_string(value):\n",
    "    \"\"\"칸 값을 비교용 글자로: NFC, 대시 통일, 앞뒤 공백 제거\"\"\"\n",
    "    text = unicodedata.normalize('NFC', js_string(value))\n",
    "    for dash in '\\u2013\\u2014\\u2212':\n",
    "        text = text.replace(dash, '-')\n",
    "    return text.strip()\n",
    "\n",
    "\n",
    "def parse_number(value):\n",
    "    \"\"\"숫자로 읽히면 float, 아니면 None\"\"\"\n",
    "    text = to_cell_string(value)\n",
    "    if text == '' or '_' in text:\n",
    "        return None\n",
    "    try:\n",
    "        n = float(text)\n",
    "    except ValueError:\n",
    "        return None\n",
    "    return n if math.isfinite(n) else None\n",
    "\n",
    "\n",
    "def canonical_cell(value):\n",
    "    \"\"\"숫자 표기를 하나로 (1, 1.0, 01 은 모두 '1'). 숫자가 아니면 글자 그대로\"\"\"\n",
    "    n = parse_number(value)\n",
    "    return js_number(n) if n is not None else to_cell_string(value)\n",
    "\n",
    "\n",
    "def read_table(frame):\n",
    "    \"\"\"시트를 [행][칸] 목록으로. 빈 행은 빼고, 열은 머리글이 있는 곳까지만 둔다\"\"\"\n",
    "    table = [list(row) for row in frame.values.tolist()]\n",
    "    table = [row for row in table if not all(is_blank(v) for v in row)]\n",
    "    if not table:\n",
    "        raise ValueError('시트에 데이터가 없습니다.')\n",
    "    filled = [i for i, v in enumerate(table[0]) if not is_blank(v)]\n",
    "    if not filled:\n",
    "        raise ValueError('첫 행(머리글)에 열 이름이 없습니다.')\n",
    "    width = filled[-1] + 1\n",
    "    return [row[:width] + [None] * (width - len(row)) for row in table]\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 엑셀 읽기\n",
    "# ==========================================\n",
    "sheets = pd.read_excel(DATA_FILE_PATH, sheet_name=None, header=None, dtype=object)\n",
    "merged_roles = None   # 원인/결과를 붙였을 때 열마다 정해지는 역할\n",
    "\n",
    "if '원인' in sheets and '결과' in sheets:\n",
    "    cause = read_table(sheets['원인'])\n",
    "    result = read_table(sheets['결과'])\n",
    "    if len(cause) != len(result):\n",
    "        raise ValueError(f\"'원인' 시트({len(cause) - 1}행)와 '결과' 시트({len(result) - 1}행)의 행 수가 다릅니다.\")\n",
    "    cols = cause[0] + result[0][1:]\n",
    "    rows = [c + r[1:] for c, r in zip(cause[1:], result[1:])]\n",
    "    first_output = len(cause[0])\n",
    "    print(\"'원인' 시트와 '결과' 시트를 행 순서대로 붙였습니다.\")\n",
    "\n",
    "    # 결과 열이 원인에도 같은 이름과 같은 값으로 있으면 하나로 합치고 입력+출력으로 둔다 (웹앱과 같다)\n",
    "    def same_column(a, b):\n",
    "        if to_cell_string(cols[a]) == '' or to_cell_string(cols[a]) != to_cell_string(cols[b]):\n",
    "            return False\n",
    "        return all(canonical_cell(row[a]) == canonical_cell(row[b]) for row in rows)\n",
    "\n",
    "    drop, both, dropped = set(), set(), []\n",
    "    for c in range(first_output, len(cols)):\n",
    "        for a in range(1, first_output):\n",
    "            if a in drop or a in both:\n",
    "                continue\n",
    "            if same_column(a, c):\n",
    "                drop.add(c)\n",
    "                both.add(a)\n",
    "                dropped.append(to_cell_string(cols[c]))\n",
    "                break\n",
    "    keep = [c for c in range(len(cols)) if c not in drop]\n",
    "    merged_roles = {}\n",
    "    for i, c in enumerate(keep):\n",
    "        if i > 0:\n",
    "            merged_roles[i] = 'both' if (c >= first_output or c in both) else 'input'\n",
    "    cols = [cols[c] for c in keep]\n",
    "    rows = [[row[c] for c in keep] for row in rows]\n",
    "    if dropped:\n",
    "        print(f\"결과 시트의 {', '.join(dropped)} 열은 원인 시트에도 같은 값으로 있어 하나로 합치고 입력+출력으로 두었습니다.\")\n",
    "else:\n",
    "    sheet_name = '데이터' if '데이터' in sheets else list(sheets.keys())[0]\n",
    "    table = read_table(sheets[sheet_name])\n",
    "    cols, rows = table[0], table[1:]\n",
    "    print(f\"'{sheet_name}' 시트를 읽었습니다.\")\n",
    "\n",
    "if is_blank(cols[0]):\n",
    "    cols[0] = '시간'\n",
    "\n",
    "column_names = [to_cell_string(c) or f'열 {i}' for i, c in enumerate(cols)]\n",
    "time_labels = [to_cell_string(row[0]) or str(r + 1) for r, row in enumerate(rows)]\n",
    "print(f'\\n행(시점) {len(rows)}개, 열 {len(cols)}개: {column_names}')\n",
    "display(pd.DataFrame(rows[:5], columns=column_names))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "71d78ebf",
   "metadata": {},
   "source": [
    "## 2. 열 역할과 종류 정하기\n",
    "\n",
    "열마다 **역할**(입력/출력/입력+출력/제외)과 **종류**(카테고리/수치)를 정합니다.\n",
    "아래 설정을 비워 두면 웹앱과 같은 규칙으로 자동으로 정합니다.\n",
    "\n",
    "- 마지막 열은 **입력+출력**, 나머지 열은 **입력** (원인/결과 파일은 결과 시트에서 온 열이 입력+출력)\n",
    "- 시계열은 자기 과거가 가장 큰 단서라, 맞힐 열은 보통 입력+출력으로 둡니다\n",
    "- 숫자가 아닌 열은 **카테고리**, 숫자 열은 정수이면서 종류가 5개 이하면 **카테고리**, 아니면 **수치**\n",
    "- 출력 열은 수치면 **회귀**(앞으로 출력 창 칸의 값), 카테고리면 **분류**(다음 한 칸의 종류)로 배웁니다\n",
    "- 5번의 **이어서 예측**에서 입력만 하는 열은 AI가 예측하지 않으므로, 표 안쪽은 실제 값을, 표가 끝난 뒤에는 마지막 값을 그대로 넣습니다\n",
    "\n",
    "웹앱에서 받은 파일에는 열 설정이 들어 있지 않습니다. 웹앱에서 바꾼 설정은 아래 칸에 다시 적어 주세요.\n",
    "같은 열 이름을 `INPUT_COLUMNS`와 `OUTPUT_COLUMNS`에 모두 적으면 입력+출력입니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ef5d550a",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\n",
    "# 열 설정 (비워 두면 웹앱과 같은 규칙으로 자동으로 정합니다)\n",
    "# ==========================================\n",
    "INPUT_COLUMNS = []           # 입력으로 쓸 열 이름. 예: ['평균기온', '판매량']\n",
    "OUTPUT_COLUMNS = []          # 출력으로 쓸 열 이름. 예: ['판매량', '재고 상태']  (양쪽에 다 적으면 입력+출력)\n",
    "KIND_OVERRIDES = {}          # 종류 바꾸기. 'categorical' 은 카테고리(분류), 'numeric' 은 수치(회귀). 예: {'등급': 'numeric'}\n",
    "NORMALIZATION = 'minMax11'   # 수치 열 정규화: 'none', 'minMax01'(0~1), 'minMax11'(-1~1, 기본), 'zScore'\n",
    "\n",
    "# ==========================================\n",
    "# 웹앱과 같은 기준값\n",
    "# ==========================================\n",
    "MAX_CATEGORIES = 50          # 카테고리 열 하나의 종류 최대 개수\n",
    "CATEGORICAL_UNIQUE_MAX = 5   # 정수이면서 종류가 이 개수 이하이면 카테고리로 추정\n",
    "\n",
    "KIND_LABELS = {'categorical': '카테고리', 'numeric': '수치'}\n",
    "ROLE_LABELS = {'input': '입력', 'output': '출력', 'both': '입력+출력', 'exclude': '제외'}\n",
    "\n",
    "\n",
    "def is_input(role):\n",
    "    return role in ('input', 'both')\n",
    "\n",
    "\n",
    "def is_output(role):\n",
    "    return role in ('output', 'both')\n",
    "\n",
    "\n",
    "def analyze_columns(cols, rows):\n",
    "    \"\"\"열마다 값을 훑어 숫자인지, 서로 다른 값, 최소/최대/평균/표준편차를 센다 (시간 열은 뺀다)\"\"\"\n",
    "    stats = []\n",
    "    for c in range(1, len(cols)):\n",
    "        cells, numbers = [], []\n",
    "        is_numeric = is_integer = True\n",
    "        empty = 0\n",
    "        for row in rows:\n",
    "            raw = row[c] if c < len(row) else None\n",
    "            s = to_cell_string(raw)\n",
    "            if s == '':\n",
    "                empty += 1\n",
    "                continue\n",
    "            cells.append(s)\n",
    "            n = parse_number(s)\n",
    "            if n is None:\n",
    "                is_numeric = is_integer = False\n",
    "            else:\n",
    "                if not n.is_integer():\n",
    "                    is_integer = False\n",
    "                numbers.append(n)\n",
    "        if not numbers:\n",
    "            is_numeric = is_integer = False\n",
    "\n",
    "        # 서로 다른 값 (분류의 종류). 숫자 열은 1 과 1.0 을 같은 값으로 보고 크기 순서로 둔다\n",
    "        unique, seen = [], set()\n",
    "        for s in cells:\n",
    "            key = js_number(parse_number(s)) if is_numeric else s\n",
    "            if key not in seen:\n",
    "                seen.add(key)\n",
    "                unique.append(key)\n",
    "        if is_numeric:\n",
    "            unique.sort(key=float)\n",
    "\n",
    "        mean = sum(numbers) / len(numbers) if numbers else None\n",
    "        std = math.sqrt(max(sum(n * n for n in numbers) / len(numbers) - mean ** 2, 0)) if numbers else None\n",
    "        suggested = 'categorical' if (not is_numeric or (is_integer and len(unique) <= CATEGORICAL_UNIQUE_MAX)) else 'numeric'\n",
    "        stats.append({\n",
    "            'index': c,\n",
    "            'name': column_names[c],\n",
    "            'is_numeric': is_numeric,\n",
    "            'unique': unique,\n",
    "            'empty': empty,\n",
    "            'min': min(numbers) if numbers else None,\n",
    "            'max': max(numbers) if numbers else None,\n",
    "            'mean': mean,\n",
    "            'std': std,\n",
    "            'suggested': suggested,\n",
    "        })\n",
    "    return stats\n",
    "\n",
    "\n",
    "stats = analyze_columns(cols, rows)\n",
    "by_name = {s['name']: s for s in stats}\n",
    "\n",
    "# ==========================================\n",
    "# 자동 역할과 종류\n",
    "# ==========================================\n",
    "for s in stats:\n",
    "    s['kind'] = s['suggested']\n",
    "    if merged_roles is not None:\n",
    "        s['role'] = merged_roles.get(s['index'], 'input')\n",
    "    else:\n",
    "        s['role'] = 'both' if s['index'] == len(cols) - 1 else 'input'\n",
    "\n",
    "# ==========================================\n",
    "# 직접 적은 설정 반영\n",
    "# ==========================================\n",
    "for name in list(INPUT_COLUMNS) + list(OUTPUT_COLUMNS) + list(KIND_OVERRIDES):\n",
    "    if name not in by_name:\n",
    "        raise ValueError(f\"'{name}' 열이 없습니다. 열 이름: {column_names[1:]}\")\n",
    "if INPUT_COLUMNS or OUTPUT_COLUMNS:\n",
    "    for s in stats:\n",
    "        inp = s['name'] in INPUT_COLUMNS if INPUT_COLUMNS else is_input(s['role'])\n",
    "        out = s['name'] in OUTPUT_COLUMNS if OUTPUT_COLUMNS else is_output(s['role'])\n",
    "        s['role'] = 'both' if inp and out else 'input' if inp else 'output' if out else 'exclude'\n",
    "for name, kind in KIND_OVERRIDES.items():\n",
    "    if kind not in ('categorical', 'numeric'):\n",
    "        raise ValueError(f\"'{name}' 의 종류는 'categorical' 이나 'numeric' 으로 적어 주세요.\")\n",
    "    if kind == 'numeric' and not by_name[name]['is_numeric']:\n",
    "        raise ValueError(f\"'{name}' 열에 숫자가 아닌 값이 있어 수치로 쓸 수 없습니다.\")\n",
    "    by_name[name]['kind'] = kind\n",
    "\n",
    "# ==========================================\n",
    "# 검사 (웹앱과 같은 조건. 행 수는 창 크기와 함께 3번에서 본다)\n",
    "# ==========================================\n",
    "input_specs = [s for s in stats if is_input(s['role'])]\n",
    "output_specs = [s for s in stats if is_output(s['role'])]\n",
    "if not input_specs:\n",
    "    raise ValueError('입력으로 쓸 열이 없습니다. INPUT_COLUMNS 에 적어 주세요. (입력 또는 입력+출력)')\n",
    "if not output_specs:\n",
    "    raise ValueError('출력으로 쓸 열이 없습니다. OUTPUT_COLUMNS 에 적어 주세요. (출력 또는 입력+출력)')\n",
    "for s in input_specs + output_specs:\n",
    "    if s['empty']:\n",
    "        raise ValueError(f\"'{s['name']}' 열에 빈칸이 {s['empty']}개 있습니다. 채우거나 빈 행을 지워 주세요.\")\n",
    "for s in input_specs:\n",
    "    if s['kind'] == 'categorical' and len(s['unique']) > MAX_CATEGORIES:\n",
    "        raise ValueError(f\"'{s['name']}' 열의 종류가 {len(s['unique'])}개로 너무 많습니다. 제외하거나 수치로 바꿔 주세요. (최대 {MAX_CATEGORIES}개)\")\n",
    "    if len(s['unique']) <= 1:\n",
    "        raise ValueError(f\"'{s['name']}' 열은 값이 한 가지뿐이라 학습에 도움이 되지 않습니다. 제외해 주세요.\")\n",
    "for s in output_specs:\n",
    "    if s['kind'] == 'numeric':\n",
    "        if not s['is_numeric']:\n",
    "            raise ValueError(f\"'{s['name']}' 열에 숫자가 아닌 값이 있어 회귀로 쓸 수 없습니다. 분류로 바꿔 주세요.\")\n",
    "        if s['min'] == s['max']:\n",
    "            raise ValueError(f\"'{s['name']}' 열의 값이 모두 같아 회귀 학습을 할 수 없습니다.\")\n",
    "    else:\n",
    "        if len(s['unique']) < 2:\n",
    "            raise ValueError(f\"'{s['name']}' 열에 종류가 두 개 이상 있어야 분류할 수 있습니다.\")\n",
    "        if len(s['unique']) > MAX_CATEGORIES:\n",
    "            raise ValueError(f\"'{s['name']}' 열의 종류가 {len(s['unique'])}개로 너무 많습니다. (최대 {MAX_CATEGORIES}개)\")\n",
    "\n",
    "# 이어서 예측(되먹임)에서 AI가 채우지 못하는 입력만 하는 열. 표 안쪽은 실제 값, 표가 끝난 뒤에는 마지막 값을 넣는다\n",
    "held_inputs = [s['name'] for s in input_specs if not is_output(s['role'])]\n",
    "\n",
    "\n",
    "def learning_of(s):\n",
    "    parts = []\n",
    "    if is_input(s['role']):\n",
    "        parts.append('원핫 입력' if s['kind'] == 'categorical' else '정규화 입력')\n",
    "    if is_output(s['role']):\n",
    "        parts.append('분류 (Softmax)' if s['kind'] == 'categorical' else '회귀 (Linear)')\n",
    "    return ' + '.join(parts) if parts else '-'\n",
    "\n",
    "\n",
    "def preview_of(s):\n",
    "    if s['kind'] == 'categorical':\n",
    "        return f\"{len(s['unique'])}종류: \" + ', '.join(s['unique'][:4]) + (f\" 외 {len(s['unique']) - 4}개\" if len(s['unique']) > 4 else '')\n",
    "    return f\"{s['min']:g} ~ {s['max']:g} (평균 {s['mean']:.2f})\"\n",
    "\n",
    "\n",
    "display(pd.DataFrame([{\n",
    "    '열': s['name'],\n",
    "    '역할': ROLE_LABELS[s['role']],\n",
    "    '종류': KIND_LABELS[s['kind']],\n",
    "    '학습': learning_of(s),\n",
    "    '값 미리보기': preview_of(s),\n",
    "} for s in stats]))\n",
    "print('이어서 예측: ' + ('입력 열이 모두 출력이기도 해서 AI가 모든 입력을 채웁니다' if not held_inputs else f\"입력만 하는 열 {held_inputs} 은(는) 표 안쪽은 실제 값, 표가 끝난 뒤에는 마지막 값을 넣습니다\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e9b30595",
   "metadata": {},
   "source": [
    "## 3. 창(window) 만들기\n",
    "\n",
    "- 수치 열은 설정한 방식으로 **정규화**하고(기본 -1~1, 표 전체의 최솟값과 최댓값), 카테고리 열은 **원핫**으로 바꿉니다.\n",
    "  회귀 출력은 예측할 때 원래 단위로 되돌립니다.\n",
    "- 창 t는 `WINDOW_IN`칸의 입력 벡터를 보고, 바로 다음의 출력을 맞힙니다.\n",
    "  수치 출력은 다음 `WINDOW_OUT`칸의 값, 카테고리 출력은 다음 한 칸의 원핫입니다.\n",
    "- 창은 **시간 순서 그대로** 두고 섞지 않습니다. 창이 20개 이상이면 뒤쪽 20%(가장 최근)를 검증에 씁니다.\n",
    "  케라스의 `validation_split`도 뒤쪽을 그대로 떼므로 웹앱과 같은 창이 검증이 됩니다.\n",
    "- 행은 최소 `ceil((WINDOW_IN + WINDOW_OUT) * 1.2 + 1)`개 있어야 합니다 (웹앱과 같은 규칙)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "722b68c9",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\n",
    "# 창 크기 (웹앱 톱니바퀴 기본값)\n",
    "# ==========================================\n",
    "WINDOW_IN = 12    # 과거 몇 칸을 볼지 (1~60)\n",
    "WINDOW_OUT = 1    # 앞으로 몇 칸을 맞힐지 (1~24). 카테고리 출력은 늘 다음 한 칸\n",
    "\n",
    "min_rows = math.ceil((WINDOW_IN + WINDOW_OUT) * 1.2 + 1)\n",
    "if len(rows) < min_rows:\n",
    "    raise ValueError(f'입력 창 {WINDOW_IN}칸과 출력 창 {WINDOW_OUT}칸으로 배우려면 행이 최소 {min_rows}개 있어야 합니다. (현재 {len(rows)}개)')\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 인코더: 수치는 정규화, 카테고리는 원핫 (같은 열이 입력이자 출력이면 같은 통계를 쓴다)\n",
    "# ==========================================\n",
    "def build_encoder(spec):\n",
    "    if spec['kind'] == 'categorical':\n",
    "        return {'index': spec['index'], 'name': spec['name'], 'kind': 'categorical', 'categories': list(spec['unique'])}\n",
    "    return {\n",
    "        'index': spec['index'], 'name': spec['name'], 'kind': 'numeric', 'normalization': NORMALIZATION,\n",
    "        'min': spec['min'], 'max': spec['max'], 'mean': spec['mean'], 'std': spec['std'],\n",
    "    }\n",
    "\n",
    "\n",
    "def normalize(value, enc):\n",
    "    lo, hi, method = enc['min'], enc['max'], enc['normalization']\n",
    "    if method == 'none':\n",
    "        return value\n",
    "    if method == 'minMax01':\n",
    "        return 0 if hi == lo else (value - lo) / (hi - lo)\n",
    "    if method == 'zScore':\n",
    "        return 0 if enc['std'] == 0 else (value - enc['mean']) / enc['std']\n",
    "    return 0 if hi == lo else (value - lo) / (hi - lo) * 2 - 1\n",
    "\n",
    "\n",
    "def denormalize(value, enc):\n",
    "    lo, hi, method = enc['min'], enc['max'], enc['normalization']\n",
    "    if method == 'none':\n",
    "        return value\n",
    "    if method == 'minMax01':\n",
    "        return lo if hi == lo else value * (hi - lo) + lo\n",
    "    if method == 'zScore':\n",
    "        return enc['mean'] if enc['std'] == 0 else value * enc['std'] + enc['mean']\n",
    "    return lo if hi == lo else (value + 1) / 2 * (hi - lo) + lo\n",
    "\n",
    "\n",
    "def encode_value(enc, raw):\n",
    "    \"\"\"칸 하나를 벡터로. 수치는 정규화한 값 하나, 카테고리는 원핫. 못 읽으면 None\"\"\"\n",
    "    text = to_cell_string(raw)\n",
    "    if enc['kind'] == 'numeric':\n",
    "        n = parse_number(text)\n",
    "        return None if n is None else [normalize(n, enc)]\n",
    "    categories = enc['categories']\n",
    "    index = categories.index(text) if text in categories else -1\n",
    "    if index == -1 and parse_number(text) is not None:\n",
    "        key = js_number(parse_number(text))   # 숫자 카테고리는 1 과 1.0 을 같은 칸으로 본다\n",
    "        index = next((k for k, c in enumerate(categories) if parse_number(c) is not None and js_number(parse_number(c)) == key), -1)\n",
    "    if index == -1:\n",
    "        return None\n",
    "    vector = [0.0] * len(categories)\n",
    "    vector[index] = 1.0\n",
    "    return vector\n",
    "\n",
    "\n",
    "def encoder_dim(enc):\n",
    "    return len(enc['categories']) if enc['kind'] == 'categorical' else 1\n",
    "\n",
    "\n",
    "def head_dim(enc):\n",
    "    \"\"\"헤드 하나의 유닛 수. 수치는 앞으로 WINDOW_OUT 칸, 카테고리는 다음 한 칸의 종류 수\"\"\"\n",
    "    return len(enc['categories']) if enc['kind'] == 'categorical' else WINDOW_OUT\n",
    "\n",
    "\n",
    "input_encoders = [build_encoder(s) for s in input_specs]\n",
    "output_encoders = [build_encoder(s) for s in output_specs]\n",
    "FEATURE_DIM = sum(encoder_dim(e) for e in input_encoders)\n",
    "OUTPUT_DIM = sum(head_dim(e) for e in output_encoders)\n",
    "\n",
    "\n",
    "def encode_input_row(row, r):\n",
    "    vector = []\n",
    "    for enc in input_encoders:\n",
    "        part = encode_value(enc, row[enc['index']])\n",
    "        if part is None:\n",
    "            raise ValueError(f\"{r + 1}번째 행의 '{enc['name']}' 값을 읽을 수 없습니다.\")\n",
    "        vector.extend(part)\n",
    "    return vector\n",
    "\n",
    "\n",
    "def encode_target(start):\n",
    "    \"\"\"창의 정답 (헤드 순서대로 이어 붙임): 수치는 start 부터 WINDOW_OUT 칸, 카테고리는 start 칸의 원핫\"\"\"\n",
    "    target = []\n",
    "    for enc in output_encoders:\n",
    "        if enc['kind'] == 'numeric':\n",
    "            for k in range(WINDOW_OUT):\n",
    "                n = parse_number(rows[start + k][enc['index']])\n",
    "                if n is None:\n",
    "                    raise ValueError(f\"{start + k + 1}번째 행의 '{enc['name']}' 값을 읽을 수 없습니다.\")\n",
    "                target.append(normalize(n, enc))\n",
    "        else:\n",
    "            part = encode_value(enc, rows[start][enc['index']])\n",
    "            if part is None:\n",
    "                raise ValueError(f\"{start + 1}번째 행의 '{enc['name']}' 값을 읽을 수 없습니다.\")\n",
    "            target.extend(part)\n",
    "    return target\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 창 자르기 (시간 순서 그대로)\n",
    "# ==========================================\n",
    "vectors = np.array([encode_input_row(row, r) for r, row in enumerate(rows)], dtype='float32')   # [행, FEATURE_DIM]\n",
    "n_windows = len(rows) - WINDOW_IN - WINDOW_OUT + 1\n",
    "X = np.stack([vectors[t:t + WINDOW_IN] for t in range(n_windows)])                              # [창, WINDOW_IN, FEATURE_DIM]\n",
    "Y_all = np.array([encode_target(t + WINDOW_IN) for t in range(n_windows)], dtype='float32')     # [창, OUTPUT_DIM]\n",
    "\n",
    "# 헤드마다 정답을 나눈다 (모델 출력 순서)\n",
    "Y_heads, offset = [], 0\n",
    "for enc in output_encoders:\n",
    "    d = head_dim(enc)\n",
    "    Y_heads.append(Y_all[:, offset:offset + d])\n",
    "    offset += d\n",
    "\n",
    "VALIDATION_SPLIT = 0.2 if n_windows >= 20 else 0.0\n",
    "train_count = math.floor(n_windows * (1 - VALIDATION_SPLIT))   # 케라스는 앞에서 이 수만큼 학습에 쓰고 뒤쪽을 검증에 쓴다\n",
    "\n",
    "for enc in input_encoders:\n",
    "    if enc['kind'] == 'categorical':\n",
    "        print(f\"[입력 {enc['name']}] 원핫 {len(enc['categories'])}종류\")\n",
    "    else:\n",
    "        print(f\"[입력 {enc['name']}] 값 {enc['min']:g} ~ {enc['max']:g}, 정규화 {enc['normalization']}\")\n",
    "for enc in output_encoders:\n",
    "    if enc['kind'] == 'categorical':\n",
    "        print(f\"[출력 {enc['name']}] 분류 {len(enc['categories'])}종류: {enc['categories']}\")\n",
    "    else:\n",
    "        print(f\"[출력 {enc['name']}] 회귀, 앞으로 {WINDOW_OUT}칸, 값 {enc['min']:g} ~ {enc['max']:g}\")\n",
    "print(f'\\n창 {WINDOW_IN}칸 x {FEATURE_DIM}차원 -> 출력 {OUTPUT_DIM}차원. 학습 창 {n_windows}개 (학습 {train_count}, 검증 {n_windows - train_count})')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2ab12395",
   "metadata": {},
   "source": [
    "## 4. 모델 학습 (LSTM + 출력 열마다 헤드)\n",
    "\n",
    "웹앱과 같은 구조입니다.\n",
    "\n",
    "- 입력 `[WINDOW_IN, FEATURE_DIM]` → LSTM `NUM_LAYERS`층 (앞 층은 칸마다의 출력을 다음 층에 넘기고, 마지막 층은 마지막 칸의 상태만 냅니다)\n",
    "  → 출력 열마다 헤드 하나. 회귀는 Linear(`WINDOW_OUT`개) + MSE, 분류는 Softmax + categorical crossentropy이고, 전체 loss는 헤드 loss의 합입니다.\n",
    "- LSTM은 칸마다 게이트 4개(잊음 f, 입력 i, 후보 g, 출력 o)로 기억 c를 고칩니다.\n",
    "  `f = σ(W_f[h, x] + b_f)`, `i = σ(W_i[h, x] + b_i)`, `g = tanh(W_g[h, x] + b_g)`, `o = σ(W_o[h, x] + b_o)`, `c = f·c + i·g`, `h = o·tanh(c)`\n",
    "  층 하나의 파라미터는 `4 × ((앞 층 차원 + 노드 수 + 1) × 노드 수)`입니다.\n",
    "- 노드 수 자동은 `(입력 차원 + 출력 차원) × 4`를 16~64 사이로 맞춘 값입니다 (웹앱과 같다).\n",
    "- Adam, 학습률 0.001, 배치 16, 100번 학습, 조기 종료는 끕니다 (웹앱 기본값).\n",
    "  조기 종료를 켜면 검증 loss가 `PATIENCE`번 나아지지 않을 때 멈추고 가장 좋았던 에포크의 가중치로 되돌립니다. 창이 20개 미만이면 검증 데이터가 없어 끝까지 돕니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8e09ee9b",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\n",
    "# 학습 설정 (웹앱 톱니바퀴 기본값)\n",
    "# ==========================================\n",
    "NUM_LAYERS = 1             # LSTM 층 수 (선택지 1, 2, 3)\n",
    "NUM_NODES = 0              # 노드 수 (0 = 자동. 선택지 8, 16, 32, 64, 128)\n",
    "EPOCHS = 100\n",
    "BATCH_SIZE = 16\n",
    "LEARNING_RATE = 0.001\n",
    "EARLY_STOPPING = False     # 검증 데이터가 있을 때만 걸린다 (웹앱 기본값은 끔)\n",
    "PATIENCE = 50\n",
    "\n",
    "\n",
    "def auto_nodes(feature_dim, output_dim):\n",
    "    return min(max((feature_dim + output_dim) * 4, 16), 64)\n",
    "\n",
    "\n",
    "units = auto_nodes(FEATURE_DIM, OUTPUT_DIM) if NUM_NODES == 0 else NUM_NODES\n",
    "\n",
    "# ==========================================\n",
    "# 모델: LSTM 층들 + 출력 열마다 헤드 (웹앱의 buildTimeSeriesUnifiedModel 과 같다)\n",
    "# ==========================================\n",
    "inputs = keras.Input(shape=(WINDOW_IN, FEATURE_DIM), name='window')\n",
    "x = inputs\n",
    "for i in range(NUM_LAYERS):\n",
    "    x = keras.layers.LSTM(units, return_sequences=i < NUM_LAYERS - 1, name=f'lstm_{i}')(x)\n",
    "\n",
    "outputs, losses = [], []\n",
    "for k, enc in enumerate(output_encoders):\n",
    "    categorical = enc['kind'] == 'categorical'\n",
    "    outputs.append(keras.layers.Dense(head_dim(enc), activation='softmax' if categorical else 'linear', name=f'head_{k}')(x))\n",
    "    losses.append('categorical_crossentropy' if categorical else 'mean_squared_error')\n",
    "\n",
    "single_head = len(outputs) == 1\n",
    "model = keras.Model(inputs, outputs[0] if single_head else outputs)\n",
    "model.compile(optimizer=keras.optimizers.Adam(learning_rate=LEARNING_RATE), loss=losses[0] if single_head else losses)\n",
    "model.summary()\n",
    "for k, enc in enumerate(output_encoders):\n",
    "    print(f\"head_{k}: {enc['name']} ({'분류' if enc['kind'] == 'categorical' else '회귀'})\")\n",
    "\n",
    "# 파라미터 수 (웹앱의 그림과 같은 식)\n",
    "expected, prev = 0, FEATURE_DIM\n",
    "for i in range(NUM_LAYERS):\n",
    "    expected += 4 * ((prev + units + 1) * units)\n",
    "    prev = units\n",
    "expected += (prev + 1) * OUTPUT_DIM\n",
    "print(f'\\n파라미터 {model.count_params():,}개 (식으로 센 값 {expected:,}개), 약 {model.count_params() * 4 / 1024:.1f}KB')\n",
    "\n",
    "callbacks = []\n",
    "if EARLY_STOPPING and VALIDATION_SPLIT > 0:\n",
    "    callbacks.append(keras.callbacks.EarlyStopping(monitor='val_loss', patience=PATIENCE, restore_best_weights=True, verbose=1))\n",
    "\n",
    "# ==========================================\n",
    "# 학습 (창은 시간 순서 그대로. 케라스가 뒤쪽 20% 를 검증으로 떼고, 학습 창만 섞는다)\n",
    "# ==========================================\n",
    "history = model.fit(\n",
    "    X,\n",
    "    Y_heads[0] if single_head else Y_heads,\n",
    "    epochs=EPOCHS,\n",
    "    batch_size=BATCH_SIZE,\n",
    "    validation_split=VALIDATION_SPLIT,\n",
    "    shuffle=True,\n",
    "    callbacks=callbacks,\n",
    "    verbose=2,\n",
    ")\n",
    "\n",
    "# ==========================================\n",
    "# 모델 저장\n",
    "# ==========================================\n",
    "model.save('timeseries_unified_model.keras')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d06b71c1",
   "metadata": {},
   "source": [
    "## 5. 결과 확인, 예측, 이어서 예측\n",
    "\n",
    "학습 손실 그래프를 그리고, 검증 창으로 출력 열마다 평균 오차(회귀)나 정확도(분류)를 봅니다.\n",
    "그 다음 표의 끝(또는 고른 시점) 앞의 `WINDOW_IN`칸을 넣어 다음을 예측합니다.\n",
    "\n",
    "**이어서 예측**은 모델이 낸 값을 실제값처럼 다음 창에 넣어 되풀이하는 자기회귀(재귀) 예측입니다.\n",
    "수치는 첫 칸의 예측값을, 카테고리는 가장 확률이 큰 종류의 원핫을 되먹입니다.\n",
    "입력만 하는 열은 AI가 예측하지 않으므로 표 안쪽은 실제 값을, 표가 끝난 뒤에는 마지막 값을 그대로 넣습니다.\n",
    "멀리 갈수록 오차가 쌓여 흐려지는 것을 점선으로 볼 수 있습니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "43a6557e",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\n",
    "# 모델 결과 확인 (그래프)\n",
    "# ==========================================\n",
    "plt.plot(history.history['loss'])\n",
    "legend = ['train']\n",
    "if 'val_loss' in history.history:\n",
    "    plt.plot(history.history['val_loss'])\n",
    "    legend.append('val')\n",
    "plt.title('model loss')\n",
    "plt.ylabel('loss')\n",
    "plt.xlabel('epoch')\n",
    "plt.legend(legend, loc='upper right')\n",
    "plt.show()\n",
    "\n",
    "\n",
    "def predict_heads(window):\n",
    "    \"\"\"창 하나 [WINDOW_IN, FEATURE_DIM] 를 넣어 헤드별 출력 목록으로\"\"\"\n",
    "    result = model.predict(np.asarray(window, dtype='float32')[None, :, :], verbose=0)\n",
    "    return [result] if single_head else list(result)\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 출력 열마다 성능 보기 (검증 창)\n",
    "# ==========================================\n",
    "if VALIDATION_SPLIT > 0:\n",
    "    X_check, Y_check = X[train_count:], [y[train_count:] for y in Y_heads]\n",
    "    print(f'검증 창 {n_windows - train_count}개 (가장 최근, 학습에 쓰지 않은 창)\\n')\n",
    "else:\n",
    "    X_check, Y_check = X, Y_heads\n",
    "    print(f'학습 창 {n_windows}개 (창이 20개 미만이라 검증 데이터가 없습니다)\\n')\n",
    "\n",
    "pred = model.predict(X_check, verbose=0)\n",
    "if single_head:\n",
    "    pred = [pred]\n",
    "for enc, p, y in zip(output_encoders, pred, Y_check):\n",
    "    if enc['kind'] == 'categorical':\n",
    "        accuracy = float(np.mean(np.argmax(p, axis=1) == np.argmax(y, axis=1)))\n",
    "        print(f\"[{enc['name']}] 분류 정확도: {accuracy * 100:.1f}%\")\n",
    "    else:\n",
    "        predicted = np.vectorize(lambda v: denormalize(float(v), enc))(p)\n",
    "        actual = np.vectorize(lambda v: denormalize(float(v), enc))(y)\n",
    "        print(f\"[{enc['name']}] 회귀 평균 오차(MAE): {np.mean(np.abs(predicted - actual)):.3f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "334f0fa1",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\n",
    "# 예측 시점과 이어서 예측 칸 수\n",
    "# ==========================================\n",
    "PREDICT_END = None    # None 이면 표의 끝. 시간 라벨(예: '2018-07')을 적으면 그 칸까지만 보고 다음을 예측한다\n",
    "ROLLOUT_STEPS = 6     # 이어서 예측할 칸 수 (0 이면 안 함)\n",
    "\n",
    "end = len(rows) if PREDICT_END is None else time_labels.index(str(PREDICT_END)) + 1\n",
    "if end < WINDOW_IN:\n",
    "    raise ValueError(f'예측 시점은 {WINDOW_IN}번째 행({time_labels[WINDOW_IN - 1]}) 뒤여야 합니다.')\n",
    "window = vectors[end - WINDOW_IN:end]\n",
    "\n",
    "\n",
    "def decode(head_values):\n",
    "    \"\"\"헤드 출력을 열마다의 예측으로: 수치는 원래 단위의 값들, 카테고리는 (종류, 확률) 목록\"\"\"\n",
    "    decoded = []\n",
    "    for enc, v in zip(output_encoders, head_values):\n",
    "        v = np.asarray(v).reshape(-1)\n",
    "        if enc['kind'] == 'numeric':\n",
    "            decoded.append([denormalize(float(x), enc) for x in v[:WINDOW_OUT]])\n",
    "        else:\n",
    "            decoded.append(list(zip(enc['categories'], [float(x) for x in v])))\n",
    "    return decoded\n",
    "\n",
    "\n",
    "def next_input_vector(head_values, t):\n",
    "    \"\"\"되먹임용 t번째 행 자리의 입력 (입력 열 순서)\n",
    "    예측하는 열: 수치는 첫 칸의 정규화 값 그대로, 카테고리는 가장 확률이 큰 종류의 원핫\n",
    "    입력만 하는 열: t 가 표 안쪽이면 그 행의 실제 값, 표가 끝났으면 마지막 행의 값\"\"\"\n",
    "    by_index = {enc['index']: np.asarray(v).reshape(-1) for enc, v in zip(output_encoders, head_values)}\n",
    "    known = vectors[min(t, len(vectors) - 1)]\n",
    "    vector, pos = [], 0\n",
    "    for enc in input_encoders:\n",
    "        width = 1 if enc['kind'] == 'numeric' else len(enc['categories'])\n",
    "        v = by_index.get(enc['index'])\n",
    "        if v is None:\n",
    "            vector.extend(float(x) for x in known[pos:pos + width])\n",
    "        elif enc['kind'] == 'numeric':\n",
    "            vector.append(float(v[0]))\n",
    "        else:\n",
    "            best = int(np.argmax(v))\n",
    "            vector.extend(1.0 if j == best else 0.0 for j in range(width))\n",
    "        pos += width\n",
    "    return vector\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 예측 시점에서 다음 예측\n",
    "# ==========================================\n",
    "print(f'{time_labels[end - 1]} 까지 {WINDOW_IN}칸을 보고 다음을 예측합니다.\\n')\n",
    "direct = decode(predict_heads(window))\n",
    "for enc, d in zip(output_encoders, direct):\n",
    "    if enc['kind'] == 'numeric':\n",
    "        print(f\"[{enc['name']}] \" + ', '.join(f'+{k + 1}: {v:.2f}' for k, v in enumerate(d)))\n",
    "    else:\n",
    "        label, prob = max(d, key=lambda item: item[1])\n",
    "        print(f\"[{enc['name']}] +1: {label} ({prob * 100:.1f}%)  \" + ', '.join(f'{c} {p * 100:.1f}%' for c, p in d))\n",
    "\n",
    "# ==========================================\n",
    "# 이어서 예측 (한 칸씩 되먹임)\n",
    "# ==========================================\n",
    "rollout = []\n",
    "if ROLLOUT_STEPS > 0:\n",
    "    win = window.copy()\n",
    "    for k in range(ROLLOUT_STEPS):\n",
    "        heads = predict_heads(win)\n",
    "        rollout.append(decode(heads))\n",
    "        # 방금 예측한 칸(end + k 번째 행 자리)을 창 끝에 붙인다\n",
    "        win = np.vstack([win[1:], np.array(next_input_vector(heads, end + k), dtype='float32')[None, :]])\n",
    "    print(f'\\n이어서 예측 {ROLLOUT_STEPS}칸:')\n",
    "    for enc_i, enc in enumerate(output_encoders):\n",
    "        if enc['kind'] == 'numeric':\n",
    "            print(f\"[{enc['name']}] \" + ', '.join(f'+{k + 1}: {step[enc_i][0]:.2f}' for k, step in enumerate(rollout)))\n",
    "        else:\n",
    "            print(f\"[{enc['name']}] \" + ' → '.join(f'+{k + 1} {max(step[enc_i], key=lambda item: item[1])[0]}' for k, step in enumerate(rollout)))\n",
    "    # 입력만 하는 열을 마지막 값으로 두기 시작한 칸 (0부터). 그 칸의 예측부터 가정이 들어간다\n",
    "    held_from = len(rows) - end + 1\n",
    "    if held_inputs and held_from < len(rollout):\n",
    "        print(f'(입력만 하는 열 {held_inputs} 은(는) +{held_from + 1}칸부터 마지막 값을 그대로 둔 가정입니다)')\n",
    "\n",
    "# ==========================================\n",
    "# 그래프: 수치 출력 열마다 실제값, 예측, 이어서 예측\n",
    "# ==========================================\n",
    "numeric_outputs = [(i, enc) for i, enc in enumerate(output_encoders) if enc['kind'] == 'numeric']\n",
    "if numeric_outputs:\n",
    "    fig, axes = plt.subplots(len(numeric_outputs), 1, figsize=(14, 4 * len(numeric_outputs)), squeeze=False)\n",
    "    for ax, (i, enc) in zip(axes[:, 0], numeric_outputs):\n",
    "        actual = [parse_number(row[enc['index']]) for row in rows]\n",
    "        ax.plot(range(len(rows)), actual, label='actual', color='#0ea5e9', marker='.', linewidth=1.5)\n",
    "        ax.plot(range(end - 1, end + WINDOW_OUT), [actual[end - 1]] + direct[i], label='predicted', color='#f97316', marker='D', linewidth=2)\n",
    "        if rollout:\n",
    "            ax.plot(range(end - 1, end + len(rollout)), [actual[end - 1]] + [step[i][0] for step in rollout],\n",
    "                    label='rollout', color='#8b5cf6', linestyle='--', marker='o', linewidth=1.5)\n",
    "        ax.axvline(x=end - 1, color='gray', linestyle=':')\n",
    "        ax.set_title(enc['name'])\n",
    "        ax.legend()\n",
    "        ax.grid(True, alpha=0.3)\n",
    "    plt.tight_layout()\n",
    "    plt.show()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
