👋

簡単な収益予測シミュレーション

に公開

ボトムアップ売上シミュレーション入門

はじめに

「来年の売上はどうなるだろう?」「営業担当者を2名増やしたらどのくらい売上が上がるだろう?」

こうした疑問に、勘や経験だけでなく、数値で答える方法があります。それが売上シミュレーションです。

売上予測をしたかったため、o3に手伝ってもらいながら、簡単なコードを実装しました。
この記事では、シンプルな予測モデルである「ボトムアップモデル」について、解説します。

売上シミュレーションとは

基本的な考え方

売上シミュレーションは、「もしも〜だったら」を数値で検証する手法です。

例えば:

  • 「もしもエンジニアを3名増やしたら?」
  • 「もしも稼働率が80%から85%に向上したら?」
  • 「もしも単価を10%上げることができたら?」

こうした仮定のもとで、売上がどう変化するかを計算で予測します。

なぜ重要なのか

  1. 計画的な経営: 感覚ではなく数値に基づいた判断ができる
  2. リスク回避: 実際に投資する前に効果を見積もれる
  3. 説得力のある提案: 上司や投資家に根拠を示せる

ボトムアップモデルの基本

概要

ボトムアップモデルは、「人月×稼働率×平均単価」を基本とする最もシンプルなモデルです。

基本の計算式

月次売上 = 人数 × 月間稼働時間 × 稼働率 × 時間単価

適用できるビジネス

  • IT・システム開発会社
  • コンサルティング会社
  • デザイン・クリエイティブ会社
  • 士業事務所(税理士、弁護士など)

要するに、人の時間を売るビジネスに最適です。

具体例で理解する

IT企業の例

現在の状況:

  • エンジニア数:10名
  • 月間稼働時間:160時間(1日8時間×20日)
  • 平均稼働率:75%(残り25%は営業活動や待機時間)
  • 平均時間単価:8,000円

計算:

月次売上 = 10名 × 160時間 × 0.75 × 8,000円
        = 9,600,000円(960万円)

年間売上予測:

年間売上 = 960万円 × 12ヶ月 = 1億1,520万円

「もしも」のシナリオ

シナリオ1: エンジニアを3名増やした場合

月次売上 = 13名 × 160時間 × 0.75 × 8,000円
        = 12,480,000円(1,248万円)
増加分 = 1,248万円 - 960万円 = 288万円/月

シナリオ2: 稼働率を80%に向上させた場合

月次売上 = 10名 × 160時間 × 0.80 × 8,000円
        = 10,240,000円(1,024万円)
増加分 = 1,024万円 - 960万円 = 64万円/月

シナリオ3: 時間単価を10%アップした場合

月次売上 = 10名 × 160時間 × 0.75 × 8,800円
        = 10,560,000円(1,056万円)
増加分 = 1,056万円 - 960万円 = 96万円/月

パラメータの意味

1. 人数

  • 意味: 実際に案件に携わるスタッフ数
  • 注意点: 管理職や営業専任は含めない

2. 月間稼働時間

  • 一般的な値: 140〜180時間
  • 計算方法: 営業日数 × 1日の労働時間

3. 稼働率

  • 意味: 全労働時間のうち、実際に売上に貢献する時間の割合
  • 含まれないもの: 営業活動、社内会議、研修、待機時間
  • 一般的な値: 60〜80%

4. 時間単価

  • 意味: 顧客に請求する1時間あたりの料金
  • 設定方法: 過去の案件実績から平均を算出

メリット・デメリット

メリット

  • 理解しやすい: 誰でも直感的に理解できる
  • 計算が簡単: Excelでも十分実装可能
  • 即座に効果測定: 採用や単価変更の効果がすぐ分かる

デメリット

  • 営業力を考慮しない: 案件獲得能力の変化を反映できない
  • 市場変動に弱い: 経済環境の変化に対応困難
  • 顧客継続性を無視: リピート案件の効果を見落としがち

シンプルなモデルなため、概算程度の予測になります。
一方、稼働率などを調整し、売上予測を見ることで、ストック技術の開発やR&Dにどの程度時間を割くことができるか、といったことを見積もることができます。

Python実装の解説

Pythonでシミュレーションする場合の解説をします。
ボトムアップモデルのサンプルコードは以下です。

サンプルコード
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ボトムアップ売上シミュレーション

このファイルは「人月 × 稼働率 × 平均単価」を月次で積み上げるボトムアップモデルに特化しています。
プロフェッショナルサービス組織の売上予測に最適で、採用計画と直結した予測が可能です。

主な機能:
- ボトムアップ売上シミュレーション
- 年度表示対応(1年目4月から開始)
- 詳細な可視化(売上、ヘッドカウント、累積売上、採用計画)
- CSV出力機能
- 高解像度PNG保存
"""

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import platform
from datetime import datetime
import os

class BottomUpSimulation:
    """
    ボトムアップ売上シミュレーション専用クラス
    
    「人月 × 稼働率 × 平均単価」を月次で積み上げる手法で、
    プロフェッショナルサービス組織の売上予測を行います。
    """
    
    def __init__(self):
        """初期化とフォント設定"""
        self.results = {}
        self._setup_japanese_font()
        print("✅ ボトムアップシミュレーター初期化完了")
    
    def _setup_japanese_font(self):
        """日本語フォントの設定"""
        try:
            import matplotlib.font_manager as fm
            
            # 利用可能なフォント一覧を取得
            available_fonts = [f.name for f in fm.fontManager.ttflist]
            
            system = platform.system()
            if system == "Windows":
                japanese_fonts = ['Yu Gothic', 'Meiryo', 'MS Gothic', 'MS PGothic']
            elif system == "Darwin":  # macOS
                japanese_fonts = ['Hiragino Sans', 'Hiragino Kaku Gothic Pro', 'Arial Unicode MS']
            else:  # Linux
                japanese_fonts = ['Noto Sans CJK JP', 'IPAexGothic', 'IPAPGothic', 'VL PGothic']
            
            # 利用可能な日本語フォントを検索
            found_font = None
            for font in japanese_fonts:
                if font in available_fonts:
                    found_font = font
                    break
            
            if found_font:
                plt.rcParams['font.family'] = found_font
                print(f"✅ 日本語フォント設定: {found_font}")
            else:
                plt.rcParams['font.family'] = 'DejaVu Sans'
                print("⚠️  日本語フォントが見つかりません。英語表示になります。")
            
            plt.rcParams['axes.unicode_minus'] = False
            
        except Exception as e:
            print(f"⚠️  フォント設定エラー: {e}")
            plt.rcParams['font.family'] = 'DejaVu Sans'
            plt.rcParams['axes.unicode_minus'] = False
    
    def _generate_year_month_labels(self, total_months, start_month=4):
        """
        年度表示ラベルを生成する関数
        
        Parameters:
        -----------
        total_months : int
            総月数
        start_month : int
            開始月(デフォルト4月)
            
        Returns:
        --------
        tuple : (月番号リスト, 年度ラベルリスト, 年度境界位置)
        """
        months = []
        labels = []
        year_boundaries = []
        
        current_month = start_month
        current_year = 1
        
        for i in range(total_months):
            months.append(i + 1)
            
            # 年度境界の記録(4月の位置)
            if current_month == start_month:
                year_boundaries.append(i + 1)
                year_label = f"{current_year}年目"
            else:
                year_label = ""
            
            # 月表示
            month_label = f"{current_month}月"
            
            # 年度と月を組み合わせ
            if year_label:
                full_label = f"{year_label}\n{month_label}"
            else:
                full_label = month_label
            
            labels.append(full_label)
            
            # 次の月に進む
            current_month += 1
            if current_month > 12:
                current_month = 1
            
            # 4月になったら次年度
            if current_month == start_month and i > 0:
                current_year += 1
        
        return months, labels, year_boundaries
    
    def _apply_year_labels(self, ax, data_length, year_labels, year_boundaries):
        """
        グラフに年度表示を適用する関数
        
        Parameters:
        -----------
        ax : matplotlib.axes
            対象のaxesオブジェクト
        data_length : int
            データの長さ
        year_labels : list
            年度ラベルのリスト
        year_boundaries : list
            年度境界の位置リスト
        """
        if not year_labels or data_length == 0:
            return
        
        # 適切な間隔でラベルを表示(データが長い場合は間引く)
        if data_length <= 12:
            # 12ヶ月以下の場合は全て表示
            tick_positions = list(range(1, data_length + 1))
            tick_labels = year_labels[:data_length]
        elif data_length <= 36:
            # 36ヶ月以下の場合は3ヶ月おきに表示
            tick_positions = list(range(1, data_length + 1, 3))
            tick_labels = [year_labels[i-1] for i in tick_positions if i-1 < len(year_labels)]
        else:
            # それ以上の場合は年度境界のみ表示
            tick_positions = [pos for pos in year_boundaries if pos <= data_length]
            tick_labels = [year_labels[pos-1] for pos in tick_positions if pos-1 < len(year_labels)]
        
        ax.set_xticks(tick_positions)
        ax.set_xticklabels(tick_labels, rotation=45, ha='right')
        
        # 年度境界に縦線を追加
        for boundary in year_boundaries:
            if boundary <= data_length and boundary > 1:
                ax.axvline(x=boundary, color='gray', linestyle='--', alpha=0.5, linewidth=1)
    
    def run_simulation(self, 
                      initial_headcount=10,
                      monthly_hires=[2, 1, 2, 1, 3, 2, 1, 2, 2, 1, 2, 1],
                      utilization_rate=0.75,
                      hourly_rate=8000,
                      working_hours_per_month=160):
        """
        ボトムアップ売上シミュレーション実行
        
        このモデルは「人月 × 稼働率 × 平均単価」を月次で積み上げる手法です。
        プロフェッショナルサービス組織に最適で、採用計画と直結した予測が可能です。
        
        Parameters:
        -----------
        initial_headcount : int
            初期ヘッドカウント(開始時点の従業員数)
        monthly_hires : list
            月次採用予定数のリスト
        utilization_rate : float
            稼働率(0.0-1.0)。billable時間の割合
        hourly_rate : float
            時間単価(円)
        working_hours_per_month : int
            月間労働時間(時間)
            
        Returns:
        --------
        dict : シミュレーション結果
        """
        
        print("=" * 80)
        print("🔧 ボトムアップ売上シミュレーション実行中...")
        print("=" * 80)
        
        # 期間を採用計画の長さに自動調整
        months = len(monthly_hires)
        
        print(f"📊 シミュレーション設定:")
        print(f"   初期ヘッドカウント: {initial_headcount}人")
        print(f"   採用計画: {monthly_hires}")
        print(f"   稼働率: {utilization_rate*100:.1f}%")
        print(f"   時間単価: ¥{hourly_rate:,}/時間")
        print(f"   月間稼働時間: {working_hours_per_month}時間")
        print(f"   シミュレーション期間: {months}ヶ月")
        
        # 結果格納用のリスト初期化
        results = {
            'month': [],           # 月
            'headcount': [],       # ヘッドカウント
            'hires': [],           # 月次採用数
            'billable_hours': [],  # 請求可能時間
            'revenue': [],         # 売上
            'cumulative_revenue': [] # 累積売上
        }
        
        # 現在のヘッドカウントを初期値に設定
        current_headcount = initial_headcount
        cumulative_revenue = 0
        
        print(f"\n📈 月次シミュレーション結果:")
        print(f"{'月':>3} {'採用':>4} {'HC':>4} {'請求時間':>8} {'月次売上':>12} {'累積売上':>12}")
        print("-" * 60)
        
        # 月次シミュレーション実行
        for month in range(1, months + 1):
            # ステップ1: 新規採用の追加
            # 前月末に採用した人員を当月から稼働開始と仮定
            if month > 1:
                current_headcount += monthly_hires[month - 2]
            
            # 当月の採用数を記録
            current_hires = monthly_hires[month - 1]
            
            # ステップ2: 請求可能時間の計算
            # 総労働時間 × 稼働率 = 請求可能時間
            total_hours = current_headcount * working_hours_per_month
            billable_hours = total_hours * utilization_rate
            
            # ステップ3: 月次売上の計算
            # 請求可能時間 × 時間単価 = 月次売上
            monthly_revenue = billable_hours * hourly_rate
            cumulative_revenue += monthly_revenue
            
            # ステップ4: 結果の記録
            results['month'].append(month)
            results['headcount'].append(current_headcount)
            results['hires'].append(current_hires)
            results['billable_hours'].append(billable_hours)
            results['revenue'].append(monthly_revenue)
            results['cumulative_revenue'].append(cumulative_revenue)
            
            # 進捗表示
            print(f"{month:3d} {current_hires:4d} {current_headcount:4d} "
                  f"{billable_hours:8.0f} {monthly_revenue:12,.0f} {cumulative_revenue:12,.0f}")
        
        # 結果をDataFrameに変換
        df_results = pd.DataFrame(results)
        
        # 統計情報の計算
        stats_info = {
            'total_revenue': cumulative_revenue,
            'average_monthly_revenue': df_results['revenue'].mean(),
            'max_monthly_revenue': df_results['revenue'].max(),
            'min_monthly_revenue': df_results['revenue'].min(),
            'final_headcount': current_headcount,
            'average_headcount': df_results['headcount'].mean(),
            'total_hires': sum(monthly_hires),
            'average_utilization': utilization_rate,
            'effective_hourly_rate': hourly_rate
        }
        
        print("\n" + "=" * 80)
        print("📊 ボトムアップシミュレーション結果サマリー")
        print("=" * 80)
        print(f"総売上:             {stats_info['total_revenue']:15,.0f} 円")
        print(f"月平均売上:         {stats_info['average_monthly_revenue']:15,.0f} 円")
        print(f"最大月次売上:       {stats_info['max_monthly_revenue']:15,.0f} 円")
        print(f"最小月次売上:       {stats_info['min_monthly_revenue']:15,.0f} 円")
        print(f"最終ヘッドカウント: {stats_info['final_headcount']:15.0f} 人")
        print(f"平均ヘッドカウント: {stats_info['average_headcount']:15.1f} 人")
        print(f"総採用人数:         {stats_info['total_hires']:15.0f} 人")
        print(f"稼働率:             {stats_info['average_utilization']*100:15.1f} %")
        print(f"時間単価:           {stats_info['effective_hourly_rate']:15,.0f} 円")
        
        # 結果を保存
        self.results = {
            'data': df_results,
            'stats': stats_info,
            'parameters': {
                'initial_headcount': initial_headcount,
                'monthly_hires': monthly_hires,
                'utilization_rate': utilization_rate,
                'hourly_rate': hourly_rate,
                'working_hours_per_month': working_hours_per_month,
                'months': months
            }
        }
        
        return self.results
    
    def visualize_results(self, save_plots=True, output_dir="bottom_up_plots"):
        """
        シミュレーション結果の可視化
        
        ボトムアップモデルの結果を4つのグラフで詳細表示します。
        
        Parameters:
        -----------
        save_plots : bool
            グラフをPNGファイルとして保存するかどうか
        output_dir : str
            保存先ディレクトリ名
        """
        
        if not self.results:
            print("⚠️  まずシミュレーションを実行してください。")
            return
        
        print("\n" + "=" * 80)
        print("📈 結果可視化中...")
        print("=" * 80)
        
        # 保存ディレクトリの作成
        if save_plots:
            if not os.path.exists(output_dir):
                os.makedirs(output_dir)
                print(f"📁 出力ディレクトリを作成: {output_dir}")
        
        # データ取得
        data = self.results['data']
        stats = self.results['stats']
        
        # 年度表示ラベルの生成
        months_list, year_labels, year_boundaries = self._generate_year_month_labels(len(data))
        
        # 図のサイズとスタイル設定
        plt.style.use('default')
        fig = plt.figure(figsize=(16, 12))
        
        # 日本語フォントの再設定
        try:
            system = platform.system()
            if system == "Windows":
                plt.rcParams['font.family'] = ['Yu Gothic', 'Meiryo', 'MS Gothic']
            elif system == "Darwin":  # macOS
                plt.rcParams['font.family'] = ['Hiragino Sans', 'Hiragino Kaku Gothic Pro']
            else:  # Linux
                plt.rcParams['font.family'] = ['Noto Sans CJK JP', 'IPAexGothic', 'DejaVu Sans']
            
            plt.rcParams['axes.unicode_minus'] = False
        except:
            plt.rcParams['font.family'] = 'DejaVu Sans'
            plt.rcParams['axes.unicode_minus'] = False
        
        # 1. 月次売上推移
        ax1 = plt.subplot(2, 2, 1)
        plt.plot(data['month'], data['revenue'] / 1000000, 
                marker='o', linewidth=3, markersize=8, color='#2E86AB',
                markerfacecolor='white', markeredgewidth=2)
        plt.title('月次売上推移', fontsize=16, fontweight='bold', pad=20)
        plt.xlabel('期間', fontsize=12)
        plt.ylabel('売上 (百万円)', fontsize=12)
        plt.grid(True, alpha=0.3)
        
        # 年度表示を適用
        self._apply_year_labels(ax1, len(data), year_labels, year_boundaries)
        
        # 平均線を追加
        plt.axhline(y=stats['average_monthly_revenue']/1000000, 
                   color='red', linestyle='--', alpha=0.7, 
                   label=f'平均: {stats["average_monthly_revenue"]/1000000:.1f}M円')
        plt.legend()
        
        # 2. ヘッドカウントと採用計画
        ax2 = plt.subplot(2, 2, 2)
        
        # ヘッドカウント(左軸)
        line1 = ax2.plot(data['month'], data['headcount'], 
                        marker='s', linewidth=3, markersize=8, color='#A23B72',
                        markerfacecolor='white', markeredgewidth=2, label='ヘッドカウント')
        ax2.set_ylabel('ヘッドカウント (人)', color='#A23B72', fontsize=12)
        
        # 採用数(右軸)
        ax2_twin = ax2.twinx()
        bars = ax2_twin.bar(data['month'], data['hires'], 
                           alpha=0.6, color='#F18F01', label='月次採用数')
        ax2_twin.set_ylabel('採用数 (人)', color='#F18F01', fontsize=12)
        
        plt.title('ヘッドカウント推移と採用計画', fontsize=16, fontweight='bold', pad=20)
        ax2.set_xlabel('期間', fontsize=12)
        ax2.grid(True, alpha=0.3)
        
        # 年度表示を適用
        self._apply_year_labels(ax2, len(data), year_labels, year_boundaries)
        
        # 凡例の統合
        lines = line1 + [bars]
        labels = [l.get_label() for l in line1] + ['月次採用数']
        ax2.legend(lines, labels, loc='upper left')
        
        # 3. 累積売上
        ax3 = plt.subplot(2, 2, 3)
        plt.plot(data['month'], data['cumulative_revenue'] / 1000000, 
                marker='D', linewidth=3, markersize=8, color='#F18F01',
                markerfacecolor='white', markeredgewidth=2)
        plt.title('累積売上推移', fontsize=16, fontweight='bold', pad=20)
        plt.xlabel('期間', fontsize=12)
        plt.ylabel('累積売上 (百万円)', fontsize=12)
        plt.grid(True, alpha=0.3)
        
        # 年度表示を適用
        self._apply_year_labels(ax3, len(data), year_labels, year_boundaries)
        
        # 最終値を表示
        final_revenue = stats['total_revenue'] / 1000000
        plt.text(0.02, 0.98, f'最終累積売上:\n{final_revenue:.1f}百万円', 
                transform=ax3.transAxes, fontsize=12, fontweight='bold',
                verticalalignment='top', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
        
        # 4. 請求可能時間と効率性分析
        ax4 = plt.subplot(2, 2, 4)
        
        # 請求可能時間(左軸)
        line2 = ax4.plot(data['month'], data['billable_hours'], 
                        marker='^', linewidth=3, markersize=8, color='#C73E1D',
                        markerfacecolor='white', markeredgewidth=2, label='請求可能時間')
        ax4.set_ylabel('請求可能時間 (時間)', color='#C73E1D', fontsize=12)
        
        # 一人当たり売上(右軸)
        ax4_twin = ax4.twinx()
        per_person_revenue = data['revenue'] / data['headcount'] / 1000
        line3 = ax4_twin.plot(data['month'], per_person_revenue,
                             marker='o', linewidth=3, markersize=8, color='#2E8B57',
                             markerfacecolor='white', markeredgewidth=2, label='一人当たり売上')
        ax4_twin.set_ylabel('一人当たり売上 (千円)', color='#2E8B57', fontsize=12)
        
        plt.title('請求時間と生産性分析', fontsize=16, fontweight='bold', pad=20)
        ax4.set_xlabel('期間', fontsize=12)
        ax4.grid(True, alpha=0.3)
        
        # 年度表示を適用
        self._apply_year_labels(ax4, len(data), year_labels, year_boundaries)
        
        # 凡例の統合
        lines = line2 + line3
        labels = [l.get_label() for l in lines]
        ax4.legend(lines, labels, loc='upper left')
        
        plt.tight_layout(pad=3.0)
        
        # グラフの保存
        if save_plots:
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            plot_filename = f"{output_dir}/bottom_up_simulation_{timestamp}.png"
            plt.savefig(plot_filename, dpi=300, bbox_inches='tight')
            print(f"📊 グラフを保存: {plot_filename}")
        
        plt.show()
        print("✅ 可視化完了")
    
    def export_results(self, filename_prefix="bottom_up_results"):
        """
        結果をCSVファイルに出力
        
        Parameters:
        -----------
        filename_prefix : str
            出力ファイル名のプレフィックス
        """
        
        if not self.results:
            print("⚠️  出力する結果がありません。")
            return
        
        print(f"\n📁 結果をCSVファイルに出力中...")
        
        # タイムスタンプ付きファイル名
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        csv_filename = f"{filename_prefix}_{timestamp}.csv"
        
        # データ出力
        self.results['data'].to_csv(csv_filename, index=False, encoding='utf-8-sig')
        print(f"✅ シミュレーション結果: {csv_filename}")
        
        # 統計情報も別途保存
        stats_filename = f"{filename_prefix}_stats_{timestamp}.txt"
        with open(stats_filename, 'w', encoding='utf-8') as f:
            f.write("ボトムアップ売上シミュレーション結果サマリー\n")
            f.write("=" * 50 + "\n")
            f.write(f"実行日時: {datetime.now().strftime('%Y年%m月%d日 %H:%M:%S')}\n\n")
            
            f.write("パラメーター設定:\n")
            params = self.results['parameters']
            f.write(f"- 初期ヘッドカウント: {params['initial_headcount']}人\n")
            f.write(f"- 採用計画: {params['monthly_hires']}\n")
            f.write(f"- 稼働率: {params['utilization_rate']*100:.1f}%\n")
            f.write(f"- 時間単価: {params['hourly_rate']:,}円\n")
            f.write(f"- 月間稼働時間: {params['working_hours_per_month']}時間\n")
            f.write(f"- シミュレーション期間: {params['months']}ヶ月\n\n")
            
            f.write("結果サマリー:\n")
            stats = self.results['stats']
            f.write(f"- 総売上: {stats['total_revenue']:,.0f}円\n")
            f.write(f"- 月平均売上: {stats['average_monthly_revenue']:,.0f}円\n")
            f.write(f"- 最大月次売上: {stats['max_monthly_revenue']:,.0f}円\n")
            f.write(f"- 最小月次売上: {stats['min_monthly_revenue']:,.0f}円\n")
            f.write(f"- 最終ヘッドカウント: {stats['final_headcount']:.0f}人\n")
            f.write(f"- 平均ヘッドカウント: {stats['average_headcount']:.1f}人\n")
            f.write(f"- 総採用人数: {stats['total_hires']:.0f}人\n")
        
        print(f"✅ 統計サマリー: {stats_filename}")
        print("📋 出力完了")


def main():
    """
    メイン実行関数
    
    ここで設定値を変更してシミュレーションを実行できます。
    """
    print("🚀 ボトムアップ売上シミュレーション開始")
    print("=" * 80)
    
    # シミュレーターインスタンス作成
    simulator = BottomUpSimulation()
    
    # シミュレーション設定
    # ※ここの値を変更してください
    config = {
        # 初期ヘッドカウント(開始時点の従業員数)
        'initial_headcount': 20,
        
        # 月次採用予定数(リストの長さがシミュレーション期間になります)
        # 例: 3年間の採用計画(各年4月に5人採用)
        'monthly_hires': [
            5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,  # 1年目
            5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,  # 2年目
            5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,  # 3年目
        ],
        
        # 稼働率(0.0-1.0)
        # 0.75 = 75%の時間がbillable(請求可能)
        # 残りの25%は営業活動やR&D、自己啓発などを想定
        'utilization_rate': 0.75,
        
        # 時間単価(円)
        'hourly_rate': 10_000,
        
        # 月間稼働時間(時間)
        'working_hours_per_month': 160,
    }
    
    # シミュレーション実行
    results = simulator.run_simulation(**config)
    
    # 結果の可視化
    simulator.visualize_results(save_plots=True, output_dir="bottom_up_plots")
    
    # 結果のCSV出力
    simulator.export_results()
    
    print("\n🎉 ボトムアップシミュレーション完了!")
    print("📊 グラフとCSVファイルが出力されました。")


if __name__ == "__main__":
    main() 

必要な準備

1. 環境構築

pip install numpy pandas matplotlib seaborn

2. 採用計画の設定

# 3年間の採用計画例
monthly_hires = [
    5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,  # 1年目:4月に5人採用
    5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,  # 2年目:4月に5人採用
    5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,  # 3年目:4月に5人採用
]

3. 年度表示機能

  • 1年目4月から開始
  • 年度境界に縦線表示
  • 長期間でも見やすい表示

4. 出力される4つのグラフ

  1. 月次売上推移: 売上の変化と平均線
  2. ヘッドカウント推移: 人員増加と採用タイミング
  3. 累積売上: 総売上の成長パターン
  4. 生産性分析: 請求時間と一人当たり売上

設定値の決め方

1. 現実的なパラメータ設定が重要

# 悪い例:根拠のない楽観的な設定
config = {
    'utilization_rate': 0.95,  # 95%稼働は非現実的
    'hourly_rate': 150_000,      # 相場より高すぎる単価
}

# 良い例:過去実績に基づく現実的な設定
config = {
    'utilization_rate': 0.72,  # 過去12ヶ月の平均値
    'hourly_rate': 8_500,       # 業界相場を参考
}

保守的な見積もりを心がける

# 実績が80%でも、予測では75%に設定
'utilization_rate': 0.75,

# 最高単価12,000円でも、平均的な8,000円で設定
'hourly_rate': 8000,

実際のビジネスでの活用

導入のステップ

Step 1: データの準備

必要なデータを整理します:

  • 過去12ヶ月の売上データ
  • 月別の人員数
  • 案件別の時間単価
  • 実際の稼働時間記録

Step 2: 現在の数値を把握

# 実績から平均値を算出
actual_utilization = 総請求時間 / (人数 × 月間稼働時間)
average_hourly_rate = 総売上 / 総請求時間

Step 3: 小さく始める

# 最初は3ヶ月間の短期予測から開始
results = simulator.run_simulation(
    monthly_hires=[1, 0, 1],  # 3ヶ月分のみ
    # その他の設定...
)

Step 4: 実績と比較して改善

毎月、予測と実績を比較してパラメータを調整します。

活用事例

事例1: 採用計画の立案

課題: 来年度の売上目標2億円を達成するために何名採用すべきか?

解決手順:

  1. 現在の設定でシミュレーション実行
  2. 目標に足りない分を計算
  3. 必要な追加人員を逆算

事例2: 目標達成に必要な単価の算出

課題: 売上目標を達成するために必要な時間単価を決めたい

解決: 現状シミュレーションと目標売上の差分から「必要単価」を逆算する

事例3: 投資判断

課題: 新しいオフィスを借りて人員を増強すべきか?

解決: 増員による売上増加と固定費増加を比較

よくある間違いと対策

間違い1: 過度に楽観的な設定

# 悪い例
utilization_rate = 0.90  # 現実的でない高稼働率
monthly_hires = [5, 5, 5, 5, 5, 5]  # 採用しすぎ

# 良い例
utilization_rate = 0.70  # 保守的な設定
monthly_hires = [2, 0, 1, 0, 2, 0]  # 現実的な採用ペース

間違い2: 一度作って終わり

# 悪い例:作りっぱなし
simulator.run_simulation()
# この後、実績との比較を行わない

# 良い例:継続的な改善
def monthly_review(predicted, actual):
    error_rate = abs(predicted - actual) / actual
    if error_rate > 0.15:  # 15%以上の誤差
        print("パラメータの見直しが必要です")

間違い3: 詳細すぎる設定

# 悪い例:複雑すぎる設定
monthly_hires = [2.5, 1.3, 0.7, ...]  # 小数点は非現実的

# 良い例:シンプルな設定
monthly_hires = [2, 1, 1, 0, 2, 0]  # 整数で十分

成功のコツ

1. 現実的な数値設定

  • 過去実績を基にする
  • 業界平均を参考にする
  • 保守的に見積もる

2. 定期的な見直し

  • 月次で実績と比較
  • 四半期でパラメータ調整
  • 年次でモデル全体を見直し

3. 目的の明確化

  • 何のための予測か?
  • どの程度の精度が必要か?
  • 誰に説明するのか?

まとめ

ボトムアップモデルの特徴

適用場面:

  • 人的サービスが主力のビジネス
  • 採用計画を立てたい場合
  • シンプルで分かりやすい予測が欲しい場合

メリット:

  • 誰でも理解できる
  • 計算が簡単
  • すぐに効果測定できる

注意点:

  • 営業力の変化は考慮されない
  • 市場環境の変動に弱い
  • 保守的な設定が重要

次のステップ

  1. まずは試してみる

    • 過去3ヶ月のデータで検証
    • 実績との差異を確認
  2. 継続的に改善する

    • 月次で見直し
    • パラメータを調整
  3. 組織で共有する

    • 経営陣への報告資料として活用
    • 採用計画の根拠として使用
  4. 人材成長要素の組み込み

    現在のモデルは全員が同じスキルレベルという前提ですが、実際のビジネスでは以下の要素が重要です:

    実装予定の機能:

    • スキルレベル別単価設定: 例えば、新人(6,000円/時間)→ 中堅(8,000円/時間)→ 熟練者(12,000円/時間)といったように、スキルレベルに応じて単価を設定する。
    • 育成期間の考慮: 採用後1年間は育成期間として低稼働率(50%)・低単価を適用
    • OJT影響の反映: 新人指導により指導者の稼働率が一時的に10-15%低下
    • 成長曲線の実装: 経験年数に応じた単価とスキルの段階的上昇
    • スキル分布の管理: チーム内の新人・中堅・熟練者の比率バランス

    期待される効果:

    # 従来モデル(全員同一条件)
    月次売上 = 人数 × 稼働率(75%) × 単価(8,000) × 時間(160h)
    
    # 改良モデル(スキルレベル考慮)
    月次売上 = Σ(各スキルレベル人数 × 個別稼働率 × 個別単価 × 時間)
             - OJT稼働率低下分
    

    現実的な改善例:

    • より正確な採用コスト算出(育成期間中の低収益を考慮)
    • 適切なスキル構成の計画(新人ばかりでは売上が上がらない)
    • 長期的な人材投資効果の可視化(育成投資→将来の高単価)

    この機能により、「採用すれば即戦力」という非現実的な前提から、「人材育成を含めた真の売上予測」へと進化します。

その他のシミュレーションモデル

ボトムアップモデル以外にもモデルがあります。

1. コホートベースモデル

概要: 顧客を獲得月でグループ化し、継続率を追跡するモデル

適用場面:

  • SaaSビジネス
  • サブスクリプションサービス
  • 継続課金型サービス

計算例:

各月の売上 = Σ(コホート別顧客数 × 継続率 × 月額料金)

メリット: 顧客の離脱パターンが分かり、LTV(顧客生涯価値)を算出できる

2. モンテカルロシミュレーション

概要: パラメータを確率分布として扱い、何千回もシミュレーションを実行

適用場面:

  • 不確実性の高い新規事業
  • リスク分析が必要な投資判断
  • 経営会議での報告資料

特徴:

  • 「90%の確率で売上は○○円以上」といった予測が可能
  • 最悪・標準・最良シナリオを同時に検証

メリット: 不確実性を数値化でき、説得力のある資料を作成できる

3. システム・ダイナミクス(フィードバック型)モデル

概要: 「評判→案件獲得→採用→実績→評判」といった相互作用を数式化

適用場面:

  • 長期戦略の検討(2-3年先)
  • 複雑な事業モデルの分析
  • 政策変更の影響分析

フィードバック例:

高い評判 → 案件増加 → 採用増加 → キャパシティ向上 → 品質向上 → 評判向上

メリット: 実際のビジネスに近い複雑な相互関係を表現できる

どのモデルを選ぶべきか?

ビジネスタイプ 推奨モデル
IT・コンサル等の人的サービス ボトムアップ
SaaS・サブスク コホート
新規事業・不確実性が高い・感度分析をしたい モンテカルロ
成熟企業・複雑な事業 システム・ダイナミクス

Discussion