乙Py先生のプログラミング教室
初学者のためのプログラミング学習サイト

python

あの伝説の国士無双13面待ち
これが出る確率を求めてみました。

解説動画はこちら


国士無双13面待ちが出る確率は?


今回は麻雀ネタです。

麻雀における最高峰の役「役満」の一つに
「国士無双」という役満があります

13種類の么九牌(一九字牌)を
各1枚以上集める特殊な形の役満です。


13面待ち
萬子・索子・筒子の1・9、東・南・西・北・白・發・中の
計13種類をすべて揃えた状態でアガリ待ちの状態のことです。

13



この13面待ちがどれくらいの確率でやってくるのか
プログラムで求めていきたいと思います。



1.接待麻雀モード

自分一人がずっと国士無双13面待ちを狙い
他はただパイを捨て続けるとした場合の確率です。



簡易に求めるコード
import random

# ヤオ九牌(1, 9, 字牌)のインデックス定義
YAOCHU_INDICES = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33]

def is_kokushi_13men_machi(tehai):
    """
    国士無双13面待ちの判定
    13種類のヤオ九牌がすべて1枚ずつ揃っているか
    """
    for idx in YAOCHU_INDICES:
        if tehai[idx] < 1:
            return False
    return True

def simulate_one_game_kokushi():
    # 山の作成(136枚)
    yama = []
    for i in range(34):
        yama.extend([i] * 4)
    random.shuffle(yama)

    # 全員の手牌(4人分)
    players_tehai = [[0] * 34 for _ in range(4)]
    
    # 配牌(各13枚)
    for p in range(4):
        for _ in range(13):
            pai = yama.pop()
            players_tehai[p][pai] += 1

    turn = 0
    while len(yama) > 14:
        turn += 1
        for p in range(4):
            # --- 1. ツモ ---
            tsumo_pai = yama.pop()
            players_tehai[p][tsumo_pai] += 1

            # --- 2. 判定(プレイヤー0のみ国士無双を判定) ---
            if p == 0 and is_kokushi_13men_machi(players_tehai[p]):
                return True, turn, p

            # --- 3. 捨て牌選択 ---
            if p == 0:
                # プレイヤー0: 国士無双を狙う戦略
                discard_candidates = []
                
                # 優先順位1: 中張牌を捨てる
                for i in range(34):
                    if i not in YAOCHU_INDICES and players_tehai[p][i] > 0:
                        discard_candidates.extend([i] * players_tehai[p][i])
                
                # 優先順位2: 被っているヤオ九牌を捨てる
                if not discard_candidates:
                    for i in YAOCHU_INDICES:
                        if players_tehai[p][i] >= 2:
                            discard_candidates.append(i)

                # 優先順位3: どれでも(13面待ち完成中など)
                if not discard_candidates:
                    discard_candidates = [i for i, count in enumerate(players_tehai[p]) if count > 0]
                
                discard_pai = random.choice(discard_candidates)
            else:
                # 他のプレイヤー: ツモ切り(今引いた牌をそのまま捨てる)
                discard_pai = tsumo_pai

            players_tehai[p][discard_pai] -= 1
            
            # 山がなくなったら終了(流局)
            if len(yama) <= 14:
                break
                
    return False, turn, None

def run_kokushi_experiment(episodes_per_step=10000):
    print(f"国士無双13面待ちシミュレーション ({episodes_per_step}局)\n")
    print("成立回数 | 成立確率 | 平均巡目")
    print("-----------------------------------------")

    success_count = 0
    total_turns = 0
        
    for _ in range(episodes_per_step):
        is_clear, turns, winner = simulate_one_game_kokushi()
        if is_clear:
            success_count += 1
            total_turns += turns
    
    prob = (success_count / episodes_per_step) * 100
    avg_turn = total_turns / success_count if success_count > 0 else 0
    print(f"{success_count:4}回  | {prob:6.2f}% | {avg_turn:5.2f}巡")

run_kokushi_experiment(100000)
国士無双13面待ちシミュレーション (100000局)

成立回数 | 成立確率 | 平均巡目
-----------------------------------------
 120回  |   0.12% | 16.44巡



接待麻雀モードになっていますが
10万局中120回
確率0.12%でした。



2.他家が確率でアガるモード

先ほどのプレイヤーがずっと国士無双を狙う状態で
他家が10巡目以降、確率でアガられてしまう状態の
確率を求めています。


3.13面待ちからアガる確率

他家が確率でアガるモードに加えて
13面待ちから、上がれたかどうかの
確率も求めました。

こちらはぜひ動画で確率をご覧ください。



まとめ

国士無双13面待ちは、それなりにアガるプレイヤーがいても
10000局に1回位は見れると推測されるので
年間300試合、試合平均11局だと、3-4年に1回は
13面待ちの実況が見れるかもしれません。

13面待ちでアガリの確率はさらに低いので(一生に一度出せるかどうか)
もう10年は見れないかもしれないですね。


今回は麻雀ネタでしたが
次回も多分麻雀ネタです。
それでは。



今回はDuckDBを用いた
簡単SQLデータ分析についてです

解説動画はこちら



DuckDB

データ分析したいけどSQLならわかるんだけど
Python良く分からないなー

そんな時はDuckDB使えばいいじゃない!!

ということで
DuckDBのご紹介です。

Python上でSQLでデータ取得できるライブラリで
DataFrameをそのままSQLテーブルとして扱えます。

pandasの複雑なメソッドを覚えなくても
SQL操作で任意のデータが抽出できるものです。
pandasも加えると更に深掘りも出来てお得です。


早速使い方を見ていきましょう。


インストール

Google Colabでは
インストール済みなので不要です。

ローカルなどでインストールしていない人は
下記コマンドを実行しておいてください。
pip install duckdb


ライブラリの呼び出し

これだけです。
import duckdb


操作の基本
# 実行結果を表示
duckdb.sql("SQL文").show()

# 実行結果を変数に格納
データフレーム変数 = duckdb.sql("SQL文").df()

データフレームを作ってSQL文を投げてみます
import pandas as pd

df = pd.DataFrame({
    "a":[1,2,3],
    "b":[10,20,30]
})

sql = "SELECT * FROM df WHERE a > 1"

result_df = duckdb.sql(sql).df()
result_df

ab
0220
1330

DuckDBではデータフレームや
ファイルに直接SQL文のクエリを実行し
データ抽出が行えます。


SQL分をファイルから読み込みする
sql = open("ファイルパス").read()
df = duckdb.sql(sql).df()


ファイルを直接クエリする

sql = open("ファイルパス").read()
df = duckdb.sql(sql).df()
Google Colabなどでもファイルを置いておけば参照できます。



サンプルデータで簡易分析

sklearnサンプルデータを用いて
DuckDBでSQL分析してみましょう。

今回用いるデータは
「California Housing Dataset」

カリフォルニアの
地域ごとの住宅価格などのデータです。
下記コードで読み込みできます。
import duckdb
import pandas as pd
from sklearn.datasets import fetch_california_housing

# データロード
data = fetch_california_housing(as_frame=True)

df = data.frame

# DuckDBに接続
con = duckdb.connect()

# DataFrameを登録
con.register("housing", df)

データの準備が整ったら
DuckDBでSQLを実行してみましょう。

1.平均住宅価格
sql = """
SELECT
    AVG(MedHouseVal) as avg_price
FROM housing
"""

print(con.sql(sql).df())

2.住宅価格ランキング
sql = """
SELECT
    MedHouseVal
FROM housing
GROUP BY MedHouseVal
ORDER BY MedHouseVal DESC
LIMIT 10
"""

print(con.sql(sql).df())

3.地域ごとの住宅価格
sql = """
SELECT
    ROUND(Latitude,1) as lat,
    ROUND(Longitude,1) as lon,
    AVG(MedHouseVal) as avg_price,
    COUNT(*) as houses
FROM housing
GROUP BY lat, lon
ORDER BY avg_price DESC, houses DESC
LIMIT 15
"""

print(con.sql(sql).df())

4.収入と住宅価格
sql = """
SELECT
    CASE
        WHEN MedInc < 2 THEN 'low'
        WHEN MedInc < 5 THEN 'middle'
        ELSE 'high'
    END as income_level,
    AVG(MedHouseVal) as avg_price,
    COUNT(*) as cnt
FROM housing
GROUP BY income_level
ORDER BY avg_price DESC
"""

print(con.sql(sql).df())

5.家の広さと価格
sql = """
SELECT
    ROUND(AveRooms,0) as rooms,
    AVG(MedHouseVal) as price,
    COUNT(*) as n
FROM housing
GROUP BY rooms
HAVING n > 50
ORDER BY rooms
"""

print(con.sql(sql).df())


応用編

海沿い住宅は高いのかどうか

カリフォルニアは 西側が海なので
経度(Longitude) で判定


sql = """
SELECT
    CASE
        WHEN Longitude < -121 THEN 'coastal'
        ELSE 'inland'
    END AS location_type,
    AVG(MedHouseVal) AS avg_house_price,
    COUNT(*) AS houses
FROM housing
GROUP BY location_type
"""

result = con.sql(sql).df()

print("海沿い住宅(coastal) vs 内陸住宅(inland)")
print(result)

結果はコードを試すか
動画の方をご覧ください





まとめ


DuckDBを用いるとSQLを使ったデータ分析が
簡単に行えます。

Pythonのコードに慣れていなくても
直接ファイルにSQLを実行して分析結果を得られます。

複雑な集計もSQLを書ければ出来ちゃうので
データアナリストでなくても簡単に分析できちゃいます。

手元にデータが有るけどデータベース作れない
SQLは分かるけどpython分からない人向け
超お手軽ライブラリなので、おすすめです

ぜひ試してみてください
それでは。





 

今回は
FastAPI × htmx で作るプロトタイプを爆速実装する方法
についてです。


解説動画はこちら




 FastAPI × htmx

昨今のPython界隈は streamlit というライブラリのおかげで
フロントエンドもまとめて作ることができる様になりました。

そのおかげで、似たような見た目ばかりになる
streamlit臭のするデザインのモックばかり
という哀しい現象が起きてきています。

なので、こんな状況を打破すべく
 - デザイン部分は自由に作り込める
 - フロントエンド(React/Vue)の学習コストが高すぎて、モック作りが進まない
 - FastAPI(堅牢なバックエンド) + htmx(HTML だけで Ajax)というシンプル構成

という FastAPI + htmx の組み合わせの方法をお伝えします。


FastAPI とは何か

爆速で動く、現代的な『注文受け付け窓口』
Python言語で作られたWEBフレームワークです。

プログラムの実行速度が非常に速く
開発者がコードを書くスピードも速くなるように設計されており

仕様書(APIドキュメント)を自動で作成
型(データ)のチェックなども行ってくれます。


htmx とは何か

HTMLを『魔法の言葉』に変える道具
通常、Webページの一部を書き換えるには
JavaScriptをたくさん書く必要があります。

htmxだと「HTMLを数文字書き足すだけ」で実現でき
JavaScriptの部分をHTMLの属性だけで指示できるます。

学習コストが劇的に低い(HTMLが分かるなら)
ので初学者でも取り組みが楽です。


なので
この組み合わせが事業モックに最適です。

フロントとバックエンド、2つの学習コストが低い
シンプルでサクサク動くし、修正が簡単

モックとして作った後も
そのまま本番サービス転用も可能です。



環境構築と基本設定

Python側のインストール
pip install fastapi uvicorn jinja2 python-multipart


FASTAPIの実行方法
main.py のあるディレクトリで
uvicorn main:app --reload

WEBブラウザー側で http://127.0.0.1:8000/
にアクセスするとFASTAPIの画面が見れます。



デモプロジェクトの概要


デモプロジェクトのディレクトリ構成

├── main.py
└── templates/
    ├── index.html        (ベースレイアウト)
    ├── customer_list.html (顧客一覧)
    ├── customer_detail.html (顧客詳細)
    ├── stock_list.html    (在庫一覧)
    ├── stock_detail.html  (在庫詳細)
    └── chat.html          (AIチャット)


こんな内容のものを作ることができます。
スクリーンショット 2026-02-21 17.28.21
スクリーンショット 2026-02-21 17.28.28
スクリーンショット 2026-02-21 17.28.34




main.py のコード

FAST API のコードはこんな感じになっています。
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from datetime import datetime

app = FastAPI()
templates = Jinja2Templates(directory="templates")

# --- ダミーデータ ---
CUSTOMERS = {
    "C001": {"id": "C001", "name": "山田 太郎", "birth": "1985/05/20", "gender": "男性", "address": "東京都渋谷区...", "rank": "S", "items": [{"name": "ルンバ", "qty": 1, "price": 50000}]},
    "C002": {"id": "C002", "name": "佐藤 花子", "birth": "1992/11/02", "gender": "女性", "address": "大阪府大阪市...", "rank": "A", "items": [{"name": "化粧水", "qty": 2, "price": 4000}]},
    # ...他3件(実装上は同様に定義)
}

STOCKS = {
    "S001": {"id": "S001", "name": "ロキソニンS", "category": "解熱鎮痛剤", "count": 150, "desc": "第1類医薬品。速効性に優れています。"},
    "S002": {"id": "S002", "name": "アレグラFX", "category": "鼻炎薬", "count": 45, "desc": "眠くなりにくいアレルギー専用鼻炎薬。"},
}

def calc_age(birth_str):
    birth_date = datetime.strptime(birth_str, "%Y/%m/%d")
    today = datetime.today()
    return today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day))

# --- Routes ---
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

# 1. 顧客管理
@app.get("/customers", response_class=HTMLResponse)
async def list_customers(request: Request):
    data = []
    for c in CUSTOMERS.values():
        data.append({**c, "age": calc_age(c['birth']), "birth_month": c['birth'][:7]})
    return templates.TemplateResponse("customer_list.html", {"request": request, "customers": data})

@app.get("/customers/{cid}", response_class=HTMLResponse)
async def detail_customer(request: Request, cid: str):
    c = CUSTOMERS.get(cid)
    return templates.TemplateResponse("customer_detail.html", {"request": request, "customer": c, "age": calc_age(c['birth'])})

# 2. 在庫チェック
@app.get("/stocks", response_class=HTMLResponse)
async def list_stocks(request: Request):
    return templates.TemplateResponse("stock_list.html", {"request": request, "stocks": STOCKS.values()})

@app.get("/stocks/{sid}", response_class=HTMLResponse)
async def detail_stock(request: Request, sid: str):
    return templates.TemplateResponse("stock_detail.html", {"request": request, "stock": STOCKS.get(sid)})

# 3. AIチャット
@app.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request):
    return templates.TemplateResponse("chat.html", {"request": request})

@app.post("/chat/send", response_class=HTMLResponse)
async def chat_send(request: Request, message: str = Form(...)):
    # 簡易的なAIレスポンスのシミュレーション
    ai_response = f"「{message}」について承知いたしました。AIとしての回答をここに表示します。"
    return f"""ここは省略しています"""

index.htmlなどは
AIエージェントを使って作ってみてください





まとめ


FAST API + htmx を用いると爆速でモック開発ができる
こちらは必要最低限の知識を学ぶだけで済む
デザイン部分は自由に作り込めるので好きなCSSを勉強しよう

足りない部分はAIエージェント使ってください
こちらは作りたいものを指示するだけ
本番作りもAIエージェントで爆速開発できます

PS
最近はこれでプロトタイプ開発していたりします。
AIエージェントも使いこなせると
爆速で開発できる様にできますよ

それでは。

 

今回は地震データの可視化についてです
最近地震が多いので、plotlyで可視化してみました。


解説動画はこちら








データの入手先


気象庁の地震データベース

コードを動かしてみたい方は
CSVがダウンロードできるようなので
手元にダウンロードしてみてください。



データの読み込み

こちらのコードはGoogle Colabで動くようになっています。
動かしたい場合は Colabの画面左メニューから
フォルダマークをクリックすると
ファイル置き場が見えるので、そこにCSVファイルを
ドラッグなどで配置します。

ファイルを読み込みするには下記のコードです。

# 必要なライブラリのインポート
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import numpy as np
import warnings
warnings.filterwarnings('ignore')

file_path = "/content/地震リスト.csv"
df = pd.read_csv(file_path)
df.head()

df変数にデータが読み込まれると思います。



データの整形


CSVそのままのデータでは
うまく可視化が行えないため
可視化用にデータを加工する必要があります。

下記のコードを実行すると
可視化用のカラムなどが追加されます。


# データの前処理
s_order = ['震度1', '震度2', '震度3', '震度4', '震度5弱', '震度5強', '震度6弱', '震度6強', '震度7']
target = "トカラ列島近海"

def preprocess_data(df):
    # 緯度・経度の変換(度分秒から度へ)
    def convert_coordinate(coord_str):
        # 例: "29°28.2′N" -> 29.47
        if '°' in coord_str and '′' in coord_str:
            parts = coord_str.replace('N', '').replace('E', '').replace('S', '').replace('W', '')
            degree_part = parts.split('°')[0]
            minute_part = parts.split('°')[1].replace('′', '')
            return float(degree_part) + float(minute_part) / 60
        return float(coord_str)
    
    df['緯度_数値'] = df['緯度'].apply(convert_coordinate)
    df['経度_数値'] = df['経度'].apply(convert_coordinate)
    
    # 深さの数値化
    df['深さ_数値'] = df['深さ'].str.replace(' km', '').astype(float)
    
    # 震度の数値化
    s_mapping = {
        '震度1': 1, '震度2': 2, '震度3': 3, '震度4': 4, '震度5弱': 5,
        '震度5強': 5.5, '震度6弱': 6, '震度6強': 6.5, '震度7': 7
    }
    df['最大震度_数値'] = df['最大震度'].map(s_mapping)
    df['最大震度'] = pd.Categorical(df['最大震度'], categories=s_order, ordered=True)
    return df

# データの前処理を実行
df = preprocess_data(df)
df = df[df["震央地名"]==target]
df.head()



時系列の可視化


最初は時系列で
どれだけ地震が発生しているのかを
見てみましょう

日別、震度別で時系列で地震の回数を
表示してみます。

# 時系列分析(日付別)
df['日付'] = pd.to_datetime(df['地震の発生日'])
daily_shindo_counts = df.groupby(['日付', '最大震度']).size().reset_index(name='地震回数')

# 震度の順序を維持
daily_shindo_counts['最大震度'] = pd.Categorical(
    daily_shindo_counts['最大震度'], 
    categories=s_order, 
    ordered=True
)

# 震度別横並び棒グラフ
fig_time = px.bar(
    daily_shindo_counts,
    x='日付',
    y='地震回数',
    color='最大震度',
    title='日付別地震発生回数(震度別)',
    labels={'地震回数': '地震発生回数', '日付': '発生日'},
    category_orders={'最大震度': s_order},
    barmode='group'  # 横並びに表示
)

# レイアウトの調整
fig_time.update_layout(
    xaxis_tickangle=-45,
    height=600,
    width=1000,
    legend=dict(
        orientation="v",
        yanchor="top",
        y=1,
        xanchor="left",
        x=1.01
    )
)

fig_time.show()
スクリーンショット 2025-07-05 17.27.01

データは直近の1週間分しか無いようです。
7月前後で急増しているのが分かります。



箱ひげ図の可視化


次は箱ひげ図で
「トカラ列島近海」の詳細を見てみましょう。


震度ごとにマグニチュードをまとめると
このような感じになります。

# 震央地名 最大震度別での箱ひげ図

fig_box = px.box(df, 
                 x='震央地名', 
                 y='M',
                 color='最大震度',
                 title='震央地名別 マグニチュード分布(最大震度別)',
                 labels={'M': 'マグニチュード', '震央地名': '震央地名'},
                 category_orders={'最大震度': s_order})

fig_box.update_layout(
    xaxis_tickangle=-45,
    height=600,
    width=1000
)
fig_box.show()
スクリーンショット 2025-07-05 17.29.52
同じ震度でも、マグニチュードにはバラツキがありますが
マグニチュードが上がるにつれ、震度も大きくなっています。



地図表示

今後は地図で震度を表示してみましょう
OpenStreetMapを使用して、緯度軽度を用いて
地図に震度を表示させてみます。

# マップ表示用のデータ準備
df_map = df.copy()
df_map['緯度_数値'] = pd.to_numeric(df_map['緯度_数値'], errors='coerce')
df_map['経度_数値'] = pd.to_numeric(df_map['経度_数値'], errors='coerce')
df_map['M'] = pd.to_numeric(df_map['M'], errors='coerce')
df_map = df_map.dropna(subset=['緯度_数値', '経度_数値', 'M'])
df_map['最大震度'] = pd.Categorical(df_map['最大震度'], categories=s_order, ordered=True)

# 緯度経度と最大震度・Mを用いたmap表示(OpenStreetMap使用)
fig_map2 = go.Figure()

# 震度別に色分けしてプロット
震度_colors = {
    '震度1': '#90EE90',  # 薄緑
    '震度2': '#FFD700',  # 金色
    '震度3': '#FFA500',  # オレンジ
    '震度4': '#FF6347',  # トマト色
    '震度5弱': '#FF4500',  # 赤オレンジ
    '震度5強': '#FF0000',  # 赤
    '震度6弱': '#8B0000',  # 濃い赤
    '震度6強': '#4B0082',  # インディゴ
    '震度7': '#000000'   # 黒
}

for 震度 in s_order:
    if 震度 in df_map['最大震度'].values:
        subset = df_map[df_map['最大震度'] == 震度]
        fig_map2.add_trace(go.Scattermapbox(
            lat=subset['緯度_数値'],
            lon=subset['経度_数値'],
            mode='markers',
            marker=dict(
                size=subset['最大震度_数値'] ** 2 ,  # サイズ調整
                color=震度_colors.get(震度, '#000000'),
                sizemode='diameter'
            ),
            text=subset['震央地名'],
            hovertemplate='%{text}
' + '緯度: %{lat:.2f}
' + '経度: %{lon:.2f}
' + 'マグニチュード: %{customdata[0]}
' + '最大震度: %{customdata[1]}
' + '', customdata=list(zip(subset['M'], subset['最大震度'])), name=震度 )) fig_map2.update_layout( title='地震発生位置(最大震度・マグニチュード)- OpenStreetMap', mapbox=dict( style="open-street-map", center=dict(lat=df_map['緯度_数値'].mean(), lon=df_map['経度_数値'].mean()), zoom=8 ), height=700, width=1000 ) fig_map2.show()
スクリーンショット 2025-07-05 17.34.15

今回の地震は、かなり局所的に起きていることが分かります。
特に震度5以上が発生した箇所がかなり近く
この近辺は注意が必要な地域に見えます。


散布図表示


最後に
マグニチュード、震度、深さを
散布図で見てみましょう

# 深さとマグニチュードの関係
fig_scatter = px.scatter(
    df,
    x='深さ_数値',
    y='M',
    color='最大震度',
    size='最大震度_数値',
    hover_name='震央地名',
    hover_data={
        '地震の発生日': True,
        '地震の発生時刻': True
    },
    title='深さとマグニチュードの関係(最大震度別)',
    labels={'深さ_数値': '深さ (km)', 'M': 'マグニチュード'},
    category_orders={'最大震度': s_order}
)

fig_scatter.show()
スクリーンショット 2025-07-05 17.36.27


どの深さでも、満遍なく起きているように見えますね
地震自体は、いつ何時、どこでも起きてしまいます。



まとめ

緯度経度が記載されているデータは
plotlyなどを用いれば地図表示が比較的
簡単に行うことができます。

可視化の表現の幅が広がるので
覚えておくと良いかもしれませんね


今日はここまでです
それでは




今回は金融庁の
NISA積立シミュレーターがやばいので
その解説です。


解説動画はこちら


 

金融庁の積立シミュレーター

これがそのシミュレーターです。

サイト

ここで
毎月の積立1万円
年利12%
40年間

で試算してみます。
スクリーンショット 2025-06-14 17.12.47


こんな感じになりました。

年利が高すぎますが
計算がわかりやすいようにして
計算してみた結果です。

40年後の資産は
11765万円になりました



Pythonでのシミュレーションコード


先程のシミュレーターと同じ計算を
Pythonでも計算してみましょう。

シミュレーションコード

monthly_rate = 0.12 / 12       # 月利(年利12%)
months = 40 * 12               # 総月数(480ヶ月)
monthly_payment = 10000       # 毎月の積立額(マイナス表記にする)

total = 0
for _ in range(months):
    total = total * (1 + monthly_rate) + monthly_payment

print(f"将来の資産総額: {total:,.0f} 円")
将来の資産総額: 117,647,725 円


金融庁のシミュレーターの結果は
11765万円 なので
ほぼ同じ結果になりました。


落とし穴

実はここに落とし穴があります!!!!

年利計算での正しいシミュレーション結果は
こうなります。
(年末に積立するパターン)

year_rate = 0.12            # 年利12%
years = 40                  # 総年数(40年)
year_payment = 120000    # 年の積立額

total = 0
for _ in range(years):
    total = total * (1 + year_rate) + year_payment

print(f"将来の資産総額: {total:,.0f} 円")
将来の資産総額: 92,050,970 円

先程の計算結果 : 117,647,725 円
とは大きく乖離してきます。


からくり

金融庁のサイトのシミュレーションは年利ではなく
年利を12ヶ月で割った月利の複利(年利 / 12)で計算しているようです。

そのため年利と月利では、計算結果が大きく異なってきます。

もし月利複利計算を正しくするとしたら
12乗して年利になるような値を月利にしないといけません。

月利 = (1 + 0.12) ** (1/12) - 1
= 0.009488792934583046 = 0.948%

年利から月利に直して計算するコードはこちら
monthly_rate = (1 + 0.12) ** (1/12) - 1  # 月利(年利12%)
months = 40 * 12                          # 総月数(480ヶ月)
monthly_payment = 10000                 # 毎月の積立額(マイナス表記にする)

total = 0
for _ in range(months):
    total = total * (1 + monthly_rate) + monthly_payment

print(f"将来の資産総額: {total:,.0f} 円")
将来の資産総額: 97,010,200 円

月利計算だとこのくらいの金額になります。




資産推移のシミュレーター

年利を月利換算しても正しく計算するようにしたものは
こんな感じになります。
import pandas as pd
import plotly.express as px

def plot_investment(rate_percent, years, payment_man):
    annual_rate = rate_percent / 100
    monthly_rate = (1 + annual_rate) ** (1/12) - 1
    months = years * 12
    monthly_payment = payment_man * 10000

    results, ci_total, am_total = [], 0, 0
    for month in range(1, months + 1):
        ci_total = ci_total * (1 + monthly_rate) + monthly_payment
        am_total += monthly_payment
        results.append({'月': month, '資産総額': ci_total, '積立金額': am_total})

    df = pd.DataFrame(results)
    fig = px.line(df, x='月', y=['資産総額', '積立金額'],
                  labels={'value': '金額(円)', 'variable': '項目'},
                  title=f'毎月{payment_man}万円積立(年利{rate_percent:.1f}%・{years}年)の資産推移')
    
    fig.update_layout(
        yaxis_tickformat=',',
        height=400
    )
    fig.show()

積立シミュレーション設定

#@title 積立シミュレーション設定
rate_percent = 5.1 # @param {type:"slider", min:1.0, max:20.0, step:0.1}
years = 30 # @param {type:"slider", min:10, max:50, step:1}
payment_man = 20 # @param {type:"slider", min:1, max:30, step:1}

plot_investment(rate_percent, years, payment_man)

colabで実行するとこのような画面が出てきます
スクリーンショット 2025-06-14 17.34.43

これを設定して実行すると
スクリーンショット 2025-06-14 17.34.53
積立のシミュレーション結果が反映されます。

colabで試せるので
コピペして試してみてください。



まとめ

金融庁のサイトのシミュレーターは
年利と月利を齟齬しているので
数十年後の金額が大きく乖離してくるようです。

単利と複利、月利と年利
この辺りの関係を正しく計算できるように
日々プログラムでシミュレーションすると安全です。

資産形成に取り組みたい方は
この機会にプログラミングも併せて
学んでみてはいかがでしょうか?


それでは。


このページのトップヘ