14
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

簡単にstreamlitアプリを配布する方法【stlite】

Last updated at Posted at 2025-12-15

はじめに

本記事では「Pythonで作った分析コードを“配布しやすいアプリ”にする選択肢」としてstliteという技術を紹介します。
Streamlit は、PythonだけでUI付きのアプリを作れるライブラリです。通常はPCやサーバー側に Python本体と依存パッケージ を入れて、streamlit run で Webサーバ を起動し、ブラウザから操作するという流れがアプリを使用できますが、実行までの手順が多く非エンジニアにはこれが障壁になります。
stlite は ブラウザの中で Python 本体を動かす仕組み(Pyodide:WebAssembly=WASM 上で動くPython)を使い、閲覧側の環境構築なしで Streamlit アプリを実行できます。配布は 静的ファイル(HTML)で済み、専用のサーバも不要です。
ただし使用できるライブラリや実行速度の制約もあります。その点を考慮して「stliteの技術的な背景 → 簡単な動作例 → コードの解説 → 適切な使用場面」の順で説明します。

stliteの技術的な背景

通常のstreamlitをローカルで動かす際には大まかに以下のようにアプリが動作します。
・streamlit run app.pyを実行
・ローカルでWebサーバー( Tornado )が起動
・ブラウザとHTTP/WebSocketでやり取りしてUIを描画される

このときのWebサーバーはstreamlit run を実行したシェルで有効なPythonで起動するため、ローカルの仮想環境のPythonとsite-packagesが実行に用いられます。
対して stlite では Pyodide(WebAssembly 上で動く CPython)を利用することで、ブラウザ内で Python を実行します。これにより閲覧側は Python のインストールや依存パッケージの構築、専用のサーバが不要となるという恩恵を受けることができます。

簡単な動作例

最初に stlite を体感してみましょう!
新規ファイルを作成し、以下のコードを貼り付けて保存、そのままブラウザで開きます。

stlite サンプルコード
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
    <title>stlite app - Gradient Boosting (Reg/Clf) [browser API]</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@stlite/browser@0.89.1/build/stlite.css" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module">
      import { mount } from "https://cdn.jsdelivr.net/npm/@stlite/browser@0.89.1/build/stlite.js";
      mount(
        {
          requirements: ["pandas", "numpy", "matplotlib", "scikit-learn"],
          entrypoint: "streamlit_app.py",
          files: {
            "streamlit_app.py": `
import streamlit as st
import pandas as pd
import numpy as np

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.metrics import (
    r2_score, mean_absolute_error, mean_squared_error,
    accuracy_score, f1_score, roc_auc_score, confusion_matrix
)
from sklearn.experimental import enable_hist_gradient_boosting  # noqa: F401
from sklearn.ensemble import HistGradientBoostingRegressor, HistGradientBoostingClassifier
from sklearn.inspection import permutation_importance
import matplotlib.pyplot as plt
import io

def show_small_fig(fig, width_px=450, dpi=120):
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
    buf.seek(0)
    st.image(buf, width=width_px)  # ← ここで最終表示幅を固定


# ---------- 共通ユーティリティ ----------
def make_unique_str_columns(cols):
    \"""列名を str 化しつつ、重複があれば .1, .2 ... を付けてユニーク化する\"""
    seen = {}
    out = []
    for c in cols:
        s = str(c)
        if s in seen:
            seen[s] += 1
            out.append(f"{s}.{seen[s]}")
        else:
            seen[s] = 0
            out.append(s)
    return out

def enforce_str_columns(df_like):
    \"""DataFrameの列名を必ず str & ユニークにする(副作用あり)\"""
    if isinstance(df_like, pd.DataFrame):
        df_like.columns = make_unique_str_columns(df_like.columns)
    return df_like

st.set_page_config(page_title="勾配ブースティング(回帰/分類 自動判定)", layout="wide")
st.title("🧠 勾配ブースティングで回帰/分類(特徴量を複数選択)")

st.write("ローカルの **CSV** を読み込み、目的変数に応じて **回帰/分類** を自動判定して学習します。必要に応じて手動で切り替えも可能です。")

file = st.file_uploader("CSVファイルを選択", type=["csv"])

if file is None:
    st.info("ファイル未選択です。CSVをドロップ or クリックして選んでください。")
    st.stop()

# ===== CSV読み込み =====
try:
    df = pd.read_csv(file)
except Exception as e:
    st.error(f"読み込みでエラーが発生しました: {e}")
    st.stop()

# ★ 1) 読み込み直後に固定
df = enforce_str_columns(df)

st.subheader("データプレビュー")
st.dataframe(df.head(200), use_container_width=True)

if df.shape[1] < 2:
    st.warning("列が2つ以上必要です。")
    st.stop()

# 列の型判定
num_cols_all = df.select_dtypes(include=["number"]).columns.tolist()
cat_cols_all = [c for c in df.columns if c not in num_cols_all]

st.markdown("### 列の選択")
left, right = st.columns([1,1])

with left:
    target_col = st.selectbox("目的変数(target)", df.columns, index=min(1, len(df.columns)-1))

with right:
    candidate_features = [c for c in df.columns if c != target_col]
    default_feats = [c for c in candidate_features if c in num_cols_all][:3] or candidate_features[:3]
    feature_cols = st.multiselect("特徴量(複数選択)", candidate_features, default=default_feats)

if len(feature_cols) == 0:
    st.warning("特徴量を1つ以上選択してください。")
    st.stop()

# 目的と特徴の抽出
y_raw = df[target_col]
X_raw = df[feature_cols].copy()

# ★ 2) 特徴量抽出直後に固定
X_raw = enforce_str_columns(X_raw)

# 自動判定(回帰 or 分類)
def auto_task_type(y: pd.Series):
    if str(y.dtype) in ("object", "category", "bool"):
        return "classification"
    nunique = y.nunique(dropna=True)
    if nunique <= 20:
        return "classification"
    return "regression"

task_guess = auto_task_type(y_raw)
task = st.radio("タスク種別", options=["auto", "regression", "classification"], index=0,
                help="autoは目的変数の型と一意数で判定します。")
task = task_guess if task == "auto" else task
st.caption(f"自動判定結果: **{task_guess}**")

# データ前処理:数値/カテゴリ列(X側)
num_cols = X_raw.select_dtypes(include=["number"]).columns.tolist()
cat_cols = [c for c in X_raw.columns if c not in num_cols]

numeric_pipe = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="mean")),
])
categorical_pipe = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="most_frequent")),
    ("ohe", OneHotEncoder(handle_unknown="ignore", sparse_output=False))
])

preprocess = ColumnTransformer(
    transformers=[
        ("num", numeric_pipe, num_cols),
        ("cat", categorical_pipe, cat_cols),
    ],
    remainder="drop"
)

# 学習条件
st.markdown("### 学習条件")
c1, c2, c3 = st.columns(3)
with c1:
    test_size = st.slider("テストサイズ(割合)", 0.1, 0.5, 0.2, 0.05)
with c2:
    random_state = st.number_input("random_state", value=42, step=1)
with c3:
    max_iter = st.slider("最大イテレーション(木の本数)", 50, 500, 200, 50)

if task == "regression":
    model = HistGradientBoostingRegressor(max_depth=None, max_iter=int(max_iter), random_state=int(random_state),early_stopping=False)
else:
    model = HistGradientBoostingClassifier(max_depth=None, max_iter=int(max_iter), random_state=int(random_state),early_stopping=False)

pipe = Pipeline(steps=[("preprocess", preprocess), ("model", model)])

# ===== 学習・評価 =====
st.markdown("### 学習・評価")
btn = st.button("モデル学習を実行")
if btn:
    # 欠損を含むyは落とす
    valid_mask = ~y_raw.isna()
    X_use = X_raw.loc[valid_mask].copy()
    y_use = y_raw.loc[valid_mask].copy()

    # 分割
    X_train, X_test, y_train, y_test = train_test_split(
        X_use, y_use, test_size=test_size, random_state=int(random_state),
        stratify=y_use if task=="classification" else None
    )

    # ★ 3) 分割後に最終固定(万一 ndarray → DataFrame 変換などが起きても防ぐ)
    X_train = enforce_str_columns(X_train)
    X_test  = enforce_str_columns(X_test)

    pipe.fit(X_train, y_train)
    y_pred = pipe.predict(X_test)

    # ===== 指標 =====
    if task == "regression":
        r2 = r2_score(y_test, y_pred)
        mae = mean_absolute_error(y_test, y_pred)
        rmse = mean_squared_error(y_test, y_pred)
        m1, m2, m3 = st.columns(3)
        m1.metric("R²", f"{r2:.4f}")
        m2.metric("MAE", f"{mae:.4f}")
        m3.metric("RMSE", f"{rmse:.4f}")

        st.write("**回帰: 実測 vs 予測**")
        fig = plt.figure(figsize=(4.5, 3.5))
        plt.scatter(y_test, y_pred, alpha=0.7)
        plt.xlabel("Actual"); plt.ylabel("Predicted")
        plt.tight_layout()
        show_small_fig(fig, width_px=450); plt.close(fig)

    else:
        acc = accuracy_score(y_test, y_pred)
        f1 = f1_score(y_test, y_pred, average="macro")
        m1, m2 = st.columns(2)
        m1.metric("Accuracy(正解率)", f"{acc:.4f}")
        m2.metric("F1 (macro)", f"{f1:.4f}")

        # ROC-AUC(二値のみ)
        try:
            if hasattr(pipe, "predict_proba"):
                proba = pipe.predict_proba(X_test)
                if isinstance(proba, np.ndarray) and proba.ndim == 2 and proba.shape[1] == 2:
                    auc = roc_auc_score(y_test, proba[:,1])
                    st.metric("ROC-AUC(二値)", f"{auc:.4f}")
        except Exception:
            pass

        # 混同行列(表+小型図)
        labels = pd.Series(y_test).astype("object").fillna("(NaN)").unique().tolist()
        labels = list(pd.Index(labels).unique())
        cm = confusion_matrix(y_test, y_pred, labels=labels)

        # ★ ここが重要:列名・インデックスを文字列にする
        labels_str = [str(x) for x in labels]
        cm_df = pd.DataFrame(
            cm,
            index=pd.Index(labels_str, name="Actual"),
            columns=pd.Index(labels_str, name="Predicted"),
        )

        st.write("**混同行列(図)**")
        fig = plt.figure(figsize=(4.5, 3.5))
        plt.imshow(cm, interpolation="nearest")
        plt.xticks(range(len(labels)), range(len(labels)))
        plt.yticks(range(len(labels)), range(len(labels)))
        for i in range(cm.shape[0]):
            for j in range(cm.shape[1]):
                plt.text(j, i, int(cm[i, j]), ha="center", va="center")
        plt.xlabel("Predicted (index)"); plt.ylabel("Actual (index)")
        plt.tight_layout()
        show_small_fig(fig, width_px=450); plt.close(fig)

    # ===== 特徴量重要度(Permutation Importance)=====
    st.markdown("#### 特徴量重要度")
    try:
        result = permutation_importance(pipe, X_test, y_test, n_repeats=5, random_state=int(random_state))
        importances = result.importances_mean

        # ColumnTransformer を介さず、自前で出力名を組み立てる
        pp = pipe.named_steps["preprocess"]
        out_names = []

        # 数値列はそのまま(安全のため再str化)
        num_cols_safe = [str(c) for c in num_cols]
        out_names.extend(num_cols_safe)

        # カテゴリ列は OneHot の展開名を取得(col_value 形式→ "col=value" に整形)
        cat_cols_safe = [str(c) for c in cat_cols]
        if len(cat_cols_safe) > 0:
            ohe = pp.named_transformers_["cat"].named_steps["ohe"]
            ohe_names = list(ohe.get_feature_names_out(input_features=cat_cols_safe))
            def pretty(n):
                parts = n.split("_", 1)
                return parts[0] + "=" + parts[1] if len(parts) == 2 else n
            ohe_names = [pretty(n) for n in ohe_names]
            out_names.extend(ohe_names)

        # 表示用DataFrame
        if len(importances) == len(out_names):
            imp_df = pd.DataFrame({"feature": out_names, "importance": importances})\
                        .sort_values("importance", ascending=False)
        else:
            # 長さ不一致のフォールバック(理論上ほぼ起きないが保険)
            imp_df = pd.DataFrame({"feature_index": list(range(len(importances))), "importance": importances})\
                        .sort_values("importance", ascending=False)

        st.write("**One-Hot展開後の特徴量名で表示**(上位50件)")
        st.dataframe(imp_df.head(50), use_container_width=True)

        # 元列で集約(カテゴリ列は同一元列で合算)
        if "feature" in imp_df.columns:
            base_names = [n.split("=")[0] if "=" in n else n for n in imp_df["feature"]]
            imp_df["_base"] = base_names
            agg_df = imp_df.groupby("_base", as_index=False)["importance"].sum().sort_values("importance", ascending=False)
            st.write("**元列で集約した重要度**(上位50件)")
            st.dataframe(agg_df.head(50), use_container_width=True)
    except Exception as e:
        st.info(f"Permutation Importanceは計算できませんでした: {e}")

    # ===== 予測CSVのダウンロード =====
    st.subheader("予測のダウンロード")
    pred_all = pipe.predict(X_use)
    out = pd.DataFrame({
        "index": X_use.index,
        "actual": y_use.values,
        "pred": pred_all
    })
    csv_bytes = out.to_csv(index=False).encode("utf-8")
    st.download_button("🔽 予測付きCSVをダウンロード", data=csv_bytes, file_name="predictions.csv", mime="text/csv")
            `,
          },
        },
        document.getElementById("root")
      );
    </script>
  </body>
</html>

すると…アプリが起動します!
image.png

今回は「簡単に予測モデルを構築できる!」をテーマにしたアプリとなっており、以下のようにアプリが動作します。

  1. ファイルを選択:解析対象のファイルをローカルからアップロード
  2. 列の選択:選択したデータから目的変数と説明変数を選択
  3. 学習条件の設定:テストデータ割合、乱数、学習で使用する最大の木数を指定
  4. 学習の実行:「学習を実行」ボタンをクリックするとHistGradientBoostingRegressorHistGradientBoostingClassifierで学習
  5. 学習の結果を確認:予測精度、特徴量重要度を図で確認する
  6. 予測結果のダウンロード: 「予測付きCSVをダウンロード」ボタンをクリックする
    ※ データの容量が大きいと動作が遅い場合があります。
    qiita.gif

コードの解説

stliteを使用するために重要な部分は、先ほどのHTMLの最初です。上の方から説明します。
まず以下の部分で描画に使用するCSSを設定しています。

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@stlite/browser@0.89.1/build/stlite.css" />

次に以下の部分で描画する場所を作り、そこにstreamlitの実装コードを入れ込む形で記述します。
requirementsに使用するライブラリを記載することで、Pyodideでそのライブラリが使用できるようになります。

    <div id="root"></div>
    <script type="module">
      import { mount } from "https://cdn.jsdelivr.net/npm/@stlite/browser@0.89.1/build/stlite.js";
      mount(
        {
          requirements: ["pandas", "numpy", "matplotlib", "scikit-learn"],
          entrypoint: "streamlit_app.py",
          files: {
             "streamlit_app.py": `
              以下にstreamlitのコードを記述

以上の記法のみで簡単にアプリを実装・配布まで行えるのは便利ですよね!
(詳細は参考文献に記載のgithubを参照)

適切な使用場面

簡単に実装・配布できることが利点となる一方で、最初にも述べたようにいくつかの制約が存在します。

  1. 使用できるライブラリ
    実行環境がPyodideに依存するため、そこにないライブラリ(主にC拡張入りのパッケージ:pyarrow, Tensorflowなど)は使用できない
  2. 実行速度
    ブラウザ上で実行するため、並列処理が行えず・依存ライブラリが多い場合に起動までに時間がかかることが考えられる
  3. 機密情報の扱い
    HTMLに埋め込んだ状態での配布となるため、秘密鍵やDB接続に必要な文字列などをコードに記載できない

以上の制約を踏まえると、使用はライブラリ依存の少ない簡単な解析のみに留まりそうです。デモ版の作成、教材、簡易アプリなどには使えそうですね。

まとめ

stlite は、ブラウザ内で Python を動かして Streamlit アプリを配布できる手軽な選択肢です。環境構築や専用サーバーが不要なのは大きな利点ですが、対応ライブラリや速度にはいくつか制約があります。デモや教材、小規模な解析とは相性が良く、重たい処理や機密情報を扱う場面では従来の構成が無難です。まずは小さな例で雰囲気を試しつつ、用途との相性をご判断いただければと思います。

最後までお読みいただき、ありがとうございました。

参考文献・記事

↓コードの解説がわかりやすく説明されている記事
https://qiita.com/ak-sakatoku/items/dda0d127a7c2ac488556
↓streamlitの仕組み
https://qiita.com/yasudakn/items/089aaf4488fc6a8396ae
↓stliteのgithub
https://github.com/whitphx/stlite

14
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?