{ "cells": [ { "cell_type": "markdown", "metadata": { "id": "zh2cG0-8e5J3" }, "source": [ "# RandomForest Model for Injury Risk Prediction\n", "\n", "This notebook trains a RandomForest model to predict injury risk levels (Low, Medium, High) for athletes based on their training and physical attributes. The pipeline includes data loading, preprocessing, feature engineering, model training with hyperparameter tuning, probability calibration, evaluation, and testing.\n", "\n", "## Objectives\n", "- Load and preprocess the dataset.\n", "- Perform feature engineering to create meaningful features.\n", "- Train a RandomForest model with optimized hyperparameter tuning.\n", "- Calibrate probabilities to ensure reliable confidence scores.\n", "- Evaluate the model using appropriate metrics and visualizations.\n", "- Save the model and encoder for use in the prediction pipeline.\n", "- Test the saved model on the test set or new data." ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "9mkm6h_OfNaL", "outputId": "536d54a0-46b7-4e91-fb9e-208fb1aa0983" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Requirement already satisfied: scikit-learn==1.6.1 in /usr/local/lib/python3.11/dist-packages (1.6.1)\n", "Requirement already satisfied: joblib in /usr/local/lib/python3.11/dist-packages (1.4.2)\n", "Requirement already satisfied: imbalanced-learn in /usr/local/lib/python3.11/dist-packages (0.12.4)\n", "Requirement already satisfied: numpy>=1.19.5 in /usr/local/lib/python3.11/dist-packages (from scikit-learn==1.6.1) (2.0.2)\n", "Requirement already satisfied: scipy>=1.6.0 in /usr/local/lib/python3.11/dist-packages (from scikit-learn==1.6.1) (1.14.1)\n", "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.11/dist-packages (from scikit-learn==1.6.1) (3.6.0)\n" ] } ], "source": [ "# Install required packages\n", "!pip install -U scikit-learn==1.6.1 joblib imbalanced-learn" ] }, { "cell_type": "code", "execution_count": 2, "metadata": { "id": "zh2cG0-8e5J3" }, "outputs": [], "source": [ "# Import libraries\n", "import os\n", "import pandas as pd\n", "import numpy as np\n", "import joblib\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", "import random\n", "from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold, RandomizedSearchCV\n", "from sklearn.metrics import classification_report, confusion_matrix, f1_score, accuracy_score\n", "from sklearn.ensemble import RandomForestClassifier\n", "from imblearn.over_sampling import SMOTE\n", "from sklearn.preprocessing import LabelEncoder\n", "from sklearn.calibration import CalibratedClassifierCV\n", "\n", "# Set seed for reproducibility across all random processes\n", "SEED = 42\n", "np.random.seed(SEED)\n", "random.seed(SEED)\n", "os.environ['PYTHONHASHSEED'] = str(SEED)" ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "lUBXg0QGe_19", "outputId": "916a5487-cdca-45ed-b6f3-d330f258189f" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Loading dataset...\n", "Dataset loaded. Shape: (10000, 18)\n", "\n", "Dataset Info:\n", "\n", "RangeIndex: 10000 entries, 0 to 9999\n", "Data columns (total 18 columns):\n", " # Column Non-Null Count Dtype \n", "--- ------ -------------- ----- \n", " 0 Age 10000 non-null int64 \n", " 1 Gender 10000 non-null object \n", " 2 Sport_Type 10000 non-null object \n", " 3 Experience_Level 10000 non-null object \n", " 4 Flexibility_Score 10000 non-null float64\n", " 5 Total_Weekly_Training_Hours 10000 non-null float64\n", " 6 High_Intensity_Training_Hours 10000 non-null float64\n", " 7 Strength_Training_Frequency 10000 non-null int64 \n", " 8 Recovery_Time_Between_Sessions 10000 non-null float64\n", " 9 Training_Load_Score 10000 non-null float64\n", " 10 Sprint_Speed 10000 non-null float64\n", " 11 Endurance_Score 10000 non-null float64\n", " 12 Agility_Score 10000 non-null float64\n", " 13 Fatigue_Level 10000 non-null int64 \n", " 14 Previous_Injury_Count 10000 non-null int64 \n", " 15 Previous_Injury_Type 10000 non-null object \n", " 16 Injury_Risk_Level 10000 non-null object \n", " 17 Injury_Outcome 10000 non-null int64 \n", "dtypes: float64(8), int64(5), object(5)\n", "memory usage: 1.4+ MB\n", "None\n", "\n", "Class Distribution:\n", "Injury_Risk_Level\n", "Medium 6014\n", "Low 2832\n", "High 1154\n", "Name: count, dtype: int64\n" ] } ], "source": [ "# ---------------------- 1. Load Data ----------------------\n", "print(\"Loading dataset...\")\n", "data_path = \"/content/Refined_Sports_Injury_Dataset.csv\"\n", "\n", "# Check if file exists\n", "if not os.path.exists(data_path):\n", " raise FileNotFoundError(f\"Dataset file not found at {data_path}. Please ensure the file exists.\")\n", "\n", "df = pd.read_csv(data_path)\n", "\n", "# Validate dataset structure\n", "expected_columns = [\n", " \"Age\", \"Gender\", \"Sport_Type\", \"Experience_Level\", \"Flexibility_Score\",\n", " \"Total_Weekly_Training_Hours\", \"High_Intensity_Training_Hours\", \"Strength_Training_Frequency\",\n", " \"Recovery_Time_Between_Sessions\", \"Training_Load_Score\", \"Sprint_Speed\", \"Endurance_Score\",\n", " \"Agility_Score\", \"Fatigue_Level\", \"Previous_Injury_Count\", \"Previous_Injury_Type\",\n", " \"Injury_Risk_Level\"\n", "]\n", "if not all(col in df.columns for col in expected_columns):\n", " missing_cols = [col for col in expected_columns if col not in df.columns]\n", " raise ValueError(f\"Dataset is missing required columns: {missing_cols}\")\n", "\n", "# Display dataset info\n", "print(\"Dataset loaded. Shape:\", df.shape)\n", "print(\"\\nDataset Info:\")\n", "print(df.info())\n", "print(\"\\nClass Distribution:\")\n", "print(df[\"Injury_Risk_Level\"].value_counts())" ] }, { "cell_type": "code", "execution_count": 4, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "5NyQ1yuhLLRW", "outputId": "ab2a5946-d752-46ba-ea42-69df3e6d8c4a" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "šŸ”  Encoding categorical columns...\n", "Encoded class mapping: {'High': 0, 'Low': 1, 'Medium': 2}\n", "Sample of encoded data:\n", " Age Gender Sport_Type Experience_Level Flexibility_Score \\\n", "0 34 0 2 0 7.2 \n", "1 29 1 4 3 8.5 \n", "2 31 0 2 2 6.8 \n", "3 27 1 0 1 7.9 \n", "4 33 0 3 2 6.5 \n", "\n", " Total_Weekly_Training_Hours High_Intensity_Training_Hours \\\n", "0 12.0 4.0 \n", "1 8.0 2.0 \n", "2 15.0 6.0 \n", "3 10.0 3.0 \n", "4 9.0 3.0 \n", "\n", " Strength_Training_Frequency Recovery_Time_Between_Sessions \\\n", "0 3 48.0 \n", "1 2 72.0 \n", "2 4 36.0 \n", "3 2 60.0 \n", "4 3 48.0 \n", "\n", " Training_Load_Score Sprint_Speed Endurance_Score Agility_Score \\\n", "0 65.0 6.8 7.5 7.0 \n", "1 45.0 7.2 8.0 8.2 \n", "2 80.0 6.5 7.0 6.8 \n", "3 55.0 7.0 7.8 7.5 \n", "4 60.0 6.7 7.2 6.9 \n", "\n", " Fatigue_Level Previous_Injury_Count Previous_Injury_Type \\\n", "0 4 1 1 \n", "1 2 0 0 \n", "2 6 2 2 \n", "3 3 0 0 \n", "4 5 1 3 \n", "\n", " Injury_Risk_Level Injury_Outcome \n", "0 2 0 \n", "1 1 0 \n", "2 0 1 \n", "3 1 0 \n", "4 2 0 \n" ] } ], "source": [ "# ---------------------- 2. Data Preprocessing & Encoding ----------------------\n", "print(\"\\nšŸ”  Encoding categorical columns...\")\n", "\n", "# Define mappings for categorical variables\n", "gender_mapping = {\"Male\": 0, \"Female\": 1}\n", "experience_mapping = {\"Beginner\": 0, \"Intermediate\": 1, \"Advanced\": 2, \"Professional\": 3}\n", "injury_type_mapping = {\"None\": 0, \"Sprain\": 1, \"Ligament Tear\": 2, \"Tendonitis\": 3, \"Strain\": 4, \"Fracture\": 5}\n", "\n", "# Handle NaN values in Previous_Injury_Type by treating them as 'None'\n", "df[\"Previous_Injury_Type\"] = df[\"Previous_Injury_Type\"].fillna(\"None\")\n", "\n", "# Validate categorical columns before encoding\n", "for col, mapping in [(\"Gender\", gender_mapping), (\"Experience_Level\", experience_mapping), (\"Previous_Injury_Type\", injury_type_mapping)]:\n", " invalid_values = set(df[col]) - set(mapping.keys())\n", " if invalid_values:\n", " raise ValueError(f\"Invalid values found in {col}: {invalid_values}. Expected values: {list(mapping.keys())}\")\n", "\n", "# Encode Gender\n", "df[\"Gender\"] = df[\"Gender\"].map(gender_mapping).fillna(0).astype(int)\n", "\n", "# Encode Sport_Type dynamically\n", "df[\"Sport_Type\"] = df[\"Sport_Type\"].astype(\"category\").cat.codes\n", "\n", "# Encode Experience_Level\n", "df[\"Experience_Level\"] = df[\"Experience_Level\"].map(experience_mapping).fillna(0).astype(int)\n", "\n", "# Encode Previous_Injury_Type\n", "df[\"Previous_Injury_Type\"] = df[\"Previous_Injury_Type\"].map(injury_type_mapping).fillna(0).astype(int)\n", "\n", "# Encode target variable (Injury_Risk_Level)\n", "le = LabelEncoder()\n", "df[\"Injury_Risk_Level\"] = le.fit_transform(df[\"Injury_Risk_Level\"].astype(str))\n", "\n", "# Verify encoding\n", "print(\"Encoded class mapping:\", dict(zip(le.classes_, range(len(le.classes_)))))\n", "print(\"Sample of encoded data:\\n\", df.head())" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "Y4373JooLLit", "outputId": "b9760f3d-3d2b-41b1-a63f-a6930099e0f7" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "šŸ› ļø Creating derived features...\n", "Features created: ['Age', 'Gender', 'Sport_Type', 'Experience_Level', 'Flexibility_Score', 'Total_Weekly_Training_Hours', 'High_Intensity_Training_Hours', 'Strength_Training_Frequency', 'Recovery_Time_Between_Sessions', 'Training_Load_Score', 'Sprint_Speed', 'Endurance_Score', 'Agility_Score', 'Fatigue_Level', 'Previous_Injury_Count', 'Previous_Injury_Type', 'Intensity_Ratio', 'Recovery_Per_Training']\n", "Sample of features:\n", " Age Gender Sport_Type Experience_Level Flexibility_Score \\\n", "0 34 0 2 0 7.2 \n", "1 29 1 4 3 8.5 \n", "2 31 0 2 2 6.8 \n", "3 27 1 0 1 7.9 \n", "4 33 0 3 2 6.5 \n", "\n", " Total_Weekly_Training_Hours High_Intensity_Training_Hours \\\n", "0 12.0 4.0 \n", "1 8.0 2.0 \n", "2 15.0 6.0 \n", "3 10.0 3.0 \n", "4 9.0 3.0 \n", "\n", " Strength_Training_Frequency Recovery_Time_Between_Sessions \\\n", "0 3 48.0 \n", "1 2 72.0 \n", "2 4 36.0 \n", "3 2 60.0 \n", "4 3 48.0 \n", "\n", " Training_Load_Score Sprint_Speed Endurance_Score Agility_Score \\\n", "0 65.0 6.8 7.5 7.0 \n", "1 45.0 7.2 8.0 8.2 \n", "2 80.0 6.5 7.0 6.8 \n", "3 55.0 7.0 7.8 7.5 \n", "4 60.0 6.7 7.2 6.9 \n", "\n", " Fatigue_Level Previous_Injury_Count Previous_Injury_Type Intensity_Ratio \\\n", "0 4 1 1 0.333333 \n", "1 2 0 0 0.250000 \n", "2 6 2 2 0.400000 \n", "3 3 0 0 0.300000 \n", "4 5 1 3 0.333333 \n", "\n", " Recovery_Per_Training \n", "0 4.0 \n", "1 9.0 \n", "2 2.4 \n", "3 6.0 \n", "4 5.3 \n" ] } ], "source": [ "# ---------------------- 3. Feature Engineering ----------------------\n", "print(\"\\nšŸ› ļø Creating derived features...\")\n", "\n", "# Replace 0 with 0.1 in Total_Weekly_Training_Hours to avoid division by zero\n", "df[\"Total_Weekly_Training_Hours\"] = df[\"Total_Weekly_Training_Hours\"].replace(0, 0.1)\n", "\n", "# Create derived features\n", "df[\"Intensity_Ratio\"] = df[\"High_Intensity_Training_Hours\"] / df[\"Total_Weekly_Training_Hours\"]\n", "df[\"Recovery_Per_Training\"] = df[\"Recovery_Time_Between_Sessions\"] / df[\"Total_Weekly_Training_Hours\"]\n", "\n", "# Check for NaN or infinite values in derived features\n", "if df[[\"Intensity_Ratio\", \"Recovery_Per_Training\"]].isna().any().any() or np.isinf(df[[\"Intensity_Ratio\", \"Recovery_Per_Training\"]]).any().any():\n", " raise ValueError(\"NaN or infinite values found in derived features. Check data for inconsistencies.\")\n", "\n", "# Define features\n", "features = [\n", " \"Age\", \"Gender\", \"Sport_Type\", \"Experience_Level\", \"Flexibility_Score\",\n", " \"Total_Weekly_Training_Hours\", \"High_Intensity_Training_Hours\", \"Strength_Training_Frequency\",\n", " \"Recovery_Time_Between_Sessions\", \"Training_Load_Score\", \"Sprint_Speed\", \"Endurance_Score\",\n", " \"Agility_Score\", \"Fatigue_Level\", \"Previous_Injury_Count\", \"Previous_Injury_Type\",\n", " \"Intensity_Ratio\", \"Recovery_Per_Training\"\n", "]\n", "\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Prepare features and target\n", "X = df[features]\n", "y = df[\"Injury_Risk_Level\"]\n", "\n", "# Verify features\n", "print(\"Features created:\", features)\n", "print(\"Sample of features:\\n\", X.head())" ] }, { "cell_type": "code", "execution_count": 6, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "MORGKXZULLsQ", "outputId": "8c993957-7909-400b-a311-eab5fda2127b" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "šŸ“Š Splitting data & applying SMOTE...\n", "Training set class distribution after SMOTE:\n", "0 4811\n", "1 4811\n", "2 4811\n", "Name: count, dtype: int64\n" ] } ], "source": [ "# ---------------------- 4. Train/Test Split & SMOTE ----------------------\n", "print(\"\\nšŸ“Š Splitting data & applying SMOTE...\")\n", "\n", "# Split data into train and test sets\n", "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=SEED)\n", "\n", "# Apply SMOTE to balance the training set\n", "try:\n", " sm = SMOTE(random_state=SEED)\n", " X_train_res, y_train_res = sm.fit_resample(X_train, y_train)\n", "except Exception as e:\n", " raise RuntimeError(f\"SMOTE failed: {str(e)}. Check for invalid data in X_train or y_train.\")\n", "\n", "# Print class distribution after SMOTE\n", "print(\"Training set class distribution after SMOTE:\")\n", "print(pd.Series(y_train_res).value_counts())" ] }, { "cell_type": "code", "execution_count": 7, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "sKycZa1YLL11", "outputId": "94940989-75fe-4cae-aee9-908a26d63bc1" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "šŸ” Performing hyperparameter tuning...\n", "Best hyperparameters: {'n_estimators': 300, 'min_samples_split': 2, 'min_samples_leaf': 1, 'max_depth': 25}\n", "Best cross-validation F1-score: 0.947693499624901\n" ] } ], "source": [ "# ---------------------- 5. Hyperparameter Tuning with RandomizedSearchCV ----------------------\n", "print(\"\\nšŸ” Performing hyperparameter tuning...\")\n", "\n", "# Compute class weights to prioritize 'High' class\n", "class_weights = {0: 2.0, 1: 1.0, 2: 1.0} # Higher weight for 'High' (encoded as 0)\n", "\n", "# Define RandomForest model\n", "rf = RandomForestClassifier(random_state=SEED, class_weight=class_weights)\n", "\n", "# Define reduced hyperparameter grid for faster tuning\n", "param_dist = {\n", " 'n_estimators': [100, 200, 300],\n", " 'max_depth': [15, 20, 25],\n", " 'min_samples_split': [2, 5],\n", " 'min_samples_leaf': [1, 2]\n", "}\n", "\n", "# Perform randomized search with cross-validation\n", "cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)\n", "random_search = RandomizedSearchCV(\n", " estimator=rf, param_distributions=param_dist, n_iter=15, cv=cv,\n", " scoring='f1_macro', n_jobs=1, random_state=SEED\n", ")\n", "random_search.fit(X_train_res, y_train_res)\n", "\n", "# Get best model\n", "best_rf = random_search.best_estimator_\n", "print(\"Best hyperparameters:\", random_search.best_params_)\n", "print(\"Best cross-validation F1-score:\", random_search.best_score_)" ] }, { "cell_type": "code", "execution_count": 8, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "KJCINYHCLL_O", "outputId": "d03379d8-2c2a-4db6-997f-73ad2dd86616" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "šŸ“ Calibrating probabilities...\n", "\n", "šŸ“ˆ Model Evaluation on Test Set:\n", "F1 Score (Macro): 0.9141359042280297\n", "Accuracy: 0.929\n", "\n", "Classification Report:\n", " precision recall f1-score support\n", "\n", " High 0.83 0.92 0.87 231\n", " Low 0.92 0.95 0.93 566\n", " Medium 0.96 0.92 0.94 1203\n", "\n", " accuracy 0.93 2000\n", " macro avg 0.90 0.93 0.91 2000\n", "weighted avg 0.93 0.93 0.93 2000\n", "\n", "Cross-Validation F1 Scores: [0.94929183 0.94712258 0.94513455 0.94776378 0.95005877]\n", "Mean CV F1 Score: 0.9479 ± 0.0017\n" ] } ], "source": [ "# ---------------------- 6. Calibrate Probabilities ----------------------\n", "print(\"\\nšŸ“ Calibrating probabilities...\")\n", "\n", "# Calibrate the best model using CalibratedClassifierCV with reduced folds\n", "calibrated_rf = CalibratedClassifierCV(best_rf, method='sigmoid', cv=3, ensemble=True)\n", "calibrated_rf.fit(X_train_res, y_train_res)\n", "\n", "# Evaluate calibrated model on test set\n", "y_pred = calibrated_rf.predict(X_test)\n", "y_proba = calibrated_rf.predict_proba(X_test)\n", "\n", "# Print evaluation metrics\n", "print(\"\\nšŸ“ˆ Model Evaluation on Test Set:\")\n", "print(\"F1 Score (Macro):\", f1_score(y_test, y_pred, average=\"macro\"))\n", "print(\"Accuracy:\", accuracy_score(y_test, y_pred))\n", "print(\"\\nClassification Report:\\n\", classification_report(y_test, y_pred, target_names=le.classes_))\n", "\n", "# Cross-validation on calibrated model\n", "cv_scores = cross_val_score(calibrated_rf, X_train_res, y_train_res, cv=cv, scoring=\"f1_macro\")\n", "print(f\"Cross-Validation F1 Scores: {cv_scores}\")\n", "print(f\"Mean CV F1 Score: {cv_scores.mean():.4f} ± {cv_scores.std():.4f}\")" ] }, { "cell_type": "code", "execution_count": 9, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "lB69j9OWLMJP", "outputId": "66942bbc-dcaf-4441-97d5-5b2715f9c508" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "šŸ“‰ Generating visualizations...\n", "Visuals saved to model/ directory.\n" ] } ], "source": [ "# ---------------------- 7. Visual Insights ----------------------\n", "print(\"\\nšŸ“‰ Generating visualizations...\")\n", "\n", "# Ensure model directory exists\n", "os.makedirs(\"model\", exist_ok=True)\n", "\n", "# Confusion Matrix\n", "conf_matrix = confusion_matrix(y_test, y_pred)\n", "plt.figure(figsize=(8, 6))\n", "sns.heatmap(conf_matrix, annot=True, fmt=\"g\", cmap=\"Blues\", xticklabels=le.classes_, yticklabels=le.classes_)\n", "plt.title(\"Confusion Matrix - RandomForest\")\n", "plt.xlabel(\"Predicted\")\n", "plt.ylabel(\"True\")\n", "plt.savefig(\"model/rf_confusion_matrix.png\")\n", "plt.close()\n", "\n", "# Feature Importance\n", "feat_imp = pd.DataFrame({\"Feature\": features, \"Importance\": best_rf.feature_importances_})\n", "feat_imp.sort_values(\"Importance\", ascending=False, inplace=True)\n", "plt.figure(figsize=(10, 6))\n", "sns.barplot(x=\"Importance\", y=\"Feature\", data=feat_imp)\n", "plt.title(\"Feature Importances - RandomForest\")\n", "plt.tight_layout()\n", "plt.savefig(\"model/rf_feature_importance.png\")\n", "plt.close()\n", "\n", "print(\"Visuals saved to model/ directory.\")" ] }, { "cell_type": "code", "execution_count": 10, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "KJCINYHCLL_O", "outputId": "d03379d8-2c2a-4db6-997f-73ad2dd86616" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "šŸ’¾ Saving model and encoder...\n", "Model and encoder saved to model/ directory.\n" ] } ], "source": [ "# ---------------------- 8. Save Model and Encoder ----------------------\n", "print(\"\\nšŸ’¾ Saving model and encoder...\")\n", "\n", "# Ensure model directory exists\n", "os.makedirs(\"model\", exist_ok=True)\n", "\n", "# Save the calibrated model\n", "joblib.dump(calibrated_rf, \"model/rf_injury_model.pkl\")\n", "\n", "# Save the label encoder\n", "joblib.dump(le, \"model/rf_target_encoder.pkl\")\n", "\n", "print(\"Model and encoder saved to model/ directory.\")" ] } ], "metadata": { "colab": { "provenance": [] }, "kernelspec": { "display_name": "Python 3", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 0 }