この記事はヌーラボブログリレー2025 冬の2日目として投稿しています
皆さんこんにちは。
私は2025年11月にアナリティクスエンジニアとしてヌーラボに入社し、ちょうど1ヶ月が経ちました。
元々は東京に住んでいましたが、フルリモート勤務なので全く必要性が無いにも関わらず、 転職を機に福岡へ引っ越してきました。
引っ越しには当然物件を探す必要があるのですが、
「アナリティクスエンジニアを名乗る身として、データを使って最適な物件を見つけなくては!!」
と謎の使命感(?)に燃えてしまい、機械学習を用いた新居探しに取り組みました。
今回はその内容をご紹介できればと思います。
やりたかったこと
とはいえ最適な物件って何じゃろな?と考えた結果、今回は
モデルが予想する家賃よりも実際の家賃が安い=お得な物件!
と定義してみることにしました。
異常検知や解約予測等と考え方としては近いかもしれません。
ステップとしては
- WEBサイトから物件の情報を取得
- 学習データで家賃を予測するモデルの作成
- テストデータで家賃を予測、実際の家賃との差分を算出
といった形で進めていきました。
物件情報の取得
スクレイピングには某不動産情報サイトを使用しました。
ここからは実際のコードをかいつまんで見ていきます。
スクレイピングについてはサイトの利用規約で禁止されてる場合があるため、実施前に利用規約を必ず確認しましょう。
サイトに負荷をかける場合があるため、適度なインターバル(スリープ)を挟みましょう。
取得したデータの利用は、個人的な利用にとどめておきましょう。
詳細ページのURL取得
物件一覧のページには家賃や間取りくらいしか載っていないため、
説明変数用の項目を取得するために、詳細ページのURLを取得してリストにする関数を定義します。
def get_soup(url, headers):
response = requests.get(url, headers=headers)
soup = BeautifulSoup(response.text, "html.parser")
return soup
def get_detail_urls(url, headers, base_url):
soup = get_soup(url, headers)
links = soup.select("a.js-cassette_link_href") # 「詳細を見る」リンクを全て取得
detail_urls = [urljoin(base_url, a["href"]) for a in links if a.get("href")] # 絶対パスに変換
return detail_urls
詳細ページのスクレイピング
家賃、間取り、築年数、駅徒歩等の情報が様々なHTMLテーブルや要素に散らばっているため、必要なものを抽出して辞書に追加していきます。
def scrape_detail(url, headers):
try:
soup = get_soup(url, headers)
# 通常情報の取得とテキスト化
title = soup.select_one("h1.section_h1-header-title")
title = title.text.strip() if title else ""
station = soup.select_one("table.property_view_table tr:has(th:contains('駅徒歩')) td")
station = station.get_text(" ", strip=True) if station else ""
# ---(中略)--- #
# テーブルからの情報取得と項目抽出
property_table = parse_tr_table(soup.select("table.property_view_table tr"))
floor_plan = property_table.get("間取り", "")
floor_area = property_table.get("専有面積", "")
# ---(中略)--- #
direction = property_table.get("向き", "")
building_type = property_table.get("建物種別", "")
# 出力用辞書に追加
house_detail = {
"url": url,
"title": title,
"station_distance": station,
# ---(中略)--- #
"remarks": remarks,
"facilities": facilities,
}
return house_detail
except Exception as e:
print(f"Error scraping {url}: {e}")
return None
ループ処理の実行
- 一覧ページで詳細URL取得
- 詳細URLから一つ一つ情報を取得
- 終わったら一覧の次のページへ
といった流れで実際にデータを取得していきます。
def scrape_all_pages(listing_url, base_url, headers, start_page, end_page):
all_data = []
overall_start = time.perf_counter()
for page in range(start_page, end_page + 1):
page_start = time.perf_counter()
page_url = listing_url.format(page)
try:
detail_urls = get_detail_urls(page_url, headers, base_url)
time.sleep(1) # 待機
except Exception as e:
print(f"Failed to get list page {page}: {e}")
continue
num = 1
for url in detail_urls:
try:
start = time.perf_counter()
data = scrape_detail(url, headers)
end = time.perf_counter()
if data:
all_data.append(data)
print(f"Scraped: Page: {page}, Number: {num}, URL: {url} ({end - start:.2f}sec.)")
else:
print(f"Empty Result: URL: {url}")
time.sleep(1) # 待機
num += 1
except Exception as e:
print(f"Failed: {url} → {e}")
page_end = time.perf_counter()
print(f"Completed: Page: {page}({page_end - page_start:.2f}sec.)")
overall_end = time.perf_counter()
print(f"All Completed: ({overall_end - overall_start:.2f}sec.)")
df = pd.DataFrame(all_data)
return df
結果のイメージはこんな感じに。
さすがに福岡全域だと多すぎたので諸々の条件で件数は絞りましたが、それでもそこそこ時間はかかってしまいました。
| url | station_distance | floor_area | age_of_building | floor_plan | features | rent | ... |
|---|---|---|---|---|---|---|---|
| hoge.com/001 | XX線/XX駅 歩19分 | 48.5m 2 | 築25年 | 3LDK | バストイレ別、バルコニー、フローリング... | 6.8万円 | ... |
| hoge.com/002 | XX/XX駅 歩13分 XX線/XX駅 歩18分 | 22.5m 2 | 新築 | 1K | バルコニー、室内洗濯置、南向き... | 5.5万円 | ... |
データの前処理
全部書くとキリがないので、主なものだけ記載していきます。
間取りの分解
1Kや2LDKといった間取りを、部屋数・リビング有無・キッチン有無といった形で数値型のカラムに変換します。
def parse_floor_plan(plan):
if isinstance(plan, str):
match = re.search(r"(\d+)", plan)
rooms = int(match.group(1)) if match else 1
has_l = int("L" in plan)
has_d = int("D" in plan)
has_k = int("K" in plan)
return pd.Series([rooms, has_l, has_d, has_k])
else:
return pd.Series([0, 0, 0, 0])
正規表現処理
5.8万円→5.8、38.0m2→38.0、築15年→15のように数値部分を抽出していきます。
ただ築年数が「新築」だったり、階数の表記が「平屋」のようになっていたりと、ここの細かい考慮が一番苦労したかもしれません。
df["rent"] = df["rent"].str.extract(r"([\d.]+)").astype(float)
df["floor_area"] = df["floor_area"].str.extract(r"([\d.]+)").astype(float)
df["age_of_building"] = (df["age_of_building"].replace("新築", "0").str.extract(r"([\d.]+)").astype(float)) # 新築は0年に変換
df["nearest_station_name"] = df["station_distance"].apply(lambda x: re.findall(r"/(.+?)駅 歩\d+分", x)[0] if isinstance(x, str) and re.findall(r"/(.+?)駅 歩\d+分", x) else np.nan) # 駅名
df["nearest_station_minutes"] = df["station_distance"].apply(lambda x: int(re.findall(r"歩(\d+)分", x)[0]) if isinstance(x, str) and re.findall(r"歩(\d+)分", x) else np.nan) # 最寄り駅までの徒歩分数
特徴・機能のカラム作成
features のカラムに「バストイレ別」がある→separate_bath_toiletのカラムにフラグを立てる、というような処理を機能ごとに追加していきます。
IMPORTANT_FEATURES = {
"バストイレ別": "separate_bath_toilet",
"洗面所独立": "separated_washstand",
"都市ガス": "city_gas",
"ペット相談": "pets_allowed",
# ---(中略)--- #
}
for jp, en in IMPORTANT_FEATURES.items():
cleaned_df[en] = cleaned_df["features"].apply(lambda x: 1 if isinstance(x, str) and jp in x else 0)
他では都道府県や市区町村名の処理には、総務省が公開している全国地方公共団体コードを使用しました。
https://www.soumu.go.jp/denshijiti/code.html
整形後のデータフレーム
結果としては主に以下のような項目が作成されました。
実際モデルを作成した際は24つの説明変数を使用しています。
| カラム名 | 意味 | サンプル | 加工前 |
|---|---|---|---|
| url | ページのURL(レコードのユニークキーとして使用) | hoge.com/001 | |
| rent | 家賃 | 5.8 | 5.8万円 |
| maintenance | 管理費 | 0.4 | 4,000 |
| prefecture | 県名 | 福岡県 | 福岡県福岡市⚪︎丁目⚪︎番XXX |
| floor_area | 面積 | 48.5 | 48.5 m2 |
| room_count | 部屋数 | 2 | 2LDK |
| has_living_room | リビングルームの有無 | 1 | 2LDK |
| age_of_building | 築年数 | 15 | 築15年 |
| floor_level | 階数 | 2 | 2階/4階建 |
| total_floors | 建物の最大階数 | 4 | 2階/4階建 |
| nearest_station_minutes | 最寄り駅までの徒歩分数 | 19 | XX線/XX駅 歩19分 |
| super_market_distance | 最寄りのスーパーまでの距離(m) | 330 | スーパー:XXXまで330メートル |
| separate_bath_toilet | バストイレ別かどうか | 1 | バストイレ別、ペット相談可、フローリングetc... |
| pets_allowed | ペット相談可かどうか | 1 | バストイレ別、ペット相談可、フローリングetc... |
また目的変数は家賃+管理費の値で設定しました。
当初は初期費用や敷金礼金をもとに5年間の月間平均コストを算出するといったイメージでしたが、条件付の敷金礼金があったり、初期費用の書き方がまちまちだったりで難しそうだったので、シンプルな形にしています。
予測の実行
モデルにはLightGBMを採用しました。
今回は全物件に対し予測を実行したかったので、OOF(Out Of Fold)予測を用いてデータセットを5分割し、1つを検証データ・残り4つを訓練データとしてモデル学習&予測を行っています。
kf = KFold(n_splits=5, shuffle=True, random_state=42)
# OOF(Out-Of-Fold)予測格納用
oof_df = base_df.copy()
oof_preds = np.zeros(len(oof_df)) # 全データ数分のゼロ配列を用意
for train_idx, val_idx in kf.split(oof_df):
X_train, X_val = x.iloc[train_idx], x.iloc[val_idx]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
model = lgb.LGBMRegressor()
model.fit(X_train, y_train)
# 検証データに対して予測 → oof_predsに格納
oof_preds[val_idx] = model.predict(X_val)
特徴量の重要度では、面積・築年数・駅徒歩分数あたりが上位に来ていました。
この辺りは割と予想通りですね。
結果どうだったか
実際に予測値-実際の家賃の値が高い順に眺めてみると、確かに「めっちゃいいじゃん!」と思う物件が多かったので、大方やりたいことは実現できた気がします。
(上位に事故物件が出てくるのでは?と思ったのですが、それっぽいのは見つけられませんでした)
が、TOP10のうち7つの物件が
「お探しの物件は掲載が終了しました」
となっていたので、やはり良い物件はすぐに埋まってしまうんだなあと。
掲載が残っていた物件も、問い合わせると既に他の人が申込済みとのことで、結局は予測モデルが出したお得物件に住むことは叶いませんでした。
あと全然知らなかったのですが、ペット可の物件でも猫はNGもしくは1匹まで、みたいな物件が多いんですね...。
にゃんこ好きとしては切ないところです。
終わりに
幸い猫がいっぱい飼える良い物件が見つかり、保護猫2匹をお迎えして福岡での新生活をスタートすることができました。
なので結論としては、
こんなモデル作る暇があったら不動産屋に行きましょう!!!

