{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Dataset processing\n",
    "\n",
    "In this notebook we are going to process each of the taxpasta files using the proposed methodology:\n",
    "1) Raw read counts is normalized against the rest of samples. In this scenario we can do two normalizations:\n",
    "    - Merged normalization of POOL and CONTROL samples (`NORM+`). \n",
    "    - Individual normalization of POOL samples on one hand and CONTROL samples on the other (`NORM|`).\n",
    "\n",
    "2) Select species based on the flagging system. The following criteria are used for flagging:\n",
    "    - Number of profilers where the species is detected.\n",
    "    - Minimum number of reads across profilers.\n",
    "    - Mean number of reads across profilers.\n",
    "    - CV across profilers.\n",
    "\n",
    "    Based on this criteria, we are going to apply a cutoff on the number of flags.\n",
    "\n",
    "The files will be saved as `SAMPLE_PASS{}_MODE{}_NORM{}.summary`\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!pip install kneed"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "from datetime import datetime\n",
    "import glob\n",
    "import matplotlib.pyplot as plt\n",
    "import numpy as np\n",
    "import os\n",
    "import pandas as pd\n",
    "import seaborn as sns\n",
    "from kneed import KneeLocator\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [],
   "source": [
    "pd.options.display.float_format = '{:.3f}'.format\n",
    "pd.set_option('display.max_columns', None)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [],
   "source": [
    "from list_vars import LIST_PROFILERS, DIR_FIGURES, DIR_SUMMARY_OUTPUT, DIR_PROFILING, CONTROLS, POOLS"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Variable setting"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [],
   "source": [
    "os.makedirs(f'{DIR_SUMMARY_OUTPUT}', exist_ok=True)\n",
    "os.makedirs(f'{DIR_FIGURES}', exist_ok=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Functions"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [],
   "source": [
    "# MAD (Median Absolute Deviation)\n",
    "# MAD = median(|X - median(X)|)\n",
    "def mad(series):\n",
    "    med = np.median(series)\n",
    "    return np.median(np.abs(series - med))\n",
    "\n",
    "def weighted_std(values, weights):\n",
    "    \"\"\"\n",
    "    Calculate the weighted standard deviation.\n",
    "    \"\"\"\n",
    "    average = np.average(values, weights=weights)\n",
    "    variance = np.average((values - average)**2, weights=weights)\n",
    "    return np.sqrt(variance)\n",
    "\n",
    "def process_df(df):\n",
    "    # Reverses the phylogenetic order of the taxa and removes \"root\" and \"cellular organisms\" labels\n",
    "    lineage_vals = df.lineage.values\n",
    "    new_lineage_vals = []\n",
    "    for lineage in lineage_vals:\n",
    "        val = ';'.join(lineage.split(';')[::-1]).replace('root;', '').replace('cellular organisms;', '')\n",
    "        new_lineage_vals.append(val)\n",
    "    df.lineage = new_lineage_vals\n",
    "    return df\n",
    "\n",
    "\n",
    "def load_and_process_tables(sample, profilers, taxonomic_level, mode, passn, verbose=False):\n",
    "    \"\"\"\n",
    "    Load and process profiling tables for the given sample and profilers.\n",
    "    \n",
    "    Args:\n",
    "        sample (str): The sample name.\n",
    "        profilers (list): List of profiler names.\n",
    "        taxonomic_level (str): Either 'species' or 'genus'.\n",
    "        mode (int): The processing mode to identify file paths.\n",
    "    \n",
    "    Returns:\n",
    "        list: A list of processed DataFrames for each profiler.\n",
    "    \"\"\"\n",
    "\n",
    "    list_df_samples = []\n",
    "    list_available_profilers = []\n",
    "\n",
    "    for profiler in profilers:\n",
    "        # Construct file pattern and find the file\n",
    "        file_pattern = f'{DIR_PROFILING}/{profiler}/pass{passn}/{sample}_mode{mode}/{sample}_mode{mode}.*.standardised.{taxonomic_level}'\n",
    "        matching_files = glob.glob(file_pattern)\n",
    "        \n",
    "        if not matching_files:\n",
    "            print(f\"No matching file found for {profiler} with pattern {file_pattern}\")\n",
    "            continue\n",
    "\n",
    "        file_path = matching_files[0]\n",
    "        if verbose:\n",
    "            print(f\"Loading file: {file_path}\")\n",
    "        df_sample_method = process_df(pd.read_csv(file_path, sep='\\t'))\n",
    "\n",
    "        df_sample_method['taxonomy_id'] = df_sample_method['taxonomy_id'].astype(int)\n",
    "        df_sample_method.rename(columns={'count': profiler}, inplace=True)\n",
    "        \n",
    "        list_df_samples.append(df_sample_method)\n",
    "        list_available_profilers.append(profiler)\n",
    "\n",
    "\n",
    "    return list_df_samples, list_available_profilers\n",
    "\n",
    "\n",
    "def merge_tables(list_df_samples, profilers, remove_human, verbose=False):\n",
    "    \"\"\"\n",
    "    Merge profiling DataFrames into a single table.\n",
    "\n",
    "    Args:\n",
    "        list_df_samples (list): List of profiling DataFrames.\n",
    "        profilers (list): List of profiler names.\n",
    "    \n",
    "    Returns:\n",
    "        pd.DataFrame: Merged DataFrame containing all profiling data.\n",
    "    \"\"\"\n",
    "    # Initialize an empty DataFrame\n",
    "    merged_df = None\n",
    "\n",
    "    for df_sample, profiler in zip(list_df_samples, profilers):\n",
    "        if merged_df is None:\n",
    "            # Start with the first DataFrame\n",
    "            merged_df = df_sample[['name', 'lineage', 'taxonomy_id']].copy()\n",
    "            merged_df[profiler] = df_sample[profiler]\n",
    "        else:\n",
    "            # Merge subsequent DataFrames on the index\n",
    "            merged_df = merged_df.merge(\n",
    "                df_sample[['name', 'lineage', 'taxonomy_id', profiler]],\n",
    "                how='outer',\n",
    "            )\n",
    "\n",
    "    if remove_human:\n",
    "        merged_df = merged_df[~ merged_df['taxonomy_id'].astype(str).isin(['9606', '9605'])]\n",
    "\n",
    "    if verbose:\n",
    "        print(\"Merged table dimensions:\", merged_df.shape)\n",
    "    return merged_df\n",
    "\n",
    "\n",
    "def normalize_counts(df_sample, profilers, sample, dict_FASTQ_len, verbose):\n",
    "    median_FASTQ_len = np.median(np.array(list(dict_FASTQ_len.values())))\n",
    "    correction_factor = median_FASTQ_len / dict_FASTQ_len[sample]\n",
    "\n",
    "    if verbose:\n",
    "        print(f'The correction factor is {correction_factor}')\n",
    "\n",
    "    # Apply normalization\n",
    "    for col_profiler in profilers:\n",
    "        df_sample[f'{col_profiler}_norm'] = df_sample[col_profiler] * correction_factor\n",
    "\n",
    "    # Print the total normalized reads per profiler if verbose\n",
    "    if verbose:\n",
    "        for col_profiler in profilers:\n",
    "            total_norm = df_sample[f'{col_profiler}_norm'].sum()\n",
    "            print(f'{col_profiler}: Total normalized reads = {total_norm}')\n",
    "\n",
    "    # Calculate relative abundance as a percentage of each profiler's total normalized reads\n",
    "    for col_profiler in profilers:\n",
    "        total_norm = df_sample[f'{col_profiler}_norm'].sum()\n",
    "        # Avoid division by zero if no reads present\n",
    "        if total_norm > 0:\n",
    "            df_sample[f'{col_profiler}_relab'] = (df_sample[f'{col_profiler}_norm'] / total_norm) * 100\n",
    "        else:\n",
    "            df_sample[f'{col_profiler}_relab'] = 0.0\n",
    "\n",
    "    return df_sample\n",
    "\n",
    "\n",
    "\n",
    "def calculate_stats(df_sample, profilers, verbose=False):\n",
    "    df_sample_fillna = df_sample.fillna(0)\n",
    "\n",
    "    # Identify columns\n",
    "    cols_sample_norm = [f'{i}_norm' for i in profilers]\n",
    "    cols_sample_relab = [f'{i}_relab' for i in profilers]\n",
    "\n",
    "    df_sample['n_profilers'] = (df_sample[profilers].fillna(0) > 0).sum(axis=1)\n",
    "    \n",
    "    # Calculate median and MAD for raw counts, normalized counts, and relative abundance\n",
    "    # Median\n",
    "    weights = df_sample_fillna[profilers].sum(axis=1)\n",
    "\n",
    "    # Calculate the weighted average\n",
    "    df_sample['mean_raw'] = df_sample_fillna[profilers].apply(\n",
    "        lambda x: np.average(x, weights=x / weights[x.name] if weights[x.name] != 0 else np.nan),\n",
    "        axis=1\n",
    "    ).copy()\n",
    "    df_sample['std_raw'] = df_sample_fillna[profilers].apply(\n",
    "        lambda row: weighted_std(row, weights=row / weights[row.name] if weights[row.name] != 0 else np.nan),\n",
    "        axis=1\n",
    "    ).copy()\n",
    "    df_sample['CV_raw'] = df_sample['std_raw'] / df_sample['mean_raw']\n",
    "\n",
    "\n",
    "\n",
    "    weights = df_sample_fillna[[f'{i}_norm' for i in profilers]].fillna(0).sum(axis=1)\n",
    "\n",
    "\n",
    "    df_sample['mean_norm'] = df_sample_fillna[cols_sample_norm].apply(\n",
    "        lambda x: np.average(x, weights=x / weights[x.name] if weights[x.name] != 0 else np.nan),\n",
    "        axis=1\n",
    "    ).copy()\n",
    "    df_sample['std_norm'] = df_sample_fillna[cols_sample_norm].apply(\n",
    "        lambda row: weighted_std(row, weights=row / weights[row.name] if weights[row.name] != 0 else np.nan),\n",
    "        axis=1\n",
    "    ).copy()\n",
    "    df_sample['CV_norm'] = df_sample['std_norm'] / df_sample['mean_norm']\n",
    "\n",
    "\n",
    "    weights = df_sample_fillna[[f'{i}_relab' for i in profilers]].fillna(0).sum(axis=1)\n",
    "\n",
    "\n",
    "    df_sample['mean_relab'] = df_sample_fillna[cols_sample_relab].apply(\n",
    "        lambda x: np.average(x, weights=x / weights[x.name] if weights[x.name] != 0 else np.nan),\n",
    "        axis=1\n",
    "    ).copy()\n",
    "    df_sample['std_relab'] = df_sample_fillna[cols_sample_relab].apply(\n",
    "        lambda row: weighted_std(row, weights=row / weights[row.name] if weights[row.name] != 0 else np.nan),\n",
    "        axis=1\n",
    "    ).copy()\n",
    "    df_sample['CV_relab'] = df_sample['std_relab'] / df_sample['mean_relab']\n",
    "\n",
    "\n",
    "\n",
    "    # # Calculate mean FASTQ length for original percentage calculation\n",
    "    # mean_FASTQ_len = np.median(np.array(list(dict_FASTQ_len.values())))\n",
    "    # df_sample['mean (%)'] = 100 * df_sample['mean'] / mean_FASTQ_len\n",
    "\n",
    "    # Sort by median of relative abundance (descending)\n",
    "    df_sample = df_sample.sort_values(by='mean_relab', ascending=False)\n",
    "\n",
    "    # if verbose:\n",
    "    #     print(\"Calculated additional statistics (median, MAD, CV based on median/MAD).\")\n",
    "    #     print(\"Sorted species by median relative abundance.\")\n",
    "\n",
    "    return df_sample\n",
    "\n",
    "\n",
    "\n",
    "def apply_flagging_system(df_sample, sample, mode, passn, verbose, norm_string, taxonomic_level, S=1, save_knee_plots=True):\n",
    "    # We assume the first three columns are metadata (e.g. name, lineage, taxonomy_id)\n",
    "    columns_flagging = df_sample.columns[3:]  # exclude name, lineage, and taxonomy\n",
    "    df_flags = df_sample.copy()\n",
    "    df_flags.iloc[:, 3:] = 1  # initialize flag columns to 0\n",
    "\n",
    "    # Create a directory for knee plots if saving is enabled\n",
    "    if save_knee_plots:\n",
    "        plot_dir = f\"{DIR_FIGURES}/knee_plot/{sample}\"\n",
    "        os.makedirs(plot_dir, exist_ok=True)\n",
    "\n",
    "    for col in columns_flagging:\n",
    "        # Extract values and sort them in descending order to form a curve\n",
    "        col_values = df_sample[col].dropna().values\n",
    "        if len(col_values) == 0:\n",
    "            # If no values, just continue\n",
    "            continue\n",
    "\n",
    "        # Sort values in descending order\n",
    "        col_values_sorted = np.sort(col_values)[::-1]\n",
    "        x = np.arange(len(col_values_sorted))\n",
    "\n",
    "        # Apply KneeLocator\n",
    "        kneedle = KneeLocator(x, col_values_sorted, curve='convex', direction='decreasing', S=S)\n",
    "\n",
    "        if kneedle.knee_y is not None:\n",
    "            knee_y = kneedle.knee_y\n",
    "        else:\n",
    "            # If no knee found, fallback to median\n",
    "            knee_y = np.median(col_values_sorted)\n",
    "\n",
    "        # Assign flags: 1 if value < knee_y, otherwise 0\n",
    "        # We compare the original (unsorted) values from df_sample\n",
    "        df_flags.loc[df_sample[col] >= knee_y, col] = 0\n",
    "        df_flags.loc[df_sample[col] < knee_y, col] = 1\n",
    "\n",
    "        # For CV_related \n",
    "        if 'CV' in col: # Since we want to avoid high CVs, samples with CV > knee will be flagged\n",
    "            df_flags.loc[df_sample[col] >= knee_y, col] = 1\n",
    "            df_flags.loc[df_sample[col] < knee_y, col] = 0\n",
    "\n",
    "        if save_knee_plots:\n",
    "            # Plot the curve and knee point\n",
    "            plt.figure()\n",
    "            plt.plot(x, col_values_sorted, label=col)\n",
    "            if kneedle.knee_y is not None:\n",
    "                plt.scatter(kneedle.knee, kneedle.knee_y, color='red', zorder=3, label='Knee Point')\n",
    "                plt.annotate(\n",
    "                    f\"Knee: ({kneedle.knee}, {kneedle.knee_y:.2f})\", \n",
    "                    xy=(kneedle.knee, kneedle.knee_y),\n",
    "                    xytext=(kneedle.knee + len(col_values_sorted)*0.1, kneedle.knee_y),\n",
    "                    arrowprops=dict(arrowstyle='->', color='black')\n",
    "                )\n",
    "            \n",
    "            plt.title(f'Knee Detection for {col}')\n",
    "            plt.xlabel('Index')\n",
    "            plt.ylabel('Value')\n",
    "            plt.grid(True)\n",
    "            plt.legend()\n",
    "\n",
    "            # Save the plot\n",
    "            plt.savefig(f'{plot_dir}/{sample}_pass{passn}_mode{mode}_tax{taxonomic_level}_S{S}{norm_string}_{col}')\n",
    "            plt.close()\n",
    "\n",
    "        if verbose:\n",
    "            print(f\"Column {col}: Knee = {(kneedle.knee, kneedle.knee_y)}\")\n",
    "\n",
    "    df_flags[df_flags.columns[3:]] = df_flags[df_flags.columns[3:]].astype(bool)\n",
    "\n",
    "    return df_flags\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [],
   "source": [
    "def create_sample_table(sample, profilers, taxonomic_level, mode, passn, dict_FASTQ_len, S, norm_string, verbose=False, remove_human=True, overwrite=False):\n",
    "    if not overwrite:\n",
    "        if os.path.exists(f'{DIR_SUMMARY_OUTPUT}/{sample}_pass{passn}_mode{mode}_tax{taxonomic_level}_S{S}{norm_string}.diversity.tsv'):\n",
    "            print(f'{DIR_SUMMARY_OUTPUT}/{sample}_pass{passn}_mode{mode}_tax{taxonomic_level}_S{S}{norm_string}.diversity.tsv EXISTS')\n",
    "            return None, None\n",
    "            \n",
    "    # Step 1: Load and process tables\n",
    "    if verbose:\n",
    "        print(f\">>> Loading and processing tables for sample: {sample}, taxonomic level: {taxonomic_level}\")\n",
    "    list_df_samples, available_profilers = load_and_process_tables(sample, profilers, taxonomic_level, mode, passn, verbose)\n",
    "\n",
    "    if verbose:\n",
    "        print(f\"the list of available profilers is {available_profilers}\")\n",
    "    # Step 2: Merge tables\n",
    "    if verbose:\n",
    "        print(\">>> Merging tables from all profilers\")\n",
    "    df_sample = merge_tables(list_df_samples, available_profilers, remove_human, verbose)\n",
    "\n",
    "    # Step 3: Normalize counts\n",
    "    if verbose:\n",
    "        print(\">>> Normalizing counts\")\n",
    "    df_sample = normalize_counts(df_sample, available_profilers, sample, dict_FASTQ_len, verbose)\n",
    "    \n",
    "    # Step 4: Calculate statistics\n",
    "    if verbose:\n",
    "        print(\">>> Calculating statistics\")\n",
    "    df_sample = calculate_stats(df_sample, available_profilers, verbose)\n",
    "    \n",
    "    # Step 5: Apply flagging system\n",
    "    if verbose:\n",
    "        print(\">>> Applying flagging system\")\n",
    "    df_flags = apply_flagging_system(df_sample, sample, mode, passn, verbose, norm_string, taxonomic_level, S, save_knee_plots=True)\n",
    "\n",
    "    \n",
    "    # Save results\n",
    "    if verbose:\n",
    "        print(f\">>> Saving results for sample: {sample}\")\n",
    "    df_sample.to_csv(f'{DIR_SUMMARY_OUTPUT}/{sample}_pass{passn}_mode{mode}_tax{taxonomic_level}_S{S}{norm_string}.diversity.tsv', sep='\\t', index=False)\n",
    "    df_flags.to_csv(f'{DIR_SUMMARY_OUTPUT}/{sample}_pass{passn}_mode{mode}_tax{taxonomic_level}_S{S}{norm_string}.flags.tsv', sep='\\t', index=False)\n",
    "\n",
    "    return df_sample, df_flags"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for mode in range(1, 10):\n",
    "    for passn in [0, 2]:\n",
    "        for S in [0, 1, 2, 3, 4, 5, 6, 7, 10, 15]:\n",
    "            df_sample, df_flags = create_sample_table(\n",
    "            sample='ARTIFICIAL',\n",
    "            profilers=LIST_PROFILERS,\n",
    "            taxonomic_level='species',\n",
    "            mode=mode,\n",
    "            passn=passn,\n",
    "            dict_FASTQ_len={'ARTIFICIAL': 50000000},\n",
    "            norm_string='', # type of normalization (0: no normalization, +: normalize using POOLS + CONTROLS, -: normalize using POOLS or CONTROLS)\n",
    "            S=S,\n",
    "            verbose=False,\n",
    "            remove_human=True\n",
    ")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {},
   "outputs": [],
   "source": [
    "dict_pools_read_number = {\n",
    "    'POOL1': 56548742,\n",
    "    'POOL2': 42205676,\n",
    "    'POOL3': 48792251,\n",
    "    'POOL4': 47145951,\n",
    "    'POOL5': 44694021,\n",
    "    'POOL6': 55640398,\n",
    "    'POOL7': 55697936,\n",
    "    'POOL8': 47826994,\n",
    "    'POOL9': 42852541,\n",
    "    'POOL10': 55067711,\n",
    "    'POOL11': 41656703,\n",
    "    'POOL12': 40367876}\n",
    "\n",
    "\n",
    "dict_controls_read_number = {\n",
    "    'ACIDOLA': 2463392, \n",
    "    'BLACTIS': 2109117}\n",
    "\n",
    "dict_all = dict_pools_read_number | dict_controls_read_number"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# We are going to do three types of normalization: No normalization (NORM0), normalization using only controls or pools (NORM|) and using controls and pools (NORM+)\n",
    "\n",
    "# NORM0\n",
    "for sample in CONTROLS + POOLS:\n",
    "    for mode in [3, 5, 7]:\n",
    "        for S in [0, 1, 2, 3, 4, 5, 6, 7, 10, 15]:\n",
    "            df_sample, df_flags = create_sample_table(\n",
    "            sample=sample,\n",
    "            profilers=LIST_PROFILERS,\n",
    "            taxonomic_level='genus',\n",
    "            mode=mode,\n",
    "            passn=2,\n",
    "            dict_FASTQ_len={sample: dict_all[sample]},\n",
    "            norm_string='_NORM0', # type of normalization (0: no normalization, +: normalize using POOLS + CONTROLS, -: normalize using POOLS or CONTROLS)\n",
    "            S=S,\n",
    "            verbose=False,\n",
    "            remove_human=True\n",
    "    )"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [],
   "source": [
    "# NORM |\n",
    "for sample in CONTROLS + POOLS:\n",
    "    for mode in [3, 5, 7]:\n",
    "        for S in [0, 1, 2, 3, 4, 5, 6, 7, 10, 15]:\n",
    "            df_sample, df_flags = create_sample_table(\n",
    "            sample=sample,\n",
    "            profilers=LIST_PROFILERS,\n",
    "            taxonomic_level='genus',\n",
    "            mode=mode,\n",
    "            passn=2,\n",
    "            dict_FASTQ_len=dict_controls_read_number if sample in CONTROLS else dict_pools_read_number,\n",
    "            norm_string='_NORMx', # type of normalization (0: no normalization, +: normalize using POOLS + CONTROLS, -: normalize using POOLS or CONTROLS)\n",
    "            S=S,\n",
    "            verbose=False,\n",
    "            remove_human=True\n",
    "    )"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# NORM +\n",
    "for sample in CONTROLS + POOLS:\n",
    "    for mode in [3, 5, 7]:\n",
    "        for S in [0, 1, 2, 3, 4, 5, 6, 7, 10, 15]:\n",
    "            df_sample, df_flags = create_sample_table(\n",
    "            sample=sample,\n",
    "            profilers=LIST_PROFILERS,\n",
    "            taxonomic_level='genus',\n",
    "            mode=mode,\n",
    "            passn=2,\n",
    "            dict_FASTQ_len=dict_all,\n",
    "            norm_string='_NORM+', # type of normalization (0: no normalization, +: normalize using POOLS + CONTROLS, -: normalize using POOLS or CONTROLS)\n",
    "            S=S,\n",
    "            verbose=False,\n",
    "            remove_human=True\n",
    "    )"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "EVs",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.1"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
