← Files tochi-satei-kunARCHIVED FILE
skills/tochi-satei-kun/scripts/time_adjust.py
7.7 KB · Oct 2, 2026 · 00:30 UTC
# 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.
"""時点修正:公示地価の **直近1年変動率** で、各事例の単価を査定時点に補正。
確定方針(2026-05-10 松田レビュー反映):
- 標準地は **査定地と同地区を優先**(地区一致がなければ市区町村平均)
- 変動率は **直近1年(前年→当年)** を採用(CAGR ではない単純1年変動率)
- 複数標準地がある場合は変動率を単純平均
- **隣接市区町村への自動拡張は行わない**(市区町村単位を厳守、標準地なし時は rate=None で返す)
"""
from datetime import date
import math
import pandas as pd
def _annual_rate_per_standard_point(group: pd.DataFrame, asof: date = None) -> float:
"""単一標準地の **直近1年変動率** を返す。
asof 以前で最新の2点(通常 前年1月1日 と 当年1月1日)から (P_t / P_{t-1})^(1/年差) - 1。
"""
g = group.sort_values("price_date")
if len(g) < 2:
return None
if asof is not None:
sub = g[g["price_date"] <= asof]
if len(sub) < 2:
return None
else:
sub = g
last2 = sub.tail(2)
p_first = float(last2.iloc[0]["price_per_sqm"])
p_last = float(last2.iloc[1]["price_per_sqm"])
if p_first <= 0:
return None
days = (last2.iloc[1]["price_date"] - last2.iloc[0]["price_date"]).days
if days <= 0:
return None
years = days / 365.25
ratio = p_last / p_first
if ratio <= 0:
return None
return ratio ** (1.0 / years) - 1.0
def _annual_rate_info_per_standard_point(group: pd.DataFrame, asof: date = None) -> dict:
"""Return rate and the exact two evidence points used for that rate."""
g = group.sort_values("price_date")
if asof is not None:
g = g[g["price_date"] <= asof]
if len(g) < 2:
return {"rate": None, "skip_reason": "asof以前の標準地価格が2点未満"}
last2 = g.tail(2)
rate = _annual_rate_per_standard_point(last2, None)
if rate is None:
return {"rate": None, "skip_reason": "標準地価格の変動率を計算できません"}
return {
"rate": rate,
"p_prev": float(last2.iloc[0]["price_per_sqm"]),
"p_curr": float(last2.iloc[1]["price_per_sqm"]),
"date_prev": last2.iloc[0]["price_date"],
"date_curr": last2.iloc[1]["price_date"],
"skip_reason": None,
}
def _collect_rates(sub: pd.DataFrame, src_name: str, asof: date) -> list:
"""DataFrame から標準地ごとの1年変動率を集める。"""
out = []
if "標準地番号" not in sub.columns:
return out
for std_id, group in sub.groupby("標準地番号"):
info = _annual_rate_info_per_standard_point(group, asof)
if info["rate"] is not None:
out.append({
"id": std_id,
"source": src_name,
"district": group.iloc[0].get("district", ""),
"rate": info["rate"],
"p_prev": info["p_prev"],
"p_curr": info["p_curr"],
"date_prev": info["date_prev"],
"date_curr": info["date_curr"],
})
return out
def annual_rate_for_city(koji: pd.DataFrame, kijun: pd.DataFrame, city: str,
target_district: str = None, asof: date = None) -> dict:
"""対象地区の標準地から **直近1年変動率の平均** を返す。地区一致優先。
Returns:
{
"rate": float,
"n_points": int,
"source": "公示" or "公示+基準地",
"method": "district_match" | "city_average" | "none",
"expanded_to": list, # 後方互換のため空配列を保持(隣接拡張は廃止)
"selected_points": [{"id", "source", "district", "rate", "p_prev", "p_curr",
"date_prev", "date_curr"}],
}
"""
log = {"rate": None, "n_points": 0, "source": "none",
"method": "none", "expanded_to": [], "selected_points": []}
sources = []
if koji is not None and not koji.empty:
c_sub = koji[koji["city"] == city]
if not c_sub.empty:
sources.append(("公示", c_sub))
if kijun is not None and not kijun.empty:
k_sub = kijun[kijun["city"] == city]
if not k_sub.empty:
sources.append(("基準地", k_sub))
if not sources:
# 標準地なし:時点修正なしで返す(隣接拡張は行わない)
return log
selected = []
method = "city_average"
# ① 地区一致優先
if target_district:
for src_name, sub in sources:
district_match = sub[sub["district"] == target_district]
if not district_match.empty:
selected.extend(_collect_rates(district_match, src_name, asof))
if selected:
method = "district_match"
# ② 地区一致がなければ市区町村全体
if not selected:
for src_name, sub in sources:
selected.extend(_collect_rates(sub, src_name, asof))
if selected:
rates_only = [p["rate"] for p in selected]
log["rate"] = sum(rates_only) / len(rates_only)
log["n_points"] = len(selected)
log["source"] = "+".join(sorted(set(p["source"] for p in selected)))
log["method"] = method
log["selected_points"] = selected
return log
def apply_time_adjustment(df: pd.DataFrame, asof: date, annual_rate: float) -> pd.DataFrame:
"""各事例の単価を asof 時点に補正。
adjusted = original * (1 + rate) ** years_to_asof
"""
if annual_rate is None:
out = df.copy()
out["years_to_asof"] = 0.0
out["adjusted_unit_price"] = out["unit_price"]
return out
def _years(d):
return (asof.year - d.year) + (asof.month - d.month) / 12.0
out = df.copy()
out["years_to_asof"] = out["transaction_date"].apply(_years)
out["adjusted_unit_price"] = out.apply(
lambda r: r["unit_price"] * ((1 + annual_rate) ** r["years_to_asof"]), axis=1
)
out["ln_adjusted_unit_price"] = out["adjusted_unit_price"].apply(math.log)
return out
if __name__ == "__main__":
from pathlib import Path
import json
from load_mlit import load_mlit_csv, load_koji_csv, load_kijun_csv
from scope import scope_dataframe
here = Path(__file__).parent.parent / "samples"
df = load_mlit_csv(here / "sample_mlit.csv")
koji = load_koji_csv(here / "sample_koji.csv")
kijun = load_kijun_csv(here / "sample_kijun.csv")
with open(here / "sample_property.json", encoding="utf-8") as f:
target = json.load(f)
asof = date(2025, 12, 1)
scoped, _ = scope_dataframe(df, target, asof)
rate_info = annual_rate_for_city(koji, kijun, target["市区町村名"])
print(f"年率情報: {rate_info}")
adjusted = apply_time_adjustment(scoped, asof, rate_info["rate"])
print(adjusted[["district", "transaction_date", "unit_price",
"years_to_asof", "adjusted_unit_price"]].head(5))
SHA-256: e9e4e4c96f7acc3390b63bcdc6de5f3fb76b33c489917617f2e9ef9a8d0b1779