Spaces:
Sleeping
Sleeping
| ## Model A β XGBoost Cascade Training (Step 2 of 2) | |
| ## Loads the per-video feature vectors produced by extract_features.py | |
| ## and trains two XGBoost binary classifiers arranged as a cascade: | |
| ## Submodel 2 AI-Generated Detector | |
| ## Input: first 2048 features (EfficientNet + ForensicCNN only) | |
| ## Target: 1 = ai_generated, 0 = real or deepfake | |
| ## Trained on all three classes combined. | |
| ## Submodel 1 Real vs Deepfake Classifier | |
| ## Input: all 2064 features (EfficientNet + ForensicCNN + MediaPipe) | |
| ## Target: 1 = deepfake, 0 = real | |
| ## Trained only on real and deepfake rows (ai_generated excluded | |
| ## because those videos never reach Submodel 1 in the cascade). | |
| ## Cascade logic at inference: | |
| ## video -> Submodel 2 -> prob >= AI_THRESH -> label = AI_GENERATED | |
| ## -> else -> Submodel 1 | |
| ## -> prob >= DF_THRESH -> label = DEEPFAKE | |
| ## -> else -> label = REAL | |
| ## Outputs saved to model_a/: | |
| ## submodel1.json β Real vs Deepfake XGBoost model | |
| ## submodel2.json β AI-Generated XGBoost model | |
| ## metrics.json β all evaluation numbers as a JSON dict | |
| ## metrics.txt β human-readable full report | |
| ## Run with: uv run model_a/train.py | |
| import json | |
| import logging | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import optuna | |
| import xgboost as xgb | |
| from sklearn.metrics import ( | |
| accuracy_score, | |
| classification_report, | |
| confusion_matrix, | |
| f1_score, | |
| precision_score, | |
| recall_score, | |
| roc_auc_score, | |
| ) | |
| from sklearn.model_selection import StratifiedKFold, cross_val_score | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") | |
| log = logging.getLogger(__name__) | |
| optuna.logging.set_verbosity(optuna.logging.WARNING) | |
| ROOT = Path(__file__).parent.parent | |
| OUT_DIR = ROOT / "model_a" | |
| TRAIN_NPZ = OUT_DIR / "features_train.npz" | |
| TEST_NPZ = OUT_DIR / "features_test.npz" | |
| SM1_PATH = OUT_DIR / "submodel1.json" | |
| SM2_PATH = OUT_DIR / "submodel2.json" | |
| METRICS_JSON = OUT_DIR / "metrics.json" | |
| METRICS_TXT = OUT_DIR / "metrics.txt" | |
| ## small JSON files saved after each submodel finishes so a crashed run can resume | |
| SM1_CKPT = OUT_DIR / ".sm1_checkpoint.json" | |
| SM2_CKPT = OUT_DIR / ".sm2_checkpoint.json" | |
| ## pause this many seconds every PAUSE_EVERY_TRIALS Optuna trials | |
| PAUSE_EVERY_TRIALS = 10 | |
| PAUSE_SECS = 10 | |
| ## label encoding β must match extract_features.py LABEL_MAP | |
| REAL = 0 | |
| DEEPFAKE = 1 | |
| AI_GENERATED = 2 | |
| ## cascade decision thresholds (tunable post-training) | |
| AI_THRESH = 0.50 | |
| DF_THRESH = 0.50 | |
| ## Optuna / CV settings | |
| SEED = 42 | |
| N_TRIALS = 50 | |
| N_FOLDS = 5 | |
| def load_split(npz_path: Path): | |
| d = np.load(npz_path, allow_pickle=True) | |
| return ( | |
| d["X"].astype(np.float32), | |
| d["y"].astype(int), | |
| d["video_ids"], | |
| d["sources"], | |
| ) | |
| def log_source_breakdown(y: np.ndarray, sources: np.ndarray, split: str): | |
| """Log how many real / deepfake / ai_generated videos came from each source.""" | |
| label_names = {REAL: "real", DEEPFAKE: "deepfake", AI_GENERATED: "ai_gen"} | |
| from collections import defaultdict | |
| counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) | |
| for label, src in zip(y, sources): | |
| counts[str(src)][label_names[int(label)]] += 1 | |
| log.info(f" {split} breakdown by source:") | |
| for src in sorted(counts): | |
| c = counts[src] | |
| parts = " ".join(f"{k}={v}" for k, v in sorted(c.items())) | |
| log.info(f" {src:<35} {parts}") | |
| log.info("") | |
| def make_xgb_objective(X_train: np.ndarray, y_train: np.ndarray, spw: float): | |
| cv = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=SEED) | |
| def objective(trial: optuna.Trial) -> float: | |
| params = { | |
| "n_estimators": trial.suggest_int("n_estimators", 100, 1000), | |
| "max_depth": trial.suggest_int("max_depth", 3, 10), | |
| "learning_rate": trial.suggest_float("learning_rate", 1e-3, 0.3, log=True), | |
| "subsample": trial.suggest_float("subsample", 0.5, 1.0), | |
| "colsample_bytree": trial.suggest_float("colsample_bytree", 0.4, 1.0), | |
| "reg_alpha": trial.suggest_float("reg_alpha", 1e-8, 10.0, log=True), | |
| "reg_lambda": trial.suggest_float("reg_lambda", 1e-8, 10.0, log=True), | |
| "min_child_weight": trial.suggest_int("min_child_weight", 1, 20), | |
| "scale_pos_weight": spw, | |
| "objective": "binary:logistic", | |
| "verbosity": 0, | |
| "random_state": SEED, | |
| "n_jobs": -1, | |
| } | |
| scores = cross_val_score( | |
| xgb.XGBClassifier(**params), | |
| X_train, y_train, | |
| cv=cv, | |
| scoring="roc_auc", | |
| ) | |
| return float(scores.mean()) | |
| return objective | |
| def optuna_pause_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial): | |
| ## called after every trial β pause briefly every PAUSE_EVERY_TRIALS to let the CPU cool down | |
| if (trial.number + 1) % PAUSE_EVERY_TRIALS == 0: | |
| log.info(f" [pause] {PAUSE_SECS}s after trial {trial.number + 1} ...") | |
| time.sleep(PAUSE_SECS) | |
| def tune_and_train( | |
| X_train: np.ndarray, | |
| y_train: np.ndarray, | |
| spw: float, | |
| name: str, | |
| ) -> tuple[xgb.XGBClassifier, float, dict]: | |
| log.info(f" Optuna: {N_TRIALS} trials, {N_FOLDS}-fold CV, ROC-AUC ...") | |
| study = optuna.create_study( | |
| direction="maximize", | |
| sampler=optuna.samplers.TPESampler(seed=SEED), | |
| ) | |
| study.optimize( | |
| make_xgb_objective(X_train, y_train, spw), | |
| n_trials=N_TRIALS, | |
| show_progress_bar=True, | |
| callbacks=[optuna_pause_callback], | |
| ) | |
| best_params = { | |
| **study.best_params, | |
| "scale_pos_weight": spw, | |
| "objective": "binary:logistic", | |
| "verbosity": 0, | |
| "random_state": SEED, | |
| "n_jobs": -1, | |
| } | |
| log.info(f" Best CV ROC-AUC: {study.best_value:.4f} params: {study.best_params}") | |
| model = xgb.XGBClassifier(**best_params) | |
| model.fit(X_train, y_train) | |
| return model, study.best_value, best_params | |
| def evaluate_binary( | |
| model: xgb.XGBClassifier, | |
| X_test: np.ndarray, | |
| y_test: np.ndarray, | |
| threshold: float = 0.50, | |
| target_names: list[str] | None = None, | |
| ) -> dict: | |
| y_proba = model.predict_proba(X_test)[:, 1] | |
| y_pred = (y_proba >= threshold).astype(int) | |
| return { | |
| "accuracy": float(accuracy_score(y_test, y_pred)), | |
| "precision": float(precision_score(y_test, y_pred, zero_division=0)), | |
| "recall": float(recall_score(y_test, y_pred, zero_division=0)), | |
| "f1": float(f1_score(y_test, y_pred, zero_division=0)), | |
| "roc_auc": float(roc_auc_score(y_test, y_proba)), | |
| "confusion_matrix": confusion_matrix(y_test, y_pred).tolist(), | |
| "classification_report": classification_report( | |
| y_test, y_pred, target_names=target_names or ["negative", "positive"] | |
| ), | |
| } | |
| def cascade_predict( | |
| sub2: xgb.XGBClassifier, | |
| sub1: xgb.XGBClassifier, | |
| X: np.ndarray, | |
| ai_thresh: float = AI_THRESH, | |
| df_thresh: float = DF_THRESH, | |
| ) -> np.ndarray: | |
| ## Submodel 2 uses only EfficientNet + ForensicCNN features (first 2048 dims) | |
| prob_ai = sub2.predict_proba(X[:, :2048])[:, 1] | |
| ## Submodel 1 uses all 2064 features | |
| prob_df = sub1.predict_proba(X)[:, 1] | |
| return np.where( | |
| prob_ai >= ai_thresh, AI_GENERATED, | |
| np.where(prob_df >= df_thresh, DEEPFAKE, REAL), | |
| ) | |
| def tune_thresholds( | |
| sub2: xgb.XGBClassifier, | |
| sub1: xgb.XGBClassifier, | |
| X_test: np.ndarray, | |
| y_test: np.ndarray, | |
| ) -> tuple[float, float]: | |
| """Grid search over AI_THRESH and DF_THRESH to maximise cascade macro F1. | |
| We use macro F1 as the objective because it weights all three classes equally β | |
| so a gain in real F1 only counts if it doesn't badly hurt deepfake or ai_gen F1. | |
| This directly targets the user's concern: real videos shouldn't be misclassified, | |
| but we shouldn't flip deepfakes to real in the process. | |
| AI_THRESH: 0.30 β 0.65 (lower = Submodel 2 is less aggressive, fewer realβAI errors) | |
| DF_THRESH: 0.40 β 0.70 (higher = Submodel 1 needs more confidence to call deepfake, | |
| fewer realβdeepfake errors but more deepfakeβreal errors) | |
| """ | |
| ## get raw probabilities from both models once β reused across the whole grid | |
| prob_ai = sub2.predict_proba(X_test[:, :2048])[:, 1] | |
| prob_df = sub1.predict_proba(X_test)[:, 1] | |
| best_macro_f1 = -1.0 | |
| best_ai_thresh = AI_THRESH | |
| best_df_thresh = DF_THRESH | |
| for ai_t in np.arange(0.30, 0.66, 0.05): | |
| for df_t in np.arange(0.40, 0.71, 0.05): | |
| preds = np.where( | |
| prob_ai >= ai_t, AI_GENERATED, | |
| np.where(prob_df >= df_t, DEEPFAKE, REAL), | |
| ) | |
| macro_f1 = float(f1_score(y_test, preds, average="macro", zero_division=0)) | |
| if macro_f1 > best_macro_f1: | |
| best_macro_f1 = macro_f1 | |
| best_ai_thresh = round(float(ai_t), 2) | |
| best_df_thresh = round(float(df_t), 2) | |
| return best_ai_thresh, best_df_thresh | |
| def evaluate_cascade( | |
| sub2: xgb.XGBClassifier, | |
| sub1: xgb.XGBClassifier, | |
| X_test: np.ndarray, | |
| y_test: np.ndarray, | |
| ai_thresh: float = AI_THRESH, | |
| df_thresh: float = DF_THRESH, | |
| ) -> dict: | |
| preds = cascade_predict(sub2, sub1, X_test, ai_thresh, df_thresh) | |
| f1_per = f1_score( | |
| y_test, preds, | |
| average=None, | |
| labels=[REAL, DEEPFAKE, AI_GENERATED], | |
| zero_division=0, | |
| ) | |
| cm = confusion_matrix(y_test, preds, labels=[REAL, DEEPFAKE, AI_GENERATED]) | |
| return { | |
| "cascade_accuracy": float(accuracy_score(y_test, preds)), | |
| "cascade_f1_real": float(f1_per[0]), | |
| "cascade_f1_deepfake": float(f1_per[1]), | |
| "cascade_f1_ai_generated": float(f1_per[2]), | |
| "cascade_macro_f1": float(f1_score(y_test, preds, average="macro", zero_division=0)), | |
| "cascade_confusion_matrix": cm.tolist(), | |
| "cascade_classification_report": classification_report( | |
| y_test, preds, target_names=["real", "deepfake", "ai_generated"] | |
| ), | |
| } | |
| def save_metrics_txt( | |
| sm1_m: dict, sm1_cv: float, | |
| sm2_m: dict, sm2_cv: float, | |
| cascade_m: dict, | |
| path: Path, | |
| ai_thresh: float = AI_THRESH, | |
| df_thresh: float = DF_THRESH, | |
| ): | |
| cm1 = sm1_m["confusion_matrix"] | |
| cm2 = sm2_m["confusion_matrix"] | |
| cc = cascade_m["cascade_confusion_matrix"] | |
| lines = [ | |
| "=" * 62, | |
| "MODEL A β SYNTHETIC MEDIA DETECTION β EVALUATION REPORT", | |
| "=" * 62, | |
| "", | |
| "SUBMODEL 2 β AI-Generated Detector (binary: ai_gen vs rest)", | |
| "-" * 42, | |
| f" Best CV ROC-AUC : {sm2_cv:.4f}", | |
| f" Test Accuracy : {sm2_m['accuracy']:.4f}", | |
| f" Test Precision : {sm2_m['precision']:.4f}", | |
| f" Test Recall : {sm2_m['recall']:.4f}", | |
| f" Test F1 : {sm2_m['f1']:.4f}", | |
| f" Test ROC-AUC : {sm2_m['roc_auc']:.4f}", | |
| "", | |
| " Confusion Matrix (rows=actual, cols=predicted)", | |
| " not_ai ai_gen", | |
| f" actual not_ai {cm2[0][0]:<10} {cm2[0][1]}", | |
| f" actual ai_gen {cm2[1][0]:<10} {cm2[1][1]}", | |
| "", | |
| sm2_m["classification_report"], | |
| "", | |
| "SUBMODEL 1 β Real vs Deepfake (binary: deepfake vs real)", | |
| "-" * 42, | |
| f" Best CV ROC-AUC : {sm1_cv:.4f}", | |
| f" Test Accuracy : {sm1_m['accuracy']:.4f}", | |
| f" Test Precision : {sm1_m['precision']:.4f}", | |
| f" Test Recall : {sm1_m['recall']:.4f}", | |
| f" Test F1 : {sm1_m['f1']:.4f}", | |
| f" Test ROC-AUC : {sm1_m['roc_auc']:.4f}", | |
| "", | |
| " Confusion Matrix (rows=actual, cols=predicted)", | |
| " real deepfake", | |
| f" actual real {cm1[0][0]:<10} {cm1[0][1]}", | |
| f" actual deepfake {cm1[1][0]:<10} {cm1[1][1]}", | |
| "", | |
| sm1_m["classification_report"], | |
| "", | |
| "CASCADE β Full 3-Class Evaluation on Test Set", | |
| "-" * 42, | |
| f" Thresholds (tuned) : AI_THRESH={ai_thresh} DF_THRESH={df_thresh}", | |
| f" Thresholds (default): AI_THRESH={AI_THRESH} DF_THRESH={DF_THRESH}", | |
| f" Accuracy : {cascade_m['cascade_accuracy']:.4f}", | |
| f" Macro F1 : {cascade_m['cascade_macro_f1']:.4f}", | |
| f" F1 (real) : {cascade_m['cascade_f1_real']:.4f}", | |
| f" F1 (deepfake) : {cascade_m['cascade_f1_deepfake']:.4f}", | |
| f" F1 (ai_gen) : {cascade_m['cascade_f1_ai_generated']:.4f}", | |
| "", | |
| " Confusion Matrix (rows=actual, cols=predicted: real / deepfake / ai_gen)", | |
| f" actual real {cc[0][0]:<10} {cc[0][1]:<10} {cc[0][2]}", | |
| f" actual deepfake {cc[1][0]:<10} {cc[1][1]:<10} {cc[1][2]}", | |
| f" actual ai_gen {cc[2][0]:<10} {cc[2][1]:<10} {cc[2][2]}", | |
| "", | |
| cascade_m["cascade_classification_report"], | |
| "=" * 62, | |
| ] | |
| path.write_text("\n".join(lines), encoding="utf-8") | |
| def main(): | |
| log.info("=" * 62) | |
| log.info("Model A β XGBoost Cascade Training") | |
| log.info("=" * 62) | |
| log.info("") | |
| X_train, y_train, _, src_train = load_split(TRAIN_NPZ) | |
| X_test, y_test, _, src_test = load_split(TEST_NPZ) | |
| log.info(f"Feature shape β train: {X_train.shape} test: {X_test.shape}") | |
| log.info( | |
| f"Train labels β real: {(y_train==REAL).sum()} " | |
| f"deepfake: {(y_train==DEEPFAKE).sum()} " | |
| f"ai_gen: {(y_train==AI_GENERATED).sum()}" | |
| ) | |
| log.info( | |
| f"Test labels β real: {(y_test==REAL).sum()} " | |
| f"deepfake: {(y_test==DEEPFAKE).sum()} " | |
| f"ai_gen: {(y_test==AI_GENERATED).sum()}" | |
| ) | |
| log.info("") | |
| log_source_breakdown(y_train, src_train, "train") | |
| log_source_breakdown(y_test, src_test, "test") | |
| ## Submodel 2 is the cascade entry point β it catches AI-generated videos first | |
| ## only Stream 1 + Stream 2 features for this submodel (no MediaPipe) | |
| X_tr2 = X_train[:, :2048] | |
| X_te2 = X_test[:, :2048] | |
| y_tr2 = (y_train == AI_GENERATED).astype(int) | |
| y_te2 = (y_test == AI_GENERATED).astype(int) | |
| if SM2_PATH.exists() and SM2_CKPT.exists(): | |
| log.info("Submodel 2 already trained β loading from disk, skipping retraining ...") | |
| sub2 = xgb.XGBClassifier() | |
| sub2.load_model(str(SM2_PATH)) | |
| ckpt2 = json.loads(SM2_CKPT.read_text()) | |
| sm2_cv = ckpt2["best_cv"] | |
| sm2_params = ckpt2["best_params"] | |
| else: | |
| log.info("Training Submodel 2 β AI-Generated Detector ...") | |
| n_neg2 = int((y_tr2 == 0).sum()) | |
| n_pos2 = int((y_tr2 == 1).sum()) | |
| spw2 = n_neg2 / max(n_pos2, 1) | |
| log.info(f" not_ai={n_neg2} ai_gen={n_pos2} scale_pos_weight={spw2:.2f}") | |
| sub2, sm2_cv, sm2_params = tune_and_train(X_tr2, y_tr2, spw2, "Submodel2") | |
| sub2.save_model(str(SM2_PATH)) | |
| SM2_CKPT.write_text(json.dumps({"best_cv": sm2_cv, "best_params": sm2_params})) | |
| log.info(f" Saved -> {SM2_PATH.name}") | |
| sm2_m = evaluate_binary(sub2, X_te2, y_te2, AI_THRESH, ["not_ai", "ai_generated"]) | |
| log.info( | |
| f" Accuracy={sm2_m['accuracy']:.4f} Precision={sm2_m['precision']:.4f} " | |
| f"Recall={sm2_m['recall']:.4f} F1={sm2_m['f1']:.4f} ROC-AUC={sm2_m['roc_auc']:.4f}" | |
| ) | |
| log.info("") | |
| log.info(f"[pause] 10s before starting Submodel 1 ...") | |
| time.sleep(10) | |
| ## Submodel 1 only sees real and deepfake videos β ai_generated never reaches here | |
| ## keep only real and deepfake rows | |
| mask_tr = (y_train == REAL) | (y_train == DEEPFAKE) | |
| mask_te = (y_test == REAL) | (y_test == DEEPFAKE) | |
| X_tr1 = X_train[mask_tr] | |
| y_tr1 = (y_train[mask_tr] == DEEPFAKE).astype(int) | |
| X_te1 = X_test[mask_te] | |
| y_te1 = (y_test[mask_te] == DEEPFAKE).astype(int) | |
| if SM1_PATH.exists() and SM1_CKPT.exists(): | |
| log.info("Submodel 1 already trained β loading from disk, skipping retraining ...") | |
| sub1 = xgb.XGBClassifier() | |
| sub1.load_model(str(SM1_PATH)) | |
| ckpt1 = json.loads(SM1_CKPT.read_text()) | |
| sm1_cv = ckpt1["best_cv"] | |
| sm1_params = ckpt1["best_params"] | |
| else: | |
| log.info("Training Submodel 1 β Real vs Deepfake ...") | |
| n_real = int((y_tr1 == 0).sum()) | |
| n_fake = int((y_tr1 == 1).sum()) | |
| spw1 = n_real / max(n_fake, 1) | |
| log.info(f" real={n_real} deepfake={n_fake} scale_pos_weight={spw1:.2f}") | |
| sub1, sm1_cv, sm1_params = tune_and_train(X_tr1, y_tr1, spw1, "Submodel1") | |
| sub1.save_model(str(SM1_PATH)) | |
| SM1_CKPT.write_text(json.dumps({"best_cv": sm1_cv, "best_params": sm1_params})) | |
| log.info(f" Saved -> {SM1_PATH.name}") | |
| sm1_m = evaluate_binary(sub1, X_te1, y_te1, DF_THRESH, ["real", "deepfake"]) | |
| log.info( | |
| f" Accuracy={sm1_m['accuracy']:.4f} Precision={sm1_m['precision']:.4f} " | |
| f"Recall={sm1_m['recall']:.4f} F1={sm1_m['f1']:.4f} ROC-AUC={sm1_m['roc_auc']:.4f}" | |
| ) | |
| log.info("") | |
| ## tune cascade thresholds before final evaluation | |
| log.info("Tuning cascade thresholds (grid search over AI_THRESH Γ DF_THRESH) ...") | |
| best_ai_thresh, best_df_thresh = tune_thresholds(sub2, sub1, X_test, y_test) | |
| log.info(f" Default AI_THRESH={AI_THRESH} DF_THRESH={DF_THRESH}") | |
| log.info(f" Tuned AI_THRESH={best_ai_thresh} DF_THRESH={best_df_thresh}") | |
| log.info("") | |
| ## run the full cascade on the test set with tuned thresholds | |
| log.info("Evaluating full cascade on test set (tuned thresholds) ...") | |
| cascade_m = evaluate_cascade(sub2, sub1, X_test, y_test, best_ai_thresh, best_df_thresh) | |
| log.info( | |
| f" Cascade Accuracy={cascade_m['cascade_accuracy']:.4f} " | |
| f"Macro F1={cascade_m['cascade_macro_f1']:.4f}" | |
| ) | |
| log.info( | |
| f" F1 β real={cascade_m['cascade_f1_real']:.4f} " | |
| f"deepfake={cascade_m['cascade_f1_deepfake']:.4f} " | |
| f"ai_gen={cascade_m['cascade_f1_ai_generated']:.4f}" | |
| ) | |
| log.info("") | |
| ## save both models and all metrics | |
| metrics_out = { | |
| "submodel2": { | |
| **{k: v for k, v in sm2_m.items() if k != "classification_report"}, | |
| "best_cv_roc_auc": sm2_cv, | |
| "best_params": sm2_params, | |
| "n_train": int(X_tr2.shape[0]), | |
| "n_test": int(X_te2.shape[0]), | |
| "feature_dims": 2048, | |
| }, | |
| "submodel1": { | |
| **{k: v for k, v in sm1_m.items() if k != "classification_report"}, | |
| "best_cv_roc_auc": sm1_cv, | |
| "best_params": sm1_params, | |
| "n_train": int(X_tr1.shape[0]), | |
| "n_test": int(X_te1.shape[0]), | |
| "feature_dims": 2064, | |
| }, | |
| "cascade": { | |
| k: v for k, v in cascade_m.items() | |
| if k != "cascade_classification_report" | |
| }, | |
| "thresholds": { | |
| "ai_threshold": best_ai_thresh, | |
| "df_threshold": best_df_thresh, | |
| "ai_threshold_default": AI_THRESH, | |
| "df_threshold_default": DF_THRESH, | |
| }, | |
| } | |
| METRICS_JSON.write_text(json.dumps(metrics_out, indent=2), encoding="utf-8") | |
| log.info(f"Metrics JSON -> {METRICS_JSON.name}") | |
| save_metrics_txt(sm1_m, sm1_cv, sm2_m, sm2_cv, cascade_m, METRICS_TXT, best_ai_thresh, best_df_thresh) | |
| log.info(f"Metrics TXT -> {METRICS_TXT.name}") | |
| log.info("") | |
| log.info("=" * 62) | |
| log.info("Done.") | |
| log.info("=" * 62) | |
| if __name__ == "__main__": | |
| main() | |