ZenMLとStreamlitを使用した従業員離職率予測
ZenMLとStreamlitを活用した従業員離職率予測の高度化
イントロダクション
人事として働いていますか?チームの従業員が続けるかどうか、または組織を去ることを考えているかの予測に苦労していますか?心配しないでください!これを予測するために占星術師になる必要はありません。データサイエンスの力を使って、それを正確に予測することができます。簡単でパワフルなMLOpsツールであるZenMLとstreamlitと一緒に、従業員の離職率の素晴らしい旅を始めましょう。旅を始めましょう。
学習目標
この記事では、以下のことを学びます。
- ZenMLとは?なぜ使うのか?どのように使うのか?
- なぜMLflowを使うのか?ZenMLとの統合方法は?
- デプロイメントパイプラインの必要性
- 従業員の離職率プロジェクトの実装と予測の作成
この記事は、データサイエンスブログマラソンの一部として公開されました。
プロジェクトの実装
問題の設定: 年齢、収入、パフォーマンスなどのいくつかの要素に基づいて、従業員が組織を去るかどうかを予測する。
解決策: ロジスティック回帰モデルを構築して従業員の離職率を予測する。
データセット: IBM HR Analytics Employee Attrition&Performance
[出典]: https://www.kaggle.com/datasets/pavansubhasht/ibm-hr-analytics-attrition-dataset
プロジェクトの実装を見る前に、なぜここでZenMLを使用しているのかを見てみましょう。
なぜZenMLを使用するのか?
ZenMLは、MLパイプラインを作成し、パイプラインのステップをキャッシュし、計算リソースを保存するためのシンプルでパワフルなMLOpsオーケストレーションツールです。ZenMLはまた、複数のMLツールとの統合を提供しており、MLパイプラインを作成するための最良のツールの1つです。モデルのステップや評価メトリックを追跡し、ダッシュボードでパイプラインを視覚的に表示することもできます。
このプロジェクトでは、ZenMLを使用した従来のパイプラインを実装し、実験のトラッキングのためにmlflowをZenMLと統合します。また、MLflowの統合を使用した継続的なデプロイメントパイプラインを実装し、データの取り込みとクリーニング、モデルの訓練、モデルの再デプロイメントを行います。このパイプラインでは、新しいモデルが以前のモデルの閾値予測値よりも良いパフォーマンスを発揮する場合、MLFlowデプロイメントサーバーは古いモデルの代わりに新しいモデルで更新されることが保証されます。
一般的なZenML用語
- パイプライン: プロジェクト内の手順のシーケンス。
- コンポーネント: MLOpsパイプライン内の特定の機能またはビルディングブロック。
- スタック: ローカル/クラウド内のコンポーネントのコレクション。
- アーティファクト: プロジェクト内のステップの入力と出力データで、アーティファクトストアに格納されます。
- アーティファクトストア: アーティファクトの格納とバージョントラッキングのためのストレージスペース。
- マテリアライザ: アーティファクトの格納とアーティファクトストアからの取得方法を定義するコンポーネント。
- フレーバー: 特定のツールやユースケースに対するソリューション。
- ZenMLサーバー: スタックコンポーネントをリモートで実行するためのデプロイメント。
事前条件と基本的なZenMLコマンド
- Python 3.7以上: ここから入手してください: https://www.python.org/downloads/
- 仮想環境を有効にする:
#仮想環境を作成するpython3 -m venv venv#プロジェクトフォルダ内の仮想環境を有効にするsource venv/bin/activate
- ZenMLコマンド:
すべての基本的なZenMLコマンドとその機能について以下に示します:
#zenmlをインストールするpip install zenml#サーバーとダッシュボードをローカルで起動するためにzenmlをインストールするpip install "zenml[server]"#zenmlのバージョンを確認するためのコマンドzenml version#新しいリポジトリを初期化するためのコマンドzenml init#ダッシュボードをローカルで実行するためのコマンドzenml up#zenmlパイプラインのステータスを確認するためのコマンドzenml show
ZenMLを使用するためには、これらのコマンドを知る必要があります。
MLflowとZenMLの統合
実験トラッカーとしてmlflowを使用し、モデル、アーティファクト、ハイパーパラメータの値をトラックしています。ここでは、スタックコンポーネント、実験トラッカー、モデルデプロイヤーを登録しています:
#ZenMLとmlflowの統合zenml integration install mlflow -y#実験トラッカーを登録するzenml experiment-tracker register mlflow_tracker_employee --flavor=mlflow#モデルデプロイヤーを登録するzenml model-deployer register mlflow_employee --flavor=mlflow#スタックを登録するzenml stack register mlflow_stack_employee -a default -o default -d mlflow_employee -e mlflow_tracker_employee --set
Zenmlスタックリスト
プロジェクト構造
employee-attrition-prediction/ # プロジェクトディレクトリ├── data/ │ └── HR-Employee-Attrition.csv # データセットファイル│├── pipelines/ │ ├── deployment_pipeline.py # デプロイメントパイプライン│ ├── training_pipeline.py # トレーニングパイプライン│ └── utils.py │├── src/ # ソースコード│ ├── data_cleaning.py # データのクリーニングと前処理│ ├── evaluation.py # モデル評価│ └── model_dev.py # モデル開発│ ├── steps/ # ZenMLステップのコードファイル│ ├── ingest_data.py # データの取り込み│ ├── clean_data.py # データのクリーニングと前処理│ ├── model_train.py # モデルのトレーニング │ ├── evaluation.py # モデル評価│ └── config.py │├── streamlit_app.py # Streamlitウェブアプリケーション│├── run_deployment.py # デプロイメントと予測パイプラインの実行コード├── run_pipeline.py # トレーニングパイプラインの実行コード│├── requirements.txt # プロジェクトに必要なパッケージのリスト├── README.md # プロジェクトのドキュメント└── .zen/ # ZenMLディレクトリ (ZenMLの初期化後、自動的に作成される)
データの取り込み
まず、HR-Employee-Attrition-Rateデータセットからデータを取り込みます。
import pandas as pdfrom zenml import stepclass IngestData: def get_data(self) -> pd.DataFrame: df = pd.read_csv("./data/HR-Employee-Attrition.csv") return df@stepdef ingest_data() -> pd.DataFrame: ingest_data = IngestData() df = ingest_data.get_data() return df
@stepはデコレータで、関数ingest_data()をパイプラインのステップとして扱います。
探索的データ分析
#データを理解するdf.info()#データの形を確認するdf.describe()#サンプルデータを確認するdf.head()#欠損値を確認するdf.isnull.sum()#会社を退職した人と残った人の割合を確認するdf['Attrition'].value_counts()df_left = df[df['Attrition'] == "Yes"]df_stayed = df[df['Attrition'] == "No"]left_percentage=df_left.shape[0]*100/df.shape[0]stayed_percentage=df_stayed.shape[0]*100/df.shape[0]print(f"会社を辞めた人の割合は:{left_percentage}")print(f"会社に残った人の割合は:{stayed_percentage}")#会社を辞めた人と残った人の間の特徴の違いを分析するdf_left.describe()df_stayed.describe()
出力
観察結果
- 退職した従業員は会社で働いた期間が短かったです。
- 会社を退職した従業員は、残った従業員よりも若かったです。
- 退職した従業員は、勤務先までの距離が残った従業員よりも遠かったです。
データのクリーニングと処理
- データのクリーニング:データセットから「EmployeeCount」、「EmployeeNumber」、「StandardHours」といった不要な列を削除しました。その後、Yes(または)Noの間のデータ値しか持たないフィーチャーをバイナリ1(または)0に変更しました。
- ワンホットエンコーディング:次に、 ‘BusinessTravel’、 ‘Department’、 ‘EducationField’、 ‘Gender’、 ‘JobRole’、 ‘MaritalStatus’などのカテゴリカル列にワンホットエンコーディングを行いました。
import pandas as pdclass DataPreProcessStrategy(DataStrategy): def __init__(self, encoder=None): self.encoder = encoder """このクラスは、与えられたデータセットを前処理するために使用されます""" def handle_data(self, data: pd.DataFrame) -> pd.DataFrame: try: print("前処理前の列名:", data.columns) # この行を追加 data = data.drop(["EmployeeCount", "EmployeeNumber", "StandardHours"], axis=1) if 'Attrition' in data.columns: print("データでAttrition列が見つかりました。") else: print("データでAttrition列が見つかりませんでした。") data["Attrition"] = data["Attrition"].apply(lambda x: 1 if x == "Yes" else 0) data["Over18"] = data["Over18"].apply(lambda x: 1 if x == "Yes" else 0) data["OverTime"] = data["OverTime"].apply(lambda x: 1 if x == "Yes" else 0) # カテゴリカル変数を抽出する cat = data[['BusinessTravel', 'Department', 'EducationField', 'Gender', 'JobRole', 'MaritalStatus']] # カテゴリカル変数にワンホットエンコーディングを実行する onehot = OneHotEncoder() cat_encoded = onehot.fit_transform(cat).toarray() # cat_encodedをDataFrameに変換する cat_df = pd.DataFrame(cat_encoded) # 数値変数を抽出する numerical = data[['Age', 'Attrition', 'DailyRate', 'DistanceFromHome', 'Education', 'EnvironmentSatisfaction', 'HourlyRate', 'JobInvolvement', 'JobLevel', 'JobSatisfaction', 'MonthlyIncome', 'MonthlyRate', 'NumCompaniesWorked', 'Over18', 'OverTime', 'PercentSalaryHike', 'PerformanceRating', 'RelationshipSatisfaction', 'StockOptionLevel', 'TotalWorkingYears', 'TrainingTimesLastYear', 'WorkLifeBalance', 'YearsAtCompany', 'YearsInCurrentRole', 'YearsSinceLastPromotion', 'YearsWithCurrManager']] # X_cat_dfとX_numericalを連結する data = pd.concat([cat_df, numerical], axis=1) print("前処理後の列名:", data.columns) # この行を追加 print("前処理されたデータ:") print(data.head()) return data except Exception as e: logging.error(f"データの前処理中にエラーが発生しました: {e}") raise e
出力
すべてのデータのクリーニングと処理が完了した後、データは次のようになります:画像で最終的に、エンコードが完了した後、データは数値データのみで構成されています。
データの分割
その後、トレーニングデータセットとテストデータセットを80:20の比率で分割します。
from sklearn.model_selection import train_test_splitclass DataDivideStrategy(DataStrategy): def handle_data(self, data: pd.DataFrame) -> Union[pd.DataFrame, pd.Series]: try: # 'Attrition'がデータに存在するかどうかを確認 if 'Attrition' in data.columns: X = data.drop(['Attrition'], axis=1) Y = data['Attrition'] X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.2, random_state=42) return X_train, X_test, Y_train, Y_test else: raise ValueError("データに'Attrition'列が見つかりません。") except Exception as e: logging.error(f"データの処理中にエラーが発生しました:{str(e)}") raise e
モデルトレーニング
分類問題であるため、ここではロジスティック回帰を使用していますが、ランダムフォレストクラシファイア、グラディエントブースティングなどの分類アルゴリズムも使用できます。
from zenml import pipeline@training_pipelinedef training_pipeline(data_path: str): df = ingest_data(data_path) X_train, X_test, y_train, y_test = clean_and_split_data(df) model = define_model() # マシンラーニングモデルを定義します trained_model = train_model(model, X_train, y_train) evaluation_metrics = evaluate_model(trained_model, X_test, y_test)
ここでは、@training_pipelineデコレータを使用して、関数training_pipeline()をZenMLのパイプラインとして定義しています。
評価
バイナリ分類問題では、正確さ、精度、F1スコア、ROC-AUC曲線などの評価指標を使用します。評価指標を計算し、分類レポートを生成するためにscikit-learnライブラリからclassification_reportをインポートします。
import loggingimport numpy as npfrom sklearn.metrics import classification_reportclass ClassificationReport: @staticmethod def calculate_scores(y_true: np.ndarray, y_pred: np.ndarray): try: logging.info("分類レポートを計算します") report = classification_report(y_true, y_pred, output_dict=True) logging.info(f"分類レポート:\n{report}") return report except Exception as e: logging.error(f"分類レポートの計算中にエラーが発生しました:{e}") raise e
分類レポート:
トレーニングパイプラインのダッシュボードを表示するには、run_pipelilne.pyを実行する必要があります。
run_pipelilne.py:
from zenml import pipelinefrom pipelines.training_pipeline import train_pipelinefrom zenml.client import Clientimport pandas as pdif __name__ == "__main__": uri = Client().active_stack.experiment_tracker.get_tracking_uri() print(uri) train_pipeline(data_path="./data/HR-Employee-Attrition.csv")
これにより、トラッキングダッシュボードのURLが返されます。URLは次のようになります。「ダッシュボードURL:http://127.0.0.1:8237/workspaces/default/pipelines/6e7941f4-cf74-4e30-b3e3-ff4428b823d2/runs/2274fc18-aba1-4536-aaee-9d2ed7d26323/dag」URLをクリックすると、ZenMLダッシュボードで素晴らしいトレーニングパイプラインを表示できます。ここでは、パイプライン全体のイメージが詳細に表示されるように、異なる画像パーツに分割されています。
全体的に、トレーニングパイプラインは以下のようにダッシュボードに表示されます:
モデルの展開
展開トリガー
class DeploymentTriggerConfig(BaseParameters): min_accuracy: float = 0.5
このDeploymentTriggerConfigクラスでは、最小精度パラメータを設定しています。これは、モデルの最小精度を示します。
展開トリガーの設定
@step(enable_cache=False)def deployment_trigger( accuracy: float, config: DeploymentTriggerConfig,): return accuracy > config.min_accuracy
ここで、deployment_trigger()関数は、モデルの展開を行うために使用されます。ただし、最小精度を超えた場合にのみ展開します。次のセクションでなぜcachingを使用しているかについて説明します。
継続的展開パイプライン
@pipeline(enable_cache=False, settings={"docker":docker_settings})def continuous_deployment_pipeline( data_path: str, #data_path="C:/Users/user/Desktop/machine learning/Project/zenml Pipeline/Customer_Satisfaction_project/data/olist_customers_dataset.csv", min_accuracy:float=0.0, workers: int=1, timeout: int=DEFAULT_SERVICE_START_STOP_TIMEOUT,): df=ingest_data() # Clean the data and split into training/test sets X_train,X_test,Y_train,Y_test=clean_df(df) model=train_model(X_train,X_test,Y_train,Y_test) evaluation_metrics=evaluate_model(model,X_test,Y_test) deployment_decision=deployment_trigger(evaluation_metrics) mlflow_model_deployer_step( model=model, deploy_decision=deployment_decision, workers=workers, timeout=timeout, )
ここで、continuous_deployment_pipeline()では、データを読み込み、データをクリーンアップし、モデルをトレーニングし、評価した後、deployment_trigger()条件を満たす場合にのみモデルを展開します。これにより、新しいモデルの予測精度が前のモデルの予測精度を超える場合にのみ、新しいモデルが実行されることが保証されます。これがcontinous_deployment_pipeline()の動作方法です。
キャッシュは、パイプラインの前に実行されたステップの出力を保存することを指します。出力はアーティファクトストアに保存されます。パイプラインパラメータでキャッシングを使用して、前回の実行と現在の実行ステップで出力に変更がないことを示し、zenMLは前回の実行出力自体を再利用します。キャッシュを有効にすることで、パイプラインの実行プロセスが高速化され、計算リソースが節約されます。ただし、continuous_deployment_pipeline()のように入力、パラメータ、出力に動的な変更があるパイプラインを実行する必要がある場合には、キャッシュをオフにすることが適しています。したがって、ここではenable_cache=Falseと記述しています。
推論パイプライン
推論パイプラインは、展開されたモデルに基づいて新しいデータに対して予測を行うために使用されます。プロジェクトでこのパイプラインを使用する方法を見てみましょう。
inference_pipeline()
@pipeline(enable_cache=False,settings={"docker":docker_settings})def inference_pipeline(pipeline_name: str, pipeline_step_name:str): data=dynamic_importer() #print("Data Shape for Inference:", data.shape) # 推論用データの形状を表示 service=prediction_service_loader( pipeline_name=pipeline_name, pipeline_step_name=pipeline_step_name, running=False, ) prediction=predictor(service=service,data=data) return prediction
ここでは、inference_pipeline() が以下の順序で動作します:
- dynamic_importer()– まず、 dynamic_importer() は新しいデータをロードし、準備します。
- prediction_service_loader()– prediction_service_loader() は、パイプライン名とステップ名のパラメータに基づいて展開されたモデルをロードします。
- predictor()-次に、展開されたモデルに基づいて、新しいデータを予測するために predictor() が使用されます。
以下にそれぞれの関数について見ていきましょう:
dynamic_importer()
@step(enable_cache=False)def dynamic_importer()->str: data=get_data_for_test() return data
ここでは、 utils.py の get_data_for_test() を呼び出し、新しいデータをロードして、データ処理を行い、データを返します。
prediction_service_loader()
@step(enable_cache=False)def prediction_service_loader( pipeline_name: str, pipeline_step_name: str, running:bool=True, model_name: str="model", )-> MLFlowDeploymentService: mlflow_model_deployer_component=MLFlowModelDeployer.get_active_model_deployer() existing_services=mlflow_model_deployer_component.find_model_server( pipeline_name=pipeline_name, pipeline_step_name=pipeline_step_name, model_name=model_name, running=running,) if not existing_services: raise RuntimeError( f"パイプライン{pipeline_name}のステップ{pipeline_step_name}およびモデル{model_name}のためのMLFlowデプロイメントサービスが見つかりません。現在、モデル{model_name}のパイプラインは実行されていません。" )
この prediction_service_loader() では、パラメータに基づいて展開されたモデルに対応するデプロイメントサービスをロードします。デプロイメントサービスは、新しいデータに対して予測リクエストを受け入れて予測を行うために準備されたランタイム環境です。 existing_services=mlflow_model_deployer_component.find_model_server() の行は、指定されたパイプライン名とパイプラインステップ名に基づいて利用可能な既存のデプロイメントサービスを検索します。既存サービスがない場合は、デプロイメントパイプラインがまだ実行されていないか、デプロイメントパイプラインに問題があることを意味し、Runtime Error をスローします。
predictor()
@stepdef predictor( service: MLFlowDeploymentService, data: str,) -> np.ndarray: """予測サービスに対して予測リクエストを実行します""" service.start(timeout=21) # 既に開始されている場合はNOPである必要があります data = json.loads(data) data.pop("columns") data.pop("index") columns_for_df = [ 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,"Age","DailyRate","DistanceFromHome","Education","EnvironmentSatisfaction","HourlyRate","JobInvolvement","JobLevel","JobSatisfaction","MonthlyIncome","MonthlyRate","NumCompaniesWorked","Over18","OverTime","PercentSalaryHike","PerformanceRating","RelationshipSatisfaction","StockOptionLevel","TotalWorkingYears","TrainingTimesLastYear","WorkLifeBalance","YearsAtCompany","YearsInCurrentRole","YearsSinceLastPromotion","YearsWithCurrManager", ] df = pd.DataFrame(data["data"], columns=columns_for_df) json_list = json.loads(json.dumps(list(df.T.to_dict().values()))) data = np.array(json_list) prediction = service.predict(data) return prediction
展開されたモデルと新しいデータを持っている後、 predictor() を使用して予測を行うことができます。
連続的なデプロイと推論パイプラインを視覚化するには、 run_deployment.py を実行する必要があります。ここでは、デプロイと予測のための設定が定義されます。
@click.option( "--config", type=click.Choice([DEPLOY, PREDICT, DEPLOY_AND_PREDICT]), default=DEPLOY_AND_PREDICT, help="選択してデプロイメントパイプラインを実行するだけ (`deploy`)、展開されたモデルに対して予測を実行するだけ (`predict`) のどちらかを指定できます。デフォルトでは両方が実行されます (`deploy_and_predict`)。",)
ここでは、次のコマンドに従って、連続的な展開パイプラインまたは推論パイプラインを実行できます。
# 連続的な展開パイプラインの実行python run_deployment.py# 推論パイプラインを表示(展開と予測)python run_deployment.py --config predict
コマンドを実行すると、以下のようにzenMLダッシュボードのURLが表示されます
ダッシュボードURL:http://127.0.0.1:8237/workspaces/default/pipelines/b437cf1a-971c-4a23-a3b6-c296c1cdf8ca/runs/58826e07-6139-453d-88f9-b3c771bb6695/dag
ダッシュボードでパイプラインの可視化をお楽しみください:
連続的な展開パイプライン
連続的な展開パイプライン(データ読み込みからmlflow_model_deployer_stepまで)は次のようになります:
推論パイプライン
Streamlitアプリケーションの作成
Streamlitは素晴らしいオープンソースのPythonベースのフレームワークで、バックエンドやフロントエンドの開発を知らなくても、ウェブアプリを素早く作成するために使用できます。まずは、PCにStreamlitをインストールする必要があります。ローカルシステムでStreamlitサーバーをインストールして実行するためのコマンドは次のとおりです:
# PCにStreamlitをインストールpip install streamlit# StreamlitローカルWebサーバーを実行するためのコマンドstreamlit run streamlit_app.py
コード:
import jsonimport numpy as npimport pandas as pdimport streamlit as stfrom PIL import Imagefrom pipelines.deployment_pipeline import prediction_service_loaderfrom run_deployment import main# サービスの状態を追跡するためのグローバル変数の定義service_started = Falsedef start_service(): global service_started service = prediction_service_loader( pipeline_name="continuous_deployment_pipeline", pipeline_step_name="mlflow_model_deployer_step", running=False, ) service.start(timeout=21) # サービスを開始する service_started = True return servicedef stop_service(service): global service_started service.stop() # サービスを停止する service_started = Falsedef main(): st.title("Employee Attrition Prediction") age = st.sidebar.slider("Age", 18, 65, 30) monthly_income = st.sidebar.slider("Monthly Income", 0, 20000, 5000) total_working_years = st.sidebar.slider("Total Working Years", 0, 40, 10) years_in_current_role = st.sidebar.slider("Years in Current Role", 0, 20, 5) years_since_last_promotion = st.sidebar.slider("Years Since Last Promotion", 0, 15, 2) if st.button("Predict"): global service_started if not service_started: service = start_service() input_data = { "Age": [age], "MonthlyIncome": [monthly_income], "TotalWorkingYears": [total_working_years], "YearsInCurrentRole": [years_in_current_role], "YearsSinceLastPromotion": [years_since_last_promotion], } df = pd.DataFrame(input_data) json_list = json.loads(json.dumps(list(df.T.to_dict().values()))) data = np.array(json_list) pred = service.predict(data) st.success( "Predicted Employee Attrition Probability (0 - 1): {:.2f}".format( pred[0] ) ) # 予測後、サービスを停止する if service_started: stop_service(service)if __name__ == "__main__": main()
ここでは、私たちは「従業員離職予測」という名前のstreamlit webアプリを作成しました。ユーザーは年齢、月収などの入力を提供して予測を行うことができます。ユーザーが「予測」ボタンをクリックすると、入力データがデプロイされたモデルに送信され、予測が行われ、ユーザーに表示されます。これが、私たちのstreamlit_appの動作方法です。streamlit_app.pyファイルを実行すると、次のようなネットワークURLが表示されます。
ネットワークURLをクリックすることで、予測を行うために使用される素晴らしいStreamlit UIを表示することができます。
ZenMLダッシュボードでは、使用されるスタック、コンポーネント、実行されたパイプラインの数など、MLOpsの旅を容易にする情報をすべて表示することができます。
ZenMLダッシュボード:
スタック:
コンポーネント:
パイプラインの数:
実行回数:
結論
私たちは、エンドツーエンドの従業員離職率予測のMLOpsプロジェクトを成功裏に構築しました。データを取り込み、クリーニングし、モデルをトレーニングし、モデルを評価し、デプロイメントをトリガーし、モデルをデプロイし、新しいデータを取得してモデルを予測し、既存のモデルサービスを検索し、データを予測し、Streamlit webアプリからユーザーの入力を受け取り、予測を行います。これにより、人事部がデータに基づいた意思決定を行うのに役立ちます。
GitHubのコード: https://github.com/VishalKumar-S/Employee-attrition-rate-MLOps-Project
キーポイント
- ZenMLは、他のMLツールとの統合を備えた強力なオーケストレーションツールとして機能します。
- 継続的デプロイパイプラインにより、最も優れたモデルのみがデプロイされ、高い精度で予測が行われます。
- キャッシュはリソースの節約に役立ち、ログはパイプラインの追跡やデバッグ、エラートラッキングに役立ちます。
- ダッシュボードは、MLパイプラインのワークフローを明確に表示するのに役立ちます。
よくある質問
この記事に表示されているメディアはAnalytics Vidhyaの所有ではありません。お好みで使用されています。
We will continue to update VoAGI; if you have any questions or suggestions, please contact us!
Was this article helpful?
93 out of 132 found this helpful
Related articles