{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "8ced2671",
   "metadata": {},
   "source": [
    "# OneClickAI 표 통합모델\n",
    "이 노트북은 Google Colab 환경에서 실행하도록 작성되었습니다.\n",
    "웹앱의 **표 통합모델**과 같은 방법으로 학습합니다.\n",
    "\n",
    "시트 하나에서 열마다 입력과 출력을 고르고, 출력 열마다 **회귀**(수치)나 **분류**(카테고리)를 정해 한 번에 학습합니다.\n",
    "카테고리 입력은 원핫으로, 수치 입력은 정규화해서 넣고, 모델은 웹앱과 같은 **MLP**(은닉층 여러 개 + 출력 열마다 헤드)입니다.\n",
    "\n",
    "**순서**\n",
    "1. 파일 업로드\n",
    "2. 열 역할과 종류 정하기\n",
    "3. 표를 숫자로 바꾸기 (원핫과 정규화)\n",
    "4. 모델 학습 (MLP + 출력 열마다 헤드)\n",
    "5. 결과 확인 및 예측"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "de449309",
   "metadata": {},
   "source": [
    "## 1. 파일 업로드\n",
    "\n",
    "엑셀 파일(`.xlsx`)을 업로드해 주세요. 두 가지 모양을 읽습니다.\n",
    "\n",
    "- **시트 하나**: 웹앱의 `데이터 저장(.xlsx)` 버튼으로 받은 파일입니다. 첫 행은 열 이름, 첫 열은 항목(이름)입니다.\n",
    "- **원인/결과 두 시트**: 표 분류, 표 회귀 페이지의 엑셀입니다. 두 시트를 행 순서대로 옆으로 붙이고, 결과 시트에서 온 열을 출력으로 둡니다.\n",
    "  결과 시트가 0/1 열 여러 개(원핫)면 웹앱처럼 열마다 따로 분류합니다 (승리, 무승부, 패배를 각각 0/1 로 맞힙니다).\n",
    "\n",
    "웹앱은 행을 999개, 열을 20개까지 쓰지만 이 노트북에는 제한이 없습니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1d3cff44",
   "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 '–—−':\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 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_output_count = 0   # 원인/결과를 붙였을 때 결과 시트에서 온 열 개수\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",
    "    merged_output_count = len(result[0]) - 1\n",
    "    print(\"'원인' 시트와 '결과' 시트를 행 순서대로 붙였습니다. 결과 시트에서 온 열은 출력입니다.\")\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",
    "print(f'\\n행 {len(rows)}개, 열 {len(cols)}개: {column_names}')\n",
    "display(pd.DataFrame(rows[:5], columns=column_names))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9e568069",
   "metadata": {},
   "source": [
    "## 2. 열 역할과 종류 정하기\n",
    "\n",
    "열마다 **역할**(입력/출력/제외)과 **종류**(카테고리/수치)를 정합니다.\n",
    "아래 설정을 비워 두면 웹앱과 같은 규칙으로 자동으로 정합니다.\n",
    "\n",
    "- 마지막 열은 **출력**, 나머지 열은 **입력** (원인/결과 파일은 결과 시트에서 온 열이 출력)\n",
    "- 숫자가 아닌 열은 **카테고리**, 숫자 열은 정수이면서 종류가 5개 이하면 **카테고리**, 아니면 **수치**\n",
    "- 입력 열은 카테고리면 원핫, 수치면 정규화해서 넣습니다\n",
    "- 출력 열은 카테고리면 **분류**(Softmax), 수치면 **회귀**(Linear)로 배웁니다. 출력이 여러 개면 헤드가 여러 개인 모델 하나로 한꺼번에 배웁니다\n",
    "\n",
    "웹앱에서 받은 파일에는 열 설정이 들어 있지 않습니다. 웹앱에서 바꾼 설정(예: 1~5 점수를 회귀로)은 아래 칸에 다시 적어 주세요."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3298d6fc",
   "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': '출력', 'exclude': '제외'}\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",
    "first_output = len(cols) - merged_output_count if merged_output_count else len(cols) - 1\n",
    "for s in stats:\n",
    "    s['kind'] = s['suggested']\n",
    "    s['role'] = 'output' if s['index'] >= first_output 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:\n",
    "    for s in stats:\n",
    "        if s['name'] in INPUT_COLUMNS:\n",
    "            s['role'] = 'input'\n",
    "        elif s['role'] == 'input':\n",
    "            s['role'] = 'exclude'\n",
    "if OUTPUT_COLUMNS:\n",
    "    for s in stats:\n",
    "        if s['name'] in OUTPUT_COLUMNS:\n",
    "            s['role'] = 'output'\n",
    "        elif s['role'] == 'output':\n",
    "            s['role'] = '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",
    "# 검사 (웹앱과 같은 조건)\n",
    "# ==========================================\n",
    "input_specs = [s for s in stats if s['role'] == 'input']\n",
    "output_specs = [s for s in stats if s['role'] == 'output']\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",
    "\n",
    "def learning_of(s):\n",
    "    if s['role'] == 'input':\n",
    "        return '원핫 입력' if s['kind'] == 'categorical' else '정규화 입력'\n",
    "    if s['role'] == 'output':\n",
    "        return '분류 (Softmax)' if s['kind'] == 'categorical' else '회귀 (Linear)'\n",
    "    return '-'\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]))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "93f5e5bd",
   "metadata": {},
   "source": [
    "## 3. 표를 숫자로 바꾸기 (원핫과 정규화)\n",
    "\n",
    "- 카테고리 열은 **원핫**(종류마다 한 칸, 해당 종류만 1)으로, 수치 열은 설정한 방식으로 **정규화**(기본 -1~1, 표 전체의 최솟값과 최댓값)합니다.\n",
    "  회귀 출력은 예측할 때 원래 단위로 되돌립니다.\n",
    "- 입력 벡터는 입력 열의 벡터를 순서대로 이어 붙인 것이고, 출력은 헤드마다 따로 둡니다 (분류는 원핫, 회귀는 값 하나).\n",
    "- 행 순서는 웹앱과 같은 시드(13)로 섞고, 뒤쪽 20%를 검증에 씁니다. 20행 미만이면 검증 없이 모두 학습합니다.\n",
    "  케라스의 `validation_split`도 뒤쪽 행을 그대로 떼므로 웹앱과 같은 행이 검증이 됩니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "afca58e2",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\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 shuffle_order(n, seed=13):\n",
    "    \"\"\"웹앱과 같은 시드 고정 셔플 (mulberry32 + Fisher-Yates). 32비트 정수 연산을 그대로 옮겼다\"\"\"\n",
    "    order = list(range(n))\n",
    "    a = seed & 0xFFFFFFFF\n",
    "\n",
    "    def rand():\n",
    "        nonlocal a\n",
    "        a = (a + 0x6D2B79F5) & 0xFFFFFFFF\n",
    "        t = ((a ^ (a >> 15)) * (a | 1)) & 0xFFFFFFFF\n",
    "        t ^= (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF)) & 0xFFFFFFFF\n",
    "        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296\n",
    "\n",
    "    for i in range(n - 1, 0, -1):\n",
    "        j = int(rand() * (i + 1))\n",
    "        order[i], order[j] = order[j], order[i]\n",
    "    return order\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",
    "INPUT_DIM = sum(encoder_dim(e) for e in input_encoders)\n",
    "OUTPUT_DIM = sum(encoder_dim(e) for e in output_encoders)\n",
    "\n",
    "\n",
    "def encode_row(encoders, row, r):\n",
    "    vector = []\n",
    "    for enc in 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",
    "n_rows = len(rows)\n",
    "X = np.array([encode_row(input_encoders, row, r) for r, row in enumerate(rows)], dtype='float32')   # [행, INPUT_DIM]\n",
    "Y_heads = [np.array([encode_row([enc], row, r) for r, row in enumerate(rows)], dtype='float32') for enc in output_encoders]\n",
    "\n",
    "# 웹앱과 같은 순서로 섞는다. 케라스의 validation_split 은 뒤쪽 행을 그대로 떼어 검증에 쓴다\n",
    "order = shuffle_order(n_rows)\n",
    "X = X[order]\n",
    "Y_heads = [y[order] for y in Y_heads]\n",
    "VALIDATION_SPLIT = 0.2 if n_rows >= 20 else 0.0\n",
    "train_count = math.floor(n_rows * (1 - VALIDATION_SPLIT))\n",
    "\n",
    "for enc in input_encoders:\n",
    "    if enc['kind'] == 'categorical':\n",
    "        print(f\"[입력 {enc['name']}] 원핫 {len(enc['categories'])}종류: {enc['categories'][:6]}{' ...' if len(enc['categories']) > 6 else ''}\")\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']}] 회귀, 값 {enc['min']:g} ~ {enc['max']:g}, 정규화 {enc['normalization']}\")\n",
    "print(f'\\n입력 {INPUT_DIM}차원, 출력 {OUTPUT_DIM}차원, 학습 {train_count}행, 검증 {n_rows - train_count}행')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f415e940",
   "metadata": {},
   "source": [
    "## 4. 모델 학습 (MLP + 출력 열마다 헤드)\n",
    "\n",
    "웹앱과 같은 구조입니다.\n",
    "\n",
    "- 입력 `INPUT_DIM`차원 → Dense(노드 수, ReLU) `NUM_LAYERS`층 → 출력 열마다 헤드 하나.\n",
    "  분류는 Softmax + categorical crossentropy, 회귀는 Linear + MSE이고, 전체 loss는 헤드 loss의 합입니다.\n",
    "- 은닉 노드 하나는 `h = ReLU(w1 x1 + w2 x2 + ... + b)` 입니다. 파라미터는 층마다 `(앞 층 차원 + 1) × 노드 수`, 헤드는 `(노드 수 + 1) × 출력 차원`입니다.\n",
    "- 노드 수 자동은 `(입력 차원 + 출력 차원) × 2`를 32~128 사이로 맞춘 값입니다 (웹앱과 같다).\n",
    "- Adam, 학습률 0.001, 배치 16, 100번 학습, 은닉층 3층, 조기 종료는 끕니다 (웹앱 기본값).\n",
    "  조기 종료를 켜면 검증 loss가 `PATIENCE`번 나아지지 않을 때 멈춥니다. 20행 미만이면 검증 데이터가 없어 끝까지 돕니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7e40e094",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\n",
    "# 학습 설정 (웹앱 톱니바퀴 기본값)\n",
    "# ==========================================\n",
    "NUM_LAYERS = 3             # 은닉층 수 (선택지 1~4)\n",
    "NUM_NODES = 0              # 노드 수 (0 = 자동. 선택지 8, 16, 32, 64, 128, 256, 512)\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(input_dim, output_dim):\n",
    "    return min(max((input_dim + output_dim) * 2, 32), 128)\n",
    "\n",
    "\n",
    "units = auto_nodes(INPUT_DIM, OUTPUT_DIM) if NUM_NODES == 0 else NUM_NODES\n",
    "\n",
    "# ==========================================\n",
    "# 모델: 은닉층들 + 출력 열마다 헤드 (웹앱의 buildUnifiedModel 과 같다)\n",
    "# ==========================================\n",
    "inputs = keras.Input(shape=(INPUT_DIM,), name='features')\n",
    "x = inputs\n",
    "for i in range(NUM_LAYERS):\n",
    "    x = keras.layers.Dense(units, activation='relu', name=f'hidden_{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(encoder_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, INPUT_DIM\n",
    "for i in range(NUM_LAYERS):\n",
    "    expected += (prev + 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",
    "# 학습\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('table_unified_model.keras')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7f4e6028",
   "metadata": {},
   "source": [
    "## 5. 결과 확인 및 예측\n",
    "\n",
    "학습 손실 그래프를 그리고, 검증 데이터로 출력 열마다 정확도(분류)나 평균 오차(회귀)를 봅니다.\n",
    "마지막 칸에서 직접 값을 넣어 예측해 보세요. 카테고리 열은 표에 있던 종류 중 하나를, 수치 열은 숫자를 적습니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f2328152",
   "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",
    "# 출력 열마다 성능 보기\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_rows - train_count}행 (학습에 쓰지 않은 행)\\n')\n",
    "else:\n",
    "    X_check, Y_check = X, Y_heads\n",
    "    print(f'학습 데이터 {n_rows}행 (행이 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.array([denormalize(float(v), enc) for v in p[:, 0]])\n",
    "        actual = np.array([denormalize(float(v), enc) for v in y[:, 0]])\n",
    "        print(f\"[{enc['name']}] 회귀 평균 오차(MAE): {np.mean(np.abs(predicted - actual)):.3f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "61b16bb1",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ==========================================\n",
    "# 직접 값을 넣어 예측하기\n",
    "# ==========================================\n",
    "def predict(values):\n",
    "    \"\"\"values: {입력 열 이름: 값}. 웹앱 예측 화면처럼 출력 열마다 결과를 보여 준다\"\"\"\n",
    "    vector = []\n",
    "    for enc in input_encoders:\n",
    "        raw = values.get(enc['name'], '')\n",
    "        part = encode_value(enc, raw)\n",
    "        if part is None:\n",
    "            if enc['kind'] == 'categorical':\n",
    "                raise ValueError(f\"'{enc['name']}' 값 '{raw}' 은(는) 표에 없던 종류입니다. 다음 중 하나를 적어 주세요: {enc['categories']}\")\n",
    "            raise ValueError(f\"'{enc['name']}' 에는 숫자를 적어 주세요. (지금 값: {raw!r})\")\n",
    "        vector.extend(part)\n",
    "        print(f\"[{enc['name']}] {to_cell_string(raw)}\")\n",
    "\n",
    "    result = model.predict(np.array([vector], dtype='float32'), verbose=0)\n",
    "    if single_head:\n",
    "        result = [result]\n",
    "    print()\n",
    "    for enc, p in zip(output_encoders, result):\n",
    "        p = p[0]\n",
    "        if enc['kind'] == 'categorical':\n",
    "            ranked = sorted(zip(enc['categories'], p), key=lambda item: -item[1])\n",
    "            print(f\"[{enc['name']}] \" + ', '.join(f'{label} {prob * 100:.1f}%' for label, prob in ranked))\n",
    "        else:\n",
    "            print(f\"[{enc['name']}] {denormalize(float(p[0]), enc):.2f}\")\n",
    "\n",
    "\n",
    "# 기본값은 웹앱 예측 화면과 같다: 카테고리는 첫 종류, 수치는 평균. 원하는 값으로 바꿔 보세요.\n",
    "my_values = {\n",
    "    enc['name']: (enc['categories'][0] if enc['kind'] == 'categorical' else round(enc['mean'], 2))\n",
    "    for enc in input_encoders\n",
    "}\n",
    "# my_values = {'요일': '토', '날씨': '맑음', '기온': 28.5, '할인': 1}\n",
    "predict(my_values)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
