ノートブックの概要¶
- 特徴量を追加
- モデルはsklearnのGradientBoostingRegressor
- 交差検証は年ごとで区切ったTimeSeriesSplit
- パラメータチューニングはOptuna
前処理¶
In [1]:
import polars as pl
pl.Config.set_fmt_str_lengths(100)
pl.Config.set_tbl_cols(100)
import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
import seaborn as sns
%matplotlib inline
from datetime import date
import jpholiday
from prophet import Prophet
from sklearn.metrics import mean_absolute_error
from sklearn.ensemble import GradientBoostingRegressor
In [2]:
# データ読み込みから前処理まで
# データ読み込み
train = pl.read_csv("../data/input/train.csv")
test = pl.read_csv("../data/input/test.csv")
# id列関連の前処理
# 元の"datetime" 列(str型)のコピーを "id" という名前で新しい列として先頭に追加する。その後、"datetime" 列をdate型に変換する。
train = train.insert_column(0, train["datetime"].alias("id")).with_columns(
pl.col("datetime").str.strptime(dtype=pl.Date)
)
test = test.insert_column(0, test["datetime"].alias("id")).with_columns(
pl.col("datetime").str.strptime(dtype=pl.Date)
)
# 休業日(close = 1)(と、お盆だけど休業していなくて結局引っ越し数0だった日)を分離する
train = train.filter(
(pl.col("close") != 1)
& (pl.col("id") != "2010-08-18")
& (pl.col("id") != "2011-08-14")
)
test_close = test.filter(pl.col("close") == 1)[["id"]]
test_close = test_close.with_columns(pl.Series("y", [0.0] * len(test_close)))
test = test.filter(pl.col("close") != 1)
# closeをカラムごと削除
train = train.drop("close")
test = test.drop("close")
# 2010年のデータは学習から削除。料金区分に関する情報が欠損している
train = train.filter(pl.col("datetime") >= date(2011, 1, 1))
# 目的変数を対数変換する
# 現状のコードだと、trainのyに0が含まれているとエラーになる。なので休業日関連の前処理を先に行う必要がある。
train = train.insert_column(3, train["y"].log().alias("y_ln"))
# 前処理後のデータ確認
print(train.head(5))
shape: (5, 7) ┌────────────┬────────────┬─────┬──────────┬────────┬──────────┬──────────┐ │ id ┆ datetime ┆ y ┆ y_ln ┆ client ┆ price_am ┆ price_pm │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ str ┆ date ┆ i64 ┆ f64 ┆ i64 ┆ i64 ┆ i64 │ ╞════════════╪════════════╪═════╪══════════╪════════╪══════════╪══════════╡ │ 2011-01-04 ┆ 2011-01-04 ┆ 16 ┆ 2.772589 ┆ 0 ┆ 0 ┆ 0 │ │ 2011-01-05 ┆ 2011-01-05 ┆ 16 ┆ 2.772589 ┆ 0 ┆ 0 ┆ 0 │ │ 2011-01-06 ┆ 2011-01-06 ┆ 13 ┆ 2.564949 ┆ 0 ┆ 0 ┆ 0 │ │ 2011-01-07 ┆ 2011-01-07 ┆ 14 ┆ 2.639057 ┆ 0 ┆ 0 ┆ 0 │ │ 2011-01-08 ┆ 2011-01-08 ┆ 16 ┆ 2.772589 ┆ 0 ┆ 0 ┆ 0 │ └────────────┴────────────┴─────┴──────────┴────────┴──────────┴──────────┘
休業日について。コンペへのデータ提出などの際は、最終的には以下の通り結合する
pl.concat(
[submit, test_close],
how = "vertical_relaxed"
).sort("id")
モデル構築¶
prophetによるベースライン作成、残差列追加¶
In [3]:
# 対数変換した目的変数y_lnをProphetで学習させる
# polarsのdatetime型をpandasのdatetime型に変換する必要がある
# 訓練データ
train_pandas = (
train.select(["datetime", "y_ln"])
.rename({"datetime": "ds", "y_ln": "y"})
.to_pandas()
)
# テストデータ
test_pandas = test.select(["datetime"]).rename({"datetime": "ds"}).to_pandas()
# 学習
model = Prophet()
model.fit(train_pandas)
# 予測
forecast_train = model.predict(train_pandas)
forecast_test = model.predict(test_pandas)
# 予測結果の可視化
fig, ax = plt.subplots(
figsize=(12, 5),
constrained_layout=True,
)
ax.set_title(
"Prophetによる予測(Train=青, Test=赤)(縦軸:y_ln、横軸:datetime)", fontsize=16
)
# train側は Prophet の標準描画(青・点群付き)
model.plot(model.predict(train_pandas), ax=ax)
# test側は後から重ね描き(赤)
ax.plot(
forecast_test["ds"],
forecast_test["yhat"],
color="red",
linewidth=2,
label="Test yhat",
)
ax.fill_between(
forecast_test["ds"],
forecast_test["yhat_lower"],
forecast_test["yhat_upper"],
color="red",
alpha=0.2,
label="Test interval",
)
ax.legend()
plt.show()
10:23:22 - cmdstanpy - INFO - Chain [1] start processing 10:23:22 - cmdstanpy - INFO - Chain [1] done processing
In [4]:
# 推定値の算出
forecast_train_pl = pl.Series("y_ln_prophet", forecast_train["yhat"].values)
forecast_test_pl = pl.Series("y_ln_prophet", forecast_test["yhat"].values)
# 推定値をDataFrameにまとめる
train = train.insert_column(4, forecast_train_pl)
test = test.insert_column(3, forecast_test_pl)
# 残差列を追加
train = train.with_columns(
(train["y_ln"] - train["y_ln_prophet"]).alias("y_ln_difference")
)
特徴量を追加¶
In [5]:
# 思いつく限りの特徴量を追加
# 今後の運用を考えると、任意の日時について自動でフラグを取得できる必要がある。要改善
# 祝日
holiday_list = []
for pair in jpholiday.between(date(2010, 1, 1), date(2017, 12, 31)):
# print(pair)
holiday_list.append(pair[0])
# その他の休日
# GW
# https://9rando.info/j-holiday/gw/2011/
gw_list = [
date(2011, 4, 29),
date(2011, 4, 30),
date(2011, 5, 1),
date(2011, 5, 2), # 有給休暇の可能性
date(2011, 5, 3),
date(2011, 5, 4),
date(2011, 5, 5),
date(2012, 4, 28),
date(2012, 4, 29),
date(2012, 4, 30),
date(2012, 5, 1),
date(2012, 5, 2), # 有休
date(2012, 5, 3),
date(2012, 5, 4),
date(2012, 5, 5),
date(2012, 5, 5),
date(2013, 4, 27),
date(2013, 4, 28),
date(2013, 4, 29),
date(2013, 4, 30),
date(2013, 5, 1),
date(2013, 5, 2), # 有休
date(2013, 5, 3),
date(2013, 5, 4),
date(2013, 5, 5),
date(2013, 5, 5),
date(2013, 5, 6),
date(2014, 4, 29),
date(2014, 4, 30),
date(2014, 5, 1),
date(2014, 5, 2), # 有休
date(2014, 5, 3),
date(2014, 5, 4),
date(2014, 5, 5),
date(2014, 5, 5),
date(2014, 5, 6),
date(2015, 4, 29),
date(2015, 4, 30),
date(2015, 5, 1), # 有休
date(2015, 5, 2),
date(2015, 5, 3),
date(2015, 5, 4),
date(2015, 5, 5),
date(2015, 5, 5),
date(2015, 5, 6),
date(2016, 4, 29),
date(2016, 4, 30),
date(2016, 5, 1),
date(2016, 5, 2), # 有休
date(2016, 5, 3),
date(2016, 5, 4),
date(2016, 5, 5),
date(2017, 4, 29),
date(2017, 4, 30),
date(2017, 5, 1),
date(2017, 5, 2), # 有休
date(2017, 5, 3),
date(2017, 5, 4),
date(2017, 5, 5),
date(2017, 5, 6),
date(2017, 5, 7),
]
# お盆
# https://9rando.info/j-holiday/obon/2011/
obon_list = [
date(2011, 8, 13),
date(2011, 8, 14),
date(2011, 8, 15),
date(2011, 8, 16),
date(2012, 8, 13),
date(2012, 8, 14),
date(2012, 8, 15),
date(2012, 8, 16),
date(2013, 8, 13),
date(2013, 8, 14),
date(2013, 8, 15),
date(2013, 8, 16),
date(2014, 8, 13),
date(2014, 8, 14),
date(2014, 8, 15),
date(2014, 8, 16),
date(2015, 8, 13),
date(2015, 8, 14),
date(2015, 8, 15),
date(2015, 8, 16),
date(2016, 8, 13),
date(2016, 8, 14),
date(2016, 8, 15),
date(2016, 8, 16),
date(2017, 8, 13),
date(2017, 8, 14),
date(2017, 8, 15),
date(2017, 8, 16),
]
# シルバーウィーク
# https://9rando.info/j-holiday/sw/2011/
sw_list = [
date(2011, 9, 17),
date(2011, 9, 18),
date(2011, 9, 19),
date(2011, 9, 20),
date(2011, 9, 21),
date(2011, 9, 22), # 有休
date(2011, 9, 23),
date(2011, 9, 24),
date(2011, 9, 25),
date(2012, 9, 15),
date(2012, 9, 16),
date(2012, 9, 17),
date(2012, 9, 18),
date(2012, 9, 19),
date(2012, 9, 20),
date(2012, 9, 21), # 有休
date(2012, 9, 22),
date(2012, 9, 23),
date(2012, 9, 24),
date(2013, 9, 14),
date(2013, 9, 15),
date(2013, 9, 16),
date(2013, 9, 17),
date(2013, 9, 18),
date(2013, 9, 19),
date(2013, 9, 20), # 有休
date(2013, 9, 21),
date(2013, 9, 22),
date(2013, 9, 23),
date(2014, 9, 13),
date(2014, 9, 14),
date(2014, 9, 15),
date(2014, 9, 16),
date(2014, 9, 17),
date(2014, 9, 18),
date(2014, 9, 19), # 有休
date(2014, 9, 20),
date(2014, 9, 21),
date(2014, 9, 22),
date(2015, 9, 19),
date(2015, 9, 20),
date(2015, 9, 21),
date(2015, 9, 22),
date(2015, 9, 23),
date(2016, 9, 17),
date(2016, 9, 18),
date(2016, 9, 19),
date(2016, 9, 20),
date(2016, 9, 21), # 有休
date(2016, 9, 22),
date(2017, 9, 16),
date(2017, 9, 17),
date(2017, 9, 18),
date(2017, 9, 19),
date(2017, 9, 20),
date(2017, 9, 21),
date(2017, 9, 22), # 有休
date(2017, 9, 23),
date(2017, 9, 24),
]
In [6]:
def add_features(df):
return (
df
# 日付の基本要素の追加
.with_columns(
[
# 年、四半期、月
pl.col("datetime").dt.year().alias("year"),
pl.col("datetime").dt.quarter().alias("quarter"),
pl.col("datetime").dt.month().alias("month"),
# 年始から数えて何週目か
pl.col("datetime").dt.week().alias("week"),
# 年始から数えて何日目か
pl.col("datetime").dt.ordinal_day().alias("ordinal_day"),
# その月の何日か
pl.col("datetime").dt.day().alias("day"),
# その月の何週目か
(pl.col("datetime").dt.day() // 7 + 1).alias("week_of_month"),
# 曜日(月曜日が1、日曜日が6)
pl.col("datetime").dt.weekday().alias("day_of_week"),
]
)
# 四半期はじめ・終わり、月はじめ・終わりのフラグ
.with_columns(
[
pl.when((pl.col("month").is_in([1, 4, 7, 10])) & (pl.col("day") == 1))
.then(1)
.otherwise(0)
.alias("quarter_start"),
pl.when(
(pl.col("month").is_in([3, 6, 9, 12]))
& (pl.col("datetime") == pl.col("datetime").dt.month_end())
)
.then(1)
.otherwise(0)
.alias("quarter_end"),
pl.when(pl.col("day") == 1).then(1).otherwise(0).alias("month_start"),
pl.when(pl.col("datetime") == pl.col("datetime").dt.month_end())
.then(1)
.otherwise(0)
.alias("month_end"),
]
)
# 祝日・休日・土日フラグ
.with_columns(
[
pl.when(pl.col("datetime").is_in(holiday_list))
.then(1)
.otherwise(0)
.alias("holiday"),
pl.when(pl.col("datetime").is_in(gw_list))
.then(1)
.otherwise(0)
.alias("GW"),
pl.when(pl.col("datetime").is_in(sw_list))
.then(1)
.otherwise(0)
.alias("SW"),
pl.when(pl.col("datetime").is_in(obon_list))
.then(1)
.otherwise(0)
.alias("obon"),
pl.when(pl.col("datetime").dt.weekday() >= 6)
.then(1)
.otherwise(0)
.alias("sat_sun"),
]
)
# 価格の和・差・積
.with_columns(
[
(pl.col("price_am") + pl.col("price_pm")).alias("price_sum"),
(pl.col("price_am") - pl.col("price_pm")).alias("price_difference"),
# price = -1が欠損、price = 0は最安価格であった。price同士をそのまま乗算しまうと最安値のときに必ず積が0になってしまうのでその他の情報が消えてしまい、都合が良くない
# price + 1とすることでこれを回避する。なお、欠損がある場合は積は必ず0となる。これはこれで良いフラグかもしれない?
((pl.col("price_am") + 1) * (pl.col("price_pm") + 1)).alias(
"price_product"
),
]
)
)
train = add_features(train)
test = add_features(test)
del holiday_list, gw_list, sw_list, obon_list
学習と予測¶
In [7]:
# 目的変数と特徴量
target_column = "y_ln_difference"
feature_columns = [
"client",
"price_am",
"price_pm",
"year",
"quarter",
"month",
"week",
"ordinal_day",
"day",
"week_of_month",
"day_of_week",
"quarter_start",
"quarter_end",
"month_start",
"month_end",
"holiday",
"GW",
"SW",
"obon",
"sat_sun",
"price_sum",
"price_difference",
"price_product",
]
In [8]:
# 交差検証とパラメータチューニングをしながらモデル学習
import optuna
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
from sklearn.ensemble import GradientBoostingRegressor
import numpy as np
# 機械学習ライブラリと親和性の高いPandasに変換する
train_pandas = train.to_pandas().set_index("id")
train_pandas = train_pandas.sort_values("datetime").reset_index(
drop=True
) # 念の為ここでソート
def year_split_positional(df, dt_col="datetime"):
years = df[dt_col].dt.year.to_numpy()
uniq = np.unique(years)
for i in range(1, len(uniq)):
train_idx = np.flatnonzero(years < uniq[i]) # ← 整数位置
test_idx = np.flatnonzero(years == uniq[i]) # ← 整数位置
yield train_idx, test_idx
X = train_pandas[feature_columns]
y = train_pandas[target_column]
cv_splits = list(year_split_positional(train_pandas, "datetime"))
# objective内でのスコアにはRMSEを使用中。コンペの評価指標(MAE)と合わせる方法は検討中
# 前処理で対数変換を行っていたり、prophetで捉えたベースラインとの差分を扱っていることを考慮する必要あり
# 両方にベースラインを加えて、指数関数でyを復元。その後改めてMAEを計算。といった流れ?
def objective(trial):
params = {
"n_estimators": trial.suggest_int("n_estimators", 100, 1200),
"max_depth": trial.suggest_int("max_depth", 2, 10),
"learning_rate": trial.suggest_float("learning_rate", 1e-3, 0.2, log=True),
"subsample": trial.suggest_float("subsample", 0.5, 1.0),
"min_samples_split": trial.suggest_int("min_samples_split", 2, 50),
"min_samples_leaf": trial.suggest_int("min_samples_leaf", 1, 50),
"random_state": 42,
}
model = GradientBoostingRegressor(**params)
scores = cross_val_score(
model,
X,
y,
cv=cv_splits,
scoring="neg_root_mean_squared_error",
n_jobs=-1,
)
return -scores.mean()
study = optuna.create_study(direction="minimize")
study.optimize(objective, n_trials=10, timeout=60) # デバッグ用に短く設定中
best_params = study.best_params.copy()
print("Best params:", best_params)
# 最適パラメータで全訓練データを学習・予測
model = GradientBoostingRegressor(**best_params).fit(X, y)
y_pred = np.exp(model.predict(X) + train_pandas["y_ln_prophet"])
[I 2025-10-03 10:23:24,539] A new study created in memory with name: no-name-cf9368cc-a07d-4d0d-8a99-b89a0598900a
[I 2025-10-03 10:23:29,164] Trial 0 finished with value: 0.22512705385476153 and parameters: {'n_estimators': 355, 'max_depth': 3, 'learning_rate': 0.017284935293097388, 'subsample': 0.9868039704312895, 'min_samples_split': 30, 'min_samples_leaf': 26}. Best is trial 0 with value: 0.22512705385476153.
[I 2025-10-03 10:23:31,054] Trial 1 finished with value: 0.23772540096805556 and parameters: {'n_estimators': 132, 'max_depth': 3, 'learning_rate': 0.020283665268912298, 'subsample': 0.9739927619755564, 'min_samples_split': 22, 'min_samples_leaf': 46}. Best is trial 0 with value: 0.22512705385476153.
[I 2025-10-03 10:23:33,131] Trial 2 finished with value: 0.2380330915959096 and parameters: {'n_estimators': 687, 'max_depth': 8, 'learning_rate': 0.00254266381459453, 'subsample': 0.5852475385244971, 'min_samples_split': 50, 'min_samples_leaf': 33}. Best is trial 0 with value: 0.22512705385476153.
[I 2025-10-03 10:23:35,138] Trial 3 finished with value: 0.22572091871879357 and parameters: {'n_estimators': 755, 'max_depth': 8, 'learning_rate': 0.03385831413027628, 'subsample': 0.5088218919366347, 'min_samples_split': 20, 'min_samples_leaf': 48}. Best is trial 0 with value: 0.22512705385476153.
[I 2025-10-03 10:23:37,711] Trial 4 finished with value: 0.22125402801771604 and parameters: {'n_estimators': 520, 'max_depth': 9, 'learning_rate': 0.02442727519969622, 'subsample': 0.9129750337412155, 'min_samples_split': 16, 'min_samples_leaf': 26}. Best is trial 4 with value: 0.22125402801771604.
[I 2025-10-03 10:23:38,310] Trial 5 finished with value: 0.30745837025852996 and parameters: {'n_estimators': 207, 'max_depth': 5, 'learning_rate': 0.002017537717323288, 'subsample': 0.6389466692371684, 'min_samples_split': 3, 'min_samples_leaf': 33}. Best is trial 4 with value: 0.22125402801771604.
[I 2025-10-03 10:23:38,823] Trial 6 finished with value: 0.3128856169236336 and parameters: {'n_estimators': 170, 'max_depth': 8, 'learning_rate': 0.0023031748576365775, 'subsample': 0.6250019779515898, 'min_samples_split': 15, 'min_samples_leaf': 39}. Best is trial 4 with value: 0.22125402801771604.
[I 2025-10-03 10:23:39,720] Trial 7 finished with value: 0.2313852034374094 and parameters: {'n_estimators': 376, 'max_depth': 4, 'learning_rate': 0.004621032463099953, 'subsample': 0.5360109877340127, 'min_samples_split': 20, 'min_samples_leaf': 6}. Best is trial 4 with value: 0.22125402801771604.
[I 2025-10-03 10:23:40,766] Trial 8 finished with value: 0.23243863540314363 and parameters: {'n_estimators': 222, 'max_depth': 9, 'learning_rate': 0.005896062946971857, 'subsample': 0.8130376346240138, 'min_samples_split': 43, 'min_samples_leaf': 5}. Best is trial 4 with value: 0.22125402801771604.
[I 2025-10-03 10:23:42,864] Trial 9 finished with value: 0.2229792942614938 and parameters: {'n_estimators': 666, 'max_depth': 6, 'learning_rate': 0.0041189814804672075, 'subsample': 0.6303152256827693, 'min_samples_split': 9, 'min_samples_leaf': 22}. Best is trial 4 with value: 0.22125402801771604.
Best params: {'n_estimators': 520, 'max_depth': 9, 'learning_rate': 0.02442727519969622, 'subsample': 0.9129750337412155, 'min_samples_split': 16, 'min_samples_leaf': 26}
In [9]:
print(
"訓練データ全体でのMAE:",
np.round(mean_absolute_error(train_pandas["y"], y_pred), decimals=7),
) # 小数第7位はコンペスコアの有効数字
fig, axes = plt.subplots(
nrows=3,
ncols=2,
height_ratios=[1, 1, 1],
width_ratios=[2, 1],
figsize=(15, 12),
constrained_layout=True,
)
fig.suptitle("モデル学習後の予測結果(元データ, 学習データ, 検証データ)", fontsize=16)
palette = sns.color_palette("tab10")
axes[0, 0].set_title("推移")
sns.lineplot(
data=train_pandas, x="datetime", y="y", label="y", color=palette[0], ax=axes[0, 0]
)
sns.lineplot(
data=train_pandas,
x="datetime",
y=y_pred,
label="y_pred",
color=palette[1],
ax=axes[0, 0],
)
axes[0, 1].set_title("分布")
sns.histplot(
data=train_pandas, x="y", binwidth=2, label="y", color=palette[0], ax=axes[0, 1]
)
sns.histplot(
data=train_pandas,
x=y_pred,
binwidth=2,
label="y_pred",
color=palette[1],
ax=axes[0, 1],
)
axes[1, 0].set_title("予測誤差")
axes[1, 0].set_ylabel("誤差")
sns.scatterplot(
data=train_pandas,
x="datetime",
y=train_pandas["y"] - y_pred,
marker="+",
label="y_pred_err",
color=palette[2],
ax=axes[1, 0],
)
axes[1, 1].set_title("予測誤差の分布")
sns.histplot(
data=train_pandas,
x=train_pandas["y"] - y_pred,
binwidth=2,
color=palette[2],
ax=axes[1, 1],
)
axes[2, 0].set_title("特徴量重要度")
sns.barplot(
x=model.feature_importances_, y=feature_columns, color=palette[3], ax=axes[2, 0]
)
axes[2, 1].axis("off")
plt.show()
訓練データ全体でのMAE: 3.2550382
- 精度
- MAEで見た平均的な予測精度は良好。
- ただ本モデルを実用することを考えると、繁忙期のピークへの追随が弱いのが気になる。これでは、実務上重要な「特に繁忙期にどれくらいの需要が来そうか」の予測精度が微妙か?
- 改善のためには、学習の際の評価指標を変える?他に適切なものは?
- 特徴量重要度について。特に強いもの
- ordinal_day:年始から数えて何日か。つまり、一年の中での時期を表している
- day:その月の中で、何日か
- day_of_week:曜日
- price_sum、price_product:午前・午後の価格の和・積
提出データ出力¶
In [10]:
# 提出前に、全ての訓練データを使って学習させておく
X, y = train_pandas[feature_columns], train_pandas[target_column]
model = GradientBoostingRegressor(random_state=1192).fit(X, y)
# 機械学習ライブラリと親和性の高いPandasに変換する
test_pandas = test.to_pandas().set_index("id")
# 説明変数の分割
X_test = test_pandas[feature_columns]
# 予測
y_pred_test = np.exp(model.predict(X_test) + test_pandas["y_ln_prophet"])
In [11]:
import ipynbname
from pathlib import Path
# 提出用DataFrame
submit = pl.DataFrame({"id": X_test.index.values, "y": y_pred_test.values})
# 退避していた休業日のデータを結合
submit = pl.concat([submit, test_close], how="vertical_relaxed").sort("id")
notebook_name = ipynbname.name()
output_path = Path("../data/output") / f"submit_{notebook_name}.csv"
# 提出ファイルを保存する
# submit.write_csv(output_path, include_header = False)
# print(f"Saved: {output_path}")
In [12]:
fig, axes = plt.subplots(
nrows=1,
ncols=2,
height_ratios=[1],
width_ratios=[2, 1],
figsize=(12, 4),
constrained_layout=True,
)
fig.suptitle("テストデータに対する予測")
ax = axes[0].set_title("推移")
sns.lineplot(data=train_pandas, x="datetime", y="y", ax=axes[0])
sns.lineplot(data=test_pandas, x="datetime", y=y_pred_test, ax=axes[0])
ax = axes[1].set_title("分布")
sns.histplot(data=train_pandas, x="y", binwidth=2, ax=axes[1])
sns.histplot(data=test_pandas, x=y_pred_test, binwidth=2, ax=axes[1])
plt.show()
htmlに変換したものを出力¶
In [13]:
# import ipynbname
# from pathlib import Path
# output_dir = Path("html")
# !jupyter nbconvert --to html "{str(ipynbname.path())}" --output-dir "{output_dir}"