← Files tochi-satei-kunARCHIVED FILE

skills/tochi-satei-kun/scripts/load_mlit.py

15.9 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

# Copyright 2026 Koichi Matsuda / SignalYield Advisory
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""MLIT 取引価格情報 CSV / 公示地価 CSV(または GeoJSON) / 基準地価 CSV の読込・正規化。
実MLIT公式CSV(cp932エンコーディング、全角コロン・全角括弧)に対応。
"""
import json
import math
import re
from datetime import date
from pathlib import Path
import pandas as pd

# 全角→半角の列名正規化(cp932 CSV対応)
COLUMN_NORMALIZE = {
    "最寄駅:名称": "最寄駅:名称",
    "最寄駅:距離(分)": "最寄駅:距離(分)",
    "取引価格(総額)": "取引価格(総額)",
    "面積(㎡)": "面積(㎡)",
    "取引価格(㎡単価)": "取引価格(㎡単価)",
    "前面道路:方位": "前面道路:方位",
    "前面道路:種類": "前面道路:種類",
    "前面道路:幅員(m)": "前面道路:幅員(m)",
    "前面道路:幅員(m)": "前面道路:幅員(m)",
    "建ぺい率(%)": "建ぺい率(%)",
    "容積率(%)": "容積率(%)",
    "取引時期": "取引時点",  # 実データは「取引時期」、内部は「取引時点」に統一
}

# MLIT列名 → 内部標準列名
MLIT_COLUMN_MAP = {
    "種類": "type",
    "価格情報区分": "price_info_type",
    "地域": "region",
    "市区町村コード": "city_code",
    "都道府県名": "prefecture",
    "市区町村名": "city",
    "地区名": "district",
    "最寄駅:名称": "station",
    "最寄駅:距離(分)": "walk_min",
    "取引価格(総額)": "total_price",
    "面積(㎡)": "area",
    "取引価格(㎡単価)": "unit_price",
    "土地の形状": "shape_raw",
    "間口": "kanguchi",
    "前面道路:方位": "road_dir",
    "前面道路:種類": "road_type_raw",
    "前面道路:幅員(m)": "road_width",
    "都市計画": "zoning",
    "建ぺい率(%)": "building_coverage",
    "容積率(%)": "floor_area_ratio",
    "取引時点": "transaction_quarter_str",
    "今後の利用目的": "use",
}

# 形状値の正規化(実MLITの値 → 内部3カテゴリ)
SHAPE_NORMALIZE = {
    "整形": "整形", "ほぼ整形": "整形",
    "ほぼ正方形": "整形", "正方形": "整形",
    "ほぼ長方形": "整形", "長方形": "整形",
    "不整形": "不整形",
    "ほぼ台形": "不整形", "台形": "不整形",
    "袋地等": "袋地", "袋地": "袋地",
    "旗竿状": "袋地", "旗竿地": "袋地",
}

# 道路種類の正規化(実MLITの値 → 公道/私道)
# 注:MLITの「道路」(曖昧値)は除外する。明示的な公道分類のみ採用。
ROAD_NORMALIZE = {
    "区道": "公道", "都道": "公道", "国道": "公道",
    "市道": "公道", "町道": "公道", "村道": "公道",
    "県道": "公道", "府道": "公道",
    "私道": "私道",
}
# MLITで明示分類されない値は除外対象("道路"などの曖昧値)
ROAD_VALID_VALUES = set(ROAD_NORMALIZE.keys())


def _parse_quarter(s: str) -> date:
    """'2024年第3四半期' → date(2024, 7, 1)"""
    if not isinstance(s, str) or "年第" not in s:
        return None
    try:
        year_str, rest = s.split("年第")
        q = int(rest.replace("四半期", ""))
        month = {1: 1, 2: 4, 3: 7, 4: 10}[q]
        return date(int(year_str), month, 1)
    except (ValueError, KeyError):
        return None


def _to_num(v):
    if pd.isna(v) or v == "":
        return None
    try:
        return float(v)
    except (ValueError, TypeError):
        return None


def _read_csv_auto_encoding(path):
    """utf-8-sig → cp932 → shift_jis の順に自動判定して読み込む。"""
    last_err = None
    for enc in ["utf-8-sig", "cp932", "shift_jis"]:
        try:
            return pd.read_csv(path, encoding=enc)
        except (UnicodeDecodeError, UnicodeError) as e:
            last_err = e
            continue
    raise UnicodeError(f"CSV のエンコーディング判定失敗: {path} ({last_err})")


def load_mlit_csv(path) -> pd.DataFrame:
    """MLIT 取引価格情報 CSV を読み込み、内部標準列名の DataFrame を返す。
    宅地(土地)のみ採用し、建物込み取引は除外。単価欠損行は土地のみ行で安全に補完。
    実MLIT (cp932, 全角コロン・全角括弧) も自動対応。
    各行に CSV原本での行番号 (case_no = 1始まり) を付与し、比準表で識別子として使う。
    """
    df = _read_csv_auto_encoding(path)
    # CSV原本の行番号を事例識別子として保持(フィルタ後も維持される)
    df["case_no"] = df.index + 1
    # 列名を全角→半角に正規化
    df = df.rename(columns=COLUMN_NORMALIZE)
    # 内部標準列名へマッピング
    df = df.rename(columns=MLIT_COLUMN_MAP)
    # 必須列存在チェック
    required = ["type", "city", "area", "unit_price"]
    missing = [c for c in required if c not in df.columns]
    if missing:
        raise ValueError(f"MLIT CSV 必須列欠損: {missing}")
    # 土地のみ。建物込み総額を土地単価として混入させない。
    df = df[df["type"] == "宅地(土地)"].copy()
    # 価格情報区分(実MLITにあれば「不動産取引価格情報」のみ採用)
    if "price_info_type" in df.columns:
        df = df[df["price_info_type"] == "不動産取引価格情報"].copy()
    # 数値変換
    for col in ["walk_min", "total_price", "area", "unit_price",
                "kanguchi", "road_width", "building_coverage", "floor_area_ratio"]:
        if col in df.columns:
            df[col] = df[col].apply(_to_num)
    # 単価派生(宅地(土地)として確認済みの行のみ)
    if "total_price" in df.columns and "area" in df.columns:
        missing_unit = df["unit_price"].isna()
        safe_area = df["area"].notna() & (df["area"] > 0)
        safe_total = df["total_price"].notna() & (df["total_price"] > 0)
        df.loc[missing_unit & safe_area & safe_total, "unit_price"] = (
            df.loc[missing_unit & safe_area & safe_total, "total_price"]
            / df.loc[missing_unit & safe_area & safe_total, "area"]
        )
    df = df[df["unit_price"].notna() & (df["unit_price"] > 0)]
    df = df[df["area"].notna() & (df["area"] > 0)]
    # 形状の正規化
    if "shape_raw" in df.columns:
        df["shape"] = df["shape_raw"].map(SHAPE_NORMALIZE).fillna("不整形")
    elif "shape" not in df.columns:
        df["shape"] = "整形"
    # 道路種類の正規化(曖昧値"道路"は除外、明示分類のみ採用)
    if "road_type_raw" in df.columns:
        df = df[df["road_type_raw"].isin(ROAD_VALID_VALUES)].copy()
        df["road_type"] = df["road_type_raw"].map(ROAD_NORMALIZE)
    elif "road_type" not in df.columns:
        df["road_type"] = "公道"
    # 間口 ≤ 2m の事例を除外(建築基準法第43条・接道義務未達)
    if "kanguchi" in df.columns:
        kang_num = pd.to_numeric(df["kanguchi"], errors="coerce")
        # 欠損は許容、2m以下の数値のみ除外
        df = df[~((kang_num.notna()) & (kang_num <= 2.0))].copy()
    # 取引時点を date 化
    df["transaction_date"] = df["transaction_quarter_str"].apply(_parse_quarter)
    df = df[df["transaction_date"].notna()]
    # 派生列:log(unit_price), log(area)
    df["ln_unit_price"] = df["unit_price"].apply(math.log)
    df["ln_area"] = df["area"].apply(math.log)
    return df.reset_index(drop=True)


# 公示地価GeoJSON(国土数値情報 L01)の市区町村名短縮形 → 完全形マッピング
KOJI_CITY_NORMALIZE = {
    # 東京23区
    "千代田": "千代田区", "中央": "中央区", "港": "港区",
    "新宿": "新宿区", "文京": "文京区", "台東": "台東区",
    "墨田": "墨田区", "江東": "江東区", "品川": "品川区",
    "目黒": "目黒区", "大田": "大田区", "世田谷": "世田谷区",
    "渋谷": "渋谷区", "中野": "中野区", "杉並": "杉並区",
    "豊島": "豊島区", "北": "北区", "荒川": "荒川区",
    "板橋": "板橋区", "練馬": "練馬区", "足立": "足立区",
    "葛飾": "葛飾区", "江戸川": "江戸川区",
    # 主要市
    "八王子": "八王子市", "立川": "立川市", "武蔵野": "武蔵野市",
    "三鷹": "三鷹市", "青梅": "青梅市", "府中": "府中市",
    "昭島": "昭島市", "調布": "調布市", "町田": "町田市",
    "小金井": "小金井市", "小平": "小平市", "日野": "日野市",
    "東村山": "東村山市", "国分寺": "国分寺市", "国立": "国立市",
    "福生": "福生市", "狛江": "狛江市", "東大和": "東大和市",
    "清瀬": "清瀬市", "東久留米": "東久留米市", "武蔵村山": "武蔵村山市",
    "多摩": "多摩市", "稲城": "稲城市", "羽村": "羽村市",
    "あきる野": "あきる野市", "西東京": "西東京市",
}


def _normalize_koji_city(short_name: str) -> str:
    """公示地価GeoJSONの短縮形("世田谷")を MLIT 形式("世田谷区")に正規化。"""
    if not short_name:
        return ""
    if short_name in KOJI_CITY_NORMALIZE:
        return KOJI_CITY_NORMALIZE[short_name]
    # マッピング外は「○○区」をデフォルトで付与(東京23区想定)
    if short_name.endswith(("区", "市", "町", "村")):
        return short_name
    return short_name + "区"


def _extract_district_from_addr(addr: str, city: str) -> str:
    """住所文字列から町丁目部分を抽出。
    例: "東京都 世田谷区桜上水5丁目480番12" → "桜上水"
    """
    if not addr:
        return ""
    s = str(addr).replace(" ", "").replace("東京都", "").strip()
    s = s.replace(city, "", 1)
    # 漢数字・数字以降を除去(「桜上水5丁目...」→ 「桜上水」)
    m = re.match(r"([^0-90-9一二三四五六七八九十]+)", s)
    return m.group(1).strip() if m else s


def load_koji_geojson(path, recent_years: int = 5) -> pd.DataFrame:
    """国土数値情報 L01(地価公示)GeoJSON を読み込み、過去 recent_years 年分の年次価格を
    展開した DataFrame を返す。既存 load_koji_csv と同形式。

    年マッピング: L01_007=最新年、L01_105=同年価格、L01_104=前年、L01_103=前々年、...
    """
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
    rows = []
    for feat in data.get("features", []):
        p = feat.get("properties", {})
        city_short = str(p.get("L01_024", "") or "")
        if not city_short:
            continue
        city = _normalize_koji_city(city_short)
        addr = str(p.get("L01_025", "") or "")
        district = _extract_district_from_addr(addr, city)
        try:
            ref_year = int(p.get("L01_007"))
        except (TypeError, ValueError):
            continue
        std_id = "-".join(str(p.get(f"L01_{i:03d}", "")) for i in (1, 2, 3))
        # 公示地価地点の追加属性(概要表示用)
        def _safe(field, default=""):
            v = p.get(field)
            if v in (None, "", "_"):
                return default
            return v
        attrs = {
            "address": str(_safe("L01_025", "")),
            "area_sqm": _safe("L01_027", None),  # 地積
            "use_detail": str(_safe("L01_029", "")),  # 利用区分
            "frontage_ratio": _safe("L01_036", None),  # 間口比率
            "depth_ratio": _safe("L01_037", None),  # 奥行比率
            "road_type": str(_safe("L01_040", "")),  # 前面道路区分
            "road_dir": str(_safe("L01_041", "")),  # 前面道路方位
            "road_width": _safe("L01_042", None),  # 前面道路幅員
            "station": str(_safe("L01_048", "")),  # 最寄駅名
            "station_dist_m": _safe("L01_050", None),  # 駅距離(m)
            "zoning": str(_safe("L01_051", "")),  # 用途地域
            "building_coverage": _safe("L01_057", None),  # 建ぺい率
            "floor_area_ratio": _safe("L01_058", None),  # 容積率
        }
        # L01_105 が ref_year 価格、L01_104 が ref_year-1, ...
        for i in range(recent_years):
            field = f"L01_{105-i:03d}"
            year = ref_year - i
            v = p.get(field)
            try:
                price = float(v) if v not in (None, "", "_") else None
            except (TypeError, ValueError):
                price = None
            if price and price > 0:
                row = {
                    "標準地番号": std_id,
                    "prefecture": "東京都",
                    "city": city,
                    "district": district,
                    "price_date": date(year, 1, 1),
                    "price_per_sqm": price,
                    "use": p.get("L01_028", ""),
                }
                row.update(attrs)
                rows.append(row)
    return pd.DataFrame(rows).reset_index(drop=True)


def load_koji_auto(path) -> pd.DataFrame:
    """ファイル拡張子で CSV/GeoJSON を自動判定して読み込む。"""
    p = str(path).lower()
    if p.endswith(".geojson") or p.endswith(".json"):
        return load_koji_geojson(path)
    return load_koji_csv(path)


def load_kijun_auto(path) -> pd.DataFrame:
    """基準地価ファイルの自動判定(GeoJSON/CSV)。Noneや空文字列の場合は空DataFrameを返す。"""
    if path is None or path == "" or not Path(path).exists():
        return pd.DataFrame(columns=["標準地番号", "prefecture", "city",
                                     "district", "price_date", "price_per_sqm"])
    p = str(path).lower()
    if p.endswith(".geojson") or p.endswith(".json"):
        return load_koji_geojson(path)  # 同じL01スキーマと仮定
    return load_kijun_csv(path)


def load_koji_csv(path) -> pd.DataFrame:
    """公示地価 CSV を読み込む。"""
    df = pd.read_csv(path, encoding="utf-8-sig")
    rename = {
        "都道府県名": "prefecture", "市区町村名": "city", "地区名": "district",
        "価格時点": "price_date", "価格(円/㎡)": "price_per_sqm",
    }
    df = df.rename(columns=rename)
    df["price_date"] = pd.to_datetime(df["price_date"]).dt.date
    df["price_per_sqm"] = df["price_per_sqm"].apply(_to_num)
    return df.dropna(subset=["price_per_sqm"]).reset_index(drop=True)


def load_kijun_csv(path) -> pd.DataFrame:
    """基準地価 CSV を読み込む。公示地価とほぼ同形式。"""
    df = pd.read_csv(path, encoding="utf-8-sig")
    rename = {
        "都道府県名": "prefecture", "市区町村名": "city", "地区名": "district",
        "価格時点": "price_date", "価格(円/㎡)": "price_per_sqm",
    }
    df = df.rename(columns=rename)
    df["price_date"] = pd.to_datetime(df["price_date"]).dt.date
    df["price_per_sqm"] = df["price_per_sqm"].apply(_to_num)
    return df.dropna(subset=["price_per_sqm"]).reset_index(drop=True)


if __name__ == "__main__":
    # 単独実行で動作確認
    here = Path(__file__).parent.parent / "samples"
    mlit = load_mlit_csv(here / "sample_mlit.csv")
    koji = load_koji_csv(here / "sample_koji.csv")
    kijun = load_kijun_csv(here / "sample_kijun.csv")
    print(f"MLIT: {len(mlit)} rows, columns={list(mlit.columns)}")
    print(mlit.head(3))
    print(f"KOJI: {len(koji)} rows")
    print(koji.head(3))
    print(f"KIJUN: {len(kijun)} rows")

SHA-256: 7bd09b74b83c47b20280df4c577d4613af3ded82e6b89edbfb1f4f72826435a5