― 秋田市の実データで検証したら全部壊れた ―
本記事のコードはPython版とPowerShell版を併記しています。実証試験はPowerShellで実施し、再現用にPython版を追加しました。
はじめに
秋田県は全国最多の熊出没件数を記録しています(2026年1〜5月: 1,321件、前年同期比 +104.5%)。
本記事では、Python(またはPowerShell)を用いて
- OpenStreetMap(OSM)から空間データを取得
- 周辺環境の特徴量抽出
- リスクスコアリング
- GeoJSON生成 → QGISで可視化
- Leaflet.jsによるブラウザヒートマップ生成
までを一気通貫で構築する過程と、実データで検証したらモデルが5回壊れて作り直した記録を書きます。

完成したヒートマップ。青が2025年、赤が2026年の出没分布。オレンジ/赤の丸がv4リスクスコア地点。
この記事で得られるもの
- OSM Overpass APIのPython/PowerShellからの叩き方と安定運用戦略
- 空間データを使ったリスクスコアリングの設計と3つの落とし穴
- 実データ検証の重要性(モック値 vs 実データの乖離)
- Leaflet.jsヒートマップのPowerShellからの自動生成
動作確認環境
| 項目 | バージョン |
|---|---|
| Python | 3.10+ (requests) |
| PowerShell | 7.6.0 (Windows) |
| QGIS | 3.x |
| Overpass API | overpass-api.de (主系) |
| OS | Windows 11 |
対象読者
GIS初心者だがPythonかPowerShellがわかる人。「動くコードだけ見たい」人は最終版スクリプト(v4)まで飛んでください。
全体構成
入力(テスト座標5地点)
↓
OSMデータ取得(Overpass API × 3クエリ)
├── Q1: landuse + natural(森林・農地・草地)
├── Q2: waterway(河川種別: river/stream/drain)
└── Q3: building count(建物数)
↓
特徴量抽出(v4: waterway分離 + tree除外)
↓
リスクスコア計算(v4: 対数比 + 疎密度ペナルティ)
↓
GeoJSON生成
├── QGIS可視化(ベクタレイヤ)
└── Leaflet.jsヒートマップ(HTML自動生成)
↓
クマダス出没データと重ね合わせ
↓
LLM考察生成(Groq qwen3-32b)
1. なぜ5回作り直したのか
最初のモデルは単純でした:
# v1: 全要因を加算(これが間違い)
$score = ($forest * 0.5) + ($water * 0.2) + ($resi * 0.3)
# v1: 全要因を加算(これが間違い)
score = (forest * 0.5) + (water * 0.2) + (resi * 0.3)
この式では住宅地が多いほどリスクが上がります。直感に反しています。
以下がモデル変遷の全記録です:
| Version | 方式 | 秋田市5地点の順序 | 致命的問題 |
|---|---|---|---|
| v1 | 全要因加算 | NG | residential加算が逆 |
| v2 | 正規化+減算 | NG | 0クランプで差が消滅 |
| v3(モック) | 対数比 | OK | 実データ未検証 |
| v3(実データ) | 対数比 | NG | building 5,260件が全てを支配 |
| v4 | 対数比+waterway分離+疎密度ペナルティ | OK | 採用 |
v3がモックデータではOKだったのに実データで壊れた。これが最大の教訓です。
2. 実データで判明した3つの落とし穴
落とし穴1: building件数の支配
上北手百崎(実際に熊出没が多い地域)でbuilding=5,260。モック値では60と推定していたので87倍の乖離。
森林24件の影響がゼロに近くなり、スコアが0.016(LOW)に。熊が出る場所なのにLOW判定。
対策: buildingを対数スケール化
$bldgLog = [Math]::Log10([Math]::Max($building, 1) + 1)
# log10(5260+1) = 3.72 vs log10(9+1) = 1.0
import math
bldg_log = math.log10(max(building, 1) + 1)
# log10(5260+1) = 3.72 vs log10(9+1) = 1.0
落とし穴2: waterway種別の無視
御所野ニュータウン(大型商業施設が並ぶ郊外)でwaterway=61。うちstream=47、drain=11。
国土数値情報(KSJ2)由来の用水路データが大量にインポートされており、熊の移動経路となる本流(river)とは性質が全く異なります。
対策: waterway種別で重みを分離
$rWater = ($wRiver * 3.0) + ($wStream * 0.3) + ($wDrain * 0.0)
r_water = (w_river * 3.0) + (w_stream * 0.3) + (w_drain * 0.0)
落とし穴3: OSMデータの疎密度パラドックス
仁別(太平山奥地の完全な山林地帯)で環境要素がたった13件。forest=1。
OSMの山林地帯はデータが極端に少ないのです。森林は広域ポリゴン(relation)で1件カウントされますが、都市部のように個別登録されません。
「データが少ない ≒ 人間活動が少ない ≒ 自然が多い」 — この逆転の論理をモデルに組み込む必要がありました。
対策: 疎密度ペナルティ
$densityPenalty = [Math]::Max(0, 3.0 - [Math]::Log10($envTotal + 1)) * 25.0
# 仁別(13件): (3.0-1.15)*25 = 46.3 → リスク加算
# 秋田駅(297件): (3.0-2.47)*25 = 13.1 → 小加算
density_penalty = max(0, 3.0 - math.log10(env_total + 1)) * 25.0
# 仁別(13件): (3.0-1.15)*25 = 46.3 → リスク加算
# 秋田駅(297件): (3.0-2.47)*25 = 13.1 → 小加算
3. 最終版スクリプト(v4)
3.1 Overpass API: クエリ設計と安定運用戦略
クエリ設計
3本のクエリに分離します。
# Q1: 環境要素(tree/tree_row/cemeteryを除外)
$q1 = @"
[out:json][timeout:60];
(
nwr(around:$Radius,$Lat,$Lon)["landuse"];
nwr(around:$Radius,$Lat,$Lon)["natural"~"^(wood|water|grassland|scrub|wetland)$"];
);
out tags;
"@
# Q2: waterway(種別を取得)
$q2 = @"
[out:json][timeout:30];
(
nwr(around:$Radius,$Lat,$Lon)["waterway"];
);
out tags;
"@
# Q3: 建物数のみ(out countで軽量化)
$q3 = @"
[out:json][timeout:30];
(
nwr(around:$Radius,$Lat,$Lon)["building"];
);
out count;
"@
Overpassクエリ自体は言語非依存のため、PythonでもPowerShellでも同じ文字列を送信します。
設計上のポイント:
-
nwrでnode/way/relationを全取得(nodeだけではlanduse=forestが取れない) -
out tags;でジオメトリを省略(タグ情報のみ必要なので通信量を削減) - Q1のnaturalを正規表現でフィルタし、
tree/tree_rowを除外(街路樹の誤カウント防止) - Q3は
out count;で件数のみ取得(建物数千件のデータ転送を回避)
Overpass APIの安定運用戦略
開発中に以下の問題に遭遇しました。
| 問題 | 原因 | 対策 |
|---|---|---|
| 406 Not Acceptable |
Invoke-RestMethodのContent-Type不正 |
HttpWebRequestで明示的にUTF-8エンコード |
| 504 Gateway Timeout | クエリが重すぎる | building/envクエリ分離、out count;活用 |
| 接続不安定 | 単一エンドポイント依存 | overpass-api.de主系 + kumi.systemsフォールバック |
フォールバック実装:
$endpoints = @(
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter"
)
function Invoke-Overpass {
param([string]$Query)
foreach ($ep in $endpoints) {
try {
$body = "data=" + [System.Uri]::EscapeDataString($Query)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
$req = [System.Net.HttpWebRequest]::Create($ep)
$req.Method = "POST"
$req.ContentType = "application/x-www-form-urlencoded; charset=utf-8"
$req.Timeout = 90000
$req.ContentLength = $bytes.Length
$stream = $req.GetRequestStream()
$stream.Write($bytes, 0, $bytes.Length)
$stream.Close()
$response = $req.GetResponse()
$reader = New-Object System.IO.StreamReader(
$response.GetResponseStream(),
[System.Text.Encoding]::UTF8
)
$jsonStr = $reader.ReadToEnd()
$reader.Close()
$response.Close()
return ($jsonStr | ConvertFrom-Json)
}
catch {
Write-Host " FAIL: $ep" -ForegroundColor Red
}
}
return $null
}
Invoke-RestMethodではなくHttpWebRequestを使う理由は、Content-Typeとエンコーディングを完全に制御するためです。Invoke-RestMethodの暗黙変換がOverpass APIの406を引き起こすケースがありました。
Python版(requests):
import requests
ENDPOINTS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
]
def query_overpass(query: str) -> dict | None:
for ep in ENDPOINTS:
try:
resp = requests.post(ep, data={"data": query}, timeout=90)
resp.raise_for_status()
return resp.json()
except Exception as e:
print(f" FAIL: {ep} ({e})")
return None
PythonのrequestsではContent-Typeの問題は発生しないため、シンプルに書けます。
3.2 特徴量抽出
function Get-FeaturesV4 {
param($LanduseElements, $WaterwayElements, $BuildingCount)
$forest = 0; $residential = 0; $farmland = 0; $grassland = 0
foreach ($el in $LanduseElements) {
$tags = $el.tags
if (-not $tags) { continue }
if ($tags.landuse -eq "forest" -or
$tags.natural -eq "wood") { $forest++ }
elseif ($tags.landuse -eq "residential") { $residential++ }
elseif ($tags.landuse -eq "farmland") { $farmland++ }
elseif ($tags.landuse -eq "meadow" -or
$tags.natural -eq "grassland" -or
$tags.natural -eq "scrub") { $grassland++ }
}
# waterway種別分離
$wRiver = 0; $wStream = 0; $wDrain = 0
foreach ($el in $WaterwayElements) {
switch ($el.tags.waterway) {
"river" { $wRiver++ }
"canal" { $wRiver++ }
"stream" { $wStream++ }
"drain" { $wDrain++ }
"ditch" { $wDrain++ }
}
}
return @{
forest = $forest; residential = $residential
farmland = $farmland; grassland = $grassland
wRiver = $wRiver; wStream = $wStream; wDrain = $wDrain
building = $BuildingCount
envTotal = $LanduseElements.Count
}
}
Python版:
def extract_features(landuse_elements, waterway_elements, building_count):
forest = residential = farmland = grassland = 0
for el in landuse_elements:
tags = el.get("tags", {})
lu, na = tags.get("landuse", ""), tags.get("natural", "")
if lu == "forest" or na == "wood":
forest += 1
elif lu == "residential":
residential += 1
elif lu == "farmland":
farmland += 1
elif lu == "meadow" or na in ("grassland", "scrub"):
grassland += 1
w_river = w_stream = w_drain = 0
for el in waterway_elements:
wt = el.get("tags", {}).get("waterway", "")
if wt in ("river", "canal"):
w_river += 1
elif wt == "stream":
w_stream += 1
elif wt in ("drain", "ditch"):
w_drain += 1
return {
"forest": forest, "residential": residential,
"farmland": farmland, "grassland": grassland,
"w_river": w_river, "w_stream": w_stream, "w_drain": w_drain,
"building": building_count,
"env_total": len(landuse_elements),
}
3.3 リスクスコア計算(v4 対数比モデル)
function Get-RiskScoreV4 {
param($F)
# --- Risk (環境要因) ---
$rForest = $F.forest * 3.0 # 採餌・隠蔽空間
$rWater = ($F.wRiver * 3.0) + # 移動経路(河川回廊)
($F.wStream * 0.3) + # 小規模水系(弱いリスク)
($F.wDrain * 0.0) # 人工水路(除外)
$rFarm = $F.farmland * 0.8 # 誘引要因(農作物)
$rGrass = $F.grassland * 0.3 # 耕作放棄地等
$riskEnv = $rForest + $rWater + $rFarm + $rGrass
# --- Risk (OSM観測バイアス補正) ---
# データが少ない地域 = 人間活動が少ない = 自然が多い
$envT = [Math]::Max($F.envTotal, 1)
$densityPenalty = [Math]::Max(0, 3.0 - [Math]::Log10($envT + 1)) * 25.0
$riskTotal = $riskEnv + $densityPenalty
# --- Safe (人間活動の抑制要因) ---
# 建物は対数スケール(5000件と500件の差を圧縮)
$bldgLog = [Math]::Log10([Math]::Max($F.building, 1) + 1)
$safeSum = ($bldgLog * 15.0) + ($F.residential * 5.0) + 1
# Sigmoid正規化: score = ratio / (ratio + 1)
$ratio = $riskTotal / $safeSum
$score = [Math]::Min(1.0, $ratio / ($ratio + 1))
return [Math]::Round($score, 4)
}
Python版:
import math
def calc_risk_score(f: dict) -> float:
# Risk (環境要因)
r_forest = f["forest"] * 3.0 # 採餌・隠蔽空間
r_water = (f["w_river"] * 3.0 + # 河川回廊
f["w_stream"] * 0.3 + # 小規模水系
f["w_drain"] * 0.0) # 人工水路(除外)
r_farm = f["farmland"] * 0.8 # 誘引要因
r_grass = f["grassland"] * 0.3 # 耕作放棄地等
risk_env = r_forest + r_water + r_farm + r_grass
# OSM観測バイアス補正
env_t = max(f["env_total"], 1)
density_penalty = max(0, 3.0 - math.log10(env_t + 1)) * 25.0
risk_total = risk_env + density_penalty
# Safe (人間活動の抑制要因)
bldg_log = math.log10(max(f["building"], 1) + 1)
safe_sum = (bldg_log * 15.0) + (f["residential"] * 5.0) + 1
# Sigmoid正規化
ratio = risk_total / safe_sum
score = min(1.0, ratio / (ratio + 1))
return round(score, 4)
スコア設計の根拠
各係数は「秋田市5地点の直感的順序との一致」で調整したヒューリスティックですが、要因の選定には生態学的な根拠があります。
| 要因 | 係数 | 根拠 |
|---|---|---|
| forest | ×3.0 | 熊の採餌空間・隠蔽場所。最も直接的な生息環境指標 |
| wRiver | ×3.0 | 河川回廊仮説:熊は河川沿いを移動経路として利用する |
| wStream | ×0.3 | 小規模水系。移動経路としての機能は限定的 |
| wDrain | ×0.0 | 人工水路(用水路・排水路)。熊の行動と無関係 |
| farmland | ×0.8 | 農作物(特に果樹)が誘引要因。山裾の農地は出没の温床 |
| grassland | ×0.3 | 耕作放棄地の可能性。弱い生息環境指標 |
| building(log) | ×15.0 | 人間干渉の代理変数。対数減衰で「5000件と500件の差」を圧縮 |
| residential | ×5.0 | 住宅地ポリゴンは建物個数より広域の人間活動を示す |
| densityPenalty | ×25.0 | OSM観測バイアス補正。データ疎 ≈ 人跡未踏 ≈ 自然環境 |
Sigmoid正規化 score = ratio / (ratio + 1) を採用した理由:
- 出力が0〜1に収まる(クランプ不要)
- risk/safe比が1のとき score=0.5(直感的な中間点)
- 極端な値で飽和する(risk=100, safe=1 でも score≈0.99、∞にならない)
スコアの解釈
| 範囲 | ラベル | 意味 |
|---|---|---|
| > 0.5 | HIGH | 森林・河川が優勢、人間活動が少ない |
| 0.25〜0.5 | MID | 都市と自然の境界域 |
| < 0.25 | LOW | 都市部、建物密集 |
3.4 GeoJSON出力
$geojson = @{
type = "FeatureCollection"
features = $gjFeatures.ToArray()
} | ConvertTo-Json -Depth 10
# BOM無しUTF-8
# PowerShell 5.xの Out-File -Encoding UTF8 はBOM付きになるため
# System.IO.File を使用
[System.IO.File]::WriteAllText(
$outPath, $geojson,
[System.Text.UTF8Encoding]::new($false)
)
Python版:
import json
geojson = {
"type": "FeatureCollection",
"features": features_list,
}
with open(out_path, "w", encoding="utf-8") as f:
json.dump(geojson, f, ensure_ascii=False, indent=2)
4. 実データ検証結果(秋田市5地点)
| 地点 | Score | Label | forest | wRiver | wStream | farmland | building |
|---|---|---|---|---|---|---|---|
| 秋田駅周辺 | 0.4631 | MID | 11 | 1 | 5 | 1 | 5,590 |
| 上北手百崎 | 0.8143 | HIGH | 32 | 2 | 1 | 171 | 5,260 |
| 太平山麓 | 0.6653 | HIGH | 2 | 4 | 1 | 9 | 291 |
| 御所野NT | 0.5776 | HIGH | 2 | 1 | 47 | 44 | 5,242 |
| 仁別 | 0.9271 | HIGH | 1 | 4 | 2 | 5 | 0 |
スコア順序:
仁別(0.93) > 上北手百崎(0.81) > 太平山麓(0.67) > 御所野NT(0.58) > 秋田駅(0.46)
上北手百崎は2026年6月に実際に熊出没報告がある地域です。farmland=171(農地が広がる山裾)が高スコアの主因で、「山奥よりも山と人里の境界の方が遭遇リスクが高い」という知見と一致します。
5. クマダス出没データとの重ね合わせ
秋田県オープンデータカタログ(クマダス)から出没データを取得し、前年比較を行いました。
5.1 データ取得
# 秋田県CKAN: クマダスCSV直接ダウンロード
$csvUrl = "https://ckan.pref.akita.lg.jp/dataset/" +
"050008_shizenhogoka_003/resource/" +
"0678f9b3-4bf7-4212-9c0e-c0cb9b09b3cf/" +
"download?user-download=true"
Invoke-WebRequest -Uri $csvUrl -OutFile $localCsv -TimeoutSec 120
$allRecords = Import-Csv $localCsv -Encoding UTF8
# CSVスキーマ:
# 出没情報ID, 情報種別, 市町村, 地番情報, 目撃日時,
# 獣種, 性別, 単独か親子, 頭数, 目撃時の状況, x(緯度), y(経度)
Python版:
import pandas as pd
url = ("https://ckan.pref.akita.lg.jp/dataset/"
"050008_shizenhogoka_003/resource/"
"0678f9b3-4bf7-4212-9c0e-c0cb9b09b3cf/"
"download?user-download=true")
df = pd.read_csv(url, encoding="utf-8")
注意点として、獣種 フィールドには「ツキノワグマ」「イノシシ」「ニホンジカ」が混在しています。PowerShellではフィルタ条件 -match "グマ" で絞り込みます(「ツキノワグマ」は「クマ」にマッチしないため -match "クマ" では全件除外される罠があります)。Pythonでは df[df["獣種"].str.contains("グマ")] で同等のフィルタが可能です。
5.2 2025年 vs 2026年 同期間比較(1月〜5月)
| 指標 | 2025年 | 2026年 | 変化 |
|---|---|---|---|
| 総件数 | 646 | 1,321 | +104.5% |
| 秋田市 | 208 | 470 | +126.0% |
| 目撃 | 600 | 1,272 | +112.0% |
| 人身被害 | 1 | 2 | +100.0% |
データソースは2026年5月31日時点のスナップショットのため、6月以降のデータは含まれていません。公平な同期間比較のため1〜5月で切り出しています。
月別推移:
| 月 | 2025 | 2026 | 変化 |
|---|---|---|---|
| 1月 | 133 | 30 | -103 |
| 2月 | 7 | 24 | +17 |
| 3月 | 23 | 29 | +6 |
| 4月 | 85 | 395 | +310 |
| 5月 | 398 | 843 | +445 |
2026年は4月以降の急増が顕著です。
5.3 市町村別の変化
| 市町村 | 2025 | 2026 | 変化率 |
|---|---|---|---|
| 秋田市 | 159 | 426 | +168% |
| 大仙市 | 75 | 152 | +103% |
| 横手市 | 41 | 102 | +149% |
| 男鹿市 | 4 | 48 | +1,100% |
| 北秋田市 | 49 | 44 | -10% |
| 鹿角市 | 42 | 35 | -17% |
男鹿市の急増(4→48件)は沿岸部への出没拡大を示唆し、北秋田市・鹿角市の減少は地域ごとのパターンの違いを示しています。
5.4 LLMによる考察自動生成
集計データをGroq Cloud(qwen3-32b)に投入し、考察を自動生成しました。プロンプトには「今後の予測は一切行わない」を明示指定しています。
$groqBody = @{
model = "qwen/qwen3-32b"
messages = @( @{ role = "user"; content = $prompt } )
temperature = 0.3
} | ConvertTo-Json -Depth 10
$resp = Invoke-RestMethod -Uri "https://api.groq.com/openai/v1/chat/completions" `
-Method POST `
-Headers @{ Authorization = "Bearer $env:GROQ_API_KEY" } `
-ContentType "application/json; charset=utf-8" `
-Body ([System.Text.Encoding]::UTF8.GetBytes($groqBody))
$analysis = $resp.choices[0].message.content
Python版:
import requests, os
resp = requests.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['GROQ_API_KEY']}"},
json={
"model": "qwen/qwen3-32b",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
},
timeout=120,
)
analysis = resp.json()["choices"][0]["message"]["content"]
以下はGroqが生成した考察テキスト(原文ママ)です:
出没件数の増減傾向
2026年1月〜5月の出没件数(1,321件)は、2025年同期間(646件)のほぼ2倍に達しています。特に4月(+310件)と5月(+445件)の増加が顕著で、年間全体の増加はこれらの2か月に集中しています。
月別推移のパターン変化
1月は2026年が2025年比で77%減少(30件 vs 133件)し、冬期の低調な活動が確認されます。一方、4月以降は2026年が急増しており、4月は2025年比465%増、5月は212%増と、春先の活動の高まりが全体の増加を牽引しています。
秋田市の特化分析
秋田市の出没件数は2025年(208件)から2026年(470件)で126%増加しています。県全体の増加率(104.5%)を上回っており、都市周辺への出没が加速していることが確認されます。
市町村別の分布変化
男鹿市(4→48件:+1,100%)、にかほ市(22→44件:+100%)が目立ち、沿岸部での活動拡大が見られます。一方、北秋田市(49→44件:-10%)、鹿角市(42→35件:-17%)は減少傾向にあり、地域ごとの活動パターンの違いが確認されます。
情報種別の構成比変化
「目撃」が2025年(600件)→2026年(1,272件)で212%増加し、全体の96%を占めています。痕跡(44→45件)、人身被害(1→2件)は微増にとどまり、構成比に大きな変化はありません。
6. QGISでの可視化
- QGIS起動 →「レイヤ」→「ベクタレイヤを追加」
-
bear_risk_combined.geojsonを読み込み - フィルタ設定:
- リスクスコア地点:
"layer" = 'risk_score' - 出没地点:
"layer" = 'sighting_2026'
- リスクスコア地点:
- シンボロジ:
- リスクスコア:
scoreフィールドで段階色(赤→緑) - 出没地点: 半径2pxの赤丸マーカー
- リスクスコア:
7. ボーナス: Leaflet.jsによるブラウザヒートマップ
QGISなしでも、ブラウザで確認できるHTMLヒートマップを自動生成します。
bear_map_generator.ps1 を実行すると bear_risk_map.html が生成されます。
機能:
- 2026年出没ヒートマップ(赤系グラデーション)
- 2025年出没ヒートマップ(青系、チェックボックスで切替)
- 個別マーカー表示(クリックで日時・場所・状況を表示)
- v4リスクスコア地点のオーバーレイ(スコア値をラベル表示)
Leaflet.js + leaflet-heat プラグインを使用し、CDNから読み込むためインストール不要です。

生成されたLeafletヒートマップ。左上のチェックボックスで2025年/2026年の切替、個別マーカー表示、リスクスコア地点の表示を制御できる。
地図を見ると、v4リスクスコアの高い地点(上北手百崎 0.81、太平山麓 0.67)が、クマダスの実際の出没ヒートマップの濃い領域と一致していることが視覚的に確認できます。一方、秋田駅周辺(MID: 0.46)はヒートマップが薄く、スコアの妥当性を裏付けています。
なお、ヒートマップの2025年データ(青系)は通年(1月〜12月)、2026年データ(赤系)は1月〜5月のみ(CKANスナップショットの範囲)です。件数の直接比較には注意が必要です。前年比較の正確な同期間データはセクション5を参照してください。
8. 秋田県全域500mグリッド化設計(未実装)
本記事は5地点の点評価ですが、面的なリスクマップ生成も設計しています。
8.1 設計
秋田県 bbox: lat 38.85-40.55, lon 139.65-140.65
↓
500mグリッド生成(約 3,400 × 2,000 = 最大6,800セル)
↓
各セルでOverpass API取得(3クエリ × 6,800 = 20,400リクエスト)
↓
v4スコア計算
↓
Polygon GeoJSON生成
↓
ヒートマップ化
8.2 並列化とレート制限
# 500mグリッド生成(概算)
$latStep = 0.0045 # 約500m
$lonStep = 0.0056 # 約500m(緯度39度付近)
$jobs = @()
foreach ($lat in ($latMin..$latMax | ForEach-Object { $latMin + $_ * $latStep })) {
foreach ($lon in ($lonMin..$lonMax | ForEach-Object { $lonMin + $_ * $lonStep })) {
$jobs += @{ lat = $lat; lon = $lon }
}
}
# PowerShell Jobで並列化(同時実行数を制限)
$maxParallel = 4
$waitBetween = 1 # 秒
Python版:
import numpy as np
lat_step = 0.0045 # 約500m
lon_step = 0.0056 # 約500m(緯度39度付近)
lat_min, lat_max = 38.85, 40.55
lon_min, lon_max = 139.65, 140.65
grid = [
(lat, lon)
for lat in np.arange(lat_min, lat_max, lat_step)
for lon in np.arange(lon_min, lon_max, lon_step)
]
# concurrent.futures で並列化
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(fetch_and_score, grid))
8.3 キャッシュ設計
Overpass APIのレート制限を考慮し、取得済みセルをローカルにキャッシュします。
$cacheDir = ".\osm_cache"
$cacheKey = "$lat_$lon"
$cachePath = Join-Path $cacheDir "$cacheKey.json"
if (Test-Path $cachePath) {
# キャッシュヒット
$data = Get-Content $cachePath | ConvertFrom-Json
}
else {
$data = Get-OSMData -Lat $lat -Lon $lon
$data | ConvertTo-Json -Depth 5 | Out-File $cachePath
}
8.4 推定所要時間
- 6,800セル × 3クエリ = 20,400リクエスト
- 並列4 × 1秒間隔 = 約5,100秒(約85分)
- キャッシュ込みで再実行は数分
Python版:
import json, os, hashlib
cache_dir = "./osm_cache"
os.makedirs(cache_dir, exist_ok=True)
def get_cached(lat, lon):
key = f"{lat:.4f}_{lon:.4f}"
path = os.path.join(cache_dir, f"{key}.json")
if os.path.exists(path):
with open(path) as f:
return json.load(f)
data = query_overpass(build_query(lat, lon))
if data:
with open(path, "w") as f:
json.dump(data, f)
return data
この段階に入ると、空間インデキシングとして扱えるようになります。LLMと組み合わせれば「この地点のリスク要因は?」という自然言語クエリに対して、キャッシュ済みのOSMデータから回答を生成する構成が可能です。
制限事項
本記事のスコアモデルはOSMデータに基づくヒューリスティックであり、統計的妥当性は検証されていません。
- OSMデータの網羅性・更新頻度は地域により大きく異なる
- 重み係数は秋田市5地点の順序一致で調整したもの(他地域では未検証)
- 熊の生態(食性・季節行動・個体密度・ブナの豊凶)は考慮していない
- クマダスデータは2026年5月31日時点のスナップショット
- Overpass APIの応答はサーバー負荷により不安定になることがある
実運用には、環境省や県の専門調査データとの照合が必要です。
まとめ
- Python/PowerShellでOSMデータ取得→リスクスコア計算→GeoJSON/HTML出力まで構築できた
- モックデータで動くモデルが実データで壊れるのは普遍的な教訓
- OSMデータでは building件数の支配、waterway種別、データ疎密度パラドックスの3つが落とし穴
- 秋田県オープンデータ(クマダス)とGroq APIで、データ比較と考察の自動生成も実現
- 500mグリッド化は設計済み、次のステップとして実装可能