diff --git a/README.md b/README.md new file mode 100644 index 0000000..41bc4b5 --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# ReadMe + +This is the repository for the submitted Remote Sensing in Environment article: *Deep Point Cloud Regression for +Above-Ground Forest Biomass Estimation from Airborne LiDAR*. + +To start unzip `biomasspointclouds.7z` with the password given in the supplementary material under Reproducability. ( +e.g. `7z x biomasspointclouds"`) + +We include **code**, **model weights** (soon), and the **dataset** (soon). + +Regarding the code: +We forked the [torch-points3d](https://github.com/nicolas-chaulet/torch-points3d) framework and added support for +regression tasks including datasets, tracking, and models on our own. In the process, we also simplified the usage of +package. + +In addition, we also included our code to load the trained linear regression and random forest in +the `pointcloud_stats_method` folder. Just run the notebook `learn_with_stats.ipynb`. + +Finally, the results/plots for each method can be seen in the `eval_scripts` folder within +the `eval_deep_learning_v2.ipynb`. The results for the network size experiment are in `eval_deep_learning_v2_size.ipynb`. + +**results on the test set:** + +| target | model | treeadd | $R^2$ | | RMSE | | MAPE | | mean bias | | +|:----------------|:---------|:--------|---------:|------:|---------:|--------:|----------:|----------:|----------:|---------:| +| | | | *median* | *max* | *median* | *min* | *median* | *min* | *median* | *min* | +| **biomass** | KPConv | False | 0.800 | 0.815 | 45.264 | 43.540 | 396.685 | 272.288 | 0.460 | 0.389 | +| | | True | 0.780 | 0.803 | 47.526 | 44.975 | 467.581 | 246.927 | 3.660 | -0.707 | +| | MSENet14 | False | 0.825 | 0.829 | 42.373 | 41.806 | 299.497 | 192.777 | 0.666 | -0.291 | +| | | True | 0.823 | 0.829 | 42.596 | 41.851 | 271.716 | 131.120 | 0.313 | 0.122 | +| | MSENet50 | False | 0.827 | 0.835 | 42.140 | 41.083 | 469.104 | 174.245 | 0.837 | -0.114 | +| | | True | 0.824 | 0.837 | 42.481 | 40.909 | 339.700 | 119.264 | 0.889 | 0.596 | +| | PointNet | False | 0.770 | 0.772 | 48.565 | 48.288 | 889.293 | 625.091 | 0.539 | 0.119 | +| | | True | 0.766 | 0.768 | 48.932 | 48.753 | 896.835 | 622.713 | 2.464 | 1.774 | +| | RF | False | 0.754 | 0.754 | 50.188 | 50.158 | 625.439 | 616.635 | 1.470 | 1.459 | +| | | True | 0.151 | 0.157 | 93.238 | 92.930 | 7644.787 | 7423.094 | 47.625 | -47.521 | +| | power | False | 0.761 | 0.761 | 49.509 | 49.509 | 365.606 | 365.606 | 2.027 | 2.027 | +| | | True | 0.034 | 0.034 | 99.478 | 99.478 | 7604.844 | 7604.844 | 57.525 | -57.525 | +| | linear | False | 0.762 | 0.762 | 49.420 | 49.420 | 425.605 | 425.605 | 1.894 | 1.894 | +| | | True | 0.195 | 0.195 | 90.801 | 90.801 | 11448.501 | 11448.501 | 39.149 | -39.149 | +| **wood volume** | KPConv | False | 0.799 | 0.805 | 85.434 | 84.255 | 103.866 | 85.633 | 0.377 | 0.285 | +| | | True | 0.778 | 0.792 | 89.808 | 87.002 | 126.543 | 85.812 | 7.885 | -1.012 | +| | MSENet14 | False | 0.823 | 0.826 | 80.309 | 79.631 | 99.105 | 72.597 | 0.515 | 0.389 | +| | | True | 0.821 | 0.825 | 80.750 | 79.716 | 84.473 | 70.097 | 2.577 | 1.829 | +| | MSENet50 | False | 0.824 | 0.831 | 79.986 | 78.344 | 131.525 | 72.381 | 0.169 | 0.123 | +| | | True | 0.822 | 0.832 | 80.571 | 78.177 | 115.634 | 78.422 | 3.572 | 2.646 | +| | PointNet | False | 0.777 | 0.781 | 90.183 | 89.198 | 205.366 | 162.049 | 1.991 | 1.369 | +| | | True | 0.773 | 0.776 | 90.844 | 90.220 | 236.383 | 174.903 | 5.708 | 4.578 | +| | RF | False | 0.757 | 0.757 | 94.091 | 94.070 | 223.652 | 222.600 | 3.979 | 3.955 | +| | | True | 0.192 | 0.197 | 171.475 | 170.930 | 1683.778 | 1676.524 | 85.629 | -85.465 | +| | power | False | 0.763 | 0.763 | 92.819 | 92.819 | 223.654 | 223.654 | 4.497 | 4.497 | +| | | True | 0.120 | 0.120 | 178.973 | 178.973 | 1793.822 | 1793.822 | 101.104 | -101.104 | +| | linear | False | 0.766 | 0.766 | 92.292 | 92.292 | 171.483 | 171.483 | 4.602 | 4.602 | +| | | True | 0.243 | 0.243 | 166.034 | 166.034 | 1747.807 | 1747.807 | 72.340 | -72.340 | + +# Install torch-points3d + +We setup our environment in the following way (conda is already installed): + +1. go to `pointcloud-biomass-estimator/torch-points3d` +2. Make sure to install cuda 11.8 (don't forget to deselect the driver install if your drivers are current) + +``` +wget https://developer.download.nvidia.com/compute/cuda/11.8.0/local_installers/cuda_11.8.0_520.61.05_linux.run +sudo sh cuda_11.8.0_520.61.05_linux.run +``` + +3. after installing close and reopen the terminal to check if the PATH is set correctly with `echo $PATH`. It should + **not** have `/usr/local/cuda-10.2` but should have something like `/usr/local/cuda-11.8` in there + +5. install mamba (optional but highly recommended) + +``` +conda install mamba -c conda-forge +``` + +3. create conda environment: + +``` +mamba env create -f env.yml +``` + +or for cpu-version: + +``` +mamba env create -f env_cpu.yml +``` + +4. activate environment: + +``` +mamba activate pts +``` + +5. install missing pip packages for Minkowski networks + +``` +pip install -U git+https://github.com/NVIDIA/MinkowskiEngine -v --no-deps --config-settings blas_include_dirs=${CONDA_PREFIX}/include blas=openblas + +``` + +or for cpu-version: + +``` +pip install -U git+https://github.com/NVIDIA/MinkowskiEngine -v --no-deps --config-settings blas=openblas + +``` + +5. compile KPConv scripts + +``` +sh compile_wrappers.sh +``` + +# Training for Regression + +run from within the torch-points3d folder. + +*MSENet50:* + +``` +python -u train.py task=instance models=instance/minkowski_baseline model_name=SENet50 data=instance/NFI/reg data.transform_type=sparse_xy training=nfi/minkowski lr_scheduler=cosineawr update_lr_scheduler_on=on_num_batch +``` + +*MSENet14:* + +``` +python -u train.py task=instance models=instance/minkowski_baseline model_name=SENet14 data=instance/NFI/reg data.transform_type=sparse_xy training=nfi/minkowski lr_scheduler=cosineawr update_lr_scheduler_on=on_num_batch +``` + +*KPConv:* + +``` +python -u train.py task=instance models=instance/kpconv model_name=KPConv data=instance/NFI/reg training=nfi/kpconv data.transform_type=xy lr_scheduler=cosineawr update_lr_scheduler_on=on_num_batch +``` + +*PointNet:* + +``` +python -u train.py task=instance models=instance/minkowski_baseline model_name=MPointNet data=instance/NFI/reg training=nfi/pointnet data.transform_type=sparse_xy lr_scheduler=cosineawr update_lr_scheduler_on=on_num_batch +``` + +# Calibration batch normalization + +to calibrate the trained models batch norm statistics. Note that the checkpoint directory has to be an absolute path, +e.g.: `checkpoint_dir=/home/user/torch-points3d/weights/SENet50/0` + +for Minkowski or Pointnet (`model_name=SENet50`, `model_name=SENet14`, or `model_name=MPointNet`): + +``` +python calibrate_bn.py model_name=${model_name} checkpoint_dir=${checkpoint_dir} data=instance/NFI/reg num_workers=4 task=instance weight_name="total_BMag_ha_rmse" batch_size=64 num_workers=4 data.transform_type=sparse_xy epochs=20 +``` + +for KPConv: + +``` +python calibrate_bn.py model_name=KPConv checkpoint_dir=${checkpoint_dir} data=instance/NFI/reg num_workers=4 task=instance weight_name="total_BMag_ha_rmse" batch_size=64 num_workers=4 data.transform_type=xy epochs=20 +``` + +# Evaluating our models + +run from within the torch-points3d folder. Note that the checkpoint directory has to be an absolute path, +e.g.: `PATHTOFRAMEWORK=/home/user/torch-points3d` +Also, there are 5 weights for each model (from different trials): `TRIAL=1` + +*MSENet50:* + +``` +python eval.py model_name=SENet50 checkpoint_dir=${PATHTOFRAMEWORK}/weights/SENet50/${TRIAL}/ weight_name="latest" batch_size=32 num_workers=4 eval_stages=["val","test"] data.transform_type=sparse_xy_eval data=instance/NFI/reg task=instance +``` + +the save folder location is `weights/msenet50/eval`. + +*MSENet14:* + +``` +python eval.py model_name=SENet14 checkpoint_dir=${PATHTOFRAMEWORK}/weights/SENet14/${TRIAL}/ weight_name="latest" batch_size=32 num_workers=4 eval_stages=["val","test"] data.transform_type=sparse_xy_eval data=instance/NFI/reg task=instance +``` + +the save folder location is `weights/msenet14/eval`. + +*KPConv:* + +``` +python eval.py model_name=KPConv checkpoint_dir=${PATHTOFRAMEWORK}/weights/KPConv/${TRIAL}/ weight_name="latest" batch_size=32 num_workers=4 eval_stages=["val","test"] data.transform_type=xy_eval data=instance/NFI/reg task=instance +``` + +the save folder location is `weights/kpconv/eval`. + +*PointNet:* + +``` +python eval.py model_name=MPointNet checkpoint_dir=${PATHTOFRAMEWORK}/weights/PointNet/${TRIAL}/ weight_name="latest" batch_size=32 num_workers=4 eval_stages=["val","test"] data.transform_type=sparse_xy_eval data=instance/NFI/reg task=instance +``` + +the save folder location is `weights/pointnet/eval`. + +# Using tree-adding augmentations during test + +same as before, but the transform type changes to use tree augmentations, e.g.: + +``` +python eval.py model_name=MPointNet checkpoint_dir=${PATHTOFRAMEWORK}/weights/pointnet/ weight_name="total_rmse" batch_size=32 num_workers=4 eval_stages=["val","test"] data.transform_type=sparse_xy_eval_treeadd +``` diff --git a/eval_scripts/eval_deep_learning_v2.ipynb b/eval_scripts/eval_deep_learning_v2.ipynb new file mode 100644 index 0000000..67609ff --- /dev/null +++ b/eval_scripts/eval_deep_learning_v2.ipynb @@ -0,0 +1,9112 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 7, + "id": "38cde886", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import geopandas as gpd\n", + "import seaborn as sns\n", + "\n", + "sns.set_context(\"paper\")\n", + "sns.set_style(\"whitegrid\")\n", + "import matplotlib.pyplot as plt\n", + "\n", + "plt.rcParams[\"svg.fonttype\"] = \"none\"\n", + "from sklearn.metrics import mean_absolute_percentage_error, mean_squared_error, r2_score\n", + "\n", + "sns.set_color_codes()\n", + "from glob import glob\n", + "from itertools import product\n", + "import pickle" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "358d4bc2", + "metadata": {}, + "outputs": [], + "source": [ + "target_vars = [\"BMag_ha\", \"V_ha\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "30bad65e", + "metadata": {}, + "outputs": [], + "source": [ + "bias_correct_splits = [\"val\", \"train\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "4d80056e", + "metadata": {}, + "outputs": [], + "source": [ + "# choose one of test, train, val\n", + "splits = [\"train\", \"val\", \"test\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "93cd866d", + "metadata": {}, + "outputs": [], + "source": [ + "models = {\n", + " # other baselines and models\n", + " \"linear\": (\n", + " f\"results_new/linear_?.gpkg\",\n", + " ),\n", + " \"RF\": (\n", + " f\"results_new/rf_?.gpkg\",\n", + " ),\n", + "\n", + " \"KPConv\": (\n", + " f\"results_new/KPConv_xy_??.gpkg\",\n", + " ),\n", + " \"PointNet\": (\n", + " f\"results_new/MPointNet_xy_??.gpkg\",\n", + " ),\n", + "\n", + " # favored basline and model\n", + " \"\\power{}\": (\n", + " f\"results_new/power_?.gpkg\",\n", + " ),\n", + "\n", + " \"MSENet14\": (\n", + " f\"results_new/SENet14_xy_??.gpkg\",\n", + " ),\n", + " \"MSENet50\": (\n", + " f\"results_new/SENet50_xy_??.gpkg\",\n", + " ),\n", + " \n", + "\n", + " # evaluation on augmented test set (treeadding augmentation)\n", + " \"linear_treeval\": (\n", + " f\"results_new/linear_?_treeadd.gpkg\",\n", + " ),\n", + " \"RF_treeval\": (\n", + " f\"results_new/rf_?_treeadd.gpkg\",\n", + " ),\n", + " \"\\power{}_treeval\": (\n", + " f\"results_new/power_?_treeadd.gpkg\",\n", + " ),\n", + "\n", + " \"KPConv_treeval\": (\n", + " f\"results_new/KPConv_xy_??_treeadd.gpkg\",\n", + " ),\n", + " \"PointNet_treeval\": (\n", + " f\"results_new/MPointNet_xy_??_treeadd.gpkg\",\n", + " ),\n", + " \n", + "\n", + " \"MSENet14_treeval\": (\n", + " f\"results_new/SENet14_xy_??_treeadd.gpkg\",\n", + " ),\n", + " \"MSENet50_treeval\": (\n", + " f\"results_new/SENet50_xy_??_treeadd.gpkg\",\n", + " ),\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "id": "49266c45-aa7e-41a0-a704-f6bcc07bff48", + "metadata": {}, + "outputs": [], + "source": [ + "with open('results_new.pickle', 'rb') as handle:\n", + " results = pickle.load(handle)" + ] + }, + { + "cell_type": "markdown", + "id": "6f20b57b-0f00-4b69-8585-71a5ee12e3e0", + "metadata": {}, + "source": [ + "# Bias correction" + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "id": "d4c831b4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "linear 0\n", + "0.9151888974556669\n", + "[0 0]\n", + "RF 0\n", + "0.9151888974556669\n", + "[0 0]\n", + "RF 1\n", + "0.9151888974556669\n", + "[0 0]\n", + "RF 2\n", + "0.9151888974556669\n", + "[0 0]\n", + "RF 3\n", + "0.9151888974556669\n", + "[0 0]\n", + "RF 4\n", + "0.9151888974556669\n", + "[0 0]\n", + "KPConv 0\n", + "0.9151888974556669\n", + "[0 0]\n", + "KPConv 1\n", + "0.9151888974556669\n", + "[0 0]\n", + "KPConv 2\n", + "0.9151888974556669\n", + "[0 0]\n", + "KPConv 3\n", + "0.9151888974556669\n", + "[0 0]\n", + "KPConv 4\n", + "0.9151888974556669\n", + "[0 0]\n", + "PointNet 0\n", + "0.9151888974556669\n", + "[0 0]\n", + "PointNet 1\n", + "0.9151888974556669\n", + "[0 0]\n", + "PointNet 2\n", + "0.9151888974556669\n", + "[0 0]\n", + "PointNet 3\n", + "0.9151888974556669\n", + "[0 0]\n", + "PointNet 4\n", + "0.9151888974556669\n", + "[0 0]\n", + "\\power{} 0\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet14 0\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet14 2\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet14 3\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet14 4\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet14 1\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet50 0\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet50 1\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet50 2\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet50 3\n", + "0.9151888974556669\n", + "[0 0]\n", + "MSENet50 4\n", + "0.9151888974556669\n", + "[0 0]\n" + ] + } + ], + "source": [ + "# get bias correction\n", + "# we do not include the 0 predictions into the adjustment since they come from a different data distribution\n", + "\n", + "deltas = {}\n", + "results_corrected = {}\n", + "use_treeadd = False\n", + "use_treeval = False\n", + "exclude_1y = False\n", + "exclude_pred_0 = False\n", + "clip_0 = True\n", + "for model in models:\n", + " if \"treeval\" in model: # using the original correction\n", + " continue\n", + " corrected = []\n", + " corrected_treeval = []\n", + " for run in pd.unique(results[model][\"run\"]):\n", + " print(model, run)\n", + " pred_vars = [f\"{v}_pred\" for v in target_vars]\n", + " preds_cal = pd.concat(\n", + " [\n", + " results[model].query(f\"(run == {run}) & (split == @split)\")\n", + " for split in bias_correct_splits\n", + " ],\n", + " axis=0,\n", + " )[target_vars + pred_vars + [\"mask\", \"temp_diff_years\"] ].copy(deep=True)\n", + " if use_treeadd and model+\"_treeval\" in results and \"treeadd\" in model:\n", + " preds_cal = pd.concat(\n", + " [ preds_cal ] + \n", + " [\n", + " results[model+\"_treeadd\"].query(f\"(run == {run}) & (split == @split)\")\n", + " for split in bias_correct_splits\n", + " ],\n", + " axis=0,\n", + " )[target_vars + pred_vars + [\"mask\", \"temp_diff_years\"] ].copy(deep=True)\n", + " if use_treeval and model+\"_treeval\" in results:\n", + " preds_cal = pd.concat(\n", + " [ preds_cal ] + \n", + " [\n", + " results[model+\"_treeval\"].query(f\"(run == {run}) & (split == @split)\")\n", + " for split in bias_correct_splits\n", + " ],\n", + " axis=0,\n", + " )[target_vars + pred_vars + [\"mask\", \"temp_diff_years\"] ].copy(deep=True)\n", + " \n", + " \n", + " #reds_cal = preds_cal.sample(len(preds_cal))\n", + " \n", + " mask = np.ones_like(preds_cal[\"mask\"])\n", + " if exclude_1y:\n", + " mask &= (preds_cal[\"temp_diff_years\"] <= 1)\n", + " if exclude_pred_0:\n", + " mask &= ~preds_cal[\"mask\"]\n", + " \n", + " correct_ = ~mask == (preds_cal[target_vars] == 0).any(axis=1)\n", + " print(correct_.sum() / len(correct_))\n", + " #print(f\"num vals != 0: {mask.sum()}\")\n", + " y_cal_ = preds_cal[target_vars][mask].values\n", + " preds_cal_ = preds_cal[pred_vars][mask].values\n", + "\n", + " '''\n", + " ds = []\n", + " num_vals = 100\n", + " for i in range(0, len(y_cal_), num_vals):\n", + " mm = np.ones(len(y_cal_), dtype=bool)\n", + " mm[i:i+num_vals] = False\n", + " ds.append((\n", + " y_cal_[mm].astype(np.float64).sum(0)\n", + " - preds_cal_[mm].astype(np.float64).sum(0)\n", + " ) / (mm.sum()))\n", + " delta = np.median(ds, 0)\n", + " '''\n", + " delta = (y_cal_.astype(np.float64).sum(0)\n", + " - preds_cal_.astype(np.float64).sum(0)) / (len(y_cal_))\n", + " deltas[model, run] = delta\n", + " \n", + " # check if calibration is close to 0 on calibration set\n", + " assert np.isclose(0, y_cal_.sum(0) - ((preds_cal_ + delta).sum(0))).all() \n", + " \n", + " # apply delta to all values\n", + " df = results[model].query(f\"run == {run}\")[target_vars + pred_vars + [\"run\", \"mask\", \"split\", \"C_qfrac\"]]\n", + " dff = df[pred_vars]\n", + " if exclude_pred_0:\n", + " mask = ~df[[\"mask\"]].values\n", + " else:\n", + " mask = np.ones_like(df[[\"mask\"]])\n", + " df[pred_vars] = (dff + delta) * mask + (~mask) * dff\n", + " if clip_0:\n", + " df[pred_vars] = df[pred_vars].mask(dff < 0.00, 0.0)\n", + " corrected.append(df)\n", + " \n", + " # apply delta to all values of treeval\n", + " if model+\"_treeval\" not in results:\n", + " continue\n", + " df = results[model+\"_treeval\"].query(f\"run == {run}\")[target_vars + pred_vars + [\"run\", \"mask\", \"split\", \"C_qfrac\"]]\n", + " dff = df[pred_vars]\n", + " if exclude_pred_0:\n", + " mask = df[[\"mask\"]].values\n", + " else:\n", + " mask = np.ones_like(df[[\"mask\"]])\n", + " df[pred_vars] = (dff + delta) * mask + (~mask) * dff\n", + " if clip_0:\n", + " df[pred_vars] = df[pred_vars].mask(dff < 0.00, 0.0)\n", + " print((dff < 0.00).sum().values)\n", + " corrected_treeval.append(df)\n", + "\n", + " results_corrected[model] = pd.concat(corrected, axis=0)\n", + " if len(corrected_treeval) > 0:\n", + " results_corrected[model+\"_treeval\"] = pd.concat(corrected_treeval, axis=0)" + ] + }, + { + "cell_type": "markdown", + "id": "64349efc-e498-4cd7-9b47-bb677f5a0aab", + "metadata": {}, + "source": [ + "# Evaluation" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "eb2c8782", + "metadata": {}, + "outputs": [], + "source": [ + "def cohen_d(y1_pred, y2_pred):\n", + " mse1 = (y1_pred**2).mean()\n", + " mse2 = (y2_pred**2).mean()\n", + " \n", + " diff = mse1 - mse2\n", + " s_pooled = np.sqrt((mse1 + mse2) / 2)\n", + " cohens_d = diff / s_pooled\n", + " return cohens_d\n", + "\n", + "def evaluate(name, results):\n", + " print(name)\n", + " columns = [\n", + " \"method\",\n", + " \"target\",\n", + " \"R2\",\n", + " \"MSE\",\n", + " \"RMSE\",\n", + " \"nRMSE\",\n", + " \"MAPE\",\n", + " \"mean error\",\n", + " \"mean bias\",\n", + " \"rel. error\",\n", + " \"run\",\n", + " ]\n", + " results_df = []\n", + "\n", + " for target in target_vars:\n", + " pred = target + \"_pred\"\n", + " for run, result in results.groupby(\"run\"):\n", + " mask = mm = result[target] != 0\n", + " #mm = result[pred] != 0\n", + " \n", + " results_df.append(\n", + " pd.DataFrame(\n", + " [\n", + " [\n", + " name,\n", + " target,\n", + " r2_score(result[target], result[pred]),\n", + " mean_squared_error(\n", + " result[target], result[pred]\n", + " ),\n", + " mean_squared_error(\n", + " result[target], result[pred], squared=False\n", + " ),\n", + " mean_squared_error(\n", + " result[target], result[pred], squared=False\n", + " ) / result[target].mean(),\n", + " mean_absolute_percentage_error(\n", + " result[target][mask], result[pred][mask]\n", + " )\n", + " * 100,\n", + " abs(\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / len(result[pred][mm])\n", + " ),\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / len(result[target][mm])\n", + " ,\n", + " abs(\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / (result[target][mm]).sum()\n", + " )\n", + " * 100,\n", + " run,\n", + " ]\n", + " ],\n", + " columns=columns,\n", + " )\n", + " )\n", + " results_df = pd.concat(results_df, axis=0)\n", + " return results, results_df\n", + "\n", + "'''\n", + " abs(\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / len(result[pred][mm])\n", + " ),\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / len(result[pred][mm])\n", + " ,\n", + " abs(\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / (result[pred][mm]).sum()\n", + " )\n", + " * 100,\n", + "''';\n", + "'''\n", + " abs(\n", + " (result[target] - result[pred]).sum()\n", + " / len(result[pred])\n", + " ),\n", + " (result[target] - result[pred]).sum()\n", + " / len(result[pred])\n", + " ,\n", + " abs(\n", + " (result[target] - result[pred]).sum()\n", + " / (result[pred]).sum()\n", + " )\n", + " * 100,\n", + "''';" + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "id": "4635281c", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "linear\n", + "linear\n", + "RF\n", + "RF\n", + "KPConv\n", + "KPConv\n", + "PointNet\n", + "PointNet\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\power{}\n", + "\\power{}\n", + "MSENet14\n", + "MSENet14\n", + "MSENet50\n", + "MSENet50\n", + "linear_treeval\n", + "linear_treeval\n", + "RF_treeval\n", + "RF_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\power{}_treeval\n", + "\\power{}_treeval\n", + "KPConv_treeval\n", + "KPConv_treeval\n", + "PointNet_treeval\n", + "PointNet_treeval\n", + "MSENet14_treeval\n", + "MSENet14_treeval\n", + "MSENet50_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50_treeval\n", + "linear\n", + "linear\n", + "RF\n", + "RF\n", + "KPConv\n", + "KPConv\n", + "PointNet\n", + "PointNet\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\power{}\n", + "\\power{}\n", + "MSENet14\n", + "MSENet14\n", + "MSENet50\n", + "MSENet50\n", + "linear_treeval\n", + "linear_treeval\n", + "RF_treeval\n", + "RF_treeval\n", + "\\power{}_treeval\n", + "\\power{}_treeval\n", + "KPConv_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "KPConv_treeval\n", + "PointNet_treeval\n", + "PointNet_treeval\n", + "MSENet14_treeval\n", + "MSENet14_treeval\n", + "MSENet50_treeval\n", + "MSENet50_treeval\n", + "linear\n", + "linear\n", + "RF\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RF\n", + "KPConv\n", + "KPConv\n", + "PointNet\n", + "PointNet\n", + "\\power{}\n", + "\\power{}\n", + "MSENet14\n", + "MSENet14\n", + "MSENet50\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50\n", + "linear_treeval\n", + "linear_treeval\n", + "RF_treeval\n", + "RF_treeval\n", + "\\power{}_treeval\n", + "\\power{}_treeval\n", + "KPConv_treeval\n", + "KPConv_treeval\n", + "PointNet_treeval\n", + "PointNet_treeval\n", + "MSENet14_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14_treeval\n", + "MSENet50_treeval\n", + "MSENet50_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_42898/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_42898/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n" + ] + } + ], + "source": [ + "result_dict = {}\n", + "result_dict_corrected = {}\n", + "result_scores = {}\n", + "for split in splits:\n", + " result_score = []\n", + " for name in models.keys():\n", + " # use corrected version except for linear regressor (optimal already)\n", + " file, scores = evaluate(name, results[name].query(\"split == @split\"))\n", + " file.loc[:, \"corrected\"] = False\n", + " scores.loc[:, \"corrected\"] = False\n", + "\n", + " result_dict[name] = file\n", + " result_score.append(scores)\n", + "\n", + " file, scores = evaluate(name, results_corrected[name].query(\"split == @split\"))\n", + " file.loc[:, \"corrected\"] = True\n", + " scores.loc[:, \"corrected\"] = True\n", + "\n", + " result_dict_corrected[name] = file\n", + " result_score.append(scores)\n", + "\n", + " result_score = pd.concat(result_score, axis=0)\n", + " result_scores[split] = result_score" + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "id": "85324aa9-c572-42cf-bc46-ee03d286c843", + "metadata": {}, + "outputs": [], + "source": [ + "# resave treeval results via flag\n", + "\n", + "for split in splits:\n", + " if \"treeval\" not in result_scores[split].columns:\n", + " treevals = result_scores[split][\"method\"].str.contains(\"treeval\")\n", + " method = result_scores[split][\"method\"]\n", + " result_scores[split].eval(\"treeval = @treevals\", inplace=True)\n", + " result_scores[split][\"method\"] = result_scores[split][\"method\"].str.replace(\"_treeval\", \"\")" + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "id": "f7494784-6232-490e-9e16-897f139b890b", + "metadata": {}, + "outputs": [], + "source": [ + "def abs_min(x): return x.iloc[np.argmin(abs(x))]\n", + "def abs_max(x): return x.iloc[np.argmax(abs(x))]\n", + "def abs_median(x): return np.median(abs(x))\n", + "def avg_sign(x): return np.mean(np.sign(x))\n", + "def abs_mean(x): return np.mean(abs(x))\n", + "def arg_abs_min(x): return np.argmin(abs(x))\n", + "def arg_abs_max(x): return np.argmax(abs(x))\n", + "def arg_max(x): return np.argmax(abs(x))\n", + "\n", + "agg = {\n", + " \"R2\": [\"median\", \"max\"],\n", + " #'MSE' : ['median', 'min'],\n", + " 'RMSE' : ['median', 'min'],\n", + " 'MAPE' : ['median', 'min'],\n", + " #\"mean error\": [\"median\", \"max\", \"min\"],\n", + " \"mean bias\": [abs_median, abs_min],\n", + " #'rel. error' : ['median', \"min\"],\n", + "}\n", + "\n", + "rr = (\n", + " result_scores[\"test\"]\n", + " #.query(\"target == 'BMag_ha'\")\n", + " .query(\"corrected == True\")\n", + " .groupby([\"target\", \"method\", \"treeval\"])\n", + " .agg(agg)\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "id": "0dd65233-9f1e-4660-a23f-76e8c24acd2e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
R2RMSEMAPEmean bias
medianmaxmedianminmedianminabs_medianabs_min
targetmethodtreeval
BMag_haKPConvFalse0.7999950.81494245.26376143.539632396.684826272.2877350.4603210.388961
True0.7795040.80253747.52595144.975245467.581224246.9273543.659850-0.707264
MSENet14False0.8247250.82938842.37314941.805641299.496832192.7774400.665678-0.290542
True0.8228800.82902242.59557041.850502271.716014131.1203850.3131840.122477
MSENet50False0.8266480.83523342.14004541.083267469.104138174.2454990.837429-0.114375
True0.8238310.83663242.48099040.908553339.700072119.2637520.8894410.596189
PointNetFalse0.7697590.77237448.56482748.288268889.292563625.0914890.5390270.118963
True0.7662660.76797348.93179748.752772896.834833622.7132942.4638041.774162
RFFalse0.7541100.75440150.18810150.158444625.439118616.6353701.4702751.458803
True0.1513640.15695293.23759092.9301267644.7870897423.09393347.625241-47.521057
\\power{}False0.7607200.76072049.50896149.508961365.605998365.6059982.0265792.026579
True0.0339630.03396399.47806399.4780637604.8440367604.84403657.525097-57.525097
linearFalse0.7615800.76158049.41993149.419931425.605420425.6054201.8938161.893816
True0.1951460.19514690.80062890.80062811448.50058711448.50058739.149145-39.149145
V_haKPConvFalse0.7994930.80498985.43377584.254618103.86617885.6334520.3765680.284536
True0.7784360.79206589.80774187.001898126.54252185.8116327.885448-1.011822
MSENet14False0.8228260.82580780.30902679.63057499.10521072.5969960.5149830.388571
True0.8208740.82543180.75021179.71647484.47311470.0974842.5773541.829469
MSENet50False0.8242490.83139179.98593278.343874131.52539372.3812830.1685060.122771
True0.8216660.83210880.57143178.177082115.63450078.4223543.5717482.645925
PointNetFalse0.7765790.78143790.18336589.197636205.366066162.0488161.9906121.368627
True0.7732930.77639990.84408990.219658236.383012174.9030005.7077394.577837
RFFalse0.7567980.75690994.09102194.069611223.651644222.6003793.9789853.955142
True0.1922560.197387171.475273170.9296971683.7780861676.52439385.629363-85.465170
\\power{}False0.7633280.76332892.81918392.819183223.653969223.6539694.4969084.496908
True0.1200750.120075178.972966178.9729661793.8216791793.821679101.104058-101.104058
linearFalse0.7660100.76601092.29174392.291743171.483495171.4834954.6019734.601973
True0.2427040.242704166.034183166.0341831747.8065061747.80650672.339538-72.339538
\n", + "
" + ], + "text/plain": [ + " R2 RMSE \\\n", + " median max median min \n", + "target method treeval \n", + "BMag_ha KPConv False 0.799995 0.814942 45.263761 43.539632 \n", + " True 0.779504 0.802537 47.525951 44.975245 \n", + " MSENet14 False 0.824725 0.829388 42.373149 41.805641 \n", + " True 0.822880 0.829022 42.595570 41.850502 \n", + " MSENet50 False 0.826648 0.835233 42.140045 41.083267 \n", + " True 0.823831 0.836632 42.480990 40.908553 \n", + " PointNet False 0.769759 0.772374 48.564827 48.288268 \n", + " True 0.766266 0.767973 48.931797 48.752772 \n", + " RF False 0.754110 0.754401 50.188101 50.158444 \n", + " True 0.151364 0.156952 93.237590 92.930126 \n", + " \\power{} False 0.760720 0.760720 49.508961 49.508961 \n", + " True 0.033963 0.033963 99.478063 99.478063 \n", + " linear False 0.761580 0.761580 49.419931 49.419931 \n", + " True 0.195146 0.195146 90.800628 90.800628 \n", + "V_ha KPConv False 0.799493 0.804989 85.433775 84.254618 \n", + " True 0.778436 0.792065 89.807741 87.001898 \n", + " MSENet14 False 0.822826 0.825807 80.309026 79.630574 \n", + " True 0.820874 0.825431 80.750211 79.716474 \n", + " MSENet50 False 0.824249 0.831391 79.985932 78.343874 \n", + " True 0.821666 0.832108 80.571431 78.177082 \n", + " PointNet False 0.776579 0.781437 90.183365 89.197636 \n", + " True 0.773293 0.776399 90.844089 90.219658 \n", + " RF False 0.756798 0.756909 94.091021 94.069611 \n", + " True 0.192256 0.197387 171.475273 170.929697 \n", + " \\power{} False 0.763328 0.763328 92.819183 92.819183 \n", + " True 0.120075 0.120075 178.972966 178.972966 \n", + " linear False 0.766010 0.766010 92.291743 92.291743 \n", + " True 0.242704 0.242704 166.034183 166.034183 \n", + "\n", + " MAPE mean bias \n", + " median min abs_median abs_min \n", + "target method treeval \n", + "BMag_ha KPConv False 396.684826 272.287735 0.460321 0.388961 \n", + " True 467.581224 246.927354 3.659850 -0.707264 \n", + " MSENet14 False 299.496832 192.777440 0.665678 -0.290542 \n", + " True 271.716014 131.120385 0.313184 0.122477 \n", + " MSENet50 False 469.104138 174.245499 0.837429 -0.114375 \n", + " True 339.700072 119.263752 0.889441 0.596189 \n", + " PointNet False 889.292563 625.091489 0.539027 0.118963 \n", + " True 896.834833 622.713294 2.463804 1.774162 \n", + " RF False 625.439118 616.635370 1.470275 1.458803 \n", + " True 7644.787089 7423.093933 47.625241 -47.521057 \n", + " \\power{} False 365.605998 365.605998 2.026579 2.026579 \n", + " True 7604.844036 7604.844036 57.525097 -57.525097 \n", + " linear False 425.605420 425.605420 1.893816 1.893816 \n", + " True 11448.500587 11448.500587 39.149145 -39.149145 \n", + "V_ha KPConv False 103.866178 85.633452 0.376568 0.284536 \n", + " True 126.542521 85.811632 7.885448 -1.011822 \n", + " MSENet14 False 99.105210 72.596996 0.514983 0.388571 \n", + " True 84.473114 70.097484 2.577354 1.829469 \n", + " MSENet50 False 131.525393 72.381283 0.168506 0.122771 \n", + " True 115.634500 78.422354 3.571748 2.645925 \n", + " PointNet False 205.366066 162.048816 1.990612 1.368627 \n", + " True 236.383012 174.903000 5.707739 4.577837 \n", + " RF False 223.651644 222.600379 3.978985 3.955142 \n", + " True 1683.778086 1676.524393 85.629363 -85.465170 \n", + " \\power{} False 223.653969 223.653969 4.496908 4.496908 \n", + " True 1793.821679 1793.821679 101.104058 -101.104058 \n", + " linear False 171.483495 171.483495 4.601973 4.601973 \n", + " True 1747.806506 1747.806506 72.339538 -72.339538 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "display(rr)" + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "id": "226a4b8e-6fbd-4cff-8df3-f12d69735685", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "| | ('R2', 'median') | ('R2', 'max') | ('RMSE', 'median') | ('RMSE', 'min') | ('MAPE', 'median') | ('MAPE', 'min') | ('mean bias', 'abs_median') | ('mean bias', 'abs_min') |\n", + "|:--------------------------------|-------------------:|----------------:|---------------------:|------------------:|---------------------:|------------------:|------------------------------:|---------------------------:|\n", + "| ('BMag_ha', 'KPConv', False) | 0.800 | 0.815 | 45.264 | 43.540 | 396.685 | 272.288 | 0.460 | 0.389 |\n", + "| ('BMag_ha', 'KPConv', True) | 0.780 | 0.803 | 47.526 | 44.975 | 467.581 | 246.927 | 3.660 | -0.707 |\n", + "| ('BMag_ha', 'MSENet14', False) | 0.825 | 0.829 | 42.373 | 41.806 | 299.497 | 192.777 | 0.666 | -0.291 |\n", + "| ('BMag_ha', 'MSENet14', True) | 0.823 | 0.829 | 42.596 | 41.851 | 271.716 | 131.120 | 0.313 | 0.122 |\n", + "| ('BMag_ha', 'MSENet50', False) | 0.827 | 0.835 | 42.140 | 41.083 | 469.104 | 174.245 | 0.837 | -0.114 |\n", + "| ('BMag_ha', 'MSENet50', True) | 0.824 | 0.837 | 42.481 | 40.909 | 339.700 | 119.264 | 0.889 | 0.596 |\n", + "| ('BMag_ha', 'PointNet', False) | 0.770 | 0.772 | 48.565 | 48.288 | 889.293 | 625.091 | 0.539 | 0.119 |\n", + "| ('BMag_ha', 'PointNet', True) | 0.766 | 0.768 | 48.932 | 48.753 | 896.835 | 622.713 | 2.464 | 1.774 |\n", + "| ('BMag_ha', 'RF', False) | 0.754 | 0.754 | 50.188 | 50.158 | 625.439 | 616.635 | 1.470 | 1.459 |\n", + "| ('BMag_ha', 'RF', True) | 0.151 | 0.157 | 93.238 | 92.930 | 7644.787 | 7423.094 | 47.625 | -47.521 |\n", + "| ('BMag_ha', '\\\\power{}', False) | 0.761 | 0.761 | 49.509 | 49.509 | 365.606 | 365.606 | 2.027 | 2.027 |\n", + "| ('BMag_ha', '\\\\power{}', True) | 0.034 | 0.034 | 99.478 | 99.478 | 7604.844 | 7604.844 | 57.525 | -57.525 |\n", + "| ('BMag_ha', 'linear', False) | 0.762 | 0.762 | 49.420 | 49.420 | 425.605 | 425.605 | 1.894 | 1.894 |\n", + "| ('BMag_ha', 'linear', True) | 0.195 | 0.195 | 90.801 | 90.801 | 11448.501 | 11448.501 | 39.149 | -39.149 |\n", + "| ('V_ha', 'KPConv', False) | 0.799 | 0.805 | 85.434 | 84.255 | 103.866 | 85.633 | 0.377 | 0.285 |\n", + "| ('V_ha', 'KPConv', True) | 0.778 | 0.792 | 89.808 | 87.002 | 126.543 | 85.812 | 7.885 | -1.012 |\n", + "| ('V_ha', 'MSENet14', False) | 0.823 | 0.826 | 80.309 | 79.631 | 99.105 | 72.597 | 0.515 | 0.389 |\n", + "| ('V_ha', 'MSENet14', True) | 0.821 | 0.825 | 80.750 | 79.716 | 84.473 | 70.097 | 2.577 | 1.829 |\n", + "| ('V_ha', 'MSENet50', False) | 0.824 | 0.831 | 79.986 | 78.344 | 131.525 | 72.381 | 0.169 | 0.123 |\n", + "| ('V_ha', 'MSENet50', True) | 0.822 | 0.832 | 80.571 | 78.177 | 115.634 | 78.422 | 3.572 | 2.646 |\n", + "| ('V_ha', 'PointNet', False) | 0.777 | 0.781 | 90.183 | 89.198 | 205.366 | 162.049 | 1.991 | 1.369 |\n", + "| ('V_ha', 'PointNet', True) | 0.773 | 0.776 | 90.844 | 90.220 | 236.383 | 174.903 | 5.708 | 4.578 |\n", + "| ('V_ha', 'RF', False) | 0.757 | 0.757 | 94.091 | 94.070 | 223.652 | 222.600 | 3.979 | 3.955 |\n", + "| ('V_ha', 'RF', True) | 0.192 | 0.197 | 171.475 | 170.930 | 1683.778 | 1676.524 | 85.629 | -85.465 |\n", + "| ('V_ha', '\\\\power{}', False) | 0.763 | 0.763 | 92.819 | 92.819 | 223.654 | 223.654 | 4.497 | 4.497 |\n", + "| ('V_ha', '\\\\power{}', True) | 0.120 | 0.120 | 178.973 | 178.973 | 1793.822 | 1793.822 | 101.104 | -101.104 |\n", + "| ('V_ha', 'linear', False) | 0.766 | 0.766 | 92.292 | 92.292 | 171.483 | 171.483 | 4.602 | 4.602 |\n", + "| ('V_ha', 'linear', True) | 0.243 | 0.243 | 166.034 | 166.034 | 1747.807 | 1747.807 | 72.340 | -72.340 |\n" + ] + } + ], + "source": [ + "print(rr.to_markdown(floatfmt=\".3f\"))" + ] + }, + { + "cell_type": "code", + "execution_count": 57, + "id": "5b115ec7-d849-4a78-b14f-e0fb64b555c7", + "metadata": {}, + "outputs": [], + "source": [ + "def abs_min(x): return x.iloc[np.argmin(abs(x))]\n", + "def abs_max(x): return x.iloc[np.argmax(abs(x))]\n", + "def abs_median(x): return np.median(abs(x))\n", + "def avg_sign(x): return np.mean(np.sign(x))\n", + "def abs_mean(x): return np.mean(abs(x))\n", + "def arg_abs_min(x): return np.argmin(abs(x))\n", + "def arg_abs_max(x): return np.argmax(abs(x))\n", + "def arg_max(x): return np.argmax(abs(x))\n", + "\n", + "agg = {\n", + " 'nRMSE' : ['median', 'min'],\n", + "}\n", + "\n", + "rr = (\n", + " result_scores[\"test\"]\n", + " #.query(\"target == 'BMag_ha'\")\n", + " .query(\"corrected == True\")\n", + " .groupby([\"target\", \"method\", \"treeval\"])\n", + " .agg(agg)\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "abd3ae88-8c20-473a-b415-4e05c58b643c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nRMSE
medianmin
targetmethodtreeval
BMag_haKPConvFalse0.4211150.405074
True0.4421610.418431
MSENet14False0.3942220.388942
True0.3962910.389359
MSENet50False0.3920530.382221
True0.3952250.380596
PointNetFalse0.4518270.449254
True0.4552410.453575
RFFalse0.4669290.466653
True0.8674430.864583
\\power{}False0.4606100.460610
True0.9255020.925502
linearFalse0.4597820.459782
True0.8447710.844771
V_haKPConvFalse0.4227710.416936
True0.4444160.430531
MSENet14False0.3974110.394054
True0.3995940.394479
MSENet50False0.3958120.387686
True0.3987100.386861
PointNetFalse0.4462740.441397
True0.4495440.446454
RFFalse0.4656120.465506
True0.8485490.845850
\\power{}False0.4593180.459318
True0.8856520.885652
linearFalse0.4567080.456708
True0.8216240.821624
\n", + "
" + ], + "text/plain": [ + " nRMSE \n", + " median min\n", + "target method treeval \n", + "BMag_ha KPConv False 0.421115 0.405074\n", + " True 0.442161 0.418431\n", + " MSENet14 False 0.394222 0.388942\n", + " True 0.396291 0.389359\n", + " MSENet50 False 0.392053 0.382221\n", + " True 0.395225 0.380596\n", + " PointNet False 0.451827 0.449254\n", + " True 0.455241 0.453575\n", + " RF False 0.466929 0.466653\n", + " True 0.867443 0.864583\n", + " \\power{} False 0.460610 0.460610\n", + " True 0.925502 0.925502\n", + " linear False 0.459782 0.459782\n", + " True 0.844771 0.844771\n", + "V_ha KPConv False 0.422771 0.416936\n", + " True 0.444416 0.430531\n", + " MSENet14 False 0.397411 0.394054\n", + " True 0.399594 0.394479\n", + " MSENet50 False 0.395812 0.387686\n", + " True 0.398710 0.386861\n", + " PointNet False 0.446274 0.441397\n", + " True 0.449544 0.446454\n", + " RF False 0.465612 0.465506\n", + " True 0.848549 0.845850\n", + " \\power{} False 0.459318 0.459318\n", + " True 0.885652 0.885652\n", + " linear False 0.456708 0.456708\n", + " True 0.821624 0.821624" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "display(rr)" + ] + }, + { + "cell_type": "code", + "execution_count": 59, + "id": "ee83f2cc-3b27-40d4-8c8f-b0f8fe246aa6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\begin{tabular}{lllrr}\n", + "\\toprule\n", + " & & & \\multicolumn{2}{l}{nRMSE} \\\\\n", + " & & & median & min \\\\\n", + "target & method & treeval & & \\\\\n", + "\\midrule\n", + "BMag\\_ha & KPConv & False & 0.421 & 0.405 \\\\\n", + " & & True & 0.442 & 0.418 \\\\\n", + " & MSENet14 & False & 0.394 & 0.389 \\\\\n", + " & & True & 0.396 & 0.389 \\\\\n", + " & MSENet50 & False & 0.392 & 0.382 \\\\\n", + " & & True & 0.395 & 0.381 \\\\\n", + " & PointNet & False & 0.452 & 0.449 \\\\\n", + " & & True & 0.455 & 0.454 \\\\\n", + " & RF & False & 0.467 & 0.467 \\\\\n", + " & & True & 0.867 & 0.865 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.461 & 0.461 \\\\\n", + " & & True & 0.926 & 0.926 \\\\\n", + " & linear & False & 0.460 & 0.460 \\\\\n", + " & & True & 0.845 & 0.845 \\\\\n", + "V\\_ha & KPConv & False & 0.423 & 0.417 \\\\\n", + " & & True & 0.444 & 0.431 \\\\\n", + " & MSENet14 & False & 0.397 & 0.394 \\\\\n", + " & & True & 0.400 & 0.394 \\\\\n", + " & MSENet50 & False & 0.396 & 0.388 \\\\\n", + " & & True & 0.399 & 0.387 \\\\\n", + " & PointNet & False & 0.446 & 0.441 \\\\\n", + " & & True & 0.450 & 0.446 \\\\\n", + " & RF & False & 0.466 & 0.466 \\\\\n", + " & & True & 0.849 & 0.846 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.459 & 0.459 \\\\\n", + " & & True & 0.886 & 0.886 \\\\\n", + " & linear & False & 0.457 & 0.457 \\\\\n", + " & & True & 0.822 & 0.822 \\\\\n", + "\\bottomrule\n", + "\\end{tabular}\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/2767379605.py:1: FutureWarning: In future versions `DataFrame.to_latex` is expected to utilise the base implementation of `Styler.to_latex` for formatting and rendering. The arguments signature may therefore change. It is recommended instead to use `DataFrame.style.to_latex` which also contains additional functionality.\n", + " print(rr.to_latex(formatters=[lambda x: \"%.3f\" % x] * 2))\n" + ] + } + ], + "source": [ + "print(rr.to_latex(formatters=[lambda x: \"%.3f\" % x] * 2))" + ] + }, + { + "cell_type": "markdown", + "id": "f1d45cb1-b689-451c-8588-e8db6dce2cd8", + "metadata": {}, + "source": [ + "### Border artifact augmentation effect" + ] + }, + { + "cell_type": "code", + "execution_count": 60, + "id": "fdd3dc7a-c7b1-46f1-a739-0712b7bbbdbd", + "metadata": {}, + "outputs": [], + "source": [ + "pd.set_option(\"display.precision\", 2)\n", + "pd.set_option(\"display.float_format\", lambda x: \"%.2f\" % x)" + ] + }, + { + "cell_type": "code", + "execution_count": 61, + "id": "082dffcd-84b2-4f1a-be30-678df2ef0bdd", + "metadata": {}, + "outputs": [], + "source": [ + "agg = {\n", + " \"R2\": \"median\",\n", + " #'MSE' : ['median', 'min'],\n", + " 'RMSE' : 'median',\n", + " #\"MAPE\": \"median\",\n", + " \"mean bias\": abs_median,\n", + " #'rel. error' : ['median', \"min\"],\n", + "}\n", + "\n", + "\n", + "rr = (\n", + " result_scores[\"test\"]\n", + " .query(\"corrected == True\")\n", + " .groupby([\"target\", \"method\", \"treeval\"])\n", + " .agg(agg)\n", + ")\n", + "\n", + "agg_ = {\n", + " \"R2\": [\"median\", \"max\"],\n", + " #'MSE' : ['median', 'min'],\n", + " 'RMSE' : ['median', 'min'],\n", + " #\"mean error\": [\"median\", \"max\", \"min\"],\n", + " \"mean bias\": [abs_median, abs_min],\n", + " #'rel. error' : ['median', \"min\"],\n", + "}\n", + "\n", + "\n", + "rr_ = (\n", + " result_scores[\"test\"]\n", + " .query(\"corrected == True\").query(\"treeval\")\n", + " .groupby([\"target\", \"method\", \"treeval\"])\n", + " .agg(agg_)\n", + ")\n", + "rr_.columns = [' '.join(col).strip() for col in rr_.columns.values]" + ] + }, + { + "cell_type": "code", + "execution_count": 62, + "id": "a01acc60-5f7b-4f0f-b29c-c77911c6a9d7", + "metadata": {}, + "outputs": [], + "source": [ + "rr_treeval = rr.query(\"treeval\").abs().reset_index().drop(columns=[\"treeval\"]).set_index([\"target\", \"method\"])\n", + "rr_notreeval = rr.query(\"not treeval\").abs().reset_index().drop(columns=[\"treeval\"]).set_index([\"target\", \"method\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 63, + "id": "bbc17b4c-dc7d-47a6-8605-d04273157f57", + "metadata": {}, + "outputs": [], + "source": [ + "rr_diff = rr_treeval - rr_notreeval\n", + "rr_full = rr_.join(rr_diff, rsuffix=\"_diff\").reset_index().drop(columns=[\"treeval\"]).set_index([\"target\", \"method\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 64, + "id": "1dbb9a74-9592-46d9-a084-34f0fc50fa62", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Index(['R2 median', 'R2 max', 'RMSE median', 'RMSE min',\n", + " 'mean bias abs_median', 'mean bias abs_min', 'R2', 'RMSE', 'mean bias'],\n", + " dtype='object')" + ] + }, + "execution_count": 64, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rr_full.columns" + ] + }, + { + "cell_type": "code", + "execution_count": 65, + "id": "5d07fcf5-b96a-4226-8274-110608d5ab8e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
R2 medianR2R2 maxRMSE medianRMSERMSE minmean bias abs_medianmean biasmean bias abs_min
targetmethod
BMag_haKPConv0.78-0.020.8047.532.2644.983.663.20-0.71
MSENet140.82-0.000.8342.600.2241.850.31-0.350.12
MSENet500.82-0.000.8442.480.3440.910.890.050.60
PointNet0.77-0.000.7748.930.3748.752.461.921.77
RF0.15-0.600.1693.2443.0592.9347.6346.15-47.52
\\power{}0.03-0.730.0399.4849.9799.4857.5355.50-57.53
linear0.20-0.570.2090.8041.3890.8039.1537.26-39.15
V_haKPConv0.78-0.020.7989.814.3787.007.897.51-1.01
MSENet140.82-0.000.8380.750.4479.722.582.061.83
MSENet500.82-0.000.8380.570.5978.183.573.402.65
PointNet0.77-0.000.7890.840.6690.225.713.724.58
RF0.19-0.560.20171.4877.38170.9385.6381.65-85.47
\\power{}0.12-0.640.12178.9786.15178.97101.1096.61-101.10
linear0.24-0.520.24166.0373.74166.0372.3467.74-72.34
\n", + "
" + ], + "text/plain": [ + " R2 median R2 R2 max RMSE median RMSE RMSE min \\\n", + "target method \n", + "BMag_ha KPConv 0.78 -0.02 0.80 47.53 2.26 44.98 \n", + " MSENet14 0.82 -0.00 0.83 42.60 0.22 41.85 \n", + " MSENet50 0.82 -0.00 0.84 42.48 0.34 40.91 \n", + " PointNet 0.77 -0.00 0.77 48.93 0.37 48.75 \n", + " RF 0.15 -0.60 0.16 93.24 43.05 92.93 \n", + " \\power{} 0.03 -0.73 0.03 99.48 49.97 99.48 \n", + " linear 0.20 -0.57 0.20 90.80 41.38 90.80 \n", + "V_ha KPConv 0.78 -0.02 0.79 89.81 4.37 87.00 \n", + " MSENet14 0.82 -0.00 0.83 80.75 0.44 79.72 \n", + " MSENet50 0.82 -0.00 0.83 80.57 0.59 78.18 \n", + " PointNet 0.77 -0.00 0.78 90.84 0.66 90.22 \n", + " RF 0.19 -0.56 0.20 171.48 77.38 170.93 \n", + " \\power{} 0.12 -0.64 0.12 178.97 86.15 178.97 \n", + " linear 0.24 -0.52 0.24 166.03 73.74 166.03 \n", + "\n", + " mean bias abs_median mean bias mean bias abs_min \n", + "target method \n", + "BMag_ha KPConv 3.66 3.20 -0.71 \n", + " MSENet14 0.31 -0.35 0.12 \n", + " MSENet50 0.89 0.05 0.60 \n", + " PointNet 2.46 1.92 1.77 \n", + " RF 47.63 46.15 -47.52 \n", + " \\power{} 57.53 55.50 -57.53 \n", + " linear 39.15 37.26 -39.15 \n", + "V_ha KPConv 7.89 7.51 -1.01 \n", + " MSENet14 2.58 2.06 1.83 \n", + " MSENet50 3.57 3.40 2.65 \n", + " PointNet 5.71 3.72 4.58 \n", + " RF 85.63 81.65 -85.47 \n", + " \\power{} 101.10 96.61 -101.10 \n", + " linear 72.34 67.74 -72.34 " + ] + }, + "execution_count": 65, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rr_full[\n", + "[\"R2 median\", \"R2\", \"R2 max\", \"RMSE median\", \"RMSE\", \"RMSE min\", \"mean bias abs_median\", \"mean bias\", \"mean bias abs_min\"]\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 66, + "id": "289db645-f91c-404c-bb9f-99e2099df080", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\begin{tabular}{llrrrrrrrrr}\n", + "\\toprule\n", + " & & R2 median & R2 & R2 max & RMSE median & RMSE & RMSE min & mean bias abs\\_median & mean bias & mean bias abs\\_min \\\\\n", + "target & method & & & & & & & & & \\\\\n", + "\\midrule\n", + "BMag\\_ha & KPConv & 0.780 & -0.020 & 0.803 & 47.53 & 2.26 & 44.98 & 3.66 & 3.20 & -0.71 \\\\\n", + " & MSENet14 & 0.823 & -0.002 & 0.829 & 42.60 & 0.22 & 41.85 & 0.31 & -0.35 & 0.12 \\\\\n", + " & MSENet50 & 0.824 & -0.003 & 0.837 & 42.48 & 0.34 & 40.91 & 0.89 & 0.05 & 0.60 \\\\\n", + " & PointNet & 0.766 & -0.003 & 0.768 & 48.93 & 0.37 & 48.75 & 2.46 & 1.92 & 1.77 \\\\\n", + " & RF & 0.151 & -0.603 & 0.157 & 93.24 & 43.05 & 92.93 & 47.63 & 46.15 & -47.52 \\\\\n", + " & \\textbackslash power\\{\\} & 0.034 & -0.727 & 0.034 & 99.48 & 49.97 & 99.48 & 57.53 & 55.50 & -57.53 \\\\\n", + " & linear & 0.195 & -0.566 & 0.195 & 90.80 & 41.38 & 90.80 & 39.15 & 37.26 & -39.15 \\\\\n", + "V\\_ha & KPConv & 0.778 & -0.021 & 0.792 & 89.81 & 4.37 & 87.00 & 7.89 & 7.51 & -1.01 \\\\\n", + " & MSENet14 & 0.821 & -0.002 & 0.825 & 80.75 & 0.44 & 79.72 & 2.58 & 2.06 & 1.83 \\\\\n", + " & MSENet50 & 0.822 & -0.003 & 0.832 & 80.57 & 0.59 & 78.18 & 3.57 & 3.40 & 2.65 \\\\\n", + " & PointNet & 0.773 & -0.003 & 0.776 & 90.84 & 0.66 & 90.22 & 5.71 & 3.72 & 4.58 \\\\\n", + " & RF & 0.192 & -0.565 & 0.197 & 171.48 & 77.38 & 170.93 & 85.63 & 81.65 & -85.47 \\\\\n", + " & \\textbackslash power\\{\\} & 0.120 & -0.643 & 0.120 & 178.97 & 86.15 & 178.97 & 101.10 & 96.61 & -101.10 \\\\\n", + " & linear & 0.243 & -0.523 & 0.243 & 166.03 & 73.74 & 166.03 & 72.34 & 67.74 & -72.34 \\\\\n", + "\\bottomrule\n", + "\\end{tabular}\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/559235959.py:1: FutureWarning: In future versions `DataFrame.to_latex` is expected to utilise the base implementation of `Styler.to_latex` for formatting and rendering. The arguments signature may therefore change. It is recommended instead to use `DataFrame.style.to_latex` which also contains additional functionality.\n", + " print(rr_full[\n" + ] + } + ], + "source": [ + "print(rr_full[\n", + "[\"R2 median\", \"R2\", \"R2 max\", \"RMSE median\", \"RMSE\", \"RMSE min\", \"mean bias abs_median\", \"mean bias\", \"mean bias abs_min\"]\n", + "].to_latex(formatters=[lambda x: \"%.3f\" % x] * 3 + [lambda x: \"%.2f\" % x] * 6))" + ] + }, + { + "cell_type": "code", + "execution_count": 67, + "id": "2b115509-d3ab-4fe8-8147-7c60d2311cda", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
R2mean bias
medianabs_median
targetmethodruntreeval
BMag_haMSENet500False0.820.84
True0.821.17
1False0.830.66
True0.820.85
2False0.840.96
True0.840.60
3False0.820.11
True0.821.56
4False0.830.96
True0.830.89
\n", + "
" + ], + "text/plain": [ + " R2 mean bias\n", + " median abs_median\n", + "target method run treeval \n", + "BMag_ha MSENet50 0 False 0.82 0.84\n", + " True 0.82 1.17\n", + " 1 False 0.83 0.66\n", + " True 0.82 0.85\n", + " 2 False 0.84 0.96\n", + " True 0.84 0.60\n", + " 3 False 0.82 0.11\n", + " True 0.82 1.56\n", + " 4 False 0.83 0.96\n", + " True 0.83 0.89" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def abs_min(x): return x.iloc[np.argmin(abs(x))]\n", + "def abs_max(x): return x.iloc[np.argmax(abs(x))]\n", + "def abs_median(x): return np.median(abs(x))\n", + "def abs_mean(x): return np.mean(abs(x))\n", + "def arg_abs_min(x): return np.argmin(abs(x))\n", + "def arg_abs_max(x): return np.argmax(abs(x))\n", + "def arg_max(x): return np.argmax(abs(x))\n", + "\n", + "agg = {\n", + " \"R2\": [\"median\"\n", + " ],\n", + " #'MSE' : ['median', 'min'],\n", + " #'RMSE' : ['median', 'min'],\n", + " #'MAPE' : ['median', 'min'],\n", + " #\"mean error\": [\"median\", \"max\", \"min\"],\n", + " \"mean bias\": [abs_median],\n", + " #'rel. error' : ['median', \"min\"],\n", + "}\n", + "\n", + "\n", + "display(\n", + " result_scores[\"test\"]\n", + " .query(\"target == 'BMag_ha'\")\n", + " #.query(\"method in ['PointNet', 'PointNet_treeval']\")\n", + " #.query(\"method in ['MSENet50_treeadd', 'MSENet50_treeadd_treeval']\")\n", + " .query(\"method in ['MSENet50', 'MSENet50_treeval']\")\n", + " #.query(\"method in ['MSENet14_treeadd', 'MSENet14_treeadd_treeval']\")\n", + " #.query(\"method in ['MSENet14', 'MSENet14_treeval']\")\n", + " .query(\"corrected == True\")\n", + " .groupby([\"target\", \"method\", \"run\", \"treeval\"])\n", + " .agg(agg)\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "436f2d31-547d-4d77-aa4a-87aaa03a521e", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 68, + "id": "962ef2d9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "train\n", + "\\begin{tabular}{lllrrrrrrrr}\n", + "\\toprule\n", + " & & & \\multicolumn{2}{l}{R2} & \\multicolumn{2}{l}{RMSE} & \\multicolumn{2}{l}{MAPE} & \\multicolumn{2}{l}{mean bias} \\\\\n", + " & & & median & max & median & min & median & min & abs\\_median & abs\\_min \\\\\n", + "target & method & treeval & & & & & & & & \\\\\n", + "\\midrule\n", + "BMag\\_ha & KPConv & False & 0.799 & 0.828 & 46.43 & 42.93 & 287.18 & 183.09 & 1.17 & 0.78 \\\\\n", + " & MSENet14 & False & 0.804 & 0.841 & 45.85 & 41.26 & 329.84 & 161.76 & 1.60 & 1.18 \\\\\n", + " & MSENet50 & False & 0.796 & 0.804 & 46.68 & 45.80 & 453.20 & 181.45 & 1.67 & 1.39 \\\\\n", + " & PointNet & False & 0.714 & 0.726 & 55.33 & 54.16 & 887.95 & 643.06 & 2.47 & 2.30 \\\\\n", + " & RF & False & 0.723 & 0.723 & 54.45 & 54.42 & 797.90 & 779.69 & 2.90 & 2.89 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.700 & 0.700 & 56.64 & 56.64 & 690.99 & 690.99 & 2.50 & 2.50 \\\\\n", + " & linear & False & 0.706 & 0.706 & 56.09 & 56.09 & 802.65 & 802.65 & 2.79 & 2.79 \\\\\n", + "V\\_ha & KPConv & False & 0.793 & 0.824 & 88.27 & 81.56 & 97.46 & 81.15 & 2.20 & 1.42 \\\\\n", + " & MSENet14 & False & 0.800 & 0.838 & 86.84 & 78.16 & 111.12 & 71.91 & 3.02 & 2.21 \\\\\n", + " & MSENet50 & False & 0.795 & 0.803 & 87.95 & 86.18 & 130.12 & 72.89 & 3.29 & 2.42 \\\\\n", + " & PointNet & False & 0.721 & 0.732 & 102.51 & 100.61 & 211.84 & 172.15 & 4.52 & 4.29 \\\\\n", + " & RF & False & 0.728 & 0.728 & 101.31 & 101.26 & 204.57 & 202.69 & 5.41 & 5.38 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.703 & 0.703 & 105.84 & 105.84 & 197.07 & 197.07 & 4.56 & 4.56 \\\\\n", + " & linear & False & 0.708 & 0.708 & 104.92 & 104.92 & 185.27 & 185.27 & 5.27 & 5.27 \\\\\n", + "\\bottomrule\n", + "\\end{tabular}\n", + "\n", + "val\n", + "\\begin{tabular}{lllrrrrrrrr}\n", + "\\toprule\n", + " & & & \\multicolumn{2}{l}{R2} & \\multicolumn{2}{l}{RMSE} & \\multicolumn{2}{l}{MAPE} & \\multicolumn{2}{l}{mean bias} \\\\\n", + " & & & median & max & median & min & median & min & abs\\_median & abs\\_min \\\\\n", + "target & method & treeval & & & & & & & & \\\\\n", + "\\midrule\n", + "BMag\\_ha & KPConv & False & 0.790 & 0.802 & 48.46 & 47.11 & 362.01 & 239.81 & 0.46 & -0.01 \\\\\n", + " & MSENet14 & False & 0.808 & 0.811 & 46.36 & 45.99 & 188.78 & 151.14 & 0.25 & -0.04 \\\\\n", + " & MSENet50 & False & 0.805 & 0.810 & 46.75 & 46.16 & 330.53 & 192.81 & 0.25 & -0.06 \\\\\n", + " & PointNet & False & 0.710 & 0.713 & 56.94 & 56.69 & 814.77 & 592.68 & 0.15 & -0.03 \\\\\n", + " & RF & False & 0.758 & 0.758 & 52.08 & 52.01 & 445.04 & 429.35 & 2.03 & 1.98 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.739 & 0.739 & 54.02 & 54.02 & 385.32 & 385.32 & 1.96 & 1.96 \\\\\n", + " & linear & False & 0.739 & 0.739 & 54.02 & 54.02 & 283.79 & 283.79 & 2.35 & 2.35 \\\\\n", + "V\\_ha & KPConv & False & 0.808 & 0.817 & 85.77 & 83.78 & 90.12 & 73.12 & 1.34 & -0.49 \\\\\n", + " & MSENet14 & False & 0.821 & 0.826 & 82.75 & 81.69 & 80.74 & 69.17 & 0.46 & 0.08 \\\\\n", + " & MSENet50 & False & 0.824 & 0.826 & 82.11 & 81.65 & 94.88 & 62.36 & 0.20 & 0.04 \\\\\n", + " & PointNet & False & 0.745 & 0.750 & 98.82 & 97.83 & 221.24 & 194.86 & 0.64 & -0.00 \\\\\n", + " & RF & False & 0.784 & 0.785 & 90.93 & 90.81 & 110.56 & 109.67 & 3.62 & 3.52 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.766 & 0.766 & 94.66 & 94.66 & 118.16 & 118.16 & 3.30 & 3.30 \\\\\n", + " & linear & False & 0.766 & 0.766 & 94.65 & 94.65 & 98.58 & 98.58 & 4.29 & 4.29 \\\\\n", + "\\bottomrule\n", + "\\end{tabular}\n", + "\n", + "test\n", + "\\begin{tabular}{lllrrrrrrrr}\n", + "\\toprule\n", + " & & & \\multicolumn{2}{l}{R2} & \\multicolumn{2}{l}{RMSE} & \\multicolumn{2}{l}{MAPE} & \\multicolumn{2}{l}{mean bias} \\\\\n", + " & & & median & max & median & min & median & min & abs\\_median & abs\\_min \\\\\n", + "target & method & treeval & & & & & & & & \\\\\n", + "\\midrule\n", + "BMag\\_ha & KPConv & False & 0.800 & 0.815 & 45.26 & 43.54 & 396.68 & 272.29 & 0.46 & 0.39 \\\\\n", + " & MSENet14 & False & 0.825 & 0.829 & 42.37 & 41.81 & 299.50 & 192.78 & 0.67 & -0.29 \\\\\n", + " & MSENet50 & False & 0.827 & 0.835 & 42.14 & 41.08 & 469.10 & 174.25 & 0.84 & -0.11 \\\\\n", + " & PointNet & False & 0.770 & 0.772 & 48.56 & 48.29 & 889.29 & 625.09 & 0.54 & 0.12 \\\\\n", + " & RF & False & 0.754 & 0.754 & 50.19 & 50.16 & 625.44 & 616.64 & 1.47 & 1.46 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.761 & 0.761 & 49.51 & 49.51 & 365.61 & 365.61 & 2.03 & 2.03 \\\\\n", + " & linear & False & 0.762 & 0.762 & 49.42 & 49.42 & 425.61 & 425.61 & 1.89 & 1.89 \\\\\n", + "V\\_ha & KPConv & False & 0.799 & 0.805 & 85.43 & 84.25 & 103.87 & 85.63 & 0.38 & 0.28 \\\\\n", + " & MSENet14 & False & 0.823 & 0.826 & 80.31 & 79.63 & 99.11 & 72.60 & 0.51 & 0.39 \\\\\n", + " & MSENet50 & False & 0.824 & 0.831 & 79.99 & 78.34 & 131.53 & 72.38 & 0.17 & 0.12 \\\\\n", + " & PointNet & False & 0.777 & 0.781 & 90.18 & 89.20 & 205.37 & 162.05 & 1.99 & 1.37 \\\\\n", + " & RF & False & 0.757 & 0.757 & 94.09 & 94.07 & 223.65 & 222.60 & 3.98 & 3.96 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.763 & 0.763 & 92.82 & 92.82 & 223.65 & 223.65 & 4.50 & 4.50 \\\\\n", + " & linear & False & 0.766 & 0.766 & 92.29 & 92.29 & 171.48 & 171.48 & 4.60 & 4.60 \\\\\n", + "\\bottomrule\n", + "\\end{tabular}\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/4029165409.py:15: FutureWarning: In future versions `DataFrame.to_latex` is expected to utilise the base implementation of `Styler.to_latex` for formatting and rendering. The arguments signature may therefore change. It is recommended instead to use `DataFrame.style.to_latex` which also contains additional functionality.\n", + " result_scores[split]\n", + "/tmp/ipykernel_42898/4029165409.py:15: FutureWarning: In future versions `DataFrame.to_latex` is expected to utilise the base implementation of `Styler.to_latex` for formatting and rendering. The arguments signature may therefore change. It is recommended instead to use `DataFrame.style.to_latex` which also contains additional functionality.\n", + " result_scores[split]\n", + "/tmp/ipykernel_42898/4029165409.py:15: FutureWarning: In future versions `DataFrame.to_latex` is expected to utilise the base implementation of `Styler.to_latex` for formatting and rendering. The arguments signature may therefore change. It is recommended instead to use `DataFrame.style.to_latex` which also contains additional functionality.\n", + " result_scores[split]\n" + ] + } + ], + "source": [ + "pd.set_option(\"display.precision\", 3)\n", + "pd.set_option(\"display.float_format\", lambda x: \"%.3f\" % x)\n", + "\n", + "agg = {\n", + " \"R2\": [\"median\", \"max\"],\n", + " #'MSE' : ['median', 'min'],\n", + " 'RMSE' : ['median', 'min'],\n", + " 'MAPE' : ['median', 'min'],\n", + " \"mean bias\": [abs_median, abs_min],\n", + "}\n", + "\n", + "for split in splits:\n", + " print(split)\n", + " print(\n", + " result_scores[split]\n", + " .query(\"corrected == True\")\n", + " .query(\"treeval == False\")\n", + " .groupby([\"target\", \"method\", \"treeval\"])[[\"R2\", \"RMSE\", \"MAPE\", \"mean bias\"]]\n", + " .agg(agg)\n", + " .to_latex(formatters=[lambda x: \"%.3f\" % x] * 2 + [lambda x: \"%.2f\" % x] * 6)\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 69, + "id": "2b07de00-3b1d-4b70-9578-5eb6a8c31b33", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "train\n", + "\\begin{tabular}{lllrr}\n", + "\\toprule\n", + " & & & \\multicolumn{2}{l}{nRMSE} \\\\\n", + " & & & median & min \\\\\n", + "target & method & treeval & & \\\\\n", + "\\midrule\n", + "BMag\\_ha & KPConv & False & 0.434 & 0.401 \\\\\n", + " & & True & 0.473 & 0.401 \\\\\n", + " & MSENet14 & False & 0.428 & 0.386 \\\\\n", + " & & True & 0.429 & 0.388 \\\\\n", + " & MSENet50 & False & 0.436 & 0.428 \\\\\n", + " & & True & 0.437 & 0.430 \\\\\n", + " & PointNet & False & 0.517 & 0.506 \\\\\n", + " & & True & 0.525 & 0.512 \\\\\n", + " & RF & False & 0.509 & 0.508 \\\\\n", + " & & True & 0.877 & 0.875 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.529 & 0.529 \\\\\n", + " & & True & 0.937 & 0.937 \\\\\n", + " & linear & False & 0.524 & 0.524 \\\\\n", + " & & True & 0.863 & 0.863 \\\\\n", + "V\\_ha & KPConv & False & 0.441 & 0.407 \\\\\n", + " & & True & 0.482 & 0.409 \\\\\n", + " & MSENet14 & False & 0.434 & 0.390 \\\\\n", + " & & True & 0.434 & 0.392 \\\\\n", + " & MSENet50 & False & 0.439 & 0.430 \\\\\n", + " & & True & 0.440 & 0.431 \\\\\n", + " & PointNet & False & 0.512 & 0.502 \\\\\n", + " & & True & 0.521 & 0.508 \\\\\n", + " & RF & False & 0.506 & 0.506 \\\\\n", + " & & True & 0.861 & 0.859 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.529 & 0.529 \\\\\n", + " & & True & 0.909 & 0.909 \\\\\n", + " & linear & False & 0.524 & 0.524 \\\\\n", + " & & True & 0.851 & 0.851 \\\\\n", + "\\bottomrule\n", + "\\end{tabular}\n", + "\n", + "val\n", + "\\begin{tabular}{lllrr}\n", + "\\toprule\n", + " & & & \\multicolumn{2}{l}{nRMSE} \\\\\n", + " & & & median & min \\\\\n", + "target & method & treeval & & \\\\\n", + "\\midrule\n", + "BMag\\_ha & KPConv & False & 0.431 & 0.419 \\\\\n", + " & & True & 0.444 & 0.428 \\\\\n", + " & MSENet14 & False & 0.412 & 0.409 \\\\\n", + " & & True & 0.415 & 0.409 \\\\\n", + " & MSENet50 & False & 0.416 & 0.411 \\\\\n", + " & & True & 0.417 & 0.413 \\\\\n", + " & PointNet & False & 0.506 & 0.504 \\\\\n", + " & & True & 0.514 & 0.510 \\\\\n", + " & RF & False & 0.463 & 0.463 \\\\\n", + " & & True & 0.805 & 0.803 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.481 & 0.481 \\\\\n", + " & & True & 0.857 & 0.857 \\\\\n", + " & linear & False & 0.481 & 0.481 \\\\\n", + " & & True & 0.798 & 0.798 \\\\\n", + "V\\_ha & KPConv & False & 0.408 & 0.398 \\\\\n", + " & & True & 0.422 & 0.407 \\\\\n", + " & MSENet14 & False & 0.394 & 0.389 \\\\\n", + " & & True & 0.396 & 0.389 \\\\\n", + " & MSENet50 & False & 0.391 & 0.388 \\\\\n", + " & & True & 0.392 & 0.391 \\\\\n", + " & PointNet & False & 0.470 & 0.465 \\\\\n", + " & & True & 0.480 & 0.472 \\\\\n", + " & RF & False & 0.432 & 0.432 \\\\\n", + " & & True & 0.776 & 0.774 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.450 & 0.450 \\\\\n", + " & & True & 0.813 & 0.813 \\\\\n", + " & linear & False & 0.450 & 0.450 \\\\\n", + " & & True & 0.767 & 0.767 \\\\\n", + "\\bottomrule\n", + "\\end{tabular}\n", + "\n", + "test\n", + "\\begin{tabular}{lllrr}\n", + "\\toprule\n", + " & & & \\multicolumn{2}{l}{nRMSE} \\\\\n", + " & & & median & min \\\\\n", + "target & method & treeval & & \\\\\n", + "\\midrule\n", + "BMag\\_ha & KPConv & False & 0.421 & 0.405 \\\\\n", + " & & True & 0.442 & 0.418 \\\\\n", + " & MSENet14 & False & 0.394 & 0.389 \\\\\n", + " & & True & 0.396 & 0.389 \\\\\n", + " & MSENet50 & False & 0.392 & 0.382 \\\\\n", + " & & True & 0.395 & 0.381 \\\\\n", + " & PointNet & False & 0.452 & 0.449 \\\\\n", + " & & True & 0.455 & 0.454 \\\\\n", + " & RF & False & 0.467 & 0.467 \\\\\n", + " & & True & 0.867 & 0.865 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.461 & 0.461 \\\\\n", + " & & True & 0.926 & 0.926 \\\\\n", + " & linear & False & 0.460 & 0.460 \\\\\n", + " & & True & 0.845 & 0.845 \\\\\n", + "V\\_ha & KPConv & False & 0.423 & 0.417 \\\\\n", + " & & True & 0.444 & 0.431 \\\\\n", + " & MSENet14 & False & 0.397 & 0.394 \\\\\n", + " & & True & 0.400 & 0.394 \\\\\n", + " & MSENet50 & False & 0.396 & 0.388 \\\\\n", + " & & True & 0.399 & 0.387 \\\\\n", + " & PointNet & False & 0.446 & 0.441 \\\\\n", + " & & True & 0.450 & 0.446 \\\\\n", + " & RF & False & 0.466 & 0.466 \\\\\n", + " & & True & 0.849 & 0.846 \\\\\n", + " & \\textbackslash power\\{\\} & False & 0.459 & 0.459 \\\\\n", + " & & True & 0.886 & 0.886 \\\\\n", + " & linear & False & 0.457 & 0.457 \\\\\n", + " & & True & 0.822 & 0.822 \\\\\n", + "\\bottomrule\n", + "\\end{tabular}\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/876785269.py:11: FutureWarning: In future versions `DataFrame.to_latex` is expected to utilise the base implementation of `Styler.to_latex` for formatting and rendering. The arguments signature may therefore change. It is recommended instead to use `DataFrame.style.to_latex` which also contains additional functionality.\n", + " result_scores[split]\n", + "/tmp/ipykernel_42898/876785269.py:11: FutureWarning: In future versions `DataFrame.to_latex` is expected to utilise the base implementation of `Styler.to_latex` for formatting and rendering. The arguments signature may therefore change. It is recommended instead to use `DataFrame.style.to_latex` which also contains additional functionality.\n", + " result_scores[split]\n", + "/tmp/ipykernel_42898/876785269.py:11: FutureWarning: In future versions `DataFrame.to_latex` is expected to utilise the base implementation of `Styler.to_latex` for formatting and rendering. The arguments signature may therefore change. It is recommended instead to use `DataFrame.style.to_latex` which also contains additional functionality.\n", + " result_scores[split]\n" + ] + } + ], + "source": [ + "pd.set_option(\"display.precision\", 3)\n", + "pd.set_option(\"display.float_format\", lambda x: \"%.3f\" % x)\n", + "\n", + "agg = {\n", + " 'nRMSE' : ['median', 'min'],\n", + "}\n", + "\n", + "for split in splits:\n", + " print(split)\n", + " print(\n", + " result_scores[split]\n", + " .query(\"corrected == True\")\n", + " #.query(\"treeval == False\")\n", + " .groupby([\"target\", \"method\", \"treeval\"])[[\"nRMSE\"]]\n", + " .agg(agg)\n", + " .to_latex(formatters=[lambda x: \"%.3f\" % x] * 2)\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "736680ac-c3df-436e-a822-31042b42e3b4", + "metadata": {}, + "source": [ + "# Statistical Tests" + ] + }, + { + "cell_type": "code", + "execution_count": 70, + "id": "f8499e57", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "dict_keys(['linear', 'linear_treeval', 'RF', 'RF_treeval', 'KPConv', 'KPConv_treeval', 'PointNet', 'PointNet_treeval', '\\\\power{}', '\\\\power{}_treeval', 'MSENet14', 'MSENet14_treeval', 'MSENet50', 'MSENet50_treeval'])" + ] + }, + "execution_count": 70, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "results_corrected.keys()" + ] + }, + { + "cell_type": "code", + "execution_count": 71, + "id": "b3252e87-2731-4164-b587-22bab5cba9cd", + "metadata": {}, + "outputs": [], + "source": [ + "split = \"test\"\n", + "baseline = '\\\\power{}'\n", + "rr = {r: results_corrected[r].query(\"split == @split\") for r in results_corrected}\n", + "rb = rr[baseline].reset_index()\n", + "favorite = \"MSENet50\"\n", + "rf = rr[favorite].query(\"run == 0\").reset_index()" + ] + }, + { + "cell_type": "code", + "execution_count": 78, + "id": "f4a28c1a-77f1-45d3-88cf-dcd2045c8e7d", + "metadata": {}, + "outputs": [], + "source": [ + "target = \"BMag_ha\"\n", + "pred = \"BMag_ha_pred\"" + ] + }, + { + "cell_type": "code", + "execution_count": 82, + "id": "b3c56ead-7d60-4e2a-82d6-b0b54ed09b4a", + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.stats import wilcoxon, ttest_rel\n", + "# effect size from wilcoxon: Kerby, Dave S. (2014), \"The simple difference formula: An approach to teaching nonparametric correlation.\", Comprehensive Psychology, 3: 11.IT.3.1, doi:10.2466/11.IT.3.1\n", + "\n", + "def get_total_ranksum(x, y):\n", + " diff = np.array(x) - np.array(y)\n", + " samples = len(diff) - (diff == 0).sum() # remove ties\n", + " return sum([i+1 for i in range(samples)])\n", + "\n", + "stat_columns = [\n", + " \"method\", \"target\", \"run\",\n", + " \"wilcoxon_two_T\", \"wilcoxon_two_p\", \"wilcoxon_two_es\", \n", + "]\n", + "stat_diff_results = []\n", + "for target in target_vars:\n", + " pred = f\"{target}_pred\"\n", + " y_b = abs(rb[target].values - rb[pred].values)\n", + " # y_b = (rb[target].values)\n", + " # y_b = [np.mean(y_b[i]) for i in index_folds]\n", + " for run_i in range(10):\n", + " for method, df in rr.items():\n", + " if method == baseline or \"treeval\" in method:\n", + " continue\n", + " df = df.query(\"run==@run_i\")\n", + " n_runs = len(df.run.unique())\n", + " if n_runs == 0:\n", + " continue\n", + " yy = rb[target].values[None, :].flatten()\n", + " y_m = abs(yy - df[pred].values)\n", + "\n", + " S = get_total_ranksum(y_b, y_m)\n", + " wilcoxon_two_T, wilcoxon_two_p = wilcoxon(y_b, y_m, alternative=\"two-sided\")\n", + " wilcoxon_two_effect_size = ((S-wilcoxon_two_T)/S) - (wilcoxon_two_T/S)\n", + "\n", + " stat_diff_results.append([\n", + " method, target, run_i,\n", + " wilcoxon_two_T, wilcoxon_two_p, wilcoxon_two_effect_size,\n", + " ])\n", + " \n", + "stat_diff_results = pd.DataFrame(stat_diff_results, columns = stat_columns)\n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": 83, + "id": "5d173654-6e9a-4e2a-a9ef-133070aa6b84", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
runwilcoxon_two_Twilcoxon_two_pwilcoxon_two_es
targetmethod
BMag_haKPConv2.0000171037.00000.00000.1766
MSENet142.0000156050.00000.00000.2487
MSENet502.0000154345.00000.00000.2569
PointNet2.0000194488.00000.09610.0636
RF2.0000207225.00000.95150.0023
linear0.0000205608.00000.79150.0101
V_haKPConv2.0000172431.00000.00000.1698
MSENet142.0000156289.00000.00000.2476
MSENet502.0000155540.00000.00000.2512
PointNet2.0000188915.00000.01800.0905
RF2.0000199221.00000.28540.0409
linear0.0000192746.00000.05960.0720
\n", + "
" + ], + "text/plain": [ + " run wilcoxon_two_T wilcoxon_two_p wilcoxon_two_es\n", + "target method \n", + "BMag_ha KPConv 2.0000 171037.0000 0.0000 0.1766\n", + " MSENet14 2.0000 156050.0000 0.0000 0.2487\n", + " MSENet50 2.0000 154345.0000 0.0000 0.2569\n", + " PointNet 2.0000 194488.0000 0.0961 0.0636\n", + " RF 2.0000 207225.0000 0.9515 0.0023\n", + " linear 0.0000 205608.0000 0.7915 0.0101\n", + "V_ha KPConv 2.0000 172431.0000 0.0000 0.1698\n", + " MSENet14 2.0000 156289.0000 0.0000 0.2476\n", + " MSENet50 2.0000 155540.0000 0.0000 0.2512\n", + " PointNet 2.0000 188915.0000 0.0180 0.0905\n", + " RF 2.0000 199221.0000 0.2854 0.0409\n", + " linear 0.0000 192746.0000 0.0596 0.0720" + ] + }, + "execution_count": 83, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pd.set_option(\"display.precision\", 3)\n", + "pd.set_option(\"display.float_format\", lambda x: \"%.4f\" % x)\n", + "stat_diff_results.query(\"target != 'Cag_ha'\").groupby([\"target\", \"method\"]).median()" + ] + }, + { + "cell_type": "markdown", + "id": "a46929b8-7766-45e3-ab23-51f82d7ed8df", + "metadata": {}, + "source": [ + "# Statistical Tests Aggregating Trees" + ] + }, + { + "cell_type": "code", + "execution_count": 85, + "id": "08ace53f-8789-4c15-9bfb-d073113f3da3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "dict_keys(['linear', 'linear_treeval', 'RF', 'RF_treeval', 'KPConv', 'KPConv_treeval', 'PointNet', 'PointNet_treeval', '\\\\power{}', '\\\\power{}_treeval', 'MSENet14', 'MSENet14_treeval', 'MSENet50', 'MSENet50_treeval'])" + ] + }, + "execution_count": 85, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "results_corrected.keys()" + ] + }, + { + "cell_type": "code", + "execution_count": 86, + "id": "15ba5a6d-c26b-463c-b0f4-d8a148dad4a2", + "metadata": {}, + "outputs": [], + "source": [ + "split = \"test\"\n", + "rr = {r: results_corrected[r].query(\"split == @split\") for r in results_corrected}" + ] + }, + { + "cell_type": "code", + "execution_count": 89, + "id": "37773f1e-32f0-4479-962c-f6ce608d8438", + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.stats import wilcoxon, ttest_rel\n", + "# effect size from wilcoxon: Kerby, Dave S. (2014), \"The simple difference formula: An approach to teaching nonparametric correlation.\", Comprehensive Psychology, 3: 11.IT.3.1, doi:10.2466/11.IT.3.1\n", + "\n", + "def get_total_ranksum(x, y):\n", + " diff = np.array(x) - np.array(y)\n", + " samples = len(diff) - (diff == 0).sum() # remove ties\n", + " return sum([i+1 for i in range(samples)])\n", + "\n", + "stat_columns = [\n", + " \"method\", \"target\", \"run\",\n", + " \"wilcoxon_two_T\", \"wilcoxon_two_p\", \"wilcoxon_two_es\", \n", + "]\n", + "stat_diff_results = []\n", + "for target in target_vars:\n", + " pred = f\"{target}_pred\"\n", + " for run_i in range(10):\n", + " for method in rr.keys():\n", + " if \"treeval\" in method:\n", + " continue\n", + " df = rr[f\"{method}\"].query(\"run==@run_i\")\n", + " if len(df) == 0: # run does not exist\n", + " continue\n", + " df_treeval = rr[f\"{method}_treeval\"].query(\"run==@run_i\")\n", + " \n", + " y_b = abs(df[target].values[None, :].flatten() - df[pred].values[None, :].flatten())\n", + " y_m = abs(df[target].values[None, :].flatten() - df_treeval[pred].values[None, :].flatten())\n", + " # resid = (df[target].values)\n", + " # y_m = [np.mean(y_m[i]) for i in index_folds]\n", + "\n", + " S = get_total_ranksum(y_b, y_m)\n", + " wilcoxon_two_T, wilcoxon_two_p = wilcoxon(y_b, y_m, alternative=\"two-sided\")\n", + " wilcoxon_two_effect_size = ((S-wilcoxon_two_T)/S) - (wilcoxon_two_T/S)\n", + "\n", + "\n", + " stat_diff_results.append([\n", + " method, target, run_i,\n", + " wilcoxon_two_T, wilcoxon_two_p, wilcoxon_two_effect_size,\n", + " ])\n", + " \n", + "stat_diff_results = pd.DataFrame(stat_diff_results, columns = stat_columns)\n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": 90, + "id": "1e8070d1-f59c-452d-9bf0-caa2810d966b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
runwilcoxon_two_Twilcoxon_two_pwilcoxon_two_es
targetmethod
BMag_haKPConv2.0000167358.00000.00030.1377
MSENet142.0000119040.00000.23630.0519
MSENet502.0000125323.00000.19640.0547
PointNet2.0000187113.50000.36860.0358
RF2.000056803.00000.00000.6997
\\power{}0.000057945.00000.00000.7210
linear0.000054639.00000.00000.7021
V_haKPConv2.0000172075.00000.00410.1097
MSENet142.0000118817.00000.15560.0612
MSENet502.0000119360.50000.09220.0726
PointNet2.0000189249.50000.70720.0146
RF2.000060310.00000.00000.6809
\\power{}0.000063803.00000.00000.6928
linear0.000059058.00000.00000.6780
\n", + "
" + ], + "text/plain": [ + " run wilcoxon_two_T wilcoxon_two_p wilcoxon_two_es\n", + "target method \n", + "BMag_ha KPConv 2.0000 167358.0000 0.0003 0.1377\n", + " MSENet14 2.0000 119040.0000 0.2363 0.0519\n", + " MSENet50 2.0000 125323.0000 0.1964 0.0547\n", + " PointNet 2.0000 187113.5000 0.3686 0.0358\n", + " RF 2.0000 56803.0000 0.0000 0.6997\n", + " \\power{} 0.0000 57945.0000 0.0000 0.7210\n", + " linear 0.0000 54639.0000 0.0000 0.7021\n", + "V_ha KPConv 2.0000 172075.0000 0.0041 0.1097\n", + " MSENet14 2.0000 118817.0000 0.1556 0.0612\n", + " MSENet50 2.0000 119360.5000 0.0922 0.0726\n", + " PointNet 2.0000 189249.5000 0.7072 0.0146\n", + " RF 2.0000 60310.0000 0.0000 0.6809\n", + " \\power{} 0.0000 63803.0000 0.0000 0.6928\n", + " linear 0.0000 59058.0000 0.0000 0.6780" + ] + }, + "execution_count": 90, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pd.set_option(\"display.precision\", 3)\n", + "pd.set_option(\"display.float_format\", lambda x: \"%.4f\" % x)\n", + "stat_diff_results.query(\"target != 'Cag_ha'\").groupby([\"target\", \"method\"]).median()" + ] + }, + { + "cell_type": "markdown", + "id": "216cbf89-d074-4774-955a-b756cc0179f4", + "metadata": {}, + "source": [ + "# Species Evaluation" + ] + }, + { + "cell_type": "code", + "execution_count": 92, + "id": "8d827085-2e90-4805-8266-ceb8206e91ac", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "figsize = (7.48031 / 2, 2)\n", + "g = sns.histplot(data=results[\"linear\"].reset_index(), x=\"C_qfrac\", hue=\"split\", stat=\"count\", multiple=\"stack\", discrete=True)\n", + "g.set(xlabel=\"conifer fraction\")\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "#plt.savefig(\"figures/c_qfrag_hist.svg\")" + ] + }, + { + "cell_type": "markdown", + "id": "5b5e22e1-5d33-472c-b8ec-3a4e23e165cd", + "metadata": {}, + "source": [ + "## boxplot comparing NFI over conifer fractions" + ] + }, + { + "cell_type": "code", + "execution_count": 94, + "id": "da5c5d4d-0443-4472-bf39-c099df23c85b", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "figsize = (7.48031 / 2, 2.5)\n", + "g = sns.catplot(data=results[\"linear\"].reset_index(), y=\"BMag_ha\", x=\"C_qfrac\", kind=\"box\", hue=\"split\", notch=True)\n", + "g.set(xlabel=\"conifer fraction\")\n", + "g.set(ylabel=\"AGB in \\,Mg\\,ha$^{-1}$\")\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "fig.tight_layout()\n", + "#plt.savefig(\"figures/frac_agb.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 95, + "id": "a8ddf178-8089-4832-869b-63e4c4669a33", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXUAAADxCAYAAAA0qyeyAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8o6BhiAAAACXBIWXMAAA9hAAAPYQGoP6dpAABVKElEQVR4nO2dd1xT1/vHP/cGwh4ibhAn4EIUnDhx4KZatb/KqFq1jrqrosXWbVv3qrb6VatoHdU6Ko5atSJ1AFVBBUGLIAoqIMgKkNzz+yNNakgCCUlICOf9euUlOefce5/Em+ee85xnMIQQAgqFQqEYBay+BaBQKBSK9qBKnUKhUIwIqtQpFArFiKBKnUKhUIwIqtQpFArFiKBKnUKhUIwIqtQpFArFiKBKnUKhUIwIqtTVhOM4FBYWguM4fYtCoVAoclClriYCgQDx8fEQCAT6FoVCoVDkoEqdQqFQjAiq1CkUCsWIoEqdYjRERkZizJgxiIyM1LcoFIreoEqdYhQIBAJs2LABr169woYNG+ieB6XGQpU6xSgICwtDVlYWACArKwuHDh3Ss0QUin7QilKfOHGiNk5DoVSKtLQ0HDp0CJLSAIQQHDp0CGlpaXqWjEKpekzUGbxlyxa5NkIIUlNTtSYQhaIOhBBs2rRJafv69evBMIweJKNQ9INaSv3IkSNYtGgRyhZLsrCw0KpQFIqqpKSkICoqSq5dJBIhKioKKSkpaNKkSdULRqHoCbWUerNmzdCtWzfUq1dPpv2vv/7SqlAUiqq4uLigU6dO+PvvvyESiaTtPB4PXl5ecHFx0aN0FErVw6hTo5QQUuOXsoWFhYiPj0erVq1gaWmpb3EoENvUg4KCZJS6iYkJDh48iEaNGulRMgql6lFro7SmK3SKYeLk5ISAgADp/ckwDAICAqhCp9RINPJ+mTlzprbkoFA0IjAwELVr1wYAODo6IiAgQM8SUSj6QSOl/vz5c23JQaFohLm5OebPn4969eph3rx5MDc317dIFIpeUGujtCzUHEMxJHx8fODj46NvMSgUvUIjSikUCsWIoEqdQqFQjAiNlLoa3pAUCoVCqQI0UuqnTp3SkhgUCoVC0QbU/EKhUChGhEbeLxzH4ejRo7h48SLS09MhFApl+v/44w+NhKNQKBSKemg0U9+2bRu+//57dO/eHenp6Rg5ciS6dOmC/Px8GvxBoVAoekCjmfrp06exZs0a9OzZEzt37sTw4cPh4uKCn3/+GTdv3tSWjBQKhUJREY1m6m/fvkWzZs0AADY2NsjNzQUA9OjRAxEREZpLR6FQKBS10Eipu7i4SAtktGzZEidPnkR+fj7Cw8NhZ2enFQEpFAqFojoamV8CAwOlJcNmzJiBqVOn4ujRozAxMcHKlSu1IiCFQqFQVEetfOoVUVBQgOTkZDRs2BAODg7aOq1BQfOpUygUQ0ajmToAZGdnIy4uDllZWeA4DgCQkJAAABg9erSmp6dQKBSKGmik1MPDw7F48WKwLItatWrJ9DEMQ5U6hUKhVDEaKfX169dj8uTJmDZtGng8nrZkolAoFEol0cj7JScnB/7+/lShUygUioGgkVIfNmwYrl69qi1ZKBQKhaIhaptftmzZIv3bxsYG27ZtQ2RkJFxdXWFiInu62bNnay4hhUKhUFRGbaUeHR0t897d3R2FhYW4d++eTHtlSt2FhYXh5MmTSExMxIABA7Bp0yZpn6+vLzIzM6WmnoYNG+LcuXPS/gsXLmDdunXIyspCx44dsXbtWtSrV0/av2nTJhw5cgQikQhDhw5FaGgoTE1N1ZaRQqFQDBm1lfrBgwd1IQcAoG7dupg+fTr++usvvH37Vq5/+/bt6NWrl1z706dPsXjxYuzYsQMdO3bEt99+i/nz5yMsLAwAcPz4cYSHh+PEiROwtLTEZ599hp07d2LWrFk6+ywU4yQyMhKbN2/GnDlzaD1UikGisZ+6Nhk4cCAAID4+XqFSV8aZM2fQq1cvdO/eHYDY7OPj44PU1FQ0btwYJ06cwPjx4+Hk5AQAmDZtGpYvX66RUheJRBCJRJU+nlL9EAgE2LBhAzIzM7FhwwZ4enrC3Nxc32JRjARtOZwYlFKviJCQEHAch5YtW2LOnDnw8vICACQmJsLDw0M6zt7eHg0aNEBiYiIaN26MpKQkuLu7S/tbtWqFjIwM5OXlwcbGplKyJCYmavZhKNWO8PBwZGVlAQCysrKwadMmDB48WM9SUYwFiT7TFJ0pdV9fX3Tt2hWzZ8+WsW1Xlu+++w5t27YFAJw8eRKTJ0/G2bNn0ahRIxQWFsopZ1tbWxQUFACAXL/k74KCgkordVdXV5omoAbx4sULXLlyRVqXlxCCK1euIDg4GI0aNdKzdBTKf+hMqY8cORIvX75EcHAwLl68qPH5vL29pX+PGzcO4eHhuH79Oj7++GNYWloiPz9fZnxeXh6srKwAQK5f8rekvzLweDzqn19DIITIeH2VbV+/fn2lHAMoFF2gsxqlM2fOxCeffIINGzbo5PwMw0hnTa6urtJ8MwCQm5uL9PR0uLq6AhCnBX6/Pz4+HvXr16/0LJ1Ss0hJSUFUVJTcHopIJEJUVBRSUlL0JBmFIo9WlbpAIMCVK1fw1VdfoU+fPpgyZQqeP3+u8vFCoRDFxcUQCoXgOA7FxcUoLS3Fy5cvER0djZKSEpSUlODYsWN48OABevToAQAYMWIErl+/jps3b0IgEGDr1q3w9PRE48aNAQCjRo3CTz/9hBcvXiA7Oxs7d+7Ehx9+qM2PTjFiXFxc0KlTJ7mVGY/HQ+fOneHi4qInySgUeTROvZuWloY///wTV69eRUxMDFq2bIlevXqhb9++aNOmjVrn2rZtG7Zv3y7TNnLkSEyaNAnz589HamoqTE1N0bx5c8yZMwddunSRjjt//jzWr1+PzMxMeHl5yfipE0KwefNmHDlyBEKhEEOHDsXSpUsr5adOU+/WTNLS0hAUFCQzWzcxMcHBgwepTZ1iUFRKqd+5cwfXrl3DtWvX8Pr1a3Tv3h19+vRB7969Ubt2bV3IaTBQpW646NqHfM+ePTh48CAIIWAYBsHBwfj000+1fh0KRRMqpdT9/PzQt29f9O7dG506dZJLD2DMUKVumAgEAowbNw6ZmZlwdHTE4cOHte5D/v416tSpg0OHDlE/dYrBUSmb+sWLFxESEoJu3brVKIVOMVzCwsJkfMgPHTqk9WuYm5tj/vz5qFevHubNm0cVOsUg0cimnpeXBz6fDzMzM23KZNDQmbrhoczefeDAAWkUMYVSU1Brpn7mzBn07NkTkyZNwrFjxzB8+HCMGDEC58+f15V8FEq5EEJkEr+VbddiCV4KpVqglu3kwIEDOH/+PAoKCuDv74/w8HBYWFhg4sSJNFyaohckPuRled+HvEmTJlUvGIWiJ9SaqVtYWMDa2hr16tVDixYt4ODgAAsLC5rClqI3qA85hSKLWkq9tLQUHMcBALZu3Sptl7RRKFUNwzCYO3eu0nYavk+paail1Pfs2QOWFR/i4OAAACgpKcHixYu1LxmFoiJOTk4ICAiQKnCGYRAQEECDgig1ErWUurW1tVwbn89HmzZtQAgBx3EyLwqlqggMDJQGvjk6OiIgIEDPElEo+kEjJ/P09HSsXbsWd+7cQW5urlx/fHy8JqenUFTG3NwcQ4YMQVhYGAYPHkx9yCk1Fo2U+rx580AIwVdffYXatWtT+yVFbwgEAoSHh4PjOISHhyMwMJAqdkqNRCOlnpCQgJMnT6Jp06bakodCqRSKIkppXhZKTUSj1Luenp5ITU3VliwUSqVIS0vDoUOHZKoSHTp0CGlpaXqWjEKpejSaqX/zzTcIDQ1FcnIymjdvLpcHplu3bhoJR6FUREURpbQqEaWmobH5JTY2FhEREXJ9DMPQjVKKzqERpRSKLBop9eXLl2PYsGGYNm0aHB0dtSUThaIykojSv//+WyahF4/Hg5eXF40opdQ4NLKp5+Tk4JNPPqEKnaI3aEQphSKLRkp96NChuH79urZkoVAqhSYRpZGRkRgzZgwiIyN1LSaFUiVoZH6xsbHBli1bEBERAVdXV7mN0tmzZ2skHIWiKoGBgQgPD5dWPlIlolQgEGDDhg3IzMzEhg0b4OXlRX3bKdUejZR6XFwc3N3dUVhYiHv37sn00WUvpSqRVCWS1ChVRTlT33aKMaJR5aOaCK18ZBzQakkUY0UjmzqFUh2h1ZIoxgxV6hSjQdVNT4lv+/uzdEDWt51Cqa5QpU4xCiSbnq9evcKGDRsgEAiUjqXVkijGDFXqFKNA0aanMqhvO8WY0cj7BQCys7MRFxeHrKwsucIYo0eP1vT0FEqFKEvo5efnp3TT08nJCa1bt0ZcXJy0rXXr1rRaEqXao5FSDw8Px+LFi8GyLGrVqiXTxzAMVeoUnVPZhF5paWl4+PChTNvDhw+RlpZGvV8o1RqNlPr69esxefJkTJs2Tc4+SaFUBZVJ6CVR+IqUPc3sSKnuaJz7xd/fnyp0it6QbHqWVcIMwyjd9KTeLxRjRiOlPmzYMFy9elVbslAoasMwDD7++GM533JCCD7++GOFM27q/UIxZjTO/bJt2zZERkbS3C8UvUAIwc8//wyGYWQUO8MwOHz4MDp27KhwFj937lwEBQUpbKemF0p1RiOlHhsbS3O/UPSKMps6IaTcIhlOTk746KOPcPjwYWnbRx99RL1fKNUejZT6wYMHtSUHhVIpXFxc4OHhgdjYWLk+Dw8PakrRMZGRkdIkaj4+PvoWp0o4efIkNm/eLE07HhISAqFQiPXr1+tZMjE0+IhS7VGWq6W8HC5paWk4evSoTNvRo0dpsWo1UCeK15j58ssv8fXXX0vf9+rVCydPntSbPGrP1Lds2YIpU6bAwsICW7ZsKXesujb1sLAwnDx5EomJiRgwYICM/3FiYiJCQ0Px+PFjODs7Y9myZfD29pb2X7hwAevWrUNWVhY6duyItWvXol69etL+TZs24ciRIxCJRBg6dChCQ0NhamqqlnwUwyMlJUUmgOh94uLiynVpLAstVq0eNHWxGBsbG32LIIPaM/Xo6GiUlpZK/1b2iomJUVuYunXrYvr06Rg7dqxMe2lpKaZNm4b+/fsjKioKkydPxvTp05GbmwsAePr0KRYvXoyVK1fi1q1bcHFxwfz586XHHz9+HOHh4Thx4gQuXbqER48eYefOnWrLRzE8JJ4sLCt7K7MsS10adYiyKN7qtNL57bffMGjQILRr1w4+Pj5YunQpAMDX1xc//vgjpk6dCg8PDwwaNAi3b99Wep6QkBB88cUXAICgoCC8evUKixcvhpubm9xmfFWg9kz9fTu6tm3qAwcOBADEx8fj7du30vY7d+5AIBBg0qRJYFkW/v7++Omnn3Dp0iWMGTMGZ86cQa9evdC9e3cA4hWCj48PUlNT0bhxY5w4cQLjx4+XRgpOmzYNy5cvx6xZs7QqP6XqUebJwrKsUk8WWqxaM4xhpfP69WssXrwY3377Ldq3b4/s7GyZCOPdu3dj3rx5WLBgAQ4fPowZM2bg6tWrFc7Kt23bhmHDhmHy5MkYMmSIXqwBGud+qQqSkpLg6uoqMxtzd3dHUlISALFpxsPDQ9pnb2+PBg0aIDExEY0bN0ZSUhLc3d2l/a1atUJGRgby8vIqvXQSiURyMz2KfmjQoAHGjRsnM8n4+OOPUb9+faX/R7Nnz0ZwcLBc+6xZs+RyGFFkqSiKNzk52eAfjBkZGeDz+ejRowesrKxQv359tG7dGiKRCIQQ+Pj4SC0GixYtwh9//IFff/0VAQEB0vtDcm8RQkAIgUgkgo2NDViWhZWVFRwcHGTGVYS2gjirhVIvKCiQU762trbIy8sDIK5GpKi/oKBAYb/kb0XnVZXExMRKHVceDx48wMmTJzFq1Ci0bdtW6+c3ZsrazZs0aSLnZlsWFxcXJCcny7x/8+YN3rx5owMJjQdCCNzc3JCUlCTzAGRZFq6ursjOzpZZaRsiIpEIjRo1Qr9+/eDp6QlPT094e3vDxMQEJSUlcHBwkLl/nJycEBUVhTZt2iA1NRWlpaXS/uzsbIhEIun70tJSpKamVnj/lcXLy0srn61aKHUrKyvk5+fLtOXl5cHKygoAYGlpqVa/5G9Jf2VwdXXVajk7gUCA1atX4+3btzh9+jRGjx5NiyCrQVlT2qlTp7B161al41+8eIHU1FSZtpSUFNSpU4f6qqvA0qVL8cknn8i0sSyL0NDQavP9/fLLL4iOjsaNGzfwyy+/4I8//sChQ4fA5/Ph5OQET09P6Vg7Ozs4OjrC09MTycnJMDU1lfY7ODhAKBRK35uamqJx48Yyx1cl1UKpt2zZEnv27AHHcVITTHx8PD7++GMAYgWbkJAgHZ+bm4v09HS4urpKj09ISEDHjh2lx9avX1+jXWsej1fhckkdH96ff/5ZxpPgyJEjNdKToDJER0fLecDExcXh7t27Mh5SEggh2LJli8LUAlu2bKkWNmF907hxY4XBW40bN9ajVOrB4/HQvXt3dO/eHZ9++im6d++OpKQkMAyDBw8eSH/fHMchPj4e3bp1A4/Hk+ogST/DMGAYRvrexMQEhBC95cTS2E+9oKAAZ8+exffff493794BEHujSBSUOgiFQhQXF0MoFILjOBQXF6O0tBSdO3cGn8/H3r17UVJSgrNnzyItLQ0DBgwAAIwYMQLXr1/HzZs3IRAIsHXrVnh6ekpvsFGjRuGnn37CixcvkJ2djZ07d+LDDz/U9KOXizo+vJX1JFC1fJsxw3Ecli1bprBv2bJlCu3jEptw2T6O46j3Sw3h/v37+PHHH/Hw4UO8ePECp06dAp/PR8OGDQEAN27cwNGjR/HPP/9gzZo1yM3NxYgRI1Q6d6NGjRAdHY03b95ITcRViUZK/fHjx/Dz88P27duxY8cOqYvhmTNn8N1336l9vp07d8LDwwO7du3ChQsX4OHhgaVLl8LU1BQ7d+7ExYsX4e3tjV27dmHHjh2wt7cHADRv3hyrV69GaGgounTpguTkZGzYsEF63jFjxmDQoEEYNWoUBgwYAHd3d0ybNk2Tj14hqlbiqWwRZBr4IebmzZvSyURZ3r17h5s3b8q1u7i4oF27dgqPoVGoqlHdg7esra1x69YtTJw4EUOGDMG5c+ewfft2ODo6AgAmTZqE33//Hf7+/oiIiMD27dtha2ur0rk///xz3L9/H3369MH06dN1+TEUwhANSqcHBwejY8eOmDNnDjp06IAzZ87A2dkZd+/exbx584wyg2NhYSHi4+PRqlUrpTb1tLQ0BAUFyex6m5iY4MCBA3IFGJ49e6bQC0PCgQMHFOYu2bNnDw4ePAhCCBiGQXBwcI0013AchxEjRihU7HZ2djh9+rScDzshBDNnzlSaWmDbtm3U/FIOhBB88cUXCl1CO3bsWO3NV76+vpg2bRrGjBmjb1EqhUYz9QcPHig0Y9SpUweZmZmanLraou7MuzJpYHUd+LFnzx706dMHe/bs0cr5dAnLskrNL8uXL5dT6IDY/KJIoQPiJHXlmV+oyYsGbxk6Gil1Gxsbhe5fjx49kgnRr0moe8OrWwS5suYaVcnJyUFYWBg4jkNYWBhycnI0Ol9VUL9+fYXtdevWVdhemcIaADV5SVAWxUvz0RsGGin1kSNHYvXq1Xjy5AkYhkFeXh6uXbuGNWvWyIX61xQqozCcnJwQEBAgPYZhGAQEBCh0DdP1LOnLL7+UbiByHIfQ0FCNzqdrlD3kACh9yFWmsAag+j6JsSOZcCj6/owhH/2VK1eqrekF0FCpz5o1C7169cLo0aNRWFiIDz/8ELNmzcLgwYMxZcoUbclYraiswggMDETt2rUBAI6OjggICFA4TpdVexS5BsbGxiI6OrrS59Q1yqIbASh9yBFCsG/fPoXH7N27V+GDwBhynegaSWQlRb9opNRZlsXs2bNx584d/Pbbbzh69Cj++usvLFq0SFvyVTsIIdi/f7/Cvn379im96c3NzTFkyBCwLIvBgwcrDTxS11yjKpVxDTQEnJ2dFdrNAfH96ezsLNf+7NmzcjM7Pnv2TKZN1yav6oaywt0Mw9TI78PQ0Eo+dT6fj+bNm6Nt27awtLQEx3EGqwR0zbNnz8rdhCurMCQIBAKEh4eD4ziEh4eXa69Vx1yjKpVxDTQEVq1apfRe4zhOK3LTjUFZqJ+/YaORUk9PT8esWbPQtWtXtG7dGm3atJF5UVRHXXutquYaVenWrZtSP1w7Ozt069ZNo/PrioqighU96CqaSWrDQ8mYqeqNUupxpB4aKfV58+bh9evX+Oqrr7B//3789NNPMq+aSJMmTZTOmJ2cnBT6nFfGXmtubo758+ejXr16mDdvnsZ5YliWxYwZMxT2ff7550pNHPpm7ty5cHNzU9jn7u6u8PuuyESlrFC1onHGsDGoLpLPrWimru3vg3ocqY9Gv9SEhASsXbsWQ4YMQZcuXdC5c2eZV02E4zikp6cr7Hv58qXcD0ETe62Pjw+OHz+uldqQhBCcO3dOYd/Zs2cN1k7KMIxMKbH3+frrrxUqmGvXrsHMzEzhMfXq1VNaqFrbJi9jQxcbpcbscXTmzJlyAw8ri0YJvTw9PZGamoqmTZtqS55qz+nTp8u18Z4+fRqjRo2StlWUm1pROTZdoMpegLL/Z30XH3ZycsLYsWNx7NgxadtHH32kVOEyDANHR0e8ePFCrq9nz55yD4L9+/fjypUrcmlmr169Ch6Ph/Hjx2vng1QTCCFYu3atwr61a9di+/btWpmtK1rB/hT2M06cCQfL03zlWFoqRFFRISwsLGFqKlaFdWrXxr7du1Q63tfXF8uWLUOvXr0qdf0RI0aonE9GHTRS6t988w1CQ0ORnJyM5s2bw8RE9nSGaofVJRWl2yzbL7FPxsTEyCkNb2/vKrPXqmtnliBZHmdmZmLDhg3w8vLSS8rgSZMm4fjx4yCEwNLSstyUCePHj8f48eOxa9cumSyDAQEB+Oyzz5Qex7IsWJaFSCRC7dq1DdYkpWtU8R7SdKKndAXLsHjl6q/RuctS8P6bp2e1cs7S0lK91UDWSKknJCQgNjYWERERcn0MwyA+Pl6T01dLmjRpAj6fj5KSErk+Pp8vN+uW2CfLbnRWdSCHunZmCYZSfNjc3Bx16tRBVlYWli5dqtKDZfz48Thy5Ag4joOdnZ1cfvD3x0lm45Ll8oEDB7QmO0We8uIPDIEFCxbg5cuX+Pzzz8Hj8RAcHIxdu3Zh6dKlOHjwIN68eYO///4be/bswdGjR5GZmYn69etjzpw58PPzAwCcPHkSR44cka4w3dzcsGLFCuzbtw9v3rxB3759sWbNGvD5fLVk02iqsXz5cgwbNgw3btxAQkKCzKsmKnRAfDMqUugAUFJSorK7F8MwFc6etekV0KRJE5mSgO/Tvn17rW3w6hJLS0s4OzurbAIyNzeHo6MjeDweQkJCaFESFanMvaIuyjyODIV169ahYcOG2L59O+7evSvdSL948SIOHz6Mv/76C4DYNBgWFoaYmBjMnDkTCxYsQEZGhtLzXrp0CUeOHMGlS5dw7949nDlzRm3ZNFLqOTk5+OSTT6TpKimQq6ZTUb9kmVl2KV9RIIe2vQIYhkFISIjS9qrOQVNVqPsgoCi/J1iWVdhe2Wso8jgydCZPnozatWtLJwiDBg1CvXr1wLIshgwZgqZNm+L+/ftKj58yZQrs7e1Ru3Zt9OnTB48ePVJbBo2U+tChQ3H9+nVNTmF0VFQcpGx/ZQNbdOEV4OTkhHHjxsm0jRs3Tic5aKjvcfXGyclJWnlMwscff6xVbyBFHkdm5oq9lrQFJ9IsaFJSZEPCqVOn4O/vD29vb3h7eyMpKanc+q3vT5DNzc1RWFiotgwa2dRtbGywZcsWREREwNXVVW6jdPbs2ZqcvlpSUdBV2X7JMlNRbmovLy+1Uu/6+fnJ5WtXl//7v//Dzz//LM3T/tFHHykcVxm5JRjK5iqlcrzvDSQxE7Isi4iICJiZmWnVGygwMBDh4eHIzMyEo6MjijnD3px+f5Xy4sULhIaGYt++fejYsSN4PB4++OADna9iNfqG4uLi4O7ujsLCQty7dw/R0dHSV0xMjLZkrFYo2jQur9/QUu/+8ssvMg+LEydOKBynSUCOMfse1yQk3kCAeIapC2+gskF2MKA4L0dHRzx//lxpf1FREQBxYWpAPGtPSkrSuVwazdQPHjyoLTmMhk8++aRczwhFHhaSZeb7lYwqSr1bFm34tUtWAO9T3gpAHbnLXkMXqwxK1aCpN5C6cQ0+Pj7/jduwWV1x1UId//cpU6Zg9erV2LRpEwIDA+X6W7RogU8//VSanfWDDz5Ahw4dtCmuQjRS6hR5FAW0lO1XpHTLLjOV5XK5evUqzM3NFW6MNmrUqNJ+7RWtAJSVKAsMDMSpU6fw7t072NralpuDprLXoBgPmpre6tSurbEveX5+PkRCkVw7z4SHOmr8fvr374/+/ftL38+ZM0duzNy5c5Vu+I4aNUomEPHx48cy/V988YXKsryPRkq9ogx4NTH4qHHjxrC2tkZ+fr5cn7W1NRo3bqzwOMkyUzKDKS/1bu3atRU+PLp161ZppajJCkAy664oM6ehRM9S9IemcQ2qRnsqQ1ITWNGvhBMCX38p7wFW3dBIqU+YMEFhu0Sx1ERf9ZSUFIUKHRDPEFJSUpRG28ksM5UgWfru2bNHuuTVRuHpym58hoWFST9vfn5+uT9SyTWio6NlbP8Mw6BTp041LtthTcMQTG8uLi5o166dwohYDw8Po7gHNU7o9f7rwYMHOHHiBLp27aq0UARFOeq4+QUGBkoDM7SRercyG5/qBh9VtioUpfqj7gb//v37ERwcjODgYGmOFMl7TXWLsd9nWt2uNjExQZs2bTB37lylVXSMnYpMEMr6BQIBVqxYgVevXmHFihUVBhOZm5ujdu3a4PF4Wkm9C/y38fk+yjY+K+OFQwjBzz//rDC17eHDh6tNwBJFfTSJaygoKEBBQYHSfnXlKC9xnTEU+NCJ0yfDMHj9+rUuTm3wlBctVl7/nj17pC5QRUVF+N///lfhtXQRDTl8+HCZ98OGDVM4rjI/UskximbqtGKOcSPZ4FeEog3+8ePH48CBAzhw4AAaNWqERo0aSd9r4gcvMb8owljMLxrZ1H/55ReZ94QQZGZm4sSJE+jevbtGglVXhgwZgs2bN5fbX5a0tDSZtLEAcPToUfj7+1e5m9+KFStk3q9cuRLbt2+XG1cZG7yLiws8PDwUzpSM5QdFUYyuNvgrK4uukARmAeI0KgBgb28PQJyqtyrSNGuk1L///nuZ9yzLwsHBAQMHDsTUqVM1Eqw6sn//fhw5cqTcMdu2bcOCBQuk7wkhWL58ucKxy5cvx48//lhlN3x0dLTcBlJsbCyio6Ph7e0t0y6xtQcFBSlsVyazMhMLNb0YN7ra4FcXVcwv73tgaaKkJSYjyfiqQiOlLvmwlP+wtrYuN1/DyJEjZd4nJyfL+adKePz4MZKTk9GsWTOtyqgIjuOU7oMsW7YMZ86ckYsYVDf4KCUlpdw83NSl0fgJDAzEoUOHIBKJtLLBry7Ozs5gWVbh3hbLsnB2dlZ6rCpK2hDSNBt2IoVqxvjx43H8+HHUr19fYX+DBg3QvHlzmTZlpe9U7dcWN2/exLt37xT2vXv3TmlMgjoFsGuCPZNSPrrY4FeHmzdvlluZrOx9rm3bflpaGtzc3FBcXFwZ8VVC7Zn6li1bVB5bExN6MQyDjRs3ymU7BICNGzfKmSUeP36sNHc6n8+vsgCux48fK53BmJmZKZVD1aApCcpyzevyJqcYFpaWlrC0tKzUBv/nUybgbdabSl9bJBLBxlS5h9qB3d+jR48elT6/IaC2Uo+OjlZpnLH7gpaHk5MThg0bht9++03aNnz4cIVmCZZlUbduXbx69Uquz9fXt8pKprEsizp16iiUo2/fvuXKoUrQFCCO5ivP1KSNMmgU4+Zt1husba87L6mFfytPi1tdUFup0yReqjFr1iypUufxeJg5c6bCcRIb3LRp0/Dw4UNpe9u2bbFkyZIqkfV9OWbMmCFj9/bw8FAoR2U2kE6dOqVtsSkUvfDjjz8iJiYGP/zwg7Rt165duH//Pv7v//4PmzdvRkJCAkxMTLB582bMnj27yia6WpkGPn36FJcvX8bly5fxzz//aOOU1R5zc3PUrVsXPB4Pq1atqtAs8X51doZhsGbNGl2LqJDVq1dL/2ZZFqtWrarwGFWDQ+zt7ZXWW6xXrx7dJKXoHVXL540YMQKRkZHIzs6Wtp09exYjRoyApaUl1q5dC29vb7i5ueHYsWO4ePGirkSWQyPvl6ysLCxcuBCRkZGwtbUFAOTl5cHHxwffffedNI9wTUUd26G9vT3s7OyQm5uLoKCgKneDUiRHYGCgUjkqs8s/YcIEDBgwAAEBAXK5XzZv3lyjTXaU6kX9+vXRoUMHnD9/HgEBAYiPj0dGRgZ8fX1hZiauzsQwDCwtLTF06FBERUVh0KBBVSKbxoWn8/PzER4ejjt37uDOnTv47bffkJeXp9T3mqKcWrVqoUmTJpg0aZLRyqGoDJqyknkUiiEzYsQIqYn1zJkz8PPzg5mZGe7fv4+goCDExMQgOjoaR44cKbeEnbbRSKlHRERg2bJlMn7UzZs3x1dffVVhBaDKEBISgrZt26JDhw7S18uXL6X9iYmJGDt2LNq3b49hw4bJbepeuHAB/fr1g6enJyZOnKhwU5Cie8aPHy/deLW1tVVYOIRCMXQGDRqER48e4fnz5zh37hxGjBgBAJg/fz769OmDDh06wNvbW2ESO12ikVI3NTVVGGhTVFQkV69UW4wfPx53796VviSFXktLSzFt2jT0798fUVFRmDx5MqZPn47c3FwAYrv/4sWLsXLlSty6dQsuLi6YP3++TmSklI+5uTkcHR3B4/GwePFiWp+UUi2xsbFB79698fXXX4NhGHTu3BmAeI/J1tYWLMsiPz9fxguuKtBIqQ8YMABLlixBREQE3r17h3fv3uH69esIDQ3FwIEDtSWjSty5cwcCgQCTJk0Cn8+X5k25dOkSAPHyqFevXujevTvMzc0xe/Zs3L17F6mpqVUqJ0WMLpKRUShVjb+/PyIjIzFs2DDp6vPrr7/G999/j6ioKLx48aLKbOkSNJpOh4aGYs2aNZg2bZo0qROPx8OoUaMQEqKbCiLHjh3DsWPHUL9+fQQHB2P06NEAgKSkJLi6usr4U7u7u0sLvSYmJsLDw0PaZ29vjwYNGiAxMVFpNaLyEIlEchkKyyJZclU0rqrGq0pVyKEL2atCDl1959WVqv7O7R0cEVJ+ItRyKRYIUFIqDoAjnPi8DCveoOeb8lG/oaNSuRTJ3adPHzx69EimfcCAARgwYIDUkeDLL7+U9jdo0EBuvARVPW8qQiOlbmFhgZUrVyIkJERaVdvZ2RlWVlZaEa4sQUFBWLhwIezs7BAdHY1Zs2bBxsYGfn5+KCgogI2Njcx4W1tb5OXlAQAKCwsV9lc2T3NiYmKFYyQ50e/du6fSOXU9XlWqQg5dyF4VcujqO6+uVPV3PnGq4ngPVbl48SLu3r0LAMjMzAQAONZyBAB06NABfn5+SuXS9b3i5eWl0riK0DihV8+ePWFlZQV3d3etCFQebdq0kf7dpUsXBAQE4MKFC/Dz84OVlZVcGbm8vDzpA8bS0rLcfnVxdXWFpaVluWMktmJPT0+Vzqnr8apSFXLoQvaqkENX33l1pbp95++fQzKTVrWSUnW5VzRS6osXLwYhBP369cPQoUPRvXv3KgtrB8TBMZIlUcuWLbFnzx5wHCeVIT4+Xuo+5+rqioSEBOmxubm5SE9Ph6ura6WuzePxKlwuSfyuVV1W6Xq8qlSFHLqQvSrk0NV3Xl2pzt95df19VoRGGjgyMhLr1q0DIQRz586Fj48PvvrqK9y+fVsnLjzh4eHIz88Hx3GIjo5GWFgYBgwYAADo3Lkz+Hw+9u7di5KSEpw9exZpaWnS/hEjRuD69eu4efMmBAIBtm7dCk9Pz0rZ0ykUCsVQ0WimbmJigt69e6N3794oKSnB9evXcf78eUyfPh2WlpZa91U/dOgQvvrqK4hEIjRs2BCzZ8/G0KFDAYjdK3fu3InQ0FBs3boVzs7O2LFjhzQisnnz5li9ejVCQ0ORmZkJLy8vbNiwQavyUSgUir7RmjM5n8+Hk5MTnJycYG9vr5PAnkOHDpXb7+bmhuPHjyvtHzx4MAYPHqxtsSgUCsVg0FipJyUl4fz58zh//jyeP3+OLl26YOrUqfDz89OGfBQKhUJRA42U+tChQ5GcnIyOHTsiKCgIgwYNqvFJvCgUCkWfaKTUx4wZg8GDB6NevXrakodCqZDMzExp+of3kVRVevr0qUy7UChEbm4u6tatCwsLC7k+AArNhWZmZnrLlkmhVBaNlHplavRRKJpy/Phx/Pzzz0r7J0yYoPY5x4wZI9c2cOBAhIaGqn0uSuU4ffq09CH7PpLauSdOnJDrs7KyqvIwfAm+vr5YtmwZevXqVelzpKWloV+/foiNjZWm7NUU3WTdolCqgNL6bUFM/vshsO/EGTs524Yy49jcFzDJy0BzWyFc7WWVRlKu2Ie4pd1/IdsiAlx6TpOMVTW7du0qN8J7y5YtIKYEeC/tPsuy2LFnR6WuJ4k2Hz56uLTNsbYj9v2wr1LnMxSoUqdUW0rrtwExey/1Q6MOCseZciIgLwPtapdidHNBxeflxEr98uXLuHbtmmxfaSkAoH///nLHMQyD8+fP6yxDqT755ptvFNYnzsrKAgBpDqb3YRgGP/zwg8r7bAQEpBYB1155YWg2mgU3+L9+Dhyyka10vCrIHP+nascsWLAAL1++xOeffw4ej4fg4GCMHDkSq1atQlxcHGxsbMDj8aSm6djYWCxfvhzJyckwMzPDoEGD8PXXXyMwMBAA0LVrVwDiB5cmM3+AKnUKRSkcOBQ7FMs2/ptpoti6THsOwAiMt3JTTk4OXr9+DZFVHZl2hhWvlNILZIMNmZJ8sKVF4DjlClohpgDqlNNvIF/xunXrEBMTIzW/FBUVYciQIZg8eTJ27tyJ58+fw9/fX5oqYPXq1QgKCsIHH3yAwsJCaQH2sLAw9OvXD7du3dKf+SUqKkrlsZ06dVL39BSK4WAGcD1VU0rMLQbMcwPROLqC4UHQ1l+lofyUm2AzHlY80AD5/PPPkaOgUlF6ejoAIDAgAADw5s0brPvuO/z4ww8QFBfD0dER48aNAwA0a9YMdevWla5kTExMkJqaiuzsbDg4OKBDB8WrSm2gtlIPCgqSeS/JbyBJC/B+ncn4+HhNZKNQKJQq5+WLNLx7m4W6FrIPdIt/dRuX/UzcwAnB5b1CRn4uikUEb9/mwNvbWzr+/cyxq1evxrZt2zBkyBA0bNgQ06ZNk6Yw0TZqK/WHD/97+t64cQPbtm3D7NmzpbnKY2NjsXXrVkyfPl17UtZwUlNTFdY4lKT2vH9fcYJpV1dXORc+CqUqYIrzARBMnzZNLqHVq9evAQAf/9//ybQXFRYBusnarTYuNiJ83Smv3DFBrzhMbFWIo89M8TyXg62trUzRl9jYWABi+zsg3tTt0aMHMjIyMHPmTISFhaFBgwZal11tpf7+f9DatWvx7bffon379tK2nj17wsbGBgsXLkTfvn21I2UNJywsDBcuXFDaP3Om4hzT+/fvl6kfS6FUGYQDwECY+xJmprL2dqt/i1KY5P1XdaxExIAjVZfhVRvYmzN4mUdQImLA45kgKzsL165fg7ml2I4uLBECBLgdcxtFhUUwMzMDy2NRUlQCQgjy8/Ph4OAAlmWRmpqKli1bakUujTZK09PTZcwtEliWpUWddQDnwcnm1cz499/6suOYDAZMRsX23QMHDij0C87JyQEA7N27V6b9zp07SEpKlJt5FReLg34GDpRdThJCwHEEkyZNgpOTk0yfpLatoqRv9evX19oNTtEvY1sUoXv90grHpeTxsOS2TYXjDImP2/CwI1qIzKIsWFhawm6EHQruFSAvIw/gAJ49D5ZdLcE6syj+vRh5qXkgQgIenwc7OztYWFjAwsIC06ZNQ3BwMEpLS7Fp0yb07NlTI7k0Uuq9evVCSEgIvvzyS7Rr1w4MwyA2NhZr1qzR2C2nuhAXF4eLFy8q7JNskqxfv16ur3///monzyfNiez/mDK9VwrVlPrBgygpLlbar6x4QEvrIpi893B5xYjf1LMskRn3Ip9FVikPO3fuVHoNSamv9xkxYgS++OKLciSn1FjMAfbCvzdfKcCCha2trdLhHMdJI43LUlwivvfN+P95nfAseCgsKkIt04pF6e7MorszH7MibJFdzIPIVgTboYplsRnw3wOLSWbARv/3A5o1axZmzZpV8QVVRCOlvnbtWqxevRqfffaZTI3SYcOGYcmSJVoR0NBJTU3FmTNnyh2jqD89PR137tyRaZPYzX/88UeZdlVK51UKAohs6qO4iWrFny0engHDleLzdgWw5VecL//gYwtcfM4D58wBDct0pv377/sTeCHAxlSvJTilauH6/Ld5yf7OooFlAxw5ckTp+Dt37lQ4QSgW/DexKX5XDAYEsNNcVn2hkVK3trbG2rVr8eWXXyItLQ2EEDg7O8Pa2lpb8lUbBC18IbIrq7nk4eVmwPzJZURFRSl1Dw0LC9O2eEohPFMQy1qqDVZgalMJe4A0LvMQUFSbpARATOUuQTEOmFwGEACoKKC3AGDzWZg7qBb5W9KoA4QOTSocxxYXwCJR8cq7uqCV4KNXr14hLU089TIzM6uRSh08U8Ck4huM8MTruva1SxHkVqjSqZfcskUJZ+Q+0FWAUMXvsFTNeJmaAwGEJYAJv4JhBBDJ79VUBAMGKAbYKBZcD055oBEHsLdZECHBjBkzVDo34VuBWNaucBzHilUip0bhNmIoEVH/opFSz8rKwsKFCxEZGSm1a+Xl5cHHxwffffcdTcNbDuYmBPUtVQxs0bEsxg5heQAIzqeaw47PYVDjYqWLjleFLLbG1cBJSQXUrVsXIBys4k5A0MQHolqKy0AyJYXgP4uESV56pa7TokULPHnyBMxTBqSFYs3KxDNgshiMHTtWJwGOBMA/70zwMNsEbRzKfzg9yjbB22LDMhlqpNSXL1+O/Px8hIeHS13nnj59iiVLlmD58uXYsmWLVoSkUBSi6mzKxAwAAwdHRxxKykRirgkmty6AZZm7P/q1KX6Mt0aRkEGdOo54U/BG2xJXW+bOnQtXV1ds374dSLwEzsQcMC2zMuU4sKUFACdCu3btEBcXp/Z1unfvjoKCAqTHpkNURyRv284E2HgWzZs3x5QpUyr/gcqFgYmJCXY8sMaqzrlwMFd8o70tZrD9gTVMTHgQCkUKx+gDjR4xERERWLZsmYwvdPPmzfHVV19pvT4ppfrB/Kt12RRWmjNFKRzAPBJPnxW5ySrC4vF5mKb9DaZYcZAIm/cK/H8iYJYmTkQ1a9Zs2NvbI+o1HzOv22PRTVvpa/YNW2yOtUZhKTB58mTUqVNeApKaB8Mw8PDwgLOzs/g9UbTKJAAnAp/PR4sWLQAA6QU8BePkSS8UqyI+n485c+YAIoD9gwV7sczrTxYg4oAePr8CM5AGdO3WDe9KxEpbqOCjCjlgW5w13pUw6N79X0eDij031RtXSTSaqZuamkr9jd+nqKjIKDPVUcQUChmVvF/4kt9zHmDyhwmEXYWAonoqxQB7iwXzWqw4Jk6cWO55hw8fjufPn+PGjRvgv/gbphkPQPiWcuPYkgJAVAqWZTHyww/x0/79yMnJQV0LEdgyzw1zHmBvxuFdCYu9//sfHOvUATiIXypMfRiRcRvJzp49i82bN6O0tBSl9VqjxLmTeB+pDLy3KcCzSPz6668wNzPDr8mAgzmHvo0UuxUCwIMsE/zwyBo21tbo3r37f95finQ2H4AAOHXqFFq3bq2dD6cAFxcXNGjQAMeOHcPnEXawK3O/55YwyC9lMXr0aPj4+OD69etgY1lwvcvZCwCAYoD3mAdrG2udxWJopHkHDBiAJUuWIDQ0VBpVeu/ePaxZswYDBw7UioAUHcIAJnmvwKXehrCOG4iFvfwYQsAWZMLkzWMwIvEUY+3ftpjrkYcmtoqXnIQA4almOPvMArY21vh85ixs3rwZhdcLQcwI8H4yOhHAFDIAAfz9/TFr1iyYmpbvJPznn3/ixo0b4mvxTEFYxbexyNwOPEEuOFEpTv5bYGFk0yKMbCaQU+oSUvJ42BpnjYwMcWQX+ycLrgsHyD8z/pP/LgPmJQMHBweVVxnVjcjISJSWClHUehg4m/pKx4lquaDApj7ME3+HIC8D9erVxf/iX4MF0FuBYn+UbYKNsTYwM7fCxk2bcP/+fdy6dQucCwfSWcHEgQDsDRYXLlxAly5d0K9fPy1+SlmaNGkCACjlGJS19ZX+u+netGlTeHl5wd/fH6dPny53LwAQ3ytEQDBnwRyd7TlqpNRDQ0OxZs0aTJs2TcZPfdSoUQgJCdGKgBTdMXHCBJw4cQJv0uPAT48DZ2YDsGWWy6JS8YwXgLOzMzw8PHDp4kWsjLHFZ63z0bme7FqyRATsTbDEjXQzNHFxwdpvvsHdu3fFASAM5Ge9zL8vIi5TV1JSUqFSl1TCEbTwhcihCcCUM5UWlYL/TwRMs//BoMYCfFhBPnUXGxGWeb/DtOv2aNy4MVJTU8G7wBM/jMr+WjiAKRV7bHh7e2Pp0qVy0bZGBcOWq9ClmJiBs6oNXl4GVqxYidDQL7E7HvjlH3OZfYwSEZAp4IHH42HDxo3g8/n4/vvvAWuAdFCiGBmA68TB5HcTrFu/Dm3atEH9+hXLxMt+BpGdE4hZOZvgolKYvBanxH379i2OHTuK2uYEqzrnwqbMTD2vhEHoHTts2bIZ7u7umDZtGm7duoVXca8gqi8CFF0mDWCfs+jZs6fCfPzaQiOlbmFhgZUrVyIkJATPnz8HIP7hW1kZSFYeA6ZIyICQil2/SzmxBUAXxMfHS6NewZpA8bqREStNwiHj1St0sbDAki+/xPLly7E1zhq1k0Qwf0+PZQsYFIlYODg4YMvWrdi/fz9+/fVXwAoQdRcB9gouIQKYaAaRkZH4bOpnWLtmrdR2Wx6cdZ3yFToA8ExBzMWeWeY81XZWzU3E49zc3ODq6orLly+L7aBlfy0iAMXie37NmjXS3NmU/0hLS0PO2xzweYBpmf8qHgvwGAKhSISEhAScO3cOpcJSiHqKxHnVlWEOCL2FKLxRiPXr1yuM2JbQrFkzdO7cGXfu3IFJ7C8oadAOpU5ecuN4WckwT70FlBTAyckJd27fhkhYipleeXIKHQBs+ASz2uVhRYwtli4Nxe7dexASEoK5c+eCvcTKJyYj4hWpjY0N5s2bp9MVnVYM31ZWVnB3d9fGqWoIBLFZpth43wqTWhXCzkyxsknJ42HXQyvpUk/b3Lx5C0ITS5Q07iJ2USs7S5cgKoVJdjJIyi1ERETg998vAQBsTTkZhQ4AdmYElpwIWdnZWLx4MR49egRSm4Dz4WTNLu/DA0hnAs6OQ2pcKo4ePWoQaQJiY2PFOYysAK4bB5SN0RKI9wKeP3+O6dOnY/ny5So9jGoS36xdCzNGiMVe7+BiI2+ue1vMYHWMLTZt2gQ+nw/iSICK3cmBBgCxI9L4GGU4Ojpi/fr12L59O44dOwb+i7swyU6WG8cW5wOcEO3atUOtWrVw/fp1BLsWooWdcq+W5nYiBLYsxE+P07Fu3TosX74c9vb2yMnJARER4P3fRikAEdC3b1/Urq3KB6w8WlHqT58+RXKy+Itq2rQpmjdvro3TVivMUv5C6bumENVuDs5K/j+NKcyGSdZTmGYnA2Dg7u6OuwkJmPsXH/Z8kdwspkgIvC3hgWFYtGzZHElJSWDiGJD2pPyNu5x/vU0gTqxWEZxlLYhqNy1/EM8UwjquME2LxutXr8DnAXM98uFVV/E2vpAD9iVY4s9HjwAApCFRrtAlMABpRgD1veB0xqtXr0AaEXDenOJNO3OA682BecTgyaMnmDRpEsLDw43bBKMqpQIABKaMEIs6KFboAFDLjGBJx3dY/bctMgqL1QvKUGFsZmYmvv32W9y+fRtgeRBa1QErlDfBcSZmYEUM4uLiwOOxcLYWYoCz8rxIEvo7FePaCz5iY+/jt99+Q05ODrgmHEinMhM1EcD7g4dz4ecwcuRInepIGnykIR4eHtKACX56LPDqETgFdjuJJwYgfvAFBgZi48aNyM7OhpCTX5qWcGLzTOvWrbBw4UJs3LgR9+/fB8kh4lmjgpU+kypOFMRjeJgzfw5cXFy0+lkZkRAEwOKO78qdwZiwwKRWhSgUAlGvtVOiSy+Y/ztDL095MABpQ8C941CUViQtFlPTYUQlABh83KIATZVsqEtwMCf4rHU+lkdrP0vjP//8g9u3b0No54SSpj0qtKmbvrgLs/T7MOeplhWDYQAzE4IcESf24bcEiKeCe4AHiDqJgD+A1WtW48cfftSZhyANPtKQ2NhYPHnyBABATMxAlNh4OdYEDMOCERYjOTkZX3/1FQjhMLJpEfybCmSyHgJAfimD/QmWuPXwIWbPmoX5X3yBZs2a4ddffwXvNx6IZZnlnQBgShjY2tnim7XfoG3btjr6xJCrCKMIhgEclJiVqg2STVxVx1LkMFNxH8NMx4sbkUOT8hU6IF6R1nWDWfp95JYwuP5SdnkW80Zs6PeqI7tCzS1mUVBaAKFQWP5+QC2Ac+fwJP4JwsLCMH78+Ep+mvLRSKlHRETg8OHDCoOPypa9M3aKm/WC0LFl+Y93QsDLfALzf/6EHV+I2e3y0VzJjNfalODzdgXwqlOCHx+J0+BKlvWEp8AEwwPAAnnv8nD//n20adPGaN3rKBRdQgC8LuLhx0eKHT5i3iiyxQnBNeXkahvInbs1AV4CP/30E3x9fdG4seJ0C5pAg4+0BDG1qHi9xjAgpmKH55Z2QqUK/X261S9FWCKH5GfJ4EQcuFYcSBuieGaYB/D+4uGHH35AQkICvvzyS8PwyCBQLaS/mk/sjR4igtmTqzJNTGG2uMtS1tTKFmRV7hrvAOZOmZs7599/7cuMLYDy+AGNYNC3b18MGjRIpnXDhg0AgPnz58u0X7x4EVeuXAFxVeEGZgGuBQfEiNNvG5xSp8FHVYNAyIDjOIi6iWTzj5fFBhD1E4GNZPHnn39iwoQJFZazY0oFYHPL9yCQ8q+9eG+8pcwewPN88Rtna1mzTPK7fzdsH7DAA9UuoUvi35rgl6eyD7mEt+KfgHut/xI3iejDRSkmWU8VdxTJ19AFgN2PrLEvQbZNkgXz/XtInBWRgBEwYFKUTI5y5ZtUXY0ypUVginIqHMcWi/NZNGrUCN26dZPps7QUP0HKtj948O/NbSCmOhp8VJ1QIe4DJgCpQ8C8VmWXB+AVvIFFgvL6p4qIVrj8BNIKFI/38vKSq05z+/ZtAECXLl3kxru5uakkB1uQCVKiQvrif4OnHueY4nGOYoNngpJ2iphVq1YpbJ8wYQIAYN++fTLtDx48wIEDBxQqXUmir7bt2sm0i0Qi+Pn5ydU2njx5MgBg9+7dcudSVanz02LAT6sZyfo1UupJSUlYtmwZDT6qpsyZPRscJ7/pKfmBSn6wEjiOg5WVldyPTtkPWwKPx5P78QUHBwMQb7ZXFvOkP9QaHxISIpcvZPHixQDEVbzeZ/ny5Xia+hRMYhmloawubF7V7l9ERkZi8+bNmDNnjkwFe12hzJwq+X8t2+/p6am0XKPk/15izqgIyTUqY0ps0KCB0v09SUWyESNGyPVJLA/qwCQysq67kiSfZXLDMTm6vVc0UuqffPIJAPEX4O3tDS8vL6270VF0x7BhwxS2//LLLwAU3+yKUPbD1hXdunVT6C57+PBhAMC4ceMUHufl5YV69WQziklSEkjyfEgwMzMT18C8r8TXX8t11ffs2YOwsDAEBgZi0qRJ5Y4VCARYuXIlCgsLsXLlSpw6dUolhVfVDwJDwNnZWTrTL4skk6yyfnVh/1Fyr2Rq5fQqo9GvMCoqCg8fPkRMTAyio6MRFhaG/Px8tG7dGl5eXli0aJG25DR4TDKfgM1/LdPGvhMXCuBsG0jbGIE4b0luMYMHWap9/ZK5NPOckXVjVFTnE7qfCegbZbPAc+fOAQA++ugjja+xYMECFBUVybWvXLkSALB06VKFx1Um8CgnJwcHDhwAABw4cACjR4+Gvb290vF79uyROigUFhbif//7X4UVgAQCAVasWIGioiKsWLECp0+fNoxNdCNgzJgxCvcQJSbob775RuFxukrvrJFSNzExQfv27dG+fXsEBwfj3r17OHr0KMLDwxEXF2dwSv3du3dYunQprl+/Dmtra0ydOhUBAQFaObfSDSQAyMuQa0rIMcU3d9Wz475fgVyGF2qdhqICyiL+zMzE62ttxgGUvQcDAgKkD6iypKWl4dixYzJtR48ehb+/P5yclO+i79mzR/qQKioqUulBQFENe3t7hQ9hySqwqq0XGin1v/76C9HR0YiKisKDBw/g4uICLy8vrFu3Dt7e3tqSUWusWLECIpEIERERSE1NxYQJE9C8eXN07dq10ufs1asX2rRpo7BPkb2WEIKrV6/C2tpaqiAk7N+/HwDkghIyMjIgEonkrrN161YAwKxZsxRev27duip/Dop+iI6ORl6ebJGPvLw8REdHy/2GCCFK9yCWL1+OH3/8UeHGYWUfBJTqiUZKfeLEiahVqxaCg4Oxfft22NmVrT1lOBQWFuLChQs4deoUrK2t0bp1a4wcORInTpzQSKnb2NjAxkZxeLMye23TpopzrZz4N+e3v7+/Stf+3//+BwDo3bu3SuO1yf79+3HlyhUAwIsX4qWCZAPM19dXZ9FyxgTHcZg3b57Cvnnz5uHatWsy+XuePXuGx48fKxz/+PFjPHv2TO7eIoRg2bJlCo9ZtmwZdu/ebZBBavT+qjwM0SBZxa+//oqYmBhERUUhOzsbHTp0gLe3N7y9veHh4WFQAUiPHj3C2LFj//Mphbh6yv79+3Hq1CmVz1NYWIj4+Hi4urpK/VYl/PTTT7h69b/gjJcvXwIAGjZsCECcoU2yuazoGF2MVxVN5MjNFTsQSx7qyuTQheyVOae+v/Oy3kOGwPv3bUVU9Xeu6v2lC9mr8l7RViI4jbTuyJEjMXLkSADi5F5RUVG4fPkytm7dClNTU9y9e1crQmqDwsJCOVdLW1tbFBQoca6ugMTERLm2jIwMCAT/ZYCTmFckbRkZGbh3757SY3QxXlXUPa9kL0UZiuTQheyVOaehfOeGhDqfoSq+88rcX7qQvSrvFS8v+TzvlUGjmToAvH79GtHR0VLb+pMnT1C3bl14e3ur7IdaFSiaqZ8+fRr79u3T2kydQlGHly9flrtRf+jQIeksDxAH5wwcOFBhbAHLsrh06ZLcbI/jOPj7+yM/X77yt7W1NU6fPq1SimaK7jGImXr//v3x4sULNGnSBN7e3vj000/h7e1tkJsvErv206dPpZ4NCQkJlS7+yuPxaN5sikZUVFCjbD+Px8OiRYvkAqUAYMmSJeDz5SN9eTweVqxYodB2v2rVqgpLB1KqHxo9ohctWoTIyEicP38eK1euxAcffGCQCh0Q523w8/PDli1bkJ+fj4SEBJw8eRKjRo3St2iUGsz169fVah88eLBc5RxHR8dycy15e3vLeU61bdsWHTt2VFNaSnVAI6U+YMCAalUI4+uvvwYA9OzZE5MmTcKsWbPkkvNQKFVNz549y31fll27dsm837lzZ4XXWLt2rdTLhWEYrFmzRk0pKdWFGmVMs7W1xdatW3H37l3cuHFDa4FHFIomrF69utz3ZalXr57Ug6Zv375yqQ8UYW9vj6CgILAsi6CgoHIjVinVG403Smsako3SVq1a0Y1SCoVicBiOI3k1QeJ5oCgvCIVCoWiCubm5xt5IVKmrSXGxuML4s2fP9CsIhUIxOrRhAaDmFzURCoXIzc2FmZkZ9e+lUChaRRszdarUKRQKxYigU00KhUIxIqhSp1AoFCOCKnUKhUIxIqhSp1AoFCOCKnUKhUIxIqhSp1AoFCOCKnUKhUIxIqhSp1AoFCOCKnUKhUIxIqhSp1AoFCOCKvUqIiwsDKNGjULbtm0xd+5cmb7ExESMHTsW7du3x7BhwxAdHS3te/36NaZOnYoePXrAzc0NT58+rRZyA8CFCxfQr18/eHp6YuLEiXj16lVVil4h7969w+zZs9GhQwf07NkThw4d0rdIRsfSpUvRs2dPdOzYEb6+vtICHwUFBQgICECXLl3QsWNH+Pv74/Lly3qWVpaLFy9i2LBh8PT0RN++fXHp0iUA4kyt27dvR+/evdGhQwcMHToUqampepb2PQilSrh48SL5/fffyfLly8mcOXOk7SUlJcTX15f88MMPpLi4mJw6dYp06tSJ5OTkEEIIefPmDQkLCyP3798nrq6u5MmTJ9VC7idPnhBPT08SGRlJioqKyLJly0hAQECVyl4R8+fPJzNmzCB5eXnk4cOHpHPnzuTmzZv6FsuoSEpKIkVFRYQQQl6+fEkGDx5MwsPDSUlJCUlKSiJCoZAQQkhMTAzx9PQkGRkZ+hRXyl9//UV69epFoqKiiEgkIpmZmSQ1NZUQQsjWrVtJQEAASU1NJRzHkX/++Ud63xsCdKZeRQwcOBD9+/dHrVq1ZNrv3LkDgUCASZMmgc/nw9/fH05OTtJZgaOjIwICAuDh4aEPsSst95kzZ9CrVy90794d5ubmmD17Nu7evWswM5rCwkJcuHABc+bMgbW1NVq3bo2RI0fixIkT+hYNvr6+2Lt3L0aNGoWOHTti8uTJyM3NBQDMmzcPPXr0gJeXF8aNG4fHjx9LjwsJCcGyZcvw+eefo0OHDhg+fDji4+P19TEAAC1atIC5ubn0PcuySElJgampKVq0aAEejwdCCFiWhVAoxIsXL/Qo7X9s3boVM2bMgLe3N1iWRe3ateHs7Ix3795h7969WLVqFZydncEwDJo2bQo7Ozt9iyyFKnU9k5SUBFdXV5l0m+7u7khKStKjVBVTkdyJiYlwd3eX9tnb26NBgwZITEysclkVIcmH36JFC2mbIX3vv/32G3bs2IGIiAjk5eVh//79AAAfHx9cuHABN2/eRJs2bTB//nyZ486dO4eJEyciOjoaXbt2xapVq/QgvSwbNmyAp6cn+vTpg8LCQowYMULaN27cOLRr1w4fffQRvL290b59ez1KKkYkEiEuLg5v377FgAED0KNHDyxatAi5ublITEwEj8fDpUuX4OPjg/79+2PHjh0gBpTslip1PVNQUAAbGxuZNltbWxQUFOhJItWoSO7CwkKD/lyFhYWwsrKSaTMk+QIDA9GgQQNYWVnBz88Pjx49AgB8+OGHsLa2Bp/Px8yZM5GUlIS3b99Kj+vXrx86duwIHo+HDz74QHqcPpk/fz7u3r2L48ePY/jw4bC1tZX2HT58GH///Td27NiBXr16gcfj6VFSMZmZmSgtLcX58+dx8OBBhIeHIzs7G2vWrEF6ejry8vLw9OlT/P7779i9ezdOnDiBkydP6ltsKVSp6xkrKyvk5+fLtOXl5ckpHEOjIrktLS0N+nNZWlrKKXBDks/R0VH6t7m5OQoLCyESibB+/Xr0799fuvEIQEapKzrOEGAYBh4eHuDz+di+fbtMH5/PR//+/XHt2jVcuXJFTxL+h4WFBQAgICAA9evXh62tLaZOnYo///xT2jdjxgxYWlqiadOmGDNmDP788099iiwDVep6pmXLlkhMTJTWPgWA+Ph4tGzZUo9SVUxFcru6uiIhIUHal5ubi/T0dLi6ula5rIpo0qQJAMh4EyUkJBj093727Fn8/vvv2LdvH2JiYnD16lUAMKilf0WIRCKkpKQo7TOEPRdbW1s0aNAADMPI9bm5uQGAwj5DgSr1KkIoFKK4uBhCoRAcx6G4uBilpaXo3Lkz+Hw+9u7di5KSEpw9exZpaWkYMGCA9Nji4mJpbdTS0lIUFxdX2Q+5snKPGDEC169fx82bNyEQCLB161Z4enqicePGVSJ3RVhaWsLPzw9btmxBfn4+EhIScPLkSYwaNUrfoimloKAAfD4f9vb2EAgE2Lx5s75FKpe8vDycOnUK+fn54DgOMTEx+Pnnn9G9e3c8fPgQt27dQklJCUpKSnD8+HHcu3cPnTt31rfYAIDRo0fj0KFDePPmDfLz87F79274+vrC2dkZXbp0wffff4/i4mI8f/4cx48fl66aDAL9Ot/UHLZu3UpcXV1lXosWLSKEEJKQkEBGjx5N2rVrR4YMGULu3Lkjc2zZ41xdXcnz588NXu7w8HDi6+tLPDw8yIQJEwzGXU1Cbm4umTlzJvH09CQ+Pj4kLCxM3yIRQgjp27cv+fPPP6XvDx8+TAIDA0l+fj6ZOnUq8fT0JH369CG//vqrjJvrokWLyLp166THPXnyhLi6ula5/BLy8vJIcHAw8fb2Jp6ensTPz4/88MMPhOM4cu/ePTJy5Eji6elJvL29yZgxY8jly5f1JmtZSktLycqVK0mnTp1I165dSUhICMnLyyOEEPLq1SsyZcoU4unpSXr37k1++OEHPUsrC61RSqFQKEYENb9QKBSKEUGVOoVCoRgRVKlTKBSKEUGVOoVCoRgRVKlTKBSKEUGVOoVCoRgRVKlTKBSKEUGVOqVGEhQUhE2bNknfx8bGYvjw4WjTpg1CQkK0eq0TJ06gd+/ecHd3r9LET7dv34abmxuEQmGVXZOif2jwEaVGkpOTA1NTU2kCr+DgYNSrVw/z58+HlZWVXIbJylJSUgJvb28sXLgQAwcOhK2trUx+cW2xadMm/P333zh48KDMtXNzc1GnTh2tX49iuJjoWwAKRR/Y29vLvE9LS4O/vz/q169fqfNxHAeO42BiIvuTev36NYqLi9G7d2/UrVtX4bElJSXg8/mVum558Pl8qtBrINT8QjFIhEIhtmzZgj59+qBdu3YYPHiwTFrWU6dOYcCAAWjbti2GDx8uk/pUYna4efMmhgwZgg4dOmD69OnS6kGArPnFzc0NL168wJIlS+Dm5iY1kfz1118YNWoUPDw84OfnJ1PDNC0tDW5ubjh//jw+/PBDeHh4yBUAuX37Nvr16wcA6N+/P9zc3JCWloaQkBDMnz8f3333Hbp06YLZs2cDAFavXo1+/fqhffv2GDp0KMLDw2XOV1hYiBUrVsDHxwceHh4YOXIk7t+/j5MnT2LXrl24c+cO3NzcpNdRZH7Zs2cPevfujbZt22Ls2LGIjY2V9p08eRK9evXChQsX4OvrC29vbyxevBglJSWV+0+k6Af9pp6hUBSzceNG4uPjQy5evEhSUlLItWvXpEmuYmJiSKtWrchPP/1Enj59SjZv3kzatGkjTXJ269Yt4urqSgIDA8n9+/dJbGws6devH1m7dq30/IGBgWTjxo2EEEJev35NfHx8yP79+8nr169JUVERefr0KenQoQM5duwYSU1NJVeuXCFdu3Yl586dI4QQ8vz5c+Lq6koGDRpEIiIiyLNnz8i7d+9kPkNxcTG5e/cucXV1Jffv3yevX78mQqGQLFq0iHh6epKVK1eSp0+fkuTkZEIIIdu3byf3798nqamp5PDhw6RNmzYkISFBer558+YRPz8/EhERQVJSUsiFCxfI33//TYqKisjq1avJRx99RF6/fi29juR7KC0tJYQQcubMGdK+fXty+vRp8uTJExIaGko6d+4sTVR14sQJ0q5dO/LZZ5+RhIQEcvPmTdK5c2dy4MABHfwPU3QFNb9QDA6BQIC9e/di3bp1GDhwIADIpOw9ePAgBgwYgODgYADA7NmzERkZiUOHDmHRokXScQsWLJDWdh0zZgwuXryo8Hp16tQBy7KwsbGRmit2796Njz76CGPGjAEAODs745NPPsGxY8cwZMgQ6bFTpkxBjx49FJ6Xz+dLa7s6ODjImEJq166NJUuWyJQDnDFjhvTvjz/+GJcvX8bvv/8ONzc3PH/+HL/99ht++eUXtGvXTu47sbCwgKmpabnmloMHD2LcuHHScnJff/01rl+/jtOnTyMgIACA2BS0atUqabENPz8/REVFISgoSOl5KYYFVeoUgyMlJQUlJSVKc2snJyfD399fps3T0xPJyckybe8X5HB0dER2drbKMiQmJiIxMRFHjhyRtgmFQjm7eKtWrVQ+5/u4u7vLKHQA+PXXX3Hw4EG8ePFCmme8QYMGAMQ1YS0tLaUKvTIkJydj0qRJ0vcmJiZo27atzPfm4OAgUz3J0dFRppAIxfChSp1icJAKHLIq6pfw/qYlwzAyVZoqorCwEBMmTMCHH34o0162hqakvJm6lPWAiY6OxtKlS7FgwQJ07twZlpaWWLlypdQerupn1pSyG73qfm8U/UM3SikGR5MmTcDn83Hnzh2F/c2aNcO9e/dk2u7du4dmzZppTQZ3d3ckJyfDxcVF5uXk5KS1a7zP/fv30bx5c3zyySdo1aoVnJ2d8fz5c2m/q6srCgsLERcXp/B4U1NTiESicq/RtGlTme9NKBTiwYMHaNq0qVY+A8UwoDN1isFhbm6OiRMnYtWqVWBZFq1atUJKSgo4jkOvXr0QFBSEwMBAhIWFwcfHB2fOnMGjR4+wceNGrcnw6aef4v/+7/+wadMmDB8+HIQQxMXFoaioSGp/1iaNGzdGcnIyrl69ChcXFxw8eBBv3ryR9js7O2PYsGFYsGABli5disaNG+Px48dwdHSEp6cnGjZsiOTkZDx9+hS1atWSc9kExL74S5cuRatWrdC6dWvs378fAoFAzpRFqd5QpU4xSGbOnAkAWLFiBXJzc+Hs7IyFCxcCADp27Ig1a9Zgx44d+Oabb9C0aVPs2LFDq7Potm3bYt++fdi0aRP27dsHMzMzuLm5YfLkyVq7xvv0798fY8eOxcKFC8GyLMaMGYO+ffvKjFm5ciW+/fZbzJs3DwKBAM2bN8eyZcsAiDc0L168iNGjR6OwsBB//PGH3DWGDRuGjIwMrFu3DtnZ2WjdujV2794Na2trnXwmin6gEaUUCoViRFCbOoVCoRgRVKlTKBSKEUGVOoVCoRgRVKlTKBSKEUGVOoVCoRgRVKlTKBSKEUGVOoVCoRgRVKlTKBSKEUGVOoVCoRgRVKlTKBSKEUGVOoVCoRgRVKlTKBSKEfH/lcHMbePhSYwAAAAASUVORK5CYII=\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "figsize = (7.48031 / 2, 2.5)\n", + "g = sns.catplot(data=results[\"linear\"].reset_index(), y=\"V_ha\", x=\"C_qfrac\", kind=\"box\", hue=\"split\", notch=True)\n", + "g.set(xlabel=\"conifer fraction\")\n", + "g.set(ylabel=\"wood volume in m$^3$\\,ha$^{-1}$\")\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "fig.tight_layout()\n", + "#plt.savefig(\"figures/frac_volume.svg\")" + ] + }, + { + "cell_type": "markdown", + "id": "ccc0d723-cf0d-4457-9f4f-b09db3fef285", + "metadata": {}, + "source": [ + "## boxplot comparing MSENet and NFI over fractions" + ] + }, + { + "cell_type": "code", + "execution_count": 96, + "id": "4bdf76dc-0673-4dcb-b562-e8593aeeb653", + "metadata": {}, + "outputs": [], + "source": [ + "df_pred = results_corrected[\"MSENet14\"].query(\"split == 'test'\").reset_index().copy()\n", + "df_pred[\"method\"] = 'MSENet14'\n", + "df_pred[\"BMag_ha\"] = df_pred[\"BMag_ha_pred\"]\n", + "df_pred[\"V_ha\"] = df_pred[\"V_ha_pred\"]\n", + "df_pred2 = results_corrected[\"\\power{}\"].query(\"split == 'test'\").reset_index().copy()\n", + "df_pred2[\"method\"] = '\\power{}'\n", + "df_pred2[\"BMag_ha\"] = df_pred2[\"BMag_ha_pred\"]\n", + "df_pred2[\"V_ha\"] = df_pred2[\"V_ha_pred\"]\n", + "df_pred3 = results_corrected[\"MSENet50\"].query(\"split == 'test'\").reset_index().copy()\n", + "df_pred3[\"method\"] = 'MSENet50'\n", + "df_pred3[\"BMag_ha\"] = df_pred3[\"BMag_ha_pred\"]\n", + "df_pred3[\"V_ha\"] = df_pred3[\"V_ha_pred\"]\n", + "df_truth = results_corrected[\"MSENet14\"].query(\"split == 'test'\").reset_index().copy()\n", + "df_truth[\"method\"] = 'NFI'\n", + "\n", + "df = pd.concat([df_pred, df_pred2, \n", + " #df_pred3, \n", + " df_truth])\n", + "\n", + "\n", + "df = df.query(\"C_qfrac != 'nan'\")\n", + "df[\"C_qfrac\"] = df[\"C_qfrac\"].astype(int)" + ] + }, + { + "cell_type": "code", + "execution_count": 97, + "id": "9e9409de-2271-4bbc-8142-da028d2f8f0e", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "figsize = (7.48031 / 2, 2.5)\n", + "g = sns.catplot(data=df, y=\"V_ha\", x=\"C_qfrac\", kind=\"box\", hue=\"method\", notch=True)\n", + "g.set(xlabel=\"conifer fraction\")\n", + "g.set(ylabel=\"wood volume in m^3\\,ha^{-1}\")\n", + "#g.set(yscale=\"log\")\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "fig.tight_layout()\n", + "#plt.savefig(\"figures/frac_volume_comp.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 98, + "id": "44dd396a-8234-44ee-bc3f-aa8500f54882", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "figsize = (7.48031 / 2, 2.5)\n", + "g = sns.catplot(data=df, y=\"BMag_ha\", x=\"C_qfrac\", kind=\"box\", hue=\"method\", notch=True)\n", + "g.set(xlabel=\"conifer fraction\")\n", + "g.set(ylabel=\"AGB in \\,Mg\\,ha^{-1}\")\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "fig.tight_layout()\n", + "#plt.savefig(\"figures/frac_agb_comp.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 101, + "id": "00d6b498", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluate_species(name, results, split):\n", + " print(name)\n", + " columns = [\"method\", \"target\", \"C_qfrac\", \"value\", \"metric\", \"run\"]\n", + " \n", + " results = results.query(\"split == @split\")\n", + " \n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + " \n", + " results_df = []\n", + " for target in target_vars:\n", + " for run, result in results.groupby(\"run\"):\n", + " C_qfracs = set(result.C_qfrac)\n", + " mask = result[target] != 0\n", + " for qfrac in C_qfracs:\n", + " frac_mask = result.C_qfrac == qfrac\n", + " if frac_mask.sum() == 0:\n", + " continue\n", + " results_df.append(\n", + " pd.DataFrame(\n", + " [\n", + " [\n", + " name,\n", + " target,\n", + " qfrac,\n", + " r2_score(\n", + " result[frac_mask][target],\n", + " result[frac_mask][target+\"_pred\"],\n", + " ),\n", + " \"R2\",\n", + " run,\n", + " ]\n", + " ],\n", + " columns=columns,\n", + " )\n", + " )\n", + " results_df.append(\n", + " pd.DataFrame(\n", + " [\n", + " [\n", + " name,\n", + " target,\n", + " qfrac,\n", + " mean_squared_error(\n", + " result[frac_mask][target],\n", + " result[frac_mask][target+\"_pred\"],\n", + " squared=False,\n", + " ),\n", + " \"RMSE\",\n", + " run,\n", + " ]\n", + " ],\n", + " columns=columns,\n", + " )\n", + " )\n", + " results_df.append(\n", + " pd.DataFrame(\n", + " [\n", + " [\n", + " name,\n", + " target,\n", + " qfrac,\n", + " mean_absolute_percentage_error(\n", + " result[frac_mask & mask][target],\n", + " result[frac_mask & mask][target+\"_pred\"],\n", + " )\n", + " * 100,\n", + " \"MAPE\",\n", + " run,\n", + " ]\n", + " ],\n", + " columns=columns,\n", + " )\n", + " )\n", + " return pd.concat(results_df, axis=0)" + ] + }, + { + "cell_type": "code", + "execution_count": 102, + "id": "b1b7c010", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "linear\n", + "RF\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "KPConv\n", + "PointNet\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\power{}\n", + "MSENet14\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50\n", + "linear_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RF_treeval\n", + "\\power{}_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "KPConv_treeval\n", + "PointNet_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14_treeval\n", + "MSENet50_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "linear\n", + "RF\n", + "KPConv\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PointNet\n", + "\\power{}\n", + "MSENet14\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50\n", + "linear_treeval\n", + "RF_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\power{}_treeval\n", + "KPConv_treeval\n", + "PointNet_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14_treeval\n", + "MSENet50_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "linear\n", + "RF\n", + "KPConv\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PointNet\n", + "\\power{}\n", + "MSENet14\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50\n", + "linear_treeval\n", + "RF_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\power{}_treeval\n", + "KPConv_treeval\n", + "PointNet_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14_treeval\n", + "MSENet50_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:7: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_42898/276587871.py:8: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + } + ], + "source": [ + "result_scores_cfrac = {}\n", + "for split in splits:\n", + " result_score = []\n", + " for name in models.keys():\n", + " result_dict[name] = file\n", + "\n", + " scores = evaluate_species(name, results_corrected[name], split)\n", + " result_score.append(scores)\n", + " result_score = pd.concat(result_score, axis=0)\n", + " result_score = result_score.query(\"C_qfrac != 'nan'\")\n", + " result_score[\"C_qfrac\"] = result_score[\"C_qfrac\"].astype(int)\n", + " result_scores_cfrac[split] = result_score" + ] + }, + { + "cell_type": "code", + "execution_count": 103, + "id": "213f4777", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "train\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
value
metricMAPER2RMSE
targetC_qfracmethod
BMag_ha0KPConv374.62280.787955.9159
KPConv_treeval1222.24040.717663.8283
MSENet14357.06640.794755.0272
MSENet14_treeval322.72380.794055.1264
MSENet50474.15870.779457.1103
MSENet50_treeval469.87020.778557.2248
PointNet1075.72630.668070.0579
PointNet_treeval1120.39390.658871.0236
RF664.55060.687268.0115
RF_treeval7388.16750.372996.3007
\\power{}677.34190.656271.3079
\\power{}_treeval13318.93840.332199.3840
linear1062.98820.671869.6684
linear_treeval6325.31910.397994.3655
33KPConv49.87770.817544.4828
KPConv_treeval62.10220.761050.0162
MSENet1455.98610.837241.9658
MSENet14_treeval44.52790.834242.3487
MSENet5059.76460.831242.8003
MSENet50_treeval62.40840.829643.0044
PointNet86.05330.727854.3584
PointNet_treeval85.53420.711255.9890
RF91.75920.721654.9777
RF_treeval514.07590.400880.6557
\\power{}108.48410.718655.2698
\\power{}_treeval458.58980.255189.9315
linear70.69660.705156.5878
linear_treeval436.65320.414579.7319
66KPConv45.34800.821133.5294
KPConv_treeval57.34540.789436.0695
MSENet1448.70370.831832.5518
MSENet14_treeval43.71270.832232.5081
MSENet5050.45140.808734.7096
MSENet50_treeval53.05260.811234.4847
PointNet62.86210.726941.4939
PointNet_treeval73.11280.704943.1428
RF59.29320.725641.6017
RF_treeval223.13430.031178.1727
\\power{}66.54870.702843.2948
\\power{}_treeval332.0502-0.198486.9403
linear63.04170.710642.7254
linear_treeval173.38060.111974.8453
100KPConv45.45460.706240.8768
KPConv_treeval57.05340.606046.8419
MSENet1442.89860.733838.9256
MSENet14_treeval42.05400.730439.1778
MSENet5047.72780.710040.6361
MSENet50_treeval45.55460.706240.9045
PointNet68.29570.674943.0369
PointNet_treeval70.69300.661143.9330
RF68.21380.697341.5248
RF_treeval441.2907-0.069378.0501
\\power{}62.62620.658244.1299
\\power{}_treeval263.7134-0.315986.5819
linear104.01960.662743.8370
linear_treeval451.5604-0.094778.9698
101KPConv454.00000.765839.8251
KPConv_treeval1253.16820.706244.2901
MSENet14370.63720.782238.4141
MSENet14_treeval368.39950.778638.7274
MSENet50466.27300.768039.6512
MSENet50_treeval485.54970.765039.9078
PointNet1010.52660.712344.1623
PointNet_treeval1026.52410.711944.1935
RF1527.42710.710644.2926
RF_treeval11221.5671-0.220190.9475
\\power{}1177.91380.687746.0147
\\power{}_treeval13871.9134-0.417398.0225
linear962.35510.689045.9157
linear_treeval9970.9772-0.138487.8503
V_ha0KPConv113.02050.7696103.4888
KPConv_treeval166.44360.6926118.0473
MSENet14114.87620.7808100.9990
MSENet14_treeval103.01410.7800101.1862
MSENet50131.97200.7681104.0084
MSENet50_treeval138.00760.7677104.1027
PointNet241.23100.6621125.5487
PointNet_treeval255.36530.6548126.8861
RF208.12990.6807122.0570
RF_treeval1434.52080.3468174.5697
\\power{}224.72210.6418129.2763
\\power{}_treeval1862.14000.3082179.6580
linear208.68480.6591126.1115
linear_treeval1176.93930.3698171.4722
33KPConv43.09830.807884.2133
KPConv_treeval47.05350.732197.2151
MSENet1440.23640.833478.3611
MSENet14_treeval38.93540.829279.3392
MSENet5042.54840.821881.1590
MSENet50_treeval43.34030.818981.8220
PointNet56.94190.7241100.9738
PointNet_treeval56.55480.7054104.3490
RF57.71720.7195101.8241
RF_treeval301.75680.4133147.2648
\\power{}65.46930.7123103.1315
\\power{}_treeval271.88890.2750163.6982
linear57.80890.7056104.3137
linear_treeval254.97760.4311145.0114
66KPConv44.60530.824563.8559
KPConv_treeval52.97340.770772.0635
MSENet1448.16980.836761.6807
MSENet14_treeval44.03880.835261.9369
MSENet5049.99090.809566.6498
MSENet50_treeval52.67610.812266.1735
PointNet61.69270.729579.4505
PointNet_treeval70.70720.706782.7277
RF58.47280.731779.1334
RF_treeval222.06750.1077144.3070
\\power{}69.44770.709182.3981
\\power{}_treeval325.0404-0.0803158.7845
linear62.18680.716881.2927
linear_treeval170.15060.1699139.1890
100KPConv42.78370.752676.1588
KPConv_treeval51.16350.627891.5020
MSENet1439.47940.774272.7413
MSENet14_treeval38.66490.772672.9951
MSENet5043.31390.752376.2178
MSENet50_treeval42.92790.748276.8461
PointNet55.74470.717581.4207
PointNet_treeval57.87670.700383.8527
RF55.28710.734378.9587
RF_treeval330.74960.1102144.4909
\\power{}56.41690.707282.8810
\\power{}_treeval247.1476-0.0617157.8359
linear72.94860.703383.4383
linear_treeval310.62370.0860146.4426
101KPConv159.66860.785481.9686
KPConv_treeval207.46730.699895.6697
MSENet14138.93510.799779.2320
MSENet14_treeval126.60560.796879.7954
MSENet50162.20380.790881.0053
MSENet50_treeval166.68210.788981.3671
PointNet267.67940.744189.5783
PointNet_treeval275.87350.738690.5287
RF336.31780.742089.9442
RF_treeval2840.50920.0755170.2752
\\power{}285.95540.719193.8661
\\power{}_treeval2939.5035-0.0358180.2302
linear257.94840.721293.5128
linear_treeval2527.98750.1315165.0414
\n", + "
" + ], + "text/plain": [ + " value \n", + "metric MAPE R2 RMSE\n", + "target C_qfrac method \n", + "BMag_ha 0 KPConv 374.6228 0.7879 55.9159\n", + " KPConv_treeval 1222.2404 0.7176 63.8283\n", + " MSENet14 357.0664 0.7947 55.0272\n", + " MSENet14_treeval 322.7238 0.7940 55.1264\n", + " MSENet50 474.1587 0.7794 57.1103\n", + " MSENet50_treeval 469.8702 0.7785 57.2248\n", + " PointNet 1075.7263 0.6680 70.0579\n", + " PointNet_treeval 1120.3939 0.6588 71.0236\n", + " RF 664.5506 0.6872 68.0115\n", + " RF_treeval 7388.1675 0.3729 96.3007\n", + " \\power{} 677.3419 0.6562 71.3079\n", + " \\power{}_treeval 13318.9384 0.3321 99.3840\n", + " linear 1062.9882 0.6718 69.6684\n", + " linear_treeval 6325.3191 0.3979 94.3655\n", + " 33 KPConv 49.8777 0.8175 44.4828\n", + " KPConv_treeval 62.1022 0.7610 50.0162\n", + " MSENet14 55.9861 0.8372 41.9658\n", + " MSENet14_treeval 44.5279 0.8342 42.3487\n", + " MSENet50 59.7646 0.8312 42.8003\n", + " MSENet50_treeval 62.4084 0.8296 43.0044\n", + " PointNet 86.0533 0.7278 54.3584\n", + " PointNet_treeval 85.5342 0.7112 55.9890\n", + " RF 91.7592 0.7216 54.9777\n", + " RF_treeval 514.0759 0.4008 80.6557\n", + " \\power{} 108.4841 0.7186 55.2698\n", + " \\power{}_treeval 458.5898 0.2551 89.9315\n", + " linear 70.6966 0.7051 56.5878\n", + " linear_treeval 436.6532 0.4145 79.7319\n", + " 66 KPConv 45.3480 0.8211 33.5294\n", + " KPConv_treeval 57.3454 0.7894 36.0695\n", + " MSENet14 48.7037 0.8318 32.5518\n", + " MSENet14_treeval 43.7127 0.8322 32.5081\n", + " MSENet50 50.4514 0.8087 34.7096\n", + " MSENet50_treeval 53.0526 0.8112 34.4847\n", + " PointNet 62.8621 0.7269 41.4939\n", + " PointNet_treeval 73.1128 0.7049 43.1428\n", + " RF 59.2932 0.7256 41.6017\n", + " RF_treeval 223.1343 0.0311 78.1727\n", + " \\power{} 66.5487 0.7028 43.2948\n", + " \\power{}_treeval 332.0502 -0.1984 86.9403\n", + " linear 63.0417 0.7106 42.7254\n", + " linear_treeval 173.3806 0.1119 74.8453\n", + " 100 KPConv 45.4546 0.7062 40.8768\n", + " KPConv_treeval 57.0534 0.6060 46.8419\n", + " MSENet14 42.8986 0.7338 38.9256\n", + " MSENet14_treeval 42.0540 0.7304 39.1778\n", + " MSENet50 47.7278 0.7100 40.6361\n", + " MSENet50_treeval 45.5546 0.7062 40.9045\n", + " PointNet 68.2957 0.6749 43.0369\n", + " PointNet_treeval 70.6930 0.6611 43.9330\n", + " RF 68.2138 0.6973 41.5248\n", + " RF_treeval 441.2907 -0.0693 78.0501\n", + " \\power{} 62.6262 0.6582 44.1299\n", + " \\power{}_treeval 263.7134 -0.3159 86.5819\n", + " linear 104.0196 0.6627 43.8370\n", + " linear_treeval 451.5604 -0.0947 78.9698\n", + " 101 KPConv 454.0000 0.7658 39.8251\n", + " KPConv_treeval 1253.1682 0.7062 44.2901\n", + " MSENet14 370.6372 0.7822 38.4141\n", + " MSENet14_treeval 368.3995 0.7786 38.7274\n", + " MSENet50 466.2730 0.7680 39.6512\n", + " MSENet50_treeval 485.5497 0.7650 39.9078\n", + " PointNet 1010.5266 0.7123 44.1623\n", + " PointNet_treeval 1026.5241 0.7119 44.1935\n", + " RF 1527.4271 0.7106 44.2926\n", + " RF_treeval 11221.5671 -0.2201 90.9475\n", + " \\power{} 1177.9138 0.6877 46.0147\n", + " \\power{}_treeval 13871.9134 -0.4173 98.0225\n", + " linear 962.3551 0.6890 45.9157\n", + " linear_treeval 9970.9772 -0.1384 87.8503\n", + "V_ha 0 KPConv 113.0205 0.7696 103.4888\n", + " KPConv_treeval 166.4436 0.6926 118.0473\n", + " MSENet14 114.8762 0.7808 100.9990\n", + " MSENet14_treeval 103.0141 0.7800 101.1862\n", + " MSENet50 131.9720 0.7681 104.0084\n", + " MSENet50_treeval 138.0076 0.7677 104.1027\n", + " PointNet 241.2310 0.6621 125.5487\n", + " PointNet_treeval 255.3653 0.6548 126.8861\n", + " RF 208.1299 0.6807 122.0570\n", + " RF_treeval 1434.5208 0.3468 174.5697\n", + " \\power{} 224.7221 0.6418 129.2763\n", + " \\power{}_treeval 1862.1400 0.3082 179.6580\n", + " linear 208.6848 0.6591 126.1115\n", + " linear_treeval 1176.9393 0.3698 171.4722\n", + " 33 KPConv 43.0983 0.8078 84.2133\n", + " KPConv_treeval 47.0535 0.7321 97.2151\n", + " MSENet14 40.2364 0.8334 78.3611\n", + " MSENet14_treeval 38.9354 0.8292 79.3392\n", + " MSENet50 42.5484 0.8218 81.1590\n", + " MSENet50_treeval 43.3403 0.8189 81.8220\n", + " PointNet 56.9419 0.7241 100.9738\n", + " PointNet_treeval 56.5548 0.7054 104.3490\n", + " RF 57.7172 0.7195 101.8241\n", + " RF_treeval 301.7568 0.4133 147.2648\n", + " \\power{} 65.4693 0.7123 103.1315\n", + " \\power{}_treeval 271.8889 0.2750 163.6982\n", + " linear 57.8089 0.7056 104.3137\n", + " linear_treeval 254.9776 0.4311 145.0114\n", + " 66 KPConv 44.6053 0.8245 63.8559\n", + " KPConv_treeval 52.9734 0.7707 72.0635\n", + " MSENet14 48.1698 0.8367 61.6807\n", + " MSENet14_treeval 44.0388 0.8352 61.9369\n", + " MSENet50 49.9909 0.8095 66.6498\n", + " MSENet50_treeval 52.6761 0.8122 66.1735\n", + " PointNet 61.6927 0.7295 79.4505\n", + " PointNet_treeval 70.7072 0.7067 82.7277\n", + " RF 58.4728 0.7317 79.1334\n", + " RF_treeval 222.0675 0.1077 144.3070\n", + " \\power{} 69.4477 0.7091 82.3981\n", + " \\power{}_treeval 325.0404 -0.0803 158.7845\n", + " linear 62.1868 0.7168 81.2927\n", + " linear_treeval 170.1506 0.1699 139.1890\n", + " 100 KPConv 42.7837 0.7526 76.1588\n", + " KPConv_treeval 51.1635 0.6278 91.5020\n", + " MSENet14 39.4794 0.7742 72.7413\n", + " MSENet14_treeval 38.6649 0.7726 72.9951\n", + " MSENet50 43.3139 0.7523 76.2178\n", + " MSENet50_treeval 42.9279 0.7482 76.8461\n", + " PointNet 55.7447 0.7175 81.4207\n", + " PointNet_treeval 57.8767 0.7003 83.8527\n", + " RF 55.2871 0.7343 78.9587\n", + " RF_treeval 330.7496 0.1102 144.4909\n", + " \\power{} 56.4169 0.7072 82.8810\n", + " \\power{}_treeval 247.1476 -0.0617 157.8359\n", + " linear 72.9486 0.7033 83.4383\n", + " linear_treeval 310.6237 0.0860 146.4426\n", + " 101 KPConv 159.6686 0.7854 81.9686\n", + " KPConv_treeval 207.4673 0.6998 95.6697\n", + " MSENet14 138.9351 0.7997 79.2320\n", + " MSENet14_treeval 126.6056 0.7968 79.7954\n", + " MSENet50 162.2038 0.7908 81.0053\n", + " MSENet50_treeval 166.6821 0.7889 81.3671\n", + " PointNet 267.6794 0.7441 89.5783\n", + " PointNet_treeval 275.8735 0.7386 90.5287\n", + " RF 336.3178 0.7420 89.9442\n", + " RF_treeval 2840.5092 0.0755 170.2752\n", + " \\power{} 285.9554 0.7191 93.8661\n", + " \\power{}_treeval 2939.5035 -0.0358 180.2302\n", + " linear 257.9484 0.7212 93.5128\n", + " linear_treeval 2527.9875 0.1315 165.0414" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "val\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
value
metricMAPER2RMSE
targetC_qfracmethod
BMag_ha0KPConv804.78580.754763.8309
KPConv_treeval576.07410.694570.7592
MSENet14446.10040.770461.7558
MSENet14_treeval376.25800.765862.3754
MSENet50703.44580.762162.8652
MSENet50_treeval703.97740.758863.3022
PointNet1611.72130.651576.0939
PointNet_treeval1654.45820.639177.4405
RF924.32330.688771.9182
RF_treeval7470.63410.398699.9654
\\power{}727.66890.673973.6097
\\power{}_treeval3606.88540.3884100.8134
linear473.89770.668474.2345
linear_treeval7580.03550.406199.3420
33KPConv126.97580.751148.8300
KPConv_treeval167.20710.717851.8262
MSENet14101.74510.786345.2657
MSENet14_treeval100.90170.790244.8564
MSENet50114.53450.787045.2197
MSENet50_treeval112.17700.792544.6263
PointNet229.99330.718851.9652
PointNet_treeval229.08610.705853.1498
RF247.39210.832640.1019
RF_treeval623.29160.426874.2055
\\power{}347.82010.809442.7924
\\power{}_treeval2631.41210.375477.4635
linear284.10870.822241.3230
linear_treeval638.74690.426274.2462
66KPConv36.11280.739743.7282
KPConv_treeval36.27960.651050.2197
MSENet1432.10030.782640.0265
MSENet14_treeval29.38240.784139.8832
MSENet5034.06370.773440.8590
MSENet50_treeval32.98160.778240.4295
PointNet43.90190.664049.7441
PointNet_treeval46.65540.658050.1903
RF36.05970.685648.1408
RF_treeval238.08660.087981.9933
\\power{}39.26810.616753.1544
\\power{}_treeval296.8905-0.191193.7010
linear36.98820.697047.2561
linear_treeval196.34960.168278.3018
100KPConv33.71570.764936.3041
KPConv_treeval36.28520.704340.1052
MSENet1433.37020.781134.9987
MSENet14_treeval33.16100.783634.8044
MSENet5031.46890.798633.6059
MSENet50_treeval31.67370.791134.2236
PointNet39.21630.719739.6459
PointNet_treeval39.25980.715439.9497
RF29.04050.775335.5112
RF_treeval140.2106-0.151080.3693
\\power{}32.95680.721339.5450
\\power{}_treeval173.3453-0.532992.7506
linear31.47650.745137.8251
linear_treeval131.0684-0.124879.4501
101KPConv136.52590.810134.0605
KPConv_treeval191.47060.732039.7428
MSENet14113.48610.819233.2394
MSENet14_treeval119.71730.820133.1538
MSENet50159.19820.827132.5048
MSENet50_treeval155.81070.825532.6540
PointNet487.06190.695343.1507
PointNet_treeval503.46940.698342.9364
RF194.02990.804734.5516
RF_treeval1749.3710-0.075981.0880
\\power{}199.08290.788035.9955
\\power{}_treeval2158.5527-0.367991.4309
linear188.24960.777736.8614
linear_treeval1529.4261-0.091581.6741
V_ha0KPConv112.59980.7659107.4898
KPConv_treeval106.56250.6898122.3325
MSENet1488.62800.7852102.9869
MSENet14_treeval79.30600.7802104.1861
MSENet50101.70460.7801104.1924
MSENet50_treeval102.32820.7764105.0706
PointNet165.49210.6829125.1439
PointNet_treeval171.36410.6713127.4126
RF132.31150.7150118.6341
RF_treeval940.97030.3854174.2194
\\power{}128.20500.6978122.1618
\\power{}_treeval551.72760.3832174.5280
linear104.49050.6980122.1251
linear_treeval841.59270.3972172.5494
33KPConv67.53590.729591.1093
KPConv_treeval74.66540.697696.0043
MSENet1455.57070.761385.6213
MSENet14_treeval56.39180.760185.8523
MSENet5058.64200.766984.6641
MSENet50_treeval58.76090.768084.4736
PointNet83.72700.721392.5901
PointNet_treeval83.28170.712194.0927
RF77.53330.822373.9322
RF_treeval389.51310.4115134.5535
\\power{}95.18320.803677.7242
\\power{}_treeval612.40380.3542140.9528
linear85.44280.813175.8177
linear_treeval382.69350.4177133.8423
66KPConv33.30950.781478.8848
KPConv_treeval35.63300.662595.5652
MSENet1430.88570.818072.0372
MSENet14_treeval27.90520.815272.5972
MSENet5032.49760.821871.2757
MSENet50_treeval31.51160.826170.4043
PointNet41.68960.723088.8534
PointNet_treeval44.72740.711190.7499
RF33.82610.736386.7481
RF_treeval237.03720.2204149.1483
\\power{}38.62180.688994.2185
\\power{}_treeval311.77230.0201167.2185
linear33.24710.738886.3327
linear_treeval194.60520.2636144.9619
100KPConv35.96330.813467.1629
KPConv_treeval38.08930.772473.5215
MSENet1435.08680.817666.3357
MSENet14_treeval34.82840.820065.8931
MSENet5033.14570.839062.3983
MSENet50_treeval32.60390.834063.3564
PointNet43.14340.783972.2820
PointNet_treeval43.07360.777973.2763
RF30.18210.837962.6256
RF_treeval155.03550.1164146.2063
\\power{}38.13730.796570.1571
\\power{}_treeval192.1182-0.1183164.4848
linear34.21640.807768.2068
linear_treeval141.42970.1184146.0452
101KPConv112.14850.842069.4998
KPConv_treeval147.44710.756684.0405
MSENet14103.69560.843169.2719
MSENet14_treeval102.44610.842869.3301
MSENet50128.75110.842469.4285
MSENet50_treeval125.03590.840469.8583
PointNet466.02460.754886.5901
PointNet_treeval477.91830.751187.2450
RF146.52710.821573.8867
RF_treeval1021.14050.2328153.1810
\\power{}162.49110.803777.4920
\\power{}_treeval1388.84900.0801167.7326
linear141.35430.796878.8431
linear_treeval856.12780.2460151.8639
\n", + "
" + ], + "text/plain": [ + " value \n", + "metric MAPE R2 RMSE\n", + "target C_qfrac method \n", + "BMag_ha 0 KPConv 804.7858 0.7547 63.8309\n", + " KPConv_treeval 576.0741 0.6945 70.7592\n", + " MSENet14 446.1004 0.7704 61.7558\n", + " MSENet14_treeval 376.2580 0.7658 62.3754\n", + " MSENet50 703.4458 0.7621 62.8652\n", + " MSENet50_treeval 703.9774 0.7588 63.3022\n", + " PointNet 1611.7213 0.6515 76.0939\n", + " PointNet_treeval 1654.4582 0.6391 77.4405\n", + " RF 924.3233 0.6887 71.9182\n", + " RF_treeval 7470.6341 0.3986 99.9654\n", + " \\power{} 727.6689 0.6739 73.6097\n", + " \\power{}_treeval 3606.8854 0.3884 100.8134\n", + " linear 473.8977 0.6684 74.2345\n", + " linear_treeval 7580.0355 0.4061 99.3420\n", + " 33 KPConv 126.9758 0.7511 48.8300\n", + " KPConv_treeval 167.2071 0.7178 51.8262\n", + " MSENet14 101.7451 0.7863 45.2657\n", + " MSENet14_treeval 100.9017 0.7902 44.8564\n", + " MSENet50 114.5345 0.7870 45.2197\n", + " MSENet50_treeval 112.1770 0.7925 44.6263\n", + " PointNet 229.9933 0.7188 51.9652\n", + " PointNet_treeval 229.0861 0.7058 53.1498\n", + " RF 247.3921 0.8326 40.1019\n", + " RF_treeval 623.2916 0.4268 74.2055\n", + " \\power{} 347.8201 0.8094 42.7924\n", + " \\power{}_treeval 2631.4121 0.3754 77.4635\n", + " linear 284.1087 0.8222 41.3230\n", + " linear_treeval 638.7469 0.4262 74.2462\n", + " 66 KPConv 36.1128 0.7397 43.7282\n", + " KPConv_treeval 36.2796 0.6510 50.2197\n", + " MSENet14 32.1003 0.7826 40.0265\n", + " MSENet14_treeval 29.3824 0.7841 39.8832\n", + " MSENet50 34.0637 0.7734 40.8590\n", + " MSENet50_treeval 32.9816 0.7782 40.4295\n", + " PointNet 43.9019 0.6640 49.7441\n", + " PointNet_treeval 46.6554 0.6580 50.1903\n", + " RF 36.0597 0.6856 48.1408\n", + " RF_treeval 238.0866 0.0879 81.9933\n", + " \\power{} 39.2681 0.6167 53.1544\n", + " \\power{}_treeval 296.8905 -0.1911 93.7010\n", + " linear 36.9882 0.6970 47.2561\n", + " linear_treeval 196.3496 0.1682 78.3018\n", + " 100 KPConv 33.7157 0.7649 36.3041\n", + " KPConv_treeval 36.2852 0.7043 40.1052\n", + " MSENet14 33.3702 0.7811 34.9987\n", + " MSENet14_treeval 33.1610 0.7836 34.8044\n", + " MSENet50 31.4689 0.7986 33.6059\n", + " MSENet50_treeval 31.6737 0.7911 34.2236\n", + " PointNet 39.2163 0.7197 39.6459\n", + " PointNet_treeval 39.2598 0.7154 39.9497\n", + " RF 29.0405 0.7753 35.5112\n", + " RF_treeval 140.2106 -0.1510 80.3693\n", + " \\power{} 32.9568 0.7213 39.5450\n", + " \\power{}_treeval 173.3453 -0.5329 92.7506\n", + " linear 31.4765 0.7451 37.8251\n", + " linear_treeval 131.0684 -0.1248 79.4501\n", + " 101 KPConv 136.5259 0.8101 34.0605\n", + " KPConv_treeval 191.4706 0.7320 39.7428\n", + " MSENet14 113.4861 0.8192 33.2394\n", + " MSENet14_treeval 119.7173 0.8201 33.1538\n", + " MSENet50 159.1982 0.8271 32.5048\n", + " MSENet50_treeval 155.8107 0.8255 32.6540\n", + " PointNet 487.0619 0.6953 43.1507\n", + " PointNet_treeval 503.4694 0.6983 42.9364\n", + " RF 194.0299 0.8047 34.5516\n", + " RF_treeval 1749.3710 -0.0759 81.0880\n", + " \\power{} 199.0829 0.7880 35.9955\n", + " \\power{}_treeval 2158.5527 -0.3679 91.4309\n", + " linear 188.2496 0.7777 36.8614\n", + " linear_treeval 1529.4261 -0.0915 81.6741\n", + "V_ha 0 KPConv 112.5998 0.7659 107.4898\n", + " KPConv_treeval 106.5625 0.6898 122.3325\n", + " MSENet14 88.6280 0.7852 102.9869\n", + " MSENet14_treeval 79.3060 0.7802 104.1861\n", + " MSENet50 101.7046 0.7801 104.1924\n", + " MSENet50_treeval 102.3282 0.7764 105.0706\n", + " PointNet 165.4921 0.6829 125.1439\n", + " PointNet_treeval 171.3641 0.6713 127.4126\n", + " RF 132.3115 0.7150 118.6341\n", + " RF_treeval 940.9703 0.3854 174.2194\n", + " \\power{} 128.2050 0.6978 122.1618\n", + " \\power{}_treeval 551.7276 0.3832 174.5280\n", + " linear 104.4905 0.6980 122.1251\n", + " linear_treeval 841.5927 0.3972 172.5494\n", + " 33 KPConv 67.5359 0.7295 91.1093\n", + " KPConv_treeval 74.6654 0.6976 96.0043\n", + " MSENet14 55.5707 0.7613 85.6213\n", + " MSENet14_treeval 56.3918 0.7601 85.8523\n", + " MSENet50 58.6420 0.7669 84.6641\n", + " MSENet50_treeval 58.7609 0.7680 84.4736\n", + " PointNet 83.7270 0.7213 92.5901\n", + " PointNet_treeval 83.2817 0.7121 94.0927\n", + " RF 77.5333 0.8223 73.9322\n", + " RF_treeval 389.5131 0.4115 134.5535\n", + " \\power{} 95.1832 0.8036 77.7242\n", + " \\power{}_treeval 612.4038 0.3542 140.9528\n", + " linear 85.4428 0.8131 75.8177\n", + " linear_treeval 382.6935 0.4177 133.8423\n", + " 66 KPConv 33.3095 0.7814 78.8848\n", + " KPConv_treeval 35.6330 0.6625 95.5652\n", + " MSENet14 30.8857 0.8180 72.0372\n", + " MSENet14_treeval 27.9052 0.8152 72.5972\n", + " MSENet50 32.4976 0.8218 71.2757\n", + " MSENet50_treeval 31.5116 0.8261 70.4043\n", + " PointNet 41.6896 0.7230 88.8534\n", + " PointNet_treeval 44.7274 0.7111 90.7499\n", + " RF 33.8261 0.7363 86.7481\n", + " RF_treeval 237.0372 0.2204 149.1483\n", + " \\power{} 38.6218 0.6889 94.2185\n", + " \\power{}_treeval 311.7723 0.0201 167.2185\n", + " linear 33.2471 0.7388 86.3327\n", + " linear_treeval 194.6052 0.2636 144.9619\n", + " 100 KPConv 35.9633 0.8134 67.1629\n", + " KPConv_treeval 38.0893 0.7724 73.5215\n", + " MSENet14 35.0868 0.8176 66.3357\n", + " MSENet14_treeval 34.8284 0.8200 65.8931\n", + " MSENet50 33.1457 0.8390 62.3983\n", + " MSENet50_treeval 32.6039 0.8340 63.3564\n", + " PointNet 43.1434 0.7839 72.2820\n", + " PointNet_treeval 43.0736 0.7779 73.2763\n", + " RF 30.1821 0.8379 62.6256\n", + " RF_treeval 155.0355 0.1164 146.2063\n", + " \\power{} 38.1373 0.7965 70.1571\n", + " \\power{}_treeval 192.1182 -0.1183 164.4848\n", + " linear 34.2164 0.8077 68.2068\n", + " linear_treeval 141.4297 0.1184 146.0452\n", + " 101 KPConv 112.1485 0.8420 69.4998\n", + " KPConv_treeval 147.4471 0.7566 84.0405\n", + " MSENet14 103.6956 0.8431 69.2719\n", + " MSENet14_treeval 102.4461 0.8428 69.3301\n", + " MSENet50 128.7511 0.8424 69.4285\n", + " MSENet50_treeval 125.0359 0.8404 69.8583\n", + " PointNet 466.0246 0.7548 86.5901\n", + " PointNet_treeval 477.9183 0.7511 87.2450\n", + " RF 146.5271 0.8215 73.8867\n", + " RF_treeval 1021.1405 0.2328 153.1810\n", + " \\power{} 162.4911 0.8037 77.4920\n", + " \\power{}_treeval 1388.8490 0.0801 167.7326\n", + " linear 141.3543 0.7968 78.8431\n", + " linear_treeval 856.1278 0.2460 151.8639" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "test\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
value
metricMAPER2RMSE
targetC_qfracmethod
BMag_ha0KPConv113.73070.779555.5312
KPConv_treeval174.99030.711862.8864
MSENet14120.00680.818250.4434
MSENet14_treeval107.84850.818350.4226
MSENet50165.72270.806352.0664
MSENet50_treeval159.74680.806951.9793
PointNet280.62550.749459.2245
PointNet_treeval280.45030.747959.4101
RF525.01870.734760.9495
RF_treeval2006.29370.435388.9206
\\power{}567.69820.743959.8803
\\power{}_treeval3790.54110.2753100.7281
linear421.47090.748659.3251
linear_treeval2041.37960.450387.7271
33KPConv37.87530.754748.2156
KPConv_treeval69.68220.655656.5580
MSENet1436.02720.733250.2557
MSENet14_treeval36.80390.734650.1321
MSENet5035.46460.762247.4598
MSENet50_treeval33.30310.776446.0203
PointNet45.02470.645257.9907
PointNet_treeval47.26730.635258.8029
RF37.27800.685754.5893
RF_treeval441.16360.132390.6949
\\power{}38.47410.690654.1601
\\power{}_treeval405.36480.149889.7779
linear39.51120.690354.1861
linear_treeval418.76930.197987.2009
66KPConv60.08660.801834.8103
KPConv_treeval136.23170.739439.6866
MSENet1454.36850.824432.7850
MSENet14_treeval49.20670.818533.3328
MSENet5058.54160.839631.3105
MSENet50_treeval56.33700.836631.5929
PointNet96.71670.761038.2226
PointNet_treeval132.82000.786036.1788
RF85.28490.727940.8137
RF_treeval638.60870.015377.6468
\\power{}55.30330.722541.2167
\\power{}_treeval1246.9583-0.514696.2992
linear51.35840.736340.1812
linear_treeval537.63810.170571.2671
100KPConv61.02290.796634.6517
KPConv_treeval143.92630.681142.9693
MSENet1441.49860.828231.8774
MSENet14_treeval39.25390.826732.0152
MSENet5050.94610.831631.5574
MSENet50_treeval71.05170.820532.5835
PointNet85.39180.775436.4519
PointNet_treeval86.67780.767837.0703
RF93.11040.801834.2500
RF_treeval355.1950-0.105380.8893
\\power{}99.84290.810633.4873
\\power{}_treeval476.1943-0.203284.3959
linear94.46810.803934.0701
linear_treeval522.7593-0.088580.2702
101KPConv1241.28820.813236.3953
KPConv_treeval1122.74150.730442.9605
MSENet14698.14850.816736.0585
MSENet14_treeval551.30030.811436.5736
MSENet50947.83110.833534.3580
MSENet50_treeval839.55440.826335.0886
PointNet2308.18910.783739.1815
PointNet_treeval2406.12210.771340.2842
RF1340.63830.756741.5531
RF_treeval21985.7921-0.227193.3179
\\power{}333.95810.753241.8513
\\power{}_treeval19277.0374-0.267694.8449
linear750.19620.750342.0953
linear_treeval35514.9320-0.159790.7198
V_ha0KPConv94.08200.7593102.4256
KPConv_treeval115.89260.6788116.9080
MSENet14106.01730.801593.0178
MSENet14_treeval93.62080.802192.8621
MSENet50126.55500.789795.7319
MSENet50_treeval121.78440.789495.7884
PointNet189.22030.7502104.3610
PointNet_treeval186.54030.7510104.1872
RF360.04260.7317108.1604
RF_treeval1270.61480.4106160.3037
\\power{}396.47690.7436105.7417
\\power{}_treeval2159.13470.2623179.3437
linear260.64030.7532103.7421
linear_treeval1275.93630.4372156.6507
33KPConv38.04920.704995.2133
KPConv_treeval53.50470.5912110.9469
MSENet1435.95080.675199.8496
MSENet14_treeval36.64520.6732100.1552
MSENet5034.91750.705495.1077
MSENet50_treeval33.32520.719192.8766
PointNet40.37280.6030110.4342
PointNet_treeval42.73920.5948111.5663
RF36.85570.6442104.5526
RF_treeval363.30430.1086165.4977
\\power{}38.42230.6496103.7661
\\power{}_treeval338.05410.1676159.9216
linear36.17550.6473104.0946
linear_treeval339.11220.1626160.4031
66KPConv49.63620.836561.7971
KPConv_treeval77.95570.779371.2130
MSENet1450.06750.860857.0490
MSENet14_treeval44.26550.854458.3527
MSENet5051.42950.866655.7932
MSENet50_treeval48.85900.862756.6026
PointNet73.94280.782471.3400
PointNet_treeval89.32210.804067.7084
RF62.69790.758975.1154
RF_treeval406.78130.1496141.0797
\\power{}54.46530.774672.6313
\\power{}_treeval699.6692-0.2760172.8152
linear52.39120.770773.2547
linear_treeval305.44280.2803129.7873
100KPConv70.27170.812268.3703
KPConv_treeval130.39460.659589.0039
MSENet1449.94870.832864.5441
MSENet14_treeval47.17930.831564.7895
MSENet5061.98060.836363.8682
MSENet50_treeval84.03230.825365.9787
PointNet94.21330.796171.2679
PointNet_treeval94.47690.787072.8608
RF107.95120.793371.7916
RF_treeval409.12710.0536153.6323
\\power{}118.29720.801970.2878
\\power{}_treeval545.43590.0044157.5772
linear113.47100.792172.0060
linear_treeval556.10680.0719152.1399
101KPConv201.97180.836474.2849
KPConv_treeval197.38270.725893.2016
MSENet14134.64170.846771.8896
MSENet14_treeval122.55830.842772.8249
MSENet50172.91020.862268.1356
MSENet50_treeval173.77400.855669.7393
PointNet325.22200.809980.0643
PointNet_treeval407.91010.796482.8831
RF198.67510.781485.8857
RF_treeval3635.66540.0954174.7154
\\power{}143.09740.768788.3447
\\power{}_treeval2599.07180.1106173.2390
linear153.54390.775587.0372
linear_treeval3820.08250.1605168.3134
\n", + "
" + ], + "text/plain": [ + " value \n", + "metric MAPE R2 RMSE\n", + "target C_qfrac method \n", + "BMag_ha 0 KPConv 113.7307 0.7795 55.5312\n", + " KPConv_treeval 174.9903 0.7118 62.8864\n", + " MSENet14 120.0068 0.8182 50.4434\n", + " MSENet14_treeval 107.8485 0.8183 50.4226\n", + " MSENet50 165.7227 0.8063 52.0664\n", + " MSENet50_treeval 159.7468 0.8069 51.9793\n", + " PointNet 280.6255 0.7494 59.2245\n", + " PointNet_treeval 280.4503 0.7479 59.4101\n", + " RF 525.0187 0.7347 60.9495\n", + " RF_treeval 2006.2937 0.4353 88.9206\n", + " \\power{} 567.6982 0.7439 59.8803\n", + " \\power{}_treeval 3790.5411 0.2753 100.7281\n", + " linear 421.4709 0.7486 59.3251\n", + " linear_treeval 2041.3796 0.4503 87.7271\n", + " 33 KPConv 37.8753 0.7547 48.2156\n", + " KPConv_treeval 69.6822 0.6556 56.5580\n", + " MSENet14 36.0272 0.7332 50.2557\n", + " MSENet14_treeval 36.8039 0.7346 50.1321\n", + " MSENet50 35.4646 0.7622 47.4598\n", + " MSENet50_treeval 33.3031 0.7764 46.0203\n", + " PointNet 45.0247 0.6452 57.9907\n", + " PointNet_treeval 47.2673 0.6352 58.8029\n", + " RF 37.2780 0.6857 54.5893\n", + " RF_treeval 441.1636 0.1323 90.6949\n", + " \\power{} 38.4741 0.6906 54.1601\n", + " \\power{}_treeval 405.3648 0.1498 89.7779\n", + " linear 39.5112 0.6903 54.1861\n", + " linear_treeval 418.7693 0.1979 87.2009\n", + " 66 KPConv 60.0866 0.8018 34.8103\n", + " KPConv_treeval 136.2317 0.7394 39.6866\n", + " MSENet14 54.3685 0.8244 32.7850\n", + " MSENet14_treeval 49.2067 0.8185 33.3328\n", + " MSENet50 58.5416 0.8396 31.3105\n", + " MSENet50_treeval 56.3370 0.8366 31.5929\n", + " PointNet 96.7167 0.7610 38.2226\n", + " PointNet_treeval 132.8200 0.7860 36.1788\n", + " RF 85.2849 0.7279 40.8137\n", + " RF_treeval 638.6087 0.0153 77.6468\n", + " \\power{} 55.3033 0.7225 41.2167\n", + " \\power{}_treeval 1246.9583 -0.5146 96.2992\n", + " linear 51.3584 0.7363 40.1812\n", + " linear_treeval 537.6381 0.1705 71.2671\n", + " 100 KPConv 61.0229 0.7966 34.6517\n", + " KPConv_treeval 143.9263 0.6811 42.9693\n", + " MSENet14 41.4986 0.8282 31.8774\n", + " MSENet14_treeval 39.2539 0.8267 32.0152\n", + " MSENet50 50.9461 0.8316 31.5574\n", + " MSENet50_treeval 71.0517 0.8205 32.5835\n", + " PointNet 85.3918 0.7754 36.4519\n", + " PointNet_treeval 86.6778 0.7678 37.0703\n", + " RF 93.1104 0.8018 34.2500\n", + " RF_treeval 355.1950 -0.1053 80.8893\n", + " \\power{} 99.8429 0.8106 33.4873\n", + " \\power{}_treeval 476.1943 -0.2032 84.3959\n", + " linear 94.4681 0.8039 34.0701\n", + " linear_treeval 522.7593 -0.0885 80.2702\n", + " 101 KPConv 1241.2882 0.8132 36.3953\n", + " KPConv_treeval 1122.7415 0.7304 42.9605\n", + " MSENet14 698.1485 0.8167 36.0585\n", + " MSENet14_treeval 551.3003 0.8114 36.5736\n", + " MSENet50 947.8311 0.8335 34.3580\n", + " MSENet50_treeval 839.5544 0.8263 35.0886\n", + " PointNet 2308.1891 0.7837 39.1815\n", + " PointNet_treeval 2406.1221 0.7713 40.2842\n", + " RF 1340.6383 0.7567 41.5531\n", + " RF_treeval 21985.7921 -0.2271 93.3179\n", + " \\power{} 333.9581 0.7532 41.8513\n", + " \\power{}_treeval 19277.0374 -0.2676 94.8449\n", + " linear 750.1962 0.7503 42.0953\n", + " linear_treeval 35514.9320 -0.1597 90.7198\n", + "V_ha 0 KPConv 94.0820 0.7593 102.4256\n", + " KPConv_treeval 115.8926 0.6788 116.9080\n", + " MSENet14 106.0173 0.8015 93.0178\n", + " MSENet14_treeval 93.6208 0.8021 92.8621\n", + " MSENet50 126.5550 0.7897 95.7319\n", + " MSENet50_treeval 121.7844 0.7894 95.7884\n", + " PointNet 189.2203 0.7502 104.3610\n", + " PointNet_treeval 186.5403 0.7510 104.1872\n", + " RF 360.0426 0.7317 108.1604\n", + " RF_treeval 1270.6148 0.4106 160.3037\n", + " \\power{} 396.4769 0.7436 105.7417\n", + " \\power{}_treeval 2159.1347 0.2623 179.3437\n", + " linear 260.6403 0.7532 103.7421\n", + " linear_treeval 1275.9363 0.4372 156.6507\n", + " 33 KPConv 38.0492 0.7049 95.2133\n", + " KPConv_treeval 53.5047 0.5912 110.9469\n", + " MSENet14 35.9508 0.6751 99.8496\n", + " MSENet14_treeval 36.6452 0.6732 100.1552\n", + " MSENet50 34.9175 0.7054 95.1077\n", + " MSENet50_treeval 33.3252 0.7191 92.8766\n", + " PointNet 40.3728 0.6030 110.4342\n", + " PointNet_treeval 42.7392 0.5948 111.5663\n", + " RF 36.8557 0.6442 104.5526\n", + " RF_treeval 363.3043 0.1086 165.4977\n", + " \\power{} 38.4223 0.6496 103.7661\n", + " \\power{}_treeval 338.0541 0.1676 159.9216\n", + " linear 36.1755 0.6473 104.0946\n", + " linear_treeval 339.1122 0.1626 160.4031\n", + " 66 KPConv 49.6362 0.8365 61.7971\n", + " KPConv_treeval 77.9557 0.7793 71.2130\n", + " MSENet14 50.0675 0.8608 57.0490\n", + " MSENet14_treeval 44.2655 0.8544 58.3527\n", + " MSENet50 51.4295 0.8666 55.7932\n", + " MSENet50_treeval 48.8590 0.8627 56.6026\n", + " PointNet 73.9428 0.7824 71.3400\n", + " PointNet_treeval 89.3221 0.8040 67.7084\n", + " RF 62.6979 0.7589 75.1154\n", + " RF_treeval 406.7813 0.1496 141.0797\n", + " \\power{} 54.4653 0.7746 72.6313\n", + " \\power{}_treeval 699.6692 -0.2760 172.8152\n", + " linear 52.3912 0.7707 73.2547\n", + " linear_treeval 305.4428 0.2803 129.7873\n", + " 100 KPConv 70.2717 0.8122 68.3703\n", + " KPConv_treeval 130.3946 0.6595 89.0039\n", + " MSENet14 49.9487 0.8328 64.5441\n", + " MSENet14_treeval 47.1793 0.8315 64.7895\n", + " MSENet50 61.9806 0.8363 63.8682\n", + " MSENet50_treeval 84.0323 0.8253 65.9787\n", + " PointNet 94.2133 0.7961 71.2679\n", + " PointNet_treeval 94.4769 0.7870 72.8608\n", + " RF 107.9512 0.7933 71.7916\n", + " RF_treeval 409.1271 0.0536 153.6323\n", + " \\power{} 118.2972 0.8019 70.2878\n", + " \\power{}_treeval 545.4359 0.0044 157.5772\n", + " linear 113.4710 0.7921 72.0060\n", + " linear_treeval 556.1068 0.0719 152.1399\n", + " 101 KPConv 201.9718 0.8364 74.2849\n", + " KPConv_treeval 197.3827 0.7258 93.2016\n", + " MSENet14 134.6417 0.8467 71.8896\n", + " MSENet14_treeval 122.5583 0.8427 72.8249\n", + " MSENet50 172.9102 0.8622 68.1356\n", + " MSENet50_treeval 173.7740 0.8556 69.7393\n", + " PointNet 325.2220 0.8099 80.0643\n", + " PointNet_treeval 407.9101 0.7964 82.8831\n", + " RF 198.6751 0.7814 85.8857\n", + " RF_treeval 3635.6654 0.0954 174.7154\n", + " \\power{} 143.0974 0.7687 88.3447\n", + " \\power{}_treeval 2599.0718 0.1106 173.2390\n", + " linear 153.5439 0.7755 87.0372\n", + " linear_treeval 3820.0825 0.1605 168.3134" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "pd.set_option(\"display.max_rows\", None)\n", + "for split in splits:\n", + " print(split)\n", + " display(\n", + " result_scores_cfrac[split]\n", + " .groupby(\n", + " [\n", + " \"target\",\n", + " \"C_qfrac\",\n", + " \"metric\",\n", + " \"method\",\n", + " ],\n", + " sort=True,\n", + " as_index=False,\n", + " )[\"value\"]\n", + " .mean()\n", + " .pivot(index=[\"target\", \"C_qfrac\", \"method\"], columns=[\"metric\"])\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "c538d37e-7a79-4ae1-aee4-e09b6176b476", + "metadata": {}, + "source": [ + "# Errors Cancel Out" + ] + }, + { + "cell_type": "code", + "execution_count": 104, + "id": "e5430d97-79a5-408d-bf0e-82fde618147f", + "metadata": {}, + "outputs": [], + "source": [ + "def spatial_aggregate(\n", + " name: str,\n", + " results,\n", + " split,\n", + " max_n_samples: int,\n", + " n_steps: int,\n", + " n_start: 1,\n", + " seed: int = 42,\n", + " n_repetitions: int = 10,\n", + "):\n", + " print(name)\n", + " columns = [\"method\", \"target\", \"n_samples\", \"i_repeat\", \"value\", \"metric\", \"run\"]\n", + " \n", + " results = results.query(\"split == @split\")\n", + " \n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n", + "\n", + " results_df = []\n", + " for target in [\"Cag_ha\"]:\n", + " for run, result in results.groupby(\"run\"):\n", + " for n_samples in range(n_start, max_n_samples, n_steps):\n", + " # if n_samples == 0:\n", + " # n_samples = 1\n", + " n_splits = len(result) / n_samples\n", + " if n_splits < 10:\n", + " print(f\"less than 10 samples possible with n_samples = {n_samples}\")\n", + " break\n", + " for i_repeat in range(n_repetitions):\n", + " # aggregate\n", + " index = np.linspace(0, n_splits, len(result)).astype(int)\n", + " rs = np.random.RandomState(seed + i_repeat)\n", + " rs.shuffle(index)\n", + "\n", + " agg_results = result.copy().select_dtypes(include=np.number)\n", + " # set aggregate index\n", + " agg_results[\"agg\"] = index\n", + " # ignore overhanging samples\n", + " agg_results.query(f\"agg < {int(n_splits)}\", inplace=True)\n", + "\n", + " agg_results = agg_results.groupby(\"agg\").apply(\n", + " lambda x: x.sum() / n_samples\n", + " )\n", + "\n", + " mask = agg_results[target] != 0\n", + "\n", + " results_df.append(\n", + " pd.DataFrame(\n", + " [\n", + " [\n", + " name,\n", + " target,\n", + " n_samples,\n", + " i_repeat,\n", + " r2_score(agg_results[target], agg_results[target+\"_pred\"]),\n", + " \"R2\",\n", + " run,\n", + " ]\n", + " ],\n", + " columns=columns,\n", + " )\n", + " )\n", + " results_df.append(\n", + " pd.DataFrame(\n", + " [\n", + " [\n", + " name,\n", + " target,\n", + " n_samples,\n", + " i_repeat,\n", + " mean_squared_error(\n", + " agg_results[target],\n", + " agg_results[target+\"_pred\"],\n", + " squared=False,\n", + " ),\n", + " \"RMSE\",\n", + " run,\n", + " ]\n", + " ],\n", + " columns=columns,\n", + " )\n", + " )\n", + " results_df.append(\n", + " pd.DataFrame(\n", + " [\n", + " [\n", + " name,\n", + " target,\n", + " n_samples,\n", + " i_repeat,\n", + " mean_absolute_percentage_error(\n", + " agg_results[mask][target],\n", + " agg_results[mask][target+\"_pred\"],\n", + " )\n", + " * 100,\n", + " \"MAPE\",\n", + " run,\n", + " ]\n", + " ],\n", + " columns=columns,\n", + " )\n", + " )\n", + " if n_samples == 1:\n", + " break\n", + " return pd.concat(results_df, axis=0)" + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "604f0c70-79a6-4f79-8555-b0e60a681d5b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "linear\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RF\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "KPConv\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PointNet\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\power{}\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14_xy_treeadd\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50_xy_treeadd\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "linear_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RF_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\\power{}_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "KPConv_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PointNet_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14_xy_treeadd_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet50_xy_treeadd_treeval\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_2805/1353084170.py:17: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha\"] = results[\"BMag_ha\"] * 0.47\n", + "/tmp/ipykernel_2805/1353084170.py:18: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " results[\"Cag_ha_pred\"] = results[\"BMag_ha_pred\"] * 0.47\n" + ] + } + ], + "source": [ + "result_scores_agg = {}\n", + "for split in [\"test\"]:\n", + " result_score = []\n", + " for name in models.keys():\n", + " result_dict[name] = file\n", + " scores = spatial_aggregate(name, results_corrected[name], split, 91, 1, 1)\n", + " result_score.append(scores)\n", + " result_score = pd.concat(result_score, axis=0)\n", + " result_scores_agg[split] = result_score" + ] + }, + { + "cell_type": "code", + "execution_count": 59, + "id": "3c8d3b69-734d-4f2b-8ea4-2a51d8d92274", + "metadata": {}, + "outputs": [], + "source": [ + "with open('result_scores_agg.pickle', 'wb') as handle:\n", + " pickle.dump(result_scores_agg, handle, protocol=pickle.HIGHEST_PROTOCOL)" + ] + }, + { + "cell_type": "code", + "execution_count": 105, + "id": "9755eb89-1e31-4fc3-b503-a81f689dea05", + "metadata": {}, + "outputs": [], + "source": [ + "with open('result_scores_agg.pickle', 'rb') as handle:\n", + " result_scores_agg = pickle.load(handle)" + ] + }, + { + "cell_type": "code", + "execution_count": 108, + "id": "269c16f8-f6d3-449b-8396-c081cd87996b", + "metadata": {}, + "outputs": [], + "source": [ + "hue_order = [\n", + " \"linear\",\n", + " \"linear_treeval\",\n", + " \"\\power{}\",\n", + " \"\\power{}_treeval\",\n", + " \"RF\",\n", + " \"RF_treeval\",\n", + " \"PointNet\",\n", + " \"PointNet_treeval\",\n", + " \"KPConv\",\n", + " \"KPConv_treeval\",\n", + " \"MSENet14\",\n", + " \"MSENet14_treeval\",\n", + " \"MSENet50\",\n", + " \"MSENet50_treeval\",\n", + "]\n", + "hue_order = hue_order[1::2]\n", + "palette = sns.color_palette(\"Set2\", n_colors=len(hue_order))" + ] + }, + { + "cell_type": "code", + "execution_count": 110, + "id": "d9dda8b4-2652-4a33-972d-b254862e6d4f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "[(0.4, 0.7607843137254902, 0.6470588235294118),\n", + " (0.9882352941176471, 0.5529411764705883, 0.3843137254901961),\n", + " (0.5529411764705883, 0.6274509803921569, 0.796078431372549),\n", + " (0.9058823529411765, 0.5411764705882353, 0.7647058823529411),\n", + " (0.6509803921568628, 0.8470588235294118, 0.32941176470588235),\n", + " (1.0, 0.8509803921568627, 0.1843137254901961),\n", + " (0.8980392156862745, 0.7686274509803922, 0.5803921568627451)]" + ] + }, + "execution_count": 110, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "palette" + ] + }, + { + "cell_type": "code", + "execution_count": 111, + "id": "b56ceb24-2ab5-4938-8e17-231a22f93e24", + "metadata": {}, + "outputs": [], + "source": [ + "treevals = result_scores_agg[\"test\"][\"method\"].str.contains(\"treeval\")\n", + "rs = result_scores_agg[\"test\"].query(\"target == 'Cag_ha' & metric == 'RMSE' & @treevals\")" + ] + }, + { + "cell_type": "code", + "execution_count": 113, + "id": "3f19d6da-f6e6-41e2-b28b-1408010f2a8c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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/PBDAJYvX051dTWTJ09GlmXGjh3LW2+9xTfffMPll19+TO1omoaqqnUeizHZiLHYcdldqMVO8grKSe8wADBWsaSe884EgjqrT3eCwyP013iEDhuH0F/jETpsHPXpT1VVI3lL4HWyMmzYMGbMmMH69evZuXMnTz755B/aflA3J7OO6mP69OkkJiZy5513Nqkcp7IOm4LgM3nws6tpWqPqbVKjw+v18uijj/Lss8+yfv360P4tW7aQmZmJLNc4Ytq3b8+WLce+YN/WrVsPezxeNVFgLqWl4mDVuq0cKJBovul7rK4StvS47JjbPV1Yu3ZtU4twSiP013iEDhuH0F/jETpsHHXpz2Qy4Xa7w37vTzZ0Xae6uppJkyYB4HI1zXxPt9t9yL49e/YwevRofv75Z6xWaxNIdXhUVcXn8zWZzg6mLh0KDkXTNHw+3yHP7JH60keiSY2OWbNmMWjQILKyssKMjqqqKiIjI8PKRkVFUVFRccxttW3bloiIiHqPlxdE8MG23+g2MIa2jiSSE5xQtBbKZLp37gQm8zG3fSqjqipr166lS5cuKIrS1OKccgj9NR6hw8Yh9Nd4hA4bR336U1WVrVu3YrfbT2q9SpKEzWbD4XA0Sfterxe/34/dbkeSpLBjNpsNAIfDUa/R4fP5MJubpg+jKApms7nJdBdE13XcbnedOhQciqqqmM1mOnTogKIooWe4bdu2jTI8mszo2LlzJ59//jmff/75IcecTieVlZVh+yoqKnA6jz2FrSzLh/1Sy4xJAgkK1HIy5RRAQj73GtBUdI8L2Wo75rZPBxRFOal/FE52hP4aj9Bh4xD6azxCh42jLv1JkhR6AczbtZYVB3JPuCy9EpozukWXoyoblO1f//oX27dv5/nnnycvL49zzjmHp556ihdeeIGKigouvvhi7r333tB5n332Ga+++ir79u0jMzOTGTNm0K5dOwBee+01PvzwQw4cOEBycjK33XYbI0eOBOCTTz7hgw8+oGfPnnz66aeMHDmSv/71r2F6CjJx4kQA+vfvD8A///lPrFYrd9xxB5MnT+b111+nffv2vPbaa4eVp7CwkMcff5zly5djNpu57LLLuPnmm/H7/QwcOJA33niDLl0MfXk8HgYMGMA777xDx44dueOOO1i+fDlut5usrCweeughsrKywnR3snT069Kh4FCCejr4mW2sR7LJ/Jm///47+/btY9iwYfTt25dHH32U9evX07dvX9LT08nJyQmLHdu4cWPo4TgRpDiicZos7NhbxvylO9mRX4YkSejrlqC/eR/a/hP/JSgQCAQCgeDUYdmyZcybN49PPvmETz75hJ9//hmABQsW8MILL/Dss8+ybNkyxowZw4033ojXaySnSU9P59133+W3337jlltu4e6776agoCBU77p164iPj2fx4sVMnz693vbfffddAH755RdWrlzJ4MGDASMraH5+Pt9//z3/+te/DiuPpmlMmzaNFi1a8MMPP/DRRx/x/fff8/HHH2OxWBg5ciRz584NtblgwQKSk5Pp2LEjAAMHDuTrr7/m559/plOnTk0+f0Nw8tJkno7zzz+fAQMGhN5//fXXfP7557z88svEx8djsVh44403mDRpEvPnzycvL49zzz33hMkjSxJtoxLZml+CpSyKnflltG0eC9GJoJjQC3agJ6QjncRxpwKBQCAQnMqMbtHlqD0QJwM333wzDocDh8NBr1692LBhA/379+f9999n8uTJtG/fHoDx48fz2muvsXr1anr37s15550XqmPUqFHMmjWL1atXk5ycDEBcXBw33HADkiRhMpkaPCdC13XuvPPOUMjV4eSxWq0UFBRw2223IUkSSUlJXHvttXzyySeMGzeOMWPGcMcddzB9+nRkWWbu3LmMGTMm1Nall14a2r7lllt4++23KSkpITY29tiUKjhtaTKjw263Y7fbQ++joqIwm82hB+7ll1/m/vvv54UXXiAjI4OXXnqJmJiYEypT2+hmrCnagyxDQZHxgMstO6FNfAh9Xy7a6h9Qss85oTIIBAKBQCA4NUhMTAxt2+32kHGQn5/P3//+d5577rnQcZ/Px759+wAj9OrNN98kPz8fMCanl5SUhMomJyc3KgwoJiYmrI91OHlkWaa4uJjevXuHjmmaRkpKCgC9e/fGbDbzyy+/0KlTJxYvXsx9990HGLH/zz//PF9//TXFxcWh8BthdAjqoslT5ga55JJLuOSSS0Lvs7KymD179h8qQ7uoRHRJxxotcaDEjavah8NmRnbGoOZ+Db9/iyrJKN3P/kPlEggEAoFAcOqQkpLC5MmTw/o1QfLz87n//vt588036dGjB4qicNFFF4Wlcz3a2Pn6DJODzz+cPKtWrSI5OZkFCxbU28bo0aOZO3cu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9re/8e677zJv3jxef/11li5dSseOHbntttsAcLvdXHfddQwbNowlS5bwyiuv8Oqrr7J06VKaNWtG7969+fLLL0Ptzp07lwEDBhAfH4+maVx00UUsWLCAH374AbvdzowZMw4rt+DkRRgdhyEYYrWp1LDW+3dLIynewYZtxeTsPLp4SKXnCOQJ9yE1S4e8HNj0C3peDmphHtruTSdMdoFAIBAIBI3DbDbTt29fFi9eHLZ/zJgxdOrUCavVyl133cWKFSsoKCjgxx9/JDU1lXHjxmEymRg6dCgDBw7kiy++AKBPnz4sX74cl8tFXl4eV155JcuWLcPr9bJq1Sr69u2Lrut8+OGH3HvvvcTHx2Oz2Zg2bRrz58/H7/eHZLjllluIiIjAZrMB8Oijj3L22WfTt29fBg8eXO81PfDAAzz77LN07dr1qDwedREXF8cNN9yA2WzGZrPx/vvv85e//IX09HTMZjO33HIL69atY8+ePfzwww8kJCRw5ZVXYjabad26NZdffjnz5s0L6XLu3LmhuufOncuYMWMAiIqK4rzzzsNutxMREcG0adNYvnz5McksaHrEOh2HISsmCQn4avd6eiZkYJJNjB7ahrc/X893P+8itVkEEQ7LEeuRzBak2GT0UVPQ8gYjAfqyL9BzlqOeex1Sm25IniqISaozBlMgEAgEgjMF5ZqaEX156PjQttSyM0rLzset3NESDLEaPXp0aF9tT0JUVBQRERHs27ePffv2kZaWFnZ+Wloa+/btAwyj49NPP6VLly50796dfv368cgjjzBo0CCSkpJISkqiqKgIl8vFFVdcEapD13UkSaKoqCi0LzU1NaydBx54gKuuuopLL72U1atX061btzqv55///CdTp05l2rRpKIrSYH0AJCcnh/VX8vPzuf3228PCumRZpqCggPz8fDZu3EivXr1Cx1RVDb0fMWIEM2bMYOfOnfh8PnJzcznnnHMAw0vyxBNPsHjx4pC3yeVy4fV6sViO3P8SnFwIo+MwJNgiGJLSjh/3buGL3A2MbdmV6Agrw/o1Z/6SnXyxcDvjzss6akNBUkwoLTqhayq6bAI0MFvRV36HvmweDLgIudtQ9O2rkdKykGIST+wFCgQCgUAgOCxDhgzh6aefRlXVUCe99hyOiooKKisrQ0bDwXMO8vPz6dzZMIL69evHQw89xNKlS+nTpw/t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hw4d8Hj2rpBLfn4+p556KnfffTePPvroTuvvuusuVq5cyfTp0wkGg1x77bVcf/31TJ48mWeffZbDDjuML774YqdchtNOOw3HcejUqRP//Oc/ad26NeBOT3DwwQent2vTpg2O47BmzRratWu3SxmPP/545s+fT35+PhdffHGdda+88gpTpkyhTZs22LbNSSedRP/+/bntttuoqKjg73//O0VFRYwbN445c+Zw33338e9//5vWrVszffp0Jk6cyJtvvsmoUaO47777KCsrIzc3F4BZs2Zx7rnnAhAIBLjlllto06YNy5cv54wzzqBdu3YMHz58r/St+P9HGR0HGEL3ogcbEgw2hMZDkFYNiW1ziG2dT6zGg5OoILZ5DlVr5+MNQqDRUZhZ22M+haHhaZCJp0GmW4Y3miS5JYy1OQwIjCoPia3AJousihYkNlYSnbsJrzeXvHoNoKGBbGDTJn8YLbOGkLSjrK3aiCNtCvztCSc2sSX6PVui35MRqY9XyyRql/N96UuYWoDmWYPI9bWiIraGmF2BT88mYOST7W2Mz3CraWlC3XYKhUKh2DW/5IXIajHgZ9f7cprgy9next56NXbHmDFjmDZtGv369WP+/Pncc889aaPj6KOPRtM0XnnlFR5++GEATjjhBCZNmpSu/vPuu+/Ss2fPdHt5eXm8/fbbdY4xYcIEhgwZwrx588jJyUkvl1Ly4osv8tJLL5GXlwe4nonDDz/8Z8OhnnnmGbp160YikWDKlCmcccYZvPnmm4RCISKRSJ3pCYQQhEKhPcrr2BXHHHNM2mvy/fffs3nzZi644AKEEBQVFXH66afz8ssvM27cOJ5//nkmTJiQNm5OOOEEpk6dyoIFC+jVqxfFxcW8+eabnHzyySxZsoTNmzczePBgAHr16pU+Zrt27TjyyCP56quvlNHxB0D1/g5whBHEW28k3nojybCiOPEtJEoX4diSmgo/NRXvoXs+w+ML483Mwlc0DM3nTkQoNIEe9KA3z4Xm7miBtB08DTNx4jbYDjJh47TJw9pag702AqkcNmFq+IqCBJtkscVvk+trTravEUkZJWlHCCe2gABT95JwTASCiLUVgY6Gzsaab9gcWVDnXFpnH0GDYDcq4uuI29VkextRFOiCoXndHBJVWUuhUCgUByiHHnoo1113HVOmTGHo0KE7eRRGjhzJyJEjsW2b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DmvWrMHr9arJARUHFC+88AJvvvkmTz311C7XT5o0iebNm++yUtcfhcsvv5z8/HwuuuiindbZto1hGLRs2bJO39lxHOLxOFlZWb/qmAes0dGsWTMAVq1aRcuWLQFYunQprVu33qt2DMMgLy/vtxZPoVAoFArFPmDbNpqmoev6AW10jB8/nrlz5/Kf//yHHj16pJffeOONPPPMM1x77bWceOKJTJkyhWnTplFaWkogEKBjx47cddddhEIh7r//fh5++GE8Hk+dtt977z3y8vIYP34833zzDbNnz6Zp06aA2/8ZMWLEHoWIr1+/nsMOO4yFCxfi9XrTy9u2bYvf709PiNejRw+mTp2aXv/ss8/yyCOPEA6HOfjgg7nxxht/VdjMvtK2bVtmz56d7u/tbzTNnURwd/elECJ97+6O3V2TAwUhxM+eo6Zp+P3+ndbvy/1xwA79BwIBhg0bxr333ks4HGbp0qW8/PLLjBkzZn+LplAoFAqF4n+IZs2a1cknTSaTvPnmm2kDYebMmUyfPp1HH32U+fPn89prrzF06NA6bQwdOpT58+fX+ew4KBoKhbjvvvt+c9lnzJiRPt6OBsd///tfHnjgAR599FE++eQTEokEN9xww29+/N+CZDK5v0X4XfizntfuOGCNDiA9e+XAgQOZMGEC//jHP+jbt+9+lkqhUCgUCsX/EiNHjuSdd94hFosB8OGHH9KuXTuKiooAWLhwIf3796dFixYA5OXlMXbs2L0aFT7llFOYM2cOS5Ys2eX6RCLB5MmTGTx4MAcddBCTJk2isrIyvS9Anz59KC4u5uOPP/7F49UO5LZv355QKMT555/P7NmziUaju90nmUwyZsyYOrNwT5w4kauvvppEIsFBBx3EwoUL0+tisRg9evTY7TkBnHzyyQAce+yxFBcXM23aNNavX0/btm156aWXGDx4MMcccwwAH3/8MWPGjKFnz56MHj26zmzm4XCYq6++mkGDBtG/f3+uv/76dO5BOBxm4sSJ9O3bl169enHmmWeycePG9L4bN27ktNNOo7i4mBNOOKHOOoAvvviCI488kuLiYv71r3/tkbGwq2vy5Zdf0r9/f5544gkGDBjAWWedBbhG65FHHknPnj056aSTWLFiRbqdrVu3csEFF9CvXz8OPvhg7r//fhzHIZFI0KtXLxYtWpTeNh6P06NHDxYvXgzA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Nw/HcYjFYnzxxRds3ryZgoICDj74YG666SYqKyuRUrJp06Z0Qn1NTQ0+n4/MzEwqKyvTuR7gei26devGXXfdRSKRYPHixXXyJA4++GBWr17NrFmzsCyLmTNn7pExuatrsitOPPFEHn30UZYuXYqUkpqaGubMmUM4HKZz587k5OTw4IMPEolEcByH1atXp0PDwA3te+edd5gxY0ad0Kqamho8Hg/Z2dnEYjHuueeeX5T590YZHQqFQqFQKBR7QF5e3i6raGZmZjJlyhQOO+wwevTowWWXXcbEiRPr5Aa88847FBcX1/nsrprQ2LFjd5qA7eKLL6Zt27acdNJJ6SpLtZWL/H4/Z511Fqeeeio9e/bkk08+obS0lEmTJtGzZ08OOeQQvv32Wx577DEyMzMBN8n47LPPZsKECQwcOBDDMLjqqqt+9vxLSkq45ppruP3228nKymLQoEEceeSRXHbZZekR9Ly8PPr06UNZWRmHHXbYHun1vPPO48orr6Rnz567DZHr2LEjt912G7fffjsHHXQQhx56KE888UQ6hOq2227DMAyOOeYYevTowV//+ldWr14NuMn0yWSSvn37Mm7cOPr161en7TvvvJMVK1Zw0EEHcf3113Psscem1+Xk5PDAAw/w0EMP0atXLz777DMOPfTQXzynXV2TXTFkyBDOPfdcLrnkknSuT20yvq7rPPzww6xevZqhQ4fSq1cvLrjgArZu3Zrev1WrVjRp0oSlS5cyfPjw9PJjjjmGRo0aMWjQIEaMGFEnDG5/IeT+9LMoFAqFQqH4n8S2bZYvX06bNm0O6MkBFXvPTTfdRCKRqFMhSvHH4fd6NpWnQ6FQKBQKhULxm1BSUsKsWbM48cQT97coigOMP63RUVVVxfnnn09xcTEDBw5M14FW7J5nn32WMWPG0KlTJy688MI665YvX864cePo2rUrRx111E7l/BTuxE1XXHEFgwcPpri4mCOPPJLXXnstvV7p8Je56qqrGDhwIN27d2fw4ME8/PDD6XVKf3tOeXk5Bx10UJ35CJT+fplLL72UTp061Ql/2XGSMKXDPePtt9/mqKOOolu3bhx66KG88847wM76mz9//n6W9MBj8eLFdT7fffddnXkzYrEYq1at4vvvv2fFihXU1NT8LnL8NAys9jN79uyf3e+BBx5g+PDhjBs3rs4cIK+99tou2+vZs+dvLnsikWD16tUsWbKEJUuWsH79+nTp4t9afw8//PAuz2vH3Io/GvF4nLVr17Jp0yZGjBjB9OnT0+v2+R0o/6RMmjRJnnPOObK6ulp+//33snfv3vLzzz/f32Id0Lz99tvy3Xffldddd5284IIL0ssTiYQcPHiwfOSRR2Q8HpczZ86UvXr1khUVFftR2gOPmpoaec8998i1a9dK27blV199Jbt37y6/+eYbpcM9ZMWKFTIajUoppdy4caM84ogj5OzZs5X+9pJLLrlEnnLKKfK4446TUqpneE+55JJL5B133LHLdUqHe8Znn30mBw0aJL/66itp27bctm2bXLt27S71179/f/ndd99Jy7L2t9gHJI7jyCVLlsjy8nIppZS2bculS5fKLVu2SNu2ZXl5uVy8eLFMJpP7V9ADjB9//DH9O5xMJuWqVavkpk2blP72AMdx5LJly+SmTZvk999/LxcuXCh79Oghv/zyy9/kHfin9HREIhHeeustLrjgAkKhEB06dGD06NHMmDFjf4t2QDN06FCGDBmyU/3muXPnEovFmDBhAh6Ph6OPPppGjRqlR68ULoFAgPPPP5/GjRujaRo9e/ake/fuzJ8/X+lwD2nVqhU+ny/9f03TWLNmjdLfXvDll1+ydu3aOlVjlP72HaXDPeO+++7jnHPOSU/glpeXR+PGjXepv4YNGxKNRve3yAcs4XAYx3HSid+RSAQpJfn5+WiaRnZ2NqZpUlVVtZ8lPbBIJBJkZ2ejaRqGYZCZmUk8Hlf62wPi8TjJZJL8/HyEEHTo0IHDDz+cGTNm/CbvwD+l0VFbraBVq1bpZe3atWPFihX7SaI/NitWrKBNmzZ1aksrff4ykUiE7777jtatWysd7gWTJ0+mW7duHHLIIUQiEUaNGqX0t4ckEgluuOEGrrnmmjo14pX+9pwXX3yR3r17M2rUKF566aX0cqXDX8a2bRYtWkR5eTmHH344AwYM4JJLLqGysnKX+mvdujXJZHK/zhtwIFNeXk5WVlZaZ7FYDK/XW+fZ9vl8xOPx/SXiAUleXh4VFRXYto1lWVRWVhIKhZT+9oLaZ7J2Xpdly5b9Ju/AP+XkgJFIhGAwWGdZZmbm7xb7+Genpqamzkyd4Oqzurp6P0l04COl5LLLLqNLly4MGDCAhQsXKh3uIZMmTeKiiy5i0aJFvP/+++lnV+nvl3nkkUcYMGAAbdu25fvvv08vV/rbM8aPH8+//vUvsrKymDdvHv/4xz/IyMhg2LBhSod7wLZt20gmk7z55ps888wzBAIBJk2axM0330yTJk120l8oFKKsrIwNGzZQVFSEaZr7SfIDD8uyqKqqolmzZul8BMuy0DQt/X9wO4WWZdVZ9r+O3++nrKyMxYsXA+4s75mZmZSWlir9/QK6rqPrOqtXr8YwDBYtWsS7775Lfn7+b/IO/FMaHYFAYCcDo7q6eidDRLFnBINBwuFwnWVKn7tHSsk111xDSUkJjz/+OEIIpcO9RAhBly5d+OSTT3jggQeoV6+e0t8vsHr1al599dV0ffcdUfffntGxY8f094MOOoiTTz6Zt956i2HDhikd7gG1k8qdfPLJ1KtXD4CJEydyzjnnMHHixF3qb9GiRfTo0YPVq1crj8cOhMNhIpFInXKl4XA4HSZUS0VFBUKInXT7v4qUkpKSEgKBABkZGUgp2bhxIxs2bMDj8Sj97QHJZJI1a9bw4IMPkp2dzejRo1mxYsVv8g78UxodzZo1A2DVqlW0bNkSgKVLl9K6dev9KNUfl9atWzN16lQcx0m71ZYsWaLK4e0CKSXXXXcdixcv5sknnyQQCABKh78W27ZZs2YNAwcOVPr7Bb755htKSkoYPHgw4IZaJRIJDjroIG666SaWL1+u9LeXaJqW7girZ/iXyczMpH79+nXCV2r5Of3Vq1ePoqIipJTK8Ehx/PHHc+SRRzJgwID0si+++IKrr76at99+O63Dk046ieOOO45evXrtL1EPKMrLyznhhBOYM2dOOj/1u+++429/+xt33XWX0t8eUDvoVzux5YUXXki3bt1+k3fgnzKnIxAIMGzYMO69917C4TBLly7l5ZdfZsyYMftbtAMay7KIx+NYloXjOOmEot69e+PxeHj88cdJJBLMmjWL9evXc/jhh+9vkQ84rr/+ehYsWMBjjz1GKBRKL1c6/GWqq6uZOXNmOnny66+/5vnnn6dfv35Kf3vAEUccwbvvvpv2dpx//vm0adOGV199lYMPPljpbw+YPXt2+v6bN28ezz77bFpH6h7cM8aOHctzzz3H1q1bCYfDTJkyhcGDB/+i/oQQaJqWDu/4X/4sXbqUJUuWMHLkyDrLe/fuDcBTTz2FbdvMnj2bH3/8kSFDhux3mQ+UT35+PoWFhbzwwgvYtk08Hmf69Ok0b95c6W8PPytWrCAWixGPx5kxYwaff/45p59++m/zDvxNa20dQFRWVsrzzjtPduvWTfbv318+++yz+1ukA5777rtPtmnTps7nkksukVJKuXTpUjl27FjZuXNnOWLECDl37tz9LO2Bx/r162WbNm1kp06dZLdu3dKfhx56SEqpdPhLVFdXy1NPPVX27NlTduvWTQ4bNkw+8sgj0nEcKaXS394yY8aMdMlcKZX+9oSTTjpJ9ujRQ3br1k2OGDFC/uc//6mzXunwl0kmk/KGG26QvXr1kn369JGXXnqprK6ullIq/e0p119/vTzrrLN2uU7p8JdZsmSJPPXUU2WvXr1kr1695JlnninXrFkjpVT62xPuvPNO2atXL9mtWzd5yimnyMWLF6fX7av+hJTKl6lQKBQKhUKhUCh+P/6U4VUKhUKhUCgUCoXiwEEZHQqFQqFQKBQKheJ3RRkdCoVCoVAoFAqF4ndFGR0KhUKhUCgUCoXid0UZHQqFQqFQKBQKheJ3RRkdCoVCoVAoFAqF4ndFGR0KhUKhUCgUCoXid0UZHQqFQqFQKBQKheJ3RRkdCoVC8T/OpZdeysUXX7y/xQBgzZo1HH/88XTq1Inx48fvb3H2iJdffplBgwbtbzEUCoXigMbY3wIoFAqFQlHLww8/jM/n4+233yYYDO5vcRQKhULxG6GMDoVCoVD85iQSCTwez17vt379enr16kXDhg1/B6kUCoVCsb9Q4VUKhUJxgDB+/Hhuv/12rr76aoqLixk8eDBvvPFGev2uwnjuv/9+TjzxxDpt3HbbbVx55ZXpNj766CM2b97M6aefTrdu3TjhhBPYsGFDnXaklNx666306NGDfv368fTTT9dZv27dOiZOnEhxcTEDBgzg+uuvJxqNptcPHjyYRx99lHPPPZeuXbvyn//8Z5fnuHr1as444wy6dOlC3759ue2227AsK93G3LlzefDBB2nbti3333//TvtLKZk8eTIDBw6kc+fOHHbYYbzwwguAa+j861//4uCDD6Zbt26MGTOGzz//vM7+bdu25aWXXuLUU0+lS5cujB07lnXr1vHll19y1FFH0b17d/75z38Sj8d3OreJEyfSpUsXhg8fzpdffrnL86vl6aef5rDDDqNr164ce+yxdbZft24df/3rX+nevTvdu3fnuOOOY82aNT/bnkKhUPzRUUaHQqFQHEBMmzaNFi1aMHPmTEaPHs1ll11GaWnpXrXx4osv0rp1a1555RUOPvhg/vWvf3HFFVdw2mmnMWPGDABuvfXWOvvMmTOHWCzGiy++yPnnn8/tt9+e7ignEgn++te/0rRpU2bMmMG///1vFi1atFMbjz32GIMGDeL1119n+PDhO8ll2zZnn302Ho+H6dOnc+utt/Lqq68ydepUAF566SW6dOnCGWecwaeffsoZZ5yxUxtvvvkmr7/+Ovfccw9vvfUWN910E/n5+QBYlkWzZs14+OGHefXVVxk8eDBnn332Tvp76KGHOP3003nllVcwDINJkybx0EMPccsttzBlyhQ+/vhjXnzxxTr7TJkyhYMPPphXXnmF/v37c84551BdXb1L/b/00ks8/fTTXHPNNbz++uscc8wx/O1vf2P9+vUAXH/99eTn5/PSSy8xY8YMxo8fj6apn2OFQvHnRr3lFAqF4gCie/funH766TRt2pSzzjoLTdNYuHDhXrdx2mmn0axZM84++2wqKiro168fhx56KC1btmT8+PHMnTu3zj4ZGRlceeWVtGzZkuOPP57hw4fz3HPPATB79mxCoRCXXXYZLVq0oEuXLlx22WXMmDED27bTbQwePJhx48bRuHFj6tWrt5Nc//3vf1m/fj233norbdu25eCDD+a8887jySefBCA3NxfDMAgEAhQUFOwyp2Pz5s00bdqU7t2707BhQ/r06cOQIUMACAQCnH322bRv356mTZty7rnnUlRUxCeffFKnjRNOOIHBgwendbFgwQImTZpE586d6dGjB8OGDdtJPwMGDODEE0+kZcuWXH755WRkZPDqq6/uUv8PPfQQV1xxBYMGDaJx48aMHz+eHj168Nprr6XPoW/fvrRo0YLmzZszatQoGjdu/HOXVKFQKP7wqJwOhUKhOIBo06ZN+rthGOTk5Oy1p2PHNmq9AK1atUovy8vLo6KiAtu20XUdgA4dOmAY238SunTpwvTp0wFYtmwZy5Yto7i4OL1eSkkymaSkpIQGDRoA0L59+5+V68cff6Rp06ZkZ2enlxUXF1NeXk5FRUWd5btj6NChPP744xxxxBEMGjSIIUOG0Lt37/T6xx57jJkzZ1JSUkIymSQWi7Fp06bd6icvLw+A1q1bp5fl5+ezatWqOvt06dIl/V3XdTp27MiPP/64k3w1NTWsX7+eCy+8ECFEenkikaCoqAiAE088kSuvvJJZs2bRr18/RowYQf369X/x3BUKheKPjDI6FAqF4gBix44/gBACKSUAmqalv9dSmw+xuzZqO76mae60bMe2duwg/5RIJELPnj25/vrrd1pXUFCQ/u73+3fbxk+P92tp1KgR77zzDh9//DGffvopEydOZPTo0Vx11VW8+uqrPPjgg1x11VW0b98ev9/Pueeeu5OOdqWLn+rMcZw6+/ycfnakNs/lzjvvrGPIAGnPzUknncTAgQOZM2cOH3zwAffffz9Tp06lZ8+ee6gFhUKh+OOhjA6FQqH4g5CTk0NFRQXJZDLdcV62bNlv0vbixYvreD4WLVpE8+bNAWjXrh1z5syhXr16eL3eX32MFi1asGbNmjpejfnz55Obm7tHXo5aAoEAw4cPZ/jw4fTr14/LLruMq666igULFtCnTx9Gjx4NuF6HjRs3/mp5d2THEDfHcVi8eDF9+vTZabu8vDwKCgrYtGlTOuxrVzRu3JjTTjuN0047jTPPPJPXX39dGR0KheJPjcrpUCgUij8InTt3RtM0HnzwQdasWcPTTz/NvHnzfpO2q6qquOmmm/jhhx+YPn06b775JieddBIAI0eOxDRNLrjgAhYuXMiaNWuYM2cOt912214dY8CAATRq1IhLL72U5cuX89FHH3H//fdz2mmn7XEbr7zyCi+//DKrVq3ixx9/5P33308bR02aNGH+/PnMmzePFStWcNlll+3ksfi1fPrpp0ybNo0ffviBm2++mcrKSkaNGrXTdkII/v73v3PvvfcyY8YM1q5dy6JFi3j00UfTlbRuvvlmPvvsM9avX8+8efNYtmxZ+hwUCoXiz4rydCgUCsUfhNzcXG655RbuvPNOnnrqKY444ghOPPHE38TwGDx4MLquc9xxx+HxeLj44ovTI/mhUIhnnnmG2267jTPOOAPLsmjSpAnHHHPMXh1D0zT+/e9/c9111zF27FiCwSDHHHMMEyZM2OM2MjIyePjhh7nhhhvQdZ2uXbty1113AW6C+MKFCznzzDMJBoNMmDCBrVu37pWMu2PChAm8++673HjjjTRo0IAHHniAzMzMXW47fvx4PB4PU6dO5ZprriE7O5tu3bqlPR+WZXHVVVexZcsWcnJyOOqoozjllFN+EzkVCoXiQEXI3yLIVqFQKBSKPymDBw/mrLPO4rjjjtvfoigUCsUfFhVepVAoFAqFQqFQKH5XlNGhUCgUCoVCoVAofldUeJVCoVAoFAqFQqH4XVGeDoVCoVAoFAqFQvG7oowOhUKhUCgUCoVC8buijA6FQqFQKBQKhULxu6KMDoVCoVAoFAqFQvG7oowOhUKhUCgUCoVC8buijA6FQqFQKBQKhULxu6KMDoVCoVAoFAqFQvG7oowOhUKhUCgUCoVC8bvyf3M9Vgbrr5UQAAAAAElFTkSuQmCC\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# RMSE\n", + "figsize = (7.48031, 2.1)\n", + "g = sns.lineplot(\n", + " data=rs,\n", + " x=\"n_samples\",\n", + " y=\"value\",\n", + " style=\"method\", \n", + " hue=\"method\",\n", + " hue_order=hue_order,\n", + " palette=palette,\n", + " orient=\"x\",\n", + " errorbar=\"se\",\n", + " #err_style=\"bars\",\n", + " #err_kws={\"capsize\": 3},\n", + " dashes=True\n", + ")\n", + "g.set(xlim=(1, 90.25))\n", + "g.set(xticks=np.arange(0, 91, 10))\n", + "# g.set(ylim=(0, 26.5))\n", + "g.set(ylabel=\"RMSE\")\n", + "g.set(xlabel=\"number of samples\")\n", + "# sns.despine(left=True, right=True, top=False)\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "plt.subplots_adjust(left=0.03, right=1, top=1, bottom=0.125, hspace=0.15, wspace=0.1)\n", + "#plt.savefig(\"figures/spatial_agg_RMSE_carbon.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 114, + "id": "6d3264fb-7a93-4231-a689-ee8fc95b2f6a", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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obS0lPr6er7//nvefvttysrKuPrqq8nMzOT888/v1GsJD+sA2O12Tj31VP7xj3+wbt26rjany3jllVfweDzMmDHjxw/uRYwbNw6n04nT6WTu3Lkcf/zxfPDBB11tVpfhcsXTHd9444243W7y8/O54IILeP/99zv9WsLDOgiGYRyRkY6ewvr161m/fj0TJ04E4rnMZVnm66+/5rnnnuti67oPkiT16hXLBw8eDMTb4UjT6zwsXdeJRqPoup5Iy6ppGlu2bGH9+vXEYjFisRjLly9n48aNTJgwoatNPuK01yYLFy7kjTfeYPXq1axevZoZM2Zw3nnn8cc//rGrTT7itNcmJSUlbNiwIfF/8tJLL/HNN99w0kkndbXJR5z22qRfv35MnDiRxx9/nGg0yu7du1m+fPkR8cx7XYrkxYsX89hjj7XaNnv2bC6++GLuvfdedu7ciaqq5Ofnc+2113LKKad0kaVHj/ba5MEHH2y1bcGCBaSlpXHHHfutQHwM0l6bzJ8/n9tvv51du3Zhs9kYOHAgt956a8ILPZY52P/Jvn37WLRoEZ9//jl+v5+5c+fyy1/+stNt6HWCJRAIei697pFQIBD0XIRgCQSCHoMQLIFA0GMQgiUQCHoMQrAEAkGPQQiWQCDoMQjBEggEPQYhWAKBoMcgBEtwUBYsWNBtItuLioq48MILGT58OPPmzetqcw6JlStX9rpEf0cSMflZ0GNYunQpTqeTN998E4/H09XmCLoAIViCo04sFsNut3f4vOLiYsaPH99r804JxCNhj2HevHk8/PDD/Pa3v2XMmDHMmDGD1157LbH/QI8eixcv5uKLL25Vx0MPPcRvfvObRB3vv/8+e/fu5YorrmD06NFcdNFF7Nmzp1U9lmXx4IMPcsIJJzBp0iSeffbZVvt3797Nddddx5gxYzjppJO47777CIfDif0zZszgySef5KabbmLUqFG8+OKLB7zHwsJCrrrqKkaOHMnPfvYzHnroIXRdT9Tx+eefs2TJEgYPHszixYvbnG9ZFn/4wx+YMmUKI0aM4JRTTuFvf/sbEBfJu+66i2nTpjF69GjOO+88Pv3001bnDx48mBUrVnDZZZcxcuRI5syZw+7du/nss88466yzGDt2LHfeeSfRaLTNvV133XWMHDmSM888k88+++yA99fEs88+yymnnMKoUaM4//zzWx2/e/durr76asaOHcvYsWO54IILKCoqOmh9vQkhWD2Iv//97wwYMIBXXnmF2bNns3DhQiorKztUx0svvcSgQYNYtWoV06ZN46677uLf//3fufzyy3n55ZcB2mRpWLduHZFIhJdeeolbbrmFhx9+OPEli8ViXH311fTv35+XX36Zxx9/nM2bN7ep4+mnn2bq1KmsWbOGM888s41dhmFwww03YLfbWb58OQ8++CCrV6/mqaeeAmDFihWMHDmSq666io8++oirrrqqTR1vvPEGa9as4Y9//CNr167l/vvvJy0tDYinRsnLy2Pp0qWJVDk33HBDm/Z74oknuOKKK1i1ahWqqnL77bfzxBNP8Pvf/55ly5bxwQcf8NJLL7U6Z9myZUybNo1Vq1YxefJkbrzxRurr6w/Y/itWrODZZ5/lnnvuYc2aNcyaNYtf/vKXFBcXA3DfffeRlpbGihUrePnll5k3bx6yLL6mCSxBj+DSSy+15s+fnyhrmmaNGjXKWrdunWVZlvXyyy9bU6ZMaXXOo48+al100UXt1rFv3z6roKDAeuqppxLb1qxZY02YMCFRvvvuu60pU6ZYmqYltt1+++3Wr371K8uyLGvVqlXW7NmzW133iy++sIYNG2bpum5ZlmWdfPLJ1oIFCw56f++//741YsQIq7q6OrHtxRdftCZOnJgoX3TRRdajjz7abh1PP/20dfnll1umaR70Wk2cccYZ1qpVqxLlgoIC68knn0yU16xZYxUUFFibNm1KbFu0aJF10003Jconn3yydeuttybKuq5b06dPt5577jnLstr+XWbMmJH4mzVx5ZVXWkuWLLEsy7LOOuusVjYJWiP6sHoQBQUFic+qqpKcnNxhD6tlHU3eR9NqQQCpqanU1NRgGEYin/3xxx+Pqjb/q4wcOZLly5cDsG3bNrZt29ZqCTDLstA0jbKyMrKzswEYOnToQe3auXMn/fv3JxAIJLaNGTOG6upqampqWm1vj9NPP51nnnmGmTNnMnXqVE499dRWCRiffvppXnnlFcrKytA0jUgkQmlpabvtk5qaCsCgQYMS29LS0vj+++9bnTNy5MjEZ0VRGDZsGDt37mxjXzAYpLi4mNtuu61Vds5YLJZYmeniiy/mN7/5Da+++iqTJk3i5z//OVlZWT96770FIVg9iJaiAa1T88qy3CZNb1P/T3t1NH1pbDZbm20t6zpY6ttQKMS4ceO477772uxLT09PfG7K+90e+9t+OOTk5PDWW2/xwQcf8NFHH3Hdddcxe/ZsFi1axOrVq1myZAmLFi1i6NChuFwubrrppjZtdKC22L/NTNNsdc6hpgZu6td75JFHWokgkBj1nDt3LlOmTGHdunW89957LF68mKeeeopx48YdYisc2wjBOkZITk6mpqYGTdMSX7pt27Z1St3ffvttK49r8+bNiXXnhgwZwrp168jMzMThcBz2NQYMGEBRUVErb+qrr74iJSXlkLyrJtxuN2eeeSZnnnkmkyZNYuHChSxatIivv/6aE088kdmzZwNxb6ekpOSw7W3Jpk2bEp9N0+Tbb7/lxBNPbHNcamoq6enplJaWcuqpp7ZbX79+/bj88su5/PLLueaaa1izZo0QrEZEb94xwogRI5BlmSVLllBUVMSzzz7Lhg0bOqXuuro67r//fn744QeWL1/OG2+8wdy5cwE4++yzsdls3HrrrWzatImioiLWrVvHQw891KFrnHTSSeTk5LBgwQK2b9/O+++/z+LFi7n88ssPuY5Vq1axcuVKvv/+e3bu3Mm7776bENbc3Fy++uorNmzYwHfffcfChQvbeEqHy0cffcTf//53fvjhBx544AFqa2s555xz2hwnSRLXXnstf/rTn3j55ZfZtWsXmzdv5sknn0yMWD7wwAN88sknFBcXs2HDBrZt23bEFiXtiQgP6xghJSWF3//+9zzyyCP8z//8DzNnzuTiiy/uFNGaMWMGiqJwwQUXYLfbueOOOxIehNfr5bnnnuOhhx7iqquuQtd1cnNzmTVrVoeuIcsyjz/+OPfeey9z5szB4/Ewa9Ys5s+ff8h1JCUlsXTpUv7jP/4DRVEYNWoU//Vf/wXARRddxKZNm7jmmmvweDzMnz+f8vLyDtnYHvPnz+ftt9/md7/7HdnZ2Tz22GP4fL4DHjtv3jzsdjtPPfUU99xzD4FAgNGjRyc8Ll3XWbRoEfv27SM5OZmzzjqLSy+9tFPsPBYQOd0Fgp/AjBkzuP7667ngggu62pRegXgkFAgEPQYhWAKBoMcgHgkFAkGPQXhYAoGgxyAESyAQ9BiEYAkEgh6DECyBQNBjEIIlEAh6DEKwBAJBj0EIlkAg6DEIwRIIBD2G/w9/QotIELoGxAAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# RMSE 2\n", + "figsize = (7.48031 / 3, 1)\n", + "g = sns.lineplot(\n", + " data=rs,\n", + " x=\"n_samples\",\n", + " y=\"value\",\n", + " style=\"method\", hue=\"method\",\n", + " hue_order=hue_order,\n", + " palette=palette,\n", + " orient=\"x\",\n", + " errorbar=\"se\",\n", + " err_style=\"bars\",\n", + " dashes=True,\n", + " err_kws={\"capsize\": 3},\n", + " legend=False\n", + ")\n", + "# sns.despine(left=True, right=True, top=False)\n", + "g.set(ylabel=\"RMSE\")\n", + "g.set(xlabel=\"number of samples\")\n", + "g.set(xlim=(12.75, 16.25))\n", + "g.set(xticks=np.arange(13, 17, 1))\n", + "g.set(ylim=(4.75, 7))\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "plt.subplots_adjust(left=0.03, right=1, top=1, bottom=0.125, hspace=0.15, wspace=0.1)\n", + "#plt.savefig(\"figures/spatial_agg_RMSE_zoom_carbon.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 115, + "id": "f5c900e4-dd77-48eb-b786-b803eaa5959d", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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X321ma1tHCfz6csvv+Taa68lMzOT66+/nm3btjWvsU1g3759TJkyhf79+zNy5MiIATSysrK4+eab6d27N+PHj2fz5s1nZqPCJIIpU6aIhx9+WFRWVoqCggIxadIk8Ze//EUIIcQLL7wgfvazn4mcnByh67rYs2ePKCoqamaLG8fJ/KrNjz/+KLp16yYOHjzYDFY2nYb88vv9IjMzU7zzzjtC0zSRnZ0tBg4cKNasWdPcJp+Shnzav3+/yMzMFF999ZUIhUJi4cKFYuDAgaK4uLi5TT4lwWBQjB07VsybN08Eg0Gxc+dO0a9fP7FhwwYRCATEqFGjxCuvvCL8fr/48MMPxYABA87ItWVGcCdw4MABxo8fj91uJy4ujjFjxpCdnU1JSQmvvfYaTz75JGlpaUiSRMeOHRs1osH5QEN+ncjixYsZMmQIbdq0aQYrm05DfhUWFlJeXs6ECROQZZkuXbqQmZlZr8/nGw35tHbtWvr06cOQIUNQFIWJEyficrlYtWpVc5t8Svbu3cvBgwe55557sFgs9OjRgzFjxrBkyRI2btyIz+dj6tSpqKrKddddR9u2bfnss89+8nZNgTuByZMn8/HHH1NRUUF+fj6fffYZw4cPJysrC0VR+Oyzzxg2bBijR49mzpw5iBYyVkFDftUmFArx8ccfM2HChGaysuk05FdKSgrjxo1j0aJFaJrGDz/8wDfffMPQoef/IKUN+aTrer3n265du5rByqZRPThlbfuFEOzatYvs7GzS09MjhjDr1q3bGbkZmQJ3AoMGDWLPnj3069ePoUOHEhsby80330xeXh6lpaXs3r2bVatWMX/+fJYsWcIHHzSc7OR8oiG/arNmzRr8fj9jxoxpJiubzsn8uuaaa3jllVfo2bMn119/Pbfddhvdu58kS9d5QkM+DR06lC1btrB27VqCwSDvv/8+hw4dwufznbrSZqZTp04kJSUxb948AoEA27dvZ9WqVVRWVlJeXo7H44ko/1NSEtTGFLhaaJrG3XffzWWXXca2bdv4+uuviYuL45FHHsHhMFIL3nfffTidTjp27MjEiRNZs2ZNM1t9ak7mV20++OADxo8fj81WT9av85CT+bV7924eeOABnnzySXbu3Mm//vUvPvvsM959991TV9yMnMynzp0785e//IXZs2czbNgwtmzZwtChQ0lJSWlus0+J1Wpl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8vJycnNzefDBB5EkKTw/EAiEhym/5ZZb+P3vf8/HH3/M0KFDueqqq2jduvUpfb9QMAUuiqktMgCSJIWzGsmyXCdDU3X/VUN1VF9kVqu1zrzaddW+GE+koqKC/v37M3PmzDrLkpKSwt+rc2A0xIm2nw7Vqem+/PJL1q1bx7333ssNN9zAjBkzWLZsGXPmzGHGjBl0794dh8PB/fffX2cf1bcvTtxn1RmlTix3Kqr7JZ977rkI0QTCT4VvvfVWLr30UlavXs2///1vXnzxRRYsWED//v0buReiG1PgLlDi4uIoKioiGAyGL9IzlX7uu+++i4jodu7cSceOHQEjHdzq1atp1arVT0pu06lTJ/bv3x8RrW3dupX4+PhGRW/VOJ1Oxo4dy9ixYxk6dCiPP/44M2bMYPv27QwePJgbbrgBMKKpQ4cOnba9tdmxY0f4u67rfPfddwwePLhOuYSEBJKSksjLy2P06NEN1peWlsbkyZOZPHkyP//5z1m+fLkpcFWYvZEXKD179kSWZebMmcP+/ft566232Lx58xmpu6SkhFmzZrFnzx4WLVrEihUruPXWWwEjlZ/VauWBBx5gx44d7N+/n9WrV/Pss882aRuXXHIJbdu25bHHHiMrK4s1a9bw4osvMnny5EbXsXTpUj744AN2797N3r17+fzzz8NC3K5dO7Zu3crmzZvJzs7m8ccfrxOJnS7r1q3jH//4B3v27OGpp56iuLiYa6+9tk45SZKYNm0a//d//8eSJUvIyclh586d/O1vfws/0X3qqaf46quvyM3NZfPmzezatSvsg4kZwV2wxMfH8/TTT/Pcc8/x5ptvMm7cOG655ZYzInKjRo0KZ15XVZWHH344HKG43W7efvttnn32WaZMmUIoFKJdu3Zcf/31TdqGLMvMnTuXJ554gptuugmXy8X111/P1KlTG12Hx+Ph5Zdf5s9//jOKotC7d2/++te/AjBp0iR27NjBz3/+c1wuF1OnTuXYsWNNsrEhpk6dyqpVq3jyySdp06YNL730El6vt96yt99+O6qqsmDBAv74xz8SGxtLnz59whFdKBRixowZHD16lLi4OMaPH89tt912RuyMBsycDCYm55BRo0bxi1/8gokTJza3KRcEZhPVxMQkajEFzsTEJGoxm6gmJiZRixnBmZiYRC2mwJmYmEQtpsCZmJhELabAmZiYRC2mwJmYmEQtpsCZmJhELabAmZiYRC2mwJmYmEQt/x92RWPtmRL1NAAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# RMSE 3\n", + "figsize = (7.48031 / 3, 1)\n", + "g = sns.lineplot(\n", + " data=rs,\n", + " x=\"n_samples\",\n", + " y=\"value\",\n", + " style=\"method\", hue=\"method\",\n", + " hue_order=hue_order,\n", + " palette=palette,\n", + " orient=\"x\",\n", + " errorbar=\"se\",\n", + " err_style=\"bars\",\n", + " dashes=True,\n", + " err_kws={\"capsize\": 3},\n", + " legend=False\n", + ")\n", + "# sns.despine(left=True, right=True, top=False)\n", + "g.set(ylabel=\"RMSE\")\n", + "g.set(xlabel=\"number of samples\")\n", + "g.set(xlim=(85.75, 90.25))\n", + "g.set(xticks=np.arange(86, 91, 1))\n", + "g.set(ylim=(1.95, 4.1))\n", + "g.set(yticks=np.arange(2., 4.1, .5))\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "plt.subplots_adjust(left=0.03, right=1, top=1, bottom=0.125, hspace=0.15, wspace=0.1)\n", + "#plt.savefig(\"figures/spatial_agg_RMSE_zoom2_carbon.svg\")" + ] + }, + { + "cell_type": "markdown", + "id": "7b282eb4-ea1d-40d6-abd7-f77021248f5f", + "metadata": {}, + "source": [ + "# Plots" + ] + }, + { + "cell_type": "code", + "execution_count": 117, + "id": "e9305ab9", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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Q4MGDXj33XcpVaoUAC1atGDUqFHEx8eTkZFBq1atmD59eqH3pYdS0A4MQgghhBBCPCC5RyCEEEIIIexGik8hhBBCCGE3UnwKIYQQQgi7keJTCCGEEELYjRSfQgghhBDCbqT4FEIIIYQQdiPFpxBCCCGEsBspPoUQQgghhN1I8SmEEEIIIezGLYvP27dvM27cOCIjI2nZsiXvv/9+nssmJibSpUsXwsPD6dOnDykpKRbzN27cSMuWLYmMjOSFF14gPT3d1uFbKGhbkpKSGDZsGNHR0URHRzN8+HDOnj1rnn/kyBHq1KlDZGSk+d/KlSvt1Ip/FebYhIWFERERYY43Pt7y9Wmucmx27dplsd8jIiIICwvjs88+A5zn2NyrMMfKFej1el5++WXatm1LZGQkjz/+OLt27TLPP3XqFH369CE8PJwuXbpw9OhRB0b74G7cuEF0dDR9+vQxT3OnNu7fv58uXboQERFBmzZtzPlkrza6W37kZePGjfTs2ZMGDRowfvx4i3n329f79u2jXbt2REREMHToUK5cuWIxf/HixURHR9OkSRMSEhIwGAw2b481POhnibvul0JR3NCECROU0aNHK2lpacpvv/2mREVFKT/88EOO5a5fv640btxY2blzp6LT6ZSVK1cq7du3VwwGg6IoinLw4EElOjpa+f3335W0tDRl5MiRyqRJk5yyLV9//bWyZ88e5fbt24pOp1Pmz5+vdOrUyTz/8OHDSvPmze0Zeq4K2h5FUZTQ0FDlzz//zHWeKx2be3399ddKo0aNFI1GoyiK8xybexW1fc4qIyNDefPNN5Xz588rJpNJ+fHHH5VGjRopP//8s6LX65W2bdsqq1atUnQ6nbJjxw6ladOmys2bNx0ddpG99NJLyjPPPKM8+eSTiqIobtXG77//XmnVqpXy448/KiaTSbl69apy/vx5u7bR3fIjL/v371c+//xz5dVXX1VeeOEF8/T77es///xTiYiIUA4dOqRkZmYqM2bMUPr372/+/Y8++khp3769cuHCBeXatWtK7969lSVLlti9fUXxIJ8l7rxfCsPtis+MjAylfv36SkpKinna66+/rkycODHHsps3b1Z69epl/tloNCqPPvqo8v333yuKoij/+c9/lHnz5pnnnzx5UmnQoIG5aLC1wrTlXlevXlVCQ0OV69evK4riHAVOYduTX/HpysdmzJgxyrRp08w/O8OxudeDtM+VxMfHK++++65y8OBBpXnz5orJZDLP69Gjh/LRRx85MLqiO3z4sNKvXz9l69at5uLTndr41FNPKZs3b84x3V5tLC75cbelS5daFJ/329eLFi1Sxo4da55348YNpV69esq5c+cURVGUvn37Khs3bjTP//LLL5VWrVrZuhk2U9DPkuK2X/Lidrfds281P/zww+ZpderUyXE7He5cGq9Tp475Zy8vL2rXrs2pU6cASElJsZgfGhpKVlYW586ds1H0lgrTlnslJiZSoUIFypYta5528+ZNWrRoQZs2bXjllVe4efOmtUPOV1HaM2jQIJo3b87w4cMtlnPVY3Pz5k0OHDhAz549c0x35LG514Oce65Co9Hw66+/Urt2bVJSUggNDcXT89+PRFdtr16vZ9asWSQkJODh4WGe7i5tNJlMnDhxghs3btChQwdiYmJ46aWXuHXrlt3aWBzy437ut6/v/ftapkwZKleunOff17p163L58mXS0tLs1ALrKcxnSXHaL/lxu+JTo9FQokQJi2mlSpUiIyMj12VLliyZ57L3zvfw8CAgIMBufQsL05a7XbhwgVmzZjF16lTztJo1a7Jjxw6+++47PvjgAy5fvszkyZNtEndeCtueDRs2cODAAT777DPq1q3L0KFDzfveVY/Nrl27CAkJITIy0jzNGY7NvYraPlehKApTpkyhYcOGxMTEkJGRke9ngStZtWoVMTExhIWFWUx3lzZevXoVg8HA3r172bBhA59++inXr1/ntddes1sb3T0/CuJ++7qwf1+z/+9q+7CwnyXFZb/cj9sVn/7+/jkOUlpaWo4Piuxl7y1W7l42t/np6ekEBARYOercFaYt2S5fvsyQIUMYPnw4cXFx5ukVKlSgdu3aeHp6EhQUxPTp0/nmm2/IzMy0Wfz3Kmx7oqKiUKvVBAQEMH78eLy9vfn555/N63K1YwOwbds2evXqZTHNGY7NvYraPlegKAoJCQlcuXKFxYsX4+HhQYkSJfL9LHAVZ8+eZefOnYwZMybHPHdpo5+fHwD9+/cnKCiIUqVKMWLECL755hu7tdGd86Og7revC/v3Nfv/rrQPi/JZUhz2S0G4XfFZo0YNAE6fPm2edvLkSWrXrp1j2dDQUE6ePGn+OSsri1OnThEaGgpA7dq1LeafOnUKT09Pqlev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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "figsize = (6.556, 1.750)\n", + "g = sns.catplot(\n", + " data=result_scores_cfrac[\"test\"].query(\"target == 'BMag_ha'\"),\n", + " x=\"value\",\n", + " y=\"C_qfrac\",\n", + " hue=\"method\",\n", + " hue_order=[\n", + " \"linear\",\n", + " \"\\power{}\",\n", + " \"RF\",\n", + " \"PointNet\",\n", + " \"KPConv\",\n", + " \"MSENet14\",\n", + " \"MSENet50\",\n", + " ],\n", + " col=\"metric\",\n", + " kind=\"bar\",\n", + " palette=sns.color_palette(\"Set2\", n_colors=7),\n", + " legend_out=True,\n", + " height=1.750,\n", + " orient=\"h\",\n", + " sharex=False,\n", + " edgecolor=\".0\",\n", + " linewidth=0.05,\n", + " errorbar=None,\n", + " estimator=np.median,\n", + ")\n", + "# g.set(xlim=(0.5, 1.01))\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "plt.subplots_adjust(left=0.08, right=0.97, top=1, bottom=0.075, hspace=0.15, wspace=0.1)\n", + "#plt.savefig(\"figures/species_bplot_b.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 118, + "id": "13c4ea56-8d73-4dc1-9a99-3e0c8e7109fa", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAp8AAAD1CAYAAAAWGJ4dAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8o6BhiAAAACXBIWXMAAA9hAAAPYQGoP6dpAABZaklEQVR4nO3dd3QUZffA8W/KbgqhhhICoZOEmoSSIL0LkSKIICA9UgX0pRiaFOEVkCIgAsKLUkQQhIAgTREVVBAFsQRBpAUBJRSTbLZmf3/wY2VJCCnbZnM/53AOmZnM3Jl57ubuPM/MeJjNZjNCCCGEEEI4gKezAxBCCCGEEAWHFJ9CCCGEEMJhpPgUQgghhBAOI8WnEEIIIYRwGCk+hRBCCCGEw0jxKYQQQgghHEaKTyGEEEII4TBSfAohhBBCCIeR4lMIIYQQQjiMFJ/CppYtW0bv3r2dHYYQDidtX4jsSY6I+6T4FI+1detWWrdunaNlBw8ezIoVK+wcUdb69etHWFgYYWFhREZG0rVrV/bu3Wu1zIcffshzzz1H/fr1adSoEaNHj+bKlStOiVe4Pndq+/Hx8YSFhbFkyRKr6WazmTZt2hAWFsaxY8cs03fv3k23bt2IioqiYcOGPPPMM2zdujXLbT74b8+ePfbdWeFSlJYj27Zts5qu0WiIiooiLCyMpKSkTPPq16/Pk08+mWl9x44ds7T58PBwWrRowYwZM0hNTc00/8F/HTp0sN9OKoi3swMQ7iEjI4OMjAwKFSrk1DgGDBjACy+8gFarZd++fYwbN47KlSsTHh4OwPHjx3n66aeJjIwEYNGiRcTFxbF7925UKpUTIxdKpZS2DxAUFMSuXbsYM2YMHh4eAHz//fcYjUardR05coT4+HgmTZpE06ZN0el0/Pzzz9y+fTvLbT6oSJEidtpDoVSukiNBQUHs3LmTHj16WKYdOHCAIkWKoNFoMi1/4MAB6tevT1JSEidPniQqKirTMl9++SUAv/32G5MmTcJgMDBnzhyr+Z6e/17n8/LysuUuKZZc+XQz/fr1Y968eUydOpWoqChat27NF198wfXr1xk4cCCRkZE899xzXL161er31q9fT5s2bYiIiOCZZ56xXAE5duwYU6dO5erVq5ZvbseOHSMpKYmwsDD27t3LM888Q926dTl79mymbhWj0ciSJUto2bIlderUoWPHjhw6dMhu++/n50epUqUICQnhhRdeoHDhwlZXcxYsWMBzzz1HeHg44eHhzJ49m4sXL3L+/Hm7xSQcQ9p+9m0foEGDBmRkZPD9999bpiUkJNClSxer5b744guaNm1K3759qVixIqGhoXTv3p0hQ4Zkuc0H//n4+NhtH0X+FPQcadeuHT///DN//vmnZdrOnTsztf/7duzYwVNPPUVsbCw7duzIcpnAwEDKlClD8+bN6devH4cPH840/8H8KFGihM32R8mk+HRDH374IdWrV2fHjh20aNGCiRMnMmXKFAYMGMBHH30EwNy5cy3Lb9u2jfXr1zN9+nR2797N008/zdChQ0lKSiIqKor4+HiCgoI4cuQIR44csfr2t3TpUl5++WX27NlDSEhIpliWLVvG1q1bmTx5Mnv27CE+Ph5v70dfcI+Kisr2365du3J0DDIyMjhw4AB3797N9orm/Ss5xYoVy9F6hWuTtp992/fw8KBz587s3LkTAJ1Ox/79++natavVciVLluTMmTNcunQpR9sUylGQc6RQoUK0bt3astyNGzc4depUll3hf/75J6dPn6ZNmzY89dRT7N27F71en+36fX19M/UiiKxJt7sbqlevHgMGDABg5MiRbNq0icaNG9OqVSvg3rffWbNmWZZfsWIFU6dOpXnz5pb5n3/+Obt27WLkyJEEBATg5eVFqVKlMm1r6NChNG3aNMs4tFota9eu5Y033qB9+/YAVKhQIdvYExISsp0fGBiY7fz//e9/rF+/Hr1ej9FopGzZso8cY2M2m3nzzTdp2rQpQUFB2a5XKIO0/ce3/a5du/Lcc88xbdo0PvvsM6pUqUKlSpWslunbty9ff/017du3p1KlStSvX582bdrQpk2bLLf5oF27dmVZaAjXUJBzBO61/9dff53hw4ezc+dOWrVqRUBAQKbldu7cSfPmzQkICCAgIIDy5cvz6aefEhsbm+V6L1++zAcffEDDhg2tpj/8c2xsrFW3fEElxacbCg0Ntfy/ZMmSAFSrVs0yLTAwkDt37mAymdBqtSQlJfHyyy9bxoAB6PV6ypQp89ht1ahR45HzLl26hF6vJzo6OsexV6xYMcfLZuXZZ59l4MCB/PXXX8ydO5cxY8Y8sptj7ty5nD17lg8++CBf2xSuQ9r+49t+1apVqVixIp999hkJCQmZrnoCBAQEsG7dOs6d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6lhHRkYSFhbGgQMHAOefj5zKzXkryOLj46ldu7bV+fzzzz8t88+ePUvPnj2JiIigU6dOnDhxwonROs/GjRvp3r07tWvX5uWXX7aa97hjtG/fPtq0aUNkZCSDBw/mxo0bjgw9S5IfeafX65kyZQqtW7cmKiqKp556il27dlnmK7E9uKrbt28TExNDz549LdNc4via3dC4cePMo0aNMqekpJh/+eUXc3R0tPmbb77JtNytW7fM9evXN+/cudOs0+nMK1euNLdt29ZsMBjMZrPZfOTIEXNMTIz5119/NaekpJhHjBhhnjhxosvtx+HDh8179uwx//PPP2adTmeeP3++uUOHDpb53377rblx48YOi/thOd0Ps9lsDg0NNf/+++9ZzlPK+XjY4cOHzfXq1TNrNBqz2ez885FTed3fguaVV14xv/HGG1nO0+v15tatW5tXrVpl1ul05oSEBHPDhg3Nd+7ccXCUzrd//37zwYMHzTNnzjS/9NJLlumPO0a///67OTIy0nz06FFzenq6ecaMGea+ffs6azcsJD/yLi0tzfzmm2+aL1++bDaZTObvvvvOXK9ePfMPP/yg2Pbgql555RXz888/b3722WfNZrPr5JvbXfnUaDTs27ePl156iYCAAGrWrEm3bt346KOPMi178OBBKlWqRJcuXVCr1cTFxZGWlsZ3330HwPbt2+nevTs1atQgICCAsWPH8sknn5Cenu5S+9GiRQtiY2MpXLgwarWawYMH88cff3D79m27x/k4udmPx1HK+XjYRx99RGxsLH5+fnaP01Zsed4KsuPHj6PVaomLi0OtVtO1a1fKly9vuQpekLRv3562bdtSvHhxq+mPO0a7du2iefPmNG7cGF9fX8aOHcvJkye5fPmyM3YDkPzIL39/f8aOHUtISAienp40aNCAevXqcfLkSUW2B1d17NgxLl++zNNPP22Z5irH1+2Kz/vdzdWqVbNMCw8Pz9SdDvcuPYeHh1t+9vLyonr16pw9exaAc+fOWc0PDQ0lIyODS5cu2Sn6f+VmPx52/PhxSpUqZfUhf+fOHZo0aUKrVq149dVXuXPnjq1DzlJe9mPAgAE0btyYoUOHWi2nxPNx584dDh06RPfu3TNNd8b5yKn8tL+C6MMPPyQ6OpouXbqwbds2y/Rz584RGhqKp+e/H7VyHK097hg9/DldrFgxypYt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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "figsize = (6.556, 1.750)\n", + "g = sns.catplot(\n", + " data=result_scores_cfrac[\"test\"].query(\"target == 'V_ha'\"),\n", + " x=\"value\",\n", + " y=\"C_qfrac\",\n", + " hue=\"method\",\n", + " hue_order=[\n", + " \"linear\",\n", + " \"\\power{}\",\n", + " \"RF\",\n", + " \"PointNet\",\n", + " \"KPConv\",\n", + " \"MSENet14\",\n", + " \"MSENet50\",\n", + " ],\n", + " col=\"metric\",\n", + " kind=\"bar\",\n", + " palette=sns.color_palette(\"Set2\", n_colors=7),\n", + " legend_out=True,\n", + " height=1.750,\n", + " orient=\"h\",\n", + " sharex=False,\n", + " edgecolor=\".0\",\n", + " linewidth=0.05,\n", + " errorbar=None,\n", + " estimator=np.median,\n", + ")\n", + "# g.set(xlim=(0.5, 1.01))\n", + "fig = plt.gcf()\n", + "fig.set_size_inches(figsize)\n", + "plt.subplots_adjust(left=0.08, right=0.97, top=1, bottom=0.075, hspace=0.15, wspace=0.1)\n", + "#plt.savefig(\"figures/species_bplot_v.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 119, + "id": "11155116", + "metadata": {}, + "outputs": [], + "source": [ + "target = \"BMag_ha\"" + ] + }, + { + "cell_type": "code", + "execution_count": 122, + "id": "b0d996be-ff49-4564-a4f6-4938ba36a90f", + "metadata": {}, + "outputs": [], + "source": [ + "dff = []\n", + "\"\"\"\n", + "df_ensemble = df_test.copy()\n", + "df_ensemble[\"method\"] = \"ensemble\"\n", + "for target in target_vars:\n", + " df_ensemble[f\"{target}_pred\"] = 0\n", + "ensemble_member = [\"Minkowski\", \"KPConv\", \"PointNet\", \"exp.\\ model\"]\n", + "\"\"\"\n", + "best = {\n", + " \"MSENet14\": 0,\n", + " \"MSENet50\": 3,\n", + " \"KPConv\": 4,\n", + " \"PointNet\": 4,\n", + " \"RF\": 3,\n", + " \"linear\": 0,\n", + " \"\\power{}\": 0,\n", + "}\n", + "for name, split in product(models, [\"test\"]):\n", + " if \"treeval\" in name:\n", + " continue\n", + " dfr = results_corrected[name].query(f\"run == {best[name]} & split == @split\").reset_index(drop=True).copy()\n", + " dfr[\"method\"] = name\n", + " \"\"\"\n", + " if name in ensemble_member:\n", + " for target in target_vars:\n", + " df_ensemble[f\"{target}_pred\"] += dfr[f\"{target}_pred\"] * (\n", + " 1.0 / len(ensemble_member)\n", + " )\n", + " \"\"\"\n", + " dff.append(dfr)\n", + "# dff.append(df_ensemble)\n", + "\n", + "dff = pd.concat(dff, axis=0)" + ] + }, + { + "cell_type": "code", + "execution_count": 123, + "id": "7d0d7ff0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "KPConv\n", + "MSENet14\n", + "MSENet50\n", + "PointNet\n", + "RF\n", + "\\power{}\n", + "linear\n" + ] + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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TYokSZhMJm666SYyMjL8evbokSNHAGjWrJk7rWXLlj5x5KwOGhJOI5RFQ4KQUAyRREbHktL8diRjBNu2bbvEU0aQ46EM+M3/qyyX8qr1B3711H344Yf5+OOPSU9Pp23btoDrF1u7di0jRozg9OnTvPDCC5SWljJkyBBvirgqVqu1iqNkTT11KwM1h0bUu+J9lZtMZFXD5BDkxBmJNEFSo2RaNW1PeGILn/e7aoOmaRw4cIBmzZoFJBCj1WqtVsR1r8S2ZcsWnnvuOQYPHuyR/tVXX/Hzzz/z4Ycf0qZNGxYuXOg3sZnN5irCqqmnrt3u6qtZutxdo7IvdM88YwcOHanR84HiwIEDASurMsbdlfBKbBs3buSFF16okt65c2e3c2N6ejrTpk3zJvtq0bRpU8C1pS8lJQVwrd82b9682nlERUXRtGlTgoODf3ehWH1NSMjVg1J7JbaGDRvyxRdfMGXKFI/0L774goYNXYHiCgoKiI6O9ib7amE2m+nTpw8zZszgjTfe4MSJE3zzzTc1ih1rNBqJjY31m406nngV5nTDhg1MmDCByMhIWrVqhSRJ7N69m5KSEmbNmkWXLl1YvHgxp0+fZvz48f6wG3DNs7344ousWbOGsLAwxowZ49U8m05g8DqmbklJCcuWLePo0aMIIUhOTua+++6r8X5End8Pfg3grKNzIV5P6jocDnbs2MGZM2equBHdf//9tbVL5wbEq5pt7969jBkzhsLCQux2OxERERQVFRESEkJ0dDS//PKLH0zVud7xarz/2muvcccdd5CVlUVwcDBff/01//nPf2jfvj2TJk3ytY06NwheiW337t1kZmZiMBgwGo3Y7XYaNmzIpEmTeO+993xto84NgldiM5vNOJ2uo2vi4uLc65SSJHHu3DmfGadzY+HVAKFjx45s3LiRZs2auXeub968mTVr1tC5c2df26hzg+DVAOHcuXPYbDYSExNRFIW5c+eyY8cOkpKSGDNmDDExMf6wVed6R+gIIYQoKioSEydOFKmpqSI9PV0sWrSork2qwuTJk0WbNm1Eamqq++fkyZPu6/v27RODBw8W7dq1E/369RObN2/2eH7FihWiV69eon379iIzM1OcOXMmoPbXqBndvHlzte67HptSX3n9+pvHHnuM5557rkq60+lkzJgx/PGPf2TRokWsWLGCsWPHsmrVKqKi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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "for target in target_vars:\n", + " for group, group_df in dff.groupby(\"method\"):\n", + " print(group)\n", + " sns.set(\n", + " rc={\"figure.figsize\": (3.28125003459, 4)},\n", + " context=\"paper\",\n", + " style=\"whitegrid\",\n", + " )\n", + "\n", + " f = sns.lmplot(\n", + " x=target,\n", + " y=f\"{target}_pred\",\n", + " hue=\"C_qfrac\",\n", + " height=3.28125003459 / 2,\n", + " data=group_df,\n", + " fit_reg=False,\n", + " facet_kws={\"legend_out\": False},\n", + " )\n", + " # f.ax.set_title(group)\n", + " plt.tight_layout()\n", + " break" + ] + }, + { + "cell_type": "code", + "execution_count": 125, + "id": "f09b9d03", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "BMag_ha\n", + "linear\n", + "RF\n", + "KPConv\n", + "PointNet\n", + "\\power{}\n", + "MSENet14\n", + "MSENet50\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXMAAADhCAYAAAA6Y1VuAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8o6BhiAAAACXBIWXMAAA9hAAAPYQGoP6dpAABwW0lEQVR4nO2dd3wUdf7/nzNb0gsQ0EASahIsQEKVIgIiyJ2CeocndvxhgTvxLHc2/GI/9PQ8UcTCqVg5FQUsRxFFilTpNYGQnpCEtE22z8zvj8kOu8kmpJLCPB+Xk536+czuvOY978/7834LiqIo6Ojo6Oi0a8TWboCOjo6OTtPRxVxHR0enA6CLuY6Ojk4HQBdzHR0dnQ6ALuY6Ojo6HQBdzHV0dHQ6ALqY6+jo6HQAdDGvJ7IsY7VakWW5tZuio6PTBDrqvayLeT2x2+0cOXIEq9Xa2k1pcWRZZv/+/R3ux14ben87Lv766LmX7XZ7K7So5dDFvIGcDxNmFUXB5XKdF30Fvb8dmfOhjx50MdfR0dHpAOhirqOjo9MBaDdi/tRTT3H55ZczePBgJkyYwNtvv62tS0lJ4cYbb2TQoEFcc8017Nq1y2ff1atXc+WVV5KUlMRdd93FqVOnznXzdXR0dFqUdiPmd9xxB+vWrWP37t18+umnrFq1iv/973+4XC5mz57NxIkT2blzJ3fffTdz5syhrKwMgBMnTvD444/z3HPPsW3bNnr27MnDDz/cyr3R0dHRaV7ajZj369ePwMBA7bMoimRkZLBjxw7sdjuzZs3CbDYzbdo0YmJiWLt2LQCrVq1i7NixjBo1isDAQB544AH27NlDZmZma3VFR0dHp9kxtnYDGsKrr77Kxx9/jM1mo0ePHkydOpW1a9eSkJCAKJ55LvXv35/U1FRAdcEMHDhQWxcZGUl0dDQpKSnExcU1uA2yLCNJUtM704bx9K+j99OD3t+OiyRJGAyGWte1l2tQWx+8aVdi/vDDD/PQQw9x4MAB1q9fT3h4OJWVlYSFhflsFx4ejsViAcBqtfpdX1lZ2ag2HD9+vF7bCYLQqOO3FQRB4ODBg63djHOG3t+Oy+DBg/0uT0lJOcctaTxDhgw56zbtSsxB/REOHDiQTZs28eabb3LhhRdSUVHhs43FYiEkJASA4ODgOtc3lH79+hEaGnrW7bzfFNobiqLgcDgICAho9w+l+qD3t+NSV5x5QkICwcHB57A1LUu7VRxJksjIyCA+Pp6UlBSfmV5HjhwhPj4eUL+wo0ePauvKysrIy8sjISGhUecVRRGDwXDWP0EQ2s3fm2++yc033+yzzO12+3zu378/W7dubfW2ttRf9f62l78dO3bQv39/JElqlv4+/vjjde5XWVnJddddxyWXXMIvv/xS57a5ubnMmDGDpKQkFi1a1OS+jhgxgpEjR/Liiy+e9Zp888032ufaqM993Fb+6qVNjVK0c4zFYmHFihVUVFQgyzK//fYbn3/+OaNGjWL48OGYzWbef/99nE4n3377LdnZ2Vx11VUATJ06lY0bN7J161bsdjsLFy4kKSmpUf7y85nNmzczdOjQc3IuT1TSZZddRmJiIhkZGWfdJz8/n7lz5zJixAiGDBnCzJkzOXbsmM82K1as4JprrmHgwIFMmTJFGySvjtvt5g9/+IPPuRcvXsyjjz561nbcdtttJCYm8tVXX/kst1qtJCcnk5iYSHZ29lmP43Q6SU5OJisr66zbnitcLhf3338/JpOJP//5zzz44IPs37+/1u2/+OILioqK+OKLL5g5c2adx162bBkzZsxg0KBBjB071u823333HY899hgfffQRJ06caFJfOiLtQswFQeCbb75h/PjxDBkyhCeffJKZM2dy6623YjKZWLx4MWvWrGHo0KG8/fbbLFq0iMjISAD69u3LCy+8wLx58xgxYgQnT57k1Vdfbd0OtUO6du2K2Ww+J+eyWq1ceumlPPTQQ/Xe5+9//zulpaUsXbqUL774gsjISO69917tNfvnn3/mqaee4u677+b777/nrrvu4uGHH2bfvn01jvXWW29pvx8P48ePZ+PGjfXKZ3LhhReycuVKn2Vr164lPDy83v0xm82MGjWKX375pd77NBar1cr//d//MX78eL777jsmT57M888/X2O7J598EovFwgcffMCf//xnHnjgAWbPnl3rA6egoIABAwaQmJh4Vremw+HgyiuvZMaMGbVu061bN37/+98DUFhYWGN9RkYG99xzD3PnzuX555/nuuuuY/ny5XWetyPRLsQ8NDSUpUuXsnPnTvbs2cPq1au55557tFeoxMREvvzyS/bv38/333/PsGHDfPafMmUK69evZ9++fbz//vtccMEF56Tdt912Gy+99BLz5s0jOTmZCRMm8Msvv5Cfn8+dd95JUlISN910Ezk5OT77ffTRR1x55ZUMGjSIP/zhD2zfvl1bd+LECe6++25GjBjB0KFDufvuu31upu3bt5OYmMjWrVv53e9+R3Jysk/cfV0sWbKEkSNHMnz4cBYtWuTjb0xMTOTXX38FoKioiLlz5zJ69GiSk5O55ZZbOHLkiLatw+Fg3rx5jBw5koEDB3L11Vfz448/1vu6XXHFFTzwwAOMGjWq3vvs37+f22+/nf79+9O3b1/uu+8+8vLyKCoqAlSr7pprrmHatGnExsYyffp0xo8fz9KlS2sc57vvvuPvf/+7z/L+/fsTEBDAgQMHztqWq666ioMHD5Kbm6stW7lyJVOnTq2x7YcffsioUaMYMmQICxYs4OGHH+axxx4DYNy4cWzYsKFe/d+5c2et3/eXX37JtGnTSEpKYvz48bz++uu43W5t/TvvvMPmzZtZsGABV1xxBU8//TSdOnXyOf6rr77KyZMn+eCDD7SAgjvvvJN7772XWbNmUVxcXKNNiqLU20Vwxx13MGvWrLO6P00mE+A/gdajjz6K2+3moYce4v/9v//HnDlz6nXujkK7EPP2zBdffEF8fDzffPMNV1xxBX//+9958sknueOOOzSrYcGCBdr2X331FR999BHz58/nu+++47rrruOee+7RXs2tViuTJ0/ms88+47PPPsNkMvm1YN966y0WLFjARx99REpKCosXL66znUePHmXv3r189NFHPPvss3zxxRd88803fre12+0MHTqU999/n6+//pq+ffsye/ZsHA4HoD6MDh06xHvvvcf333/P448/7mOZJSYm8vXXXzfsQp6FpKQkfvjhByorK3E6naxYsYL+/fsTFRUFqG6LgIAAn30CAwPZs2ePT78effRRnnnmGb+W5NixY+slriEhIUyYMIFVq1YBcOrUKfbu3cvVV1/ts922bdt45ZVXePDBB/nyyy9xuVz8/PPP2vorrriCnTt3YrPZznrOur5vRVF49NFH+fbbb3n66af56quvfL7bo0ePMnHiREaMGEFYWBgjR47kz3/+s8/xH374Yb788ssakWG33347a9asoXPnzjXa5HQ6NfFtToxGI06ns8byY8eOcfPNN9OrVy+io6OZNGkSf/jDH5r9/G0VXcxbmMGDB3PHHXfQq1cv5syZQ2lpKaNGjWL8+PH07duX2267jR07dmjbL168mCeffJKxY8cSGxvLbbfdxpAhQzRhGDBgAH/84x/p27cvCQkJPPPMM+zfv9/HCgT429/+xsCBAxkwYADTp0/3OYc/ZFnmhRdeID4+nquvvpo//elPfPrpp363jYmJ4fbbbycxMZHevXszf/58ysrKNP9pfn4+F110EZdeeimxsbFcccUVjBw5Utu/d+/eNUShqbz22msUFBQwZMgQBg0axE8//cTixYu1t7eRI0fy/fffs3fvXhRFYceOHaxbt87ndf2f//wnI0eO9GmrN+PGjau322PatGmaq2XlypWMHz++RhTU559/ztVXX8306dPp06cPTzzxhI8rplu3bvTt25etW7ee9Xx1fd833ngjo0aN0r6L2267jfXr12vrBw0axJo1a3yWNZXCwkL27NlDr169mu2YHuLi4vjpp5983i5A7cfSpUt93hLPJ9pdaGJ7w/u10WMl9uvXT1vWpUsXSktLkSQJu91OdnY2Dz74oM8ovNPp1FxDFouFf/3rX2zZsoWioiLNFZKXl0f37t1rPa+/12Bv4uLiiIiI0D5fcsklLFu2zO+2LpeLN954QxNDSZKw2Wzk5eUBqpDNnDmTo0ePMmbMGCZNmsSll16q7b969eo629IYXnvtNURR5NNPP8VsNvPhhx8yZ84cvvjiC8xmMzfddBNpaWnceuutyLJM9+7dmTp1qmah7ty5k82bN7NixYpazzFq1CgefPBBCgoK6NatW53tGT16NBaLhf3797Nq1Sr+9re/1dgmPT2d66+/XvtsMBjo37+/zzYeV8uECRPqPF9d3/fu3bt58803SU1NpaKiArfb7eNqvPvuuwF4+eWXyczMJCUlhVmzZvG73/2uznPWxv/93//x3//+l6SkJO68806fdbt27dLOB/Dee+81eGD9ueee495779XeYj37v/LKK/z73//m3XffpaKigu+//56HHnqISy65pFH9aG/oYt7CGI1nLrFHoL1fPT3LFEXRXqdfeeUVLbTSg+e1f8GCBezbt48nnniCmJgY3G4306ZNq2GlVD/v2Qbu6grhqs57773HN998w7x58+jduzcBAQFMnz5da8PAgQNZv349GzZsYNOmTcyYMYO//vWv/L//9//qfY6GkJGRwX//+19+/vln7YG2YMEChg0bxubNm5kwYQKiKDJv3jweffRRiouL6dq1K//617+IiYkBVMHLzMysISxTpkzhvvvuY+7cuQQGBjJ8+HB++eUXpk+fXmebDAYD11xzDS+99BIlJSWMGTOmRhRLfXJtjxs3jvvvv/+s29X2fVdUVHDvvfcyZcoU5s6dS0REBN9++62Pm8tkMjFnzhzmzJnD7NmzGT58OI888gjdunVrVATT3LlzmThxIg8//DCrVq3yuVaXXnqpzwOzMeNX//rXv0hKSuKRRx6hd+/e2vKoqCief/55tm/fzrZt28jPz2fmzJn89NNP9Zob0t7R3SxtiC5dutC1a1fy8vLo2bOnz5/Hqt+3bx9//OMfGTduHP369asxIaqxZGRkUF5ern0+fPiwz43izb59+7j66quZPHkyCQkJmM3mGgOskZGRXHfddbz66qvMnTu3RaMKPA9B78E2T4xx9YeYyWTiggsuQFEU1q1bx/jx4wGYPn06K1euZMWKFaxYsYJ3330XUN1et9xyi7Z/QwYlr7vuOnbt2sU111zjdyCwd+/eHDp0SPssSZLPnAhQ3Wput7vG8vpy8uRJysvLeeSRR0hKSqJ3797k5+fXun1ERAQzZ84kPj7eb6RPfYiKimLs2LGMHDmSvXv3+qwLDAz0+V1751uqL/v372fGjBlcdNFFte4fGxvL448/TllZGSdPnmxMN9odumXehhAEgXvvvZfXX3+d4OBghg0bRllZGVu3bmXAgAGMHDmS2NhY1qxZw5gxYygtLeXll19ulnN7LNe5c+dy4sQJli1bxhNPPOF329jYWDZt2qQJ0UsvveQzuPjhhx9ywQUXcNFFF+FwONiyZYvPg+Hqq6/m4Ycf1uYCVKeyspLMzEwKCgoANYLHarUSHR2thQx6H6NPnz7ExMTw1FNP8dBDD2EymViyZAkmk0mbyl1YWMiGDRsYOnQoZWVlLF68GIfDwaxZswDo3LkzoaGh2huKZ2Zgr1696NKli9a2cePG8corr+B0Os8aqtm/f3+2bdtWa1jejBkzmDVrFiNGjGDw4MF89tlnlJeX+7wlCYKgDbxWd8HUh+7du2Mymfjss8/4/e9/z+bNm/nxxx99Zj6++eabJCcnM3DgQNxuNz/99BNpaWlcfPHFDT6fN8HBwdqg+NkoLCykqKiI3Nxc3G635vfu27dvjevscrn8ztx86qmnuOmmm3A4HFitVj766COCg4NbxG/fFtHFvI1x2223YTabWbJkCfPnzycyMpKkpCQmTpwIwGOPPcajjz7KDTfcQExMDI8//rgmSE2hf//+XHrppdxyyy1IksQf//hHbrjhBr/bzpkzh/T0dG6++Wa6dOnCQw89RHp6urY+KCiIt956i8zMTAIDA7nsssuYN2+etv7kyZNa7hx/HDx4kNtvv137PHv2bAD+8Y9/aG3yPobZbObdd9/l5Zdf5o477kCSJC666CLeffddLcpCURSWLVvG888/j9FoZPTo0Xz66ad06tSpQaXFevToQUxMDDt37mT06NE89thj5OTk8PHHH/vdvnqInzeXXXYZjzzyCK+++ipOp5Pp06czatSoGhEg48aN44MPPuC+++4jOzubK6+8ko8++ogRI0actb1dunTh2Wef5d///jdvv/02Y8aM4Z577uGTTz7RtomJieH111/XHpq7d+/m4YcfrnUguL4IglDva7ts2TLefPNN7fN1110HwPr16zVXGJwJSfSXLiM8PJxHHnmEnJwcZFmmb9++vPHGG80+2N5mUXTqRWVlpbJr1y6lvLy8tZvS4siyrJSXlyuyLLd2U84JDe3vP//5T+X5559XFEVRbr31VmXhwoXN1o5JkyYp7733ns9yi8WiDBgwQCkuLla2b9+uDB06VCktLW3SeWrr76OPPtro41bn1VdfVa699lrFarU22zF37dqlJCQkKMePH691m23btinLly9XFEXx20fPvVxZWdls7WoL6D5zHZ0GcsMNN5CQkIDVaiUrK4u77rqr0cdasmQJqampHD9+nOeee47c3Nwa8eihoaE88cQTlJeXs2XLFu69916fyKO2yrRp0ygqKiI5OdmnMlhjGTNmDDfffLMW1qvji6Ao51H56iZgtVo5cuQICQkJHf61TVEUKioqfHzIHZmW7K+iKJSU27E7JAIDDHQKD/Q5x913383+/ftxOp3Ex8fz97//vcVz4JzL71eWZQoKCggKCmryAyg7O5uwsLAGHUdRlBp99NzLF110UYfKmqj7zHX84h3qdj7QUv2ttLuwOyUUAexOiUq7i9CgMwN67733Xouc92ycq+9XFEUuvPDCZjmWt+9cpybn1x2rUy9kWebIkSMMGjSo3rk12jMt2d/P1xxl64E8oiKDKCq1MXJANHdfN/DsO7Yg59P3K8tyh++jB91nruOX88371lL9jbswHEmG3MJKJFn9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T73b6l1VJioqCoDi4mJCQkLIz8/n1VdfZffu3RQXF6MoCm63m6KiIi644IKzHnvgwIE1Pp88edJn2cUXX6z9+/jx47jdbsaNG+ezjd1uJzs7m8TERE6fPu1z3L59+7b7Mmc6zYsu5jrNRnh4OD179gSgd+/eWCwWHnnkER599FGf7aZNm8bSpUs5evQoL7/8co3jvPfee3zzzTfMmzeP3r17ExAQwPTp07WBUkVRfKqxNwaTyaT923MsT5mxxx57DJfLxfPPP0+3bt3Izs7mnnvuweVyNemc3gQGBmr/tlqtBAYG+vWDd+nSRWtXU/us07HRfeY6LYbBYECSpBoi+Pvf/56DBw8yZswYIiMja+y3b98+rr76aiZPnkxCQgJms5mysjJtfe/evTly5IhPjceDBw82W7v37dvHzJkzGTlyJH379qWkpKRB+x84cKDG5969e9e6fWJiIjabDbvdTs+ePX3+QkNDCQ8Pp0uXLuzfv1/bJy0trd1Xk9dpXnTLXKfZsNlsFBYWoigKWVlZLF68mCFDhhAWFuazXefOndmyZQsBAQF+jxMbG8umTZs4dOgQAC+99JLPttdeey3/+te/WLBgATNmzGDXrl1s3ry52dwOsbGxrFy5kvj4eDIyMnjnnXcatH9KSgqLFy/m6quvZu3atezdu5cXX3yx1u379u3LpEmTeOihh3jsscfo1asX+fn5rF69mr/85S906tSJm266iTfffJO4uDg6d+7Miy++WOv10zk/0cVcp9lYunQpS5cuRRAEoqKiuOyyy/jb3/7md9uIiIhajzNnzhzS09O5+eab6dKlCw899BDp6ena+vDwcN58802efvppli1bxsiRI7ntttv47rvvmqUfzz//PPPmzeOaa64hISGBv/71r8ydO7fe+//pT3/i+PHjXH/99URERPDPf/6TXr161bnPK6+8wmuvvcYTTzxBSUkJF1xwAaNHjyYoKAiA++67j/z8fObMmUNYWBgPPvigzzXR0dFrgOp0CJ588kkKCwt59913W7Udt912G4MHD+bBBx9s1XbonH/olrlOu+Srr74iPj6eTp06sWXLFlauXMmCBQtau1k6Oq2GLuY67ZK8vDwWLlxISUkJMTExPPnkk1xzzTUAJCcn+92ne/fufP/9900676xZs3xmsHrT1GPr6DQF3c2i0+GoPtvSg9FopEePHk069qlTp7Db7X7X9ejRA6NRt490WgddzHV0dHQ6AHqcuY6Ojk4HQBdzHR0dnQ6ALuY6Ojo6HQBdzHV0dHQ6ALqY6+jo6HQAdDHX0dHR6QDoYq6jo6PTAfj/EfbGlY6eXaIAAAAASUVORK5CYII=\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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cXjg1Rbdu3eHQqHAtm3bcP36dfzwww84dOiQ1TZr167FiRMnoFarwXEc/vzzT2RmZqJv376NOqYZd3d3aVC8PoqLi5GcnIy8vDxwHIfk5GQkJyfbPM8mk8nmzM0333wTly9fhsFggFarxXfffQd3d/cW8du3RaibpY0xZ84cKJVKbNq0CcuXL4evry8iIyMxYcIEAMDrr7+OxYsXY8aMGQgICMCSJUswb968Jh+3T58+uPvuu/HII4+A53k8+OCDmDFjhs1tFyxYgKysLMyePRudOnXCSy+9hKysLGm9m5sbvvjiC+Tk5MDV1RVDhgyx8l9fu3ZNyp1ji6FDh2LFihX44osvUFxcjP79++Orr76yinO33IdCocClS5ewbds2VFZWokuXLhg+fDief/55qUccGxuLyspKfPHFF8jPz4efnx8efPBBzJ8/X9pnfn4+3n//fZSXlyMgIACvvfZarbhnf39/BAQEICEhAcOHD8frr7+O3Nxc/O9//7NpS4cOHezaOWTIELzyyitYvXo1jEYjYmNjMWzYsFoRIGPGjME333yDZ555BiqVCuPHj8d3332HwYMH2923mU6dOuHtt9/Gp59+ii+//BIjRozAU089ha1bt0rbBAQE4LPPPkNGRga0Wi3OnTuHl19+2WrQujEwDONw2bbt27dj7dq10ufp06cDAA4fPiy5uYCbb1y20mV4e3vjlVdeQW5uLgRBQEhICD7//PM6B9uditaNjGw/NCbOvL0iCAKprKyU4pCdnYba+9///pesXLmSECLGla9Zs8bmPm+Ua4mqUE1ulGsd2rcgCGTixIlk48aNVsvVajXp168fKS0tJadPnyYxMTF1zhtw5Dj27DXHuDcHq1evJvfffz/RarXNts8zZ86QsLAwkp6ebnebuLg4snPnTkIIsWkjjTOnUJwAQghKK3TILapCaYWuUQV/Z8yYgbCwMGi1Wly/fh1PPPFErW3KKvUoLNWhQmNAYakOZZW2K8Fv2rQJaWlpSE9PxzvvvIO8vLxa8eienp544403UFlZiRMnTuDpp5+2ijxqq0ybNg0lJSWIioqyqgzWWEaMGIHZs2dLYb0UaxjSmF/zbYhWq0VycjLCwsKc/rWNEIKqqip4eno2KMqlPVBaoUNRmQ4EBAwYdOnghg7ers1ub15RFSq0BijkLEycAB93F3TvUjui4skn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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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4UIyatQoMmLECLJw4UJSUFAg7U9JSSEqlYocO3aMzJgxg8TFxZFFixaRmpoah/1ct24deeCBB8imTZvImDFjSGJiInnjjTcIx3HSMSqVihw9epQQQkhFRQV5+umnybhx40hcXBx58MEHyaVLl6Rj9Xo9eemll8iYMWPI8OHDyfTp08n+/fudvm5PPfUUefbZZ6XXDQ0NJCYmxqk2duzYQW6//fZmj5k0aRL59ttvpdc8z5P09HSiUqlsPosrV64QlUpFcnJyCCGE3HHHHeTcuXPNtv3www+TV199lcTFxZGioiJp+6OPPkrefvttolKpSGFhobT9888/J2PHjiUJCQnkjTfeIM8++yxZvnw5IYSQb7/9lvz1r39t9nzOfN7ffvstmTVrFomNjSUTJ04k7733HqmqqiI8zxNCCHn33XfJpEmTSEpKClm8eDE5duwYWb9+vc153n77bXLfffc1+f5v2bKFTJs2jVy7dq1J35YvX06ee+65ZvvfGGc+P+vvojX3338/WbBgAdm2bRtZv349SU5OJtu3b29ynPhbbmhoaFXfujs9YmTek/n2228RFRWF77//HnfccQf++c9/4qWXXsJf/vIXadSwdu1a6fjvvvsOX375JVatWoUff/wR99xzDx5//HHp0Vyr1WL69On4+uuv8fXXX0OhUODZZ59tct6PPvoIa9euxZdffonMzExs3Lix2X5evnwZ586dw5dffolXXnkF3377Lb7//nu7x+r1eiQmJuKzzz7Dzp07ERERgUWLFsFgMAAAvvzyS1y8eBGbNm3CTz/9hBUrVtiMzKKjo7Fz506HfUlLS8OYMWOk197e3oiJicH58+ebtaEtmEwmALBZy+Dl5QUAOHv2LABgwoQJOHjwYItt+fj4YPLkydizZw8AwX1z7tw53HXXXTbHpaSk4O2338ayZcuwfft2mEwm/P7779L+O+64AydPnoROp2vxnM193oQQLF++HD/88AP+/e9/47vvvrP5bC9fvoypU6di9OjR8PPzw9ixY/HUU0/ZtP/cc89h+/btTSLDHnnkESQnJ6Nv375N+mQ0GqFQKFrse2uRy+V2n9KuXLmCBx98EGFhYRg4cCCmTZuGP//5z+1+/u4KFfMOJiEhAX/5y18QFhaGxYsXo6amBuPGjcOkSZMQERGB+fPnIzU1VTp+48aNeOmllzBhwgSEhoZi/vz5GDFihCQMw4cPx3333YeIiAioVCqsXr0aaWlpKC4utjnvP/7xD8TExGD48OFISkqyOYc9eJ7Ha6+9hqioKNx11124//778b///c/usSEhIXjkkUcQHR2N8PBwrFq1CrW1tZL/tLS0FDfddBNuvfVWhIaG4o477sDYsWOl94eHhzcRBWuqqqrQr18/m219+/bFtWvXmrWhLQwZMgQ33ngj3nnnHWi1WtTX1+O9994DAFRWVgIAJk6c6LTbY/bs2ZKrZffu3Zg0aVKTKKhvvvkGd911F5KSkjB06FC8+OKLNq6YAQMGICIiAsePH2/xfM193nPnzsW4ceOkz2L+/Pk4cOCAtD82NhbJyck229pKRUUFzp49i7CwsHZrU2Tw4MH47bffYDabbbbHxsZiy5YtyMjIaPdz9gSomHcw1lEzQUFBAITwRpF+/fqhpqYGHMehoaEBarUay5YtQ3x8vPR34sQJyS+p0WiwevVqTJs2DQkJCZg2bRoAoKSkpNnzVlVVNdvPwYMHIyAgQHp9yy23OJx0NJlMePfddzFjxgwkJiYiMTEROp1O6sPs2bORnJyMOXPm4N13320Sz7x3715pgrq7oFAo8P777+PMmTNISEjAmDFj0K9fPwQFBUkREePGjUNOTg7Ky8tbbO+2226DRqNBWloa9uzZg9mzZzc5Ji8vD7feeqv0WiaTYdiwYTbHTJw40amngeY+7zNnzuCxxx7D7bffjvj4eKxfv94mP9Hf/vY33H///Xjrrbewe/duzJkzBz///HOL53TEv/71L4wfPx4DBgzAo48+arPv1KlTNt/txnmUnOHVV1/FTz/9hJiYGJv3v/322xgyZAg+/fRT/Pvf/8Zf//pXXLp0yWU7eho9Ls68pyGXX7/EoihYP3qK2wgh0uP022+/LYVWiohuirVr1+L8+fN48cUXERISArPZjNmzZzcZpTQ+b0sTd82FcDVm06ZN+P7777Fy5UqEh4fDw8MDSUlJUh9iYmJw4MABHDx4EIcPH8a8efPwzDPP4K9//atT7dsbhVdVVXV4BFJcXBz27t2LqqoqKBQKyOVybN26FSEhIQAEF8yoUaPwxx9/ICkpqdm2ZDIZZs6ciTfffBPV1dUYP358kygW4kSu7YkTJ+Lpp59u8ThHn3d9fT2eeOIJzJgxA0uWLEFAQAB++OEHGzeXQqHA4sWLsXjxYixatAijRo3C888/jwEDBrgUwbRkyRJMnToVzz33HPbs2WNzrW699Vbs2rVLen3DDTe0uv13330XcXFxeP755xEeHi5tDwoKwpo1a3DixAmkpKSgtLQUCxYswG+//ebU2pCeDh2ZdyP69euH/v37o6SkBEOGDLH5E0f158+fx3333YeJEyciMjKyyYIoV8nPz5fC8ADg0qVLNj8Ua86fP4+77roL06dPh0qlglKplBKbiQQGBuKee+7BO++8gyVLlrQqqiAmJgYnTpyQXut0OqSlpSE2NraVVrlG37594efnh+TkZCgUCowbN07a5+xIGQDuuecenDp1CjNnzrQbKxweHo6LFy9KrzmOs1kTAQhuNbPZ3GS7s1y9ehV1dXV4/vnnERcXh/DwcJSWljo8PiAgAAsWLEBUVJTLcxRBQUGYMGECxo4di3Pnztns8/T0tPleW89ROEtaWhrmzZuHm266yeH7Q0NDsWLFCtTW1rYY1uouUDHvRjAMgyeeeAIffPABduzYgYKCAqSnp+PTTz+V/KahoaFITk5GdnY2Tp06hbfeeqtdzs2yLFauXIns7Gzs27cP27Ztw4MPPmj32NDQUBw+fBgXL17ExYsXsXz5cnh4eEj7v/jiC/zyyy/Iy8vDlStXcPToUZsbw1133YX9+/c77MtDDz2EX375Bdu3b0dWVhZefPFFDBgwwCb+uHEbFRUVyMjIQHFxMcxmMzIyMpCRkWEzUWa9raSkRDpe5Oeff8apU6eQn5+P7du3Y/Xq1ViyZIkU5goIYn7s2LFmwyRFhg0bhpSUFIdZOufNm4dffvkF3333HXJzc/HGG2+grq7O5imJYRinJ17tMWjQICgUCnz99dcoLCzEN998g19//dXmmPXr1+Po0aPQaDQwm8347bffkJubi5tvvtmlc4p4e3tLk+It4cznJ2Iymeyu3Hz55Zdx8eJFGAwGaLVafPnll/D29u4Qv313hLpZuhnz58+HUqnE5s2bsWrVKgQGBiIuLg5Tp04FALzwwgtYvnw55syZg5CQEKxYsQILFy5s83mHDRuGW2+9FQ899BA4jsN9992HOXPm2D128eLFyMvLw4MPPoh+/frh2WefRV5enrTfy8sLH330EQoKCuDp6YkxY8Zg5cqV0v6rV69KuXPsMXbsWKxevRofffQRKioqEBsbi08++cQm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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXMAAADhCAYAAAA6Y1VuAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8o6BhiAAAACXBIWXMAAA9hAAAPYQGoP6dpAABtI0lEQVR4nO2deXwUVfa3n6pesi9IQIEEwpKAC5AAsouACDqjuMwERUdHfBkVRmVcZtARf4yKio4roqg4Co4Lo6KAjrLoiCBb2AlrEkISsgAJZO/0VnXfPzpddCfd2UjIQj2fT5Suqq66t7r7e0+de+45khBCoKOjo6PTppFbugE6Ojo6OueOLuY6Ojo67QBdzHV0dHTaAbqY6+jo6LQDdDHX0dHRaQfoYq6jo6PTDtDFXEdHR6cdoIt5PVFVFYvFgqqqLd0UHR2dc6C9/pZ1Ma8nVquVQ4cOYbFYWropzY6qquzbt6/dfdn9ofe3/eKrj+7fstVqbYEWNR+6mDeQC2HBrBACh8NxQfQV9P62Zy6EPrrRxVxHR0enHaCLuY6Ojk47oM2I+dNPP81VV13FoEGDGD9+PO+++662LzU1lSlTpjBw4EBuuOEGduzY4fXe1atXc80115CQkMC9997LyZMnz3fzdXR0dJqVNiPmf/zjH1m3bh27du3i008/ZdWqVfzwww84HA5mzJjBhAkT2L59O3/605+YOXMmJSUlABw9epQnn3yS5557jq1bt9KjRw8ee+yxFu6Njo6OTtPSZsS8T58+BAYGaq9lWSYrK4vk5GSsVivTp0/HbDZz0003ER0dzdq1awFYtWoVY8aMYeTIkQQGBjJr1ix2795NdnZ2S3VFR0dHp8kxtnQDGsKrr77Kv//9byorK+nWrRuTJ09m7dq1xMfHI8tnx6V+/fqRlpYGuFwwAwYM0PZFRkbSpUsXUlNT6d69e4PboKoqiqKce2daMe7+tfd+utH7235RFAWDweB3X1u5B/764EmbEvPHHnuMRx99lJSUFH766SfCw8OpqKggLCzM67jw8HDKysoAsFgsPvdXVFQ0qg3p6en1Ok6SpEadv7UgSRL79+9v6WacN/T+tl8GDRrkc3tqaup5bknjGTx4cJ3HtCkxB9eXcMCAAWzcuJGFCxdyySWXUF5e7nVMWVkZISEhAAQHB9e6v6H06dOH0NDQOo/zfFJoawghsNlsBAQEtPlBqT7o/W2/1BZnHh8fT3Bw8HlsTfPSZhVHURSysrKIi4sjNTXVa6XXoUOHiIuLA1wf2OHDh7V9JSUl5OfnEx8f36jryrKMwWCo80+SpDbzt3DhQu644w6vbU6n0+t1v3792LJlS4u3tbn+qve3rfwlJyfTr18/FEVpkv4++eSTtb6voqKCm2++mcsvv5xffvml1mPz8vKYOnUqCQkJvP322+fc12HDhjFixAheeOGFOu/JN998o732R31+x63lr17a1ChFO8+UlZWxYsUKysvLUVWVnTt38vnnnzNy5EiGDh2K2Wzmww8/xG638+2335KTk8O1114LwOTJk9mwYQNbtmzBarWyYMECEhISGuUvv5D59ddfGTJkyHm51hdffMHtt9/O4MGDGT58OA899BDHjx+v9T1Op5MXX3yRYcOGMWjQIGbPnu3lStu2bRv3338/I0aMIDExkdtvv52tW7d6naNfv3707du3xt++fftYtGgRs2fPrrPtd911F3379uWrr77y2m6xWEhMTKRv377k5OTUeR673U5iYmKd/T6fOBwOHnroIUwmE3/+85955JFH2Ldvn9/jv/jiCwoLC/niiy+YNm1aredetmwZU6dOZeDAgYwZM8bnMd999x1PPPEEH3/8MUePHj2nvrRH2oSYS5LEN998w7hx4xg8eDBPPfUU06ZN4w9/+AMmk4lFixaxZs0ahgwZwrvvvsvbb79NZGQkAL179+b5559nzpw5DBs2jGPHjvHqq6+2bIfaIJ06dcJsNp+XayUnJ3PzzTfz6aefsmTJEmw2G9OnT8fhcPh9zzvvvMN3333HG2+8wZIlS9i/fz/PPPOMtn/Pnj1cccUVLFq0iBUrVjBs2DDuu+8+MjIytGM2btzIr7/+qv3de++9dOnShSuuuIJx48axYcOGeuUzueSSS1i5cqXXtrVr1xIeHl7ve2A2mxk5ciS//PJLvd/TWCwWC//3f//HuHHj+O6775g0aRLz5s2rcdxTTz1FWVkZH330EX/+85+ZNWsWM2bM8DvgnDp1iv79+9O3b9863Zo2m41rrrmGqVOn+j2mc+fO/Pa3vwWgoKCgxv6srCzuu+8+Hn74YebNm8fNN9/M8uXLa71uu0Lo1IuKigqxY8cOUVpaWu/3/OEPfxDz588XTz31lEhISBDjxo0T69evF/n5+eKP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7TV++OEHPv3001rF0z1InjlzRrN+FUWhpKSEjh07eh37888/M3v2bF5//XWGDRvm83zr1q3D4XDUELPAwECGDh3KL7/8QlJSUq1tNxgM3HDDDbz00ksUFRUxevToGlEs9RmExo4dy0MPPVTncf4+7/Lycu6//36uv/56Hn74YSIiIvj222+93Fwmk4mZM2cyc+ZMZsyYwdChQ3n88cfp3LlzoyKYHn74YSZMmMBjjz3GqlWrvO7VFVdcwYoVK7TXnoNKfXnttddISEjg8ccf93pyioqKYt68eWzbto2tW7dy4sQJpk2bxv/+9796rQ1p6+hullZEx44d6dSpE/n5+fTo0cPrzy1Ye/fu5fe//z1jx46lT58+NRZENZasrCxKS0u11wcPHvT6oXiyd+9errvuOiZNmkR8fDxms1lLbOYmMjKSm2++mVdffZWHH364wVEFCxcu5Msvv+Sjjz4iJiam1mNjYmLo0KED27Zt07Zt374dwOuJYOPGjTzyyCM8//zzjB071u/5VqxYwYQJE3y6gtyWcn24+eab2bFjBzfccIPPWOGePXty4MAB7bWiKF5rIsDlVnM6nTW215djx45RWlrK448/TkJCAj179uTEiRN+j4+IiGDatGnExcWxd+/eRl0zKiqKMWPGMGLECPbs2eO1LzAw0Ot77Zlvqb7s27ePqVOncumll/p9f0xMDE8++SQlJSUcO3asMd1oc+hi3oqQJIn777+fN998k+XLl5OdnU1KSgrvv/++5jeNiYlhzZo1pKens2PHDl5++eUmubYsy8yZM4f09HTWrl3LsmXLuOOOO3weGxMTw8aNGzlw4AAHDhxg9uzZBAQEaPuXLFnCDz/8QGZmJkeOHGHTpk1eA8N1113HunXr/Lbl/fffZ/Hixbz00kuEh4dTUFBAQUGB16SX5zlkWWbq1Km8+eabbNmyhX379vH8889zww030KFDB8A12fjggw8yY8YMrrzySu2c1dM6nDx5ks2bN3tNfHoyduxYNm/e7HMCrjr9+vVj69atfrN0Tp06lR9++IGvvvqKjIwMXnzxRUpLS72ekiRJqvfEqy+6du2KyWTis88+4/jx43z++ef8+OOPXscsXLiQTZs2UVZWhtPp5H//+x8ZGRlcdtlljbqmm+DgYG1SvC4KCgo4dOgQeXl5OJ1ODh06xKFDh3zeZ4fD4XPl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JScnhxdffJEXXniBuXPnasf07t2bHj168NNPP7FixYoaVjm4/MhvvfUW69ato6CgAEVRqKysJD8/H3CVDOvXr59Xwd4rrriC3bt317utnlVloqKiADhz5gwhISGcOHGCV199lV27dnHmzBmEEDidTgoLC7n44ovrPPeAAQNqvD527JjXtssuu0z7d3p6Ok6nk7Fjx3odY7VaycnJoW/fvpw+fdrrvL17927zZc50mhZdzHWajPDwcHr06AFAz549KSsr4/HHH2f27Nlex910000sXbqUw4cP8/LLL9c4z+LFi/nmm2+YM2cOPXv2JCAggKSkJG2iVAjhVY29MZhMJu3f7nO5y4w98cQTOBwO5s2bR+fOncnJyeG+++7D4XCc0zU9CQwM1P5tsVgIDAz06Qfv2LGj1q5z7bNO+0b3mes0GwaDAUVRaojgb3/7W/bv38/o0aOJjIys8b69e/dy3XXXMWnSJOLj4zGbzZSUlGj7e/bsyaFDh7xqPO7fv7/J2r13716mTZvGiBEj6N27N0VFRQ16f0pKSo3XPXv29Ht83759qaysxGq10qNHD6+/0NBQwsPD6dixI/v27dPek5GR0earyes0LbplrtNkVFZWUlBQgBCC48ePs2jRIgYPHkxYWJjXcRdddBGbNm0iICDA53liYmLYuHEjBw4cAOCll17yOvbGG2/ktddeY/78+UydOpUdO3bw66+/NpnbISYmhpUrVxIXF0dWVhbvvfdeg96fmprKokWLuO6661i7di179uzhhRde8Ht87969mThxIo8++ihPPPEEsbGxnDhxgtWrV/Pggw/SoUMHbr/9dhYuXEj37t256KKLeOGFF/zeP50LE13MdZqMpUuXsnTpUiRJIioqiuHDh/PXv/7V57ERERF+zzNz5kwyMzO544476NixI48++iiZmZna/vDwcBYuXMg//vEPli1bxogRI7jrrrv47rvvmqQf8+bNY86cOdxwww3Ex8fzl7/8hYcffrje77/ttttIT0/nlltuISIign/+85/ExsbW+p5XXnmF119/nb///e8UFRVx8cUXM2rUKIKCggB44IEHOHHiBDNnziQsLIxHHnnE657o6Og1QHXaBU899RQFBQW8//77LdqOu+66i0GDBvHII4+0aDt0Ljx0y1ynTfLVV18RFxdHhw4d2LRpEytXrmT+/Pkt3SwdnRZDF3OdNkl+fj4LFiygqKiI6OhonnrqKW644QYAEhMTfb6na9eu/Pe//z2n606fPt1rBasn53puHZ1zQXez6LQ7qq+2dGM0GunWrds5nfvkyZNYrVaf+7p164bRqNtHOi2DLuY6Ojo67QA9zlxHR0enHaCLuY6Ojk47QBdzHR0dnXaALuY6Ojo67QBdzHV0dHTaAbqY6+jo6LQDdDHX0dHRaQf8f1Uebhq+W6W8AAAAAElFTkSuQmCC\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXMAAADhCAYAAAA6Y1VuAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8o6BhiAAAACXBIWXMAAA9hAAAPYQGoP6dpAABqeklEQVR4nO2deXwUVfa3n6pesi+sCiQQCAm4EBIIuyCbIDMK6gyouPuiI8woM+qojPhjcGHQcRkRBHdRRxkdEHBjc0QQgbCvgRBClk4CJGTrpNNbVb1/VLpIZyMJCUmaej6fKF1VXXVvd9f3njr33HMERVEUdHR0dHTaNGJLN0BHR0dH5+LRxVxHR0fHB9DFXEdHR8cH0MVcR0dHxwfQxVxHR0fHB9DFXEdHR8cH0MVcR0dHxwfQxbyeyLKMzWZDluWWboqOjs5F4Kv3si7m9cRut5OcnIzNZmvppjQ7sixz8OBBn/ux14beX9+lpj567mW73d4CLWo+dDFvIJfDgllFUXC5XJdFX0Hvry9zOfTRgy7mOjo6Oj6ALuY6Ojo6PkCbEfPnnnuOkSNHMmDAAMaOHcuyZcu0fSkpKUybNo3+/ftz0003sXv3bq/3rlu3jnHjxhEfH8+DDz7ImTNnLnXzdXR0dJqVNiPm9913Hxs3bmTv3r38+9//Zu3atfzwww+4XC5mzpzJ+PHj2bVrFw899BCzZs2iuLgYgJMnTzJnzhxeeOEFduzYQY8ePXjiiSdauDc6Ojo6TUubEfPevXvj7++vvRZFkYyMDJKSkrDb7cyYMQOz2cyUKVOIiIhgw4YNAKxdu5ZRo0YxfPhw/P39mT17Nvv27SMzM7OluqKjo6PT5BhbugEN4bXXXuPTTz+lvLycbt26MXnyZDZs2EBsbCyieH5c6tu3LydOnABUF0xcXJy2Lzw8nC5dupCSkkL37t0b3AZZlpEk6eI704rx9M/X++lB76/vIkkSBoOh1n1t5TOorQ+VaVNi/sQTT/D4449z6NAhfvzxR0JDQykrKyMkJMTruNDQUKxWKwA2m63G/WVlZY1qQ2pqaq37BEFo1DlbI4IgcPjw4ZZuxiVD76/vMmDAgBq3p6SkXOKWNJ6BAwde8Jg2Jeag/gjj4uLYunUrixcv5sorr6S0tNTrGKvVSlBQEACBgYF17m8ovXv3Jjg4uMZ9lZ8O2jKKouBwOPDz8/OpAao29P76LnXFmcfGxhIYGHgJW9O8tFn1kSSJjIwMYmJiSElJ8VrplZycTExMDKB+YceOHdP2FRcXk5ubS2xsbKOuK4oiBoOhxj9BENrM3+LFi5k+fXqt+91uN1dddRXbt29v8bZeij+3293ibWjO77Mh/U1KSuLrr7+u8/0pKSkMGjSIkSNHkp2dXeexv/zyCzfeeCPXXnstSUlJF9XPY8eOcc011zBu3DjWrFlzwc8kOzu71nu5tvu4Nf7VS5sapWiXGKvVyurVqyktLUWWZfbs2cMXX3zB8OHDGTx4MGazmQ8//BCn08k333yDxWLhhhtuAGDy5Mls2bKF7du3Y7fbWbRoEfHx8Y3yl19ubN26lcTExBa7vtvt5h//+AdDhgxhwIABPP3003W6xywWC3PmzGHMmDHExcUxadIkPv/8c69jnE4nr7/+OmPHjiUuLo67775bm1/x8MILLzB58mSuvvpqnnzySa99DzzwACtXrrxg2/v06UOfPn2qTbQnJSXRp08fxo4de8FzAOzZs6fex14qTp8+zUMPPcSkSZNISEjgoYceoqioqNbjlyxZQu/evVm3bh0JCQl1nruuzx4gJiaGTZs2MXr0aP75z39ebFd8ijYh5oIg8PXXXzNmzBgGDhzIs88+ywMPPMDdd9+NyWRi6dKlrF+/nsTERJYtW8aSJUsIDw8HIDo6mpdeeom5c+cyZMgQTp06xWuvvdayHWojdOrUCbPZ3GLXf/vtt/n222/517/+xccff8zhw4eZP39+rcenpaUhiiILFizgu+++Y+bMmbz88susXr1aO+aNN97g+++/Z8GCBaxdu5a+ffvy4IMPeg0SgiAwffp0hg0bVu0ao0eP5ueff65X+6+88krWrl3rtW316tVceeWV9Xo/QEJCAmVlZZfEv5uRkcHDDz/MY489xosvvsgtt9zCf//7X69jrFYrDz30EKNHj+b555/njTfeICYmhpkzZ+JwOGo8b15eHtdddx2RkZEX/D3V9dkDGI1Gunbtyrhx48jPz68x98ovv/zC73//ez744AOmTZvG9OnTOXDgQD0/hTaMolMvysrKlN27dyslJSV1Hnf33XcrCxcuVJ599lklPj5eGTNmjLJ582YlNzdXue+++5T+/fsrt99+u2KxWLzet3z5cmXs2LFKXFyccttttyk7duzQ9qWmpiozZsxQBg8erAwcOFCZMWOGkpmZqe3fsWOHEhsbq/z666/KpEmTlPj4eGXmzJlKUVFRre1ctGiRcscddyjvvfeeMnToUCUxMVF57bXXFFmWFVmWlZKSEiU2NlbZtm2boiiKkpeXpzz66KPK8OHDlfj4eGX69OnK0aNHtfPZ7Xbl2WefVYYOHar069dPmThxorJx48YGfcaVkSRJGTJkiPKf//xH2/brr78qV111lVJQUFDv8zz33HPKH//4R+31iBEjlFWrVnldZ9iwYcpHH32kyLLs9d6nn35aeeKJJ7y2ZWRkKAMGDFCcTmed142NjVX++c9/KuPHj9e2lZeXKwMHDlT++c9/KmPGjNG2OxwOZc6cOUp8fLwyatQo5euvv1ZGjhyprFy5UlEURXniiSeUd955p87reb7PTz/9VBkxYoQyePBg5eWXX/bq04svvqj9xm688Ubl22+/9TrH7bffrjzwwAPKihUrlMWLFyvr169X/vvf/3q18+6771b+7//+z+u8LpdLmT17tvLoo48qkiRVa9uYMWOUL7/8ss72V6Wmz74ynt+8y+Xy2l5UVKTEx8crCxcuVP7+978rmzZtUv7zn/8oO3fu1I7x3MtlZWUNalNrp01Y5m2NL7/8kpiYGL7++muuv/56nnrqKZ599lnuu+8+7RF94cKF2vH//e9/+eSTT5g3bx7ffvstt9xyCw8//DAWiwVQI3ImTpzI559/zueff47JZOLxxx+vdt23336bhQsX8sknn5CSksLSpUvrbOexY8fYv38/n3zyCS+88AKfffYZX3/9dY3H2u12EhMT+fDDD1m1ahXR0dFe1tgnn3zCkSNHeO+99/juu++YM2eO1yRznz59WLVqVb0/w6ysLAoLCxk6dKi2bfDgwQANisIoLCwkLCxMe+1yufDz89Nei6KI2Wzm4MGD9Tpf9+7d6dSpU7VVxjUxdOhQnE4ne/fuBWDTpk307t2bHj16eB23bNkyfvnlF5YsWcI777zDqlWrvNwWY8aMqdfTwPHjxzl06BDLly/npZde4pNPPuGnn37S9oeHh/PGG2/wzTffMG3aNJ5++mmOHz/u9f7p06cTFRVFly5dmDBhAr/73e+0/WazmU8//ZT58+cjCOcnTo1GI//6179YtGhRjUEATqcTo7FpYy0853M6nV7bMzMzsdlszJo1i/bt2xMbG8u0adO0344vo4t5MzBgwADuu+8+oqKimDVrFkVFRQwfPpwxY8YQHR3NPffcQ1JSknb80qVLefbZZxk1ahSRkZHcc889DBw4UHtE79evH7///e+Jjo4mNjaW+fPnc/DgQXJycryu+9e//pW4uDj69evH1KlTva5RE7Is89JLLxETE8ONN97IPffcw2effVbjsREREdx777306dOHnj17Mm/ePIqLizURPH36NFdddRXXXnstkZGRXH/99V6Pyj179qwWIloX586dA6B9+/baNoPBQFhYmLbvQhw4cICffvrJS5CGDRvGBx98QG5uLm63m48//pjc3Nx6nxPq72oRRZGbb76ZNWvWAKqL5ZZbbql23BdffMGjjz7K8OHD6du3L/Pnz/dyWYwcOZKDBw9qq5prw2g08vzzzxMdHc348eMZMmQIu3bt0vb/8Y9/JC4ujsjISH7/+98zZMgQNm7cqO3v378/y5cvJzk5+YJ9qy979uwhPz+fqKioJjsnQGRkJKIoaosDPfTs2ZOwsDBef/11Tp8+3aTXbO3oYt4MVI6U6dixI6CGNHro0KEDRUVFSJJEWVkZFouFv/zlLyQkJGh/O3fuJCsrC1D9lPPnz2fChAkMGDCACRMmAJCbm1vndQsKCupsZ/fu3b2s1ri4OE6dOlXjsS6Xi9dff51JkyaRmJhIYmIi5eXlWhumTJnC+vXrue2223j99derWc/r1q3TJqWrsnv3bq++7969+6JTl546dYpZs2bx2GOPecUZP/vss4SHh2uTpD/99BMjR45sUFjp6NGj2bx5c72OveWWW/jhhx/Izs5m9+7dTJo0yWt/SUkJBQUFXHPNNdq2nj17ej3VhIaG0r9/f3755Zc6r9WjRw+vp46OHTt6DVJff/01t912G0OHDmXkyJHs2LHDS/BeffVVevTowbvvvsvf//53/t//+38cPXq0Xv2siYkTJzJ9+nTuvvvuahOfy5Yt8/rOG0rnzp15+umneeaZZ7j22mu17cHBwXz00Ufk5OTw3Xff8bvf/Y6//e1vDRqs2yptLs68LVD5kdLzOGoymaptUxSF8vJyQL2RPOGUHjw39MKFCzlw4AB/+9vfiIiIwO12M2XKFNxud53XvVDxgcqPyhfivffe4+uvv2bu3Ln07NkTPz8/pk6dqrUhLi6OH3/8kc2bN7N161buvPNO/vznP/P//t//u+C5r732Wq9JyiuuuEJLhlZQUKDF9UuSRHFxMR06dKjzfFlZWdx///387ne/4+GHH/ba16lTJz744ANsNhs2m42OHTsybdq0ap99XQwcOJD8/HwyMzMvGBXVu3dvIiIieOqppxg1apTX4Ann46Av9F1cf/31bN68md/+9re1HlPVlSEIgrbCcffu3Tz33HP89a9/ZdCgQQC8/vrrXr+hjh078uKLL7Jz505N6B944AF+/PHHWtdW1MV7773H1q1bWbBgAXfeeSfR0dHavjvuuKPawNYQSkpKWLRoEQ899BBTpkzx2nfNNdfwzjvv8NZbb9GzZ08++eQTnnzyST766KNGX68toFvmLUyHDh3o1KkTubm59OjRw+vPY9UfOHCA3//+94wePZrevXtXWwTVWDIyMigpKdFeHzp0iJ49e9Z47IEDB7jxxhuZOHEisbGxmM3mao/94eHh3HLLLbz22ms89thj9QrhA/D39/fqt7+/P5GRkbRr146dO3dqx3lcBpUtsark5ORw3333MX78+BrnFTwEBgbSsWNHLBYLhw8fZuTIkfVqK6gD84gRI7z80XUxZcoUdu/eXU10AMLCwmjfvr3Xk0x6enq1EMwxY8awZcuWRlcHOnDgANHR0dx3331cddVVdOvWrc78RJGRkcyZM4eioqJan9YuRPfu3bnrrrsIDg6u9qQWHh7u9Z03lJMnT1JWVsZDDz3k9dRblf79+/Pwww+zf//+Bl+jraGLeQsjCAJ/+MMfePPNN1m5ciWZmZkcOnSId999l+3btwPqjbV+/XpSU1PZvXs3r7zySpNcWxRF5s6dS2pqKhs2bODTTz/lrrvuqvHYyMhItm7dypEjRzhy5AhPP/201yP9xx9/zA8//EB6ejrHjx9n27ZtXgPDjTfe6OWfrU/b7rzzTt588022b9/OwYMHeemll7jpppto164dAAcPHuTGG2/UrPgzZ85ofv0//OEP5OXlkZeX5zWZuGfPHv73v/+RlZXFli1bePDBB7nuuusYMWKEdkxGRgbJyckUFRVRUlJCcnJytTQODQlRvOuuu9i+fTtjxoypcf+dd97J4sWL2b59O8eOHWP+/PnVVmd6Vh43VpS6d+/OqVOn+Omnn0hLS+Of//wn+fn5Xsc899xzHDlyBIfDgc1m45NPPiEwMPCi/d1BQUHVJiproz6fPZyf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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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CxgxYgTJycksWLCAM2fOaPt37NhBQkIC27Zt41e/+hVJSUkevva18cEHHzBq1CiGDRvGa6+95mFj7N+/P7/88gsA+fn53HvvvYwZM4akpCRuueUWjh49qh1rs9lYsmQJo0aNIjExkWuvvZZNmzY1+nuUZZkvvviCxYsXa9dcsmQJ3333HYWFhTWet2TJEubMmUO3bt287h8/fjyLFy9m9OjRtd7/k08+ISwsjF//+tce2ydOnMjWrVtxOBx11mH69OmsWbNG+2y1WtmwYQPTp0/3OM5ut/PEE0+QlJTE+PHjWb16NePGjWPVqlWIosjYsWPZvHlznfcD+Pzzz7nqqqsYMWIEr7zyisfzfOGFF7TFc7Nnz+Y///mPx7mPPvooTqeTBx54gN///vcsWrRIe8dc5Vy0aBFDhgzhmWeeQRAEjEYjr732Gj169ODhhx/2Kq6KomA01m96rq7n58J1var3Ky4u5p577mHYsGHccMMNWqNUU0PjS3QIMe9o/POf/yQuLo5vvvmG8ePH88gjj/Dkk09y++23a0P0l156STv+X//6F59++ilLly7lu+++4/rrr+euu+4iKysLUHuTU6dO5YsvvuCLL77AZDJ57Vn+/e9/56WXXuLTTz8lNTWV5cuX11rOY8eOsW/fPj799FOee+45Pv/8c7755huvx1qtVpKTk/noo49YtWoVsbGxHr2xTz/9lMOHD/P+++/z73//m8cff9xjkjkhIYFVq1bV+zs8c+YMhYWFjBw5Uts2fPhwgBb3wjh58iQrVqzg6aefrrYvOjqabt26VVtl7I2RI0dit9vZs2cPAJs2baJfv3707t3b47h33nmHn3/+mbfffpt3332XVatWeZgtJk6cWK/RwPHjxzl48CArVqzghRde4NNPP+WHH37Q9oeFhfH666/z7bffMnv2bB599FGOHz/ucf7NN99MTEwMPXv2ZMqUKdx0003afrPZzGeffaYJuQuj0cjf/vY3li1b5tUJwG6311vM64vrena73WN7ZmYmFouFRYsW0aVLF+Lj45k9e7b22/FldDFvAYYMGcLtt99OTEwMixYtoqioiNGjRzNx4kRiY2OZN28eKSkp2vHLly/nySefZNy4cURFRTFv3jyGDh2qDdEHDhzIb37zG2JjY4mPj+eZZ57hwIED5OTkeNz34YcfJjExkYEDBzJr1iyPe3hDlmVeeOEF4uLiuPbaa5k3bx6ff/6512MjIyO57bbbSEhIoE+fPixdupTi4mIOHDgAqHbUyy+/nAEDBhAVFcX48eM9hsp9+vSp5iJaGxcuXACgS5cu2jaDwUBoaKi2ryVwOBw88sgjPProo3Tt2tXrMfU1tYii6NE7X716Nddff32147788kvuueceRo8eTf/+/XnmmWc8epJjx47lwIEDdY60jEYjzz77LLGxsUyePJkRI0awc+dObf8f//hHEhMTiYqK4je/+Q0jRoxg48aN2v5BgwaxYsUKjxFXU9m9ezf5+fnExMQ02zUBoqKiEEVRWxzook+fPoSGhvLaa69x9uzZZr1ne0cX8xbA3VMmPDwcUF0aXXTt2pWioiIkSaK8vJysrCzuv/9+kpKStL8dO3ZoppTS0lKeeeYZpkyZwpAhQ5gyZQoAubm5td63oKCg1nJGR0cTGhqqfU5MTOT06dNej3U4HLz22mtMmzaN5ORkkpOTqaio0Mowc+ZM1q9fz4033shrr71Wrfe8bt06bVK6Krt27fKo+65du9osdOny5cvp3bs306ZNq/GYCRMm1Nvscf311/Pf//6X7Oxsdu3aVe26JSUlFBQUcOWVV2rb+vTp4zGqCQkJYdCgQfz888+13qt37974+flpn8PDwz0avm+++YYbb7yRkSNHMnbsWLZv3+4heH/961/p3bs37733Hk8//TS///3vOXLkSL3q6Y2pU6dy8803c+utt1ab+HznnXc8nnlD6d69O48++iiPPfYYAwYM0LYHBwfz8ccfk5OTw7///W9uuukmnnjiiRbtALQXOpyfeUfAfUjpGo6aTKZq2xRFoaKiAlBfJJc7pQvXC/3SSy+xf/9+nnjiCSIjI3E6ncycOROn01nrfetKPuA+VK6L999/n2+++YYlS5bQp08f/Pz8mDVrllaGxMREvv/+ezZv3syWLVuYO3cu9913H7///e/rvPaAAQNYvXq19rlHjx5aMLSCggLNr1+SJIqLi2vsMTcHO3fuZNeuXaxfvx5Qn5Esy1xxxRV8+OGHjBo1iqFDh5Kfn09mZmadXlH9+vUjMjKSRx55hHHjxnk0nq7rQ93PYvz48WzevLmaDd+dqqYMQRC0FY67du3iqaee4uGHH9bmlF577TWP31B4eDjPP/88O3bs0IR+/vz5fP/99zWuraiN999/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6yJdUug9c512TXP29ly+8u7XagvaSzmaG18dcXQUdDHXadc0p33ZFYu8rWkv5Whu+kaEsv3QWZ8bcXQUdDHXaTItaSvVe3sdB18dcXQUdDH3MdpiEqolvTNq6u3pk22tS32+b18dcXQUdDH3MdrC7a0le8819fZ0977WRf++2z9NEnNFUcjLy8PpdHps79WrV5MKpdN42sIs0ZK20pp6e7r5pXXRv+/2T6PEvLCwkGeffZaNGzd6zeRx9OjRJhdMp3G0xSTU5OG9kRX4aU8WCCArCpKstKjZQ59sa13077v90ygxf/755zl//jyff/458+fP54033qCgoID33nuP+++/v7nLqNMA2mISyiAKiALk5JcjSTJfbUhFFFrWfqpPtrUMNdnG9e+7/dMoMf/ll1/44IMPuPLKKxEEgaioKMaNG0eXLl148803tcQQOq1PQyahmnMSsbWH4fpkW8tQk21c/77bP41aAep0OgkJCQGgS5cunD9/HoCYmJgOlfH6Uqc5V+z56qrGSw33RllPF9ixaFTP/PLLL+fw4cNERUWRlJTEW2+9RVlZGWvWrKFPnz7NXUadFqI5e9P6MNw30G3jHZdGifn999+PxWIB4KGHHuLRRx/loYceIjo6mueff75ZC6jTcjTni6sPw32DpjbKuv9/29EoMU9KStL+36NHDz755JPmKo9OK9Laven28qJXLcfEoZGtXob2SlMbZd0fve1okp95RUUFFy5cQFEUj+1RUVFNKlRVPv/8c1atWkVqairXXHMNr7/+urYvNTWVJUuWcPz4caKionj66adJTk7W9q9bt46//OUvXLhwgSFDhvDnP/+ZHj16NGv5Oiqt3ZtuLy961XLIskx3v1Yvhk+i+6O3HY2aAD1+/Dg33XQTQ4YM4ZprruGaa65hypQp2r/NTffu3Vm0aBGzZ8/22O5wOFi4cCGTJ09m586dLFiwgEWLFlFcrP6ATp06xeOPP85zzz3H9u3b6d27Nw8++GCzl0+nfrSHyTVJVti8J4tSix2zyYDTKZGWXdLq5agPkqywfns6y1fuZ/32dCRZqfukNkafCG87GtUzf/zxx+nevTtffvkl4eHhCELLDpVdDcTRo0cpLCzUtqekpGC1WrnzzjsRRZGZM2eyYsUKNmzYwKxZs1i7di3jxo1j9OjRACxevJgxY8aQmZlJdHR0o8oi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WbOG8ePH+1Rd600be9N0CIqLi5V77rlHGTx4sDJmzBjl888/b+siNZply5Yp8fHxHn+PPvqooiiKcuzYMeU3v/mNMnDgQOVXv/qVkpKS4nHuf/7zH2XSpElKYmKiMn/+fOXs2bNtUYV6k5WVpcTHxysDBgxQBg8erP0tX75cURTfq29paaly2223KcnJycrgwYOVqVOnKu+++64iy7KiKL5XX3dWrlypuSYqim/XtSb0HKA6Ojo6PoBuZtHR0dHxAXQx19HR0fEBdDHX0dHR8QF0MdfR0dHxAXQx19HR0fEBdDHX0dHR8QF0MdfR0dHxAXQx19HR0fEBdDHXueRISEjgl19+aetiNJlx48axatWqti6GTjtBF3OdZmHevHkkJCSQkJDA5Zdfzrhx43j++eex2+0APPbYYyQkJPDGG294nKcoCldffTUJCQns2LGjLYquo+MT6GKu02zcfvvt/Pzzz2zevJmXXnqJjRs38vbbb2v7L7vsMtauXYt7BIndu3fjdDrborjtAofDgR5RQ6c50MVcp9kICAigW7du9OjRg9GjRzNlyhSOHj2q7U9OTtai+blYvXo1M2bM8LhOfn4+9957L2PGjCEpKYlbbrnF4zoA27Zt49prryUxMZE//OEPvPfee0yaNKneZT179ix33HEHgwYN4sYbb/QIibpnzx7mzZtHcnIyI0eO5IEHHqCgoKBe1503bx4vv/wyjz76KIMHD2bixIn85z//0fbv2LGDhIQEfvzxR371q18xaNAgSkpKqKio4JlnnmHkyJEkJyfzhz/8gaysLO08u93OU089RVJSEuPHj2f16tX1rqvOpYEu5jotQm5uLtu2bWPgwIHaNkEQmD59OmvWrAHAZrOxfv16Zs6c6XGu1WolOTmZjz76iFWrVhEbG8vChQux2WwAlJSU8Kc//YmrrrqK1atXM2nSJD744IMGle/tt9/m1ltvZfXq1XTv3p0nnnhC22exWJg7dy4rV67k/fffJzc3l2eeeabe1/7qq6+Ijo5m1apVzJ49m4cffpiMjAyPY/7+97/z/PPP8+233xIQEMDSpUvJyMjg/fff55///CddunRh4cKFSJIEwHvvvccPP/zAm2++ybvvvsvKlSspKipqUJ11fJw2jdmo4zPceuutypVXXqkMHjxYGThwoBIfH6/Mnz9fsdvtiqKoGdQffPBB5eTJk0pycrJis9mUf//738rs2bMVh8OhxMfHK9u3b/d6bafTqQwePFgLY/r5558rEyZMUCRJ0o554IEHlIkTJ9arrPHx8cp7772nfd6zZ48SHx+vlJWVeT1+7969yhVXXKE4nc56fQ/uoVgVRVHmzJmjvPTSS4qiKMr27duV+Ph4ZceOHdr+M2fOKFdeeaWWPV5R1AzzgwYNUnbu3KkoiqKMGjVK+eKLL7T9J0+eVOLj45WVK1fWo8Y6lwLGtm5MdHyHWbNmcccddyDLMllZWfz5z3/mxRdfZOnSpdoxsbGx9O7dm++//57Vq1dX65WDakd+88032bhxI3l5eUiSREVFBbm5uYCaMqx///4eCXsHDBjA3r17611W96wy4eHhABQUFBAUFMTZs2d59dVX2bNnDwUFBSiKgtPpJD8/nx49etR57cTExGqfT58+7bHtiiuu0P5/8uRJnE4nEyZM8DjGarWSlZVFQkICFy5c8LhubGxsh09zptO86GKu02yEhITQu3dvAPr06UNpaSkPPfQQjz76qMdxM2fOZMWKFRw7doxXXnml2nXef/99vvnmG5YsWUKfPn3w8/Nj1qxZ2kSpoige2dgbg8lk0v7vupYrzdhjjz2Gw+Hg+eefp3v37mRlZXHXXXfhcDiadE93/P39tf9bLBb8/f292sG7du2qlaupddbxbXSbuU6LYTAYkCSpmgj++te/5tChQ1x11VWEhYVVO2///v1ce+21TJ06lfj4eMxmM8XFxdr+Pn36cPToUY8cj4cOHWq2cu/fv5/58+czatQoYmNjKSwsbND5Bw8erPa5T58+NR6fkJBARUUFVquV3r17e/wFBwcTEhJC165dOXDggHZOWlpah88mr9O86D1znWajoqKCvLw8FEXhzJkzLF++nKFDh9KpUyeP47p06cLWrVvx8/Pzep2oqCi2bNnC4cOHAXj55Zc9jp0+fTqvvfYaL730EnPnzmXXrl38/PPPzWZ2iIqKYs2aNcTFxZGRkcG7777boPNTU1NZvnw51157LRs2bGDfvn28+OKLNR4fGxvLlClTeOCBB3jssceIiYnh7NmzrFu3jj/96U907tyZOXPm8NZbbxEdHU2XLl148cUXa/z+dC5NdDHXaTZWrFjBihUrEASB8PBwRo4cycMPP+z12NDQ0Bqvs2jRItLT07n55pvp2rUrDzzwAOnp6dr+kJAQ3nrrLZ5++mm++uorRo0axbx58/juu++apR7PP/88S5Ys4brrriM+Pp777ruPe++9t97n//a3v+XkyZPccMMNhIaG8pe//IWYmJhaz/nrX//K66+/zhNPPEFhYSE9evRgzJgxBAQEAHD33Xdz9uxZFi1aRKdOnbj//vs9vhMdHT0HqI5P8OSTT5KXl8d7773XpuWYN28eQ4YM4f7772/Tcuhceug9c50Oyb/+9S/i4uLo3LkzW7duZc2aNbz00kttXSwdnTZDF3OdDklubi7Lli2jsLCQyMhInnzySa677joAkpKSvJ7Tq1cv/v3vfzfpvnfeeafHClZ3mnptHZ2moJtZdHyOqqstXRiNRiIiIpp07XPnzmG1Wr3ui4iIwGjU+0c6bYMu5jo6Ojo+gO5nrqOjo+MD6GKuo6Oj4wPoYq6jo6PjA+hirqOjo+MD6GKuo6Oj4wPoYq6jo6PjA+hirqOjo+MD/H9NIeDM7ZGjlQAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "figsize = (3.28125003459, 1.8)\n", + "sns.set(\n", + " rc={\"figure.figsize\": figsize}, context=\"paper\", style=\"whitegrid\", font_scale=0.75\n", + ")\n", + "\n", + "max_dict = {\n", + " \"BMag_ha\": 400,\n", + " \"V_ha\": 1000,\n", + "}\n", + "\n", + "y_range = {\n", + " \"BMag_ha\": (-225, 325),\n", + " \"V_ha\": (-310, 440),\n", + "}\n", + "\n", + "y_unit = {\n", + " \"BMag_ha\": \"\\,Mg\\,ha\\$^{-1}\\$\",\n", + " \"V_ha\": \"\\,m$^3\\$\\,ha\\$^-1\\$\",\n", + "}\n", + "\n", + "\n", + "split = \"test\"\n", + "\n", + "for target in [\"BMag_ha\"]: #target_vars:\n", + " print(target)\n", + " # if target != \"BMag_ha\": continue\n", + " for method in result_scores[split].method.unique():\n", + " # f, ax = plt.subplots()\n", + " run_i = best[method]\n", + " dff = results_corrected[method].query(f\"run == {run_i} & split == @split\")\n", + " if len(dff) == 0: continue\n", + " print(method)\n", + " sns.set(\n", + " rc={\"figure.figsize\": figsize},\n", + " context=\"paper\",\n", + " style=\"whitegrid\",\n", + " font_scale=1,\n", + " )\n", + " y_min, y_max = y_range[target]\n", + " #y_max = (dff[target] - dff[f\"{target}_pred\"]).max()\n", + " #y_min = (dff[target] - dff[f\"{target}_pred\"]).min()\n", + " x_min = 0\n", + " x_max = max_dict[target]\n", + "\n", + " # n_bins = 500//10\n", + " # bins = np.linspace(0, 500, n_bins+1)#np.histogram_bin_edges(dff[target], n_bins, range=(0,500))\n", + " # err = abs(dff[target] - group_df[f\"{target}_pred\"]).values\n", + " # bin_err = []\n", + " # bin_x = []\n", + " # for i in range(n_bins):\n", + " # mask = (bins[i] <= dff[target]) & (dff[target] < bins[i+1])\n", + " # err_ = err[mask].std()\n", + " # err_ = 0 if np.isnan(err_) else err_\n", + " # bin_err.append(err_)\n", + " # bin_err.append(err_)\n", + " # bin_x.append(bins[i])\n", + " # bin_x.append(bins[i+1])\n", + " # bin_err = np.array(bin_err)\n", + "\n", + " # ax.fill_between(bin_x, -bin_err, bin_err, alpha=0.5, color=\"g\")\n", + "\n", + " # ax.scatter(group_df[target], group_df[target] - group_df[f\"{target}_pred\"], s=5, label=group)\n", + "\n", + " f = sns.jointplot(\n", + " y=target,\n", + " x=f\"{target}_pred\",\n", + " kind=\"resid\",\n", + " data=dff,\n", + " # label=group,\n", + " robust=False,\n", + " scatter_kws={\"s\": 5},\n", + " marginal_kws={\"edgecolor\": \".0\", \"linewidth\": 0.00},\n", + " )\n", + " # f.ax_joint.legend()\n", + "\n", + " # f.ax_marg_x.set_title(group)\n", + " fig = plt.gcf()\n", + " fig.delaxes(f.ax_marg_x)\n", + " fig.set_size_inches(figsize)\n", + " # plt.title(method)\n", + " plt.subplots_adjust(\n", + " left=0.125, right=1, top=1.2, bottom=0.12, hspace=0.1, wspace=0.1\n", + " )\n", + " me = (\n", + " result_scores[split]\n", + " .query(f\"method == '{method}' & target == '{target}' & corrected == True & run == {run_i} & not treeval\")[\n", + " \"mean bias\"\n", + " ]\n", + " .median()\n", + " )\n", + " f.ax_joint.text(\n", + " 0.525,\n", + " 0.9,\n", + " f\"mean bias: {me:0.3f}{y_unit[target]}\",\n", + " horizontalalignment=\"center\",\n", + " verticalalignment=\"center\",\n", + " transform=f.ax_joint.transAxes,\n", + " bbox={\"facecolor\": \"white\", \"alpha\": 0.75, \"pad\": .1},\n", + " )\n", + " f.ax_joint.set_xlim((x_min, x_max))\n", + " f.ax_joint.set_ylim((y_min, y_max))\n", + " f.ax_marg_y.set_ylim((y_min, y_max))\n", + " f.ax_marg_y.margins(y=0.01)\n", + " f.ax_marg_x.margins(x=0)\n", + " f.ax_joint.margins(y=0.01, x=0)\n", + " group_name = method.replace(\" \", \"\").replace(\"\\\\\", \"\").replace(\"{}\", \"\")\n", + " #plt.savefig(f\"figures/{target}_{group_name}_resid.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e23c3131-25e2-4f2c-85a3-e1f67ee06ad5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.8.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/eval_scripts/eval_deep_learning_v2_size.ipynb b/eval_scripts/eval_deep_learning_v2_size.ipynb new file mode 100644 index 0000000..308b61d --- /dev/null +++ b/eval_scripts/eval_deep_learning_v2_size.ipynb @@ -0,0 +1,1250 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "38cde886", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/stefan/.conda/envs/pts/lib/python3.8/site-packages/geopandas/_compat.py:123: UserWarning: The Shapely GEOS version (3.8.0-CAPI-1.13.1 ) is incompatible with the GEOS version PyGEOS was compiled with (3.10.4-CAPI-1.16.2). Conversions between both will be slow.\n", + " warnings.warn(\n", + "/home/stefan/.conda/envs/pts/lib/python3.8/site-packages/scipy/__init__.py:146: UserWarning: A NumPy version >=1.16.5 and <1.23.0 is required for this version of SciPy (detected version 1.24.4\n", + " warnings.warn(f\"A NumPy version >={np_minversion} and <{np_maxversion}\"\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import geopandas as gpd\n", + "import seaborn as sns\n", + "\n", + "sns.set_context(\"paper\")\n", + "sns.set_style(\"whitegrid\")\n", + "import matplotlib.pyplot as plt\n", + "\n", + "plt.rcParams[\"svg.fonttype\"] = \"none\"\n", + "from sklearn.metrics import mean_absolute_percentage_error, mean_squared_error, r2_score\n", + "\n", + "sns.set_color_codes()\n", + "from glob import glob\n", + "from itertools import product\n", + "import pickle" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "358d4bc2", + "metadata": {}, + "outputs": [], + "source": [ + "target_vars = [\"BMag_ha\", \"V_ha\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "30bad65e", + "metadata": {}, + "outputs": [], + "source": [ + "bias_correct_splits = [\"val\", \"train\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "4d80056e", + "metadata": {}, + "outputs": [], + "source": [ + "# choose one of test, train, val\n", + "splits = [\"train\", \"val\", \"test\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "93cd866d", + "metadata": {}, + "outputs": [], + "source": [ + "models = {\n", + " \"MSENet14\": (\n", + " f\"results_new/SENet14_xy_??.gpkg\",\n", + " ),\n", + " \n", + " \"MSENet14 < 1y 100%\": (\n", + " f\"results_size/SENet14_1y_xy_treeadd_??.gpkg\",\n", + " ),\n", + "\n", + " \"MSENet14 < 1y 75%\": (\n", + " f\"results_size/SENet14_75_1y_xy_treeadd_??.gpkg\",\n", + " ), \n", + "\n", + " \"MSENet14 < 1y 50%\": (\n", + " f\"results_size/SENet14_50_1y_xy_treeadd_??.gpkg\",\n", + " ),\n", + "\n", + " \"MSENet14 < 1y 25%\": (\n", + " f\"results_size/SENet14_25_1y_xy_treeadd_??.gpkg\",\n", + " ),\n", + "\n", + " \"MSENet14 < 1y 12.5%\": (\n", + " f\"results_size/SENet14_12_1y_xy_treeadd_??.gpkg\",\n", + " ),\n", + "\n", + " \"MSENet14 < 1y 6.25%\": (\n", + " f\"results_size/SENet14_6_1y_xy_treeadd_??.gpkg\",\n", + " ),\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "49266c45-aa7e-41a0-a704-f6bcc07bff48", + "metadata": {}, + "outputs": [], + "source": [ + "with open('results_size.pickle', 'rb') as handle:\n", + " results = pickle.load(handle)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "aebad451-aaa4-4ad8-812e-b2713cdffe8d", + "metadata": {}, + "outputs": [], + "source": [ + "# set number of instance\n", + "results[\"MSENet14\"].eval(\"n_samples = 4270\", inplace=True)\n", + "results[\"MSENet14 < 1y 100%\"].eval(\"n_samples = 2636\", inplace=True)\n", + "results[\"MSENet14 < 1y 75%\"].eval(\"n_samples = 1977\", inplace=True)\n", + "results[\"MSENet14 < 1y 50%\"].eval(\"n_samples = 1318\", inplace=True)\n", + "results[\"MSENet14 < 1y 25%\"].eval(\"n_samples = 659\", inplace=True)\n", + "results[\"MSENet14 < 1y 12.5%\"].eval(\"n_samples = 330\", inplace=True)\n", + "results[\"MSENet14 < 1y 6.25%\"].eval(\"n_samples = 165\", inplace=True)" + ] + }, + { + "cell_type": "markdown", + "id": "6f20b57b-0f00-4b69-8585-71a5ee12e3e0", + "metadata": {}, + "source": [ + "# Bias correction" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "d4c831b4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14 0\n", + "0.9151888974556669\n", + "MSENet14 2\n", + "0.9151888974556669\n", + "MSENet14 3\n", + "0.9151888974556669\n", + "MSENet14 4\n", + "0.9151888974556669\n", + "MSENet14 5\n", + "0.9151888974556669\n", + "MSENet14 < 1y 100% 1\n", + "0.9151888974556669\n", + "MSENet14 < 1y 100% 2\n", + "0.9151888974556669\n", + "MSENet14 < 1y 100% 3\n", + "0.9151888974556669\n", + "MSENet14 < 1y 100% 4\n", + "0.9151888974556669\n", + "MSENet14 < 1y 100% 5\n", + "0.9151888974556669\n", + "MSENet14 < 1y 75% 0\n", + "0.9151888974556669\n", + "MSENet14 < 1y 75% 1\n", + "0.9151888974556669\n", + "MSENet14 < 1y 75% 2\n", + "0.9151888974556669\n", + "MSENet14 < 1y 75% 3\n", + "0.9151888974556669\n", + "MSENet14 < 1y 75% 4\n", + "0.9151888974556669\n", + "MSENet14 < 1y 50% 0\n", + "0.9151888974556669\n", + "MSENet14 < 1y 50% 1\n", + "0.9151888974556669\n", + "MSENet14 < 1y 50% 2\n", + "0.9151888974556669\n", + "MSENet14 < 1y 50% 3\n", + "0.9151888974556669\n", + "MSENet14 < 1y 50% 5\n", + "0.9151888974556669\n", + "MSENet14 < 1y 25% 1\n", + "0.9151888974556669\n", + "MSENet14 < 1y 25% 2\n", + "0.9151888974556669\n", + "MSENet14 < 1y 25% 3\n", + "0.9151888974556669\n", + "MSENet14 < 1y 25% 4\n", + "0.9151888974556669\n", + "MSENet14 < 1y 25% 5\n", + "0.9151888974556669\n", + "MSENet14 < 1y 12.5% 0\n", + "0.9151888974556669\n", + "MSENet14 < 1y 12.5% 1\n", + "0.9151888974556669\n", + "MSENet14 < 1y 12.5% 2\n", + "0.9151888974556669\n", + "MSENet14 < 1y 12.5% 3\n", + "0.9151888974556669\n", + "MSENet14 < 1y 12.5% 4\n", + "0.9151888974556669\n", + "MSENet14 < 1y 6.25% 0\n", + "0.9151888974556669\n", + "MSENet14 < 1y 6.25% 1\n", + "0.9151888974556669\n", + "MSENet14 < 1y 6.25% 2\n", + "0.9151888974556669\n", + "MSENet14 < 1y 6.25% 3\n", + "0.9151888974556669\n", + "MSENet14 < 1y 6.25% 5\n", + "0.9151888974556669\n" + ] + } + ], + "source": [ + "# get bias correction\n", + "# we do not include the 0 predictions into the adjustment since they come from a different data distribution\n", + "\n", + "deltas = {}\n", + "results_corrected = {}\n", + "exclude_1y = False\n", + "exclude_pred_0 = False\n", + "clip_0 = False\n", + "for model in models:\n", + " if \"treeval\" in model: # using the original correction\n", + " continue\n", + " corrected = []\n", + " corrected_treeval = []\n", + " for run in pd.unique(results[model][\"run\"]):\n", + " print(model, run)\n", + " pred_vars = [f\"{v}_pred\" for v in target_vars]\n", + " preds_cal = pd.concat(\n", + " [\n", + " results[model].query(f\"(run == {run}) & (split == @split)\")\n", + " for split in bias_correct_splits\n", + " ],\n", + " axis=0,\n", + " )[target_vars + pred_vars + [\"mask\", \"temp_diff_years\"] ].copy(deep=True) \n", + " \n", + " #reds_cal = preds_cal.sample(len(preds_cal))\n", + " \n", + " mask = np.ones_like(preds_cal[\"mask\"])\n", + " if exclude_1y:\n", + " mask &= (preds_cal[\"temp_diff_years\"] <= 1)\n", + " if exclude_pred_0:\n", + " mask &= ~preds_cal[\"mask\"]\n", + " \n", + " correct_ = ~mask == (preds_cal[target_vars] == 0).any(axis=1)\n", + " print(correct_.sum() / len(correct_))\n", + " #print(f\"num vals != 0: {mask.sum()}\")\n", + " y_cal_ = preds_cal[target_vars][mask].values\n", + " preds_cal_ = preds_cal[pred_vars][mask].values\n", + "\n", + " '''\n", + " ds = []\n", + " num_vals = 100\n", + " for i in range(0, len(y_cal_), num_vals):\n", + " mm = np.ones(len(y_cal_), dtype=bool)\n", + " mm[i:i+num_vals] = False\n", + " ds.append((\n", + " y_cal_[mm].astype(np.float64).sum(0)\n", + " - preds_cal_[mm].astype(np.float64).sum(0)\n", + " ) / (mm.sum()))\n", + " delta = np.median(ds, 0)\n", + " '''\n", + " delta = (y_cal_.astype(np.float64).sum(0)\n", + " - preds_cal_.astype(np.float64).sum(0)) / (len(y_cal_))\n", + " deltas[model, run] = delta\n", + " \n", + " # check if calibration is close to 0 on calibration set\n", + " assert np.isclose(0, y_cal_.sum(0) - ((preds_cal_ + delta).sum(0))).all() \n", + " \n", + " # apply delta to all values\n", + " df = results[model].query(f\"run == {run}\")[target_vars + pred_vars + [\"run\", \"mask\", \"split\", \"n_samples\"]]\n", + " dff = df[pred_vars]\n", + " if exclude_pred_0:\n", + " mask = ~df[[\"mask\"]].values\n", + " else:\n", + " mask = np.ones_like(df[[\"mask\"]])\n", + " df[pred_vars] = (dff + delta) * mask + (~mask) * dff\n", + " if clip_0:\n", + " df[pred_vars] = df[pred_vars].mask(dff < 0.00, 0.0)\n", + " corrected.append(df)\n", + "\n", + " results_corrected[model] = pd.concat(corrected, axis=0)" + ] + }, + { + "cell_type": "markdown", + "id": "64349efc-e498-4cd7-9b47-bb677f5a0aab", + "metadata": {}, + "source": [ + "# Evaluation" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "eb2c8782", + "metadata": {}, + "outputs": [], + "source": [ + "def cohen_d(y1_pred, y2_pred):\n", + " mse1 = (y1_pred**2).mean()\n", + " mse2 = (y2_pred**2).mean()\n", + " \n", + " diff = mse1 - mse2\n", + " s_pooled = np.sqrt((mse1 + mse2) / 2)\n", + " cohens_d = diff / s_pooled\n", + " return cohens_d\n", + "\n", + "def evaluate(name, results):\n", + " print(name)\n", + " columns = [\n", + " \"method\",\n", + " \"target\",\n", + " \"R2\",\n", + " \"MSE\",\n", + " \"RMSE\",\n", + " \"MAPE\",\n", + " \"mean error\",\n", + " \"mean bias\",\n", + " \"rel. error\",\n", + " \"n_samples\",\n", + " \"run\",\n", + " ]\n", + " results_df = []\n", + "\n", + " for target in target_vars:\n", + " pred = target + \"_pred\"\n", + " for run, result in results.groupby(\"run\"):\n", + " mask = mm = result[target] != 0\n", + " #mm = result[pred] != 0\n", + " \n", + " results_df.append(\n", + " pd.DataFrame(\n", + " [\n", + " [\n", + " name,\n", + " target,\n", + " r2_score(result[target], result[pred]),\n", + " mean_squared_error(\n", + " result[target], result[pred]\n", + " ),\n", + " mean_squared_error(\n", + " result[target], result[pred], squared=False\n", + " ),\n", + " mean_absolute_percentage_error(\n", + " result[target][mask], result[pred][mask]\n", + " )\n", + " * 100,\n", + " abs(\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / len(result[pred][mm])\n", + " ),\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / len(result[target][mm])\n", + " ,\n", + " abs(\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / (result[target][mm]).sum()\n", + " )\n", + " * 100,\n", + " result[\"n_samples\"].median(),\n", + " run,\n", + " ]\n", + " ],\n", + " columns=columns,\n", + " )\n", + " )\n", + " results_df = pd.concat(results_df, axis=0)\n", + " return results, results_df\n", + "\n", + "'''\n", + " abs(\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / len(result[pred][mm])\n", + " ),\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / len(result[pred][mm])\n", + " ,\n", + " abs(\n", + " (result[target][mm] - result[pred][mm]).sum()\n", + " / (result[pred][mm]).sum()\n", + " )\n", + " * 100,\n", + "''';\n", + "'''\n", + " abs(\n", + " (result[target] - result[pred]).sum()\n", + " / len(result[pred])\n", + " ),\n", + " (result[target] - result[pred]).sum()\n", + " / len(result[pred])\n", + " ,\n", + " abs(\n", + " (result[target] - result[pred]).sum()\n", + " / (result[pred]).sum()\n", + " )\n", + " * 100,\n", + "''';" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "4635281c", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14\n", + "MSENet14\n", + "MSENet14 < 1y 100%\n", + "MSENet14 < 1y 100%\n", + "MSENet14 < 1y 75%\n", + "MSENet14 < 1y 75%\n", + "MSENet14 < 1y 50%\n", + "MSENet14 < 1y 50%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14 < 1y 25%\n", + "MSENet14 < 1y 25%\n", + "MSENet14 < 1y 12.5%\n", + "MSENet14 < 1y 12.5%\n", + "MSENet14 < 1y 6.25%\n", + "MSENet14 < 1y 6.25%\n", + "MSENet14\n", + "MSENet14\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14 < 1y 100%\n", + "MSENet14 < 1y 100%\n", + "MSENet14 < 1y 75%\n", + "MSENet14 < 1y 75%\n", + "MSENet14 < 1y 50%\n", + "MSENet14 < 1y 50%\n", + "MSENet14 < 1y 25%\n", + "MSENet14 < 1y 25%\n", + "MSENet14 < 1y 12.5%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14 < 1y 12.5%\n", + "MSENet14 < 1y 6.25%\n", + "MSENet14 < 1y 6.25%\n", + "MSENet14\n", + "MSENet14\n", + "MSENet14 < 1y 100%\n", + "MSENet14 < 1y 100%\n", + "MSENet14 < 1y 75%\n", + "MSENet14 < 1y 75%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSENet14 < 1y 50%\n", + "MSENet14 < 1y 50%\n", + "MSENet14 < 1y 25%\n", + "MSENet14 < 1y 25%\n", + "MSENet14 < 1y 12.5%\n", + "MSENet14 < 1y 12.5%\n", + "MSENet14 < 1y 6.25%\n", + "MSENet14 < 1y 6.25%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n", + "/tmp/ipykernel_45615/1417926861.py:9: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = False\n", + "/tmp/ipykernel_45615/1417926861.py:16: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " file.loc[:, \"corrected\"] = True\n" + ] + } + ], + "source": [ + "result_dict = {}\n", + "result_dict_corrected = {}\n", + "result_scores = {}\n", + "for split in splits:\n", + " result_score = []\n", + " for name in models.keys():\n", + " # use corrected version except for linear regressor (optimal already)\n", + " file, scores = evaluate(name, results[name].query(\"split == @split\"))\n", + " file.loc[:, \"corrected\"] = False\n", + " scores.loc[:, \"corrected\"] = False\n", + "\n", + " result_dict[name] = file\n", + " result_score.append(scores)\n", + "\n", + " file, scores = evaluate(name, results_corrected[name].query(\"split == @split\"))\n", + " file.loc[:, \"corrected\"] = True\n", + " scores.loc[:, \"corrected\"] = True\n", + "\n", + " result_dict_corrected[name] = file\n", + " result_score.append(scores)\n", + "\n", + " result_score = pd.concat(result_score, axis=0)\n", + " result_scores[split] = result_score" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "2a606535-70d0-4251-9ba7-87a44e604999", + "metadata": {}, + "outputs": [], + "source": [ + "result_scores[\"test\"] = result_scores[\"test\"].query(\"corrected == True\")" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "f7494784-6232-490e-9e16-897f139b890b", + "metadata": {}, + "outputs": [], + "source": [ + "def abs_min(x): return x.iloc[np.argmin(abs(x))]\n", + "def abs_max(x): return x.iloc[np.argmax(abs(x))]\n", + "def abs_median(x): return np.median(abs(x))\n", + "def avg_sign(x): return np.mean(np.sign(x))\n", + "def abs_mean(x): return np.mean(abs(x))\n", + "def arg_abs_min(x): return np.argmin(abs(x))\n", + "def arg_abs_max(x): return np.argmax(abs(x))\n", + "def arg_max(x): return np.argmax(abs(x))\n", + "\n", + "agg = {\n", + " \"R2\": [\"median\", \"max\"],\n", + " #'MSE' : ['median', 'min'],\n", + " 'RMSE' : ['median', 'min'],\n", + " 'MAPE' : ['median', 'min'],\n", + " #\"mean error\": [\"median\", \"max\", \"min\"],\n", + " \"mean bias\": [abs_median, abs_min],\n", + " #'rel. error' : ['median', \"min\"],\n", + "}\n", + "\n", + "rr = (\n", + " result_scores[\"test\"].query(\"target == 'BMag_ha'\")\n", + " .groupby([\"target\", \"n_samples\"])\n", + " .agg(agg)\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "abd3ae88-8c20-473a-b415-4e05c58b643c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
R2RMSEMAPEmean bias
medianmaxmedianminmedianminabs_medianabs_min
targetn_samples
BMag_ha165.00.7748400.77734648.02592447.757922859.315691337.0231620.7451730.130643
330.00.7900700.79488746.37324145.838127956.707443355.4326650.6111450.193949
659.00.7881640.81050046.58335344.059004828.811509438.9796170.3410790.228971
1318.00.8079080.81293444.35938743.775150583.615801189.1330630.2421150.200385
1977.00.8128190.82261443.78865742.627488812.118072734.5218550.448185-0.025055
2636.00.8235940.83017542.50956341.709073545.921768398.3767410.2192230.028558
4270.00.8247250.82938842.37314941.805641299.496832192.7774400.665678-0.290542
\n", + "
" + ], + "text/plain": [ + " R2 RMSE MAPE \\\n", + " median max median min median \n", + "target n_samples \n", + "BMag_ha 165.0 0.774840 0.777346 48.025924 47.757922 859.315691 \n", + " 330.0 0.790070 0.794887 46.373241 45.838127 956.707443 \n", + " 659.0 0.788164 0.810500 46.583353 44.059004 828.811509 \n", + " 1318.0 0.807908 0.812934 44.359387 43.775150 583.615801 \n", + " 1977.0 0.812819 0.822614 43.788657 42.627488 812.118072 \n", + " 2636.0 0.823594 0.830175 42.509563 41.709073 545.921768 \n", + " 4270.0 0.824725 0.829388 42.373149 41.805641 299.496832 \n", + "\n", + " mean bias \n", + " min abs_median abs_min \n", + "target n_samples \n", + "BMag_ha 165.0 337.023162 0.745173 0.130643 \n", + " 330.0 355.432665 0.611145 0.193949 \n", + " 659.0 438.979617 0.341079 0.228971 \n", + " 1318.0 189.133063 0.242115 0.200385 \n", + " 1977.0 734.521855 0.448185 -0.025055 \n", + " 2636.0 398.376741 0.219223 0.028558 \n", + " 4270.0 192.777440 0.665678 -0.290542 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "display(rr)" + ] + }, + { + "cell_type": "markdown", + "id": "7b282eb4-ea1d-40d6-abd7-f77021248f5f", + "metadata": {}, + "source": [ + "# Plots" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "113fd06e-010e-4e99-a939-5b15eed6bfdd", + "metadata": {}, + "outputs": [], + "source": [ + "result_scores[\"test\"].columns = result_scores[\"test\"].columns.map(lambda x: x.replace(' ', '_'))" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "ac10dda3-5bd0-40e4-8760-839a957d760a", + "metadata": {}, + "outputs": [], + "source": [ + "targets = [\"BMag_ha\", \"V_ha\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "3ac99322-4d59-454a-8d54-d94f5cbc9020", + "metadata": {}, + "outputs": [ + { + "ename": "FileNotFoundError", + "evalue": "[Errno 2] No such file or directory: 'figures/BMag_ha_R2_size.svg'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mFileNotFoundError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[19], line 17\u001b[0m\n\u001b[1;32m 15\u001b[0m ax\u001b[38;5;241m.\u001b[39mset_xlim(\u001b[38;5;241m0\u001b[39m, \u001b[38;5;241m4300\u001b[39m)\n\u001b[1;32m 16\u001b[0m plt\u001b[38;5;241m.\u001b[39msubplots_adjust(left\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m0.15\u001b[39m, right\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m1\u001b[39m, top\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m1\u001b[39m, bottom\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m0.15\u001b[39m, hspace\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m0.15\u001b[39m, wspace\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m0.1\u001b[39m)\n\u001b[0;32m---> 17\u001b[0m \u001b[43mplt\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43msavefig\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43mf\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mfigures/\u001b[39;49m\u001b[38;5;132;43;01m{\u001b[39;49;00m\u001b[43mtarget\u001b[49m\u001b[38;5;132;43;01m}\u001b[39;49;00m\u001b[38;5;124;43m_R2_size.svg\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/.conda/envs/pts/lib/python3.8/site-packages/matplotlib/pyplot.py:954\u001b[0m, in \u001b[0;36msavefig\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 951\u001b[0m \u001b[38;5;129m@_copy_docstring_and_deprecators\u001b[39m(Figure\u001b[38;5;241m.\u001b[39msavefig)\n\u001b[1;32m 952\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21msavefig\u001b[39m(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs):\n\u001b[1;32m 953\u001b[0m fig \u001b[38;5;241m=\u001b[39m gcf()\n\u001b[0;32m--> 954\u001b[0m res \u001b[38;5;241m=\u001b[39m \u001b[43mfig\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43msavefig\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 955\u001b[0m fig\u001b[38;5;241m.\u001b[39mcanvas\u001b[38;5;241m.\u001b[39mdraw_idle() \u001b[38;5;66;03m# Need this if 'transparent=True', to reset colors.\u001b[39;00m\n\u001b[1;32m 956\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m res\n", + "File \u001b[0;32m~/.conda/envs/pts/lib/python3.8/site-packages/matplotlib/figure.py:3274\u001b[0m, in \u001b[0;36mFigure.savefig\u001b[0;34m(self, fname, transparent, **kwargs)\u001b[0m\n\u001b[1;32m 3270\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m ax \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39maxes:\n\u001b[1;32m 3271\u001b[0m stack\u001b[38;5;241m.\u001b[39menter_context(\n\u001b[1;32m 3272\u001b[0m ax\u001b[38;5;241m.\u001b[39mpatch\u001b[38;5;241m.\u001b[39m_cm_set(facecolor\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mnone\u001b[39m\u001b[38;5;124m'\u001b[39m, edgecolor\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mnone\u001b[39m\u001b[38;5;124m'\u001b[39m))\n\u001b[0;32m-> 3274\u001b[0m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mcanvas\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mprint_figure\u001b[49m\u001b[43m(\u001b[49m\u001b[43mfname\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/.conda/envs/pts/lib/python3.8/site-packages/matplotlib/backend_bases.py:2338\u001b[0m, in \u001b[0;36mFigureCanvasBase.print_figure\u001b[0;34m(self, filename, dpi, facecolor, edgecolor, orientation, format, bbox_inches, pad_inches, bbox_extra_artists, backend, **kwargs)\u001b[0m\n\u001b[1;32m 2334\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m 2335\u001b[0m \u001b[38;5;66;03m# _get_renderer may change the figure dpi (as vector formats\u001b[39;00m\n\u001b[1;32m 2336\u001b[0m \u001b[38;5;66;03m# force the figure dpi to 72), so we need to set it again here.\u001b[39;00m\n\u001b[1;32m 2337\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m cbook\u001b[38;5;241m.\u001b[39m_setattr_cm(\u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mfigure, dpi\u001b[38;5;241m=\u001b[39mdpi):\n\u001b[0;32m-> 2338\u001b[0m result \u001b[38;5;241m=\u001b[39m \u001b[43mprint_method\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 2339\u001b[0m \u001b[43m \u001b[49m\u001b[43mfilename\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 2340\u001b[0m \u001b[43m \u001b[49m\u001b[43mfacecolor\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mfacecolor\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 2341\u001b[0m \u001b[43m \u001b[49m\u001b[43medgecolor\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43medgecolor\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 2342\u001b[0m \u001b[43m \u001b[49m\u001b[43morientation\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43morientation\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 2343\u001b[0m \u001b[43m \u001b[49m\u001b[43mbbox_inches_restore\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43m_bbox_inches_restore\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 2344\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 2345\u001b[0m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[1;32m 2346\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m bbox_inches \u001b[38;5;129;01mand\u001b[39;00m restore_bbox:\n", + "File \u001b[0;32m~/.conda/envs/pts/lib/python3.8/site-packages/matplotlib/backend_bases.py:2204\u001b[0m, in \u001b[0;36mFigureCanvasBase._switch_canvas_and_return_print_method..\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 2200\u001b[0m optional_kws \u001b[38;5;241m=\u001b[39m { \u001b[38;5;66;03m# Passed by print_figure for other renderers.\u001b[39;00m\n\u001b[1;32m 2201\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mdpi\u001b[39m\u001b[38;5;124m\"\u001b[39m, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mfacecolor\u001b[39m\u001b[38;5;124m\"\u001b[39m, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124medgecolor\u001b[39m\u001b[38;5;124m\"\u001b[39m, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124morientation\u001b[39m\u001b[38;5;124m\"\u001b[39m,\n\u001b[1;32m 2202\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mbbox_inches_restore\u001b[39m\u001b[38;5;124m\"\u001b[39m}\n\u001b[1;32m 2203\u001b[0m skip \u001b[38;5;241m=\u001b[39m optional_kws \u001b[38;5;241m-\u001b[39m {\u001b[38;5;241m*\u001b[39minspect\u001b[38;5;241m.\u001b[39msignature(meth)\u001b[38;5;241m.\u001b[39mparameters}\n\u001b[0;32m-> 2204\u001b[0m print_method \u001b[38;5;241m=\u001b[39m functools\u001b[38;5;241m.\u001b[39mwraps(meth)(\u001b[38;5;28;01mlambda\u001b[39;00m \u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs: \u001b[43mmeth\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 2205\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43m{\u001b[49m\u001b[43mk\u001b[49m\u001b[43m:\u001b[49m\u001b[43m \u001b[49m\u001b[43mv\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mfor\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mk\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mv\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mkwargs\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mitems\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mif\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mk\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01mnot\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mskip\u001b[49m\u001b[43m}\u001b[49m\u001b[43m)\u001b[49m)\n\u001b[1;32m 2206\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m: \u001b[38;5;66;03m# Let third-parties do as they see fit.\u001b[39;00m\n\u001b[1;32m 2207\u001b[0m print_method \u001b[38;5;241m=\u001b[39m meth\n", + "File \u001b[0;32m~/.conda/envs/pts/lib/python3.8/site-packages/matplotlib/_api/deprecation.py:410\u001b[0m, in \u001b[0;36mdelete_parameter..wrapper\u001b[0;34m(*inner_args, **inner_kwargs)\u001b[0m\n\u001b[1;32m 400\u001b[0m deprecation_addendum \u001b[38;5;241m=\u001b[39m (\n\u001b[1;32m 401\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mIf any parameter follows \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mname\u001b[38;5;132;01m!r}\u001b[39;00m\u001b[38;5;124m, they should be passed as \u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 402\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mkeyword, not positionally.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 403\u001b[0m warn_deprecated(\n\u001b[1;32m 404\u001b[0m since,\n\u001b[1;32m 405\u001b[0m name\u001b[38;5;241m=\u001b[39m\u001b[38;5;28mrepr\u001b[39m(name),\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 408\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m deprecation_addendum,\n\u001b[1;32m 409\u001b[0m \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs)\n\u001b[0;32m--> 410\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43minner_args\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43minner_kwargs\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/.conda/envs/pts/lib/python3.8/site-packages/matplotlib/backends/backend_svg.py:1389\u001b[0m, in \u001b[0;36mFigureCanvasSVG.print_svg\u001b[0;34m(self, filename, bbox_inches_restore, metadata, *args)\u001b[0m\n\u001b[1;32m 1355\u001b[0m \u001b[38;5;129m@_api\u001b[39m\u001b[38;5;241m.\u001b[39mdelete_parameter(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m3.5\u001b[39m\u001b[38;5;124m\"\u001b[39m, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124margs\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 1356\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mprint_svg\u001b[39m(\u001b[38;5;28mself\u001b[39m, filename, \u001b[38;5;241m*\u001b[39margs, bbox_inches_restore\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m,\n\u001b[1;32m 1357\u001b[0m metadata\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m):\n\u001b[1;32m 1358\u001b[0m \u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 1359\u001b[0m \u001b[38;5;124;03m Parameters\u001b[39;00m\n\u001b[1;32m 1360\u001b[0m \u001b[38;5;124;03m ----------\u001b[39;00m\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 1387\u001b[0m \u001b[38;5;124;03m __ DC_\u001b[39;00m\n\u001b[1;32m 1388\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[0;32m-> 1389\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[43mcbook\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mopen_file_cm\u001b[49m\u001b[43m(\u001b[49m\u001b[43mfilename\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mw\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mencoding\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mutf-8\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m \u001b[38;5;28;01mas\u001b[39;00m fh:\n\u001b[1;32m 1390\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m cbook\u001b[38;5;241m.\u001b[39mfile_requires_unicode(fh):\n\u001b[1;32m 1391\u001b[0m fh \u001b[38;5;241m=\u001b[39m codecs\u001b[38;5;241m.\u001b[39mgetwriter(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mutf-8\u001b[39m\u001b[38;5;124m'\u001b[39m)(fh)\n", + "File \u001b[0;32m~/.conda/envs/pts/lib/python3.8/site-packages/matplotlib/cbook/__init__.py:506\u001b[0m, in \u001b[0;36mopen_file_cm\u001b[0;34m(path_or_file, mode, encoding)\u001b[0m\n\u001b[1;32m 504\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mopen_file_cm\u001b[39m(path_or_file, mode\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mr\u001b[39m\u001b[38;5;124m\"\u001b[39m, encoding\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m):\n\u001b[1;32m 505\u001b[0m \u001b[38;5;124mr\u001b[39m\u001b[38;5;124;03m\"\"\"Pass through file objects and context-manage path-likes.\"\"\"\u001b[39;00m\n\u001b[0;32m--> 506\u001b[0m fh, opened \u001b[38;5;241m=\u001b[39m \u001b[43mto_filehandle\u001b[49m\u001b[43m(\u001b[49m\u001b[43mpath_or_file\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mmode\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mTrue\u001b[39;49;00m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mencoding\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 507\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m fh \u001b[38;5;28;01mif\u001b[39;00m opened \u001b[38;5;28;01melse\u001b[39;00m contextlib\u001b[38;5;241m.\u001b[39mnullcontext(fh)\n", + "File \u001b[0;32m~/.conda/envs/pts/lib/python3.8/site-packages/matplotlib/cbook/__init__.py:492\u001b[0m, in \u001b[0;36mto_filehandle\u001b[0;34m(fname, flag, return_opened, encoding)\u001b[0m\n\u001b[1;32m 490\u001b[0m fh \u001b[38;5;241m=\u001b[39m bz2\u001b[38;5;241m.\u001b[39mBZ2File(fname, flag)\n\u001b[1;32m 491\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m--> 492\u001b[0m fh \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mopen\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mfname\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mflag\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mencoding\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mencoding\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 493\u001b[0m opened \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mTrue\u001b[39;00m\n\u001b[1;32m 494\u001b[0m \u001b[38;5;28;01melif\u001b[39;00m \u001b[38;5;28mhasattr\u001b[39m(fname, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mseek\u001b[39m\u001b[38;5;124m'\u001b[39m):\n", + "\u001b[0;31mFileNotFoundError\u001b[0m: [Errno 2] No such file or directory: 'figures/BMag_ha_R2_size.svg'" + ] + }, + { + "data": { + "image/png": "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FosFRVH49ddf+fe//83ll1/eanwYPnw4a9as4fPPP+fzzz9nxowZ9OjRg88//9yvBXBWfVAFqqqqamVlpXr//ferKSkp6hVXXKEuX7482Cb5WLRokdqjR496/2bPnq2qqqqmpKSomzZt8pXdsGGDOmLECLV///7qLbfcoubm5vquZWZmqmPHjq1X97Jly9QLL7xQ/e23386oD4WFhWqPHj3Ufv36qSkpKb5///znP1uNH9XV1ertt9+uDhw4UE1JSVGHDRumLl26VFUUpdX4cDzH2xEsH0TOaoFAEHRE00wgEAQdIUQCgSDoCCESCARBRwiRQCAIOkKIBAJB0BFCJBAIgo4QIoFAEHSEEAkEgqAjhOg8YsKECSxYsKDJ5RcvXsz48ePPoEUtk4yMDD755JMz+hqFhYUsXrw46HW0FHTBNkBwYsaPH8/ll1/epNXSJ2Px4sXo9foml580aRITJkw47dcVCE6GEKJzAJfL1aSEVFFRUQHV29rSoLQGSktLeeaZZ9i4cSPV1dWsWrWKkSNHcs8995zVOloaomnWgvnb3/7G5s2bWbJkCT179iQjIwM41mR65513GDRoEGPHjgW8Ca+GDx9OcnIy1157Le+99169+o5vmvXs2ZOsrCzuuOMOkpOTGTNmTL20D8c3zSZMmMD8+fN57LHHSE1NJSMjg6+++qrea6xcuZIhQ4aQkpLC7Nmzef75508YVe3YsYPx48eTkpLCRRddxF/+8heqqqoA+Oabbxg3bhypqakMGjSIJ554ApvN5mffu+++y6BBgxg4cCCvv/46LpeLxx57jLS0NK655hp+/PFH3z1ZWVlceeWVZGdnM3jwYFJTU5kzZw4ul6tRGwsKCpgyZYrPjnnz5mG3233Xly1bRkZGBv369ePKK688YXPp2WefZf/+/Tz22GOMHj2aWbNmYTQafdd//vlnevbsyU8//cSIESNITU1l2rRpVFZWNrmO1ogQohbM3//+dwYMGMCkSZP44Ycf+PTTT33XcnJy2Lp1K++88w4vv/wyAAaDgaeeeoovv/ySBx54gAULFrB27doTvsarr77KX/7yF7Kzs4mLi+PRRx89YfmPPvqICy64gOzsbEaPHs0jjzxCaWkp4E269de//pXx48eTlZVFfHw8H3300Qnre/jhh0lLS+OLL77gww8/5MYbb/RdczqdTJkyhRUrVrBgwQJ+/vlnvyRku3btIicnh3fffZdHH32UBQsWMHXqVJKSksjKymLQoEHMnj27ntBUVFSQmZnJ0qVLWbJkCd9//z1Lly5t0D6Xy8Wdd95Jt27dyMzM5LXXXmPbtm0899xzAGzdupXFixfz5JNPsnr1al555RW6du3aqL+7du1i1KhR9OnThzZt2jB48GDuuOMOv3KvvfYazz33HO+99x65ubn885//DLiO1oRomrVgwsPD0el0hIaGEhsb63f96aefrtd8mjhxou/3Ll26sGHDBlatWsXgwYMbfY1bb72Vq6++GoB77rmHW2+9FavV2mizLC0tzffQT506lX/9619s3bqVq666io8//pjk5GRfE2Hq1KknFcKioiKuuuoqunTpAlAvGd2IESPq+XPffffx8ssvM2vWLN95nU7Hk08+icFgICEhgTfeeAOdTueLwqZNm8aHH37I/v37falRnU4nTzzxBAkJCQDMmDGDF198scF+uJUrV2I2m3nkkUd85x555BFuv/12HnvsMYqKimjbti2XXXYZOp2Ojh07kpaW1qi/ycnJfPrppw2ml63Lww8/zIABAwAYO3Ys//nPfwKuozUhhKiVEh8f7ycWtd/sBw4cwG6343a7G0xiVZe62fbatm0LQFlZWaNCVLe8TqcjOjq6XkTUt2/feuX79evHrl27Gn39v/zlL0yaNIlBgwZxxRVXMHz4cGJiYgDYs2cPCxYsYMeOHVRWViLLMrIs17u/W7du9frH2rZtW28ThLo+1RIWFuYTIYD+/ftTUVFBeXm5304pu3btYteuXaSmpvrOqaqK2+2muLiYyy+/nAULFnDNNdeQnp5ORkYGgwcPbjQp/SOPPMKrr77KwoULOXr0KD/99BP3338/l112Wb1yx78vde1vah2tCdE0a6Ucv2VLQUEB9913H5deeilLly7ls88+46abbmpwD6q61B1Fq/3wKIrSaHmdrv53l0ajoTallaqqJ9wVoiEeeughPv30U1JSUvj8888ZPny4b1eVqVOnotFoePHFF8nMzGTOnDl+/jRkT91ztfbUTbsViI02m42BAweSnZ3t+/f555+zevVqYmNjCQ8PZ8WKFTzxxBMYDAYeffRRpk6d2mh9ZrOZ2bNns3z5cm6++WbS0tKYPHkyBQUFjfql0WjqvSdNraM1IYSohaPT6fyigIbYsWMHJpOJGTNm0L9/f+Lj4yksLDwLFh6je/fu7Nixo9657du3n/S+Hj16MHnyZD7++GPatm3LmjVrKCsrIz8/n3vvvZeBAwdywQUXcPTo0Wax02KxsG/fPt/xtm3biIqKanDfuF69erFv3z7at29Pt27d6v2rFXGDwcDgwYOZM2cOr7/+Ot99950vSjwRsbGx/PWvf8VoNPr93ZpKc9TREhBC1MLp1KkTv//+O8XFxfVGTo6na9euWCwWsrKyOHDggK9T9Wwybtw4fvvtN9544w3279/PG2+8UW8PrONxOBw8/fTT/PLLLxw8eJC1a9dy6NAhunfvTmRkJJGRkXz88ccUFBSwcuXKk3Z8NxWj0cgTTzxBTk4OP/30E4sXL+a2225rsOyNN96IXq/ngQceYOvWrRw4cIBvv/2W559/HoDvvvuODz74gF27dvnsjI6ObnSqxD/+8Q9+/fVX7HY7LpeLTz75BLvdftKtfZq7jpaG6CNq4UyaNInZs2czdOhQ4uLi+Pbbbxss16dPH2bOnMkLL7yA0+lk+PDh/OlPf2pSRNJcxMfHM3/+fF588UVee+01rr32Wm666Sby8/MbLC9JEqWlpTz44IOUlZXRrl077r33Xl/n+QsvvMAzzzxDZmYmKSkp3H///cyZM+e07YyKimLUqFHcfffdVFdXM2LECKZMmdJgWbPZzPvvv8/zzz/PpEmT8Hg8dO3a1bdDanh4OCtXrmTBggXIskzv3r1ZunRpo3t8tWvXjnnz5pGfn4/L5aJz584899xzdO/evcn2N0cdLQ2Rs1pwRrnjjjvo3r07jz/+eLBNAbzziF555RXWrVsXVDsKCwv57LPPTmvGfHPU0VIQEZGgWVm+fDkXXnghISEhfP3112zYsIHp06cH2yxBC0cIkaBZ2bVrF6+99hpWq5X4+HgWL158wnk15yudO3c+7UimOepoKYimmUAgCDpi1EwgEAQdIUQCgSDoCCESCARBRwiRQCAIOkKIBAJB0BFCJBAIgo4QIoFAEHSEEAkEgqDz/wHrrjZnqLh5bgAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "b_r2 = {\n", + " \"BMag_ha\": 0.760720,\n", + " \"V_ha\": 0.763\n", + "}\n", + "\n", + "for target in targets:\n", + " f, ax = plt.subplots(figsize=(7.48031/3, 1.5))\n", + " sns.lineplot(x=\"n_samples\", y=\"R2\", data=result_scores[\"test\"].query(\"target == @target\"), errorbar=\"se\", markers=[\"x\"], dashes=False, ax=ax)\n", + " ax.plot(np.arange(4300), [b_r2[target]] * 4300, linestyle=\"--\",c=\"orange\")\n", + " ax.scatter([4270], [b_r2[target]], c=\"orange\", label=\"\\power{} baseline\")\n", + " ax.set_xticks(range(0, 4300, 1000), labels=[f\"{n/1000}K\" if n > 0 else 0 for n in range(0, 4300, 1000)])\n", + " #ax.legend()\n", + " ax.set_ylabel(\"\\$R^2\\$\")\n", + " ax.set_xlabel(\"training samples \\$n\\$\")\n", + " ax.set_xlim(0, 4300)\n", + " plt.subplots_adjust(left=0.15, right=1, top=1, bottom=0.15, hspace=0.15, wspace=0.1)\n", + " plt.savefig(f\"figures/{target}_R2_size.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e5e5641-4927-4862-823b-798dd44c35c8", + "metadata": {}, + "outputs": [], + "source": [ + "b_rmse = {\n", + " \"BMag_ha\": 49.508961,\n", + " \"V_ha\": 92.82\n", + "}\n", + "\n", + "for target in targets:\n", + " f, ax = plt.subplots(figsize=(7.48031/3, 1.5))\n", + " sns.lineplot(x=\"n_samples\", y=\"RMSE\", data=result_scores[\"test\"].query(\"target == @target\"), markers=True, errorbar=\"se\", ax=ax)\n", + " ax.plot(np.arange(4300), [b_rmse[target]] * 4300, linestyle=\"--\", c=\"orange\")\n", + " ax.scatter([4270], [b_rmse[target]], c=\"orange\", label=\"\\power{} baseline\")\n", + " ax.set_xlim(0, 4300)\n", + " ax.set_xticks(range(0, 4300, 1000), labels=[f\"{n/1000}K\" if n > 0 else 0 for n in range(0, 4300, 1000)])\n", + " plt.subplots_adjust(left=0.15, right=1, top=1, bottom=0.15, hspace=0.15, wspace=0.1)\n", + " ax.set_xlabel(\"training samples \\$n\\$\")\n", + " #ax.legend()\n", + " plt.savefig(f\"figures/{target}_RMSE_size.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "df355b4a-ab63-457d-9362-722744fa6af2", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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eOEF5s6dyz//+U+ioqIYPnx4iRaHM99evXoxZMiQCvOtLlUSmk2bNjFy5Ei6detGt27dGDlyZAkT6soSFBTE1KlTS7QxK2LDhg0ug+qVK1e6fb1bHkmC7XdB/lkQLWDLd/zNPws77nacrwGio6M5c+aM6yZ3smHDBl555RX27t1Ly5YtXQKRk5PDxIkTGTlyJPv27WPmzJlMnz6dpKQkGjduTEBAAEePHgVg//79NG7c2NVE2L9/P927dwcctp0nT55k3bp1/Pbbb6jVaubPn18iht9//50tW7awYcMG17FLly6RmJhIkyZNyi1T06ZN2b1793XNrrZv307v3r05cOAAEydOZOrUqa73wc/Pj2XLlnHo0CFee+01Fi1a5CrXqlWrCA4O5n//+x+7d+/m2WefRRAERFFkypQptGrVih07dvD111+zbds21q9ff93PAeC7775jwoQJHDx4kDvuuMNlDFY83+3bt/PZZ5+xffv2SufrLm4LzZIlS1iwYAG9e/fmzTff5M0336R3794sXLjwuk5o1zJ48GAGDhyIn5+fu2HIVIX03VBwHqRrbCwlG+T/5ThfA6jVamJiYly2C07uvfdebrvtNrRaLbNmzeLw4cOkpqby66+/0rRpU0aNGoVKpaJfv3706tWL7777DoDu3buzf/9+jEYjKSkpPPLII+zbtw+LxcKRI0eIiYmptG3ntGnTMBgM6HQ6ABYsWED//v2JiYlxmbiVxSuvvMLixYvp3LlzhTWbiIgI7rvvPlQqFSNGjKB58+au2l3fvn1p1aoVCoWC7t2706tXL5f9qEqlIj09nYsXL6JWq+nWrRsqlYrjx4+TmprK008/7dprffz48Xz77beV+iwGDBhA165dUSqV3HfffZw8eRKgVL6BgYFu5esubg9vf/nll7z22msltsWIjY2lQ4cOzJ49u1Z73ceNG4coinTs2JFnn322hFmRO4iiWKlqcH3FWb11mjNVmrwzIKhRiKWdECVB4zgf2KtGYuzXrx+//vorw4YNc8XapEkTV7xeXl54enqSmppKWloazZo1K1GWZs2akZaWhiRJdO/enW+++YaOHTsSGRlJTEwMCxYsoFevXgQHB7sMrcqz7bx69aor7+IxAMyePZtHHnmEkSNHcuTIEbp06VJmed59910mT57Mk08+iSAIJd774n+vzb9p06aucuzc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PH788UfuvfdeJk+efEPzqE/ITac65oUXXuDQoUO8//77hIeHExsbC/zdpFm1ahW9e/fmwQcfBByGRkOGDKFLly4MHjyYzz77rER+1zadwsPD2bhxI+PHj6dLly7cf//9JWwBrm06jR07lkWLFjFnzhyioqKIjY3lu+++K3GN77//nn79+hEZGcnzzz/PG2+8UWGt6MSJEzz88MNERkZy++23M2bMGHJzcwHYtm0bo0aNIioqit69ezN37lyMRmOp+D799FN69+5NdHQ0y5cvx2KxMGfOHLp27cqgQYPYvXu36zUbN27kzjvvZNOmTfTt25eoqChmz56NxWIpN8YLFy7w5JNPuuKYP38+hYWFrvOrV68mNjaWjh07cuedd1bYnPn3v//NuXPnmDNnDiNGjOC5555Dq9W6zu/bt4/w8HD27NnD0KFDiYqKYurUqeTk5FQ6j4aGLDR1zMsvv0znzp2ZMGECv//+O+vXr3edi4+P5+jRo6xatYq3334bAI1Gw4IFC/j22295+umnWbJkCb/99luF1/jggw8YM2YMmzZtIigoiJdeeqnC9F999RWhoaFs2rSJESNG8OKLL5KRkQE4TJVmzZrFww8/zMaNGwkJCeGrr76qML9nn32Wrl278v/+3//jiy++YPjw4a5zZrOZJ598ki1btrBkyRL27dtXymTq9OnTxMfH8+mnn/LSSy+xZMkSpkyZQrt27di4cSO9e/fm+eefLyEk2dnZbNiwgRUrVvD+++/z66+/smLFijLjs1gs/OMf/6BVq1Zs2LCBZcuWcezYMV5//XUAjh49ytKlS5k3bx4///wz77zzDi1btiy3vKdPn+a+++6jQ4cO+Pv707dvX8aPH18q3bJly3j99df57LPPSEhI4MMPP3Q7j4aC3HSqY7y8vFCpVOj1egIDA0udX7hwYYnmzeOPP+563qJFC/bu3cuPP/5I3759y73G6NGjGThwIACTJ09m9OjRFBQUlNts6tq1q+tLPWXKFD755BOOHj1K//79+frrr+nSpYurCj9lypTrCt3ly5fp378/LVq0AChhODZ06NAS5fnnP//J22+/zXPPPec6rlKpmDdvHhqNhjZt2vDRRx+hUqlctaipU6fyxRdfcO7cOZd1pdlsZu7cubRp0waAGTNm8NZbb5XZD/b9999jMBh48cUXXcdefPFFHnvsMebMmcPly5cJCAigR48eqFQqmjZtSteuXcstb5cuXVi/fn2Z9p/FefbZZ+ncuTMADz74ID/99JPbeTQUZKGpx4SEhJQSA+cvc1JSEoWFhVit1jJNiopT3C0tICAAgMzMzHKFpnh6lUqFn59fiRrNbbfdViJ9x44dOX36dLnXHzNmDBMmTKB379706tWLIUOG0KhRIwASExNZsmQJJ06cICcnB7vdjt1uL/H6Vq1aleifCggIKGE0X7xMTjw9PV0iA9CpUyeys7PJysoqtdvE6dOnOX36NFFRUa5jkiRhtVpJS0ujZ8+eLFmyhEGDBtGnTx9iY2Pp27dvuabfL774Ih988AHvvvsu6enp7Nmzh2nTptGjR48S6a79XIrHX9k8Ggpy06kec+2WFhcuXOCf//wnd9xxBytWrOCbb77h//7v/8rcg6c4xUehnDeHKIrlplepSv7+KBQKnLZFkiRV6KpfFjNnzmT9+vVERkayefNmhgwZ4tqZYsqUKSgUCt566y02bNjA7NmzS5WnrHiKH3PGU9xayZ0YjUYj0dHRbNq0yfXYvHkzP//8M4GBgXh5ebFlyxbmzp2LRqPhpZdeYsqUKeXmZzAYeP7551m7di0PPPAAXbt25YknnuDChQvllkuhUJT4TCqbR0NBFpp6gEqlKvUrXhYnTpxAp9MxY8YMOnXqREhICCkpKTcgwr9p3bo1J06cKHHs+PHj131dWFgYTzzxBF9//TUBAQFs3bqVzMxMkpOTeeqpp4iOjiY0NJT09PQaiTM/P5+//vrL9f+xY8fw9fUtc++siIgI/vrrLxo3bkyrVq1KPJwirdFo6Nu3L7Nnz2b58uXs2LHDVcuriMDAQGbNmoVWqy31vlWWmsijrpGFph7QrFkz/vzzT9LS0kqMPFxLy5Ytyc/PZ+PGjSQlJbk6LW8ko0aN4siRI3z00UecO3eOjz76qMQeQNdiMplYuHAhBw8e5OLFi/z2229cunSJ1q1b4+Pjg4+PD19//TUXLlzg+++/v27HcmXRarXMnTuX+Ph49uzZw9KlS3n00UfLTDt8+HDUajVPP/00R48eJSkpie3bt/PGG28AsGPHDj7//HNOnz7titPPz6/cqQSvvfYaf/zxB4WFhVgsFtatW0dhYeF1tz6p6TzqE3IfTT1gwoQJPP/88wwYMICgoCC2b99eZroOHTrwzDPP8Oabb2I2mxkyZAgPPfRQpWoUNUVISAiLFi3irbfeYtmyZQwePJj/+7//Izk5ucz0giCQkZHBv/71LzIzMwkODuapp55ydU6/+eabvPrqq2zYsIHIyEimTZvG7Nmzqx2nr68v9913H5MmTSIvL4+hQ4fy5JNPlpnWYDCwZs0a3njjDSZMmIDNZqNly5auHR69vLz4/vvvWbJkCXa7nfbt27NixYpy9zgKDg5m/vz5JCcnY7FYaN68Oa+//jqtW7eudPw1kUd9QvYMlqk248ePp3Xr1sTFxdV1KIBjHs0777zDzp076zSOlJQUvvnmm2rN+K6JPOoDco1Gxm3Wrl1Lt27d8PDw4IcffmDv3r1Mnz69rsOSqcfIQiPjNqdPn2bZsmUUFBQQEhLC0qVLK5xXcqvSvHnzatdEaiKP+oDcdJKRkal15FEnGRmZWkcWGhkZmVpHFhoZGZlaRxYaGRmZWkcWGhkZmVpHFhoZGZlaRxYaGRmZWkcWGhkZmVrn/wN8BlBafbUvtgAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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b79l8eLFbN++vULQnJSUFI4ePcrHH3/Mc889x+LFi5kyZQpt27Zl9erV9O3bl2eeeaacMOTn57Nq1SqWL1/O0qVL2bhxI8uXL6/UPqvVygMPPEB0dDSrVq1i2bJlHDhwgJdeegmA/fv3s2TJEubNm8fPP//MG2+8QcuWLausb0pKCjfffDMdOnQgMDCQ/v37c//991c4btmyZbz00kt88sknHDt2jHfeecfjMpoKcleljvH29kapVKLT6QgODq6wf+HCheW6E+PHj3f/HRUVxbZt2/jxxx/p379/lde44447GDx4MACTJk3ijjvuoKSkpMpuSlJSkvsmnTJlCh988AH79+9n4MCBfPXVV3Tt2tXdZJ4yZcoVhSszM5OBAwcSFRUFUC540ogRI8rV55FHHuH111/n6aefdm9XKpXMmzcPtVpN69atee+991Aqle5WztSpU/n88885deqUO1SexWJh7ty5tG7dGoDHHnuM1157rVI/0vfff4/BYODZZ591b3v22We59957mT17NpmZmQQFBXH99dejVCqJiIggKSmpyvp27dqVr7/+utJwg5fy1FNP0aVLFwDGjBnDTz/95HEZTQVZOK4hMTExFR7usjdnWloaJpMJm81WadCVS7k0mlNQUBAAubm5VQrHpccrlUr8/f3LtTg6duxY7vhOnTqRkpJS5fXvueceJkyYQN++fenTpw/Dhw8nICAAgBMnTrB48WIOHTpEQUEBDocDh8NR7vzo6Ohy/p2goKByQacvrVMZer3eLRoAnTt3Jj8/n7y8vAqR5FNSUkhJSSExMdG9TZIkbDYb2dnZ9O7dm8WLFzNkyBD69etHcnIy/fv3rzII8LPPPsvbb7/Nm2++yfnz59m6dSvTpk3j+uuvL3fc5d/LpfbXtIymgtxVuYZcHoL+9OnTPPLII1x33XUsX76cb775hr///e+V5sC4lEtHWcpudqfTWeXxSmX594MgCJSFYZEkqdqo2ZUxffp0vv76axISEli7di3Dhw93R52fMmUKgiDw2muvsWrVKmbNmlWhPpXZc+m2MnsuDRXjiY1Go5Hu3buzZs0a98/atWv5+eefCQ4Oxtvbm2+//Za5c+eiVqt57rnnmDJlSpXlGQwGnnnmGT799FNuu+02kpKSeOihhzh9+nSV9RIEodx3UtMymgqycNQDSqWywlu2Mg4dOoRWq+Wxxx6jc+fOxMTEcObMmWtg4UViY2M5dOhQuW0HDx684nlxcXE89NBDfPXVVwQFBfHLL7+Qm5tLeno6Dz/8MN27d6dVq1acP3++TuwsLi7mzz//dP9/4MAB/Pz8Ks1b065dO/7880/CwsKIjo4u91Mmumq1mv79+zNr1izeffddNmzY4G6FVUdwcDBPPvkkGo2mwudWU+qijIZGFo56IDIykn379pGdnV3Os345LVu2pLi4mNWrV5OWluZ24l1Lxo4dy969e3nvvfc4deoU7733XrkcHJdjNptZuHAhu3btIiMjg02bNnH27FliY2Px9fXF19eXr776itOnT/P9999f0dFaUzQaDXPnzuXo0aNs3bqVJUuWcPfdd1d67KhRo1CpVDz++OPs37+ftLQ01q9fz8svvwzAhg0b+Oyzz0hJSXHb6e/vX+XQ94svvsgff/yByWTCarWycuVKTCbTFVMV1HUZjQnZx1EPTJgwgWeeeYZBgwYREhLC+vXrKz2uQ4cOPPHEE7z66qtYLBaGDx/O7bffXqM3fl0RExPDK6+8wmuvvcayZcsYOnQof//730lPT6/0eFEUycnJ4f/+7//Izc0lNDSUhx9+2O2sffXVV3nhhRdYtWoVCQkJTJs2jVmzZl21nX5+ftx88808+OCDFBUVMWLECCZPnlzpsQaDgRUrVvDyyy8zYcIE7HY7LVu2dGdA8/b25vvvv2fx4sU4HA7at2/P8uXLq8wxEhoayvz580lPT8dqtdKiRQteeuklYmNja2x/XZTRmJBjjspU4P777yc2NpY5c+Y0tCmAax7HG2+8wa+//tqgdpw5c4ZvvvnmqmYE10UZjQG5xSHDp59+Srdu3fDy8uKHH35g27ZtPProow1tlkwjRhYOGVJSUli2bBklJSXExMSwZMmSauc1/FVp0aLFVbcU6qKMxoDcVZGRkfEYeVRFRkbGY2ThkJGR8RhZOGRkZDxGFg4ZGRmPkYVDRkbGY2ThkJGR8RhZOGRkZDxGFg4ZGRmP+X8LsWtsDUqxTgAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "b_mbias = {\n", + " \"BMag_ha\": 2.03,\n", + " \"V_ha\": 4.50\n", + "}\n", + "ticks = {\n", + " \"BMag_ha\": np.arange(0, 2.1, .5),\n", + " \"V_ha\": np.arange(0, 4.6, 1)\n", + "}\n", + "\n", + "for target in targets:\n", + " f, ax = plt.subplots(figsize=(7.48031/3, 1.5))\n", + " sns.lineplot(x=\"n_samples\", y=\"mean_bias\", data=result_scores[\"test\"].query(\"target == @target\").eval(\"mean_bias = abs(mean_bias)\"), markers=True, errorbar=\"se\", ax=ax, label=\"MSENet14\")\n", + " ax.plot(np.arange(4300), [b_mbias[target]] * 4300, linestyle=\"--\", c=\"orange\")\n", + " ax.scatter([4270], [b_mbias[target]], c=\"orange\", label=\"\\power{} baseline\")\n", + " ax.set_xticks(range(0, 4300, 1000), labels=[f\"{n/1000}K\" if n > 0 else 0 for n in range(0, 4300, 1000)])\n", + " ax.set_yticks(ticks[target])\n", + " ax.set_ylabel(\"abs. mean bias\")\n", + " ax.set_xlabel(\"training samples \\$n\\$\")\n", + " ax.set_xlim(0, 4300)\n", + " ax.legend()\n", + " plt.subplots_adjust(left=0.15, right=1, top=1, bottom=0.15, hspace=0.15, wspace=0.1)\n", + " #plt.savefig(f\"figures/{target}_mbias_size.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "e2d4332d-38ab-4b13-a60c-e31315f43e1e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAkIAAAGxCAYAAACOSdkqAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8o6BhiAAAACXBIWXMAAA9hAAAPYQGoP6dpAACaHUlEQVR4nOzdd5hkVZn48e+9t27lqq7q6tzTaaanJycmISAZJGfQXdeVRVTCCrrIqiyYUNcA666KsO5vVdRdVwkOIEpSgmQQhsnT0zPTEzrHyvHe+/ujp5sZmNChqutW9fk8Dw/TXVW3zu1Kb53zvu+RDMMwEARBEARBmIHkfA9AEARBEAQhX0QgJAiCIAjCjCUCIUEQBEEQZiwRCAmCIAiCMGOJQEgQBEEQhBlLBEKCIAiCIMxYIhASBEEQBGHGsuR7AGaWyWQIBoPYbDZkWcSMgiAIglAIdF0nmUxSUlKCxXL0UEcEQkcRDAZpb2/P9zAEQRAEQZiExsZGAoHAUa8jAqGjsNlsANTX1+NyufI8mtzRNI3W1lZaWlpQFCXfw8mJmXCOIM6zmMyEcwRxnsXETOcYj8dpb28f+xw/GhEIHcXocpjdbsfpdOZ5NLmjaRoATqcz70/eXJkJ5wjiPIvJTDhHEOdZTMx4juNJaxGJL4IgCIIgzFgiEBIEQRAEYcYSS2OCIAhCTui6jmEYR73O6HLK6P+L1Uw4z+k+R0mSslLRLQIhQRAEIauGhobo6+sb1weiYRhYLBba2tqQJGkaRpcfM+E883GOdrudhoaGKQVEIhASBEEQsmZoaIje3l5qa2ux2+3H/EA0DIN4PI7D4SjaAAFmxnlO9zkahkFHRwe9vb1UVVVN+jgiEBIEQRCypq+vj9raWtxu97iubxgGsiyjKErRBggwM84zH+dYWVlJe3s7lZWVk75PkSwtCIIgZIWu62iaht1uz/dQhBlCVVUMwzhmLtrRiEBIEARByIrRD6NinfEQzEsEQoIgCIIgCJMgAiFBEARBMLE333yT8847jxUrVrBz586x3//DP/wDq1at4pvf/OYxj/HDH/6Qz33uc7kc5vs8/PDDXHXVVWM/r1ixgt27d0/rGMZDBEKCIAjCjPGhD33okGCiEPziF79g7dq1vPXWW8yZM2fs9z/72c/43//9X37xi18wODiYxxGOz9tvv01TU1O+h/E+IhASBEEQZoxTTjmFZ599Nt/DOKp0On3Iz0NDQ7S0tBw296qlpQWA4eHh6RhaURKBkCAIgjBjnHbaaTz33HOH/O5jH/sYd999Nx/5yEdYsWIFH//4x+nq6hq7fMOGDVx11VWsXLmSCy+8kOeffx6A7u5uli9fTiqVAuCuu+5i8eLFxONxAO655x5uv/12AFKpFD/84Q85/fTTWbt2LbfccgvBYBCA/fv3M2/ePB588EFOP/10LrnkkkPGp2naURsGSpI0ruaV6XSaz3/+86xYsYLzzz+f119/feyydevWcf7557NixQrOOOMM/ud//mfsssHBQT796U+zevVq1qxZw0c+8pGxc+7r6+Ozn/0sJ5xwAqeeeir33Xcfuq4f9v7nzZs3Nhv3xS9+ka9+9av84z/+IytWrODCCy9k69atY9c9+LinnHIKP/zhD4943KkSgZAgCAVjKpUhQh7ds/bw/w2MfChKQ7vhx8cf/jqj2p45/OX/+5EJDWXVqlXs2LFjLAgZ9dBDD3HHHXfw6quv0tDQwK233gpAMBjk2muv5YorruC1117jlltu4aabbmLPnj1UVVVRVlbGhg0bAHj99depqqri7bffHvt5zZo1APzbv/0b27Zt44EHHuD5559HVVW+/vWvHzKGF198kUcffZSHHnpo7HednZ20tbVRXV19xHOqqanhpZdeOubr489//jMnnXQSb7zxBtdeey033HDD2N/B7/fz4x//mLfeeot//dd/5bvf/e7Yef3sZz+jsrKSl19+mZdeeolbb70VWZbRdZ3rr7+ehoYGnn32WX7zm9/w/PPP8+CDDx7zcQB4/PHHueaaa3jzzTc5/vjj+cY3vgHwvuP+9re/5U9/+tO4jztRIhASBKEgaLrBhv1BBqOpfA9FKGCqqrJ27Vr+8pe/HPL7iy66iEWLFmGz2fj85z/Pm2++SXd3N8899xw1NTVcddVVWCwWTj31VE488UQef/xxANasWcPrr79OLBZj//79/O3f/i2vvfYaqVSK9evXs3btWgzD4Le//S233HILgUAAu93OzTffzJNPPkkmkxkbw2c+8xncbvdYH6Y777yT0047jbVr13LyyScf8ZzuuOMO7r77bpYuXXrUmaH58+dzySWXYLFYuPTSS5k1a9bY7Ngpp5xCQ0MDkiSxZs0aTjzxRN58800ALBYLfX19dHR0oKoq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VfGUhstqweuYWd++JUliToUbRZJMsUZvtcjccFozqYzOf5q0t1BG03lsQxd+p8opOWqgmEhrOK2WoljOMYManwOX1UIsZY5KyWyQJYmrT2jiUx+cTedwnFsffIe23sLtNSRLByrJesP0i0qyGWHS87OVlZV86lOf4lvf+haNjY38/Oc/JxYr3K68ZhFJpqn1z8yGdXZVYW6lebrxLpvl49R55by5Z4iXdw7kezjv89LOAfojSc5fWpOzpZZQIk2dv3i2eMk3VZGZW+kmnMygm+A5nk0XLqvhC+fMJ5rMcNvvNvLWnqF8D2nSVEXGa1fZ1BEkJCrJit6k3j27u7v5yU9+wnnnnccNN9zAkiVLWLduHSUlJdke34wy+sYYcM/cb98VHjvlXhvDMXO8+XzixCbcNgs/+csuU32LNwyDdW93YLXInLuoKif3kdZ0LIpEubd4e1nlQ6nLSrXXTtAkW8xk04nNZdx58WIUWeLrj2/hma09+R7SpNlVBasis7lDVJIVuwkFQg8//DAf//jHOe+882htbeVLX/oSzz//PF/4wheYN29ersY4Y8SSGqVuW94rp/JJliWay91ohjl2qPc5rVx9QiOD0RS/enVPvoczZlNniLa+CGcuqMxZcmookabW5yjoSiAzkiSJpnIXukme49m2qKaE716+lIDLyn/8aQe/ebNwew157CqpjMbWrpAptgMScmNCn7i33XYb1dXVXHHFFbhcLt566y3eeuut913v5ptvztoAZ5J4JsOcClGJ57JZaK5ws70nRKUn/yXbZy2s5E9be3h8Yxenz6+kucKd7yGx7u0OJODiZTU5Ob5+YMPfKlEynxNOq4U55W5ae8JFWRhRV+rke1cs42uPbeZXr+6hP5zkulPmFOQSa6nLRm84wY7eMPOrvDMybaHYTSgQWr16NQBbt2494nXEk2RyMpqOIkv4RFIqMJJU2htOEIynKclzOa4sSdxwajOf/e167nm2jbuuXJbXN/T9QzFebx9kbVMpNb7cBCrhRIYKr73oNvw1k+oSO13DCSLJTFH+nUtdVv71siX86x+38cTmbgajKW790LyCbMpZ5rbRMZTAabXQkKOmpUL+TOjV98tf/jJX45jxwskMFR47Vkvh9xfJhtEd6t/cPUhGU/LeU6mxzMUly2t46K0OHt/YxUU5mokZj0ff6QTg0hw1UISR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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "sns.lineplot(x=\"n_samples\", y=\"MAPE\", data=result_scores[\"test\"].query(\"target == 'BMag_ha'\"), markers=True, errorbar=\"se\")\n", + "plt.plot(np.arange(4300), [365.34] * 4300, linestyle=\"--\", label=\"\\power{} baseline\")\n", + "plt.xlim(0, 4300)\n", + "plt.legend()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aea2c2c8", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.8.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/eval_scripts/result_scores_agg.pickle b/eval_scripts/result_scores_agg.pickle new file mode 100644 index 0000000..75a3879 Binary files /dev/null and b/eval_scripts/result_scores_agg.pickle differ diff --git a/eval_scripts/results_new.pickle b/eval_scripts/results_new.pickle new file mode 100644 index 0000000..32f5940 Binary files /dev/null and b/eval_scripts/results_new.pickle differ diff --git a/eval_scripts/results_size.pickle b/eval_scripts/results_size.pickle new file mode 100644 index 0000000..a92b84b Binary files /dev/null and b/eval_scripts/results_size.pickle differ diff --git a/pointcloud_stats_method/learn_with_stats.ipynb b/pointcloud_stats_method/learn_with_stats.ipynb new file mode 100644 index 0000000..64b350c --- /dev/null +++ b/pointcloud_stats_method/learn_with_stats.ipynb @@ -0,0 +1,898 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "f7927e81", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/stefan/.conda/envs/pts/lib/python3.8/site-packages/scipy/__init__.py:146: UserWarning: A NumPy version >=1.16.5 and <1.23.0 is required for this version of SciPy (detected version 1.24.4\n", + " warnings.warn(f\"A NumPy version >={np_minversion} and <{np_maxversion}\"\n" + ] + } + ], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "from pathlib import Path\n", + "import glob\n", + "from joblib import load\n", + "from sklearn.metrics import r2_score" + ] + }, + { + "cell_type": "markdown", + "id": "954b6c05", + "metadata": {}, + "source": [ + "# look at stats" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "836b6c7c", + "metadata": {}, + "outputs": [], + "source": [ + "# we only include the anonomysed set as agreed with the data owners\n", + "\n", + "df_train = pd.read_csv(\"../nfi-data/train_split.csv\")\n", + "df_val = pd.read_csv(\"../nfi-data/val_split.csv\")\n", + "df_test = pd.read_csv(\"../nfi-data/test_split.csv\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "229e28c0", + "metadata": {}, + "outputs": [], + "source": [ + "df_train.eval(\"temp_diff_years = temp_diff_days / 365\", inplace=True)\n", + "df_val.eval(\"temp_diff_years = temp_diff_days / 365\", inplace=True)\n", + "df_test.eval(\"temp_diff_years = temp_diff_days / 365\", inplace=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "01da773a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "919" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "len(df_val)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f7e8a4e2", + "metadata": {}, + "outputs": [], + "source": [ + "variable_list = [\n", + " \"h_mean_1_\",\n", + " \"h_mean_2_\",\n", + " \"h_std_1_\",\n", + " \"h_std_2_\",\n", + " \"h_coov_1_\",\n", + " \"h_coov_2_\",\n", + " \"h_kur_1_\",\n", + " \"h_kur_2_\",\n", + " \"h_skew_1_\",\n", + " \"h_skew_2_\",\n", + " \"IR_\",\n", + " *[f\"h_q{i}_1_\" for i in [5, 10, 25, 50, 75, 90, 95, 99]],\n", + " *[f\"h_q{i}_2_\" for i in [5, 10, 25, 50, 75, 90, 95, 99]],\n", + " \"temp_diff_years\"\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "62dc65ee", + "metadata": {}, + "outputs": [], + "source": [ + "target_list = [\"BMag_ha\", \"V_ha\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a202a5b8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
h_mean_1_h_mean_2_h_std_1_h_std_2_h_coov_1_h_coov_2_h_kur_1_h_kur_2_h_skew_1_h_skew_2_...h_q99_1_h_q5_2_h_q10_2_h_q25_2_h_q50_2_h_q75_2_h_q90_2_h_q95_2_h_q99_2_temp_diff_years
count919.000000919.000000919.000000919.000000919.000000919.000000919.000000919.000000919.000000919.000000...919.000000919.000000919.000000919.000000919.00000919.000000919.000000919.000000919.000000919.000000
mean7.75688610.7375375.0214823.6190871.0672210.38217822.0670382.0802550.551701-0.235734...16.7719354.3937715.8400248.44227411.0842013.30409415.00724515.91396617.312287-0.070031
std5.6509145.8267002.6188111.7756451.1043520.218708280.69339714.6060874.2206331.242230...7.5695413.8625394.6776385.6693296.379346.7919607.0770317.2507017.4579280.555424
min-0.0248251.1128570.0532500.087785-4.6569720.069645-1.926685-2.000000-13.816676-3.078436...0.0700001.0100001.0200001.0300001.075001.1500001.1970001.2525001.330500-1.000000
25%3.1796626.2398913.0993132.3837290.5351940.262913-1.173350-0.621735-0.683866-0.871200...11.3717501.5900002.1850003.7437506.012508.0450009.77600010.44000011.883950-0.613699
50%6.6729529.6470544.6351123.4010800.7987200.354456-0.470218-0.0289350.003507-0.356868...16.8602002.8650004.1720007.0600009.9500012.36000014.56000015.69650017.2900000.068493
75%11.12915014.8997066.8804954.6253161.1976070.4445631.4943421.1753100.8863960.223805...22.5609005.9340008.33500012.13250015.7600018.27375020.24600021.30700022.7899500.410959
max26.87360428.32845115.99349214.31947113.5157062.9336475577.448026253.40650368.14599312.024318...44.66000021.68000024.49000027.53000029.8050037.08000040.99400042.59000046.5100000.936986
\n", + "

8 rows × 28 columns

\n", + "
" + ], + "text/plain": [ + " h_mean_1_ h_mean_2_ h_std_1_ h_std_2_ h_coov_1_ h_coov_2_ \\\n", + "count 919.000000 919.000000 919.000000 919.000000 919.000000 919.000000 \n", + "mean 7.756886 10.737537 5.021482 3.619087 1.067221 0.382178 \n", + "std 5.650914 5.826700 2.618811 1.775645 1.104352 0.218708 \n", + "min -0.024825 1.112857 0.053250 0.087785 -4.656972 0.069645 \n", + "25% 3.179662 6.239891 3.099313 2.383729 0.535194 0.262913 \n", + "50% 6.672952 9.647054 4.635112 3.401080 0.798720 0.354456 \n", + "75% 11.129150 14.899706 6.880495 4.625316 1.197607 0.444563 \n", + "max 26.873604 28.328451 15.993492 14.319471 13.515706 2.933647 \n", + "\n", + " h_kur_1_ h_kur_2_ h_skew_1_ h_skew_2_ ... h_q99_1_ \\\n", + "count 919.000000 919.000000 919.000000 919.000000 ... 919.000000 \n", + "mean 22.067038 2.080255 0.551701 -0.235734 ... 16.771935 \n", + "std 280.693397 14.606087 4.220633 1.242230 ... 7.569541 \n", + "min -1.926685 -2.000000 -13.816676 -3.078436 ... 0.070000 \n", + "25% -1.173350 -0.621735 -0.683866 -0.871200 ... 11.371750 \n", + "50% -0.470218 -0.028935 0.003507 -0.356868 ... 16.860200 \n", + "75% 1.494342 1.175310 0.886396 0.223805 ... 22.560900 \n", + "max 5577.448026 253.406503 68.145993 12.024318 ... 44.660000 \n", + "\n", + " h_q5_2_ h_q10_2_ h_q25_2_ h_q50_2_ h_q75_2_ h_q90_2_ \\\n", + "count 919.000000 919.000000 919.000000 919.00000 919.000000 919.000000 \n", + "mean 4.393771 5.840024 8.442274 11.08420 13.304094 15.007245 \n", + "std 3.862539 4.677638 5.669329 6.37934 6.791960 7.077031 \n", + "min 1.010000 1.020000 1.030000 1.07500 1.150000 1.197000 \n", + "25% 1.590000 2.185000 3.743750 6.01250 8.045000 9.776000 \n", + "50% 2.865000 4.172000 7.060000 9.95000 12.360000 14.560000 \n", + "75% 5.934000 8.335000 12.132500 15.76000 18.273750 20.246000 \n", + "max 21.680000 24.490000 27.530000 29.80500 37.080000 40.994000 \n", + "\n", + " h_q95_2_ h_q99_2_ temp_diff_years \n", + "count 919.000000 919.000000 919.000000 \n", + "mean 15.913966 17.312287 -0.070031 \n", + "std 7.250701 7.457928 0.555424 \n", + "min 1.252500 1.330500 -1.000000 \n", + "25% 10.440000 11.883950 -0.613699 \n", + "50% 15.696500 17.290000 0.068493 \n", + "75% 21.307000 22.789950 0.410959 \n", + "max 42.590000 46.510000 0.936986 \n", + "\n", + "[8 rows x 28 columns]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_val[variable_list].describe()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "cd534652", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
BMag_haV_ha
count919.000000919.000000
mean112.378628210.213042
std105.828078195.837800
min0.0000000.000000
25%29.29285552.422856
50%86.551500154.638388
75%165.194985325.370432
max668.0819761176.918249
\n", + "
" + ], + "text/plain": [ + " BMag_ha V_ha\n", + "count 919.000000 919.000000\n", + "mean 112.378628 210.213042\n", + "std 105.828078 195.837800\n", + "min 0.000000 0.000000\n", + "25% 29.292855 52.422856\n", + "50% 86.551500 154.638388\n", + "75% 165.194985 325.370432\n", + "max 668.081976 1176.918249" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_val[target_list].describe()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "958358bd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([,\n", + " ], dtype=object)" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAj8AAAGdCAYAAAD9kBJPAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8o6BhiAAAACXBIWXMAAA9hAAAPYQGoP6dpAABAAUlEQVR4nO3de1RVdf7/8deJywkUUEE4MCJRYk7iVGrpOJqaiqKZl76lqQlqrRzN0dS85LefVipWI1njV2uaAh0r7aJ97S5eu2ijonhrMsZQUCHMFMQLIOzfHy7PtyN44bgPHDjPx1p7rc5nf84+7/PROq8++7P3thiGYQgAAMBD3FDTBQAAAFQnwg8AAPAohB8AAOBRCD8AAMCjEH4AAIBHIfwAAACPQvgBAAAehfADAAA8indNF+AOysvLdfToUQUEBMhisdR0OQAA4BoYhqFTp04pIiJCN9xw7fM5hB9JR48eVWRkZE2XAQAAnJCTk6MmTZpcc3/Cj6SAgABJFwYvMDCwhqsBAADXorCwUJGRkfbf8WtF+JHsp7oCAwMJPwAA1DJVXbLCgmcAAOBRCD8AAMCjEH4AAIBHYc0PAKBOMwxD58+fV1lZWU2Xgiry8vKSt7e36behIfwAAOqskpIS5ebm6syZMzVdCpzk7++v8PBw+fr6mnZMwo+L3TTt0wptB+f1qYFKAMCzlJeXKysrS15eXoqIiJCvry83sq1FDMNQSUmJjh07pqysLMXExFTpRoZXQvgBANRJJSUlKi8vV2RkpPz9/Wu6HDjBz89PPj4+OnTokEpKSnTjjTeaclwWPAMA6jSzZgtQM1zx58ffCAAA4FEIPwAAwKOw5gcA4HEquxjFlTzhQpfExESdPHlSH330UU2XclXM/AAA4GYSExNlsVjsW3BwsHr16qXdu3fb+1zc99133zm8t7i4WMHBwbJYLNq4cWM1V147EH4AAHBDvXr1Um5urnJzc7Vu3Tp5e3vrvvvuc+gTGRmplJQUh7ZVq1apfv361VlqrUP4AQDADVmtVtlsNtlsNt1xxx2aOnWqcnJydOzYMXufhIQELV++XGfPnrW3vfXWW0pISKhwvKlTp6p58+by9/fXzTffrGeeeUalpaUOfWbPnq3Q0FAFBATo0Ucf1bRp03THHXdUqe6//vWvCg8PV3BwsMaOHevwGcuWLVPbtm0VEBAgm82mIUOGKD8/v0rHNwPhBwAAN1dUVKS3335bzZo1U3BwsL29TZs2io6O1ocffihJysnJ0VdffaVHHnmkwjECAgKUmpqq77//Xq+88oreeOMNvfzyy/b9b7/9tubMmaMXXnhB6enpatq0qRYvXlylOjds2KADBw5ow4YNWrJkiVJTU5WammrfX1JSoueff167du3SRx99pKysLCUmJlZtMEzAgmcAANzQJ598Yj99dfr0aYWHh+uTTz6pcN+bESNG6K233tKwYcOUkpKi3r17q3HjxhWO99///d/2f77ppps0adIkrVixQlOmTJEk/e1vf9OoUaM0YsQISdL/+3//T2vWrFFRUdE119ywYUMtXLhQXl5eatGihfr06aN169bpsccekySNHDnS3vfmm2/Wq6++qrvvvltFRUXVeqquRmd+vvrqK/Xt21cRERGyWCwOK8RLS0s1depUtWrVSvXq1VNERISGDx+uo0ePOhyjS5cuDovCLBaLBg8eXM3fBAAAc3Xt2lUZGRnKyMjQv/71L8XFxSk+Pl6HDh1y6Dds2DBt2bJFP/30k1JTUx0Cxm998MEH6tixo2w2m+rXr69nnnlG2dnZ9v379+/X3Xff7fCeS19fTcuWLeXl5WV/HR4e7nBaa+fOnerXr5+ioqIUEBCgLl26SJJDHdWhRsPP6dOndfvtt2vhwoUV9p05c0Y7duzQM888ox07dmjlypX68ccfdf/991fo+9hjj9kXheXm5ur111+vjvIBAHCZevXqqVmzZmrWrJnuvvtuvfnmmzp9+rTeeOMNh37BwcG67777NGrUKJ07d07x8fEVjvXdd99p8ODBio+P1yeffKKdO3dqxowZKikpceh36bPPDMOoUs0+Pj4VjldeXi7pwm9+XFyc6tevr2XLlmnbtm1atWqVJFWow9Vq9LRXfHx8pX9IkhQUFKS0tDSHtr/97W+6++67lZ2draZNm9rb/f39ZbPZXForAAA1yWKx6IYbbnBY3HzRyJEj1bt3b02dOtVh5uWib7/9VlFRUZoxY4a97dIZpFtvvVVbt251WC+0fft20+r/4Ycf9Msvv2jevHmKjIw0/fhVUavW/BQUFMhisahBgwYO7W+//baWLVumsLAwxcfHa+bMmQoICLjscYqLi1VcXGx/XVhY6KqSAQBwSnFxsfLy8iRJJ06c0MKFC1VUVKS+fftW6NurVy8dO3ZMgYGBlR6rWbNmys7O1vLly3XXXXfp008/tc+6XDRu3Dg99thjatu2rTp06KAVK1Zo9+7duvnmm035Pk2bNpWvr6/+9re/afTo0dq7d6+ef/55U45dVbUm/Jw7d07Tpk3TkCFDHP5whw4dqujoaNlsNu3du1fTp0/Xrl27Kswa/VZSUpKeffbZ6igbAOCGasMdl7/44guFh4dLunClVosWLfT+++/b18n8lsViUUhIyGWP1a9fPz355JN64oknVFxcrD59+uiZZ57RrFmz7H2GDh2qn376SZMnT9a5c+f00EMPKTExUVu3bjXl+zRu3Fipqal6+umn9eqrr6p169b661//WulyFlezGFU9oeciFotFq1atUv/+/SvsKy0t1YMPPqjs7Gxt3LjxsslWktLT09W2bVulp6erdevWlfapbOYnMjJSBQUFVzy2Myq7hXpt+JcOAGq7c+fOKSsrS9HR0brxxhtrupxaqUePHrLZbPrnP/9ZYzVc6c+xsLBQQUFBVf79dvuZn9LSUj300EPKysrS+vXrr/rlWrduLR8fH2VmZl42/FitVlmtVleUCwBArXTmzBm99tpr6tmzp7y8vPTuu+9q7dq1VzyTUlu5dfi5GHwyMzO1YcMGhxs7Xc6+fftUWlpqnyoEAABXZ7FY9Nlnn2n27NkqLi7Wrbfeqg8//FDdu3eXpCveh+fzzz9Xp06dqqvU61aj4aeoqEj/+c9/7K+zsrKUkZGhRo0aKSIiQv/1X/+lHTt26JNPPlFZWZl94VejRo3k6+urAwcO6O2331bv3r0VEhKi77//XpMmTdKdd96pP/3pTzX1tQAAqHX8/Py0du3ay+7PyMi47L7f/e53LqjIdWo0/Gzfvl1du3a1v544caKkC88qmTVrllavXi1JFZ4rsmHDBnXp0kW+vr5at26dXnnlFRUVFSkyMlJ9+vTRzJkzK73UDwAAOKdZs2Y1XYJpajT8dOnS5Yo3ULraWuzIyEht2rTJ7LIAAHWIm1zXAye54s+PB5sCAOqki3cbPnPmTA1Xgutx8c/v0rtHXw+3XvAMAICzvLy81KBBA/uzpfz9/Ss8vgHuyzAMnTlzRvn5+WrQoIGpy1kIPwCAOuvio49++3BN1C4NGjQw/RFWhB8AQJ1lsVgUHh6u0NBQlZaW1nQ5qCIfHx+XXMBE+AEA1HleXl5cBQw7FjwDAACPQvgBAAAehfADAAA8CuEHAAB4FMIPAADwKIQfAADgUQg/AADAoxB+AACARyH8AAAAj0L4AQAAHoXwAwAAPArhBwAAeBTCDwAA8Cg1Gn6++uor9e3bVxEREbJYLProo48c9huGoVmzZikiIkJ+fn7q0qWL9u3b59CnuLhY48aNU0hIiOrVq6f7779fhw8frsZvAQAAapMaDT+nT5/W7bffroULF1a6/8UXX1RycrIWLlyobdu2yWazqUePHjp16pS9z4QJE7Rq1SotX75c33zzjYqKinTfffeprKysur4GAACoRbxr8sPj4+MVHx9f6T7DMLRgwQLNmDFDAwcOlCQtWbJEYWFheuedd/T444+roKBAb775pv75z3+qe/fukqRly5YpMjJSa9euVc+ePavtuwAAgNrBqZmfrKwss+uo9DPy8vIUFxdnb7NarercubM2b94sSUpPT1dpaalDn4iICMXGxtr7VKa4uFiFhYUOGwAA8AxOhZ9mzZqpa9euWrZsmc6dO2d2TZKkvLw8SVJYWJhDe1hYmH1fXl6efH191bBhw8v2qUxSUpKCgoLsW2RkpMnVAwAAd+VU+Nm1a5fuvPNOTZo0STabTY8//ri2bt1qdm2SJIvF4vDaMIwKbZe6Wp/p06eroKDAvuXk5JhSKwAAcH9OhZ/Y2FglJyfryJEjSklJUV5enjp27KiWLVsqOTlZx44du+7CbDabJFWYwcnPz7fPBtlsNpWUlOjEiROX7VMZq9WqwMBAhw0AAHiG67ray9vbWwMGDNB7772nF154QQcOHNDkyZPVpEkTDR8+XLm5uU4fOzo6WjabTWlpafa2kpISbdq0SR06dJAktWnTRj4+Pg59cnNztXfvXnsfAACA37qu8LN9+3aNGTNG4eHhSk5O1uTJk3XgwAGtX79eR44cUb9+/a74/qKiImVkZCgjI0PShUXOGRkZys7OlsVi0YQJEzR37lytWrVKe/fuVWJiovz9/TVkyBBJUlBQkEaNGqVJkyZp3bp12rlzp4YNG6ZWrVrZr/4CAAD4LacudU9OTlZKSor279+v3r17a+nSperdu7duuOFCloqOjtbrr7+uFi1aXPE427dvV9euXe2vJ06cKElKSEhQamqqpkyZorNnz2rMmDE6ceKE2rVrpzVr1iggIMD+npdfflne3t566KGHdPbsWXXr1k2pqany8vJy5qsBAIA6zmIYhlHVN8XExGjkyJEaMWKEfW3OpUpKSvTuu+8qISHhuot0tcLCQgUFBamgoMD09T83Tfu0QtvBeX1M/QwAADyRs7/fTs38ZGZmXrWPr69vrQg+AADAszi15iclJUXvv/9+hfb3339fS5Ysue6iAAAAXMWp8DNv3jyFhIRUaA8NDdXcuXOvuygAAABXcSr8HDp0SNHR0RXao6KilJ2dfd1FAQAAuIpT4Sc0NFS7d++u0L5r1y4FBwdfd1EAAACu4lT4GTx4sP7yl79ow4YNKisrU1lZmdavX6/x48dr8ODBZtcIAABgGqeu9po9e7YOHTqkbt26ydv7wiHKy8s1fPhw1vwAAAC35lT48fX11YoVK/T8889r165d8vPzU6tWrRQVFWV2fQAAAKZyKvxc1Lx5czVv3tysWgAAAFzOqfBTVlam1NRUrVu3Tvn5+SovL3fYv379elOKAwAAMJtT4Wf8+PFKTU1Vnz59FBsbK4vFYnZdAAAALuFU+Fm+fLnee+899e7d2+x6AAAAXMqpS919fX3VrFkzs2sBAABwOafCz6RJk/TKK6/IiQfCAwAA1CinTnt988032rBhgz7//HO1bNlSPj4+DvtXrlxpSnEAAABmcyr8NGjQQAMGDDC7FgAAAJdzKvykpKSYXQcAAEC1cGrNjySdP39ea9eu1euvv65Tp05Jko4ePaqioiLTigMAADCbU+Hn0KFDatWqlfr166exY8fq2LFjkqQXX3xRkydPNrXAm266SRaLpcI2duxYSVJiYmKFfe3btze1BgAAUHc4fZPDtm3bateuXQoODra3DxgwQI8++qhpxUnStm3bVFZWZn+9d+9e9ejRQw8++KC9rVevXg6n4nx9fU2tAQAA1B1OX+317bffVggZUVFROnLkiCmFXdS4cWOH1/PmzdMtt9yizp0729usVqtsNpupnwsAAOomp057lZeXO8zGXHT48GEFBARcd1GXU1JSomXLlmnkyJEOj9TYuHGjQkND1bx5cz322GPKz8+/4nGKi4tVWFjosAEAAM/gVPjp0aOHFixYYH9tsVhUVFSkmTNnuvSRFx999JFOnjypxMREe1t8fLzefvttrV+/XvPnz9e2bdt07733qri4+LLHSUpKUlBQkH2LjIx0Wc0AAMC9WAwnbtN89OhRde3aVV5eXsrMzFTbtm2VmZmpkJAQffXVVwoNDXVFrerZs6d8fX318ccfX7ZPbm6uoqKitHz5cg0cOLDSPsXFxQ7hqLCwUJGRkSooKFBgYKCpNd807dMKbQfn9TH1MwAA8ESFhYUKCgqq8u+3U2t+IiIilJGRoXfffVc7duxQeXm5Ro0apaFDh8rPz8+ZQ17VoUOHtHbt2qvePTo8PFxRUVHKzMy8bB+r1Sqr1Wp2iQAAoBZwKvxIkp+fn0aOHKmRI0eaWc9lpaSkKDQ0VH36XHnW5Pjx48rJyVF4eHi11AUAAGoXp8LP0qVLr7h/+PDhThVzOeXl5UpJSVFCQoK8vf+v5KKiIs2aNUsPPPCAwsPDdfDgQT399NMKCQnh8RsAAKBSTt/n57dKS0t15swZ+fr6yt/f3/Tws3btWmVnZ1eYZfLy8tKePXu0dOlSnTx5UuHh4eratatWrFjh0qvOAABA7eVU+Dlx4kSFtszMTP35z3/WU089dd1FXSouLk6Vrcv28/PTl19+afrnAQCAusvpZ3tdKiYmRvPmzaswKwQAAOBOTAs/0oXTUEePHjXzkAAAAKZy6rTX6tWrHV4bhqHc3FwtXLhQf/rTn0wpDAAAwBWcCj/9+/d3eG2xWNS4cWPde++9mj9/vhl11WmX3viQmx4CAFB9nAo/5eXlZtcBAABQLUxd8wMAAODunJr5mThx4jX3TU5OduYjAAAAXMKp8LNz507t2LFD58+f16233ipJ+vHHH+Xl5aXWrVvb+1ksFnOqBAAAMIlT4adv374KCAjQkiVL1LBhQ0kXbnw4YsQIderUSZMmTTK1SAAAALM4teZn/vz5SkpKsgcfSWrYsKFmz57N1V4AAMCtORV+CgsL9fPPP1doz8/P16lTp667KAAAAFdx6rTXgAEDNGLECM2fP1/t27eXJH333Xd66qmnNHDgQFML9ASX3vdH4t4/AAC4ilPh57XXXtPkyZM1bNgwlZaWXjiQt7dGjRqll156ydQCAQAAzORU+PH399eiRYv00ksv6cCBAzIMQ82aNVO9evXMrg8AAMBU13WTw9zcXOXm5qp58+aqV6+eDMMwqy4AAACXcCr8HD9+XN26dVPz5s3Vu3dv5ebmSpIeffRRLnMHAABuzanw8+STT8rHx0fZ2dny9/e3tw8aNEhffPGFacUBAACYzak1P2vWrNGXX36pJk2aOLTHxMTo0KFDphQGR1wRBgCAOZya+Tl9+rTDjM9Fv/zyi6xW63UXddGsWbNksVgcNpvNZt9vGIZmzZqliIgI+fn5qUuXLtq3b59pnw8AAOoep8LPPffco6VLl9pfWywWlZeX66WXXlLXrl1NK06SWrZsaV9YnZubqz179tj3vfjii0pOTtbChQu1bds22Ww29ejRgxstAgCAy3LqtNdLL72kLl26aPv27SopKdGUKVO0b98+/frrr/r222/NLdDb22G25yLDMLRgwQLNmDHDfmPFJUuWKCwsTO+8844ef/xxU+sAAAB1g1MzP7fddpt2796tu+++Wz169NDp06c1cOBA7dy5U7fccoupBWZmZioiIkLR0dEaPHiwfvrpJ0lSVlaW8vLyFBcXZ+9rtVrVuXNnbd68+YrHLC4uVmFhocMGAAA8Q5VnfkpLSxUXF6fXX39dzz77rCtqsmvXrp2WLl2q5s2b6+eff9bs2bPVoUMH7du3T3l5eZKksLAwh/eEhYVdddF1UlKSy2sHAADuqcozPz4+Ptq7d68sFosr6nEQHx+vBx54QK1atVL37t316acXrnhasmSJvc+ldRiGcdXapk+froKCAvuWk5NjfvEAAMAtOXXaa/jw4XrzzTfNruWq6tWrp1atWikzM9O+DujiDNBF+fn5FWaDLmW1WhUYGOiw1RU3TfvUYQMAAI6cWvBcUlKif/zjH0pLS1Pbtm0rPNMrOTnZlOIuVVxcrH//+9/q1KmToqOjZbPZlJaWpjvvvNNe16ZNm/TCCy+45PMBAEDtV6Xw89NPP+mmm27S3r171bp1a0nSjz/+6NDHzNNhkydPVt++fdW0aVPl5+dr9uzZKiwsVEJCgiwWiyZMmKC5c+cqJiZGMTExmjt3rvz9/TVkyBDTanBnzOwAAFB1VQo/MTExys3N1YYNGyRdeJzFq6++etXTTM46fPiwHn74Yf3yyy9q3Lix2rdvr++++05RUVGSpClTpujs2bMaM2aMTpw4oXbt2mnNmjUKCAhwST0AAKD2q1L4ufSp7Z9//rlOnz5takG/tXz58ivut1gsmjVrlmbNmuWyGgAAQN3i1ILniy4NQwAAAO6uSuHn4vO1Lm0DAACoLap82isxMdH+8NJz585p9OjRFa72WrlypXkVAgAAmKhK4SchIcHh9bBhw0wtBgAAwNWqFH5SUlJcVQdcpLLL4Q/O61MDlQAA4B6cuskhXK+m7+Fz6ecTmAAAdcV1Xe0FAABQ2xB+AACAR+G0lwfilBYAwJMRflDj64sAAKhOnPYCAAAehfADAAA8CuEHAAB4FMIPAADwKIQfAADgUQg/AADAo3CpO0zDc8QAALUB4QdO4/5AAIDayK1PeyUlJemuu+5SQECAQkND1b9/f+3fv9+hT2JioiwWi8PWvn37GqoYl7pp2qcOGwAANc2tw8+mTZs0duxYfffdd0pLS9P58+cVFxen06dPO/Tr1auXcnNz7dtnn31WQxUDAAB359anvb744guH1ykpKQoNDVV6erruuecee7vVapXNZqvu8gAAQC3k1uHnUgUFBZKkRo0aObRv3LhRoaGhatCggTp37qw5c+YoNDT0sscpLi5WcXGx/XVhYaFrCq5DOGUFAKgr3Pq0128ZhqGJEyeqY8eOio2NtbfHx8fr7bff1vr16zV//nxt27ZN9957r0O4uVRSUpKCgoLsW2RkZHV8BQAA4AYshmEYNV3EtRg7dqw+/fRTffPNN2rSpMll++Xm5ioqKkrLly/XwIEDK+1T2cxPZGSkCgoKFBgYaGrdzJg44tJ3AIBZCgsLFRQUVOXf71px2mvcuHFavXq1vvrqqysGH0kKDw9XVFSUMjMzL9vHarXKarWaXSauAfcCAgDUNLcOP4ZhaNy4cVq1apU2btyo6Ojoq77n+PHjysnJUXh4eDVUCAAAahu3Dj9jx47VO++8o//93/9VQECA8vLyJElBQUHy8/NTUVGRZs2apQceeEDh4eE6ePCgnn76aYWEhGjAgAE1XD3MdOmMEbNFAABnuXX4Wbx4sSSpS5cuDu0pKSlKTEyUl5eX9uzZo6VLl+rkyZMKDw9X165dtWLFCgUEBNRAxQAAwN25dfi52lpsPz8/ffnll9VUDQAAqAtqzaXuAAAAZiD8AAAAj+LWp73gmbg3EgDAlZj5AQAAHoWZH9Q4s2Z6uBweAHAtCD+olZwNTAQkAACnvQAAgEch/AAAAI9C+AEAAB6FNT/waDxlHgA8D+EHMMG1LMAmVAGAe+C0FwAA8CjM/ACX4HJ4AKjbmPkBAAAehfADAAA8Cqe9UGfxgFQAQGUIP4ATnAlWXFYPAO6hzoSfRYsW6aWXXlJubq5atmypBQsWqFOnTjVdFuoAV84gcYk8AFS/OrHmZ8WKFZowYYJmzJihnTt3qlOnToqPj1d2dnZNlwYAANyMxTAMo6aLuF7t2rVT69attXjxYnvb73//e/Xv319JSUlXfX9hYaGCgoJUUFCgwMBAU2tj3QnMVtlMkDOX53MaDkBt5+zvd62f+SkpKVF6erri4uIc2uPi4rR58+YaqgoAALirWr/m55dfflFZWZnCwsIc2sPCwpSXl1fpe4qLi1VcXGx/XVBQIOlCgjRbefEZ048Jz9b0yfed6rP32Z4Oryv7u3kt/w7Ezvzyqn2uxaX1VHZcZ/pU5lpqvpbjVNdxAVybi//NqupJrFoffi6yWCwOrw3DqNB2UVJSkp599tkK7ZGRkS6pDXAHQQvM6WMWs+oxq2ZXfffqHFPAU506dUpBQUHX3L/Wh5+QkBB5eXlVmOXJz8+vMBt00fTp0zVx4kT76/Lycv36668KDg6+bGByRmFhoSIjI5WTk2P6WqK6ijGrOsasahivqmPMqo4xqxpnx8swDJ06dUoRERFV+rxaH358fX3Vpk0bpaWlacCAAfb2tLQ09evXr9L3WK1WWa1Wh7YGDRq4rMbAwED+8lcRY1Z1jFnVMF5Vx5hVHWNWNc6MV1VmfC6q9eFHkiZOnKhHHnlEbdu21R//+Ef9/e9/V3Z2tkaPHl3TpQEAADdTJ8LPoEGDdPz4cT333HPKzc1VbGysPvvsM0VFRdV0aQAAwM3UifAjSWPGjNGYMWNqugwHVqtVM2fOrHCKDZfHmFUdY1Y1jFfVMWZVx5hVTXWPV524ySEAAMC1qvU3OQQAAKgKwg8AAPAohB8AAOBRCD8AAMCjEH4AAIBHIfwAAACPQvgBAAAehfADAAA8CuEHAAB4FMIPAADwKIQfAADgUQg/AADAoxB+AACARyH8AAAAj0L4AQAAHoXwAwAAPArhBwAAeBTCDwAA8CiEHwAA4FEIPwAAwKMQfgAAgEch/AAAAI9C+AEAAB6F8AMAADwK4QcAAHgU75ouwB2Ul5fr6NGjCggIkMViqelyAADANTAMQ6dOnVJERIRuuOHa53MIP5KOHj2qyMjImi4DAAA4IScnR02aNLnm/oQfSQEBAZIuDF5gYGANVwMAAK5FYWGhIiMj7b/j14rwI9lPdQUGBhJ+AACoZaq6ZIUFzwAAwKMQfgAAgEch/AAAAI/Cmh8AAFykrKxMpaWlNV1GreXj4yMvLy/Tj0v4AQDAZIZhKC8vTydPnqzpUmq9Bg0ayGazmXofPsKPq80KqqStoPrrAABUm4vBJzQ0VP7+/txA1wmGYejMmTPKz8+XJIWHh5t2bMIPAAAmKisrswef4ODgmi6nVvPz85Mk5efnKzQ01LRTYCx4BgDARBfX+Pj7+9dwJXXDxXE0c+0U4QcAABfgVJc5XDGOhB8AAOBRCD8AAOC6bdy4URaLpVZc4caCZwAAqktlVwC77LOqdmVx3759dfbsWa1du7bCvi1btqhDhw5KT09X69atzaqwxjDzAwAANGrUKK1fv16HDh2qsO+tt97SHXfcUSeCj0T4AQAAku677z6FhoYqNTXVof3MmTNasWKFRo0adU3HSU9PV9u2beXv768OHTpo//799n0HDhxQv379FBYWpvr16+uuu+6qdKbJ1Qg/AABA3t7eGj58uFJTU2UYhr39/fffV0lJiYYOHXpNx5kxY4bmz5+v7du3y9vbWyNHjrTvKyoqUu/evbV27Vrt3LlTPXv2VN++fZWdnW3697kSwg8AAJAkjRw5UgcPHtTGjRvtbW+99ZYGDhyohg0bXtMx5syZo86dO+u2227TtGnTtHnzZp07d06SdPvtt+vxxx9Xq1atFBMTo9mzZ+vmm2/W6tWrXfF1LovwAwAAJEktWrRQhw4d9NZbb0m6cJrq66+/dpi9uZo//OEP9n+++EiKi4+oOH36tKZMmaLbbrtNDRo0UP369fXDDz8w8wMAAGrOqFGj9OGHH6qwsFApKSmKiopSt27drvn9Pj4+9n++eIPC8vJySdJTTz2lDz/8UHPmzNHXX3+tjIwMtWrVSiUlJeZ+iasg/AAAALuHHnpIXl5eeuedd7RkyRKNGDHCtLssf/3110pMTNSAAQPUqlUr2Ww2HTx40JRjVwXhBwAA2NWvX1+DBg3S008/raNHjyoxMdG0Yzdr1kwrV65URkaGdu3apSFDhthnhaoT4QcAADgYNWqUTpw4oe7du6tp06amHffll19Ww4YN1aFDB/Xt21c9e/askXsHWYzfXs/moQoLCxUUFKSCggIFBgaae/DK7uZZxbtuAgBqj3PnzikrK0vR0dG68cYba7qcWu9K4+ns7zczPwAAwKMQfgAAwFWNHj1a9evXr3QbPXp0TZdXJTzYFAAAXNVzzz2nyZMnV7rP9CUjLkb4AQAAVxUaGqrQ0NCaLsMUnPYCAAAepUbDz1dffaW+ffsqIiJCFotFH330kcN+wzA0a9YsRUREyM/PT126dNG+ffsc+hQXF2vcuHEKCQlRvXr1dP/99+vw4cPV+C0AAKioJu5fUxe5Yhxr9LTX6dOndfvtt2vEiBF64IEHKux/8cUXlZycrNTUVDVv3lyzZ89Wjx49tH//fgUEBEiSJkyYoI8//ljLly9XcHCwJk2apPvuu0/p6eny8vKq7q8EAPBwvr6+uuGGG3T06FE1btxYvr6+pt0h2ZMYhqGSkhIdO3ZMN9xwg3x9fU07ttvc58disWjVqlXq37+/pAtfOiIiQhMmTNDUqVMlXZjlCQsL0wsvvKDHH39cBQUFaty4sf75z39q0KBBkqSjR48qMjJSn332mXr27HlNn819fgAAZiopKVFubq7OnDlT06XUev7+/goPD680/Dj7++22C56zsrKUl5enuLg4e5vValXnzp21efNmPf7440pPT1dpaalDn4iICMXGxmrz5s2XDT/FxcUqLi62vy4sLHTdFwEAeBxfX181bdpU58+fV1lZWU2XU2t5eXnJ29vb9Jkztw0/eXl5kqSwsDCH9rCwMB06dMjex9fXVw0bNqzQ5+L7K5OUlKRnn33W5IoBAPg/FotFPj4+Dk85h3tw+6u9Lk17hmFcNQFerc/06dNVUFBg33JyckypFQAAuD+3DT82m02SKszg5Ofn22eDbDabSkpKdOLEicv2qYzValVgYKDDBgAAPIPbhp/o6GjZbDalpaXZ20pKSrRp0yZ16NBBktSmTRv5+Pg49MnNzdXevXvtfQAAAH6rRtf8FBUV6T//+Y/9dVZWljIyMtSoUSM1bdpUEyZM0Ny5cxUTE6OYmBjNnTtX/v7+GjJkiCQpKChIo0aN0qRJkxQcHKxGjRpp8uTJatWqlbp3715TXwsAALixGg0/27dvV9euXe2vJ06cKElKSEhQamqqpkyZorNnz2rMmDE6ceKE2rVrpzVr1tjv8SNJL7/8sry9vfXQQw/p7Nmz6tatm1JTU7nHDwAAqJTb3OenJnGfHwAAah9nf7/dds0PAACAKxB+AACARyH8AAAAj0L4AQAAHoXwAwAAPArhBwAAeBTCDwAA8CiEHwAA4FEIPwAAwKMQfgAAgEdxKvxkZWWZXQcAAEC1cCr8NGvWTF27dtWyZct07tw5s2sCAABwGafCz65du3TnnXdq0qRJstlsevzxx7V161azawMAADCdU+EnNjZWycnJOnLkiFJSUpSXl6eOHTuqZcuWSk5O1rFjx8yuEwAAwBTXteDZ29tbAwYM0HvvvacXXnhBBw4c0OTJk9WkSRMNHz5cubm5ZtUJAABgiusKP9u3b9eYMWMUHh6u5ORkTZ48WQcOHND69et15MgR9evXz6w6AQAATOHtzJuSk5OVkpKi/fv3q3fv3lq6dKl69+6tG264kKWio6P1+uuvq0WLFqYWCwAAcL2cCj+LFy/WyJEjNWLECNlstkr7NG3aVG+++eZ1FQcAAGA2p8JPZmbmVfv4+voqISHBmcMDAAC4jFNrflJSUvT+++9XaH///fe1ZMmS6y4KAADAVZwKP/PmzVNISEiF9tDQUM2dO/e6iwIAAHAVp8LPoUOHFB0dXaE9KipK2dnZ110UAACAqzgVfkJDQ7V79+4K7bt27VJwcPB1F/VbN910kywWS4Vt7NixkqTExMQK+9q3b29qDQAAoO5wasHz4MGD9Ze//EUBAQG65557JEmbNm3S+PHjNXjwYFML3LZtm8rKyuyv9+7dqx49eujBBx+0t/Xq1UspKSn2176+vqbWAAAA6g6nws/s2bN16NAhdevWTd7eFw5RXl6u4cOHm77mp3Hjxg6v582bp1tuuUWdO3e2t1mt1stecg8AAPBbToUfX19frVixQs8//7x27dolPz8/tWrVSlFRUWbX56CkpETLli3TxIkTZbFY7O0bN25UaGioGjRooM6dO2vOnDkKDQ297HGKi4tVXFxsf11YWOjSugEAgPtwKvxc1Lx5czVv3tysWq7qo48+0smTJ5WYmGhvi4+P14MPPqioqChlZWXpmWee0b333qv09HRZrdZKj5OUlKRnn322mqoGAADuxGIYhlHVN5WVlSk1NVXr1q1Tfn6+ysvLHfavX7/etAJ/q2fPnvL19dXHH3982T65ubmKiorS8uXLNXDgwEr7VDbzExkZqYKCAgUGBppb9KygStoKzP0MAAA8UGFhoYKCgqr8++3UzM/48eOVmpqqPn36KDY21uEUlKscOnRIa9eu1cqVK6/YLzw8XFFRUVe8C7XVar3srBAAAKjbnAo/y5cv13vvvafevXubXc9lpaSkKDQ0VH369Lliv+PHjysnJ0fh4eHVVBkAAKhNnLrPj6+vr5o1a2Z2LZdVXl6ulJQUJSQk2K8uk6SioiJNnjxZW7Zs0cGDB7Vx40b17dtXISEhGjBgQLXVBwAAag+nws+kSZP0yiuvyInlQk5Zu3atsrOzNXLkSId2Ly8v7dmzR/369VPz5s2VkJCg5s2ba8uWLQoICKiW2gAAQO3i1Gmvb775Rhs2bNDnn3+uli1bysfHx2H/1dblVFVcXFylQcvPz09ffvmlqZ8FAADqNqfCT4MGDTitBAAAaiWnws9vHyUBAABQmzi15keSzp8/r7Vr1+r111/XqVOnJElHjx5VUVGRacUBAACYzamZn0OHDqlXr17Kzs5WcXGxevTooYCAAL344os6d+6cXnvtNbPrBAAAMIVTMz/jx49X27ZtdeLECfn5+dnbBwwYoHXr1plWHAAAgNmcvtrr22+/la+vr0N7VFSUjhw5YkphAAAAruDUzE95ebnKysoqtB8+fJj76wAAALfmVPjp0aOHFixYYH9tsVhUVFSkmTNnVusjLwAAAKrKqdNeL7/8srp27arbbrtN586d05AhQ5SZmamQkBC9++67ZtcIAABgGqfCT0REhDIyMvTuu+9qx44dKi8v16hRozR06FCHBdAAAADuxqnwI114tMTIkSMrPG8LAADAnTkVfpYuXXrF/cOHD3eqGAAAAFdzKvyMHz/e4XVpaanOnDkjX19f+fv7E34AAIDbcupqrxMnTjhsRUVF2r9/vzp27MiCZwAA4NacfrbXpWJiYjRv3rwKs0IAAADuxLTwI0leXl46evSomYcEAAAwlVNrflavXu3w2jAM5ebmauHChfrTn/5kSmEAAACu4FT46d+/v8Nri8Wixo0b695779X8+fPNqAsAAMAlnAo/5eXlZtcBAABQLUxd8wMAAODunJr5mThx4jX3TU5OduYjAAAAXMKp8LNz507t2LFD58+f16233ipJ+vHHH+Xl5aXWrVvb+1ksFnOqBAAAMIlT4adv374KCAjQkiVL1LBhQ0kXbnw4YsQIderUSZMmTTK1SAAAALM4teZn/vz5SkpKsgcfSWrYsKFmz55t6tVes2bNksVicdhsNpt9v2EYmjVrliIiIuTn56cuXbpo3759pn0+AACoe5wKP4WFhfr5558rtOfn5+vUqVPXXdRvtWzZUrm5ufZtz5499n0vvviikpOTtXDhQm3btk02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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "df_val[target_list].plot.hist(bins=100, subplots=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "08199065", + "metadata": {}, + "outputs": [], + "source": [ + "X_train = df_train[variable_list + target_list ]\n", + "X_val = df_val[variable_list + target_list]\n", + "X_test = df_test[variable_list + target_list ]" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "02ed1cb4", + "metadata": {}, + "outputs": [], + "source": [ + "def rmse_loss(x, y):\n", + " return ((x-y)**2).mean()**.5" + ] + }, + { + "cell_type": "markdown", + "id": "e5eb81bf", + "metadata": {}, + "source": [ + "## linear model with sklearn" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "485019d1", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.impute import SimpleImputer" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "e00a025a", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RMSE:\n", + "\tBMag_ha: 51.999\n", + "\tV_ha: 96.744\n", + "R2 score:\n", + "\tBMag_ha: 0.742\n", + "\tV_ha: 0.747\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_10413/256522388.py:2: FutureWarning: The frame.append method is deprecated and will be removed from pandas in a future version. Use pandas.concat instead.\n", + " X_trainval = X_train.append(X_val)\n" + ] + }, + { + "data": { + "text/plain": [ + "[None, None]" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# pure linear model\n", + "X_trainval = X_train.append(X_val)\n", + "\n", + "imputer = SimpleImputer().fit(X_trainval[variable_list])\n", + "X_train_ = imputer.transform(X_trainval[variable_list])\n", + "model = LinearRegression().fit(\n", + " X_train_, \n", + " X_trainval[target_list]\n", + ")\n", + "y_pred = model.predict(imputer.transform(X_test[variable_list]))\n", + "y_pred = np.clip(y_pred, a_min=0, a_max=None)\n", + "rmse = []\n", + "r2 = []\n", + "for i, name in enumerate(target_list):\n", + " rmse.append(rmse_loss(X_test[target_list[i]], y_pred[:, i]))\n", + " r2.append(r2_score(X_test[target_list[i]], y_pred[:, i]))\n", + " \n", + "print(f\"RMSE:\")\n", + "[print(f\"\\t{target}: {score:.3f}\") for target, score in zip(target_list, rmse)]\n", + " \n", + "print(f\"R2 score:\")\n", + "[print(f\"\\t{target}: {score:.3f}\") for target, score in zip(target_list, r2)]" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "73a90ee1", + "metadata": {}, + "outputs": [], + "source": [ + "y_pred_train = model.predict(imputer.transform(X_train[variable_list]))\n", + "# np.savetxt(\"linreg_train.csv\", y_pred_train)\n", + "\n", + "y_pred_val = model.predict(imputer.transform(X_val[variable_list]))\n", + "# np.savetxt(\"linreg_val.csv\", y_pred_val)\n", + "\n", + "y_pred_test = model.predict(imputer.transform(X_test[variable_list]))\n", + "# np.savetxt(\"linreg_test.csv\", y_pred_test)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "cb2e5a83", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RMSE:\n", + "\tBMag_ha: 54.368\n", + "\tV_ha: 95.053\n", + "R2 score:\n", + "\tBMag_ha: 0.736\n", + "\tV_ha: 0.764\n" + ] + }, + { + "data": { + "text/plain": [ + "[None, None]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "y_pred_val = np.clip(y_pred_val, a_min=0, a_max=None)\n", + "rmse = []\n", + "r2 = []\n", + "for i, name in enumerate(target_list):\n", + " rmse.append(rmse_loss(X_val[target_list[i]], y_pred_val[:, i]))\n", + " r2.append(r2_score(X_val[target_list[i]], y_pred_val[:, i]))\n", + "\n", + "print(f\"RMSE:\")\n", + "[print(f\"\\t{target}: {score:.3f}\") for target, score in zip(target_list, rmse)]\n", + " \n", + "print(f\"R2 score:\")\n", + "[print(f\"\\t{target}: {score:.3f}\") for target, score in zip(target_list, r2)]" + ] + }, + { + "cell_type": "markdown", + "id": "c66446ac", + "metadata": {}, + "source": [ + "## RF with sklearn" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "6456ddfd", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.ensemble import RandomForestRegressor" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "56441402", + "metadata": {}, + "outputs": [], + "source": [ + "imputer = SimpleImputer(strategy=\"constant\", fill_value=-100).fit(X_trainval[variable_list])" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "65256bf6", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.model_selection import ParameterGrid\n", + "param_grid = {\n", + " # Number of features to consider at every split\n", + " 'max_features': np.arange(0.1, 1.1, 0.1),\n", + " # depth of tree (None means full depth)\n", + " 'max_depth': list(np.arange(5, 21)) + [None],\n", + " # Minimum number of samples required at each leaf node\n", + " 'min_samples_leaf': [1] + list(np.arange(2, 17, 2)),\n", + " # Method of selecting samples for training each tree\n", + " \"max_samples\": np.arange(0.1, 1.1, 0.1),\n", + " 'bootstrap': [True],\n", + "}\n", + "pgrid = ParameterGrid(param_grid)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3cbedf8", + "metadata": {}, + "outputs": [], + "source": [ + "from tqdm.auto import tqdm\n", + "best_score = -np.inf\n", + "best_p = {}\n", + "best_rf = None\n", + "pbar = tqdm(pgrid)\n", + "for p in pbar:\n", + " pbar.set_postfix_str(str(p))\n", + " pbar.refresh()\n", + " rf = RandomForestRegressor(1000, n_jobs=-1, oob_score=True, **p).fit(\n", + " imputer.transform(X_trainval[variable_list]), \n", + " X_trainval[target_list]\n", + " )\n", + " if rf.oob_score_ > best_score:\n", + " best_score = rf.oob_score_\n", + " best_p = p\n", + " best_rf = rf" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed215d65", + "metadata": {}, + "outputs": [], + "source": [ + "# print(f\"best params: {best_p}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "34f9d264", + "metadata": {}, + "outputs": [], + "source": [ + "best_p = {'bootstrap': True, 'max_depth': 11, 'max_features': 0.9, 'max_samples': 0.2, 'min_samples_leaf': 6}" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "7e25184b", + "metadata": {}, + "outputs": [], + "source": [ + "best_rf = RandomForestRegressor(5000, n_jobs=-1, oob_score=True, **best_p).fit(\n", + " imputer.transform(X_trainval[variable_list]), \n", + " X_trainval[target_list]\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "6c3092c8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RMSE:\n", + "\tBMag_ha: 50.176\n", + "\tV_ha: 87.487\n", + "R2 score:\n", + "\tBMag_ha: 0.775\n", + "\tV_ha: 0.800\n" + ] + }, + { + "data": { + "text/plain": [ + "[None, None]" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "y_pred = best_rf.predict(imputer.transform(X_val[variable_list]))\n", + "y_pred = np.clip(y_pred, a_min=0, a_max=None)\n", + "rmse = []\n", + "r2 = []\n", + "for i, name in enumerate(target_list):\n", + " rmse.append(rmse_loss(X_val[target_list[i]], y_pred[:, i]))\n", + " r2.append(r2_score(X_val[target_list[i]], y_pred[:, i]))\n", + "print(f\"RMSE:\")\n", + "[print(f\"\\t{target}: {score:.3f}\") for target, score in zip(target_list, rmse)]\n", + " \n", + "print(f\"R2 score:\")\n", + "[print(f\"\\t{target}: {score:.3f}\") for target, score in zip(target_list, r2)]" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "12454bdb", + "metadata": {}, + "outputs": [], + "source": [ + "# np.savetxt(\"rf_val.csv\", y_pred)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.8.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/torch-points3d/calibrate_bn.py b/torch-points3d/calibrate_bn.py new file mode 100644 index 0000000..61633ea --- /dev/null +++ b/torch-points3d/calibrate_bn.py @@ -0,0 +1,25 @@ +import hydra +from hydra.core.global_hydra import GlobalHydra +from omegaconf import OmegaConf + +from torch_points3d.trainer import Trainer + + +@hydra.main(config_path="conf", config_name="calibrate_bn") +def main(cfg): + if cfg.pretty_print: + print(OmegaConf.to_yaml(cfg)) + + epochs = cfg["epochs"] + + OmegaConf.set_struct(cfg, False) # This allows getattr and hasattr methods to function correctly + trainer = Trainer(cfg) + trainer.iterate_epochs(epochs) + + # # https://github.com/facebookresearch/hydra/issues/440 + GlobalHydra.get_state().clear() + return 0 + + +if __name__ == "__main__": + main() diff --git a/torch-points3d/compile_wrappers.sh b/torch-points3d/compile_wrappers.sh new file mode 100755 index 0000000..8d34270 --- /dev/null +++ b/torch-points3d/compile_wrappers.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Compile cpp subsampling +cd torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling +python3 setup.py build_ext --inplace +cd .. + +# Compile cpp neighbors +cd cpp_neighbors +python3 setup.py build_ext --inplace +cd ../../../.. \ No newline at end of file diff --git a/torch-points3d/conf/calibrate_bn.yaml b/torch-points3d/conf/calibrate_bn.yaml new file mode 100644 index 0000000..c972575 --- /dev/null +++ b/torch-points3d/conf/calibrate_bn.yaml @@ -0,0 +1,32 @@ +defaults: + - visualization: default + - task: ??? + - data: ??? + - debugging: default + +num_workers: 0 +batch_size: 2 +cuda: 0 +weight_name: "latest" # Used during resume, select with model to load from [miou, macc, acc..., latest] +enable_cudnn: True +checkpoint_dir: ??? # "{your_path}/outputs/2020-01-28/11-04-13" for example +model_name: ??? +precompute_multi_scale: False # Compute multiscale features on cpu for faster training / inference +epochs: 1 + +pretty_print: True + +wandb: + project: ??? + log: True + public: True + +tracker_options: # Extra options for the tracker + full_res: False + make_submission: True + +hydra: + run: + dir: ${checkpoint_dir}/calibrate + + diff --git a/torch-points3d/conf/config.yaml b/torch-points3d/conf/config.yaml new file mode 100644 index 0000000..11d04b9 --- /dev/null +++ b/torch-points3d/conf/config.yaml @@ -0,0 +1,25 @@ +defaults: # for loading the default.yaml config + - task: ??? + + - visualization: default + - lr_scheduler: exponential + - training: default + - debugging: default + - data: ??? +models: ??? + +job_name: benchmark # prefix name for saving the experiment file. +model_name: ??? # Name of the specific model to load +update_lr_scheduler_on: "on_epoch" # ["on_epoch", "on_num_batch", "on_num_sample"] +selection_stage: "" +pretty_print: False +eval_frequency: 1 + +tracker_options: # Extra options for the tracker + full_res: False + make_submission: False + track_boxes: False + +hydra: + run: + dir: ./outputs/${now:%Y-%m-%d}/${now:%H-%M-%S-%f}/ \ No newline at end of file diff --git a/torch-points3d/conf/data/instance/NFI/default.yaml b/torch-points3d/conf/data/instance/NFI/default.yaml new file mode 100644 index 0000000..b282607 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/default.yaml @@ -0,0 +1,44 @@ +# @package data +class: las_dataset.LasDataset +name: LASRegression +dataset_name: biomass +task: instance +dataroot: data +transform_type: ??? +areas: { + NFI: { + type: object, + pt_files: [2014/*/*.las, 2018/*/*.las, 2019/*/*.las], + label_files: nfi.gpkg, + check_pt_crs: False, + pt_identifier: las_file + }, +} +xy_radius: 15 +x_scale: 30 +y_scale: 30 +z_scale: 40 +x_center: 0.5 +y_center: 0.5 +first_subsampling: 0.0125 +split_col: "split" +log_train_metrics: False +save_local_stats: False +in_memory: True +min_pts_outer: 100 +min_pts_inner: 0 +skip_list: [ "y_mol", "y_mol_mask", "y_cls", "y_cls_mask", "y_reg", "y_reg_mask"] +features: [ ] +stats: [ ] +pre_transform: + - transform: DBSCANZOutlierRemoval + params: + eps: 1.5 # in m + min_samples: 10 + skip_list: ${data.skip_list} + - transform: StartZFromZero + - transform: ZFilter + params: + z_min: -1e-5 + z_max: 50 + skip_keys: ${data.skip_list} diff --git a/torch-points3d/conf/data/instance/NFI/noground/default.yaml b/torch-points3d/conf/data/instance/NFI/noground/default.yaml new file mode 100644 index 0000000..e70d135 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/noground/default.yaml @@ -0,0 +1,50 @@ +# @package data +class: las_dataset.LasDataset +name: LASRegression +dataset_name: biomass +task: instance +dataroot: data +transform_type: ??? +areas: { + NFI: { + type: object, + pt_files: [2014/*/*.las, 2018/*/*.las, 2019/*/*.las], + label_files: nfi.gpkg, + check_pt_crs: False, + pt_identifier: las_file + }, +} +xy_radius: 15 +x_scale: 30 +y_scale: 30 +z_scale: 40 +x_center: 0.5 +y_center: 0.5 +first_subsampling: 0.0125 +split_col: "split" +log_train_metrics: False +save_local_stats: False +in_memory: True +min_pts_outer: 100 +min_pts_inner: 0 +skip_list: [ "y_mol", "y_mol_mask", "y_cls", "y_cls_mask", "y_reg", "y_reg_mask"] +features: [ "classification" ] +stats: [ ] +pre_transform: + - transform: DBSCANZOutlierRemoval + params: + eps: 1.5 # in m + min_samples: 10 + skip_list: ${data.skip_list} + - transform: StartZFromZero + - transform: ZFilter + params: + z_min: -1e-5 + z_max: 50 + skip_keys: ${data.skip_list} + - transform: ClassificationFilter + params: + feature_index: 0 + keep: False + class_indices: [2] + remove_feat: True diff --git a/torch-points3d/conf/data/instance/NFI/noground/reg.yaml b/torch-points3d/conf/data/instance/NFI/noground/reg.yaml new file mode 100755 index 0000000..de71e20 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/noground/reg.yaml @@ -0,0 +1,31 @@ +# @package data +defaults: + - instance/default + - instance/NFI/noground/default + - instance/NFI/transforms/xy + - instance/NFI/transforms/xy-grid + - instance/NFI/transforms/xy-treeadd-eval + - instance/NFI/transforms/xy-eval + - instance/NFI/transforms/sparse + - instance/NFI/transforms/sparse-xy + - instance/NFI/transforms/sparse-ori + - instance/NFI/transforms/sparse-skeleton + - instance/NFI/transforms/sparse-treeadd + - instance/NFI/transforms/sparse-xy-treeadd + - instance/NFI/transforms/sparse-eval + - instance/NFI/transforms/sparse-xy-eval + - instance/NFI/transforms/sparse-treeadd-inner + - instance/NFI/transforms/sparse-treeadd-eval + - instance/NFI/transforms/sparse-xy-treeadd-eval + - instance/NFI/transforms/fixed + - instance/NFI/transforms/fixed-xy + - instance/NFI/transforms/fixed-xy-treeadd-eval + - instance/NFI/transforms/fixed-xy-eval + - instance/NFI/transforms/fixed-skeleton + +processed_folder: "processed_nfi_reg_noground" +targets: { + BMag_ha: { task: regression, weight: 0.5 }, + V_ha: { task: regression, weight: 0.5 }, +} # metrics: m m cm + diff --git a/torch-points3d/conf/data/instance/NFI/reg.yaml b/torch-points3d/conf/data/instance/NFI/reg.yaml new file mode 100755 index 0000000..7f54e94 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/reg.yaml @@ -0,0 +1,25 @@ +# @package data +defaults: + - instance/default + - instance/NFI/default + - instance/NFI/transforms/xy + - instance/NFI/transforms/xy-treeadd-eval + - instance/NFI/transforms/xy-eval + - instance/NFI/transforms/sparse + - instance/NFI/transforms/sparse-xy + - instance/NFI/transforms/sparse-ori + - instance/NFI/transforms/sparse-eval + - instance/NFI/transforms/sparse-xy-eval + - instance/NFI/transforms/sparse-treeadd-eval + - instance/NFI/transforms/sparse-xy-treeadd-eval + - instance/NFI/transforms/fixed + - instance/NFI/transforms/fixed-xy + - instance/NFI/transforms/fixed-xy-treeadd-eval + - instance/NFI/transforms/fixed-xy-eval + +processed_folder: "processed_nfi_reg" +targets: { + BMag_ha: { task: regression, weight: 0.5 }, + V_ha: { task: regression, weight: 0.5 }, +} # metrics: m m cm + diff --git a/torch-points3d/conf/data/instance/NFI/transforms/eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/eval.yaml new file mode 100755 index 0000000..39bfb59 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/eval.yaml @@ -0,0 +1,7 @@ +# @package data + +eval: + test_transform: {} + train_transform: {} + val_transform: {} + diff --git a/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy-eval.yaml new file mode 100644 index 0000000..4ebc1db --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy-eval.yaml @@ -0,0 +1,45 @@ +# @package data + +fixed_xy_eval: + num_points: 12000 + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: FixedPointsOwn + params: + num: ${data.fixed.num_points} + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + train_transform: ${data.fixed_xy_eval.test_transform} + val_transform: ${data.fixed_xy_eval.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy-treeadd-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy-treeadd-eval.yaml new file mode 100644 index 0000000..faf7ea0 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy-treeadd-eval.yaml @@ -0,0 +1,64 @@ +# @package data + +fixed_xy_treeadd_eval: + num_points: 12000 + test_transform: + - transform: RadiusObjectAdder + params: + areas: { + treeDB: { type: object }, + } + root_folder: ${data.dataroot} + dataset_name: treeDB + processed_folder: processed_treeDB_ALS + split: train + rot_x: 0.0 + rot_y: 0.0 + rot_z: 180 + min_radius: 15.1 + max_radius: 20 + n_max_objects: { scene: 10 } + adjust_point_density: False + in_memory: True + zero_center_z: True + p: 1.0 + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: FixedPointsOwn + params: + num: ${data.fixed.num_points} + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + train_transform: ${data.fixed_xy_treeadd_eval.test_transform} + val_transform: ${data.fixed_xy_treeadd_eval.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy.yaml b/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy.yaml new file mode 100644 index 0000000..64ef39c --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/fixed-xy.yaml @@ -0,0 +1,131 @@ +# @package data + +fixed_xy: + num_points: 12000 + train_transform: + - transform: RandomGroundRemoval + params: + min_v: 0.05 # at least 5 cm + max_v: 0.5 # at most 50 cm + p: 0.1 + min_points: 500 + skip_list: ${data.skip_list} + - transform: RandomDropout + params: + dropout_ratio: 0.2 + dropout_application_ratio: 0.5 + min_points: 500 + skip_list: ${data.skip_list} + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: RandomNoise + params: + sigma: 0.0025 + - transform: Random3AxisRotation + params: + apply_rotation: True + rot_x: 0 + rot_y: 0 + rot_z: 180 + - transform: RandomShiftPos + params: + p: 0.5 + max_x: 0.01 + max_y: 0.01 + max_z: 0.0 + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: AddRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + - transform: CopyJitterRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + sigma: 0.005 + clip: 0.015 + - transform: RandomPolygon2dExtend + params: + polygons: [ + [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ], + ] + rotate: 180 + skip_list: ${data.skip_list} + - transform: FixedPointsOwn + params: + num: ${data.fixed.num_points} + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes +# - transform: RandomScaling +# params: +# scales: [ 0.9, 1.1 ] + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: FixedPointsOwn + params: + num: ${data.fixed.num_points} + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + val_transform: ${data.fixed_xy.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/fixed.yaml b/torch-points3d/conf/data/instance/NFI/transforms/fixed.yaml new file mode 100755 index 0000000..3255446 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/fixed.yaml @@ -0,0 +1,123 @@ +# @package data + +fixed: + num_points: 12000 + train_transform: + - transform: RandomGroundRemoval + params: + min_v: 0.05 # at least 5 cm + max_v: 0.5 # at most 50 cm + p: 0.1 + min_points: 500 + skip_list: ${data.skip_list} + - transform: RandomDropout + params: + dropout_ratio: 0.2 + dropout_application_ratio: 0.5 + min_points: 500 + skip_list: ${data.skip_list} + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: RandomNoise + params: + sigma: 0.0025 + - transform: Random3AxisRotation + params: + apply_rotation: True + rot_x: 0 + rot_y: 0 + rot_z: 180 + - transform: RandomShiftPos + params: + p: 0.5 + max_x: 0.01 + max_y: 0.01 + max_z: 0.0 + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: AddRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + - transform: CopyJitterRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + sigma: 0.005 + clip: 0.015 + - transform: RandomPolygon2dExtend + params: + polygons: [ + [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ], + ] + rotate: 180 + skip_list: ${data.skip_list} + - transform: FixedPointsOwn + params: + num: ${data.fixed.num_points} + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes +# - transform: RandomScaling +# params: +# scales: [ 0.9, 1.1 ] + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True ] + feat_names: [ ones, pos_z ] + delete_feats: [ True, True ] + input_nc_feats: [ 1, 1 ] + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: FixedPointsOwn + params: + num: ${data.fixed.num_points} + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True ] + feat_names: [ ones, pos_z ] + delete_feats: [ True, True ] + input_nc_feats: [ 1, 1 ] + val_transform: ${data.fixed.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/sparse-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/sparse-eval.yaml new file mode 100755 index 0000000..a12c30d --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/sparse-eval.yaml @@ -0,0 +1,53 @@ +# @package data + +sparse_eval: + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 16000 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + train_transform: ${data.sparse_eval.test_transform} + val_transform: ${data.sparse_eval.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/sparse-ori.yaml b/torch-points3d/conf/data/instance/NFI/transforms/sparse-ori.yaml new file mode 100755 index 0000000..95f75b6 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/sparse-ori.yaml @@ -0,0 +1,99 @@ +# @package data + +sparse_ori: + train_transform: + - transform: RandomDropout + params: + dropout_ratio: 0.2 + dropout_application_ratio: 0.5 + min_points: 500 + skip_list: ${data.skip_list} + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: RandomNoise + params: + sigma: 0.025 + - transform: Random3AxisRotation + params: + apply_rotation: True + rot_x: 0 + rot_y: 0 + rot_z: 180 + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: RandomPolygon2dExtend + params: + polygons: [ + [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ], + ] + rotate: 180 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True ] + feat_names: [ ones, pos_z ] + delete_feats: [ True, True ] + input_nc_feats: [ 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + - transform: RandomCoordsFlip + params: + ignored_axis: "z" + p: 0.5 + - transform: ShiftVoxels + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True ] + feat_names: [ ones, pos_z ] + delete_feats: [ True, True ] + input_nc_feats: [ 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + val_transform: ${data.sparse_ori.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/sparse-treeadd-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/sparse-treeadd-eval.yaml new file mode 100755 index 0000000..4646fd1 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/sparse-treeadd-eval.yaml @@ -0,0 +1,69 @@ +# @package data + +sparse_treeadd_eval: + test_transform: + - transform: RadiusObjectAdder + params: + areas: { + treeDB: { type: object }, + } + root_folder: ${data.dataroot} + dataset_name: treeDB + processed_folder: processed_treeDB_ALS + split: train + #processed_folder: merge_processed_instance_extra + rot_x: 0.0 + rot_y: 0.0 + rot_z: 180 + min_radius: 15.1 + max_radius: 20 + n_max_objects: { scene: 10 } + adjust_point_density: False + in_memory: True + zero_center_z: True + p: 1.0 + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 16000 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True ] + feat_names: [ ones, pos_z ] + delete_feats: [ True, True ] + input_nc_feats: [ 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + train_transform: ${data.xy_treeadd_eval.test_transform} + val_transform: ${data.xy_treeadd_eval.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy-eval.yaml new file mode 100755 index 0000000..ce57440 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy-eval.yaml @@ -0,0 +1,63 @@ +# @package data + +sparse_xy_eval: + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: AddGround # only triggers for empty plots + params: + max_points: 1 + n_points: 1000 + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: AddGround # only triggers for empty plots + params: + max_points: 1 + n_points: 1000 + xy_min: 0.25 + xy_max: 0.75 + - transform: MaxPoints + params: + num: 16000 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + train_transform: ${data.sparse_xy_eval.test_transform} + val_transform: ${data.sparse_xy_eval.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy-treeadd-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy-treeadd-eval.yaml new file mode 100755 index 0000000..252756b --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy-treeadd-eval.yaml @@ -0,0 +1,72 @@ +# @package data + +sparse_xy_treeadd_eval: + test_transform: + - transform: RadiusObjectAdder + params: + areas: { + treeDB: { type: object }, + } + root_folder: ${data.dataroot} + dataset_name: treeDB + processed_folder: processed_treeDB_ALS + split: train + rot_x: 0.0 + rot_y: 0.0 + rot_z: 180 + min_radius: 15.1 + max_radius: 20 + n_max_objects: { scene: 10 } + adjust_point_density: False + in_memory: True + zero_center_z: True + p: 1.0 + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 16000 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + train_transform: ${data.sparse_xy_treeadd_eval.test_transform} + val_transform: ${data.sparse_xy_treeadd_eval.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy.yaml b/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy.yaml new file mode 100644 index 0000000..5467c50 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/sparse-xy.yaml @@ -0,0 +1,153 @@ +# @package data + +sparse_xy: + train_transform: + - transform: RandomGroundRemoval + params: + min_v: 0.05 # at least 5 cm + max_v: 0.5 # at most 50 cm + p: 0.1 + min_points: 500 + skip_list: ${data.skip_list} + - transform: RandomDropout + params: + dropout_ratio: 0.2 + dropout_application_ratio: 0.5 + min_points: 500 + skip_list: ${data.skip_list} + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: RandomNoise + params: + sigma: 0.0025 + - transform: Random3AxisRotation + params: + apply_rotation: True + rot_x: 0 + rot_y: 0 + rot_z: 180 + - transform: RandomShiftPos + params: + p: 0.5 + max_x: 0.01 + max_y: 0.01 + max_z: 0.0 + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: AddRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + - transform: CopyJitterRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + sigma: 0.005 + clip: 0.015 + - transform: RandomPolygon2dExtend + params: + polygons: [ + [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ], + ] + rotate: 180 + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 16000 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes +# - transform: RandomScaling +# params: +# scales: [ 0.9, 1.1 ] + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + - transform: RandomCoordsFlip + params: + ignored_axis: "z" + p: 0.5 + - transform: ShiftVoxels + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 16000 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + val_transform: ${data.sparse_xy.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/sparse.yaml b/torch-points3d/conf/data/instance/NFI/transforms/sparse.yaml new file mode 100755 index 0000000..40186ba --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/sparse.yaml @@ -0,0 +1,145 @@ +# @package data + +sparse: + train_transform: + - transform: RandomGroundRemoval + params: + min_v: 0.05 # at least 5 cm + max_v: 0.5 # at most 50 cm + p: 0.1 + min_points: 500 + skip_list: ${data.skip_list} + - transform: RandomDropout + params: + dropout_ratio: 0.2 + dropout_application_ratio: 0.5 + min_points: 500 + skip_list: ${data.skip_list} + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: RandomNoise + params: + sigma: 0.0025 + - transform: Random3AxisRotation + params: + apply_rotation: True + rot_x: 0 + rot_y: 0 + rot_z: 180 + - transform: RandomShiftPos + params: + p: 0.5 + max_x: 0.01 + max_y: 0.01 + max_z: 0.0 + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: AddRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + - transform: CopyJitterRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + sigma: 0.005 + clip: 0.015 + - transform: RandomPolygon2dExtend + params: + polygons: [ + [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ], + ] + rotate: 180 + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 16000 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes +# - transform: RandomScaling +# params: +# scales: [ 0.9, 1.1 ] + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True ] + feat_names: [ ones, pos_z ] + delete_feats: [ True, True ] + input_nc_feats: [ 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + - transform: RandomCoordsFlip + params: + ignored_axis: "z" + p: 0.5 + - transform: ShiftVoxels + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 16000 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True ] + feat_names: [ ones, pos_z ] + delete_feats: [ True, True ] + input_nc_feats: [ 1, 1 ] + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + val_transform: ${data.sparse.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/treeadd-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/treeadd-eval.yaml new file mode 100755 index 0000000..46b7b27 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/treeadd-eval.yaml @@ -0,0 +1,31 @@ +# @package data + +treeadd_eval: + test_transform: + - transform: RadiusObjectAdder + params: + areas: { + treeDB: { type: object }, + } + root_folder: ${data.dataroot} + dataset_name: treeDB + processed_folder: processed_treeDB_ALS + split: train + #processed_folder: merge_processed_instance_extra + rot_x: 0.0 + rot_y: 0.0 + rot_z: 180 + min_radius: 15.1 + max_radius: 20 + n_max_objects: { scene: 20 } + adjust_point_density: False + in_memory: True + zero_center_z: True + p: 1.0 + indicator_key: tree_add + - transform: CylinderExtend + params: + radius: 15.0 + skip_list: ${data.skip_list} + train_transform: ${data.treeadd_eval.test_transform} + val_transform: ${data.treeadd_eval.test_transform} \ No newline at end of file diff --git a/torch-points3d/conf/data/instance/NFI/transforms/xy-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/xy-eval.yaml new file mode 100644 index 0000000..feeef83 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/xy-eval.yaml @@ -0,0 +1,48 @@ +# @package data + +xy_eval: + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 6144 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + train_transform: ${data.xy_eval.test_transform} + val_transform: ${data.xy_eval.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/xy-grid.yaml b/torch-points3d/conf/data/instance/NFI/transforms/xy-grid.yaml new file mode 100644 index 0000000..c803599 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/xy-grid.yaml @@ -0,0 +1,148 @@ +# @package data + +xy_grid: + train_transform: + - transform: RandomGroundRemoval + params: + min_v: 0.05 # at least 5 cm + max_v: 0.5 # at most 50 cm + p: 0.1 + min_points: 500 + skip_list: ${data.skip_list} + - transform: RandomDropout + params: + dropout_ratio: 0.2 + dropout_application_ratio: 0.5 + min_points: 500 + skip_list: ${data.skip_list} + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: RandomNoise + params: + sigma: 0.0025 + - transform: Random3AxisRotation + params: + apply_rotation: True + rot_x: 0 + rot_y: 0 + rot_z: 180 + - transform: RandomShiftPos + params: + p: 0.5 + max_x: 0.01 + max_y: 0.01 + max_z: 0.0 + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: AddRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + - transform: CopyJitterRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + sigma: 0.005 + clip: 0.015 + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + - transform: RandomPolygon2dExtend + params: + polygons: [ + [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ], + ] + rotate: 180 + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 6144 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes +# - transform: RandomScaling +# params: +# scales: [ 0.9, 1.1 ] + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: GridSampling3D + params: + size: ${data.first_subsampling} + quantize_coords: True + mode: "last" + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 6144 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + val_transform: ${data.xy_grid.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/xy-treeadd-eval.yaml b/torch-points3d/conf/data/instance/NFI/transforms/xy-treeadd-eval.yaml new file mode 100644 index 0000000..40171ea --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/xy-treeadd-eval.yaml @@ -0,0 +1,67 @@ +# @package data + +xy_treeadd_eval: + test_transform: + - transform: RadiusObjectAdder + params: + areas: { + treeDB: { type: object }, + } + root_folder: ${data.dataroot} + dataset_name: treeDB + processed_folder: processed_treeDB_ALS + split: train + rot_x: 0.0 + rot_y: 0.0 + rot_z: 180 + min_radius: 15.1 + max_radius: 20 + n_max_objects: { scene: 10 } + adjust_point_density: False + in_memory: True + zero_center_z: True + p: 1.0 + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 6144 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + train_transform: ${data.xy_treeadd_eval.test_transform} + val_transform: ${data.xy_treeadd_eval.test_transform} diff --git a/torch-points3d/conf/data/instance/NFI/transforms/xy.yaml b/torch-points3d/conf/data/instance/NFI/transforms/xy.yaml new file mode 100644 index 0000000..7746940 --- /dev/null +++ b/torch-points3d/conf/data/instance/NFI/transforms/xy.yaml @@ -0,0 +1,138 @@ +# @package data + +xy: + train_transform: + - transform: RandomGroundRemoval + params: + min_v: 0.05 # at least 5 cm + max_v: 0.5 # at most 50 cm + p: 0.1 + min_points: 500 + skip_list: ${data.skip_list} + - transform: RandomDropout + params: + dropout_ratio: 0.2 + dropout_application_ratio: 0.5 + min_points: 500 + skip_list: ${data.skip_list} + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: RandomNoise + params: + sigma: 0.0025 + - transform: Random3AxisRotation + params: + apply_rotation: True + rot_x: 0 + rot_y: 0 + rot_z: 180 + - transform: RandomShiftPos + params: + p: 0.5 + max_x: 0.01 + max_y: 0.01 + max_z: 0.0 + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: AddRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + - transform: CopyJitterRandomPoints + params: + n_max_points: 12000 + add_ratio_min: 0.01 + add_ratio_max: 0.2 + p: 0.25 + sigma: 0.005 + clip: 0.015 + - transform: RandomPolygon2dExtend + params: + polygons: [ + [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ], + ] + rotate: 180 + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 6144 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes +# - transform: RandomScaling +# params: +# scales: [ 0.9, 1.1 ] + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + test_transform: + - transform: ScalePos + params: + scale_x: ${data.x_scale} + scale_y: ${data.y_scale} + scale_z: ${data.z_scale} + op: "div" + - transform: MoveCenterPosPerSample + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: StartZFromZero + - transform: Polygon2dExtend + params: + polygon: [ + [ 0., 0.5 ], [ 0.25, 0.9330127 ], [ 0.75, 0.9330127 ], + [ 1., 0.5 ], [ 0.75, 0.0669873 ], [ 0.25, 0.0669873 ] + ] + skip_list: ${data.skip_list} + - transform: MaxPoints + params: + num: 6144 + skip_list: ${data.skip_list} + - transform: MinPoints + params: + num: 500 + skip_list: ${data.skip_list} + - transform: XYZFeature + params: + add_x: False + add_y: False + add_z: True + - transform: AddOnes + - transform: AddXYDistanceToCenter + params: + center_x: ${data.x_center} + center_y: ${data.y_center} + - transform: AddFeatsByKeys + params: + list_add_to_x: [ True, True, True ] + feat_names: [ ones, pos_z, xy_distance ] + delete_feats: [ True, True, True ] + input_nc_feats: [ 1, 1, 1 ] + val_transform: ${data.xy.test_transform} diff --git a/torch-points3d/conf/data/instance/default.yaml b/torch-points3d/conf/data/instance/default.yaml new file mode 100644 index 0000000..47c2da5 --- /dev/null +++ b/torch-points3d/conf/data/instance/default.yaml @@ -0,0 +1,2 @@ +# @package data +task: instance \ No newline at end of file diff --git a/torch-points3d/conf/data/instance/treeDB/ALS.yaml b/torch-points3d/conf/data/instance/treeDB/ALS.yaml new file mode 100755 index 0000000..b698464 --- /dev/null +++ b/torch-points3d/conf/data/instance/treeDB/ALS.yaml @@ -0,0 +1,30 @@ +# @package data +defaults: + - instance/default + - instance/treeDB/default + - instance/trees-sparse + - instance/trees-fixed + + +areas: { + treeDB: { + type: object, + pt_files: [ ALS/*.laz ], + label_files: treeDB_epsg_25832.gpkg, + # 'Carpinus betulus', 'Picea abies', 'Larix decidua', + # 'Quercus petraea', 'Fagus sylvatica', 'Quercus rubra', + # 'Pinus sylvestris', 'Pseudotsuga menziesii', 'Quercus robur', + # 'Abies alba', 'Prunus avium', 'Fraxinus excelsior', + # 'Acer pseudoplatanus', 'Tilia spec.', 'Tsuga heterophylla', + # 'Juglans regia', 'Acer campestre', 'Betula pendula', + # 'Prunus serotina', 'Robinia pseudoacacia', 'Sorbus torminalis', + # 'Salix caprea' + alias_targets: [ height_m, mean_crown_diameter_m, DBH_cm, species ], + targets_must_be_present: [ False, False, False, False ], + pt_identifier: file_path, + test_ratio: 0.1, + val_ratio: 0.0 + }, +} +features: [ "return_number", "classification" ] +processed_folder: processed_treeDB_ALS \ No newline at end of file diff --git a/torch-points3d/conf/data/instance/treeDB/default.yaml b/torch-points3d/conf/data/instance/treeDB/default.yaml new file mode 100755 index 0000000..b9dc739 --- /dev/null +++ b/torch-points3d/conf/data/instance/treeDB/default.yaml @@ -0,0 +1,47 @@ +# @package data +class: las_dataset.LasDataset +name: LASRegression +dataset_name: treeDB +task: instance +dataroot: data +transform_type: ??? +xy_radius: 30 +x_scale: 6 +y_scale: 6 +z_scale: 6 +x_center: 0.5 +y_center: 0.5 +first_subsampling: 0.1 +split_col: "split" +log_train_metrics: False +save_local_stats: False +min_pts_outer: 10 +min_pts_inner: 0 +# if samples are stored, you need to reprocess file when you change anything below (until center_z) +targets: { + height_m: { task: mol, num_mixtures: 10, class_tol: 1, weight: 0.25 }, # tolerance of 1m + mean_crown_diameter_m: { task: mol, num_mixtures: 10, class_tol: 0.1, weight: 0.25 }, # tolerance of 10cm + DBH_cm: { task: mol, num_mixtures: 10, class_tol: 1, weight: 0.25 }, # tolerance of 1cm + tree_species: { task: classification, class_names: [ RGR, BOG, DGR, Rest ], + class_mapping: { + "Picea abies": 0, "Fagus sylvatica": 1, "Pseudotsuga menziesii": 2, + "Abies alba": 3, "Quercus petraea": 3, "QUERCUS": 3, "Quercus rubra": 3, + "Quercus robur": 3, "Larix decidua": 3, "Tsuga heterophylla": 3, "Sorbus torminalis": 3, + } }, +} # metrics: m m cm +"features": [ "return_number", "classification" ] +stats: [ ] +skip_list: [ "y_mol", "y_mol_mask", "y_cls", "y_cls_mask", "y_reg", "y_reg_mask" ] +pre_transform: + - transform: DBSCANZOutlierRemoval + params: + eps: 1.5 # in m + min_samples: 10 + skip_list: ${data.skip_list} + - transform: StartZFromZero + - transform: CenterXYbyZ + params: + center_x: 0 + center_y: 0 + z_thresh_min: 0.0 # 0 cm over lowest point + z_thresh_max: 2.5 # 2.5 m over lowest point \ No newline at end of file diff --git a/torch-points3d/conf/debugging/default.yaml b/torch-points3d/conf/debugging/default.yaml new file mode 100644 index 0000000..0bae882 --- /dev/null +++ b/torch-points3d/conf/debugging/default.yaml @@ -0,0 +1,5 @@ +# @package debugging +find_neighbour_dist: False +num_batches: 50 +early_break: False +profiling: False \ No newline at end of file diff --git a/torch-points3d/conf/debugging/early_break.yaml b/torch-points3d/conf/debugging/early_break.yaml new file mode 100644 index 0000000..acde870 --- /dev/null +++ b/torch-points3d/conf/debugging/early_break.yaml @@ -0,0 +1,2 @@ +# @package _group_ +early_break: True \ No newline at end of file diff --git a/torch-points3d/conf/debugging/find_neighbour_dist.yaml b/torch-points3d/conf/debugging/find_neighbour_dist.yaml new file mode 100644 index 0000000..b85bcb0 --- /dev/null +++ b/torch-points3d/conf/debugging/find_neighbour_dist.yaml @@ -0,0 +1,3 @@ +# @package _group_ +find_neighbour_dist: True +num_batches: 20 \ No newline at end of file diff --git a/torch-points3d/conf/eval.yaml b/torch-points3d/conf/eval.yaml new file mode 100644 index 0000000..161fb9b --- /dev/null +++ b/torch-points3d/conf/eval.yaml @@ -0,0 +1,33 @@ +defaults: + - visualization: eval + - task: ??? + - data: ??? + - debugging: default + +num_workers: 0 +batch_size: 2 +cuda: 0 +weight_name: "latest" # Used during resume, select with model to load from [miou, macc, acc..., latest] +enable_cudnn: True +checkpoint_dir: ??? # "{your_path}/outputs/2020-01-28/11-04-13" for example +model_name: ??? +precompute_multi_scale: False # Compute multiscale features on cpu for faster training / inference +enable_dropout: False +voting_runs: 1 +eval_stages: [ "val", "test" ] +pretty_print: True + +wandb: + project: ??? + log: False + public: True + +tracker_options: # Extra options for the tracker + full_res: False + make_submission: True + +hydra: + run: + dir: ${checkpoint_dir}/eval/${now:%Y-%m-%d_%H-%M-%S-%f} + + diff --git a/torch-points3d/conf/hydra/job_logging/custom.yaml b/torch-points3d/conf/hydra/job_logging/custom.yaml new file mode 100644 index 0000000..f374a2c --- /dev/null +++ b/torch-points3d/conf/hydra/job_logging/custom.yaml @@ -0,0 +1,19 @@ +# @package _group_ +formatters: + simple: + format: "%(message)s" +root: + handlers: [debug_console_handler, file_handler] +version: 1 +handlers: + debug_console_handler: + level: DEBUG + formatter: simple + class: logging.StreamHandler + stream: ext://sys.stdout + file_handler: + level: DEBUG + formatter: simple + class: logging.FileHandler + filename: train.log +disable_existing_loggers: False diff --git a/torch-points3d/conf/hydra/output/custom.yaml b/torch-points3d/conf/hydra/output/custom.yaml new file mode 100644 index 0000000..f7d9099 --- /dev/null +++ b/torch-points3d/conf/hydra/output/custom.yaml @@ -0,0 +1,4 @@ +# @package _global_ +hydra: + run: + dir: ./outputs/${job_name}/${job_name}-${model_name}-${now:%Y%m%d_%H%M%S} diff --git a/torch-points3d/conf/lr_scheduler/cosine.yaml b/torch-points3d/conf/lr_scheduler/cosine.yaml new file mode 100644 index 0000000..b40d164 --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/cosine.yaml @@ -0,0 +1,4 @@ +# @package _group_ +class: CosineAnnealingLR +params: + T_max: 10 \ No newline at end of file diff --git a/torch-points3d/conf/lr_scheduler/cosineawr.yaml b/torch-points3d/conf/lr_scheduler/cosineawr.yaml new file mode 100644 index 0000000..c64dd80 --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/cosineawr.yaml @@ -0,0 +1,5 @@ +# @package _group_ +class: CosineAnnealingWarmRestarts +params: + T_0: 10 + T_mult: 2 \ No newline at end of file diff --git a/torch-points3d/conf/lr_scheduler/cyclic.yaml b/torch-points3d/conf/lr_scheduler/cyclic.yaml new file mode 100644 index 0000000..67b09cc --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/cyclic.yaml @@ -0,0 +1,5 @@ +# @package _group_ +class: CyclicLR +params: + base_lr: ${training.optim.base_lr} + max_lr: 0.1 diff --git a/torch-points3d/conf/lr_scheduler/exponential.yaml b/torch-points3d/conf/lr_scheduler/exponential.yaml new file mode 100644 index 0000000..43d511a --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/exponential.yaml @@ -0,0 +1,4 @@ +# @package _group_ +class: ExponentialLR +params: + gamma: 0.9885 # = 0.1**(1/200.) divide by 10 every 200 epochs \ No newline at end of file diff --git a/torch-points3d/conf/lr_scheduler/multi_step.yaml b/torch-points3d/conf/lr_scheduler/multi_step.yaml new file mode 100644 index 0000000..11f2c5b --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/multi_step.yaml @@ -0,0 +1,5 @@ +# @package _group_ +class: MultiStepLR +params: + milestones: [80,120,160] + gamma: 0.2 diff --git a/torch-points3d/conf/lr_scheduler/multi_step_reg.yaml b/torch-points3d/conf/lr_scheduler/multi_step_reg.yaml new file mode 100644 index 0000000..cdcae5d --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/multi_step_reg.yaml @@ -0,0 +1,5 @@ +# @package _group_ +class: MultiStepLR +params: + milestones: [600, 1200, 1800, 3000] + gamma: 0.5 diff --git a/torch-points3d/conf/lr_scheduler/plateau.yaml b/torch-points3d/conf/lr_scheduler/plateau.yaml new file mode 100644 index 0000000..2260719 --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/plateau.yaml @@ -0,0 +1,4 @@ +# @package _group_ +class: ReduceLROnPlateau + params: + mode: "min" \ No newline at end of file diff --git a/torch-points3d/conf/lr_scheduler/poly_lr.yaml b/torch-points3d/conf/lr_scheduler/poly_lr.yaml new file mode 100644 index 0000000..188861b --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/poly_lr.yaml @@ -0,0 +1,9 @@ +# @package _group_ +class: PolyLR +params: + on_epoch: + max_iter: 150 + power: 0.9 + on_num_batch: + max_iter: 60000 + power: 2 diff --git a/torch-points3d/conf/lr_scheduler/step.yaml b/torch-points3d/conf/lr_scheduler/step.yaml new file mode 100644 index 0000000..921f63e --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/step.yaml @@ -0,0 +1,6 @@ +# @package _group_ +class: StepLR +params: + step_size: 10 + gamma: 0.9 + last_epoch: -1 \ No newline at end of file diff --git a/torch-points3d/conf/lr_scheduler/warmupcosine.yaml b/torch-points3d/conf/lr_scheduler/warmupcosine.yaml new file mode 100644 index 0000000..cdb83ac --- /dev/null +++ b/torch-points3d/conf/lr_scheduler/warmupcosine.yaml @@ -0,0 +1,5 @@ +# @package _group_ +class: LinearWarmupCosineAnnealingLR +params: + warmup_epochs: 10 + max_epochs: ${training.epochs} \ No newline at end of file diff --git a/torch-points3d/conf/models/default.yaml b/torch-points3d/conf/models/default.yaml new file mode 100644 index 0000000..af203a0 --- /dev/null +++ b/torch-points3d/conf/models/default.yaml @@ -0,0 +1 @@ +# @package models diff --git a/torch-points3d/conf/models/instance/default.yaml b/torch-points3d/conf/models/instance/default.yaml new file mode 100644 index 0000000..97c23be --- /dev/null +++ b/torch-points3d/conf/models/instance/default.yaml @@ -0,0 +1,3 @@ +# @package models +defaults: + - /models/default \ No newline at end of file diff --git a/torch-points3d/conf/models/instance/kpconv.yaml b/torch-points3d/conf/models/instance/kpconv.yaml new file mode 100755 index 0000000..3259122 --- /dev/null +++ b/torch-points3d/conf/models/instance/kpconv.yaml @@ -0,0 +1,89 @@ +# @package models + +KPConv: + class: kpconv.KPConv + conv_type: "PARTIAL_DENSE" + config: + ################## + # Input parameters + ################## + + # Dimension of input points + in_points_dim: 3 + + # Dimension of input features + in_features_dim: FEAT + + # Radius of the input sphere (ignored for models, only used for point clouds) + in_radius: 1.0 + + ################## + # Model parameters + ################## + + # Architecture definition. List of blocks + architecture: [ 'simple', + 'resnetb', + 'resnetb_strided', + 'resnetb', + 'resnetb', + 'resnetb_strided', + 'resnetb', + 'resnetb', + 'resnetb_strided', + 'resnetb', + 'resnetb', + 'resnetb_strided', + 'resnetb', + 'resnetb', + 'global_sum' ] + + # Dimension of the first feature maps + first_features_dim: 64 + + # Batch normalization parameters + use_batch_norm: True + batch_norm_momentum: 0.02 + + ################### + # KPConv parameters + ################### + + # Activation function + activation: relu + + # Number of kernel points + num_kernel_points: 15 + + # Size of the first subsampling grid + first_subsampling_dl: ${data.first_subsampling} + + # Radius of convolution in "number grid cell". (2.5 is the standard value) + conv_radius: 2.5 + + # Radius of deformable convolution in "number grid cell". Larger so that deformed kernel can spread out + deform_radius: 5.0 + + # Kernel point influence radius + KP_extent: 1.0 + + # Influence function when d < KP_extent. ('constant', 'linear', 'gaussian') When d > KP_extent, always zero + KP_influence: 'linear' + + # Aggregation function of KPConv in ('closest', 'sum') + # Decide if you sum all kernel point influences, or if you only take the influence of the closest KP + aggregation_mode: 'sum' + + # Fixed points in the kernel : 'none', 'center' or 'verticals' + fixed_kernel_points: 'center' + + # Use modulateion in deformable convolutions + modulated: False + + # Deformable offset loss + # 'point2point' fitting geometry by penalizing distance from deform point to input points + # 'point2plane' fitting geometry by penalizing distance from deform point to input point triplet (not implemented) + deform_fitting_mode: 'point2point' + deform_fitting_power: 1.0 # Multiplier for the fitting/repulsive loss + deform_lr_factor: 0.1 # Multiplier for learning rate applied to the deformations + repulse_extent: 1.2 # Distance of repulsion for deformed kernel points diff --git a/torch-points3d/conf/models/instance/minkowski_baseline.yaml b/torch-points3d/conf/models/instance/minkowski_baseline.yaml new file mode 100644 index 0000000..f3afbdd --- /dev/null +++ b/torch-points3d/conf/models/instance/minkowski_baseline.yaml @@ -0,0 +1,124 @@ +# @package models +# Minkowski Engine: https://github.com/StanfordVL/MinkowskiEngine/blob/master/examples/minkunet.py + +MPointNet: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "MinkowskiPointNet" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + global_pool: mean + add_pos: True + +ResNet14: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "ResNet14_" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + +ResNet18: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "ResNet18_" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + +ResNet34: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "ResNet34_" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + +ResNet50: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "ResNet50_" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + +ResNet101: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "ResNet101_" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + + +SENet14: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "SENet14" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + +SENet18: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "SENet18" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + +SENet34: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "SENet34" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + +SENet50: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "SENet50" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean + +SENet101: + class: minkowski.MinkowskiBaselineModel + conv_type: "SPARSE" + model_name: "SENet101" + D: 3 + activation: "relu" + first_stride: 2 + dropout: 0.0 + drop_path: 0.01 + global_pool: mean \ No newline at end of file diff --git a/torch-points3d/conf/models/instance/minkowski_selfsupervised.yaml b/torch-points3d/conf/models/instance/minkowski_selfsupervised.yaml new file mode 100644 index 0000000..8f7bf51 --- /dev/null +++ b/torch-points3d/conf/models/instance/minkowski_selfsupervised.yaml @@ -0,0 +1,38 @@ +# @package models +# Minkowski Engine: https://github.com/StanfordVL/MinkowskiEngine/blob/master/examples/minkunet.py + +BarlowTwins: + class: minkowski.MinkowskiBarlowTwins + conv_type: "SPARSE" + model_name: "BarlowTwins" + backbone: "SENet34" + D: 3 + activation: elu + proj_activation: relu + first_stride: 1 + dropout: 0 + global_pool: mean + proj_layers: [ 2048, 2048, 2048 ] + proj_last_norm: True + loss_fn: "smoothl1" + scale_loss: { "lambda": 0.0051, "all": 0.1 , } + mode: "train" + backbone_lr: "base_lr" + +VICReg: + class: minkowski.MinkowskiVICReg + conv_type: "SPARSE" + model_name: "BarlowTwins" + backbone: "SENet34" + D: 3 + activation: elu + proj_activation: relu + first_stride: 1 + dropout: 0 + global_pool: mean + proj_layers: [ 2048, 2048, 2048 ] + proj_last_norm: False + loss_fn: "smoothl1" + mode: "train" + backbone_lr: "base_lr" + scale_loss: { "invariance": 25., "variance": 25. , "covariance": 1. } \ No newline at end of file diff --git a/torch-points3d/conf/models/instance/pointnet.yaml b/torch-points3d/conf/models/instance/pointnet.yaml new file mode 100755 index 0000000..f4138a9 --- /dev/null +++ b/torch-points3d/conf/models/instance/pointnet.yaml @@ -0,0 +1,10 @@ +# @package models +# Minkowski Engine: https://github.com/StanfordVL/MinkowskiEngine/blob/master/examples/minkunet.py + +PointNet: + class: pointnext.PointNext + conv_type: "PARTIAL_DENSE" + arch: "pointnet" + radius: ${data.first_subsampling} + stride: 4 + num_points: 8192 diff --git a/torch-points3d/conf/models/instance/pointnext.yaml b/torch-points3d/conf/models/instance/pointnext.yaml new file mode 100755 index 0000000..a92468c --- /dev/null +++ b/torch-points3d/conf/models/instance/pointnext.yaml @@ -0,0 +1,14 @@ +# @package models + +PointNext: + class: pointnext.PointNext + conv_type: "PARTIAL_DENSE" + arch: "pointnext_s" + radius: ${data.first_subsampling} + radius_scaling: 2 + nsample: 32 + stride: 4 + activation: relu + num_points: 8192 + use_mlps: True + diff --git a/torch-points3d/conf/models/instance/pointnext_selfsupervised.yaml b/torch-points3d/conf/models/instance/pointnext_selfsupervised.yaml new file mode 100755 index 0000000..c0874b2 --- /dev/null +++ b/torch-points3d/conf/models/instance/pointnext_selfsupervised.yaml @@ -0,0 +1,37 @@ +# @package models + +BarlowTwins: + class: pointnext.PointNextBarlowTwins + conv_type: "PARTIAL_DENSE" + arch: "pointnext_s" + radius: ${data.first_subsampling} + activation: relu + proj_activation: relu + num_points: 8192 + loss_fn: smoothl1 + stride: 4 + dropout: 0 + global_pool: mean + proj_layers: [ 2048, 2048, 2048 ] + proj_last_norm: True + scale_loss: { "lambda": 0.0051, "all": 0.1 , } + mode: "train" + backbone_lr: "base_lr" + +VICReg: + class: pointnext.PointNextVICReg + conv_type: "PARTIAL_DENSE" + arch: "pointnext_b" + radius: ${data.first_subsampling} + activation: relu + proj_activation: relu + num_points: 8192 + loss_fn: smoothl1 + stride: 4 + dropout: 0 + global_pool: mean + proj_layers: [ 2048, 2048, 2048 ] + proj_last_norm: False + mode: "train" + backbone_lr: "base_lr" + scale_loss: { "invariance": 25., "variance": 25. , "covariance": 1. } \ No newline at end of file diff --git a/torch-points3d/conf/models/instance/simplestnet.yaml b/torch-points3d/conf/models/instance/simplestnet.yaml new file mode 100755 index 0000000..b49c8fe --- /dev/null +++ b/torch-points3d/conf/models/instance/simplestnet.yaml @@ -0,0 +1,5 @@ +# @package models + +SimplestNet: + class: simplestnet.SimplestNet + conv_type: "PARTIAL_DENSE" diff --git a/torch-points3d/conf/sota.yaml b/torch-points3d/conf/sota.yaml new file mode 100644 index 0000000..22c7101 --- /dev/null +++ b/torch-points3d/conf/sota.yaml @@ -0,0 +1,26 @@ +# @package sota +s3dis5: + miou: 67.1 + mrec: 72.8 + +s3dis: + acc: 88.2 + macc: 81.5 + miou: 70.6 + +scannet: + miou: 72.5 + +semantic3d: + miou: 76.0 + acc: 94.4 + +semantickitti: + miou: 50.3 + +modelnet40: + acc: 92.9 + +shapenet: + mciou: 85.1 + miou: 86.4 \ No newline at end of file diff --git a/torch-points3d/conf/task/default.yaml b/torch-points3d/conf/task/default.yaml new file mode 100644 index 0000000..6ffc0e9 --- /dev/null +++ b/torch-points3d/conf/task/default.yaml @@ -0,0 +1,10 @@ +# @package task +defaults: + - /data@_group_: default + - /models@_group_: default + +# By default.yaml we turn off recursive instantiation, allowing the user to instantiate themselves at the appropriate times. +_recursive_: false + +#_target_: lightning_transformers.core.model.TaskTransformer +lr_scheduler: ${lr_scheduler} diff --git a/torch-points3d/conf/task/instance.yaml b/torch-points3d/conf/task/instance.yaml new file mode 100644 index 0000000..9258141 --- /dev/null +++ b/torch-points3d/conf/task/instance.yaml @@ -0,0 +1,7 @@ +# @package task +defaults: + - /task/default + - override /data@_group_: instance/default + - override /models@_group_: instance/default + +name: instance \ No newline at end of file diff --git a/torch-points3d/conf/training/default.yaml b/torch-points3d/conf/training/default.yaml new file mode 100644 index 0000000..bbfb90e --- /dev/null +++ b/torch-points3d/conf/training/default.yaml @@ -0,0 +1,55 @@ +# @package training +# Those arguments defines the training hyper-parameters +epochs: 100 +num_workers: 6 +batch_size: 16 +shuffle: True +cuda: 0 # -1 -> no cuda otherwise takes the specified index +precompute_multi_scale: False # Compute multiscate features on cpu for faster training / inference +optim: + base_lr: 0.001 + # accumulated_gradient: -1 # Accumulate gradient accumulated_gradient * batch_size + grad_clip: -1 + optimizer: + class: Adam + params: + lr: ${training.optim.base_lr} # The path is cut from training + lr_scheduler: ${lr_scheduler} + bn_scheduler: + bn_policy: "step_decay" + params: + bn_momentum: 0.1 + bn_decay: 0.9 + decay_step: 10 + bn_clip: 1e-2 +weight_name: "latest" # Used during resume, select with model to load from [miou, macc, acc..., latest] +enable_cudnn: True +checkpoint_dir: "" + +# Those arguments within experiment defines which model, dataset and task to be created for benchmarking +# parameters for Weights and Biases +wandb: + project: default + log: True + name: dev + public: True # It will be display the model within wandb log, else not. + config: + model_name: ${model_name} + +# parameters for TensorBoard Visualization +tensorboard: + log: True + pytorch_profiler: + log: True # activate PyTorch Profiler in TensorBoard + nb_epoch: 3 # number of epochs to profile (0 -> all). + skip_first: 10 # number of first iterations to skip. + wait: 5 # number of iterations where the profiler is disable. + warmup: 3 # number of iterations where the profiler starts tracing but the results are discarded. This is for reducing the profiling overhead. The overhead at the beginning of profiling is high and easy to bring skew to the profiling result. + active: 5 # number of iterations where the profiler is active and records events. + repeat: 0 # number of cycle wait/warmup/active to realise before stoping profiling (0 -> all). + record_shapes: True # save information about operator’s input shapes. + profile_memory: True # track tensor memory allocation/deallocation. + with_stack: True # record source information (file and line number) for the ops. + with_flops: True # use formula to estimate the FLOPS of specific operators (matrix multiplication and 2D convolution). + +enable_mixed: True diff --git a/torch-points3d/conf/training/default_reg.yaml b/torch-points3d/conf/training/default_reg.yaml new file mode 100644 index 0000000..d08bfc2 --- /dev/null +++ b/torch-points3d/conf/training/default_reg.yaml @@ -0,0 +1,41 @@ +# @package training +# Those arguments defines the training hyper-parameters +epochs: 6000 +num_workers: 6 +batch_size: 64 +shuffle: True +cuda: 0 +precompute_multi_scale: False # Compute multiscate features on cpu for faster training / inference +optim: + base_lr: 0.001 + # accumulated_gradient: -1 # Accumulate gradient accumulated_gradient * batch_size + grad_clip: -1 + optimizer: + class: Adam + params: + lr: ${training.optim.base_lr} # The path is cut from training + + lr_scheduler: ${lr_scheduler} + bn_scheduler: + bn_policy: "step_decay" + params: + bn_momentum: 0.1 + bn_decay: 0.9 + decay_step: 3000 + bn_clip: 1e-2 +weight_name: "latest" # Used during resume, select with model to load from [miou, macc, acc..., latest] +enable_cudnn: True +checkpoint_dir: "" + +# Those arguments within experiment defines which model, dataset and task to be created for benchmarking +# parameters for Weights and Biases +wandb: + project: default + log: False + notes: + name: + public: True # It will be display the model within wandb log, else not. + + # parameters for TensorBoard Visualization +tensorboard: + log: True diff --git a/torch-points3d/conf/training/nfi/kpconv.yaml b/torch-points3d/conf/training/nfi/kpconv.yaml new file mode 100644 index 0000000..c228f32 --- /dev/null +++ b/torch-points3d/conf/training/nfi/kpconv.yaml @@ -0,0 +1,64 @@ +# @package training +# Those arguments defines the training hyper-parameters +epochs: 310 +num_workers: 4 +batch_size: 32 +cuda: 0 +shuffle: True +optim: + base_lr: 0.005 + grad_clip: 100 + optimizer: +# class: SGD +# params: +# momentum: 0.98 +# lr: ${training.optim.base_lr} # The path is cut from training +# weight_decay: 1e-3 + class: AdaBelief + params: + lr: ${training.optim.base_lr} # The path is cut from training + weight_decay: 1e-2 + lr_scheduler: ${lr_scheduler} +# bn_scheduler: +# bn_policy: "step_decay" +# params: +# bn_momentum: 0.98 +# bn_decay: 0.9 +# decay_step: 1000 +# bn_clip: 1e-2 +weight_name: "latest" # Used during resume, select with model to load from [miou, macc, acc..., latest] +enable_cudnn: True +checkpoint_dir: "" + +# Those arguments within experiment defines which model, dataset and task to be created for benchmarking +# parameters for Weights and Biases +wandb: + project: nfi + log: True + name: ${model_name} + public: True # It will be display the model within wandb log, else not. + config: + model_name: ${model_name} + features: ${data.features} + batch_size: ${training.batch_size} + first_subsampling: ${data.first_subsampling} + base_lr: ${training.optim.base_lr} + + +# parameters for TensorBoard Visualization +tensorboard: + log: False + pytorch_profiler: + log: False # activate PyTorch Profiler in TensorBoard + nb_epoch: 3 # number of epochs to profile (0 -> all). + skip_first: 10 # number of first iterations to skip. + wait: 5 # number of iterations where the profiler is disable. + warmup: 3 # number of iterations where the profiler starts tracing but the results are discarded. This is for reducing the profiling overhead. The overhead at the beginning of profiling is high and easy to bring skew to the profiling result. + active: 5 # number of iterations where the profiler is active and records events. + repeat: 0 # number of cycle wait/warmup/active to realise before stoping profiling (0 -> all). + record_shapes: True # save information about operator’s input shapes. + profile_memory: True # track tensor memory allocation/deallocation. + with_stack: True # record source information (file and line number) for the ops. + with_flops: True # use formula to estimate the FLOPS of specific operators (matrix multiplication and 2D convolution). + +enable_mixed: True diff --git a/torch-points3d/conf/training/nfi/minkowski.yaml b/torch-points3d/conf/training/nfi/minkowski.yaml new file mode 100644 index 0000000..a14ce6f --- /dev/null +++ b/torch-points3d/conf/training/nfi/minkowski.yaml @@ -0,0 +1,68 @@ +# @package training +# Those arguments defines the training hyper-parameters +epochs: 310 +num_workers: 4 +batch_size: 32 +cuda: 0 +shuffle: True +optim: + base_lr: 0.005 + grad_clip: 100 + optimizer: +# class: SGD +# params: +# momentum: 0.98 +# lr: ${training.optim.base_lr} # The path is cut from training +# weight_decay: 1e-3 + class: AdaBelief + params: + lr: ${training.optim.base_lr} # The path is cut from training + weight_decay: 1e-2 + lr_scheduler: ${lr_scheduler} +# bn_scheduler: +# bn_policy: "step_decay" +# params: +# bn_momentum: 0.98 +# bn_decay: 0.9 +# decay_step: 1000 +# bn_clip: 1e-2 +weight_name: "latest" # Used during resume, select with model to load from [miou, macc, acc..., latest] +enable_cudnn: True +checkpoint_dir: "" + +# Those arguments within experiment defines which model, dataset, and task to be created for benchmarking +# parameters for Weights and Biases +wandb: + project: nfi + log: True + name: ${model_name} + public: True # It will be display the model within wandb log, else not. + config: + model_name: ${model_name} + features: ${data.features} + batch_size: ${training.batch_size} + first_subsampling: ${data.first_subsampling} + base_lr: ${training.optim.base_lr} + activation: ${models.${model_name}.activation} + first_stride: ${models.${model_name}.first_stride} + transform_type: ${data.transform_type} + + +# parameters for TensorBoard Visualization +tensorboard: + log: False + pytorch_profiler: + log: False # activate PyTorch Profiler in TensorBoard + nb_epoch: 3 # number of epochs to profile (0 -> all). + skip_first: 10 # number of first iterations to skip. + wait: 5 # number of iterations where the profiler is disable. + warmup: 3 # number of iterations where the profiler starts tracing but the results are discarded. This is for reducing the profiling overhead. The overhead at the beginning of profiling is high and easy to bring skew to the profiling result. + active: 5 # number of iterations where the profiler is active and records events. + repeat: 0 # number of cycle wait/warmup/active to realise before stoping profiling (0 -> all). + record_shapes: True # save information about operator’s input shapes. + profile_memory: True # track tensor memory allocation/deallocation. + with_stack: True # record source information (file and line number) for the ops. + with_flops: True # use formula to estimate the FLOPS of specific operators (matrix multiplication and 2D convolution). + + +enable_mixed: True diff --git a/torch-points3d/conf/training/nfi/pointnet.yaml b/torch-points3d/conf/training/nfi/pointnet.yaml new file mode 100644 index 0000000..8e2a911 --- /dev/null +++ b/torch-points3d/conf/training/nfi/pointnet.yaml @@ -0,0 +1,65 @@ +# @package training +# Those arguments defines the training hyper-parameters +epochs: 310 +num_workers: 4 +batch_size: 32 +cuda: 0 +shuffle: True +optim: + base_lr: 0.005 + grad_clip: 100 + optimizer: +# class: SGD +# params: +# momentum: 0.98 +# lr: ${training.optim.base_lr} # The path is cut from training +# weight_decay: 1e-3 + class: AdaBelief + params: + lr: ${training.optim.base_lr} # The path is cut from training + weight_decay: 1e-2 + lr_scheduler: ${lr_scheduler} +# bn_scheduler: +# bn_policy: "step_decay" +# params: +# bn_momentum: 0.98 +# bn_decay: 0.9 +# decay_step: 1000 +# bn_clip: 1e-2 +weight_name: "latest" # Used during resume, select with model to load from [miou, macc, acc..., latest] +enable_cudnn: True +checkpoint_dir: "" + +# Those arguments within experiment defines which model, dataset, and task to be created for benchmarking +# parameters for Weights and Biases +wandb: + project: nfi + log: True + name: ${model_name} + public: True # It will be display the model within wandb log, else not. + config: + model_name: ${model_name} + features: ${data.features} + batch_size: ${training.batch_size} + base_lr: ${training.optim.base_lr} + transform_type: ${data.transform_type} + + +# parameters for TensorBoard Visualization +tensorboard: + log: False + pytorch_profiler: + log: False # activate PyTorch Profiler in TensorBoard + nb_epoch: 3 # number of epochs to profile (0 -> all). + skip_first: 10 # number of first iterations to skip. + wait: 5 # number of iterations where the profiler is disable. + warmup: 3 # number of iterations where the profiler starts tracing but the results are discarded. This is for reducing the profiling overhead. The overhead at the beginning of profiling is high and easy to bring skew to the profiling result. + active: 5 # number of iterations where the profiler is active and records events. + repeat: 0 # number of cycle wait/warmup/active to realise before stoping profiling (0 -> all). + record_shapes: True # save information about operator’s input shapes. + profile_memory: True # track tensor memory allocation/deallocation. + with_stack: True # record source information (file and line number) for the ops. + with_flops: True # use formula to estimate the FLOPS of specific operators (matrix multiplication and 2D convolution). + + +enable_mixed: True diff --git a/torch-points3d/conf/visualization/default.yaml b/torch-points3d/conf/visualization/default.yaml new file mode 100644 index 0000000..c503387 --- /dev/null +++ b/torch-points3d/conf/visualization/default.yaml @@ -0,0 +1,14 @@ +# @package _group_ +activate: False +format: ["ply", "tensorboard"] +num_samples_per_epoch: 10 +deterministic: True # False -> Randomly sample elements from epoch to epoch +deterministic_seed: 42 +saved_keys: + pos: [['x', 'float'], ['y', 'float'], ['z', 'float']] + y: [['l', 'float']] + pred: [['p', 'float']] +ply_format: 'binary_big_endian' +tensorboard_mesh: + label: 'y' + prediction: 'pred' diff --git a/torch-points3d/conf/visualization/eval.yaml b/torch-points3d/conf/visualization/eval.yaml new file mode 100644 index 0000000..67e2316 --- /dev/null +++ b/torch-points3d/conf/visualization/eval.yaml @@ -0,0 +1,14 @@ +# @package _group_ +activate: True +format: ["csv"] # image will come later +num_samples_per_epoch: -1 +deterministic: True # False -> Randomly sample elements from epoch to epoch +deterministic_seed: 42 +saved_keys: + pos: [['x', 'float'], ['y', 'float'], ['z', 'float']] + y: [['l', 'float']] + pred: [['p', 'float']] +ply_format: 'binary_big_endian' +tensorboard_mesh: + label: 'y' + prediction: 'pred' diff --git a/torch-points3d/conf/visualization/predict.yaml b/torch-points3d/conf/visualization/predict.yaml new file mode 100644 index 0000000..67e2316 --- /dev/null +++ b/torch-points3d/conf/visualization/predict.yaml @@ -0,0 +1,14 @@ +# @package _group_ +activate: True +format: ["csv"] # image will come later +num_samples_per_epoch: -1 +deterministic: True # False -> Randomly sample elements from epoch to epoch +deterministic_seed: 42 +saved_keys: + pos: [['x', 'float'], ['y', 'float'], ['z', 'float']] + y: [['l', 'float']] + pred: [['p', 'float']] +ply_format: 'binary_big_endian' +tensorboard_mesh: + label: 'y' + prediction: 'pred' diff --git a/torch-points3d/env.yml b/torch-points3d/env.yml new file mode 100644 index 0000000..1557a31 --- /dev/null +++ b/torch-points3d/env.yml @@ -0,0 +1,62 @@ +name: pts +channels: + - pytorch + - nvidia + - metric-learning + - anaconda + - conda-forge + - pyg + - defaults +dependencies: + - cloudpickle + - colorama + - cudatoolkit=11.8 + - gdal + - gdown + - geopandas=0.12.2 + - llvmlite + - numba + - hydra-core + - matplotlib + - openblas + - pandas=1.5.2 + - proj + - python=3.8 + - pyyaml + - rtree + - scikit-image + - scikit-learn + - scipy + - tensorboard + - tqdm + - wandb + - werkzeug + - wheel + - yaml + - ipython + - h5py + - plyfile + - pip + - pytorch::pytorch=2.0 + - pytorch::pytorch-cuda=11.8 + - pyg::pyg=2.3.1 + - pyg::pytorch-cluster + - pyg::pytorch-scatter + - pip: + - jupyter-client + - jupyter-core + - jupyterlab + - lazrs + - open3d + - numba + - widgetsnbextension + - laspy + - plyfile + - dbscan1d + - multimethod + - termcolor + - shortuuid + - easydict + - tabulate + - torchnet + - visdom diff --git a/torch-points3d/env_cpu.yml b/torch-points3d/env_cpu.yml new file mode 100644 index 0000000..e84f623 --- /dev/null +++ b/torch-points3d/env_cpu.yml @@ -0,0 +1,61 @@ +name: pts +channels: + - pytorch + - metric-learning + - anaconda + - conda-forge + - pyg + - defaults +dependencies: + - cloudpickle + - colorama + - gdal + - gdown + - geopandas=0.12.2 + - llvmlite + - numba + - hydra-core + - matplotlib + - openblas + - pandas=1.5.2 + - proj + - python=3.8 + - pyyaml + - rtree + - scikit-image + - scikit-learn + - scipy + - tensorboard + - tqdm + - wandb + - werkzeug + - wheel + - yaml + - ipython + - h5py + - plyfile + - pip + - pytorch::pytorch=2.0 + - pytorch::torchvision + - pytorch::cpuonly + - pyg::pyg=2.3.1 + - pyg::pytorch-cluster + - pyg::pytorch-scatter + - pip: + - jupyter-client + - jupyter-core + - jupyterlab + - lazrs + - open3d + - numba + - widgetsnbextension + - laspy + - plyfile + - dbscan1d + - multimethod + - termcolor + - shortuuid + - easydict + - tabulate + - torchnet + - visdom diff --git a/torch-points3d/eval.py b/torch-points3d/eval.py new file mode 100644 index 0000000..bd35a08 --- /dev/null +++ b/torch-points3d/eval.py @@ -0,0 +1,38 @@ +import hydra +import numpy as np +import torch.random +from hydra.core.global_hydra import GlobalHydra +from omegaconf import OmegaConf, open_dict + +from torch_points3d.trainer import Trainer + + +@hydra.main(config_path="conf", config_name="eval") +def main(cfg): + rs = cfg.get("random_seed", 21) + + # disable random shuffling and dropping of last batch in training loader + OmegaConf.set_struct(cfg, True) + with open_dict(cfg): + cfg["shuffle"] = False + cfg["drop_last"] = False + + np.random.default_rng(rs) + torch.random.manual_seed(rs) + + OmegaConf.set_struct(cfg, False) # This allows getattr and hasattr methods to function correctly + if cfg.pretty_print: + print(OmegaConf.to_yaml(cfg)) + + trainer = Trainer(cfg) + eval_stages = cfg.get("eval_stages", [""]) + for stage in eval_stages: + trainer.eval(stage) + # + # # https://github.com/facebookresearch/hydra/issues/440 + GlobalHydra.get_state().clear() + return 0 + + +if __name__ == "__main__": + main() diff --git a/torch-points3d/torch_points3d/__init__.py b/torch-points3d/torch_points3d/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/applications/__init__.py b/torch-points3d/torch_points3d/applications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/applications/conf/kpconv/encoder_4.yaml b/torch-points3d/torch_points3d/applications/conf/kpconv/encoder_4.yaml new file mode 100644 index 0000000..0ea0664 --- /dev/null +++ b/torch-points3d/torch_points3d/applications/conf/kpconv/encoder_4.yaml @@ -0,0 +1,68 @@ +class: kpconv.KPConvPaper +conv_type: "PARTIAL_DENSE" +define_constants: + in_grid_size: 0.02 + in_feat: 64 + bn_momentum: 0.2 + output_nc: 256 + max_neighbors: 25 +down_conv: + down_conv_nn: + [ + [[FEAT + 1, in_feat], [in_feat, 2*in_feat]], + [[2*in_feat, 2*in_feat], [2*in_feat, 4*in_feat]], + [[4*in_feat, 4*in_feat], [4*in_feat, 8*in_feat]], + [[8*in_feat, 8*in_feat], [8*in_feat, 16*in_feat]], + [[16*in_feat, 16*in_feat], [16*in_feat, 32 * in_feat]], + ] + grid_size: + [ + [in_grid_size, in_grid_size], + [2*in_grid_size, 2*in_grid_size], + [4*in_grid_size, 4*in_grid_size], + [8*in_grid_size, 8*in_grid_size], + [16*in_grid_size, 16*in_grid_size], + ] + prev_grid_size: + [ + [in_grid_size, in_grid_size], + [in_grid_size, 2*in_grid_size], + [2*in_grid_size, 4*in_grid_size], + [4*in_grid_size, 8*in_grid_size], + [8*in_grid_size, 16*in_grid_size], + ] + block_names: + [ + ["SimpleBlock", "ResnetBBlock"], + ["ResnetBBlock", "ResnetBBlock"], + ["ResnetBBlock", "ResnetBBlock"], + ["ResnetBBlock", "ResnetBBlock"], + ["ResnetBBlock", "ResnetBBlock"], + ] + has_bottleneck: + [ + [False, True], + [True, True], + [True, True], + [True, True], + [True, True], + ] + deformable: + [ + [False, False], + [False, False], + [False, False], + [False, False], + [False, False], + ] + max_num_neighbors: + [[max_neighbors,max_neighbors], [max_neighbors, max_neighbors], [max_neighbors, max_neighbors], [max_neighbors, max_neighbors], [max_neighbors, max_neighbors]] + module_name: KPDualBlock +innermost: + module_name: GlobalBaseModule + activation: + name: LeakyReLU + negative_slope: 0.2 + aggr: "mean" + nn: [32 * in_feat + 3, 32 * in_feat] + diff --git a/torch-points3d/torch_points3d/applications/minkowski.py b/torch-points3d/torch_points3d/applications/minkowski.py new file mode 100644 index 0000000..b90454e --- /dev/null +++ b/torch-points3d/torch_points3d/applications/minkowski.py @@ -0,0 +1,196 @@ +import os +import sys +from omegaconf import DictConfig, OmegaConf +import logging +import torch +from torch_geometric.data import Batch + +from torch_points3d.applications.modelfactory import ModelFactory +from torch_points3d.modules.MinkowskiEngine.api_modules import * +from torch_points3d.core.base_conv.message_passing import * +from torch_points3d.core.base_conv.partial_dense import * +from torch_points3d.models.base_architectures.unet import UnwrappedUnetBasedModel +from torch_points3d.core.common_modules.base_modules import MLP + +from .utils import extract_output_nc + + +CUR_FILE = os.path.realpath(__file__) +DIR_PATH = os.path.dirname(os.path.realpath(__file__)) +PATH_TO_CONFIG = os.path.join(DIR_PATH, "conf/sparseconv3d") + +log = logging.getLogger(__name__) + + +def Minkowski( + architecture: str = None, input_nc: int = None, num_layers: int = None, config: DictConfig = None, *args, **kwargs +): + """ Create a Minkowski backbone model based on architecture proposed in + https://arxiv.org/abs/1904.08755 + + Parameters + ---------- + architecture : str, optional + Architecture of the model, choose from unet, encoder and decoder + input_nc : int, optional + Number of channels for the input + output_nc : int, optional + If specified, then we add a fully connected head at the end of the network to provide the requested dimension + num_layers : int, optional + Depth of the network + config : DictConfig, optional + Custom config, overrides the num_layers and architecture parameters + in_feat: + Size of the first layer + block: + Type of resnet block, ResBlock by default but can be any of the blocks in modules/MinkowskiEngine/api_modules.py + """ + log.warning( + "Minkowski API is deprecated in favor of the SparseConv3d API. It should be a simple drop in replacement (no change to the API)." + ) + factory = MinkowskiFactory( + architecture=architecture, num_layers=num_layers, input_nc=input_nc, config=config, **kwargs + ) + return factory.build() + + +class MinkowskiFactory(ModelFactory): + def _build_unet(self): + if self._config: + model_config = self._config + else: + path_to_model = os.path.join(PATH_TO_CONFIG, "unet_{}.yaml".format(self.num_layers)) + model_config = OmegaConf.load(path_to_model) + ModelFactory.resolve_model(model_config, self.num_features, self._kwargs) + modules_lib = sys.modules[__name__] + return MinkowskiUnet(model_config, None, None, modules_lib, **self.kwargs) + + def _build_encoder(self): + if self._config: + model_config = self._config + else: + path_to_model = os.path.join(PATH_TO_CONFIG, "encoder_{}.yaml".format(self.num_layers),) + model_config = OmegaConf.load(path_to_model) + ModelFactory.resolve_model(model_config, self.num_features, self._kwargs) + modules_lib = sys.modules[__name__] + return MinkowskiEncoder(model_config, None, None, modules_lib, **self.kwargs) + + +class BaseMinkowski(UnwrappedUnetBasedModel): + CONV_TYPE = "sparse" + + def __init__(self, model_config, model_type, dataset, modules, *args, **kwargs): + super(BaseMinkowski, self).__init__(model_config, model_type, dataset, modules) + self.weight_initialization() + default_output_nc = kwargs.get("default_output_nc", None) + if not default_output_nc: + default_output_nc = extract_output_nc(model_config) + + self._output_nc = default_output_nc + self._has_mlp_head = False + if "output_nc" in kwargs: + self._has_mlp_head = True + self._output_nc = kwargs["output_nc"] + self.mlp = MLP([default_output_nc, self.output_nc], activation=torch.nn.LeakyReLU(0.2), bias=False) + + @property + def has_mlp_head(self): + return self._has_mlp_head + + @property + def output_nc(self): + return self._output_nc + + def weight_initialization(self): + for m in self.modules(): + if isinstance(m, ME.MinkowskiConvolution): + ME.utils.kaiming_normal_(m.kernel, mode="fan_out", nonlinearity="relu") + + if isinstance(m, ME.MinkowskiBatchNorm): + nn.init.constant_(m.bn.weight, 1) + nn.init.constant_(m.bn.bias, 0) + + def _set_input(self, data): + """Unpack input data from the dataloader and perform necessary pre-processing steps. + + Parameters + ----------- + data: + a dictionary that contains the data itself and its metadata information. + """ + coords = torch.cat([data.batch.unsqueeze(-1).int(), data.coords.int()], -1) + self.input = ME.SparseTensor(features=data.x, coordinates=coords, device=self.device) + if data.pos is not None: + self.xyz = data.pos.to(self.device) + else: + self.xyz = data.coords.to(self.device) + + +class MinkowskiEncoder(BaseMinkowski): + def forward(self, data, *args, **kwargs): + """ + Parameters: + ----------- + data + A SparseTensor that contains the data itself and its metadata information. Should contain + F -- Features [N, C] + coords -- Coords [N, 4] + + Returns + -------- + data: + - x [1, output_nc] + + """ + self._set_input(data) + data = self.input + for i in range(len(self.down_modules)): + data = self.down_modules[i](data) + + out = Batch(x=data.F, batch=data.C[:, 0].long().to(data.F.device)) + if not isinstance(self.inner_modules[0], Identity): + out = self.inner_modules[0](out) + + if self.has_mlp_head: + out.x = self.mlp(out.x) + return out + + +class MinkowskiUnet(BaseMinkowski): + def forward(self, data, *args, **kwargs): + """Run forward pass. + Input --- D1 -- D2 -- D3 -- U1 -- U2 -- output + | |_________| | + |______________________| + + Parameters + ----------- + data + A SparseTensor that contains the data itself and its metadata information. Should contain + F -- Features [N, C] + coords -- Coords [N, 4] + + Returns + -------- + data: + - pos [N, 3] (coords or real pos if xyz is in data) + - x [N, output_nc] + - batch [N] + """ + self._set_input(data) + data = self.input + stack_down = [] + for i in range(len(self.down_modules) - 1): + data = self.down_modules[i](data) + stack_down.append(data) + + data = self.down_modules[-1](data) + stack_down.append(None) + # TODO : Manage the inner module + for i in range(len(self.up_modules)): + data = self.up_modules[i](data, stack_down.pop()) + + out = Batch(x=data.F, pos=self.xyz, batch=data.C[:, 0]) + if self.has_mlp_head: + out.x = self.mlp(out.x) + return out diff --git a/torch-points3d/torch_points3d/applications/modelfactory.py b/torch-points3d/torch_points3d/applications/modelfactory.py new file mode 100644 index 0000000..9346b67 --- /dev/null +++ b/torch-points3d/torch_points3d/applications/modelfactory.py @@ -0,0 +1,99 @@ +from enum import Enum +from omegaconf import DictConfig +import logging + +from torch_points3d.utils.model_building_utils.model_definition_resolver import resolve + +log = logging.getLogger(__name__) + + +class ModelArchitectures(Enum): + UNET = "unet" + ENCODER = "encoder" + DECODER = "decoder" + + +class ModelFactory: + MODEL_ARCHITECTURES = [e.value for e in ModelArchitectures] + + @staticmethod + def raise_enum_error(arg_name, arg_value, options): + raise Exception("The provided argument {} with value {} isn't within {}".format(arg_name, arg_value, options)) + + def __init__( + self, + architecture: str = None, + input_nc: int = None, + num_layers: int = None, + config: DictConfig = None, + **kwargs + ): + if not architecture: + raise ValueError() + self._architecture = architecture.lower() + assert self._architecture in self.MODEL_ARCHITECTURES, ModelFactory.raise_enum_error( + "model_architecture", self._architecture, self.MODEL_ARCHITECTURES + ) + + self._input_nc = input_nc + self._num_layers = num_layers + self._config = config + self._kwargs = kwargs + + if self._config: + log.info("The config will be used to build the model") + + @property + def modules_lib(self): + raise NotImplementedError + + @property + def kwargs(self): + return self._kwargs + + @property + def num_layers(self): + return self._num_layers + + @property + def num_features(self): + return self._input_nc + + def _build_unet(self): + raise NotImplementedError + + def _build_encoder(self): + raise NotImplementedError + + def _build_decoder(self): + raise NotImplementedError + + def build(self): + if self._architecture == ModelArchitectures.UNET.value: + return self._build_unet() + elif self._architecture == ModelArchitectures.ENCODER.value: + return self._build_encoder() + elif self._architecture == ModelArchitectures.DECODER.value: + return self._build_decoder() + else: + raise NotImplementedError + + @staticmethod + def resolve_model(model_config, num_features, kwargs): + """ Parses the model config and evaluates any expression that may contain constants + Overrides any argument in the `define_constants` with keywords wrgument to the constructor + """ + # placeholders to subsitute + constants = { + "FEAT": max(num_features, 0), + } + + # user defined contants to subsitute + if "define_constants" in model_config.keys(): + constants.update(dict(model_config.define_constants)) + define_constants = model_config.define_constants + for key in define_constants.keys(): + value = kwargs.get(key) + if value: + constants[key] = value + resolve(model_config, constants) diff --git a/torch-points3d/torch_points3d/applications/models.py b/torch-points3d/torch_points3d/applications/models.py new file mode 100644 index 0000000..afef03e --- /dev/null +++ b/torch-points3d/torch_points3d/applications/models.py @@ -0,0 +1,15 @@ +import logging + +log = logging.getLogger(__name__) + +try: + from .sparseconv3d import SparseConv3d +except: + log.warning( + "Sparse convolutions are not supported, please install one of the available backends, MinkowskiEngine or MIT SparseConv" + ) + +try: + from .minkowski import Minkowski +except: + log.warning("MinkowskiEngine is not installed.") diff --git a/torch-points3d/torch_points3d/applications/pretrained_api.py b/torch-points3d/torch_points3d/applications/pretrained_api.py new file mode 100644 index 0000000..b71fa2f --- /dev/null +++ b/torch-points3d/torch_points3d/applications/pretrained_api.py @@ -0,0 +1,174 @@ +import os +import logging +import urllib.request +from omegaconf import DictConfig + +# Import building function for model and dataset +from torch_points3d.datasets.dataset_factory import instantiate_dataset +from torch_points3d.models.model_factory import instantiate_model + +# Import BaseModel / BaseDataset for type checking +from torch_points3d.models.base_model import BaseModel +from torch_points3d.datasets.base_dataset import BaseDataset + +from torch_points3d.utils.wandb_utils import Wandb +from torch_points3d.metrics.model_checkpoint import ModelCheckpoint + +log = logging.getLogger(__name__) + +DIR = os.path.dirname(os.path.realpath(__file__)) +CHECKPOINT_DIR = os.path.join(DIR, "weights") + + +def download_file(url, out_file): + if not os.path.exists(out_file): + if not os.path.exists(os.path.dirname(out_file)): + os.makedirs(os.path.dirname(out_file)) + urllib.request.urlretrieve(url, out_file) + else: + log.warning("WARNING: skipping download of existing file " + out_file) + + +class PretainedRegistry(object): + + MODELS = { + "pointnet2_largemsg-s3dis-1": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/1e1p0csk/pointnet2_largemsg.pt", + "pointnet2_largemsg-s3dis-2": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/2i499g2e/pointnet2_largemsg.pt", + "pointnet2_largemsg-s3dis-3": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/1gyokj69/pointnet2_largemsg.pt", + "pointnet2_largemsg-s3dis-4": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/1ejjs4s2/pointnet2_largemsg.pt", + "pointnet2_largemsg-s3dis-5": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/etxij0j6/pointnet2_largemsg.pt", + "pointnet2_largemsg-s3dis-6": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/8n8t391d/pointnet2_largemsg.pt", + "pointgroup-scannet": "https://api.wandb.ai/files/nicolas/panoptic/2ta6vfu2/PointGroup.pt", + "minkowski-res16-s3dis-1": "https://api.wandb.ai/files/nicolas/s3dis-benchmark/1fyr7ri9/Res16UNet34C.pt", + "minkowski-res16-s3dis-2": "https://api.wandb.ai/files/nicolas/s3dis-benchmark/1gdgx2ni/Res16UNet34C.pt", + "minkowski-res16-s3dis-3": "https://api.wandb.ai/files/nicolas/s3dis-benchmark/gt3ttamp/Res16UNet34C.pt", + "minkowski-res16-s3dis-4": "https://api.wandb.ai/files/nicolas/s3dis-benchmark/36yxu3yc/Res16UNet34C.pt", + "minkowski-res16-s3dis-5": "https://api.wandb.ai/files/nicolas/s3dis-benchmark/2r0tsub1/Res16UNet34C.pt", + "minkowski-res16-s3dis-6": "https://api.wandb.ai/files/nicolas/s3dis-benchmark/30yrkk5p/Res16UNet34C.pt", + "minkowski-registration-3dmatch": "https://api.wandb.ai/files/humanpose1/registration/2wvwf92e/MinkUNet_Fragment.pt", + "minkowski-registration-kitti": "https://api.wandb.ai/files/humanpose1/KITTI/2xpy7u1i/MinkUNet_Fragment.pt", + "minkowski-registration-modelnet": "https://api.wandb.ai/files/humanpose1/modelnet/39u5v3bm/MinkUNet_Fragment.pt", + "rsconv-s3dis-1": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/2b99o12e/RSConv_MSN_S3DIS.pt", + "rsconv-s3dis-2": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/1onl4h59/RSConv_MSN_S3DIS.pt", + "rsconv-s3dis-3": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/2cau6jua/RSConv_MSN_S3DIS.pt", + "rsconv-s3dis-4": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/1qqmzgnz/RSConv_MSN_S3DIS.pt", + "rsconv-s3dis-5": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/378enxsu/RSConv_MSN_S3DIS.pt", + "rsconv-s3dis-6": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/23f4upgc/RSConv_MSN_S3DIS.pt", + "kpconv-s3dis-1": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/okiba8gp/KPConvPaper.pt", + "kpconv-s3dis-2": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/2at56wrm/KPConvPaper.pt", + "kpconv-s3dis-3": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/1ipv9lso/KPConvPaper.pt", + "kpconv-s3dis-4": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/2c13jhi0/KPConvPaper.pt", + "kpconv-s3dis-5": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/1kf8yg5s/KPConvPaper.pt", + "kpconv-s3dis-6": "https://api.wandb.ai/files/loicland/benchmark-torch-points-3d-s3dis/2ph7ejss/KPConvPaper.pt", + } + + MOCK_USED_PROPERTIES = { + "pointnet2_largemsg-s3dis-1": {"feature_dimension": 4, "num_classes": 13}, + "pointnet2_largemsg-s3dis-2": {"feature_dimension": 4, "num_classes": 13}, + "pointnet2_largemsg-s3dis-3": {"feature_dimension": 4, "num_classes": 13}, + "pointnet2_largemsg-s3dis-4": {"feature_dimension": 4, "num_classes": 13}, + "pointnet2_largemsg-s3dis-5": {"feature_dimension": 4, "num_classes": 13}, + "pointnet2_largemsg-s3dis-6": {"feature_dimension": 4, "num_classes": 13}, + "pointgroup-scannet": {}, + "rsconv-s3dis-1": {"feature_dimension": 4, "num_classes": 13}, + "rsconv-s3dis-2": {"feature_dimension": 4, "num_classes": 13}, + "rsconv-s3dis-3": {"feature_dimension": 4, "num_classes": 13}, + "rsconv-s3dis-4": {"feature_dimension": 4, "num_classes": 13}, + "rsconv-s3dis-5": {"feature_dimension": 4, "num_classes": 13}, + "rsconv-s3dis-6": {"feature_dimension": 4, "num_classes": 13}, + "minkowski-res16-s3dis-1": {"feature_dimension": 4, "num_classes": 13}, + "minkowski-res16-s3dis-2": {"feature_dimension": 4, "num_classes": 13}, + "minkowski-res16-s3dis-3": {"feature_dimension": 4, "num_classes": 13}, + "minkowski-res16-s3dis-4": {"feature_dimension": 4, "num_classes": 13}, + "minkowski-res16-s3dis-5": {"feature_dimension": 4, "num_classes": 13}, + "minkowski-res16-s3dis-6": {"feature_dimension": 4, "num_classes": 13}, + "minkowski-registration-3dmatch": {"feature_dimension": 1}, + "minkowski-registration-kitti": {"feature_dimension": 1}, + "minkowski-registration-modelnet": {"feature_dimension": 1}, + "kpconv-s3dis-1": {"feature_dimension": 4, "num_classes": 13}, + "kpconv-s3dis-2": {"feature_dimension": 4, "num_classes": 13}, + "kpconv-s3dis-3": {"feature_dimension": 4, "num_classes": 13}, + "kpconv-s3dis-4": {"feature_dimension": 4, "num_classes": 13}, + "kpconv-s3dis-5": {"feature_dimension": 4, "num_classes": 13}, + "kpconv-s3dis-6": {"feature_dimension": 4, "num_classes": 13}, + } + + @staticmethod + def from_pretrained(model_tag, download=True, out_file=None, weight_name="latest", mock_dataset=True): + # Convert inputs to registry format + + if PretainedRegistry.MODELS.get(model_tag) is not None: + url = PretainedRegistry.MODELS.get(model_tag) + else: + raise Exception( + "model_tag {} doesn't exist within available models. Here is the list of pre-trained models {}".format( + model_tag, PretainedRegistry.available_models() + ) + ) + + checkpoint_name = model_tag + ".pt" + out_file = os.path.join(CHECKPOINT_DIR, checkpoint_name) + + if download: + download_file(url, out_file) + + weight_name = weight_name if weight_name is not None else "latest" + + checkpoint: ModelCheckpoint = ModelCheckpoint( + CHECKPOINT_DIR, model_tag, weight_name if weight_name is not None else "latest", resume=False, + ) + if mock_dataset: + dataset = checkpoint.dataset_properties.copy() + if PretainedRegistry.MOCK_USED_PROPERTIES.get(model_tag) is not None: + for k, v in PretainedRegistry.MOCK_USED_PROPERTIES.get(model_tag).items(): + dataset[k] = v + + else: + dataset = instantiate_dataset(checkpoint.data_config) + + model: BaseModel = checkpoint.create_model(dataset, weight_name=weight_name) + + Wandb.set_urls_to_model(model, url) + + BaseDataset.set_transform(model, checkpoint.data_config) + + return model + + @staticmethod + def from_file(path, weight_name="latest", mock_property=None): + """ + Load a pretrained model trained with torch-points3d from file. + return a pretrained model + Parameters + ---------- + path: str + path of a pretrained model + weight_name: str, optional + name of the weight + mock_property: dict, optional + mock dataset + + """ + weight_name = weight_name if weight_name is not None else "latest" + path_dir, name = os.path.split(path) + name = name.split(".")[0] # ModelCheckpoint will add the extension + + checkpoint: ModelCheckpoint = ModelCheckpoint( + path_dir, name, weight_name if weight_name is not None else "latest", resume=False, + ) + dataset = checkpoint.data_config + + if mock_property is not None: + for k, v in mock_property.items(): + dataset[k] = v + + else: + dataset = instantiate_dataset(checkpoint.data_config) + + model: BaseModel = checkpoint.create_model(dataset, weight_name=weight_name) + BaseDataset.set_transform(model, checkpoint.data_config) + return model + + @staticmethod + def available_models(): + return PretainedRegistry.MODELS.keys() diff --git a/torch-points3d/torch_points3d/applications/sparseconv3d.py b/torch-points3d/torch_points3d/applications/sparseconv3d.py new file mode 100644 index 0000000..4be99b5 --- /dev/null +++ b/torch-points3d/torch_points3d/applications/sparseconv3d.py @@ -0,0 +1,208 @@ +import os +import sys +from omegaconf import DictConfig, OmegaConf +import logging +import torch +from torch_geometric.data import Batch + +from torch_points3d.applications.modelfactory import ModelFactory +import torch_points3d.modules.SparseConv3d as sp3d +from torch_points3d.modules.SparseConv3d.modules import * +from torch_points3d.models.base_architectures.unet import UnwrappedUnetBasedModel +from torch_points3d.core.common_modules.base_modules import MLP + +from .utils import extract_output_nc + + +CUR_FILE = os.path.realpath(__file__) +DIR_PATH = os.path.dirname(os.path.realpath(__file__)) +PATH_TO_CONFIG = os.path.join(DIR_PATH, "conf/sparseconv3d") + +log = logging.getLogger(__name__) + + +def SparseConv3d( + architecture: str = None, + input_nc: int = None, + num_layers: int = None, + config: DictConfig = None, + backend: str = "minkowski", + *args, + **kwargs +): + """Create a Sparse Conv backbone model based on architecture proposed in + https://arxiv.org/abs/1904.08755 + + Two backends are available at the moment: + - https://github.com/mit-han-lab/torchsparse + - https://github.com/NVIDIA/MinkowskiEngine + + Parameters + ---------- + architecture : str, optional + Architecture of the model, choose from unet, encoder and decoder + input_nc : int, optional + Number of channels for the input + output_nc : int, optional + If specified, then we add a fully connected head at the end of the network to provide the requested dimension + num_layers : int, optional + Depth of the network + config : DictConfig, optional + Custom config, overrides the num_layers and architecture parameters + block: + Type of resnet block, ResBlock by default but can be any of the blocks in modules/SparseConv3d/modules.py + backend: + torchsparse or minkowski + """ + if "SPARSE_BACKEND" in os.environ and sp3d.nn.backend_valid(os.environ["SPARSE_BACKEND"]): + sp3d.nn.set_backend(os.environ["SPARSE_BACKEND"]) + else: + sp3d.nn.set_backend(backend) + + factory = SparseConv3dFactory( + architecture=architecture, num_layers=num_layers, input_nc=input_nc, config=config, **kwargs + ) + return factory.build() + + +class SparseConv3dFactory(ModelFactory): + def _build_unet(self): + if self._config: + model_config = self._config + else: + path_to_model = os.path.join(PATH_TO_CONFIG, "unet_{}.yaml".format(self.num_layers)) + model_config = OmegaConf.load(path_to_model) + ModelFactory.resolve_model(model_config, self.num_features, self._kwargs) + modules_lib = sys.modules[__name__] + return SparseConv3dUnet(model_config, None, None, modules_lib, **self.kwargs) + + def _build_encoder(self): + if self._config: + model_config = self._config + else: + path_to_model = os.path.join( + PATH_TO_CONFIG, + "encoder_{}.yaml".format(self.num_layers), + ) + model_config = OmegaConf.load(path_to_model) + ModelFactory.resolve_model(model_config, self.num_features, self._kwargs) + modules_lib = sys.modules[__name__] + return SparseConv3dEncoder(model_config, None, None, modules_lib, **self.kwargs) + + +class BaseSparseConv3d(UnwrappedUnetBasedModel): + CONV_TYPE = "sparse" + + def __init__(self, model_config, model_type, dataset, modules, *args, **kwargs): + super().__init__(model_config, model_type, dataset, modules) + self.weight_initialization() + default_output_nc = kwargs.get("default_output_nc", None) + if not default_output_nc: + default_output_nc = extract_output_nc(model_config) + + self._output_nc = default_output_nc + self._has_mlp_head = False + if "output_nc" in kwargs: + self._has_mlp_head = True + self._output_nc = kwargs["output_nc"] + self.mlp = MLP([default_output_nc, self.output_nc], activation=torch.nn.ReLU(), bias=False) + + @property + def has_mlp_head(self): + return self._has_mlp_head + + @property + def output_nc(self): + return self._output_nc + + def weight_initialization(self): + for m in self.modules(): + if isinstance(m, sp3d.nn.Conv3d) or isinstance(m, sp3d.nn.Conv3dTranspose): + torch.nn.init.kaiming_normal_(m.kernel, mode="fan_out", nonlinearity="relu") + + if isinstance(m, sp3d.nn.BatchNorm): + torch.nn.init.constant_(m.bn.weight, 1) + torch.nn.init.constant_(m.bn.bias, 0) + + def _set_input(self, data): + """Unpack input data from the dataloader and perform necessary pre-processing steps. + + Parameters + ----------- + data: + a dictionary that contains the data itself and its metadata information. + """ + self.input = sp3d.nn.SparseTensor(data.x, data.coords, data.batch, self.device) + if data.pos is not None: + self.xyz = data.pos + else: + self.xyz = data.coords + +class SparseConv3dEncoder(BaseSparseConv3d): + def forward(self, data, *args, **kwargs): + """ + Parameters: + ----------- + data + A SparseTensor that contains the data itself and its metadata information. Should contain + F -- Features [N, C] + coords -- Coords [N, 4] + + Returns + -------- + data: + - x [1, output_nc] + + """ + self._set_input(data) + data = self.input + for i in range(len(self.down_modules)): + data = self.down_modules[i](data) + + out = Batch(x=data.F, batch=data.C[:, -1].long().to(data.F.device)) + if not isinstance(self.inner_modules[0], Identity): + out = self.inner_modules[0](out) + + if self.has_mlp_head: + out.x = self.mlp(out.x) + return out + + +class SparseConv3dUnet(BaseSparseConv3d): + def forward(self, data, *args, **kwargs): + """Run forward pass. + Input --- D1 -- D2 -- D3 -- U1 -- U2 -- output + | |_________| | + |______________________| + + Parameters + ----------- + data + A SparseTensor that contains the data itself and its metadata information. Should contain + F -- Features [N, C] + coords -- Coords [N, 4] + + Returns + -------- + data: + - pos [N, 3] (coords or real pos if xyz is in data) + - x [N, output_nc] + - batch [N] + """ + self._set_input(data) + data = self.input + stack_down = [] + for i in range(len(self.down_modules) - 1): + data = self.down_modules[i](data) + stack_down.append(data) + + data = self.down_modules[-1](data) + stack_down.append(None) + # TODO : Manage the inner module + for i in range(len(self.up_modules)): + data = self.up_modules[i](data, stack_down.pop()) + + out = Batch(x=data.F, pos=self.xyz).to(self.device) + if self.has_mlp_head: + out.x = self.mlp(out.x) + return out diff --git a/torch-points3d/torch_points3d/applications/utils.py b/torch-points3d/torch_points3d/applications/utils.py new file mode 100644 index 0000000..c79e310 --- /dev/null +++ b/torch-points3d/torch_points3d/applications/utils.py @@ -0,0 +1,10 @@ +def extract_output_nc(model_config): + """ Extracts the number of channels at the output of the network form the model config + """ + if model_config.get('up_conv') is not None: + output_nc = model_config.up_conv.up_conv_nn[-1][-1] + elif model_config.get('innermost') is not None: + output_nc = model_config.innermost.nn[-1] + else: + raise ValueError("Input model_config does not match expected pattern") + return output_nc diff --git a/torch-points3d/torch_points3d/core/__init__.py b/torch-points3d/torch_points3d/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/core/common_modules/__init__.py b/torch-points3d/torch_points3d/core/common_modules/__init__.py new file mode 100644 index 0000000..cc11ef7 --- /dev/null +++ b/torch-points3d/torch_points3d/core/common_modules/__init__.py @@ -0,0 +1,2 @@ +from .base_modules import * +from .spatial_transform import * diff --git a/torch-points3d/torch_points3d/core/common_modules/base_modules.py b/torch-points3d/torch_points3d/core/common_modules/base_modules.py new file mode 100644 index 0000000..5669a86 --- /dev/null +++ b/torch-points3d/torch_points3d/core/common_modules/base_modules.py @@ -0,0 +1,166 @@ +import numpy as np +import torch +from torch import nn +from torch.nn.parameter import Parameter + +import torch_points3d.models.instance.semi_supervised_helper + + +class BaseModule(nn.Module): + """ Base module class with some basic additions to the pytorch Module class + """ + + @property + def nb_params(self): + """This property is used to return the number of trainable parameters for a given layer + It is useful for debugging and reproducibility. + """ + model_parameters = filter(lambda p: p.requires_grad, self.parameters()) + self._nb_params = sum([np.prod(p.size()) for p in model_parameters]) + return self._nb_params + + +def weight_variable(shape): + initial = torch.empty(shape, dtype=torch.float) + torch.nn.init.xavier_normal_(initial) + return initial + + +class Identity(BaseModule): + def __init__(self): + super(Identity, self).__init__() + + def forward(self, data): + return data + + +def MLP(channels, activation=nn.LeakyReLU(0.2), bn_momentum=0.1, bias=True): + return nn.Sequential( + *[ + nn.Sequential( + nn.Linear(channels[i - 1], channels[i], bias=bias), + FastBatchNorm1d(channels[i], momentum=bn_momentum), + activation, + ) + for i in range(1, len(channels)) + ] + ) + + +class UnaryConv(BaseModule): + def __init__(self, kernel_shape): + """ + 1x1 convolution on point cloud (we can even call it a mini pointnet) + """ + super(UnaryConv, self).__init__() + self.weight = Parameter(weight_variable(kernel_shape)) + + def forward(self, features): + """ + features(Torch Tensor): size N x d d is the size of inputs + """ + return torch.matmul(features, self.weight) + + def __repr__(self): + return "UnaryConv {}".format(self.weight.shape) + + +class MultiHeadClassifier(BaseModule): + """ Allows segregated segmentation in case the category of an object is known. This is the case in ShapeNet + for example. + + Arguments: + in_features -- size of the input channel + cat_to_seg {[type]} -- category to segment maps for example: + { + 'Airplane': [0,1,2], + 'Table': [3,4] + } + + Keyword Arguments: + dropout_proba (default: {0.5}) + bn_momentum -- batch norm momentum (default: {0.1}) + """ + + def __init__(self, in_features, cat_to_seg, dropout_proba=0.5, bn_momentum=0.1): + super().__init__() + self._cat_to_seg = {} + self._num_categories = len(cat_to_seg) + self._max_seg_count = 0 + self._max_seg = 0 + self._shifts = torch.zeros((self._num_categories,), dtype=torch.long) + for i, seg in enumerate(cat_to_seg.values()): + self._max_seg_count = max(self._max_seg_count, len(seg)) + self._max_seg = max(self._max_seg, max(seg)) + self._shifts[i] = min(seg) + self._cat_to_seg[i] = seg + + self.channel_rasing = MLP( + [in_features, self._num_categories * in_features], bn_momentum=bn_momentum, bias=False + ) + if dropout_proba: + self.channel_rasing.add_module("Dropout", nn.Dropout(p=dropout_proba)) + + self.classifier = UnaryConv((self._num_categories, in_features, self._max_seg_count)) + self._bias = Parameter(torch.zeros(self._max_seg_count,)) + + def forward(self, features, category_labels, **kwargs): + assert features.dim() == 2 + self._shifts = self._shifts.to(features.device) + in_dim = features.shape[-1] + features = self.channel_rasing(features) + features = features.reshape((-1, self._num_categories, in_dim)) + features = features.transpose(0, 1) # [num_categories, num_points, in_dim] + features = self.classifier(features) + self._bias # [num_categories, num_points, max_seg] + ind = category_labels.unsqueeze(-1).repeat(1, 1, features.shape[-1]).long() + + logits = torch_points3d.models.instance.semi_supervised_helper.gather(0, ind).squeeze(0) + softmax = torch.nn.functional.log_softmax(logits, dim=-1) + + output = torch.zeros(logits.shape[0], self._max_seg + 1).to(features.device) + cats_in_batch = torch.unique(category_labels) + for cat in cats_in_batch: + cat_mask = category_labels == cat + seg_indices = self._cat_to_seg[cat.item()] + probs = softmax[cat_mask, : len(seg_indices)] + output[cat_mask, seg_indices[0] : seg_indices[-1] + 1] = probs + return output + + +class FastBatchNorm1d(BaseModule): + def __init__(self, num_features, momentum=0.1, **kwargs): + super().__init__() + self.batch_norm = nn.BatchNorm1d(num_features, momentum=momentum, **kwargs) + + def _forward_dense(self, x): + return self.batch_norm(x.permute(0, 2, 1)).permute(0, 2, 1) + + def _forward_sparse(self, x): + """ Batch norm 1D is not optimised for 2D tensors. The first dimension is supposed to be + the batch and therefore not very large. So we introduce a custom version that leverages BatchNorm1D + in a more optimised way + """ + x = x.unsqueeze(2) + x = x.transpose(0, 2) + x = self.batch_norm(x) + x = x.transpose(0, 2) + return x.squeeze(dim=2) + + def forward(self, x): + if x.dim() == 2: + return self._forward_sparse(x) + elif x.dim() == 3: + return self._forward_dense(x) + else: + raise ValueError("Non supported number of dimensions {}".format(x.dim())) + + +class Seq(nn.Sequential): + def __init__(self): + super().__init__() + self._num_modules = 0 + + def append(self, module): + self.add_module(str(self._num_modules), module) + self._num_modules += 1 + return self diff --git a/torch-points3d/torch_points3d/core/common_modules/dense_modules.py b/torch-points3d/torch_points3d/core/common_modules/dense_modules.py new file mode 100644 index 0000000..5cb3046 --- /dev/null +++ b/torch-points3d/torch_points3d/core/common_modules/dense_modules.py @@ -0,0 +1,29 @@ +import torch.nn as nn +from .base_modules import Seq + + +class Conv2D(Seq): + def __init__(self, in_channels, out_channels, bias=True, bn=True, activation=nn.LeakyReLU(negative_slope=0.01)): + super().__init__() + self.append(nn.Conv2d(in_channels, out_channels, kernel_size=(1, 1), stride=(1, 1), bias=bias)) + if bn: + self.append(nn.BatchNorm2d(out_channels)) + if activation: + self.append(activation) + + +class Conv1D(Seq): + def __init__(self, in_channels, out_channels, bias=True, bn=True, activation=nn.LeakyReLU(negative_slope=0.01)): + super().__init__() + self.append(nn.Conv1d(in_channels, out_channels, kernel_size=1, bias=bias)) + if bn: + self.append(nn.BatchNorm1d(out_channels)) + if activation: + self.append(activation) + + +class MLP2D(Seq): + def __init__(self, channels, bias=False, bn=True, activation=nn.LeakyReLU(negative_slope=0.01)): + super().__init__() + for i in range(len(channels) - 1): + self.append(Conv2D(channels[i], channels[i + 1], bn=bn, bias=bias, activation=activation)) diff --git a/torch-points3d/torch_points3d/core/common_modules/gathering.py b/torch-points3d/torch_points3d/core/common_modules/gathering.py new file mode 100644 index 0000000..98d1c39 --- /dev/null +++ b/torch-points3d/torch_points3d/core/common_modules/gathering.py @@ -0,0 +1,36 @@ +import torch_points3d.models.instance.semi_supervised_helper + + +def gather(x, idx, method=2): + """ + https://github.com/pytorch/pytorch/issues/15245 + implementation of a custom gather operation for faster backwards. + :param x: input with shape [N, D_1, ... D_d] + :param idx: indexing with shape [n_1, ..., n_m] + :param method: Choice of the method + :return: x[idx] with shape [n_1, ..., n_m, D_1, ... D_d] + """ + idx[idx == -1] = x.shape[0] - 1 # Shadow point + if method == 0: + return x[idx] + elif method == 1: + x = x.unsqueeze(1) + x = x.expand((-1, idx.shape[-1], -1)) + idx = idx.unsqueeze(2) + idx = idx.expand((-1, -1, x.shape[-1])) + return torch_points3d.models.instance.semi_supervised_helper.gather(0, idx) + elif method == 2: + for i, ni in enumerate(idx.size()[1:]): + x = x.unsqueeze(i + 1) + new_s = list(x.size()) + new_s[i + 1] = ni + x = x.expand(new_s) + n = len(idx.size()) + for i, di in enumerate(x.size()[n:]): + idx = idx.unsqueeze(i + n) + new_s = list(idx.size()) + new_s[i + n] = di + idx = idx.expand(new_s) + return x.gather(0, idx) + else: + raise ValueError("Unknown method") diff --git a/torch-points3d/torch_points3d/core/common_modules/spatial_transform.py b/torch-points3d/torch_points3d/core/common_modules/spatial_transform.py new file mode 100644 index 0000000..712b36c --- /dev/null +++ b/torch-points3d/torch_points3d/core/common_modules/spatial_transform.py @@ -0,0 +1,65 @@ +import torch +from torch.nn import Linear + + +class BaseLinearTransformSTNkD(torch.nn.Module): + """STN which learns a k-dimensional linear transformation + + Arguments: + nn (torch.nn.Module) -- module which takes feat_x as input and regresses it to a global feature used to calculate the transform + nn_feat_size -- the size of the global feature + k -- the size of trans_x + batch_size -- the number of examples per batch + """ + + def __init__(self, nn, nn_feat_size, k=3, batch_size=1): + super().__init__() + + self.nn = nn + self.k = k + self.batch_size = batch_size + + # fully connected layer to regress the global feature to a k-d linear transform + # the transform is initialized to the identity + self.fc_layer = Linear(nn_feat_size, k * k) + torch.nn.init.constant_(self.fc_layer.weight, 0) + torch.nn.init.constant_(self.fc_layer.bias, 0) + self.identity = torch.eye(k).view(1, k * k).repeat(batch_size, 1) + + def forward(self, feat_x, trans_x, batch): + """ + Learns and applies a linear transformation to trans_x based on feat_x. + feat_x and trans_x may be the same or different. + """ + global_feature = self.nn(feat_x, batch) + trans = self.fc_layer(global_feature) + + # needed so that transform is initialized to identity + trans = trans + self.identity.to(feat_x.device) + trans = trans.view(-1, self.k, self.k) + self.trans = trans + + # convert trans_x from (N, K) to (B, N, K) to do batched matrix multiplication + # batch_x = trans_x.view(self.batch_size, -1, trans_x.shape[1]) + if trans_x.squeeze().dim() == 2: + batch_x = trans_x.view(trans_x.shape[0], 1, trans_x.shape[1]) + x_transformed = torch.bmm(batch_x[:, :, :trans.shape[-1]], trans[batch]) + if batch_x.shape[-1] > trans.shape[-1]: + x_transformed = torch.cat([x_transformed, batch_x[:, :, trans.shape[-1]:]], dim=-1) + return x_transformed.view(len(trans_x), trans_x.shape[1]) + else: + x_transformed = torch.bmm(trans_x[:, :, :trans.shape[-1]], trans) + if trans_x.shape[-1] > trans.shape[-1]: + x_transformed = torch.cat([x_transformed, trans_x[:, :, trans.shape[-1]:]], dim=-1) + return x_transformed + + def get_orthogonal_regularization_loss(self): + loss = torch.mean( + torch.norm( + torch.bmm(self.trans, self.trans.transpose(2, 1)) + - self.identity.to(self.trans.device).view(-1, self.k, self.k), + dim=(1, 2), + ) + ) + + return loss diff --git a/torch-points3d/torch_points3d/core/data_transform/__init__.py b/torch-points3d/torch_points3d/core/data_transform/__init__.py new file mode 100644 index 0000000..44aa58a --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/__init__.py @@ -0,0 +1,249 @@ +import sys + +import numpy as np +import torch_geometric.transforms as T +from .transforms import * +from .grid_transform import * +from .sparse_transforms import * +from .inference_transforms import * +from .feature_augment import * +from .features import * +from .filters import * +from .precollate import * +from .prebatchcollate import * +from omegaconf.dictconfig import DictConfig +from omegaconf.listconfig import ListConfig +from omegaconf import OmegaConf + +_custom_transforms = sys.modules[__name__] +_torch_geometric_transforms = sys.modules["torch_geometric.transforms"] +_intersection_names = set(_custom_transforms.__dict__) & set(_torch_geometric_transforms.__dict__) +_intersection_names = set([module for module in _intersection_names if not module.startswith("_")]) +L_intersection_names = len(_intersection_names) > 0 +_intersection_cls = [] + +for transform_name in _intersection_names: + transform_cls = getattr(_custom_transforms, transform_name) + if not "torch_geometric.transforms." in str(transform_cls): + _intersection_cls.append(transform_cls) +L_intersection_cls = len(_intersection_cls) > 0 + +if L_intersection_names: + if L_intersection_cls: + raise Exception( + "It seems that you are overriding a transform from pytorch gemetric, \ + this is forbidden, please rename your classes {} from {}".format( + _intersection_names, _intersection_cls + ) + ) + else: + raise Exception( + "It seems you are importing transforms {} from pytorch geometric within the current code base. \ + Please, remove them or add them within a class, function, etc.".format( + _intersection_names + ) + ) + + +def instantiate_transform(transform_option, attr="transform"): + """ Creates a transform from an OmegaConf dict such as + transform: GridSampling3D + params: + size: 0.01 + """ + tr_name = getattr(transform_option, attr, None) + try: + # tr_params = transform_option.params + tr_params = transform_option.get('params') # Update to OmegaConf 2.0 + except KeyError: + tr_params = None + try: + # lparams = transform_option.lparams + lparams = transform_option.get('lparams') # Update to OmegaConf 2.0 + except KeyError: + lparams = None + + cls = getattr(_custom_transforms, tr_name, None) + if not cls: + cls = getattr(_torch_geometric_transforms, tr_name, None) + if not cls: + raise ValueError("Transform %s is nowhere to be found" % tr_name) + + if tr_params and lparams: + return cls(*lparams, **tr_params) + + if tr_params: + return cls(**tr_params) + + if lparams: + return cls(*lparams) + + return cls() + + +def instantiate_transforms(transform_options): + """ Creates a torch_geometric composite transform from an OmegaConf list such as + - transform: GridSampling3D + params: + size: 0.01 + - transform: NormaliseScale + """ + transforms = [] + for transform in transform_options: + transforms.append(instantiate_transform(transform)) + return T.Compose(transforms) + + +def instantiate_filters(filter_options): + filters = [] + for filt in filter_options: + filters.append(instantiate_transform(filt, "filter")) + return FCompose(filters) + + +class LotteryTransform(object): + """ + Transforms which draw a transform randomly among several transforms indicated in transform options + Examples + + Parameters + ---------- + transform_options Omegaconf list which contains the transform + """ + + def __init__(self, transform_options): + self.random_transforms = instantiate_transforms(transform_options) + + def __call__(self, data): + list_transforms = self.random_transforms.transforms + i = np.random.randint(len(list_transforms)) + transform = list_transforms[i] + return transform(data) + + def __repr__(self): + rep = "LotteryTransform([" + for trans in self.random_transforms.transforms: + rep = rep + "{}, ".format(trans.__repr__()) + rep = rep + "])" + return rep + + +class ComposeTransform(object): + """ + Transform to compose other transforms with YAML (Compose of torch_geometric does not work). + Example : + .. code-block:: yaml + + - transform: ComposeTransform + params: + transform_options: + - transform: GridSampling3D + params: + size: 0.1 + - transform: RandomNoise + params: + sigma: 0.05 + + + Parameters: + transform_options: Omegaconf Dict + contains a list of transform + """ + + def __init__(self, transform_options): + self.transform = instantiate_transforms(transform_options) + + def __call__(self, data): + return self.transform(data) + + def __repr__(self): + rep = "ComposeTransform([" + for trans in self.transform.transforms: + rep = rep + "{}, ".format(trans.__repr__()) + rep = rep + "])" + return rep + + +class RandomParamTransform(object): + """ + create a transform with random parameters + + Example (on the yaml) + + .. code-block:: yaml + + transform: RandomParamTransform + params: + transform_name: GridSampling3D + transform_params: + size: + min: 0.1 + max: 0.3 + type: "float" + mode: + value: "last" + + + We can also draw random numbers for two parameters, integer or float + + .. code-block:: yaml + + transform: RandomParamTransform + params: + transform_name: RandomSphereDropout + transform_params: + radius: + min: 1 + max: 2 + type: "float" + num_sphere: + min: 1 + max: 5 + type: "int" + + + Parameters + ---------- + transform_name: string: + the name of the transform + transform_options: Omegaconf Dict + contains the name of a variables as a key and min max type as value to specify the range of the parameters and + the type of the parameters or it contains the value "value" to specify a variables (see Example above) + + """ + + def __init__(self, transform_name, transform_params): + self.transform_name = transform_name + self.transform_params = transform_params + self.random_transform = self._instanciate_transform_with_random_params() + + def _instanciate_transform_with_random_params(self): + dico = dict() + for p, rang in self.transform_params.items(): + if "max" in rang and "min" in rang: + assert rang["max"] - rang["min"] > 0 + v = np.random.random() * (rang["max"] - rang["min"]) + rang["min"] + + if rang["type"] == "float": + v = float(v) + elif rang["type"] == "int": + v = int(v) + else: + raise NotImplementedError + dico[p] = v + elif "value" in rang: + v = rang["value"] + dico[p] = v + else: + raise NotImplementedError + + trans_opt = DictConfig(dict(params=dico, transform=self.transform_name)) + random_transform = instantiate_transform(trans_opt, attr="transform") + return random_transform + + def __call__(self, data): + self.random_transform = self._instanciate_transform_with_random_params() + return self.random_transform(data) + + def __repr__(self): + return "RandomParamTransform({}, params={})".format(self.transform_name, self.transform_params) diff --git a/torch-points3d/torch_points3d/core/data_transform/feature_augment.py b/torch-points3d/torch_points3d/core/data_transform/feature_augment.py new file mode 100644 index 0000000..3c856c5 --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/feature_augment.py @@ -0,0 +1,164 @@ +import random +import torch + +# Those Transformation are adapted from https://github.com/chrischoy/SpatioTemporalSegmentation/blob/master/lib/transforms.py + + +class NormalizeRGB(object): + """Normalize rgb between 0 and 1 + + Parameters + ---------- + normalize: bool: Whether to normalize the rgb attributes + """ + + def __init__(self, normalize=True): + self._normalize = normalize + + def __call__(self, data): + assert hasattr(data, "rgb") + if not (data.rgb.max() <= 1 and data.rgb.min() >= 0): + data.rgb = data.rgb.float() / 255.0 + return data + + def __repr__(self): + return "{}({})".format(self.__class__.__name__, self._normalize) + + +class ChromaticTranslation(object): + """Add random color to the image, data must contain an rgb attribute between 0 and 1 + + Parameters + ---------- + trans_range_ratio: + ratio of translation i.e. tramnslation = 2 * ratio * rand(-0.5, 0.5) (default: 1e-1) + """ + + def __init__(self, trans_range_ratio=1e-1): + self.trans_range_ratio = trans_range_ratio + + def __call__(self, data): + assert hasattr(data, "rgb") + assert data.rgb.max() <= 1 and data.rgb.min() >= 0 + if random.random() < 0.95: + tr = (torch.rand(1, 3) - 0.5) * 2 * self.trans_range_ratio + data.rgb = torch.clamp(tr + data.rgb, 0, 1) + return data + + def __repr__(self): + return "{}(trans_range_ratio={})".format(self.__class__.__name__, self.trans_range_ratio) + + +class ChromaticAutoContrast(object): + """ Rescale colors between 0 and 1 to enhance contrast + + Parameters + ---------- + randomize_blend_factor : + Blend factor is random + blend_factor: + Ratio of the original color that is kept + """ + + def __init__(self, randomize_blend_factor=True, blend_factor=0.5): + self.randomize_blend_factor = randomize_blend_factor + self.blend_factor = blend_factor + + def __call__(self, data): + assert hasattr(data, "rgb") + assert data.rgb.max() <= 1 and data.rgb.min() >= 0 + if random.random() < 0.2: + feats = data.rgb + lo = feats.min(0, keepdims=True)[0] + hi = feats.max(0, keepdims=True)[0] + assert hi.max() > 0, "invalid color value. Color is supposed to be [0-255]" + + scale = 1.0 / (hi - lo) + + contrast_feats = (feats - lo) * scale + + blend_factor = random.random() if self.randomize_blend_factor else self.blend_factor + data.rgb = (1 - blend_factor) * feats + blend_factor * contrast_feats + return data + + def __repr__(self): + return "{}(randomize_blend_factor={}, blend_factor={})".format( + self.__class__.__name__, self.randomize_blend_factor, self.blend_factor + ) + + +class ChromaticJitter: + """ Jitter on the rgb attribute of data + + Parameters + ---------- + std : + standard deviation of the Jitter + """ + + def __init__(self, std=0.01): + self.std = std + + def __call__(self, data): + assert hasattr(data, "rgb") + assert data.rgb.max() <= 1 and data.rgb.min() >= 0 + if random.random() < 0.95: + noise = torch.randn(data.rgb.shape[0], 3) + noise *= self.std + data.rgb = torch.clamp(noise + data.rgb, 0, 1) + return data + + def __repr__(self): + return "{}(std={})".format(self.__class__.__name__, self.std) + + +class DropFeature: + """ Sets the given feature to 0 with a given probability + + Parameters + ---------- + drop_proba: + Probability that the feature gets dropped + feature_name: + Name of the feature to drop + """ + + def __init__(self, drop_proba=0.2, feature_name="rgb"): + self._drop_proba = drop_proba + self._feature_name = feature_name + + def __call__(self, data): + assert hasattr(data, self._feature_name) + if random.random() < self._drop_proba: + data[self._feature_name] = data[self._feature_name] * 0 + return data + + def __repr__(self): + return "DropFeature: proba = {}, feature = {}".format(self._drop_proba, self._feature_name) + + +class Jitter: + """ + add a small gaussian noise to the feature. + Parameters + ---------- + mu: float + mean of the gaussian noise + sigma: float + standard deviation of the gaussian noise + p: float + probability of noise + """ + + def __init__(self, mu=0, sigma=0.01, p=0.95): + self.mu = mu + self.sigma = sigma + self.p = p + + def __call__(self, data): + if random.random() < self.p: + data.x += torch.randn_like(data.x) * self.sigma + self.mu + return data + + def __repr__(self): + return "Jitter(mu={}, sigma={})".format(self.mu, self.sigma) diff --git a/torch-points3d/torch_points3d/core/data_transform/features.py b/torch-points3d/torch_points3d/core/data_transform/features.py new file mode 100644 index 0000000..ada3f1f --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/features.py @@ -0,0 +1,386 @@ +import random +from typing import List, Optional + +import numpy as np +import torch +from torch.nn import PairwiseDistance +from torch_geometric.data import Data + +from torch_points3d.utils.geometry import euler_angles_to_rotation_matrix + + +class Random3AxisRotation(object): + """ + Rotate pointcloud with random angles along x, y, z axis + + The angles should be given `in degrees`. + + Parameters + ----------- + apply_rotation: bool: + Whether to apply the rotation + rot_x: float + Rotation angle in degrees on x axis + rot_y: float + Rotation anglei n degrees on y axis + rot_z: float + Rotation angle in degrees on z axis + """ + + def __init__(self, apply_rotation: bool = True, rot_x: float = None, rot_y: float = None, rot_z: float = None, + p: float = None): + self._apply_rotation = apply_rotation + if apply_rotation: + if (rot_x is None) and (rot_y is None) and (rot_z is None): + raise Exception("At least one rot_ should be defined") + + self._rot_x = np.abs(min(rot_x, 180)) if rot_x else 0 + self._rot_y = np.abs(min(rot_y, 180)) if rot_y else 0 + self._rot_z = np.abs(min(rot_z, 180)) if rot_z else 0 + self._p = 1 if p is None else p + + self._degree_angles = [self._rot_x, self._rot_y, self._rot_z] + + def generate_random_rotation_matrix(self): + thetas = torch.zeros(3, dtype=torch.float) + for axis_ind, deg_angle in enumerate(self._degree_angles): + if deg_angle > 0 and random.random() < self._p: + rand_deg_angle = random.random() * 2 * deg_angle - deg_angle + rand_radian_angle = float(rand_deg_angle * np.pi) / 180.0 + thetas[axis_ind] = rand_radian_angle + return euler_angles_to_rotation_matrix(thetas, random_order=True) + + def __call__(self, data): + if self._apply_rotation: + pos = data.pos.float() + M = self.generate_random_rotation_matrix() + data.pos = pos @ M.T + if getattr(data, "norm", None) is not None: + data.norm = data.norm.float() @ M.T + return data + + def __repr__(self): + return "{}(apply_rotation={}, rot_x={}, rot_y={}, rot_z={})".format( + self.__class__.__name__, self._apply_rotation, self._rot_x, self._rot_y, self._rot_z + ) + + +class RandomTranslation(object): + """ + random translation + Parameters + ----------- + delta_min: list + min translation + delta_max: list + max translation + """ + + def __init__(self, delta_max: List = [1.0, 1.0, 1.0], delta_min: List = [-1.0, -1.0, -1.0]): + self.delta_max = torch.tensor(delta_max) + self.delta_min = torch.tensor(delta_min) + + def __call__(self, data): + pos = data.pos + trans = torch.rand(3) * (self.delta_max - self.delta_min) + self.delta_min + data.pos = pos + trans + return data + + def __repr__(self): + return "{}(delta_min={}, delta_max={})".format(self.__class__.__name__, self.delta_min, self.delta_max) + + +class AddFeatsByKeys(object): + """This transform takes a list of attributes names and if allowed, add them to x + + Example: + + Before calling "AddFeatsByKeys", if data.x was empty + + - transform: AddFeatsByKeys + params: + list_add_to_x: [False, True, True] + feat_names: ['normal', 'rgb', "elevation"] + input_nc_feats: [3, 3, 1] + + After calling "AddFeatsByKeys", data.x contains "rgb" and "elevation". Its shape[-1] == 4 (rgb:3 + elevation:1) + If input_nc_feats was [4, 4, 1], it would raise an exception as rgb dimension is only 3. + + Paremeters + ---------- + list_add_to_x: List[bool] + For each boolean within list_add_to_x, control if the associated feature is going to be concatenated to x + feat_names: List[str] + The list of features within data to be added to x + input_nc_feats: List[int], optional + If provided, evaluate the dimension of the associated feature shape[-1] found using feat_names and this provided value. It allows to make sure feature dimension didn't change + stricts: List[bool], optional + Recommended to be set to list of True. If True, it will raise an Exception if feat isn't found or dimension doesn t match. + delete_feats: List[bool], optional + Wether we want to delete the feature from the data object. List length must match teh number of features added. + """ + + def __init__( + self, + list_add_to_x: List[bool], + feat_names: List[str], + input_nc_feats: List[Optional[int]] = None, + stricts: List[bool] = None, + delete_feats: List[bool] = None, + ): + + self._feat_names = feat_names + self._list_add_to_x = list_add_to_x + self._delete_feats = delete_feats + if self._delete_feats: + assert len(self._delete_feats) == len(self._feat_names) + from torch_geometric.transforms import Compose + + num_names = len(feat_names) + if num_names == 0: + raise Exception("Expected to have at least one feat_names") + + assert len(list_add_to_x) == num_names + + if input_nc_feats: + assert len(input_nc_feats) == num_names + else: + input_nc_feats = [None for _ in range(num_names)] + + if stricts: + assert len(stricts) == num_names + else: + stricts = [True for _ in range(num_names)] + + transforms = [ + AddFeatByKey(add_to_x, feat_name, input_nc_feat=input_nc_feat, strict=strict) + for add_to_x, feat_name, input_nc_feat, strict in zip(list_add_to_x, feat_names, input_nc_feats, stricts) + ] + + self.transform = Compose(transforms) + + def __call__(self, data): + data = self.transform(data) + if self._delete_feats: + for feat_name, delete_feat in zip(self._feat_names, self._delete_feats): + if delete_feat: + delattr(data, feat_name) + return data + + def __repr__(self): + msg = "" + for f, a in zip(self._feat_names, self._list_add_to_x): + msg += "{}={}, ".format(f, a) + return "{}({})".format(self.__class__.__name__, msg[:-2]) + + +class AddFeatByKey(object): + """This transform is responsible to get an attribute under feat_name and add it to x if add_to_x is True + + Paremeters + ---------- + add_to_x: bool + Control if the feature is going to be added/concatenated to x + feat_name: str + The feature to be found within data to be added/concatenated to x + input_nc_feat: int, optional + If provided, check if feature last dimension maches provided value. + strict: bool, optional + Recommended to be set to True. If False, it won't break if feat isn't found or dimension doesn t match. (default: ``True``) + """ + + def __init__(self, add_to_x, feat_name, input_nc_feat=None, strict=True): + + self._add_to_x: bool = add_to_x + self._feat_name: str = feat_name + self._input_nc_feat = input_nc_feat + self._strict: bool = strict + + def __call__(self, data: Data): + if not self._add_to_x: + return data + feat = getattr(data, self._feat_name, None) + if feat is None: + if self._strict: + raise Exception("Data should contain the attribute {}".format(self._feat_name)) + else: + return data + else: + if self._input_nc_feat: + feat_dim = 1 if feat.dim() == 1 else feat.shape[-1] + if self._input_nc_feat != feat_dim and self._strict: + raise Exception("The shape of feat: {} doesn t match {}".format(feat.shape, self._input_nc_feat)) + x = getattr(data, "x", None) + if x is None: + if self._strict and data.pos.shape[0] != feat.shape[0]: + raise Exception("We expected to have an attribute x") + if feat.dim() == 1: + feat = feat.unsqueeze(-1) + data.x = feat + else: + if x.shape[0] == feat.shape[0]: + if x.dim() == 1: + x = x.unsqueeze(-1) + if feat.dim() == 1: + feat = feat.unsqueeze(-1) + data.x = torch.cat([x, feat], dim=-1) + else: + raise Exception( + "The tensor x and {} can't be concatenated, x: {}, feat: {}".format( + self._feat_name, x.pos.shape[0], feat.pos.shape[0] + ) + ) + return data + + def __repr__(self): + return "{}(add_to_x: {}, feat_name: {}, strict: {})".format( + self.__class__.__name__, self._add_to_x, self._feat_name, self._strict + ) + + +def compute_planarity(eigenvalues): + r""" + compute the planarity with respect to the eigenvalues of the covariance matrix of the pointcloud + let + :math:`\lambda_1, \lambda_2, \lambda_3` be the eigenvalues st: + + .. math:: + \lambda_1 \leq \lambda_2 \leq \lambda_3 + + then planarity is defined as: + + .. math:: + planarity = \frac{\lambda_2 - \lambda_1}{\lambda_3} + """ + + return (eigenvalues[1] - eigenvalues[0]) / eigenvalues[2] + + +class NormalFeature(object): + """ + add normal as feature. if it doesn't exist, compute normals + using PCA + """ + + def __call__(self, data): + if getattr(data, "norm", None) is None: + raise NotImplementedError("TODO: Implement normal computation") + + norm = data.norm + if data.x is None: + data.x = norm + else: + data.x = torch.cat([data.x, norm], -1) + return data + + +class PCACompute(object): + r""" + compute `Principal Component Analysis `__ of a point cloud :math:`x_1,\dots, x_n`. + It computes the eigenvalues and the eigenvectors of the matrix :math:`C` which is the covariance matrix of the point cloud: + + .. math:: + x_{centered} &= \frac{1}{n} \sum_{i=1}^n x_i + + C &= \frac{1}{n} \sum_{i=1}^n (x_i - x_{centered})(x_i - x_{centered})^T + + store the eigen values and the eigenvectors in data. + in eigenvalues attribute and eigenvectors attributes. + data.eigenvalues is a tensor :math:`(\lambda_1, \lambda_2, \lambda_3)` such that :math:`\lambda_1 \leq \lambda_2 \leq \lambda_3`. + + data.eigenvectors is a 3 x 3 matrix such that the column are the eigenvectors associated to their eigenvalues + Therefore, the first column of data.eigenvectors estimates the normal at the center of the pointcloud. + """ + + def __call__(self, data): + pos_centered = data.pos - data.pos.mean(axis=0) + cov_matrix = pos_centered.T.mm(pos_centered) / len(pos_centered) + eig, v = torch.symeig(cov_matrix, eigenvectors=True) + data.eigenvalues = eig + data.eigenvectors = v + return data + + def __repr__(self): + return "{}()".format(self.__class__.__name__) + + +class AddOnes(object): + """ + Add ones tensor to data + """ + + def __call__(self, data): + num_nodes = data.pos.shape[0] + data.ones = torch.ones((num_nodes, 1)).float() + return data + + def __repr__(self): + return "{}()".format(self.__class__.__name__) + + +class AddXYDistanceToCenter(object): + """ + Distance to a certain point (center) + """ + + def __init__(self, center_x: float, center_y: float): + self.pdist = PairwiseDistance() + self.center: torch.Tensor = torch.tensor([[center_x, center_y]]) + + def __call__(self, data): + pos = data.pos[:, :2] + + data.xy_distance = self.pdist(pos, self.center.repeat_interleave(pos.shape[0], dim=0)) + return data + + def __repr__(self): + return "{}(center_x: {}, center_y: {})".format(self.__class__.__name__, self.center[0, 0], self.center[0, 1]) + + +class AddZDistanceToTop(object): + """ + Add distance to top of the point cloud (99 quantile) + """ + + def __call__(self, data): + pos = data.pos[:, [2]] + highest_point = torch.quantile(pos, 0.99, keepdim=True) + + data.z_distance_to_top = -(pos - highest_point) + return data + + +class XYZFeature(object): + """ + Add the X, Y and Z as a feature + Parameters + ----------- + add_x: bool [default: False] + whether we add the x position or not + add_y: bool [default: False] + whether we add the y position or not + add_z: bool [default: True] + whether we add the z position or not + """ + + def __init__(self, add_x=False, add_y=False, add_z=True): + self._axis = [] + axis_names = ["x", "y", "z"] + if add_x: + self._axis.append(0) + if add_y: + self._axis.append(1) + if add_z: + self._axis.append(2) + + self._axis_names = [axis_names[idx_axis] for idx_axis in self._axis] + + def __call__(self, data): + assert data.pos is not None + for axis_name, id_axis in zip(self._axis_names, self._axis): + f = data.pos[:, id_axis].clone() + setattr(data, "pos_{}".format(axis_name), f) + return data + + def __repr__(self): + return "{}(axis={})".format(self.__class__.__name__, self._axis_names) diff --git a/torch-points3d/torch_points3d/core/data_transform/filters.py b/torch-points3d/torch_points3d/core/data_transform/filters.py new file mode 100644 index 0000000..eaba8cd --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/filters.py @@ -0,0 +1,138 @@ +import random + +import numpy as np +import torch + +from torch_points3d.core.data_transform.features import PCACompute, compute_planarity + + +class FCompose(object): + """ + allow to compose different filters using the boolean operation + + Parameters + ---------- + list_filter: list + list of different filter functions we want to apply + boolean_operation: function, optional + boolean function to compose the filter (take a pair and return a boolean) + """ + + def __init__(self, list_filter, boolean_operation=np.logical_and): + self.list_filter = list_filter + self.boolean_operation = boolean_operation + + def __call__(self, data): + assert len(self.list_filter) > 0 + res = self.list_filter[0](data) + for filter_fn in self.list_filter: + res = self.boolean_operation(res, filter_fn(data)) + return res + + def __repr__(self): + rep = "{}([".format(self.__class__.__name__) + for filt in self.list_filter: + rep = rep + filt.__repr__() + ", " + rep = rep + "])" + return rep + + +class PlanarityFilter(object): + """ + compute planarity and return false if the planarity of a pointcloud is above or below a threshold + + Parameters + ---------- + thresh: float, optional + threshold to filter low planar pointcloud + is_leq: bool, optional + choose whether planarity should be lesser or equal than the threshold or greater than the threshold. + """ + + def __init__(self, thresh=0.3, is_leq=True): + self.thresh = thresh + self.is_leq = is_leq + + def __call__(self, data): + if getattr(data, "eigenvalues", None) is None: + data = PCACompute()(data) + planarity = compute_planarity(data.eigenvalues) + if self.is_leq: + return planarity <= self.thresh + else: + return planarity > self.thresh + + def __repr__(self): + return "{}(thresh={}, is_leq={})".format(self.__class__.__name__, self.thresh, self.is_leq) + + +class RandomFilter(object): + """ + Randomly select an elem of the dataset (to have smaller dataset) with a bernouilli distribution of parameter thresh. + + Parameters + ---------- + thresh: float, optional + the parameter of the bernouilli function + """ + + def __init__(self, thresh=0.3): + self.thresh = thresh + + def __call__(self, data): + return random.random() < self.thresh + + def __repr__(self): + return "{}(thresh={})".format(self.__class__.__name__, self.thresh) + + +class ClassificationFilter(object): + """ + Select specific classes from "classification" feature to remove or keep. + Keep is prioritized. + + Parameters + ---------- + feature_index: int + which index the classification is expected in + class_indices: + which class indices to select for keeping or removing + keep: bool, optional + keep the given class indices if true, else remove them (default: True) + remove_feat: bool, optional + if the feature should be removed after filtering (default: True) + + """ + + def __init__(self, feature_index: int, class_indices: list, keep: bool = True, remove_feat: bool = True): + self.class_indices = class_indices + self.keep = keep + self.feature_index = feature_index + self.remove_feat = remove_feat + + def __call__(self, data): + cls = data.x[:, self.feature_index] + mask = torch.stack([cls == i for i in self.class_indices]).any(0) + if not self.keep: + mask = ~mask + + num_nodes = data.num_nodes + for key, item in data: + if key == 'num_nodes': + data.num_nodes = mask.size(0) + elif (torch.is_tensor(item) and item.size(0) == num_nodes + and item.size(0) != 1): + data[key] = item[mask] + + if self.remove_feat: + if data.x.shape[1] == 1: + data.x = None + else: + data.x = torch.cat([data.x[:, :self.feature_index], data.x[:, self.feature_index + 1:]], 1) + + return data + + def __repr__(self): + return "{}(feature_index={},class_indices={},keep={},remove_feat={})".format( + self.__class__.__name__, self.feature_index, self.class_indices, self.keep, self.remove_feat + ) diff --git a/torch-points3d/torch_points3d/core/data_transform/grid_transform.py b/torch-points3d/torch_points3d/core/data_transform/grid_transform.py new file mode 100644 index 0000000..e6c897d --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/grid_transform.py @@ -0,0 +1,231 @@ +import logging +import random +import re +from typing import * + +import numpy as np +import scipy +import torch +import torch.nn.functional as F +from torch_cluster import grid_cluster +from torch_geometric.data import Data +from torch_geometric.nn import voxel_grid +from torch_geometric.nn.pool.consecutive import consecutive_cluster +from torch_scatter import scatter_mean, scatter_add + +log = logging.getLogger(__name__) + +# Label will be the majority label in each voxel +_INTEGER_LABEL_KEYS = ["y", "y_cls", "instance_labels"] + + +def shuffle_data(data): + num_points = data.pos.shape[0] + shuffle_idx = torch.randperm(num_points) + for key in set(data.keys): + item = data[key] + if torch.is_tensor(item) and num_points == item.shape[0]: + data[key] = item[shuffle_idx] + return data + + +def group_data(data, cluster=None, unique_pos_indices=None, mode="last", skip_keys=[]): + """ Group data based on indices in cluster. + The option ``mode`` controls how data gets aggregated within each cluster. + + Parameters + ---------- + data : Data + [description] + cluster : torch.Tensor + Tensor of the same size as the number of points in data. Each element is the cluster index of that point. + unique_pos_indices : torch.tensor + Tensor containing one index per cluster, this index will be used to select features and labels + mode : str + Option to select how the features and labels for each voxel is computed. Can be ``last`` or ``mean``. + ``last`` selects the last point falling in a voxel as the represented, ``mean`` takes the average. + skip_keys: list + Keys of attributes to skip in the grouping + """ + + assert mode in ["mean", "last"] + if mode == "mean" and cluster is None: + raise ValueError("In mean mode the cluster argument needs to be specified") + if mode == "last" and unique_pos_indices is None: + raise ValueError("In last mode the unique_pos_indices argument needs to be specified") + + num_nodes = data.num_nodes + for key, item in data: + if bool(re.search("edge", key)): + raise ValueError("Edges not supported. Wrong data type.") + if key in skip_keys: + continue + + if torch.is_tensor(item) and item.size(0) == num_nodes: + if mode == "last" or key == "batch" or key == SaveOriginalPosId.KEY: + data[key] = item[unique_pos_indices] + elif mode == "mean": + is_item_bool = item.dtype == torch.bool + if is_item_bool: + item = item.int() + if key in _INTEGER_LABEL_KEYS: + item_min = item.min() + item = F.one_hot(item - item_min) + item = scatter_add(item, cluster, dim=0) + data[key] = item.argmax(dim=-1) + item_min + else: + data[key] = scatter_mean(item, cluster, dim=0) + if is_item_bool: + data[key] = data[key].bool() + return data + + +class GridSampling3D: + """ Clusters points into voxels with size :attr:`size`. + Parameters + ---------- + size: float + Size of a voxel (in each dimension). + quantize_coords: bool + If True, it will convert the points into their associated sparse coordinates within the grid and store + the value into a new `coords` attribute + mode: string: + The mode can be either `last` or `mean`. + If mode is `mean`, all the points and their features within a cell will be averaged + If mode is `last`, one random points per cell will be selected with its associated features + """ + + def __init__(self, size, quantize_coords=False, mode="mean", verbose=False): + self._grid_size = size + self._quantize_coords = quantize_coords + self._mode = mode + if verbose: + log.warning( + "If you need to keep track of the position of your points, use SaveOriginalPosId transform before using GridSampling3D" + ) + + if self._mode == "last": + log.warning( + "The tensors within data will be shuffled each time this transform is applied. Be careful that if an attribute doesn't have the size of num_points, it won't be shuffled" + ) + + def _process(self, data): + if self._mode == "last": + data = shuffle_data(data) + + coords = torch.round((data.pos) / self._grid_size) + if "batch" not in data: + cluster = grid_cluster(coords, torch.tensor([1, 1, 1])) + else: + cluster = voxel_grid(pos=coords, size=1, batch=data.batch) + cluster, unique_pos_indices = consecutive_cluster(cluster) + + data = group_data(data, cluster, unique_pos_indices, mode=self._mode) + if self._quantize_coords: + data.coords = coords[unique_pos_indices].int() + + data.grid_size = torch.tensor([self._grid_size]) + return data + + def __call__(self, data): + if isinstance(data, list): + data = [self._process(d) for d in data] + else: + data = self._process(data) + return data + + def __repr__(self): + return "{}(grid_size={}, quantize_coords={}, mode={})".format( + self.__class__.__name__, self._grid_size, self._quantize_coords, self._mode + ) + + +class SaveOriginalPosId: + """ Transform that adds the index of the point to the data object + This allows us to track this point from the output back to the input data object + """ + + KEY = "origin_id" + + def _process(self, data): + if hasattr(data, self.KEY): + return data + + setattr(data, self.KEY, torch.arange(0, data.pos.shape[0])) + return data + + def __call__(self, data): + if isinstance(data, list): + data = [self._process(d) for d in data] + else: + data = self._process(data) + return data + + def __repr__(self): + return self.__class__.__name__ + + +class ElasticDistortion: + """Apply elastic distortion on sparse coordinate space. First projects the position onto a + voxel grid and then apply the distortion to the voxel grid. + + Parameters + ---------- + granularity: List[float] + Granularity of the noise in meters + magnitude:List[float] + Noise multiplier in meters + Returns + ------- + data: Data + Returns the same data object with distorted grid + """ + + def __init__( + self, apply_distorsion: bool = True, granularity: List = [0.2, 0.8], magnitude=[0.4, 1.6], + ): + assert len(magnitude) == len(granularity) + self._apply_distorsion = apply_distorsion + self._granularity = granularity + self._magnitude = magnitude + + @staticmethod + def elastic_distortion(coords, granularity, magnitude): + coords = coords.numpy() + blurx = np.ones((3, 1, 1, 1)).astype("float32") / 3 + blury = np.ones((1, 3, 1, 1)).astype("float32") / 3 + blurz = np.ones((1, 1, 3, 1)).astype("float32") / 3 + coords_min = coords.min(0) + + # Create Gaussian noise tensor of the size given by granularity. + noise_dim = ((coords - coords_min).max(0) // granularity).astype(int) + 3 + noise = np.random.randn(*noise_dim, 3).astype(np.float32) + + # Smoothing. + for _ in range(2): + noise = scipy.ndimage.filters.convolve(noise, blurx, mode="constant", cval=0) + noise = scipy.ndimage.filters.convolve(noise, blury, mode="constant", cval=0) + noise = scipy.ndimage.filters.convolve(noise, blurz, mode="constant", cval=0) + + # Trilinear interpolate noise filters for each spatial dimensions. + ax = [ + np.linspace(d_min, d_max, d) + for d_min, d_max, d in zip(coords_min - granularity, coords_min + granularity * (noise_dim - 2), noise_dim) + ] + interp = scipy.interpolate.RegularGridInterpolator(ax, noise, bounds_error=0, fill_value=0) + coords = coords + interp(coords) * magnitude + return torch.tensor(coords).float() + + def __call__(self, data): + # coords = data.pos / self._spatial_resolution + if self._apply_distorsion: + if random.random() < 0.95: + for i in range(len(self._granularity)): + data.pos = ElasticDistortion.elastic_distortion(data.pos, self._granularity[i], + self._magnitude[i], ) + return data + + def __repr__(self): + return "{}(apply_distorsion={}, granularity={}, magnitude={})".format( + self.__class__.__name__, self._apply_distorsion, self._granularity, self._magnitude, + ) diff --git a/torch-points3d/torch_points3d/core/data_transform/inference_transforms.py b/torch-points3d/torch_points3d/core/data_transform/inference_transforms.py new file mode 100644 index 0000000..6a8e805 --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/inference_transforms.py @@ -0,0 +1,87 @@ +import os +import sys +import logging + +ROOT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "..") +sys.path.insert(0, os.path.join(ROOT)) + +log = logging.getLogger(__name__) + + +class ModelInference(object): + """ Base class transform for performing a point cloud inference using a pre_trained model + Subclass and implement the ``__call__`` method with your own forward. + See ``PointNetForward`` for an example implementation. + + Parameters + ---------- + checkpoint_dir: str + Path to a checkpoint directory + model_name: str + Model name, the file ``checkpoint_dir/model_name.pt`` must exist + """ + + def __init__(self, checkpoint_dir, model_name, weight_name, feat_name, num_classes=None, mock_dataset=True): + # Checkpoint + from torch_points3d.datasets.base_dataset import BaseDataset + from torch_points3d.datasets.dataset_factory import instantiate_dataset + from torch_points3d.utils.mock import MockDataset + import torch_points3d.metrics.model_checkpoint as model_checkpoint + + checkpoint = model_checkpoint.ModelCheckpoint(checkpoint_dir, model_name, weight_name, strict=True) + if mock_dataset: + dataset = MockDataset(num_classes) + dataset.num_classes = num_classes + else: + dataset = instantiate_dataset(checkpoint.data_config) + BaseDataset.set_transform(self, checkpoint.data_config) + self.model = checkpoint.create_model(dataset, weight_name=weight_name) + self.model.eval() + + def __call__(self, data): + raise NotImplementedError + + +class PointNetForward(ModelInference): + """ Transform for running a PointNet inference on a Data object. It assumes that the + model has been trained for segmentation. + + Parameters + ---------- + checkpoint_dir: str + Path to a checkpoint directory + model_name: str + Model name, the file ``checkpoint_dir/model_name.pt`` must exist + weight_name: str + Type of weights to load (best for iou, best for loss etc...) + feat_name: str + Name of the key in Data that will hold the output of the forward + num_classes: int + Number of classes that the model was trained on + """ + + def __init__(self, checkpoint_dir, model_name, weight_name, feat_name, num_classes, mock_dataset=True): + super(PointNetForward, self).__init__( + checkpoint_dir, model_name, weight_name, feat_name, num_classes=num_classes, mock_dataset=mock_dataset + ) + self.feat_name = feat_name + + from torch_points3d.datasets.base_dataset import BaseDataset + from torch_geometric.transforms import FixedPoints, GridSampling3D + + self.inference_transform = BaseDataset.remove_transform(self.inference_transform, [GridSampling3D, FixedPoints]) + + def __call__(self, data): + data_c = data.clone() + data_c.pos = data_c.pos.float() + if self.inference_transform: + data_c = self.inference_transform(data_c) + self.model.set_input(data_c, data.pos.device) + feat = self.model.get_local_feat().detach() + setattr(data, str(self.feat_name), feat) + return data + + def __repr__(self): + return "{}(model: {}, transform: {})".format( + self.__class__.__name__, self.model.__class__.__name__, self.inference_transform + ) diff --git a/torch-points3d/torch_points3d/core/data_transform/prebatchcollate.py b/torch-points3d/torch_points3d/core/data_transform/prebatchcollate.py new file mode 100644 index 0000000..58ce9a3 --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/prebatchcollate.py @@ -0,0 +1,43 @@ +import logging + +log = logging.getLogger(__name__) + + +class ClampBatchSize: + """ Drops sample in a batch if the batch gets too large + + Parameters + ---------- + num_points : int, optional + Maximum number of points per batch, by default 100000 + """ + + def __init__(self, num_points=100000): + self._num_points = num_points + + def __call__(self, datas): + assert isinstance(datas, list) + batch_id = 0 + batch_num_points = 0 + removed_sample = False + datas_out = [] + for batch_id, d in enumerate(datas): + num_points = datas[batch_id].pos.shape[0] + batch_num_points += num_points + if self._num_points and batch_num_points > self._num_points: + batch_num_points -= num_points + removed_sample = True + continue + datas_out.append(d) + + if removed_sample: + num_full_points = sum(len(d.pos) for d in datas) + num_full_batch_size = len(datas_out) + log.warning( + f"\t\tCannot fit {num_full_points} points into {self._num_points} points " + f"limit. Truncating batch size at {num_full_batch_size} out of {len(datas)} with {batch_num_points}." + ) + return datas_out + + def __repr__(self): + return "{}(num_points={})".format(self.__class__.__name__, self._num_points) diff --git a/torch-points3d/torch_points3d/core/data_transform/precollate.py b/torch-points3d/torch_points3d/core/data_transform/precollate.py new file mode 100644 index 0000000..c531a6d --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/precollate.py @@ -0,0 +1,26 @@ + + +class NormalizeFeature(object): + """Normalize a feature. By default, features will be scaled between [0,1]. Should only be applied on a dataset-level. + + Parameters + ---------- + standardize: bool: Will use standardization rather than scaling. + """ + + def __init__(self, feature_name, standardize=False): + self._feature_name = feature_name + self._standardize = standardize + + def __call__(self, data): + assert hasattr(data, self._feature_name) + feature = data[self._feature_name] + if self._standardize: + feature = (feature - feature.mean()) / (feature.std()) + else: + feature = (feature - feature.min()) / (feature.max() - feature.min()) + data[self._feature_name] = feature + return data + + def __repr__(self): + return "{}(feature_name={}, standardize={})".format(self.__class__.__name__, self._feature_name, self._standardize) \ No newline at end of file diff --git a/torch-points3d/torch_points3d/core/data_transform/sparse_transforms.py b/torch-points3d/torch_points3d/core/data_transform/sparse_transforms.py new file mode 100644 index 0000000..5163b33 --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/sparse_transforms.py @@ -0,0 +1,60 @@ +from typing import List +import itertools +import numpy as np +import math +import re +import torch +import scipy +import random +from tqdm.auto import tqdm as tq +from torch.nn import functional as F +from functools import partial +from torch_geometric.nn import fps, radius, knn, voxel_grid +from torch_geometric.nn.pool.consecutive import consecutive_cluster +from torch_geometric.nn.pool.pool import pool_pos, pool_batch +from torch_scatter import scatter_add, scatter_mean +from torch_cluster import grid_cluster + +from torch_points3d.datasets.multiscale_data import MultiScaleData +from torch_points3d.utils.config import is_list +from torch_points3d.utils import is_iterable +from .grid_transform import group_data, GridSampling3D, shuffle_data + + +class RandomCoordsFlip(object): + def __init__(self, ignored_axis, is_temporal=False, p=0.95): + """This transform is used to flip sparse coords using a given axis. Usually, it would be x or y + + Parameters + ---------- + ignored_axis: str + Axis to be chosen between x, y, z + is_temporal : bool + Used to indicate if the pointcloud is actually 4 dimensional + + Returns + ------- + data: Data + Returns the same data object with only one point per voxel + """ + assert 0 <= p <= 1, "p should be within 0 and 1. Higher probability reduce chance of flipping" + self._is_temporal = is_temporal + self._D = 4 if is_temporal else 3 + mapping = {"x": 0, "y": 1, "z": 2} + self._ignored_axis = [mapping[axis] for axis in ignored_axis] + # Use the rest of axes for flipping. + self._horz_axes = set(range(self._D)) - set(self._ignored_axis) + self._p = p + + def __call__(self, data): + for curr_ax in self._horz_axes: + if random.random() < self._p: + coords = data.coords + coord_max = torch.max(coords[:, curr_ax]) + data.coords[:, curr_ax] = coord_max - coords[:, curr_ax] + return data + + def __repr__(self): + return "{}(flip_axis={}, prob={}, is_temporal={})".format( + self.__class__.__name__, self._horz_axes, self._p, self._is_temporal + ) diff --git a/torch-points3d/torch_points3d/core/data_transform/transforms.py b/torch-points3d/torch_points3d/core/data_transform/transforms.py new file mode 100755 index 0000000..1024db5 --- /dev/null +++ b/torch-points3d/torch_points3d/core/data_transform/transforms.py @@ -0,0 +1,1796 @@ +import math +import os +import random +import re +from functools import partial +from glob import glob +from itertools import chain +from pathlib import Path as PPath +from typing import List + +import numba +import numpy as np +import torch +from dbscan1d.core import DBSCAN1D +from matplotlib.path import Path +from matplotlib.transforms import Affine2D +from omegaconf import OmegaConf +from sklearn.cluster import OPTICS +from sklearn.neighbors import KDTree, KernelDensity +from torch.nn import functional as F +from torch_geometric.data import Data, Batch +from torch_geometric.transforms import FixedPoints as FP +from tqdm.auto import tqdm as tq + +from torch_points3d.datasets.multiscale_data import MultiScaleData +from torch_points3d.utils.transform_utils import SamplingStrategy +from .features import Random3AxisRotation +from .grid_transform import GridSampling3D, shuffle_data +from ...utils import is_iterable + +KDTREE_KEY = "kd_tree" + + +class RemoveAttributes(object): + """This transform allows to remove unnecessary attributes from data for optimization purposes + + Parameters + ---------- + attr_names: list + Remove the attributes from data using the provided `attr_name` within attr_names + strict: bool=False + Wether True, it will raise an execption if the provided attr_name isn t within data keys. + """ + + def __init__(self, attr_names=[], strict=False): + self._attr_names = attr_names + self._strict = strict + + def __call__(self, data): + keys = set(data.keys) + for attr_name in self._attr_names: + if attr_name not in keys and self._strict: + raise Exception("attr_name: {} isn t within keys: {}".format(attr_name, keys)) + for attr_name in self._attr_names: + delattr(data, attr_name) + return data + + def __repr__(self): + return "{}(attr_names={}, strict={})".format(self.__class__.__name__, self._attr_names, self._strict) + + +class PointCloudFusion(object): + """This transform is responsible to perform a point cloud fusion from a list of data + + - If a list of data is provided -> Create one Batch object with all data + - If a list of list of data is provided -> Create a list of fused point cloud + """ + + def _process(self, data_list): + if len(data_list) == 0: + return Data() + data = Batch.from_data_list(data_list) + delattr(data, "batch") + delattr(data, "ptr") + return data + + def __call__(self, data_list: List[Data]): + if len(data_list) == 0: + raise Exception("A list of data should be provided") + elif len(data_list) == 1: + return data_list[0] + else: + if isinstance(data_list[0], list): + data = [self._process(d) for d in data_list] + else: + data = self._process(data_list) + return data + + def __repr__(self): + return "{}()".format(self.__class__.__name__) + + +class GridSphereSampling(object): + """Fits the point cloud to a grid and for each point in this grid, + create a sphere with a radius r + + Parameters + ---------- + radius: float + Radius of the sphere to be sampled. + grid_size: float, optional + Grid_size to be used with GridSampling3D to select spheres center. If None, radius will be used + delattr_kd_tree: bool, optional + If True, KDTREE_KEY should be deleted as an attribute if it exists + center: bool, optional + If True, a centre transform is apply on each sphere. + """ + + KDTREE_KEY = KDTREE_KEY + + def __init__(self, radius, grid_size=None, delattr_kd_tree=True, center=True): + self._radius = eval(radius) if isinstance(radius, str) else float(radius) + grid_size = eval(grid_size) if isinstance(grid_size, str) else float(grid_size) + self._grid_sampling = GridSampling3D(size=grid_size if grid_size else self._radius) + self._delattr_kd_tree = delattr_kd_tree + self._center = center + + def _process(self, data): + if not hasattr(data, self.KDTREE_KEY): + tree = KDTree(np.asarray(data.pos), leaf_size=50) + else: + tree = getattr(data, self.KDTREE_KEY) + + # The kdtree has bee attached to data for optimization reason. + # However, it won't be used for down the transform pipeline and should be removed before any collate func call. + if hasattr(data, self.KDTREE_KEY) and self._delattr_kd_tree: + delattr(data, self.KDTREE_KEY) + + # apply grid sampling + grid_data = self._grid_sampling(data.clone()) + + datas = [] + for grid_center in np.asarray(grid_data.pos): + pts = np.asarray(grid_center)[np.newaxis] + + # Find closest point within the original data + ind = torch.LongTensor(tree.query(pts, k=1)[1][0]) + grid_label = data.y[ind] + + # Find neighbours within the original data + ind = torch.LongTensor(tree.query_radius(pts, r=self._radius)[0]) + sampler = SphereSampling(self._radius, grid_center, align_origin=self._center) + new_data = sampler(data) + new_data.center_label = grid_label + + datas.append(new_data) + return datas + + def __call__(self, data): + if isinstance(data, list): + data = [self._process(d) for d in tq(data)] + data = list(chain(*data)) # 2d list needs to be flatten + else: + data = self._process(data) + return data + + def __repr__(self): + return "{}(radius={}, center={})".format(self.__class__.__name__, self._radius, self._center) + + +class GridCylinderSampling(object): + """Fits the point cloud to a grid and for each point in this grid, + create a cylinder with a radius r + + Parameters + ---------- + radius: float + Radius of the cylinder to be sampled. + grid_size: float, optional + Grid_size to be used with GridSampling3D to select cylinders center. If None, radius will be used + delattr_kd_tree: bool, optional + If True, KDTREE_KEY should be deleted as an attribute if it exists + center: bool, optional + If True, a centre transform is apply on each cylinder. + """ + + KDTREE_KEY = KDTREE_KEY + + def __init__(self, radius, grid_size=None, delattr_kd_tree=True, center=True): + self._radius = eval(radius) if isinstance(radius, str) else float(radius) + grid_size = eval(grid_size) if isinstance(grid_size, str) else float(grid_size) + self._grid_sampling = GridSampling3D(size=grid_size if grid_size else self._radius) + self._delattr_kd_tree = delattr_kd_tree + self._center = center + + def _process(self, data): + if not hasattr(data, self.KDTREE_KEY): + tree = KDTree(np.asarray(data.pos[:, :-1]), leaf_size=50) + else: + tree = getattr(data, self.KDTREE_KEY) + + # The kdtree has bee attached to data for optimization reason. + # However, it won't be used for down the transform pipeline and should be removed before any collate func call. + if hasattr(data, self.KDTREE_KEY) and self._delattr_kd_tree: + delattr(data, self.KDTREE_KEY) + + # apply grid sampling + grid_data = self._grid_sampling(data.clone()) + + datas = [] + for grid_center in np.unique(grid_data.pos[:, :-1], axis=0): + pts = np.asarray(grid_center)[np.newaxis] + + # Find closest point within the original data + ind = torch.LongTensor(tree.query(pts, k=1)[1][0]) + grid_label = data.y[ind] + + # Find neighbours within the original data + ind = torch.LongTensor(tree.query_radius(pts, r=self._radius)[0]) + sampler = CylinderSampling(self._radius, grid_center, align_origin=self._center) + new_data = sampler(data) + new_data.center_label = grid_label + + datas.append(new_data) + return datas + + def __call__(self, data): + if isinstance(data, list): + data = [self._process(d) for d in tq(data)] + data = list(chain(*data)) # 2d list needs to be flatten + else: + data = self._process(data) + return data + + def __repr__(self): + return "{}(radius={}, center={})".format(self.__class__.__name__, self._radius, self._center) + + +class ComputeKDTree(object): + """Calculate the KDTree and saves it within data + + Parameters + ----------- + leaf_size:int + Size of the leaf node. + """ + + def __init__(self, leaf_size): + self._leaf_size = leaf_size + + def _process(self, data): + data.kd_tree = KDTree(np.asarray(data.pos), leaf_size=self._leaf_size) + return data + + def __call__(self, data): + if isinstance(data, list): + data = [self._process(d) for d in data] + else: + data = self._process(data) + return data + + def __repr__(self): + return "{}(leaf_size={})".format(self.__class__.__name__, self._leaf_size) + + +class RandomSphere(object): + """Select points within a sphere of a given radius. The centre is chosen randomly within the point cloud. + + Parameters + ---------- + radius: float + Radius of the sphere to be sampled. + strategy: str + choose between `random` and `freq_class_based`. The `freq_class_based` \ + favors points with low frequency class. This can be used to balance unbalanced datasets + center: bool + if True then the sphere will be moved to the origin + """ + + def __init__(self, radius, strategy="random", class_weight_method="sqrt", center=True): + self._radius = eval(radius) if isinstance(radius, str) else float(radius) + self._sampling_strategy = SamplingStrategy(strategy=strategy, class_weight_method=class_weight_method) + self._center = center + + def _process(self, data): + # apply sampling strategy + random_center = self._sampling_strategy(data) + random_center = np.asarray(data.pos[random_center])[np.newaxis] + sphere_sampling = SphereSampling(self._radius, random_center, align_origin=self._center) + return sphere_sampling(data) + + def __call__(self, data): + if isinstance(data, list): + data = [self._process(d) for d in data] + else: + data = self._process(data) + return data + + def __repr__(self): + return "{}(radius={}, center={}, sampling_strategy={})".format( + self.__class__.__name__, self._radius, self._center, self._sampling_strategy + ) + + +class SphereSampling: + """ Samples points within a sphere + + Parameters + ---------- + radius : float + Radius of the sphere + sphere_centre : torch.Tensor or np.array + Centre of the sphere (1D array that contains (x,y,z)) + align_origin : bool, optional + move resulting point cloud to origin + """ + + KDTREE_KEY = KDTREE_KEY + + def __init__(self, radius, sphere_centre, align_origin=True): + self._radius = radius + self._centre = np.asarray(sphere_centre) + if len(self._centre.shape) == 1: + self._centre = np.expand_dims(self._centre, 0) + self._align_origin = align_origin + + def __call__(self, data): + num_points = data.pos.shape[0] + if not hasattr(data, self.KDTREE_KEY): + tree = KDTree(np.asarray(data.pos), leaf_size=50) + setattr(data, self.KDTREE_KEY, tree) + else: + tree = getattr(data, self.KDTREE_KEY) + + t_center = torch.FloatTensor(self._centre) + ind = torch.LongTensor(tree.query_radius(self._centre, r=self._radius)[0]) + new_data = Data() + for key in set(data.keys): + if key == self.KDTREE_KEY: + continue + item = data[key] + if torch.is_tensor(item) and num_points == item.shape[0]: + item = item[ind] + if self._align_origin and key == "pos": # Center the sphere. + item -= t_center + elif torch.is_tensor(item): + item = item.clone() + setattr(new_data, key, item) + return new_data + + def __repr__(self): + return "{}(radius={}, center={}, align_origin={})".format( + self.__class__.__name__, self._radius, self._centre, self._align_origin + ) + + +class CylinderSampling: + """ Samples points within a cylinder + + Parameters + ---------- + radius : float + Radius of the cylinder + cylinder_centre : torch.Tensor or np.array + Centre of the cylinder (1D array that contains (x,y,z) or (x,y)) + align_origin : bool, optional + move resulting point cloud to origin + """ + + KDTREE_KEY = KDTREE_KEY + + def __init__(self, radius, cylinder_centre, align_origin=True): + self._radius = radius + if cylinder_centre.shape[0] == 3: + cylinder_centre = cylinder_centre[:-1] + self._centre = np.asarray(cylinder_centre) + if len(self._centre.shape) == 1: + self._centre = np.expand_dims(self._centre, 0) + self._align_origin = align_origin + + def __call__(self, data): + num_points = data.pos.shape[0] + if not hasattr(data, self.KDTREE_KEY): + tree = KDTree(np.asarray(data.pos[:, :-1]), leaf_size=50) + setattr(data, self.KDTREE_KEY, tree) + else: + tree = getattr(data, self.KDTREE_KEY) + + t_center = torch.FloatTensor(self._centre) + ind = torch.LongTensor(tree.query_radius(self._centre, r=self._radius)[0]) + + new_data = Data() + for key in set(data.keys): + if key == self.KDTREE_KEY: + continue + item = data[key] + if torch.is_tensor(item) and num_points == item.shape[0]: + item = item[ind] + if self._align_origin and key == "pos": # Center the cylinder. + item[:, :-1] -= t_center + elif torch.is_tensor(item): + item = item.clone() + setattr(new_data, key, item) + return new_data + + def __repr__(self): + return "{}(radius={}, center={}, align_origin={})".format( + self.__class__.__name__, self._radius, self._centre, self._align_origin + ) + + +class Select: + """ Selects given points from a data object + + Parameters + ---------- + indices : torch.Tensor + indeices of the points to keep. Can also be a boolean mask + """ + + def __init__(self, indices=None): + self._indices = indices + + def __call__(self, data): + num_points = data.pos.shape[0] + new_data = Data() + for key in data.keys: + if key == KDTREE_KEY: + continue + item = data[key] + if torch.is_tensor(item) and num_points == item.shape[0]: + item = item[self._indices].clone() + elif torch.is_tensor(item): + item = item.clone() + setattr(new_data, key, item) + return new_data + + +class CylinderNormalizeScale(object): + """ Normalize points within a cylinder + + """ + + def __init__(self, normalize_z=True): + self._normalize_z = normalize_z + + def _process(self, data): + data.pos -= data.pos.mean(dim=0, keepdim=True) + scale = (1 / data.pos[:, :-1].abs().max()) * 0.999999 + data.pos[:, :-1] = data.pos[:, :-1] * scale + if self._normalize_z: + scale = (1 / data.pos[:, -1].abs().max()) * 0.999999 + data.pos[:, -1] = data.pos[:, -1] * scale + return data + + def __call__(self, data): + if isinstance(data, list): + data = [self._process(d) for d in data] + else: + data = self._process(data) + return data + + def __repr__(self): + return "{}(normalize_z={})".format(self.__class__.__name__, self._normalize_z) + + +class RandomSymmetry(object): + """ Apply a random symmetry transformation on the data + + Parameters + ---------- + axis: Tuple[bool,bool,bool], optional + axis along which the symmetry is applied + """ + + def __init__(self, axis=[False, False, False]): + self.axis = axis + + def __call__(self, data): + + for i, ax in enumerate(self.axis): + if ax: + if torch.rand(1) < 0.5: + c_max = torch.max(data.pos[:, i]) + data.pos[:, i] = c_max - data.pos[:, i] + return data + + def __repr__(self): + return "Random symmetry of axes: x={}, y={}, z={}".format(*self.axis) + + +class RandomNoise(object): + """ Simple isotropic additive gaussian noise (Jitter) + + Parameters + ---------- + sigma: + Variance of the noise + clip: + Maximum amplitude of the noise + """ + + def __init__(self, sigma=0.01, clip=0.05, p: float = None): + self.sigma = sigma + self.clip = clip + self.p = 1 if p is None else p + + def __call__(self, data): + if random.random() < self.p: + noise = self.sigma * torch.randn(data.pos.shape) + noise = noise.clamp(-self.clip, self.clip) + data.pos = data.pos + noise + return data + + def __repr__(self): + return "{}(sigma={}, clip={})".format(self.__class__.__name__, self.sigma, self.clip) + + +class StatZOutlierRemoval: + def __init__(self, threshold: float = 4, skip_list: list = None): + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) + self.threshold = threshold # std deviation + + def __call__(self, data): + z = data.pos[:, 2] + m = z.mean() + s = z.std() + out = abs((z - m) / s) + mask = out < self.threshold + data = apply_mask(data, mask, self.skip_list) + return data + + def __repr__(self): + return "{}(threshold={})".format(self.__class__.__name__, self.p) + + +class DBSCANZOutlierRemoval: + def __init__(self, eps: float = 1, min_samples: int = 10, skip_list: list = None): + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) + self.eps = eps + self.min_samples = min_samples + self.dbscan = DBSCAN1D(eps=eps, min_samples=min_samples) + + def __call__(self, data): + z = data.pos[:, 2] + label = torch.tensor(self.dbscan.fit_predict(z[:, None])) + mask = label != -1 + mask = (z <= z[mask].max()) & (z >= z[mask].min()) + data = apply_mask(data, mask, self.skip_list) + return data + + def __repr__(self): + return "{}(eps={},min_samples={})".format(self.__class__.__name__, self.eps, self.min_samples) + + +class OPTICSZOutlierRemoval: + def __init__(self, eps: float = 1, min_samples: int = 10, skip_list: list = None): + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) + self.eps = eps + self.min_samples = min_samples + self.dbscan = OPTICS(eps=eps, min_samples=min_samples, cluster_method="dbscan") + + def __call__(self, data): + z = data.pos[:, 2] + label = torch.tensor(self.dbscan.fit_predict(z[:, None])) + mask = label != -1 + mask = (z <= z[mask].max()) & (z >= z[mask].min()) + # if ~(mask).any(): + # from openpoints.dataset import vis_points + # vis_points(data['pos'], mask) + data = apply_mask(data, mask, self.skip_list) + return data + + def __repr__(self): + return "{}(eps={},min_samples={})".format(self.__class__.__name__, self.eps, self.min_samples) + + +class KernelDensityZOutlierRemoval: + def __init__(self, bandwidth: float = 1, p: float = 0.05, skip_list: list = None): + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) + self.bandwidth = bandwidth + self.p = p + self.kd = KernelDensity(kernel="gaussian", bandwidth=bandwidth) + + def __call__(self, data): + z = data.pos[:, 2] + label = torch.tensor(self.kd.fit(z[:, None]).score_samples(z[:, None])) + mask = label > np.log(self.p) + mask = (z <= z[mask].max()) & (z >= z[mask].min()) + # if ~(mask).any(): + # from openpoints.dataset import vis_points + # vis_points(data['pos'], mask) + data = apply_mask(data, mask, self.skip_list) + return data + + def __repr__(self): + return "{}(bandwidth={},p={})".format(self.__class__.__name__, self.bandwidth, self.p) + + +class ScalePos: + def __init__(self, scale_x=1., scale_y=1., scale_z=1., op="mul"): + self.scale = torch.tensor([scale_x, scale_y, scale_z]).unsqueeze(0) + self.op_str = op + self.op = torch.mul if op == "mul" else torch.div + + def __call__(self, data): + data.pos = self.op(data.pos, self.scale) + return data + + def __repr__(self): + return "{}(scale={},op={})".format(self.__class__.__name__, self.scale, self.op_str) + + +def maxmin_center(data): + return (data.pos.amax(dim=0, keepdim=True) + data.pos.amin(dim=0, keepdim=True)) / 2. + + +def quantile_center(data): + return (torch.quantile(data.pos, 0.99, dim=0, keepdim=True) + + torch.quantile(data.pos, 0.01, dim=0, keepdim=True)) / 2. + + +def mean_center(data): + return data.pos.mean(axis=0, keepdims=True) + + +class CenterPosPerSample: + r"""Centers point positions by a defined 'center' function. + Parameters + ----------- + center_x: bool + centering the x-axis. + center_y: bool + centering the y-axis. + center_z: bool + centering the z-axis. + center: str + which center function is used (choose from: 'mean', 'quantile', 'maxmin'). + """ + + def __init__(self, center_x: bool = True, center_y: bool = True, center_z: bool = False, center: str = "mean"): + self.center_ = torch.FloatTensor([[center_x, center_y, center_z]]) + self.center_x = center_x + self.center_y = center_y + self.center_z = center_z + self.center_any = center_x or center_z or center_y + self.center = center + if center == "mean": + self.agg = mean_center + elif center == "quantile": + self.agg = quantile_center + elif center == "maxmin": + self.agg = maxmin_center + else: + raise Exception(f"Unknown center function: {center} (should be 'mean', 'quantile', or 'maxmin')") + + def __call__(self, data): + if self.center_any: + center = self.agg(data) * self.center_ + data.pos -= center + return data + + def __repr__(self): + return "{}(center_x={},center_y={},center_z={},center={})".format( + self.__class__.__name__, self.center_x, self.center_y, self.center_z, self.center + ) + + +class CenterXYbyZ: + r"""Centers xy point positions by z selected points. + Parameters + ----------- + center_x: float + centering the x-axis. + center_y: float + centering the y-axis. + z_thresh_min: float + min threshold for selecting z. + z_thresh_max: float + max threshold for selecting z. + """ + + def __init__(self, center_x: float = 0., center_y: float = 0., z_thresh_min: float = 0., z_thresh_max: float = 1.): + self.z_thresh_min = z_thresh_min + self.z_thresh_max = z_thresh_max + self.center_ = torch.FloatTensor([[center_x, center_y]]) + + def __call__(self, data): + z_points = (self.z_thresh_min < data.pos[:, 2]) & (data.pos[:, 2] < self.z_thresh_max) + pos = data.pos[:, :2] + amax = pos[z_points].amax(0, keepdim=True) + amin = pos[z_points].amin(0, keepdim=True) + pos -= (amax + amin) / 2. + pos += self.center_ + data.pos[:, :2] = pos + data["pos_deviation"] = amax - amin + data["pos_center_points"] = z_points.sum() + return data + + def __repr__(self): + return "{}(center_x={},center_y={},z_thresh_min={},z_thresh_max={})".format( + self.__class__.__name__, self.center_[0, 0], self.center_[0, 1], self.z_thresh_min, self.z_thresh_max + ) + + +class FixedCenterPosPerSample: + r"""Centers point positions by a defined 'center' function. + Parameters + ----------- + center_x: float + centering the x-axis. + center_y: float + centering the y-axis. + center_z: float + centering the z-axis. + """ + + def __init__(self, center_x: float = 0.5, center_y: float = 0.5, center_z: float = 0.5): + self.center_ = torch.FloatTensor([[center_x, center_y, center_z]]) + + def __call__(self, data): + data.pos -= (data.pos.amax(0, keepdim=True) + data.pos.amin(0, keepdim=True)) / 2. + data.pos += self.center_ + return data + + def __repr__(self): + return "{}(center_x={},center_y={},center_z={})".format( + self.__class__.__name__, self.center_[0, 0], self.center_[0, 1], self.center_[0, 2], + ) + + +class MoveCenterPosPerSample: + r"""Centers point positions by a defined 'center' function. + Parameters + ----------- + center_x: float + centering the x-axis. + center_y: float + centering the y-axis. + center_z: float + centering the z-axis. + """ + + def __init__(self, center_x: float = 0.5, center_y: float = 0.5, center_z: float = 0.5): + self.center_ = torch.FloatTensor([[center_x, center_y, center_z]]) + + def __call__(self, data): + data.pos += self.center_ + return data + + def __repr__(self): + return "{}(center_x={},center_y={},center_z={})".format( + self.__class__.__name__, self.center_[0, 0], self.center_[0, 1], self.center_[0, 2], + ) + + +class RandomShiftPos: + def __init__(self, max_x: float = 0.01, max_y: float = 0.01, max_z: float = 0.01, p: float = 0.5): + self.max_ = torch.FloatTensor([[max_x, max_y, max_y]]) + self.max_x = max_x + self.max_y = max_y + self.max_z = max_z + self.p = p + + def __call__(self, data): + if random.random() > self.p: + data.pos += (torch.rand(1, 3) * 2 * self.max_) - self.max_ + return data + + def __repr__(self): + return "{}(max_x={},max_y={},max_z={},p={})".format( + self.__class__.__name__, self.max_x, self.max_y, self.max_z, self.p + ) + + +class StartZFromZero: + def __call__(self, data): + data.pos[:, 2] -= data.pos[:, 2].min() + return data + + def __repr__(self): + return "{}()".format(self.__class__.__name__, ) + + +class AddRandomPoints: + r"""Add points randomly within existing cloud bounds. Only works without additional features. + Intended for regression or classification (will not add point-wise labels). + Parameters + ----------- + n_max_points: int + Maximal total number of points (will not add points if there are already many). + add_ratio_min: float + Minimal amount of points to add according to existing number of points. + add_ratio_max: float + Maximal amount of points to add according to existing number of points. + + """ + + def __init__(self, n_max_points: int, add_ratio_min: float, add_ratio_max: float, p: float = 0.5): + self.n_max_points = n_max_points + self.add_ratio_min = add_ratio_min + self.add_ratio_max = add_ratio_max + self.p = p + + def __call__(self, data): + n_ori_points = len(data.pos) + if n_ori_points >= self.n_max_points: + return data + + if self.p > random.random(): + ratio = random.random() * (self.add_ratio_max - self.add_ratio_min) + self.add_ratio_min + n_points = int(ratio * n_ori_points) + n_points += np.amin([0, self.n_max_points - (n_ori_points + n_points)]) # remove points if necessary + + min_ = data.pos.amin(0, keepdim=True) + max_ = data.pos.amin(0, keepdim=True) + random_points = (torch.rand(n_points, data.pos.shape[1]) * (max_ - min_) + min_) + + data.pos = torch.cat([data.pos, random_points], 0) + return data + + def __repr__(self): + return "{}(n_max_points={},add_ratio_min={},add_ratio_max={},p={})".format( + self.__class__.__name__, self.n_max_points, self.add_ratio_min, self.add_ratio_max, self.p + ) + + +class CopyJitterRandomPoints: + r"""Randomly copies and jitters points. Will also copy features and labels (if present) but not alter them. + Parameters + ----------- + n_max_points: int + Maximal total number of points (will not add points if there are already many). + add_ratio_min: float + Minimal amount of points to add according to existing number of points. + add_ratio_max: float + Maximal amount of points to add according to existing number of points. + sigma: + Variance of the noise + clip: + Maximum amplitude of the noise + + + """ + + def __init__(self, n_max_points: int, add_ratio_min: float, add_ratio_max: float, + sigma: float, clip: float, p: float = 0.5): + self.n_max_points = n_max_points + self.add_ratio_min = add_ratio_min + self.add_ratio_max = add_ratio_max + self.sigma = sigma + self.clip = clip + self.p = p + + def __call__(self, data): + n_ori_points = len(data.pos) + if n_ori_points >= self.n_max_points: + return data + + if self.p > random.random(): + ratio = random.random() * (self.add_ratio_max - self.add_ratio_min) + self.add_ratio_min + n_points = int(ratio * n_ori_points) + n_points += np.amin([0, self.n_max_points - (n_ori_points + n_points)]) # remove points if necessary + + idx = np.random.choice(n_ori_points, size=n_points, replace=True) + random_points = data.pos[idx].clone() + noise = self.sigma * torch.randn(random_points.shape) + noise = noise.clamp(-self.clip, self.clip) + random_points += noise + + if data.x is not None: + data.x = torch.cat([data.x, data.x[idx].clone()], 0) + if data.y is not None and len(data.y) == len(data.pos): + data.y = torch.cat([data.y, data.y[idx].clone()], 0) + + data.pos = torch.cat([data.pos, random_points], 0) + return data + + def __repr__(self): + return "{}(n_max_points={},add_ratio_min={},add_ratio_max={},sigma={},clip={},p={})".format( + self.__class__.__name__, self.n_max_points, self.add_ratio_min, self.add_ratio_max, + self.sigma, self.clip, self.p + ) + + +class RandomScaling: + r""" Scales node positions by a randomly sampled factor ``s1, s2, s3`` within a + given interval, *e.g.*, resulting in the transformation matrix + + .. math:: + \left[ + \begin{array}{ccc} + s1 & 0 & 0 \\ + 0 & s2 & 0 \\ + 0 & 0 & s3 \\ + \end{array} + \right] + + + for three-dimensional positions. + + Parameters + ----------- + scales: + scaling factor interval, e.g. ``(a, b)``, then scale \ + is randomly sampled from the range \ + ``a <= b``. \ + """ + + def __init__(self, scales=None): + assert is_iterable(scales) and len(scales) == 2 + assert scales[0] <= scales[1] + self.scales = scales + + def __call__(self, data): + scale = self.scales[0] + torch.rand((3,)) * (self.scales[1] - self.scales[0]) + data.pos = data.pos * scale + if getattr(data, "norm", None) is not None: + data.norm = data.norm / scale + data.norm = torch.nn.functional.normalize(data.norm, dim=1) + return data + + def __repr__(self): + return "{}({})".format(self.__class__.__name__, self.scales) + + +class MeshToNormal(object): + """ Computes mesh normals (IN PROGRESS) + """ + + def __init__(self): + pass + + def __call__(self, data): + if hasattr(data, "face"): + pos = data.pos + face = data.face + vertices = [pos[f] for f in face] + normals = torch.cross(vertices[0] - vertices[1], vertices[0] - vertices[2], dim=1) + normals = F.normalize(normals) + data.normals = normals + return data + + def __repr__(self): + return "{}".format(self.__class__.__name__) + + +class MultiScaleTransform(object): + """ Pre-computes a sequence of downsampling / neighboorhood search on the CPU. + This currently only works on PARTIAL_DENSE formats + + Parameters + ----------- + strategies: Dict[str, object] + Dictionary that contains the samplers and neighbour_finder + """ + + def __init__(self, strategies): + self.strategies = strategies + self.num_layers = len(self.strategies["sampler"]) + + @staticmethod + def __inc__wrapper(func, special_params): + def new__inc__(key, num_nodes, special_params=None, func=None): + if key in special_params: + return special_params[key] + else: + return func(key, num_nodes) + + return partial(new__inc__, special_params=special_params, func=func) + + def __call__(self, data: Data) -> MultiScaleData: + # Compute sequentially multi_scale indexes on cpu + data.contiguous() + ms_data = MultiScaleData.from_data(data) + precomputed = [Data(pos=data.pos)] + upsample = [] + upsample_index = 0 + for index in range(self.num_layers): + sampler, neighbour_finder = self.strategies["sampler"][index], self.strategies["neighbour_finder"][index] + support = precomputed[index] + new_data = Data(pos=support.pos) + if sampler: + query = sampler(new_data.clone()) + query.contiguous() + + if len(self.strategies["upsample_op"]): + if upsample_index >= len(self.strategies["upsample_op"]): + raise ValueError("You are missing some upsample blocks in your network") + + upsampler = self.strategies["upsample_op"][upsample_index] + upsample_index += 1 + pre_up = upsampler.precompute(query, support) + upsample.append(pre_up) + special_params = {} + special_params["x_idx"] = query.num_nodes + special_params["y_idx"] = support.num_nodes + setattr(pre_up, "__inc__", self.__inc__wrapper(pre_up.__inc__, special_params)) + else: + query = new_data + + s_pos, q_pos = support.pos, query.pos + if hasattr(query, "batch"): + s_batch, q_batch = support.batch, query.batch + else: + s_batch, q_batch = ( + torch.zeros((s_pos.shape[0]), dtype=torch.long), + torch.zeros((q_pos.shape[0]), dtype=torch.long), + ) + + idx_neighboors = neighbour_finder(s_pos, q_pos, batch_x=s_batch, batch_y=q_batch) + special_params = {} + special_params["idx_neighboors"] = s_pos.shape[0] + setattr(query, "idx_neighboors", idx_neighboors) + setattr(query, "__inc__", self.__inc__wrapper(query.__inc__, special_params)) + precomputed.append(query) + ms_data.multiscale = precomputed[1:] + upsample.reverse() # Switch to inner layer first + ms_data.upsample = upsample + return ms_data + + def __repr__(self): + return "{}".format(self.__class__.__name__) + + +class ShuffleData(object): + """ This transform allow to shuffle feature, pos and label tensors within data + """ + + def _process(self, data): + return shuffle_data(data) + + def __call__(self, data): + if isinstance(data, list): + data = [self._process(d) for d in tq(data)] + data = list(chain(*data)) # 2d list needs to be flatten + else: + data = self._process(data) + return data + + +class ShiftVoxels: + """ Trick to make Sparse conv invariant to even and odds coordinates + https://github.com/chrischoy/SpatioTemporalSegmentation/blob/master/lib/train.py#L78 + + Parameters + ----------- + apply_shift: bool: + Whether to apply the shift on indices + """ + + def __init__(self, apply_shift=True, p=0.5): + self._apply_shift = apply_shift + self.p = p + + def __call__(self, data): + if self._apply_shift and random.random() < self.p: + if not hasattr(data, "coords"): + raise Exception("should quantize first using GridSampling3D") + + if not isinstance(data.coords, torch.IntTensor): + raise Exception("The pos are expected to be coordinates, so torch.IntTensor") + data.coords[:, :3] += (torch.rand(3) * 100).type_as(data.coords) + return data + + def __repr__(self): + return "{}(apply_shift={})".format(self.__class__.__name__, self._apply_shift) + + +class RandomDropout: + """ Randomly drop points from the input data + + Parameters + ---------- + dropout_ratio : float, optional + Ratio that gets dropped + dropout_application_ratio : float, optional + chances of the dropout to be applied + """ + + def __init__(self, dropout_ratio: float = 0.2, dropout_application_ratio: float = 0.5, min_points: int = 0, + skip_list: list = None): + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) + self.dropout_ratio = dropout_ratio + self.dropout_application_ratio = dropout_application_ratio + self.min_points = min_points + + def __call__(self, data): + N = len(data.pos) + if N > self.min_points and random.random() < self.dropout_application_ratio: + data = FixedPointsOwn(int(N * (1 - self.dropout_ratio)), skip_list=self.skip_list)(data) + return data + + def __repr__(self): + return "{}(dropout_ratio={}, dropout_application_ratio={})".format( + self.__class__.__name__, self.dropout_ratio, self.dropout_application_ratio + ) + + +def apply_mask(data, mask, skip_keys=[]): + size_pos = len(data.pos) + for k in data.keys: + if torch.is_tensor(data[k]) and size_pos == len(data[k]) and k not in skip_keys: + data[k] = data[k][mask] + return data + + +@numba.jit(nopython=True, cache=True) +def rw_mask(pos, ind, dist, mask_vertices, random_ratio=0.04, num_iter=5000): + rand_ind = np.random.randint(0, len(pos)) + for _ in range(num_iter): + mask_vertices[rand_ind] = False + if np.random.rand() < random_ratio: + rand_ind = np.random.randint(0, len(pos)) + else: + neighbors = ind[rand_ind][dist[rand_ind] > 0] + if len(neighbors) == 0: + rand_ind = np.random.randint(0, len(pos)) + else: + n_i = np.random.randint(0, len(neighbors)) + rand_ind = neighbors[n_i] + return mask_vertices + + +def topview_sample(data, num_samples: int): + # simulates a little airborne lidar behavior (discarding of lower points more likely) + num_nodes = data.num_nodes + z = data.pos[:, 2].numpy() + choice = random.choices(np.arange(num_nodes), weights=z, k=num_samples) + + for key, item in data: + if key == 'num_nodes': + data.num_nodes = choice.size(0) + elif (torch.is_tensor(item) and item.size(0) == num_nodes + and item.size(0) != 1): + data[key] = item[choice] + + return data + + +class RandomGroundRemoval: + def __init__(self, min_v: float, max_v: float, p: float = 0.5, min_points: int = 500, skip_list: list = None): + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) + self.min_v = min_v + self.max_v = max_v + self.range = max_v - min_v + self.p = p + self.min_points = min_points + + def __call__(self, data): + if random.random() < self.p: + pos = data.pos + remove_v = random.random() * self.range + self.min_v + cond = pos[:, 2] > remove_v + if cond.sum() < self.min_points: + return data + pos[:, 2] -= remove_v + data = apply_mask(data, cond, self.skip_list) + + return data + + +class RadiusObjectAdder: + def __init__(self, areas, root_folder: str, dataset_name: str, processed_folder: str, + min_radius: float, max_radius: float, n_max_objects, + rot_x: float, rot_y: float, rot_z: float, indicator_key: str = None, + adjust_point_density: bool = False, density_topview_sample: bool = False, density_index: int = 0, + density_adjustment: list = 1., split: str = "train", zero_center_z: bool = False, + only_doubled_batch: bool = False, in_memory: bool = False, p: float = 0.5): + areas = OmegaConf.to_container(areas) + self.areas = {area: areas[area] for area in areas if areas[area]["type"] == "object"} + self.processed_dir = PPath(os.path.join(root_folder, dataset_name, processed_folder, split)) + self.object_files = list(chain(*[glob(str(self.processed_dir / f"{area}/*.pt")) for area in self.areas])) + + self.min_radius = min_radius + self.max_radius = max_radius + self.adjust_point_density = adjust_point_density + if adjust_point_density: + self.density_index = density_index + self.density_topview_sample = density_topview_sample + # adjust original data density given as range + # (e.g., 0.5 will decrease original point density, + # thus potentially removing more points from the added object) + self.density_adjustment = (density_adjustment[0], density_adjustment[1]) + self.memory = {} + self.in_memory = in_memory + self.random_rotation = Random3AxisRotation(rot_x=rot_x, rot_y=rot_y, rot_z=rot_z) + if isinstance(n_max_objects, int): + n_max_objects = { + "object": n_max_objects, + "scene": n_max_objects, + } + self.n_max_objects: dict = n_max_objects + self.p: float = p + self.zero_center_z = zero_center_z + self.indicator_key = indicator_key + self.only_doubled_batch = only_doubled_batch + + def __call__(self, data): + if len(self.object_files) == 0: + self.object_files = list(chain(*[glob(str(self.processed_dir / f"{area}/*.pt")) for area in self.areas])) + assert len(self.object_files) > 0, "no objects given for RadiusObjectAdder" + ori_n = None + if random.random() < self.p and ( + not self.only_doubled_batch or (self.only_doubled_batch and data.get("is_double", False))): + sample_type = "object" if data.area_name in self.areas else "scene" + n_objects = random.randint(1, self.n_max_objects[sample_type]) + files = np.random.choice(self.object_files, n_objects, replace=True) + pos_ = [] + feat_ = [] + i = 0 + while i < len(files): + file = files[i] + i += 1 + if self.in_memory: + new_object = self.memory.get(file, None) + if new_object is None: + new_object = torch.load(file) + self.memory[file] = new_object.clone() + else: + new_object = new_object.clone() + else: + new_object = torch.load(file) + if self.zero_center_z: + new_object.pos[:, 2] -= new_object.pos[:, 2].min() + new_object = self.random_rotation(new_object) + + if self.adjust_point_density: + # only removes points if too dense, will not add points + sample_density = data["local_stats"][self.density_index] + obj_density = new_object["local_stats"][self.density_index] + density_adjustment_factor = random.random() + density_adjustment_factor *= self.density_adjustment[1] - self.density_adjustment[0] + density_adjustment_factor += self.density_adjustment[0] + drop_ratio = (sample_density * density_adjustment_factor) / obj_density + if drop_ratio < 1: + if self.density_topview_sample: + new_object = topview_sample(new_object, int(drop_ratio * len(new_object.pos))) + else: + new_object = FP(int(drop_ratio * len(new_object.pos)), replace=False)(new_object) + + # random point in outer circle + angle = random.uniform(0, 2 * math.pi) + + min_radius = self.min_radius + max_radius = self.max_radius + # add safety margin if we are given center deviation + if "pos_deviation" in new_object: + min_radius += (new_object["pos_deviation"] ** 2).sum() ** .5 / 2 # pythagoras + if min_radius > max_radius: # add another object to list and skip this one + files = np.concatenate([files, np.random.choice(self.object_files, 1)], axis=0) + continue + radius = random.uniform(min_radius, max_radius) + shift = torch.tensor(([[math.cos(angle), math.sin(angle), 0]])) * radius # no shift in z + + pos_.append(new_object.pos + shift) + feat_.append(new_object.x) + + ori_n = len(data.pos) + data.pos = torch.cat([data.pos, *pos_], 0) + if data.x is not None: + if len(feat_) > 0 and feat_[0] is not None: + data.x = torch.cat([data.x, *feat_], 0) + else: + data.x = torch.cat([data.x, torch.zeros(len(data.pos) - ori_n, data.x.shape[1])], 0) + + if self.indicator_key is not None: + if ori_n is not None: + indicator = torch.zeros(len(data.pos)) + indicator[ori_n:] = True + else: + indicator = torch.zeros(len(data.pos)) + + data[self.indicator_key] = indicator + return data + + +class CubeCrop(object): + """ + Crop cubically the point cloud. This function take a cube of size c + centered on a random point, then points outside the cube are rejected. + + Parameters + ---------- + c: float, optional + half size of the cube + rot_x: float_otional + rotation of the cube around x axis + rot_y: float_otional + rotation of the cube around x axis + rot_z: float_otional + rotation of the cube around x axis + """ + + def __init__( + self, c: float = 1, rot_x: float = 180, rot_y: float = 180, rot_z: float = 180, + grid_size_center: float = 0.01 + ): + self.c = c + self.random_rotation = Random3AxisRotation(rot_x=rot_x, rot_y=rot_y, rot_z=rot_z) + self.grid_sampling = GridSampling3D(grid_size_center, mode="last") + + def __call__(self, data): + data_c = self.grid_sampling(data.clone()) + data_temp = data.clone() + i = torch.randint(0, len(data_c.pos), (1,)) + center = data_c.pos[i] + min_square = center - self.c + max_square = center + self.c + data_temp.pos = data_temp.pos - center + data_temp = self.random_rotation(data_temp) + data_temp.pos = data_temp.pos + center + mask = torch.prod((data_temp.pos - min_square) > 0, dim=1) * torch.prod((max_square - data_temp.pos) > 0, dim=1) + mask = mask.to(torch.bool) + data = apply_mask(data, mask) + return data + + def __repr__(self): + return "{}(c={}, rotation={})".format(self.__class__.__name__, self.c, self.random_rotation) + + +class FixedPointsOwn(object): + r"""Samples a fixed number of :obj:`num` points and features from a point + cloud (functional name: :obj:`fixed_points`). + + Args: + num (int): The number of points to sample. + replace (bool, optional): If set to :obj:`False`, samples points + without replacement. (default: :obj:`True`) + allow_duplicates (bool, optional): In case :obj:`replace` is + :obj`False` and :obj:`num` is greater than the number of points, + this option determines whether to add duplicated nodes to the + output points or not. + In case :obj:`allow_duplicates` is :obj:`False`, the number of + output points might be smaller than :obj:`num`. + In case :obj:`allow_duplicates` is :obj:`True`, the number of + duplicated points are kept to a minimum. (default: :obj:`False`) + """ + + def __init__(self, num, replace=False, allow_duplicates=True, skip_list: list = None): + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) if isinstance(skip_list, + OmegaConf) else skip_list + self.num = num + self.replace = replace + self.allow_duplicates = allow_duplicates + + def __call__(self, data): + num_nodes = data.num_nodes + + if self.replace: + choice = np.random.choice(num_nodes, self.num, replace=True) + choice = torch.from_numpy(choice).to(torch.long) + elif not self.allow_duplicates: + choice = torch.randperm(num_nodes)[:self.num] + else: + choice = torch.cat([ + torch.randperm(num_nodes) + for _ in range(math.ceil(self.num / num_nodes)) + ], dim=0)[:self.num] + + for key, item in data: + if key == 'num_nodes': + data.num_nodes = choice.size(0) + elif bool(re.search('edge', key)): + continue + elif (torch.is_tensor(item) and item.size(0) == num_nodes and key not in self.skip_list + and (item.size(0) != 1) or key == "pos"): + data[key] = item[choice] + assert data.pos.shape[ + 0] == self.num, f"pos: {data.pos.shape}, y: {data.y_mol.shape}, {data.y_mol_mask.shape}, choice: {len(choice)} {self.num}" + return data + + +class CylinderExtend(object): + """ + Restrict extend the point cloud to a cylinder. This function take a radius + centered at the origin, then points outside are rejected. + Parameters + ---------- + radius: float + half size of the x axis of the rectangle + skip_list: list + list of keys not to mask away + """ + + def __init__(self, radius: float, skip_list: list = None): + self.radius = radius + self.skip_list = skip_list + + def __call__(self, data): + pos = data.pos + if not hasattr(data, KDTREE_KEY): + tree = KDTree(np.asarray(pos[:, :-1]), leaf_size=50) + setattr(data, KDTREE_KEY, tree) + else: + tree = getattr(data, KDTREE_KEY) + idx = tree.query_radius([[0., 0.]], self.radius)[0] + mask = torch.zeros(len(pos)).bool() + mask[idx] = True + + data = apply_mask(data, mask, self.skip_list) + return data + + def __repr__(self): + return "{}(radius={}, skip_list={})".format(self.__class__.__name__, self.radius, self.skip_list) + + +class RectangleExtend(object): + """ + Restrict extend the point cloud to a rectangle. This function take a rectangle of size (e_x, e_y, e_z) + centered at the origin, then points outside are rejected. + Parameters + ---------- + e_x: float, optional + half size of the x axis of the rectangle + e_y: float, optional + half size of the y axis of the rectangle + e_z: float, optional + half size of the z axis of the rectangle + """ + + def __init__(self, e_x: float = 1, e_y: float = 1, e_z: float = 1, ): + self.e_x = e_x + self.e_y = e_y + self.e_z = e_z + + def __call__(self, data): + pos = data.pos + posx = pos[:, 0] + posy = pos[:, 1] + posz = pos[:, 2] + mask = (posx < self.e_x) & (posx > -self.e_x) & \ + (posy < self.e_y) & (posx > -self.e_y) & \ + (posz < self.e_z) & (posz > -self.e_z) + data = apply_mask(data, mask) + return data + + def __repr__(self): + return "{}(e_x={}, e_y={}, e_z={})".format(self.__class__.__name__, self.e_x, self.e_y, self.e_z) + + +def append_skeleton(self, data, skeleton): + if self.cage_skeleton: + min_z = data.pos[:, -1].min() + max_z = data.pos[:, -1].max() + heights = torch.arange(min_z, max_z + self.height_skeleton_pts, self.height_skeleton_pts).float() + n_heights = len(heights) + n_pts = len(skeleton) + skeleton = skeleton.repeat_interleave(n_heights, 0) + skeleton[:, 2] *= heights.reshape(-1).repeat(n_pts) + else: + + skeleton *= self.height_skeleton_pts + num_skeleton_pts = len(skeleton) + indicator = torch.zeros(len(data.pos) + num_skeleton_pts) + indicator[-num_skeleton_pts:] = 1.0 + # add empty features for skeleton + size_pos = len(data.pos) + for k in data.keys: + if torch.is_tensor(data[k]) and size_pos == len(data[k]) and k not in ["pos"] + self.skip_list: + dtype = data[k].dtype + if len(data[k].shape) > 1: + n_feat = data[k].shape[1] + data[k] = torch.cat([data[k], torch.ones(num_skeleton_pts, n_feat, dtype=dtype)], 0) + else: + data[k] = torch.cat([data[k], torch.ones(num_skeleton_pts, dtype=dtype)], 0) + data["skeleton"] = indicator + data["pos"] = torch.cat([data["pos"], skeleton], 0) + + +class Polygon2dExtend(object): + """ + Restrict extend the point cloud to a given polygon. This function takes point tuples of size + (e.g., [[0, 1], [1, 0], [1, 1]]). + centered at the origin, then points outside are rejected. + + Parameters + ---------- + polygon: list + List of tuples containing the border points of the polygon + """ + + def __init__(self, polygon, skip_list: list = None, add_skeleton_pts: bool = False, + num_skeleton_pts: int = 100, height_skeleton_pts: float = 1.0, + cage_skeleton: bool = False): + self.polygon = Path(polygon) + + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) + + self.add_skeleton_pts = add_skeleton_pts + self.num_skeleton_pts = num_skeleton_pts + self.height_skeleton_pts = height_skeleton_pts + self.cage_skeleton = cage_skeleton + + if add_skeleton_pts: + skeleton = torch.tensor(self.polygon.interpolated(self.num_skeleton_pts).vertices).float() + self.skeleton = torch.cat([skeleton, torch.ones(len(skeleton), 1)], 1) + + def __call__(self, data): + pos = data.pos[:, [0, 1]] + mask = self.polygon.contains_points(pos) + data = apply_mask(data, mask, self.skip_list) + if self.add_skeleton_pts: + append_skeleton(self, data, self.skeleton) + + return data + + def __repr__(self): + return "{}(polygon={})".format(self.__class__.__name__, self.polygon.to_polygons()) + + +class RandomPolygon2dExtend(object): + """ + Restrict extend the point cloud to a given polygon. This function takes point tuples of size + (e.g., [[0, 1], [1, 0], [1, 1]]). + centered at the origin, then points outside are rejected. + + Parameters + ---------- + polygons: list + List of polygons, each defined by tuples containing the border points + """ + + def __init__(self, polygons: list, skip_list: list = None, size_min: float = 1, size_max: float = 1, + rotate: float = 180, + add_skeleton_pts: bool = False, num_skeleton_pts: int = 100, height_skeleton_pts: float = 1.0, + cage_skeleton: bool = False): + self.polygons = [polygon for polygon in polygons] + self.n_p = len(self.polygons) + self.size_min = size_min + self.size_max = size_max + self.rotate = rotate + + self.skip_list = [] if skip_list is None else OmegaConf.to_object(skip_list) + + self.add_skeleton_pts = add_skeleton_pts + self.num_skeleton_pts = num_skeleton_pts + self.height_skeleton_pts = height_skeleton_pts + self.cage_skeleton = cage_skeleton + + def __call__(self, data): + pos = data.pos[:, [0, 1]] + polygon = self.polygons[np.random.choice(self.n_p)] + if polygon != "None": + rand_scale = np.random.rand() * (self.size_max - self.size_min) + self.size_min + trans = (1 - rand_scale) / 2 + rand_rotate = np.random.rand() * self.rotate * np.sign(np.random.rand() - .5) + A = Affine2D().scale(rand_scale).translate(trans, trans).rotate_deg_around(0.5, 0.5, rand_rotate) + polygon = Path(polygon).transformed(A) + mask = polygon.contains_points(pos) + if mask.sum() > 0: # apply masking if any points remain + data = apply_mask(data, mask, self.skip_list) + if self.add_skeleton_pts: + skeleton = torch.tensor(polygon.interpolated(self.num_skeleton_pts).vertices).to(pos.dtype) + skeleton = torch.cat([skeleton, torch.ones(len(skeleton), 1)], 1) + + append_skeleton(self, data, skeleton) + elif self.add_skeleton_pts: + data["skeleton"] = torch.zeros(len(data.pos), 1) + return data + + def __repr__(self): + return "{}(polygons={}, size_min={}, size_max={}, rotate={})".format( + self.__class__.__name__, str(self.polygons), self.size_min, self.size_max, self.rotate + ) + + +class EllipsoidCrop(object): + """ + + """ + + def __init__( + self, a: float = 1, b: float = 1, c: float = 1, rot_x: float = 180, rot_y: float = 180, rot_z: float = 180 + ): + """ + Crop with respect to an ellipsoid. + the function of an ellipse is defined as: + + Parameters + ---------- + a: float, optional + half size of the cube + b: float_otional + rotation of the cube around x axis + c: float_otional + rotation of the cube around x axis + + + """ + self._a2 = a ** 2 + self._b2 = b ** 2 + self._c2 = c ** 2 + self.random_rotation = Random3AxisRotation(rot_x=rot_x, rot_y=rot_y, rot_z=rot_z) + + def _compute_mask(self, pos: torch.Tensor): + mask = (pos[:, 0] ** 2 / self._a2 + pos[:, 1] ** 2 / self._b2 + pos[:, 2] ** 2 / self._c2) < 1 + return mask + + def __call__(self, data): + data_temp = data.clone() + i = torch.randint(0, len(data.pos), (1,)) + data_temp = self.random_rotation(data_temp) + center = data_temp.pos[i] + data_temp.pos = data_temp.pos - center + mask = self._compute_mask(data_temp.pos) + data = apply_mask(data, mask) + return data + + def __repr__(self): + return "{}(a={}, b={}, c={}, rotation={})".format( + self.__class__.__name__, np.sqrt(self._a2), np.sqrt(self._b2), np.sqrt(self._c2), self.random_rotation + ) + + +class ZFilter(object): + """ + Remove points lower or higher than certain values + """ + + def __init__(self, z_min, z_max, skip_keys: List = []): + self.z_min = z_min + self.z_max = z_max + self.skip_keys = skip_keys + + def __call__(self, data): + z = data.pos[:, 2] + mask = (z > self.z_min) & (z < self.z_max) + + data = apply_mask(data, mask, self.skip_keys) + return data + + def __repr__(self): + return "{}(z_min={}, z_max={}, skip_keys={})".format( + self.__class__.__name__, self.z_min, self.z_max, self.skip_keys + ) + + +class DensityFilter(object): + """ + Remove points with a low density(compute the density with a radius search and remove points with) + a low number of neighbors + + Parameters + ---------- + radius_nn: float, optional + radius for the neighbors search + min_num: int, optional + minimum number of neighbors to be dense + skip_keys: int, optional + list of attributes of data to skip when we apply the mask + """ + + def __init__(self, radius_nn: float = 0.04, min_num: int = 6, skip_keys: List = []): + self.radius_nn = radius_nn + self.min_num = min_num + self.skip_keys = skip_keys + + def __call__(self, data): + ind, dist = ball_query(data.pos, data.pos, radius=self.radius_nn, max_num=-1, mode=0) + + mask = (dist > 0).sum(1) > self.min_num + data = apply_mask(data, mask, self.skip_keys) + return data + + def __repr__(self): + return "{}(radius_nn={}, min_num={}, skip_keys={})".format( + self.__class__.__name__, self.radius_nn, self.min_num, self.skip_keys + ) + + +class IrregularSampling(object): + """ + a sort of soft crop. the more we are far from the center, the more it is unlikely to choose the point + """ + + def __init__(self, d_half=2.5, p=2, grid_size_center=0.1, skip_keys=[]): + self.d_half = d_half + self.p = p + self.skip_keys = skip_keys + self.grid_sampling = GridSampling3D(grid_size_center, mode="last") + + def __call__(self, data): + data_temp = self.grid_sampling(data.clone()) + i = torch.randint(0, len(data_temp.pos), (1,)) + center = data_temp.pos[i] + + d_p = (torch.abs(data.pos - center) ** self.p).sum(1) + + sigma_2 = (self.d_half ** self.p) / (2 * np.log(2)) + thresh = torch.exp(-d_p / (2 * sigma_2)) + + mask = torch.rand(len(data.pos)) < thresh + data = apply_mask(data, mask, self.skip_keys) + return data + + def __repr__(self): + return "{}(d_half={}, p={}, skip_keys={})".format(self.__class__.__name__, self.d_half, self.p, self.skip_keys) + + +class PeriodicSampling(object): + """ + sample point at a periodic distance + """ + + def __init__(self, period=0.1, prop=0.1, box_multiplier=1, skip_keys=[]): + self.pulse = 2 * np.pi / period + self.thresh = np.cos(self.pulse * prop * period * 0.5) + self.box_multiplier = box_multiplier + self.skip_keys = skip_keys + + def __call__(self, data): + data_temp = data.clone() + max_p = data_temp.pos.max(0)[0] + min_p = data_temp.pos.min(0)[0] + + center = self.box_multiplier * torch.rand(3) * (max_p - min_p) + min_p + d_p = torch.norm(data.pos - center, dim=1) + mask = torch.cos(self.pulse * d_p) > self.thresh + data = apply_mask(data, mask, self.skip_keys) + return data + + def __repr__(self): + return "{}(pulse={}, thresh={}, box_mullti={}, skip_keys={})".format( + self.__class__.__name__, self.pulse, self.thresh, self.box_multiplier, self.skip_keys + ) + + +class AddGround: + '''simple class to add "n_points" ground points if less than "max_points" are present in a unit radius''' + + def __init__(self, max_points: int, n_points: int, xy_min: float = 0, xy_max: float = 1): + self.max_points = max_points + self.n_points = n_points + self.xy_range = (xy_max - xy_min) / 2. + self.xy_min = xy_min + + def __call__(self, data): + nodes = data.num_nodes + if nodes < self.max_points: + data.pos = torch.rand(self.n_points, 3) * self.xy_range + self.xy_min + data.pos[:, 2] = 0.0 + + return data + + def __repr__(self): + return "{}(max_points={}, n_points={})".format( + self.__class__.__name__, self.max_points, self.n_points + ) + + +class MinPoints(FixedPointsOwn): + r"""Samples a minimal number of :obj:`num` points and features from a point + cloud. + + Args: + num (int): The number of minimal points in point_idxs, resamples with replacement if less are present. + """ + + def __init__(self, num, skip_list: list = None): + super().__init__(num, False, True, skip_list) + + def __call__(self, data): + num_nodes = data.num_nodes + + if num_nodes < self.num: + # TODO verify state is persistent + state = np.random.get_state() + np.random.set_state(np.random.RandomState(42).get_state()) + data = super().__call__(data) + np.random.set_state(state) + return data + + return data + + def __repr__(self): + return "{}(num={}, skip_list={})".format( + self.__class__.__name__, self.num, self.skip_list + ) + + +class MaxPoints(FixedPointsOwn): + r"""Samples a maximal number of :obj:`num` points and features from a point + cloud. + + Args: + num (int): The number to maximal number of points in point_idxs, resamples without replacement. + """ + + def __init__(self, num, skip_list: list = None): + super().__init__(num, False, False, skip_list) + + def __call__(self, data): + num_nodes = data.num_nodes + + if num_nodes > self.num: + # TODO verify state is persistent + data = super().__call__(data) + return data + + return data + + def __repr__(self): + return "{}(num={}, skip_list={})".format( + self.__class__.__name__, self.num, self.skip_list + ) diff --git a/torch-points3d/torch_points3d/core/initializer/__init__.py b/torch-points3d/torch_points3d/core/initializer/__init__.py new file mode 100644 index 0000000..07c9955 --- /dev/null +++ b/torch-points3d/torch_points3d/core/initializer/__init__.py @@ -0,0 +1 @@ +from .initializer import * diff --git a/torch-points3d/torch_points3d/core/initializer/initializer.py b/torch-points3d/torch_points3d/core/initializer/initializer.py new file mode 100644 index 0000000..6cc0b7b --- /dev/null +++ b/torch-points3d/torch_points3d/core/initializer/initializer.py @@ -0,0 +1,35 @@ +import torch +from torch.nn import init + + +def init_weights(net, init_type="normal", gain=0.02): + def init_func(m): + classname = m.__class__.__name__ + if hasattr(m, "weight") and (classname.find("Conv") != -1 or classname.find("Linear") != -1): + if init_type == "normal": + init.normal_(m.weight.data, 0.0, gain) + elif init_type == "xavier": + init.xavier_normal_(m.weight.data, gain=gain) + elif init_type == "kaiming": + init.kaiming_normal_(m.weight.data, a=0, mode="fan_in") + elif init_type == "orthogonal": + init.orthogonal_(m.weight.data, gain=gain) + else: + raise NotImplementedError("initialization method [%s] is not implemented" % init_type) + if hasattr(m, "bias") and m.bias is not None: + init.constant_(m.bias.data, 0.0) + elif classname.find("BatchNorm2d") != -1: + init.normal_(m.weight.data, 1.0, gain) + init.constant_(m.bias.data, 0.0) + + print("initialize network with %s" % init_type) + net.apply(init_func) + + +def init_net(net, init_type="normal", init_gain=0.02, gpu_ids=[]): + if len(gpu_ids) > 0: + assert torch.cuda.is_available() + net.to(gpu_ids[0]) + net = torch.nn.DataParallel(net, gpu_ids) + init_weights(net, init_type, gain=init_gain) + return net diff --git a/torch-points3d/torch_points3d/core/losses/__init__.py b/torch-points3d/torch_points3d/core/losses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/core/losses/focal_loss.py b/torch-points3d/torch_points3d/core/losses/focal_loss.py new file mode 100644 index 0000000..c9f377f --- /dev/null +++ b/torch-points3d/torch_points3d/core/losses/focal_loss.py @@ -0,0 +1,48 @@ +import torch +import torch.nn.functional as F + + +def focal_ce(input, target, alpha=None, gamma=2, reduction="mean", label_smoothing=0.0): + """ + input: [N, C], float32 + target: [N, ], int64 + """ + assert 0 <= label_smoothing < 1 + + if input.ndim > 2: + # (N, C, d1, d2, ..., dK) --> (N * d1 * ... * dK, C) + c = input.shape[1] + input = input.permute(0, *range(2, input.ndim), 1).reshape(-1, c) + # (N, d1, d2, ..., dK) --> (N * d1 * ... * dK,) + target = target.view(-1) + + # compute weighted cross entropy term: -alpha * log(pt) + # (alpha is already part of self.nll_loss) + log_p = F.log_softmax(input, dim=-1) + ce = F.nll_loss(log_p, target, weight=alpha, reduction="none") + if label_smoothing != 0: + confidence = 1.0 - label_smoothing + smoothing = label_smoothing + smooth_loss = -log_p.mean(dim=-1) + ce = confidence * ce + smoothing * smooth_loss + + # get true class column from each row + all_rows = torch.arange(len(input)) + log_pt = log_p[all_rows, target] + + # compute focal term: (1 - pt)^gamma + pt = log_pt.exp() + focal_term = (1 - pt) ** gamma + + # the full loss: -alpha * ((1 - pt)^gamma) * log(pt) + loss = focal_term * ce + + + + if reduction == 'mean': + loss = loss.mean() + elif reduction == 'sum': + loss = loss.sum() + + + return loss diff --git a/torch-points3d/torch_points3d/core/optimizer/__init__.py b/torch-points3d/torch_points3d/core/optimizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/core/optimizer/adabelief.py b/torch-points3d/torch_points3d/core/optimizer/adabelief.py new file mode 100644 index 0000000..80a0de0 --- /dev/null +++ b/torch-points3d/torch_points3d/core/optimizer/adabelief.py @@ -0,0 +1,201 @@ +import math +import torch +from torch.optim.optimizer import Optimizer + + +class AdaBelief(Optimizer): + r"""Implements AdaBelief algorithm. Modified from Adam in PyTorch + + Arguments: + params (iterable): iterable of parameters to optimize or dicts defining + parameter groups + lr (float, optional): learning rate (default: 1e-3) + betas (Tuple[float, float], optional): coefficients used for computing + running averages of gradient and its square (default: (0.9, 0.999)) + eps (float, optional): term added to the denominator to improve + numerical stability (default: 1e-16) + weight_decay (float, optional): weight decay (L2 penalty) (default: 0) + amsgrad (boolean, optional): whether to use the AMSGrad variant of this + algorithm from the paper `On the Convergence of Adam and Beyond`_ + (default: False) + decoupled_decay (boolean, optional): (default: True) If set as True, then + the optimizer uses decoupled weight decay as in AdamW + fixed_decay (boolean, optional): (default: False) This is used when weight_decouple + is set as True. + When fixed_decay == True, the weight decay is performed as + $W_{new} = W_{old} - W_{old} \times decay$. + When fixed_decay == False, the weight decay is performed as + $W_{new} = W_{old} - W_{old} \times decay \times lr$. Note that in this case, the + weight decay ratio decreases with learning rate (lr). + rectify (boolean, optional): (default: True) If set as True, then perform the rectified + update similar to RAdam + degenerated_to_sgd (boolean, optional) (default:True) If set as True, then perform SGD update + when variance of gradient is high + reference: AdaBelief Optimizer, adapting stepsizes by the belief in observed gradients, NeurIPS 2020 + + For a complete table of recommended hyperparameters, see https://github.com/juntang-zhuang/Adabelief-Optimizer' + For example train/args for EfficientNet see these gists + - link to train_scipt: https://gist.github.com/juntang-zhuang/0a501dd51c02278d952cf159bc233037 + - link to args.yaml: https://gist.github.com/juntang-zhuang/517ce3c27022b908bb93f78e4f786dc3 + """ + + def __init__( + self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-16, weight_decay=0, amsgrad=False, + decoupled_decay=True, fixed_decay=False, rectify=True, degenerated_to_sgd=True): + + if not 0.0 <= lr: + raise ValueError("Invalid learning rate: {}".format(lr)) + if not 0.0 <= eps: + raise ValueError("Invalid epsilon value: {}".format(eps)) + if not 0.0 <= betas[0] < 1.0: + raise ValueError("Invalid beta parameter at index 0: {}".format(betas[0])) + if not 0.0 <= betas[1] < 1.0: + raise ValueError("Invalid beta parameter at index 1: {}".format(betas[1])) + + if isinstance(params, (list, tuple)) and len(params) > 0 and isinstance(params[0], dict): + for param in params: + if 'betas' in param and (param['betas'][0] != betas[0] or param['betas'][1] != betas[1]): + param['buffer'] = [[None, None, None] for _ in range(10)] + + defaults = dict( + lr=lr, betas=betas, eps=eps, weight_decay=weight_decay, amsgrad=amsgrad, + degenerated_to_sgd=degenerated_to_sgd, decoupled_decay=decoupled_decay, rectify=rectify, + fixed_decay=fixed_decay, buffer=[[None, None, None] for _ in range(10)]) + super(AdaBelief, self).__init__(params, defaults) + + def __setstate__(self, state): + super(AdaBelief, self).__setstate__(state) + for group in self.param_groups: + group.setdefault('amsgrad', False) + + @torch.no_grad() + def reset(self): + for group in self.param_groups: + for p in group['params']: + state = self.state[p] + amsgrad = group['amsgrad'] + + # State initialization + state['step'] = 0 + # Exponential moving average of gradient values + state['exp_avg'] = torch.zeros_like(p) + + # Exponential moving average of squared gradient values + state['exp_avg_var'] = torch.zeros_like(p) + if amsgrad: + # Maintains max of all exp. moving avg. of sq. grad. values + state['max_exp_avg_var'] = torch.zeros_like(p) + + @torch.no_grad() + def step(self, closure=None): + """Performs a single optimization step. + Arguments: + closure (callable, optional): A closure that reevaluates the model + and returns the loss. + """ + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + for p in group['params']: + if p.grad is None: + continue + grad = p.grad + if grad.dtype in {torch.float16, torch.bfloat16}: + grad = grad.float() + if grad.is_sparse: + raise RuntimeError( + 'AdaBelief does not support sparse gradients, please consider SparseAdam instead') + + p_fp32 = p + if p.dtype in {torch.float16, torch.bfloat16}: + p_fp32 = p_fp32.float() + + amsgrad = group['amsgrad'] + beta1, beta2 = group['betas'] + state = self.state[p] + # State initialization + if len(state) == 0: + state['step'] = 0 + # Exponential moving average of gradient values + state['exp_avg'] = torch.zeros_like(p_fp32) + # Exponential moving average of squared gradient values + state['exp_avg_var'] = torch.zeros_like(p_fp32) + if amsgrad: + # Maintains max of all exp. moving avg. of sq. grad. values + state['max_exp_avg_var'] = torch.zeros_like(p_fp32) + + # perform weight decay, check if decoupled weight decay + if group['decoupled_decay']: + if not group['fixed_decay']: + p_fp32.mul_(1.0 - group['lr'] * group['weight_decay']) + else: + p_fp32.mul_(1.0 - group['weight_decay']) + else: + if group['weight_decay'] != 0: + grad.add_(p_fp32, alpha=group['weight_decay']) + + # get current state variable + exp_avg, exp_avg_var = state['exp_avg'], state['exp_avg_var'] + + state['step'] += 1 + bias_correction1 = 1 - beta1 ** state['step'] + bias_correction2 = 1 - beta2 ** state['step'] + + # Update first and second moment running average + exp_avg.mul_(beta1).add_(grad, alpha=1 - beta1) + grad_residual = grad - exp_avg + exp_avg_var.mul_(beta2).addcmul_(grad_residual, grad_residual, value=1 - beta2) + + if amsgrad: + max_exp_avg_var = state['max_exp_avg_var'] + # Maintains the maximum of all 2nd moment running avg. till now + torch.max(max_exp_avg_var, exp_avg_var.add_(group['eps']), out=max_exp_avg_var) + + # Use the max. for normalizing running avg. of gradient + denom = (max_exp_avg_var.sqrt() / math.sqrt(bias_correction2)).add_(group['eps']) + else: + denom = (exp_avg_var.add_(group['eps']).sqrt() / math.sqrt(bias_correction2)).add_(group['eps']) + + # update + if not group['rectify']: + # Default update + step_size = group['lr'] / bias_correction1 + p_fp32.addcdiv_(exp_avg, denom, value=-step_size) + else: + # Rectified update, forked from RAdam + buffered = group['buffer'][int(state['step'] % 10)] + if state['step'] == buffered[0]: + num_sma, step_size = buffered[1], buffered[2] + else: + buffered[0] = state['step'] + beta2_t = beta2 ** state['step'] + num_sma_max = 2 / (1 - beta2) - 1 + num_sma = num_sma_max - 2 * state['step'] * beta2_t / (1 - beta2_t) + buffered[1] = num_sma + + # more conservative since it's an approximated value + if num_sma >= 5: + step_size = math.sqrt( + (1 - beta2_t) * + (num_sma - 4) / (num_sma_max - 4) * + (num_sma - 2) / num_sma * + num_sma_max / (num_sma_max - 2)) / (1 - beta1 ** state['step']) + elif group['degenerated_to_sgd']: + step_size = 1.0 / (1 - beta1 ** state['step']) + else: + step_size = -1 + buffered[2] = step_size + + if num_sma >= 5: + denom = exp_avg_var.sqrt().add_(group['eps']) + p_fp32.addcdiv_(exp_avg, denom, value=-step_size * group['lr']) + elif step_size > 0: + p_fp32.add_(exp_avg, alpha=-step_size * group['lr']) + + if p.dtype in {torch.float16, torch.bfloat16}: + p.copy_(p_fp32) + + return loss diff --git a/torch-points3d/torch_points3d/core/regularizer/__init__.py b/torch-points3d/torch_points3d/core/regularizer/__init__.py new file mode 100644 index 0000000..843295f --- /dev/null +++ b/torch-points3d/torch_points3d/core/regularizer/__init__.py @@ -0,0 +1 @@ +from .regularizers import * diff --git a/torch-points3d/torch_points3d/core/regularizer/regularizers.py b/torch-points3d/torch_points3d/core/regularizer/regularizers.py new file mode 100644 index 0000000..4f8fb87 --- /dev/null +++ b/torch-points3d/torch_points3d/core/regularizer/regularizers.py @@ -0,0 +1,202 @@ +from enum import Enum + + +class _Regularizer(object): + """ + Parent class of Regularizers + """ + + def __init__(self, model): + super(_Regularizer, self).__init__() + self.model = model + + def regularized_param(self, param_weights, reg_loss_function): + raise NotImplementedError + + def regularized_all_param(self, reg_loss_function): + raise NotImplementedError + + +class L1Regularizer(_Regularizer): + """ + L1 regularized loss + """ + + def __init__(self, model, lambda_reg=0.01): + super(L1Regularizer, self).__init__(model=model) + self.lambda_reg = lambda_reg + + def regularized_param(self, param_weights, reg_loss_function): + reg_loss_function += self.lambda_reg * L1Regularizer.__add_l1(var=param_weights) + return reg_loss_function + + def regularized_all_param(self, reg_loss_function): + for model_param_name, model_param_value in self.model.named_parameters(): + if ( + model_param_name.endswith("weight") + and "1.weight" not in model_param_name + and "bn" not in model_param_name + ): + reg_loss_function += self.lambda_reg * L1Regularizer.__add_l1(var=model_param_value) + return reg_loss_function + + @staticmethod + def __add_l1(var): + return var.abs().sum() + + +class L2Regularizer(_Regularizer): + """ + L2 regularized loss + """ + + def __init__(self, model, lambda_reg=0.01): + super(L2Regularizer, self).__init__(model=model) + self.lambda_reg = lambda_reg + + def regularized_param(self, param_weights, reg_loss_function): + reg_loss_function += self.lambda_reg * L2Regularizer.__add_l2(var=param_weights) + return reg_loss_function + + def regularized_all_param(self, reg_loss_function): + for model_param_name, model_param_value in self.model.named_parameters(): + if ( + model_param_name.endswith("weight") + and "1.weight" not in model_param_name + and "bn" not in model_param_name + ): + reg_loss_function += self.lambda_reg * L2Regularizer.__add_l2(var=model_param_value) + return reg_loss_function + + @staticmethod + def __add_l2(var): + return var.pow(2).sum() + + +class ElasticNetRegularizer(_Regularizer): + """ + Elastic Net Regularizer + """ + + def __init__(self, model, lambda_reg=0.01, alpha_reg=0.01): + super(ElasticNetRegularizer, self).__init__(model=model) + self.lambda_reg = lambda_reg + self.alpha_reg = alpha_reg + + def regularized_param(self, param_weights, reg_loss_function): + reg_loss_function += self.lambda_reg * ( + ((1 - self.alpha_reg) * ElasticNetRegularizer.__add_l2(var=param_weights)) + + (self.alpha_reg * ElasticNetRegularizer.__add_l1(var=param_weights)) + ) + return reg_loss_function + + def regularized_all_param(self, reg_loss_function): + for model_param_name, model_param_value in self.model.named_parameters(): + if model_param_name.endswith("weight"): + reg_loss_function += self.lambda_reg * ( + ((1 - self.alpha_reg) * ElasticNetRegularizer.__add_l2(var=model_param_value)) + + (self.alpha_reg * ElasticNetRegularizer.__add_l1(var=model_param_value)) + ) + return reg_loss_function + + @staticmethod + def __add_l1(var): + return var.abs().sum() + + @staticmethod + def __add_l2(var): + return var.pow(2).sum() + + +class GroupSparseLassoRegularizer(_Regularizer): + """ + Group Sparse Lasso Regularizer + """ + + def __init__(self, model, lambda_reg=0.01): + super(GroupSparseLassoRegularizer, self).__init__(model=model) + self.lambda_reg = lambda_reg + self.reg_l2_l1 = GroupLassoRegularizer(model=self.model, lambda_reg=self.lambda_reg) + self.reg_l1 = L1Regularizer(model=self.model, lambda_reg=self.lambda_reg) + + def regularized_param(self, param_weights, reg_loss_function): + reg_loss_function = self.lambda_reg * ( + self.reg_l2_l1.regularized_param(param_weights=param_weights, reg_loss_function=reg_loss_function) + + self.reg_l1.regularized_param(param_weights=param_weights, reg_loss_function=reg_loss_function) + ) + + return reg_loss_function + + def regularized_all_param(self, reg_loss_function): + reg_loss_function = self.lambda_reg * ( + self.reg_l2_l1.regularized_all_param(reg_loss_function=reg_loss_function) + + self.reg_l1.regularized_all_param(reg_loss_function=reg_loss_function) + ) + + return reg_loss_function + + +class GroupLassoRegularizer(_Regularizer): + """ + GroupLasso Regularizer: + The first dimension represents the input layer and the second dimension represents the output layer. + The groups are defined by the column in the matrix W + """ + + def __init__(self, model, lambda_reg=0.01): + super(GroupLassoRegularizer, self).__init__(model=model) + self.lambda_reg = lambda_reg + + def regularized_param(self, param_weights, reg_loss_function, group_name="input_group"): + if group_name == "input_group": + reg_loss_function += self.lambda_reg * GroupLassoRegularizer.__inputs_groups_reg( + layer_weights=param_weights + ) # apply the group norm on the input value + elif group_name == "hidden_group": + reg_loss_function += self.lambda_reg * GroupLassoRegularizer.__inputs_groups_reg( + layer_weights=param_weights + ) # apply the group norm on every hidden layer + elif group_name == "bias_group": + reg_loss_function += self.lambda_reg * GroupLassoRegularizer.__bias_groups_reg( + bias_weights=param_weights + ) # apply the group norm on the bias + else: + print( + "The group {} is not supported yet. Please try one of this: [input_group, hidden_group, bias_group]".format( + group_name + ) + ) + return reg_loss_function + + def regularized_all_param(self, reg_loss_function): + for model_param_name, model_param_value in self.model.named_parameters(): + if model_param_name.endswith("weight"): + reg_loss_function += self.lambda_reg * GroupLassoRegularizer.__inputs_groups_reg( + layer_weights=model_param_value + ) + if model_param_name.endswith("bias"): + reg_loss_function += self.lambda_reg * GroupLassoRegularizer.__bias_groups_reg( + bias_weights=model_param_value + ) + return reg_loss_function + + @staticmethod + def __grouplasso_reg(groups, dim): + if dim == -1: + # We only have single group + return groups.norm(2) + return groups.norm(2, dim=dim).sum() + + @staticmethod + def __inputs_groups_reg(layer_weights): + return GroupLassoRegularizer.__grouplasso_reg(groups=layer_weights, dim=1) + + @staticmethod + def __bias_groups_reg(bias_weights): + return GroupLassoRegularizer.__grouplasso_reg(groups=bias_weights, dim=-1) # ou 0 i dont know yet + + +class RegularizerTypes(Enum): + L1 = L1Regularizer + L2 = L2Regularizer + ELASTIC = ElasticNetRegularizer diff --git a/torch-points3d/torch_points3d/core/schedulers/__init__.py b/torch-points3d/torch_points3d/core/schedulers/__init__.py new file mode 100644 index 0000000..9adc2d5 --- /dev/null +++ b/torch-points3d/torch_points3d/core/schedulers/__init__.py @@ -0,0 +1,2 @@ +from .lr_schedulers import * +from .bn_schedulers import * diff --git a/torch-points3d/torch_points3d/core/schedulers/bn_schedulers.py b/torch-points3d/torch_points3d/core/schedulers/bn_schedulers.py new file mode 100644 index 0000000..05faf85 --- /dev/null +++ b/torch-points3d/torch_points3d/core/schedulers/bn_schedulers.py @@ -0,0 +1,112 @@ +from typing import * +from omegaconf import OmegaConf +from torch import nn +import logging + +try: + import MinkowskiEngine as ME + + BATCH_NORM_MODULES: Any = ( + nn.BatchNorm1d, + nn.BatchNorm2d, + nn.BatchNorm3d, + ME.MinkowskiBatchNorm, + ME.MinkowskiInstanceNorm, + ) +except: + BATCH_NORM_MODULES = (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d) + + +log = logging.getLogger(__name__) + + +def set_bn_momentum_default(bn_momentum): + """ + This function return a function which will assign `bn_momentum` to every module instance within `BATCH_NORM_MODULES`. + """ + + def fn(m): + if isinstance(m, BATCH_NORM_MODULES): + m.momentum = bn_momentum + + return fn + + +class BNMomentumScheduler(object): + def __init__(self, model, bn_lambda, update_scheduler_on, last_epoch=-1, setter=set_bn_momentum_default): + if not isinstance(model, nn.Module): + raise RuntimeError("Class '{}' is not a PyTorch nn Module".format(type(model).__name__)) + + self.model = model + self.setter = setter + self.bn_lambda = bn_lambda + self._current_momemtum = None + self.step(last_epoch + 1) + self.last_epoch = last_epoch + self._scheduler_opt = None + self._update_scheduler_on = update_scheduler_on + + @property + def update_scheduler_on(self): + return self._update_scheduler_on + + @property + def scheduler_opt(self): + return self._scheduler_opt + + @scheduler_opt.setter + def scheduler_opt(self, scheduler_opt): + self._scheduler_opt = scheduler_opt + + def step(self, epoch=None): + + if epoch is None: + epoch = self.last_epoch + 1 + + self.last_epoch = epoch + new_momemtum = self.bn_lambda(epoch) + if self._current_momemtum != new_momemtum: + self._current_momemtum = new_momemtum + log.info("Setting batchnorm momentum at {}".format(new_momemtum)) + self.model.apply(self.setter(new_momemtum)) + + def state_dict(self): + return { + "current_momemtum": self.bn_lambda(self.last_epoch), + "last_epoch": self.last_epoch, + } + + def load_state_dict(self, state_dict): + self.last_epoch = state_dict["last_epoch"] + self.current_momemtum = state_dict["current_momemtum"] + + def __repr__(self): + return "{}(base_momentum: {}, update_scheduler_on={})".format( + self.__class__.__name__, self._current_momemtum, self._update_scheduler_on + ) + + +def instantiate_bn_scheduler(model, bn_scheduler_opt): + """Return a batch normalization scheduler + Parameters: + model -- the nn network + bn_scheduler_opt (option class) -- dict containing all the params to build the scheduler  + opt.bn_policy is the name of learning rate policy: lambda_rule | step | plateau | cosine + opt.params contains the scheduler_params to construct the scheduler + See https://pytorch.org/docs/stable/optim.html for more details. + """ + update_scheduler_on = bn_scheduler_opt.get("update_scheduler_on") + bn_scheduler_params = bn_scheduler_opt.get("params") + if bn_scheduler_opt.get("bn_policy") == "step_decay": + bn_lambda = lambda e: max( + bn_scheduler_params.bn_momentum + * bn_scheduler_params.bn_decay ** (int(e // bn_scheduler_params.decay_step)), + bn_scheduler_params.bn_clip, + ) + + else: + return NotImplementedError("bn_policy [%s] is not implemented", bn_scheduler_opt.bn_policy) + + bn_scheduler = BNMomentumScheduler(model, bn_lambda, update_scheduler_on) + bn_scheduler.scheduler_opt = OmegaConf.to_container(bn_scheduler_opt) + return bn_scheduler diff --git a/torch-points3d/torch_points3d/core/schedulers/lr_schedulers.py b/torch-points3d/torch_points3d/core/schedulers/lr_schedulers.py new file mode 100644 index 0000000..d8f59d7 --- /dev/null +++ b/torch-points3d/torch_points3d/core/schedulers/lr_schedulers.py @@ -0,0 +1,270 @@ +import math +import sys +import warnings +from typing import List + +from torch.optim import lr_scheduler, Optimizer +from omegaconf import OmegaConf +import logging +from torch.optim.lr_scheduler import LambdaLR, _LRScheduler + +from torch_points3d.utils.enums import SchedulerUpdateOn + +log = logging.getLogger(__name__) + +_custom_lr_scheduler = sys.modules[__name__] + + +def collect_params(params, update_scheduler_on): + """ + This function enable to handle if params contains on_epoch and on_iter or not. + """ + on_epoch_params = params.get("on_epoch") + on_batch_params = params.get("on_num_batch") + on_sample_params = params.get("on_num_sample") + + def check_params(params): + if params is not None: + return params + else: + raise Exception( + "The lr_scheduler doesn't have policy {}. Options: {}".format(update_scheduler_on, SchedulerUpdateOn) + ) + + if on_epoch_params or on_batch_params or on_sample_params: + if update_scheduler_on == SchedulerUpdateOn.ON_EPOCH.value: + return check_params(on_epoch_params) + elif update_scheduler_on == SchedulerUpdateOn.ON_NUM_BATCH.value: + return check_params(on_batch_params) + elif update_scheduler_on == SchedulerUpdateOn.ON_NUM_SAMPLE.value: + return check_params(on_sample_params) + else: + raise Exception( + "The provided update_scheduler_on {} isn't within {}".format(update_scheduler_on, SchedulerUpdateOn) + ) + else: + return params + + +class LinearWarmupCosineAnnealingLR(_LRScheduler): + """Sets the learning rate of each parameter group to follow a linear warmup schedule between warmup_start_lr + and base_lr followed by a cosine annealing schedule between base_lr and eta_min. + .. warning:: + It is recommended to call :func:`.step()` for :class:`LinearWarmupCosineAnnealingLR` + after each iteration as calling it after each epoch will keep the starting lr at + warmup_start_lr for the first epoch which is 0 in most cases. + .. warning:: + passing epoch to :func:`.step()` is being deprecated and comes with an EPOCH_DEPRECATION_WARNING. + It calls the :func:`_get_closed_form_lr()` method for this scheduler instead of + :func:`get_lr()`. Though this does not change the behavior of the scheduler, when passing + epoch param to :func:`.step()`, the user should call the :func:`.step()` function before calling + train and validation methods. + Example: + >>> layer = nn.Linear(10, 1) + >>> optimizer = Adam(layer.parameters(), lr=0.02) + >>> scheduler = LinearWarmupCosineAnnealingLR(optimizer, warmup_epochs=10, max_epochs=40) + >>> # + >>> # the default case + >>> for epoch in range(40): + ... # train(...) + ... # validate(...) + ... scheduler.step() + >>> # + >>> # passing epoch param case + >>> for epoch in range(40): + ... scheduler.step(epoch) + ... # train(...) + ... # validate(...) + """ + + def __init__( + self, + optimizer: Optimizer, + warmup_epochs: int, + max_epochs: int, + warmup_start_lr: float = 0.00003, + eta_min: float = 0.0, + last_epoch: int = -1, + ) -> None: + """ + from https://github.com/Lightning-AI/lightning-bolts/blob/master/pl_bolts/optimizers/lr_scheduler.py + Args: + optimizer (Optimizer): Wrapped optimizer. + warmup_epochs (int): Maximum number of iterations for linear warmup + max_epochs (int): Maximum number of iterations + warmup_start_lr (float): Learning rate to start the linear warmup. Default: 0. + eta_min (float): Minimum learning rate. Default: 0. + last_epoch (int): The index of last epoch. Default: -1. + """ + self.warmup_epochs = warmup_epochs + self.max_epochs = max_epochs + self.warmup_start_lr = warmup_start_lr + self.eta_min = eta_min + + super().__init__(optimizer, last_epoch) + + def get_lr(self) -> List[float]: + """Compute learning rate using chainable form of the scheduler.""" + if not self._get_lr_called_within_step: + warnings.warn( + "To get the last learning rate computed by the scheduler, " "please use `get_last_lr()`.", + UserWarning, + ) + + if self.last_epoch == 0: + return [self.warmup_start_lr] * len(self.base_lrs) + if self.last_epoch < self.warmup_epochs: + return [ + group["lr"] + (base_lr - self.warmup_start_lr) / (self.warmup_epochs - 1) + for base_lr, group in zip(self.base_lrs, self.optimizer.param_groups) + ] + if self.last_epoch == self.warmup_epochs: + return self.base_lrs + if (self.last_epoch - 1 - self.max_epochs) % (2 * (self.max_epochs - self.warmup_epochs)) == 0: + return [ + group["lr"] + + (base_lr - self.eta_min) * (1 - math.cos(math.pi / (self.max_epochs - self.warmup_epochs))) / 2 + for base_lr, group in zip(self.base_lrs, self.optimizer.param_groups) + ] + + return [ + (1 + math.cos(math.pi * (self.last_epoch - self.warmup_epochs) / (self.max_epochs - self.warmup_epochs))) + / ( + 1 + + math.cos( + math.pi * (self.last_epoch - self.warmup_epochs - 1) / (self.max_epochs - self.warmup_epochs) + ) + ) + * (group["lr"] - self.eta_min) + + self.eta_min + for group in self.optimizer.param_groups + ] + + def _get_closed_form_lr(self) -> List[float]: + """Called when epoch is passed as a param to the `step` function of the scheduler.""" + if self.last_epoch < self.warmup_epochs: + return [ + self.warmup_start_lr + self.last_epoch * (base_lr - self.warmup_start_lr) / (self.warmup_epochs - 1) + for base_lr in self.base_lrs + ] + + return [ + self.eta_min + + 0.5 + * (base_lr - self.eta_min) + * (1 + math.cos(math.pi * (self.last_epoch - self.warmup_epochs) / (self.max_epochs - self.warmup_epochs))) + for base_lr in self.base_lrs + ] + + @property + def last_step(self): + """Use last_epoch for the step counter""" + return self.last_epoch + + @last_step.setter + def last_step(self, v): + self.last_epoch = v + + +class LambdaStepLR(LambdaLR): + def __init__(self, optimizer, lr_lambda, last_step=-1): + super(LambdaStepLR, self).__init__(optimizer, lr_lambda, last_step) + + @property + def last_step(self): + """Use last_epoch for the step counter""" + return self.last_epoch + + @last_step.setter + def last_step(self, v): + self.last_epoch = v + + +class PolyLR(LambdaStepLR): + """DeepLab learning rate policy""" + + def __init__(self, optimizer, max_iter, power=0.9, last_step=-1): + lambda_func = lambda s: (1 - s / (max_iter + 1)) ** power + composite_func = lambda s: lambda_func(max_iter) if s > max_iter else lambda_func(s) + super(PolyLR, self).__init__(optimizer, lambda s: composite_func(s), last_step) + + +class SquaredLR(LambdaStepLR): + """ Used for SGD Lars""" + + def __init__(self, optimizer, max_iter, last_step=-1): + super(SquaredLR, self).__init__(optimizer, lambda s: (1 - s / (max_iter + 1)) ** 2, last_step) + + +class ExpLR(LambdaStepLR): + def __init__(self, optimizer, step_size, gamma=0.9, last_step=-1): + # (0.9 ** 21.854) = 0.1, (0.95 ** 44.8906) = 0.1 + # To get 0.1 every N using gamma 0.9, N * log(0.9)/log(0.1) = 0.04575749 N + # To get 0.1 every N using gamma g, g ** N = 0.1 -> N * log(g) = log(0.1) -> g = np.exp(log(0.1) / N) + super(ExpLR, self).__init__(optimizer, lambda s: gamma ** (s / step_size), last_step) + + +def repr(self, scheduler_params={}): + return "{}({})".format(self.__class__.__name__, scheduler_params) + + +class LRScheduler: + def __init__(self, scheduler, scheduler_params, update_scheduler_on): + self._scheduler = scheduler + self._scheduler_params = scheduler_params + self._update_scheduler_on = update_scheduler_on + + @property + def scheduler(self): + return self._scheduler + + @property + def scheduler_opt(self): + return self._scheduler._scheduler_opt + + def __repr__(self): + return "{}({}, update_scheduler_on={})".format( + self._scheduler.__class__.__name__, self._scheduler_params, self._update_scheduler_on + ) + + def step(self, *args, **kwargs): + self._scheduler.step(*args, **kwargs) + + def state_dict(self): + return self._scheduler.state_dict() + + def load_state_dict(self, state_dict): + self._scheduler.load_state_dict(state_dict) + + +def instantiate_scheduler(optimizer, scheduler_opt): + """Return a learning rate scheduler + Parameters: + optimizer -- the optimizer of the network + scheduler_opt (option class) -- dict containing all the params to build the scheduler  + opt.lr_policy is the name of learning rate policy: lambda_rule | step | plateau | cosine + opt.params contains the scheduler_params to construct the scheduler + See https://pytorch.org/docs/stable/optim.html for more details. + """ + + update_scheduler_on = scheduler_opt.update_scheduler_on + scheduler_cls_name = getattr(scheduler_opt, "class") + scheduler_params = collect_params(scheduler_opt.params, update_scheduler_on) + + try: + scheduler_cls = getattr(lr_scheduler, scheduler_cls_name) + except: + scheduler_cls = getattr(_custom_lr_scheduler, scheduler_cls_name) + log.info("Created custom lr scheduler") + + if scheduler_cls_name.lower() == "ReduceLROnPlateau".lower(): + raise NotImplementedError("This scheduler is not fully supported yet") + + scheduler = scheduler_cls(optimizer, **scheduler_params) + # used to re_create the scheduler + # instantiate vars + for key in scheduler_params.keys(): + scheduler_params[key] = getattr(scheduler, key) + + setattr(scheduler, "_scheduler_opt", OmegaConf.to_container(scheduler_opt)) + return LRScheduler(scheduler, scheduler_params, update_scheduler_on) diff --git a/torch-points3d/torch_points3d/core/spatial_ops/__init__.py b/torch-points3d/torch_points3d/core/spatial_ops/__init__.py new file mode 100644 index 0000000..f91c893 --- /dev/null +++ b/torch-points3d/torch_points3d/core/spatial_ops/__init__.py @@ -0,0 +1,2 @@ +from .sampling import * +from .interpolate import * diff --git a/torch-points3d/torch_points3d/core/spatial_ops/interpolate.py b/torch-points3d/torch_points3d/core/spatial_ops/interpolate.py new file mode 100644 index 0000000..fbb29ca --- /dev/null +++ b/torch-points3d/torch_points3d/core/spatial_ops/interpolate.py @@ -0,0 +1,70 @@ +import torch +from torch_geometric.nn import knn_interpolate, knn +from torch_scatter import scatter_add +from torch_geometric.data import Data + + +class KNNInterpolate: + def __init__(self, k): + self.k = k + + def precompute(self, query, support): + """ Precomputes a data structure that can be used in the transform itself to speed things up + """ + pos_x, pos_y = query.pos, support.pos + if hasattr(support, "batch"): + batch_y = support.batch + else: + batch_y = torch.zeros((support.num_nodes,), dtype=torch.long) + if hasattr(query, "batch"): + batch_x = query.batch + else: + batch_x = torch.zeros((query.num_nodes,), dtype=torch.long) + + with torch.no_grad(): + assign_index = knn(pos_x, pos_y, self.k, batch_x=batch_x, batch_y=batch_y) + y_idx, x_idx = assign_index + diff = pos_x[x_idx] - pos_y[y_idx] + squared_distance = (diff * diff).sum(dim=-1, keepdim=True) + weights = 1.0 / torch.clamp(squared_distance, min=1e-16) + normalisation = scatter_add(weights, y_idx, dim=0, dim_size=pos_y.size(0)) + + return Data(num_nodes=support.num_nodes, x_idx=x_idx, y_idx=y_idx, weights=weights, normalisation=normalisation) + + def __call__(self, query, support, precomputed: Data = None): + """ Computes a new set of features going from the query resolution position to the support + resolution position + Args: + - query: data structure that holds the low res data (position + features) + - support: data structure that holds the position to which we will interpolate + Returns: + - torch.tensor: interpolated features + """ + if precomputed: + num_points = support.pos.size(0) + if num_points != precomputed.num_nodes: + raise ValueError("Precomputed indices do not match with the data given to the transform") + + x = query.x + x_idx, y_idx, weights, normalisation = ( + precomputed.x_idx, + precomputed.y_idx, + precomputed.weights, + precomputed.normalisation, + ) + y = scatter_add(x[x_idx] * weights, y_idx, dim=0, dim_size=num_points) + y = y / normalisation + return y + + x, pos = query.x, query.pos + pos_support = support.pos + if hasattr(support, "batch"): + batch_support = support.batch + else: + batch_support = torch.zeros((support.num_nodes,), dtype=torch.long) + if hasattr(query, "batch"): + batch = query.batch + else: + batch = torch.zeros((query.num_nodes,), dtype=torch.long) + + return knn_interpolate(x, pos, pos_support, batch, batch_support, k=self.k) diff --git a/torch-points3d/torch_points3d/core/spatial_ops/sampling.py b/torch-points3d/torch_points3d/core/spatial_ops/sampling.py new file mode 100644 index 0000000..2a10b89 --- /dev/null +++ b/torch-points3d/torch_points3d/core/spatial_ops/sampling.py @@ -0,0 +1,126 @@ +from abc import ABC, abstractmethod +import math +import torch +from torch_geometric.nn import voxel_grid +from torch_geometric.nn.pool.consecutive import consecutive_cluster +from torch_geometric.nn.pool.pool import pool_pos, pool_batch +import torch_points_kernels as tp + +from torch_points3d.utils.config import is_list +from torch_points3d.utils.enums import ConvolutionFormat + + +class BaseSampler(ABC): + """If num_to_sample is provided, sample exactly + num_to_sample points. Otherwise sample floor(pos[0] * ratio) points + """ + + def __init__(self, ratio=None, num_to_sample=None, subsampling_param=None): + if num_to_sample is not None: + if (ratio is not None) or (subsampling_param is not None): + raise ValueError("Can only specify ratio or num_to_sample or subsampling_param, not several !") + self._num_to_sample = num_to_sample + + elif ratio is not None: + self._ratio = ratio + + elif subsampling_param is not None: + self._subsampling_param = subsampling_param + + else: + raise Exception('At least ["ratio, num_to_sample, subsampling_param"] should be defined') + + def __call__(self, pos, x=None, batch=None): + return self.sample(pos, batch=batch, x=x) + + def _get_num_to_sample(self, batch_size) -> int: + if hasattr(self, "_num_to_sample"): + return self._num_to_sample + else: + return math.floor(batch_size * self._ratio) + + def _get_ratio_to_sample(self, batch_size) -> float: + if hasattr(self, "_ratio"): + return self._ratio + else: + return self._num_to_sample / float(batch_size) + + @abstractmethod + def sample(self, pos, x=None, batch=None): + pass + + +class FPSSampler(BaseSampler): + """If num_to_sample is provided, sample exactly + num_to_sample points. Otherwise sample floor(pos[0] * ratio) points + """ + + def sample(self, pos, batch, **kwargs): + from torch_geometric.nn import fps + + if len(pos.shape) != 2: + raise ValueError(" This class is for sparse data and expects the pos tensor to be of dimension 2") + return fps(pos, batch, ratio=self._get_ratio_to_sample(pos.shape[0])) + + +class GridSampler(BaseSampler): + """If num_to_sample is provided, sample exactly + num_to_sample points. Otherwise sample floor(pos[0] * ratio) points + """ + + def sample(self, pos=None, x=None, batch=None): + if len(pos.shape) != 2: + raise ValueError("This class is for sparse data and expects the pos tensor to be of dimension 2") + + pool = voxel_grid(pos, batch, self._subsampling_param) + pool, perm = consecutive_cluster(pool) + batch = pool_batch(perm, batch) + if x is not None: + return pool_pos(pool, x), pool_pos(pool, pos), batch + else: + return None, pool_pos(pool, pos), batch + + +class DenseFPSSampler(BaseSampler): + """If num_to_sample is provided, sample exactly + num_to_sample points. Otherwise sample floor(pos[0] * ratio) points + """ + + def sample(self, pos, **kwargs): + """ Sample pos + + Arguments: + pos -- [B, N, 3] + + Returns: + indexes -- [B, num_sample] + """ + if len(pos.shape) != 3: + raise ValueError(" This class is for dense data and expects the pos tensor to be of dimension 2") + return tp.furthest_point_sample(pos, self._get_num_to_sample(pos.shape[1])) + + +class RandomSampler(BaseSampler): + """If num_to_sample is provided, sample exactly + num_to_sample points. Otherwise sample floor(pos[0] * ratio) points + """ + + def sample(self, pos, batch, **kwargs): + if len(pos.shape) != 2: + raise ValueError(" This class is for sparse data and expects the pos tensor to be of dimension 2") + idx = torch.randint(0, pos.shape[0], (self._get_num_to_sample(pos.shape[0]),)) + return idx + + +class DenseRandomSampler(BaseSampler): + """If num_to_sample is provided, sample exactly + num_to_sample points. Otherwise sample floor(pos[0] * ratio) points + Arguments: + pos -- [B, N, 3] + """ + + def sample(self, pos, **kwargs): + if len(pos.shape) != 3: + raise ValueError(" This class is for dense data and expects the pos tensor to be of dimension 2") + idx = torch.randint(0, pos.shape[1], (self._get_num_to_sample(pos.shape[1]),)) + return idx diff --git a/torch-points3d/torch_points3d/datasets/__init__.py b/torch-points3d/torch_points3d/datasets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/datasets/base_dataset.py b/torch-points3d/torch_points3d/datasets/base_dataset.py new file mode 100644 index 0000000..ccad2f0 --- /dev/null +++ b/torch-points3d/torch_points3d/datasets/base_dataset.py @@ -0,0 +1,592 @@ +import os +from abc import abstractmethod +import logging +import functools +from functools import partial +import numpy as np +import torch +import torch_geometric +from torch_geometric.transforms import Compose +import copy + +from torch_points3d.models import model_interface +from torch_points3d.core.data_transform import instantiate_transforms, MultiScaleTransform +from torch_points3d.core.data_transform import instantiate_filters +from torch_points3d.datasets.batch import SimpleBatch +from torch_points3d.datasets.multiscale_data import MultiScaleBatch +from torch_points3d.utils.enums import ConvolutionFormat +from torch_points3d.utils.config import ConvolutionFormatFactory +from torch_points3d.utils.colors import COLORS + +# A logger for this file +log = logging.getLogger(__name__) + + +def explode_transform(transforms): + """ Returns a flattened list of transform + Arguments: + transforms {[list | T.Compose]} -- Contains list of transform to be added + + Returns: + [list] -- [List of transforms] + """ + out = [] + if transforms is not None: + if isinstance(transforms, Compose): + out = copy.deepcopy(transforms.transforms) + elif isinstance(transforms, list): + out = copy.deepcopy(transforms) + else: + raise Exception("Transforms should be provided either within a list or a Compose") + return out + + +def save_used_properties(func): + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Save used_properties for mocking dataset when calling pretrained registry + result = func(self, *args, **kwargs) + if isinstance(result, torch.Tensor): + self.used_properties[func.__name__] = result.numpy().tolist() + elif isinstance(result, np.ndarray): + self.used_properties[func.__name__] = result.tolist() + else: + self.used_properties[func.__name__] = result + return result + + return wrapper + + +class BaseDataset: + def __init__(self, dataset_opt): + self.dataset_opt = dataset_opt + + # Default dataset path + dataset_name = dataset_opt.get("dataset_name", None) + if dataset_name: + self._data_path = os.path.join(dataset_opt.dataroot, dataset_name) + else: + class_name = self.__class__.__name__.lower().replace("dataset", "") + self._data_path = os.path.join(dataset_opt.dataroot, class_name) + self._batch_size = None + self.strategies = {} + self._contains_dataset_name = False + + self.train_sampler = None + self.test_sampler = None + self.val_sampler = None + + self._train_dataset = None + self._test_dataset = None + self._val_dataset = None + + self.train_pre_batch_collate_transform = None + self.val_pre_batch_collate_transform = None + self.test_pre_batch_collate_transform = None + + transforms = dataset_opt.get(dataset_opt.transform_type) + if "pre_transform" in dataset_opt: + transforms["pre_transform"] = dataset_opt.get("pre_transform") + BaseDataset.set_transform(self, transforms) + self.set_filter(dataset_opt) + + self.used_properties = {} + + @staticmethod + def remove_transform(transform_in, list_transform_class): + """ Remove a transform if within list_transform_class + + Arguments: + transform_in {[type]} -- [Compose | List of transform] + list_transform_class {[type]} -- [List of transform class to be removed] + + Returns: + [type] -- [description] + """ + if isinstance(transform_in, Compose) or isinstance(transform_in, list): + if len(list_transform_class) > 0: + transform_out = [] + transforms = transform_in.transforms if isinstance(transform_in, Compose) else transform_in + for t in transforms: + if not isinstance(t, tuple(list_transform_class)): + transform_out.append(t) + transform_out = Compose(transform_out) + else: + transform_out = transform_in + return transform_out + + @staticmethod + def set_transform(obj, dataset_opt): + """This function create and set the transform to the obj as attributes + """ + obj.pre_transform = None + obj.test_transform = None + obj.train_transform = None + obj.val_transform = None + obj.inference_transform = None + + for key_name in dataset_opt.keys(): + if "transform" in key_name: + new_name = key_name.replace("transforms", "transform") + try: + transform = instantiate_transforms(getattr(dataset_opt, key_name)) + except Exception: + log.exception("Error trying to create {}, {}".format(new_name, getattr(dataset_opt, key_name))) + continue + setattr(obj, new_name, transform) + inference_transform = explode_transform(obj.pre_transform) + inference_transform += explode_transform(obj.test_transform) + obj.inference_transform = Compose(inference_transform) if len(inference_transform) > 0 else None + + def set_filter(self, dataset_opt): + """This function create and set the pre_filter to the obj as attributes + """ + self.pre_filter = None + for key_name in dataset_opt.keys(): + if "filter" in key_name: + new_name = key_name.replace("filters", "filter") + try: + filt = instantiate_filters(getattr(dataset_opt, key_name)) + except Exception: + log.exception("Error trying to create {}, {}".format(new_name, getattr(dataset_opt, key_name))) + continue + setattr(self, new_name, filt) + + @staticmethod + def _collate_fn(batch, collate_fn=None, pre_collate_transform=None): + if pre_collate_transform: + batch = pre_collate_transform(batch) + return collate_fn(batch) + + @staticmethod + def _get_collate_function(conv_type, is_multiscale, pre_collate_transform=None): + is_dense = ConvolutionFormatFactory.check_is_dense_format(conv_type) + if is_multiscale: + if conv_type.lower() == ConvolutionFormat.PARTIAL_DENSE.value.lower(): + fn = MultiScaleBatch.from_data_list + else: + raise NotImplementedError( + "MultiscaleTransform is activated and supported only for partial_dense format" + ) + else: + if is_dense: + fn = SimpleBatch.from_data_list + else: + fn = torch_geometric.data.batch.Batch.from_data_list + return partial(BaseDataset._collate_fn, collate_fn=fn, pre_collate_transform=pre_collate_transform) + + @staticmethod + def get_num_samples(batch, conv_type): + is_dense = ConvolutionFormatFactory.check_is_dense_format(conv_type) + if is_dense: + return batch.pos.shape[0] + else: + return batch.batch.max() + 1 + + @staticmethod + def get_sample(batch, key, index, conv_type): + assert hasattr(batch, key) + is_dense = ConvolutionFormatFactory.check_is_dense_format(conv_type) + if is_dense: + return batch[key][index] + else: + return batch[key][batch.batch == index] + + def create_dataloaders( + self, + model: model_interface.DatasetInterface, + batch_size: int, + shuffle: bool, + drop_last: bool, + num_workers: int, + precompute_multi_scale: bool, + ): + """ Creates the data loaders. Must be called in order to complete the setup of the Dataset + """ + conv_type = model.conv_type + self._batch_size = batch_size + + if self.train_sampler is not None: + log.info(self.train_sampler) + + if self.train_dataset: + self._train_loader = self._dataloader( + self.train_dataset, + self.train_pre_batch_collate_transform, + conv_type, + precompute_multi_scale, + batch_size=batch_size, + shuffle=shuffle and not self.train_sampler, + num_workers=num_workers, + sampler=self.train_sampler, + drop_last=drop_last and not self.train_sampler + ) + + if self.val_dataset: + self._val_loader = self._dataloader( + self.val_dataset, + self.val_pre_batch_collate_transform, + conv_type, + precompute_multi_scale, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + sampler=self.val_sampler, + ) + + if self.test_dataset: + self._test_loaders = [ + self._dataloader( + dataset, + self.test_pre_batch_collate_transform, + conv_type, + precompute_multi_scale, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + sampler=self.test_sampler, + ) + for dataset in self.test_dataset + ] + + if precompute_multi_scale: + self.set_strategies(model) + + def _dataloader(self, dataset, pre_batch_collate_transform, conv_type, precompute_multi_scale, **kwargs): + batch_collate_function = self.__class__._get_collate_function( + conv_type, precompute_multi_scale, pre_batch_collate_transform + ) + num_workers = kwargs.get("num_workers", 0) + persistent_workers = num_workers > 0 + dataloader = partial( + torch.utils.data.DataLoader, + collate_fn=batch_collate_function, + worker_init_fn=np.random.seed, + persistent_workers=persistent_workers, + ) + return dataloader(dataset, **kwargs) + + @property + def has_train_loader(self): + return hasattr(self, "_train_loader") + + @property + def has_val_loader(self): + return hasattr(self, "_val_loader") + + @property + def has_test_loader(self): + return hasattr(self, "_test_loaders") + + @property + def train_dataset(self): + return self._train_dataset + + @train_dataset.setter + def train_dataset(self, value): + self._train_dataset = value + if not hasattr(self._train_dataset, "name"): + setattr(self._train_dataset, "name", "train") + + @property + def val_dataset(self): + return self._val_dataset + + @val_dataset.setter + def val_dataset(self, value): + self._val_dataset = value + if not hasattr(self._val_dataset, "name"): + setattr(self._val_dataset, "name", "val") + + @property + def test_dataset(self): + return self._test_dataset + + @test_dataset.setter + def test_dataset(self, value): + if isinstance(value, list): + self._test_dataset = value + else: + self._test_dataset = [value] + + for i, dataset in enumerate(self._test_dataset): + if not hasattr(dataset, "name"): + if self.num_test_datasets > 1: + setattr(dataset, "name", "test_%i" % i) + else: + setattr(dataset, "name", "test") + else: + self._contains_dataset_name = True + + # Check for uniqueness + all_names = [d.name for d in self.test_dataset] + if len(set(all_names)) != len(all_names): + raise ValueError("Datasets need to have unique names. Current names are {}".format(all_names)) + + @property + def train_dataloader(self): + return self._train_loader + + @property + def val_dataloader(self): + return self._val_loader + + @property + def test_dataloaders(self): + if self.has_test_loader: + return self._test_loaders + else: + return [] + + @property + def _loaders(self): + loaders = [] + if self.has_train_loader: + loaders += [self.train_dataloader] + if self.has_val_loader: + loaders += [self.val_dataloader] + if self.has_test_loader: + loaders += self.test_dataloaders + return loaders + + @property + def num_test_datasets(self): + return len(self._test_dataset) if self._test_dataset else 0 + + @property + def _test_datatset_names(self): + if self.test_dataset: + return [d.name for d in self.test_dataset] + else: + return [] + + @property + def available_stage_names(self): + out = self._test_datatset_names + if self.has_val_loader: + out += [self._val_dataset.name] + return out + + @property + def available_dataset_names(self): + return ["train"] + self.available_stage_names + + def get_raw_data(self, stage, idx, **kwargs): + assert stage in self.available_dataset_names + dataset = self.get_dataset(stage) + if hasattr(dataset, "get_raw_data"): + return dataset.get_raw_data(idx, **kwargs) + else: + raise Exception("Dataset {} doesn t have a get_raw_data function implemented".format(dataset)) + + def has_labels(self, stage: str) -> bool: + """ Tests if a given dataset has labels or not + + Parameters + ---------- + stage : str + name of the dataset to test + """ + assert stage in self.available_dataset_names + dataset = self.get_dataset(stage) + if hasattr(dataset, "has_labels"): + return dataset.has_labels + + sample = dataset[0] + if hasattr(sample, "y"): + return sample.y is not None + return False + + @property # type: ignore + @save_used_properties + def is_hierarchical(self): + """ Used by the metric trackers to log hierarchical metrics + """ + return False + + @property # type: ignore + @save_used_properties + def class_to_segments(self): + """ Use this property to return the hierarchical map between classes and segment ids, example: + { + 'Airplaine': [0,1,2], + 'Boat': [3,4,5] + } + """ + return None + + @property # type: ignore + @save_used_properties + def num_classes(self): + if self.train_dataset: + return self.train_dataset.num_classes + elif self.test_dataset is not None: + if isinstance(self.test_dataset, list): + return self.test_dataset[0].num_classes + else: + return self.test_dataset.num_classes + elif self.val_dataset is not None: + return self.val_dataset.num_classes + else: + raise NotImplementedError() + + @property + def weight_classes(self): + return getattr(self.train_dataset, "weight_classes", None) + + @property # type: ignore + @save_used_properties + def feature_dimension(self): + if self.train_dataset: + return self.train_dataset.num_features + elif self.test_dataset is not None: + if isinstance(self.test_dataset, list): + return self.test_dataset[0].num_features + else: + return self.test_dataset.num_features + elif self.val_dataset is not None: + return self.val_dataset.num_features + else: + raise NotImplementedError() + + @property + def batch_size(self): + return self._batch_size + + @property + def num_batches(self): + out = { + "train": len(self._train_loader) if self.has_train_loader else 0, + "val": len(self._val_loader) if self.has_val_loader else 0, + } + if self.test_dataset: + for loader in self._test_loaders: + stage_name = loader.dataset.name + out[stage_name] = len(loader) + return out + + def get_dataset(self, name): + """ Get a dataset by name. Raises an exception if no dataset was found + + Parameters + ---------- + name : str + """ + all_datasets = [self.train_dataset, self.val_dataset] + if self.test_dataset: + all_datasets += self.test_dataset + for dataset in all_datasets: + if dataset is not None and dataset.name == name: + return dataset + raise ValueError("No dataset with name %s was found." % name) + + def _set_composed_multiscale_transform(self, attr, transform): + current_transform = getattr(attr.dataset, "transform", None) + if current_transform is None: + setattr(attr.dataset, "transform", transform) + else: + if ( + isinstance(current_transform, Compose) and transform not in current_transform.transforms + ): # The transform contains several transformations + current_transform.transforms += [transform] + elif current_transform != transform: + setattr( + attr.dataset, "transform", Compose([current_transform, transform]), + ) + + def _set_multiscale_transform(self, transform): + for _, attr in self.__dict__.items(): + if isinstance(attr, torch.utils.data.DataLoader): + self._set_composed_multiscale_transform(attr, transform) + for loader in self.test_dataloaders: + self._set_composed_multiscale_transform(loader, transform) + + def set_strategies(self, model): + strategies = model.get_spatial_ops() + transform = MultiScaleTransform(strategies) + self._set_multiscale_transform(transform) + + @abstractmethod + def get_tracker(self, wandb_log: bool, tensorboard_log: bool): + pass + + def resolve_saving_stage(self, selection_stage): + """This function is responsible to determine if the best model selection + is going to be on the validation or test datasets + """ + log.info( + "Available stage selection datasets: {} {} {}".format( + COLORS.IPurple, self.available_stage_names, COLORS.END_NO_TOKEN + ) + ) + + if self.num_test_datasets > 1 and not self._contains_dataset_name: + msg = "If you want to have better trackable names for your test datasets, add a " + msg += COLORS.IPurple + "name" + COLORS.END_NO_TOKEN + msg += " attribute to them" + log.info(msg) + + if selection_stage == "": + if self.has_val_loader: + selection_stage = self.val_dataset.name + elif self.has_test_loader: + selection_stage = self.test_dataset[0].name + else: + selection_stage = self.train_dataset.name + log.info( + "The models will be selected using the metrics on following dataset: {} {} {}".format( + COLORS.IPurple, selection_stage, COLORS.END_NO_TOKEN + ) + ) + return selection_stage + + def add_weights(self, dataset_name="train", class_weight_method="sqrt"): + """ Add class weights to a given dataset that are then accessible using the `class_weights` attribute + """ + L = self.num_classes + weights = torch.ones(L) + dataset = self.get_dataset(dataset_name) + idx_classes, counts = torch.unique(dataset.data.y, return_counts=True) + + dataset.idx_classes = torch.arange(L).long() + weights[idx_classes] = counts.float() + weights = weights.float() + weights = weights.mean() / weights + if class_weight_method == "sqrt": + weights = torch.sqrt(weights) + elif str(class_weight_method).startswith("log"): + weights = torch.log(1.1 + weights / weights.sum()) + else: + raise ValueError("Method %s not supported" % class_weight_method) + + weights /= torch.sum(weights) + log.info("CLASS WEIGHT : {}".format([np.round(weight.item(), 4) for weight in weights])) + setattr(dataset, "weight_classes", weights) + + return dataset + + def __repr__(self): + message = "Dataset: %s \n" % self.__class__.__name__ + for attr in self.__dict__: + if "transform" in attr: + message += "{}{} {}= {}\n".format(COLORS.IPurple, attr, COLORS.END_NO_TOKEN, getattr(self, attr)) + for attr in self.__dict__: + if attr.endswith("_dataset"): + dataset = getattr(self, attr) + if isinstance(dataset, list): + if len(dataset) > 1: + size = ", ".join([str(len(d)) for d in dataset]) + else: + size = len(dataset[0]) + elif dataset: + size = len(dataset) + else: + size = 0 + if attr.startswith("_"): + attr = attr[1:] + message += "Size of {}{} {}= {}\n".format(COLORS.IPurple, attr, COLORS.END_NO_TOKEN, size) + for key, attr in self.__dict__.items(): + if key.endswith("_sampler") and attr: + message += "{}{} {}= {}\n".format(COLORS.IPurple, key, COLORS.END_NO_TOKEN, attr) + message += "{}Batch size ={} {}".format(COLORS.IPurple, COLORS.END_NO_TOKEN, self.batch_size) + return message diff --git a/torch-points3d/torch_points3d/datasets/batch.py b/torch-points3d/torch_points3d/datasets/batch.py new file mode 100644 index 0000000..51a1536 --- /dev/null +++ b/torch-points3d/torch_points3d/datasets/batch.py @@ -0,0 +1,58 @@ +import torch +from torch_geometric.data import Data + + +class SimpleBatch(Data): + r""" A classic batch object wrapper with :class:`torch_geometric.data.Data` being the + base class, all its methods can also be used here. + """ + + def __init__(self, batch=None, **kwargs): + super(SimpleBatch, self).__init__(**kwargs) + + self.batch = batch + self.__data_class__ = Data + + @staticmethod + def from_data_list(data_list): + r"""Constructs a batch object from a python list holding + :class:`torch_geometric.data.Data` objects. + """ + keys = [set(data.keys) for data in data_list] + keys = list(set.union(*keys)) + + # Check if all dimensions matches and we can concatenate data + # if len(data_list) > 0: + # for data in data_list[1:]: + # for key in keys: + # assert data_list[0][key].shape == data[key].shape + + batch = SimpleBatch() + batch.__data_class__ = data_list[0].__class__ + + for key in keys: + batch[key] = [] + + for _, data in enumerate(data_list): + for key in data.keys: + item = data[key] + batch[key].append(item) + + for key in batch.keys: + item = batch[key][0] + if ( + torch.is_tensor(item) + or isinstance(item, int) + or isinstance(item, float) + ): + batch[key] = torch.stack(batch[key]) + else: + raise ValueError("Unsupported attribute type") + + return batch.contiguous() + # return [batch.x.transpose(1, 2).contiguous(), batch.pos, batch.y.view(-1)] + + @property + def num_graphs(self): + """Returns the number of graphs in the batch.""" + return self.batch[-1].item() + 1 diff --git a/torch-points3d/torch_points3d/datasets/dataset_factory.py b/torch-points3d/torch_points3d/datasets/dataset_factory.py new file mode 100644 index 0000000..c39149d --- /dev/null +++ b/torch-points3d/torch_points3d/datasets/dataset_factory.py @@ -0,0 +1,48 @@ +import importlib +import copy +import hydra +import logging + +from torch_points3d.datasets.base_dataset import BaseDataset + +log = logging.getLogger(__name__) + + +def get_dataset_class(dataset_config): + task = dataset_config.task + # Find and create associated dataset + try: + dataset_config.dataroot = hydra.utils.to_absolute_path( + dataset_config.dataroot) + except Exception: + log.error("This should happen only during testing") + dataset_class = getattr(dataset_config, "class") + dataset_paths = dataset_class.split(".") + module = ".".join(dataset_paths[:-1]) + class_name = dataset_paths[-1] + dataset_module = ".".join(["torch_points3d.datasets", task, module]) + datasetlib = importlib.import_module(dataset_module) + + target_dataset_name = class_name + + for name, cls in datasetlib.__dict__.items(): + if name.lower() == target_dataset_name.lower() and issubclass(cls, BaseDataset): + dataset_cls = cls + + if dataset_cls is None: + raise NotImplementedError( + "In %s.py, there should be a subclass of BaseDataset with class name that matches %s in lowercase." + % (module, class_name) + ) + return dataset_cls + + +def instantiate_dataset(dataset_config) -> BaseDataset: + """Import the module "data/[module].py". + In the file, the class called {class_name}() will + be instantiated. It has to be a subclass of BaseDataset, + and it is case-insensitive. + """ + dataset_cls = get_dataset_class(dataset_config) + dataset = dataset_cls(dataset_config) + return dataset diff --git a/torch-points3d/torch_points3d/datasets/instance/__init__.py b/torch-points3d/torch_points3d/datasets/instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/datasets/instance/las_dataset.py b/torch-points3d/torch_points3d/datasets/instance/las_dataset.py new file mode 100644 index 0000000..5153048 --- /dev/null +++ b/torch-points3d/torch_points3d/datasets/instance/las_dataset.py @@ -0,0 +1,1074 @@ +import logging +import os +from collections import OrderedDict +from functools import partial +from glob import glob +from itertools import chain, product +from pathlib import Path +from typing import Sized, Iterator + +import geopandas as gpd +import laspy +import numpy as np +import pandas as pd +import pyproj +import scipy.stats as scstats +import torch +from omegaconf import OmegaConf +from plyfile import PlyData +from shapely.geometry import Point +from sklearn.neighbors import KDTree +from torch.utils.data import Sampler +from torch_geometric.data import Dataset, Data +from tqdm.auto import tqdm + +from torch_points3d.datasets.base_dataset import BaseDataset, save_used_properties +from torch_points3d.metrics.instance_tracker import InstanceTracker +from torch_points3d.models import model_interface + +log = logging.getLogger(__name__) + + +def read_pt(pt_file, feature_cols, delimiter: str): + crs = None + has_features = len(feature_cols) > 0 + if Path(pt_file).suffix in [".las", ".laz"]: + backend = laspy.compression.LazBackend(0) + if not backend.is_available(): + backend = laspy.compression.LazBackend(1) + if not backend.is_available(): + backend = laspy.compression.LazBackend(2) + loaded_file = laspy.read(pt_file, laz_backend=backend) + pos = np.stack([loaded_file.x, loaded_file.y, loaded_file.z], 1) + if has_features: + features = np.stack([getattr(loaded_file, feature) for feature in feature_cols], 1) + else: + features = None + + # get crs + for vlr in loaded_file.header.vlrs: + if isinstance(vlr, laspy.vlrs.known.WktCoordinateSystemVlr): + # read general CRS (ignores specific parameters) + crs = pyproj.CRS(vlr.string) + elif Path(pt_file).suffix in [".ply"]: + loaded_file = PlyData.read(pt_file) + pos = np.stack([loaded_file.elements[0]["x"], loaded_file.elements[0]["y"], loaded_file.elements[0]["z"]], 1) + if has_features: + features = np.stack([loaded_file.elements[0][feat] for feat in feature_cols], 1) + else: + features = None + else: + # try to read as csv + loaded_file = pd.read_csv( + pt_file, header=None, engine="pyarrow", delimiter=delimiter, dtype=np.float32, skip_blank_lines=True + ) + pos = loaded_file.values[:, :3] # assumes first 3 values are positions + if has_features: + features = loaded_file[feature_cols] + else: + features = None + + return pos, features, crs + + +class Las(Dataset): + """loads all las files into memory and creates samples based on a label_df""" + + def __init__( + self, root, areas: dict, split: str, stats=None, + xy_radius=15., + transform=None, targets=None, feature_cols=None, feature_scaling_dict: dict = None, + pre_transform=None, pre_filter=None, save_local_stats: bool = True, + min_pts_outer: int = 500, min_pts_inner: int = 250, + save_processed: bool = True, processed_folder="processed", in_memory: bool = False, + pos_dict: dict = None, features_dict: dict = None, pos_tree_dict: dict = None, crs_dict: dict = None + ): + self.root = root + self.split = split + + self.min_pts_outer = min_pts_outer + self.min_pts_inner = min_pts_inner + + # useful for double batch detection + self.prev_idx = None + + assert save_processed or in_memory, "Samples are neither saved to processed folder or kept in memory! " \ + "(set either save_processed or in_memory to True)" + self.in_memory = in_memory + if in_memory: + self.memory = {} + + if not save_processed and in_memory: + log.info("Not saving any samples, storing areas in memory if not present on disk") + + self.save_processed = save_processed + self.processed_folder = processed_folder + + self.save_local_stats = save_local_stats + + if pos_dict is not None or pos_tree_dict is not None: + assert pos_dict is not None and pos_tree_dict is not None, \ + "if any of pos or pos_tree are given, both need to be there" + + assert (len(feature_cols) > 0 and (features_dict is not None)) or len(feature_cols) == 0, \ + "need to give features, if pos is given and there are features" + self.pos_dict = {} if pos_dict is None else pos_dict + self.features_dict = {} if features_dict is None else features_dict + self.pos_tree_dict = {} if pos_tree_dict is None else pos_tree_dict + self.crs_dict = {} if crs_dict is None else crs_dict + + self.areas = areas + + self.targets = targets + self.feature_cols = [] if feature_cols is None else feature_cols + self.stats = [] if stats is None else stats + # difference between measurement and pointclouds taken + self.radius = xy_radius + + # different types of targets + self.reg_targets = [target for target in self.targets if self.targets[target]["task"] == "regression"] + self.cls_targets = [target for target in self.targets if self.targets[target]["task"] == "classification"] + self.cls_targets_ = [f"{target}_" for target in self.targets if + self.targets[target]["task"] == "classification"] + self.mol_targets = [target for target in self.targets if self.targets[target]["task"] == "mol"] + + # if not give, calculate on given data + if feature_scaling_dict is None: + feature_scaling_dict = { + area_name: + { # feature: (center, scale) + "num_returns": (0., 5.), + "return_num": (0., 5.), } + for area_name in areas + } + self.feature_scaling_dict = feature_scaling_dict + + super().__init__( + root, transform, pre_transform, pre_filter + ) + # check if all areas are actually processed when using saves + if self.save_processed: + for area_name in areas: + area = areas[area_name] + labels = area["labels"].query(f"{area['split_col']} == '{self.split}'") + if len(labels) > 0 and not (Path(self.processed_dir) / self.split / area_name / "done.flag").exists(): + log.info(f'Resuming processing, since {area_name} is not complete!') + self.process() + else: + self.process() + + # pre-load into memory if not already done during processing + if self.in_memory: + log.info("Pre-loading into memory") + pbar = tqdm(range(len(self)), total=len(self)) + [self.get(idx) for idx in pbar] + + + @property + def processed_dir(self) -> str: + return os.path.join(self.root, self.processed_folder) + + @property + def raw_file_names(self): + files = list(chain(*[[area["pt_files"]] for area in self.areas.values()])) + return files + + @property + def has_labels(self) -> bool: + return self.split in ["val", "test"] + + @property + def processed_file_names(self): + path = Path(self.processed_dir) / self.split + files = glob(str(path / f"*/*.pt")) + return files + + @property + def num_samples(self): + n = 0 + if self.in_memory and not self.save_processed: + n = len(self.memory) + if n == 0: # memory not initialized yet + n = sum([len(area["labels"].query(f"{area['split_col']} == '{self.split}'")) for area in self.areas]) + return n + + for area_name in self.areas: + area = self.areas[area_name] + + if (Path(self.processed_dir) / self.split / area_name / "done.flag").exists(): + n += len(list((Path(self.processed_dir) / self.split / area_name).glob("*.pt"))) + else: + n += len(area["labels"].query(f"{area['split_col']} == '{self.split}'")) + + return n + + def process(self): + file_idx = 0 + + for area_name in self.areas: + flag = (Path(self.processed_dir) / self.split / area_name / "done.flag") + area = self.areas[area_name] + + log.info(f"### start processing area: '{area_name}'") + if not flag.exists(): + + labels = area["labels"].query(f"{area['split_col']} == '{self.split}'") + if len(labels) == 0: + continue + + if area["type"] == "scene": + # can prepare this beforehand + pos, features, inner_label_point_idx, label_point_idx, labels = \ + self.process_scene_area_(area_name, labels) + + ### TODO reintroduce feature scaling + # if feature in feature_scaling: + # center, scale = feature_scaling.get(feature, (0., 1.)) + # else: + # # fill with iqr scaling + # center = np.median(feat) + # scale = (np.quantile(feat, 0.75) - np.quantile(feat, 0.25)) * 1.349 + # feature_scaling[feature] = (center, scale) + # features_sample.append((feat - center) / scale) + + log.info("Saving samples and calculating stats") + if self.save_processed: + (Path(self.processed_dir) / self.split).mkdir(exist_ok=True) + (Path(self.processed_dir) / self.split / area_name).mkdir(exist_ok=True) + missing_idx = [] + for idx, index in tqdm(enumerate(labels.index.values)): + sample = labels.iloc[idx] + file = Path(self.processed_dir) / self.split / area_name / f"{file_idx}.pt" + if file.exists(): + file_idx += 1 + continue + + if area["type"] == "object": + # only load objects here instead of bulk loading before to avoid memory issues + pos, features, crs = read_pt(sample["pt_file"], self.feature_cols, area["delimiter"]) + + if area.get("check_pt_crs", True) and crs is not None and \ + not pyproj.CRS.is_exact_same(labels.crs, crs): + sample = labels.to_crs(crs).iloc[idx] + + # find points + label_centers = [[sample.geometry.x, sample.geometry.y]] + tree = KDTree(pos[:, :2]) + point_idxs = tree.query_radius(label_centers, self.radius)[0] + inner_point_idx = tree.query_radius(label_centers, self.radius / 2.)[0] + del tree + + elif area["type"] == "scene": + point_idxs = label_point_idx[idx] + inner_point_idx = inner_label_point_idx[idx] + else: + raise NotImplementedError("Only 'scence' and 'object' area types are implemented") + + data = self.save_data_( + area_name, index, sample, pos, features, + point_idxs, inner_point_idx + ) + if data is not None: + if self.in_memory: + self.memory[file_idx] = data + if self.save_processed: + torch.save(data, file) + file_idx += 1 + else: + missing_idx.append(index) + area["labels"].drop(index=missing_idx, inplace=True) + if self.save_processed: + flag.touch() + else: + file_idx += len(list((Path(self.processed_dir) / self.split / area_name).glob("*.pt"))) + + def process_scene_area_(self, area_name, labels): + area = self.areas[area_name] + pos_tree = self.pos_tree_dict.get(area_name, None) + + if not pos_tree: + log.info(f"Loading Las files") + pt = [read_pt(las_file, self.feature_cols, area["delimiter"]) for las_file in area["pt_files"]] + + pos = np.concatenate([p[0] for p in pt], 0) + if len(self.feature_cols) > 0: + features = np.concatenate([p[1] for p in pt], 0) + else: + features = None + + crs = np.stack([p[2] for p in pt], 0) + assert np.all(crs[0] == crs_ for crs_ in crs), "pt_files of an area need to be in same crs currently" + crs = crs[0] + + # fit this into a KDTree + log.info("Creating KDTree") + pos_tree = KDTree(pos[:, :2]) + + self.pos_dict[area_name] = pos + self.pos_tree_dict[area_name] = pos_tree + self.features_dict[area_name] = features + self.crs_dict[area_name] = crs + log.info("Querying KDTree") + # restrict to bounds + crs = self.crs_dict[area_name] + if area.get("check_pt_crs", True) and crs is not None and not pyproj.CRS.is_exact_same(labels.crs, crs): + labels = labels.to_crs(crs) + + label_centers = np.stack([labels.geometry.x, labels.geometry.y], 1) + radius = self.radius + label_point_idx = self.pos_tree_dict[area_name].query_radius(label_centers, radius) + inner_label_point_idx = self.pos_tree_dict[area_name].query_radius(label_centers, radius / 2.) + return self.pos_dict[area_name], self.features_dict[area_name], inner_label_point_idx, label_point_idx, labels + + @property + def num_classes(self) -> int: + if not hasattr(self, "num_classes_"): + num_reg_classes = 0 + num_mol_classes = 0 + num_cls_classes = [] + if self.targets: + for target in self.targets: + task = self.targets[target]["task"] + if task == "classification": + num_cls_classes.append(len(self.targets[target]["class_names"])) + elif task == "regression": + num_reg_classes += 1 + elif task.lower() == "mol": + num_mixtures = self.targets[target].get("num_mixtures", 1) + num_mol_classes += num_mixtures * 3 + + self.num_reg_classes_ = num_reg_classes + self.num_mol_classes_ = num_mol_classes + self.num_cls_classes_ = num_cls_classes + + self.num_classes_ = self.num_reg_classes + self.num_mol_classes + int(np.sum(self.num_cls_classes)) + + return self.num_classes_ + + @property + def num_reg_classes(self) -> int: + if not hasattr(self, "num_reg_classes_"): + # init by calling num_classes + _ = self.num_classes + + return self.num_reg_classes_ + + @property + def num_mol_classes(self) -> int: + if not hasattr(self, "num_mol_classes_"): + # init by calling num_classes + _ = self.num_classes + + return self.num_mol_classes_ + + @property + def num_cls_classes(self) -> []: + if not hasattr(self, "num_cls_classes_"): + # init by calling num_classes + _ = self.num_classes + + return self.num_cls_classes_ + + def len(self): + return self.num_samples + + @staticmethod + def get_local_stats(points, postfix=""): + stats = {} + z = points[:, 2] + + z_stats = { + "h_mean": np.mean, + "h_std": np.std, + "h_coov": scstats.variation, + "h_kur": scstats.kurtosis, + "h_skew": scstats.skew, + } + + quantiles = [5, 10, 25, 50, 75, 90, 95, 99] + z_stats.update({f"h_q{i}": partial(np.quantile, q=i / 100) for i in quantiles}) + + def density_q(z, q): + # the proportion of points above the height percentiles + quant = np.quantile(z, q=q) + return len(z[z > quant]) / len(z) + + z_stats.update({f"d_q{i}": partial(density_q, q=i / 100) for i in quantiles}) + + tree = KDTree(points) + # create 1m grid spanning extend + xx = np.arange(points[:, 0].min(), points[:, 0].max(), 1) + yy = np.arange(points[:, 1].min(), points[:, 1].max(), 1) + zz = np.arange(points[:, 2].min(), points[:, 2].max(), 1) + grid = [[x, y, z] for x, y, z in product(xx, yy, zz)] + # get highest density in grid + if len(grid) > len(points): # use points directly if only few points present + grid = points + density = tree.kernel_density(grid, 1, kernel="gaussian").max() + stats["kde_h1"] = density + + for key in z_stats.keys(): + try: + value = z_stats[key](z) + except IndexError: + # return -1 if not enough values in quantiles + value = -1 + + stats[key + postfix] = value + + return stats + + def get(self, idx): + if self.in_memory: + if idx in self.memory.keys(): + data = self.memory[idx].clone() + else: + data = torch.load(self.processed_file_names[idx]) + self.memory[idx] = data.clone() + else: + data = torch.load(self.processed_file_names[idx]) + + del data.local_stats_keys + data["is_double"] = self.prev_idx == idx + self.prev_idx = idx + + return data + + def save_data_(self, area_name: str, idx, sample, pos_: np.array, features_: np.array, + point_idxs: np.array, inner_point_idxs: np.array): + + if len(point_idxs) < self.min_pts_outer: + log.warning(f"only {len(point_idxs)} in total, skipping") + return None + elif len(inner_point_idxs) < self.min_pts_inner: + log.warning(f"only {len(inner_point_idxs)} in inner circle, skipping") + return None + + # only coordinates for now + x = pos_[point_idxs] + inner_x = pos_[inner_point_idxs] + + if features_ is not None: + features = features_[point_idxs] + else: + features = None + + # normalize + inner_x, x = self.center_pos(x, inner_x, sample) + + # get local and df stats + local_stats, local_stats_keys, stats = self.get_stats(x, inner_x, sample) + + # target + if self.targets: + y_reg = sample[self.reg_targets] + y_reg_mask = ~y_reg.isna() + y_mol = sample[self.mol_targets] + y_mol_mask = ~y_mol.isna() + y_cls = sample[self.cls_targets_] + y_cls_mask = ~y_cls.isna() + + else: + y_reg = y_reg_mask = y_mol = y_mol_mask = y_cls = y_cls_mask = [] + + data = self.covert_to_data_( + x, y_reg, y_reg_mask, y_mol, y_mol_mask, y_cls, y_cls_mask, + features, idx, area_name, local_stats, local_stats_keys, stats + ) + + return data + + def covert_to_data_( + self, x, y_reg, y_reg_mask, y_mol, y_mol_mask, y_cls, y_cls_mask, features, idx, area_name, local_stats, + local_stats_keys, stats + ): + x = torch.tensor(x, dtype=torch.float32) + y_reg = torch.tensor(y_reg, dtype=torch.float32) + y_reg_mask = torch.tensor(y_reg_mask, dtype=torch.bool) + y_mol = torch.tensor(y_mol, dtype=torch.float32) + y_mol_mask = torch.tensor(y_mol_mask, dtype=torch.bool) + y_cls[~y_cls_mask] = - 1 + y_cls = torch.tensor(y_cls, dtype=torch.long) + y_cls_mask = torch.tensor(y_cls_mask, dtype=torch.bool) + features = features if features is None else torch.tensor(features, dtype=torch.float32) + stats = torch.tensor(stats, dtype=torch.float32) + local_stats = torch.tensor(local_stats, dtype=torch.float32) + data = Data( + x=features, + y_reg=y_reg, y_reg_mask=y_reg_mask, + y_mol=y_mol, y_mol_mask=y_mol_mask, + y_cls=y_cls, y_cls_mask=y_cls_mask, + pos=x, stats=stats, label_idx=[idx], area_name=area_name, + local_stats=local_stats, local_stats_keys=local_stats_keys + ) + + # apply pre_transform + if self.pre_transform is not None: + data = self.pre_transform(data) + if data.pos.shape[0] == 0: + log.warning(f"Pre transform reduced sample to 0 points, skipping") + return None + + return data + + def get_stats(self, x, inner_x, df): + # local stats + if self.save_local_stats: + local_stats = self.get_local_stats(x) + local_stats.update(self.get_local_stats(inner_x, "_inner")) + local_stats_keys = list(local_stats.keys()) + local_stats = list(local_stats.values()) + else: + local_stats = local_stats_keys = [] + # global stats + stats = df[self.stats] + return local_stats, local_stats_keys, stats + + def center_pos(self, x, inner_x, df): + x_center = np.amin(x, axis=0, keepdims=True) + x_center[:, 0] = df.geometry.x + x_center[:, 1] = df.geometry.y + x -= x_center + inner_x -= x_center + return inner_x, x + + +class LasDataset(BaseDataset): + def __init__(self, dataset_opt): + super().__init__(dataset_opt) + self.dataset_opt = dataset_opt + self.targets = dataset_opt.get("targets", None) + self.target_keys = list(self.targets.keys()) if self.targets is not None else None + self.features = dataset_opt.features + self.stats = dataset_opt.stats + self.xy_radius = dataset_opt.xy_radius + self.x_scale = dataset_opt.x_scale + self.y_scale = dataset_opt.y_scale + self.z_scale = dataset_opt.z_scale + self.transform_type = dataset_opt.transform_type + self.double_batch = dataset_opt.get(self.transform_type).get("double_batch", False) + self.log_train_metrics = dataset_opt.get("log_train_metrics", True) + + self.reg_targets = [target for target in self.targets if self.targets[target]["task"] == "regression"] + self.reg_targets_idx = [self.targets[target]["task"] == "regression" for target in self.targets] + self.cls_targets = [target for target in self.targets if self.targets[target]["task"] == "classification"] + self.cls_targets_idx = [self.targets[target]["task"] == "classification" for target in self.targets] + self.cls_targets_ = [f"{target}_" for target in self.cls_targets] + self.mol_targets = [target for target in self.targets if self.targets[target]["task"] == "mol"] + self.mol_targets_idx = [self.targets[target]["task"] == "mol" for target in self.targets] + + self.min_pts_outer = dataset_opt.get("min_pts_outer", 500) + self.min_pts_inner = dataset_opt.get("min_pts_inner", 250) + + in_memory = dataset_opt.get("in_memory", False) + save_processed = dataset_opt.get("save_processed", True) + save_local_stats = dataset_opt.get("save_local_stats", True) + train_subset = dataset_opt.get("train_subset", False) + + processed_folder = dataset_opt.get("processed_folder", "processed") + + areas_file = (self._data_path / (Path(processed_folder)) / "areas.pt") + self.areas: dict = OrderedDict(OmegaConf.to_container(dataset_opt.areas)) + if areas_file.exists(): + self.areas.update(torch.load(areas_file)) + self.process_area_labels(dataset_opt) + train_set_avail = any( + [len(area["labels"].query(f"{area['split_col']} == 'train'")) > 0 for area in self.areas.values()]) + val_set_avail = any( + [len(area["labels"].query(f"{area['split_col']} == 'val'")) > 0 for area in self.areas.values()]) + test_set_avail = any( + [len(area["labels"].query(f"{area['split_col']} == 'test'")) > 0 for area in self.areas.values()]) + + if save_processed: + (self._data_path / (Path(processed_folder))).mkdir(exist_ok=True) + + feature_scaling_file = self._data_path / (Path(processed_folder) / "features_scaling.pt") + feature_scaling_dict = torch.load(feature_scaling_file) if feature_scaling_file.exists() else None + + assert train_set_avail or val_set_avail or test_set_avail, "Apparently no data available" + + pos_dict = {} + pos_tree_dict = {} + features_dict = {} + crs_dict = {} + if train_set_avail: + if train_subset: + train_subset_remove = 1 - train_subset + for area in self.areas.values(): + idx = area["labels"].query(f"{area['split_col']} == 'train'").index + idx = np.random.choice(idx, int(len(idx) * train_subset_remove), replace=False) + area["labels"].drop(index=idx, inplace=True) + + log.info("Init train dataset") + self.train_dataset = Las( + self._data_path, areas=self.areas, split="train", + targets=self.targets, feature_cols=self.features, feature_scaling_dict=feature_scaling_dict, + stats=dataset_opt.stats, transform=self.train_transform, pre_transform=self.pre_transform, + save_processed=save_processed, processed_folder=processed_folder, in_memory=in_memory, + xy_radius=self.xy_radius, save_local_stats=save_local_stats, + min_pts_outer=self.min_pts_outer, min_pts_inner=self.min_pts_inner + ) + if not feature_scaling_file.exists(): + feature_scaling_dict = self.train_dataset.feature_scaling_dict + torch.save(feature_scaling_dict, feature_scaling_file) + + pos_dict.update(self.train_dataset.pos_dict) + pos_tree_dict.update(self.train_dataset.pos_tree_dict) + features_dict.update(self.train_dataset.features_dict) + crs_dict.update(self.train_dataset.crs_dict) + + if val_set_avail: + log.info("Init val dataset") + self.val_dataset = Las( + self._data_path, areas=self.areas, split="val", + targets=self.targets, feature_cols=self.features, feature_scaling_dict=feature_scaling_dict, + stats=dataset_opt.stats, transform=self.val_transform, pre_transform=self.pre_transform, + save_processed=save_processed, processed_folder=processed_folder, in_memory=in_memory, + xy_radius=self.xy_radius, save_local_stats=save_local_stats, + min_pts_outer=self.min_pts_outer, min_pts_inner=self.min_pts_inner, + pos_dict=pos_dict, features_dict=features_dict, + pos_tree_dict=pos_tree_dict, crs_dict=crs_dict + ) + + pos_dict.update(self.val_dataset.pos_dict) + pos_tree_dict.update(self.val_dataset.pos_tree_dict) + features_dict.update(self.val_dataset.features_dict) + crs_dict.update(self.val_dataset.crs_dict) + + if test_set_avail: + log.info("Init test dataset") + self.test_dataset = Las( + self._data_path, areas=self.areas, split="test", + targets=self.targets, feature_cols=self.features, feature_scaling_dict=feature_scaling_dict, + stats=dataset_opt.stats, transform=self.test_transform, pre_transform=self.pre_transform, + save_processed=save_processed, processed_folder=processed_folder, in_memory=in_memory, + xy_radius=self.xy_radius, save_local_stats=save_local_stats, + min_pts_outer=self.min_pts_outer, min_pts_inner=self.min_pts_inner, + pos_dict=pos_dict, features_dict=features_dict, + pos_tree_dict=pos_tree_dict, crs_dict=crs_dict + ) + + del pos_dict, pos_tree_dict, features_dict, crs_dict + + # save areas in preprocessed file + if save_processed: + torch.save(self.areas, areas_file) + + self.set_label_stats_(save_processed) + + self.has_reg_targets = len(self.reg_targets) > 0 + self.has_mol_targets = len(self.mol_targets) > 0 + self.has_cls_targets = len(self.cls_targets) > 0 + + def process_area_labels(self, dataset_opt): + for area_name in self.areas: + area = self.areas[area_name] + + # assume that if the labels are set, the area was already processed + if area.get("labels", None) is not None: + continue + + # set some standard params + area["delimiter"] = area.get("delimiter", dataset_opt.get("delimiter", ",")) + + # processing file lists + pt_files = area["pt_files"] + if isinstance(pt_files, (str, Path)): + pt_files = glob(str(Path(self._data_path) / "raw" / pt_files)) + elif isinstance(pt_files, list): + # iterating to list of files + unpacked_files = [] + for f in pt_files: + unpacked_files.extend(glob(str(Path(self._data_path) / "raw" / f))) + pt_files = unpacked_files + else: + raise Exception("pt_files need to be a str or a list of str (can use * expression)") + + labels = self.process_label_files_(area, area_name) + + labels.geometry = labels.centroid + + if area["type"] == "object": + # check if each label has a pt_file + def find_pt_file(id): + for ptf in pt_files: + # return first occurrence + if id in ptf: + return ptf + return "None" + + labels["pt_file"] = labels[area["pt_identifier"]].apply(find_pt_file) + + # removing sample without pt_file + n_samples = len(labels) + labels.query("pt_file != 'None'", inplace=True) + if len(labels) != n_samples: + log.warning(f"{n_samples - len(labels)} removed due to missing pt_file") + + pt_files = labels["pt_file"].values.tolist() + + area["pt_files"] = pt_files + + split_col = area.get("split_col", dataset_opt.get("split_col", "split")) + area["split_col"] = split_col + # create split if fully labeled data available + if split_col not in labels.columns: + targets_must_be_present = np.array(area.get("targets_must_be_present", [True] * len(self.target_keys))) + lb = labels[np.array(self.target_keys)[targets_must_be_present]] + + val_ratio = area.get("val_ratio", .1) + test_ratio = area.get("test_ratio", .1) + + # if no targets are fully available, only use this area for training + if (lb.shape[1] > 0 and lb.isna().all().all()) or val_ratio == test_ratio == 0.0: + labels.loc[:, split_col] = "train" + else: + # no split available, create own + # only select those that have labels others are for training + if any(targets_must_be_present): + partly_missing = lb.isna().all(axis=1) + lables_partly_missing = labels[partly_missing] + lables_partly_missing[split_col] = "train" + + lables_full = labels[~partly_missing] + else: + lables_partly_missing = pd.DataFrame() + lables_full = labels + index = lables_full.index.values + + rs = np.random.RandomState(42) + + rs.shuffle(index) + + train_end = int(len(index) * (1 - (val_ratio + test_ratio))) + val_end = int(len(index) * (1 - test_ratio)) + train_idx = index[:train_end] + val_idx = index[train_end:val_end] + test_idx = index[val_end:] + + lables_full.loc[train_idx, split_col] = "train" + if val_ratio != 0 and len(val_idx) > 0: + lables_full.loc[val_idx, split_col] = "val" + if test_ratio != 0 and len(test_idx) > 0: + lables_full.loc[test_idx, split_col] = "test" + + labels = pd.concat([lables_partly_missing, lables_full]) + + if len(labels.query(f"['val', 'test'] in {split_col}")) == 0: + log.warning(f"neither val nor test set present for {area_name}") + + area["labels"] = labels + + def process_label_files_(self, area: dict, area_name: str): + label_files = area["label_files"] + # ensure labels file follows schemata: + # [file_1, ..., file_n] + if isinstance(label_files, (str, Path)): + label_files = [label_files] + + assert len(label_files) > 0, f"no labels given, check area {area_name}" + + labels = None + for lf in label_files: + lb = gpd.read_file(Path(self._data_path) / "raw" / lf) + + # put dummy point if no position exists (usually true for csv data) + lb.geometry = lb.geometry.apply(lambda g: Point(0, 0) if g is None else g) + + alias_targets = area.get("alias_targets", self.targets) + assert len(alias_targets) == len(self.targets), f"given target aliases for '{area_name}' have " \ + f"different lengths: {alias_targets} vs {self.targets}" + + target_metric_factor = area.get("target_metric_factor", None) + + # add targets if present else set to nan + for ori_target, alias_target in zip(self.targets, alias_targets): + task = self.targets[ori_target]["task"] + if alias_target in lb: + lb[ori_target] = lb[alias_target] + # assumes that classification targets will be not necessarily be numbers, but everything else is + if task in ["regression", "mol"]: + lb[ori_target] = pd.to_numeric(lb[ori_target], errors="coerce") + if target_metric_factor is not None: + lb[ori_target] *= target_metric_factor.get(ori_target, 1.0) + else: + lb[ori_target] = np.nan + + if task == "classification": + # also save numerical values according to given classes + lb[f"{ori_target}_"] = lb[ori_target].map( + self.targets[ori_target]["class_mapping"] + ).astype(float) + + # crs comparison + if labels is None: + labels = lb + crs = lb.crs + else: + if crs != lb.crs: + Warning("CRS of label files do not match, have to convert") + lb = lb.to_crs(crs) + labels = pd.concat([labels, lb]) + + # indicate fully/partly missing targets in label sample + n_labels = len(labels) + nans_allowed = area.get("nans_allowed", True) + fully_missing = labels[self.targets].isna().all(axis=1).sum() + partly_missing = labels[self.targets].isna().any(axis=1).sum() + partly_missing = abs(partly_missing - fully_missing) + if fully_missing > 0: + log.info(f"{fully_missing} of {n_labels} labels fully missing in {area_name}") + if fully_missing == n_labels: + area["has_labels"] = False + if partly_missing > 0: + log.info(f"{partly_missing} of {n_labels} labels partly missing in {area_name}") + if fully_missing + partly_missing == n_labels and not nans_allowed: + area["has_labels"] = False + + if not nans_allowed: + labels.dropna(axis=0, how="any", subset=self.targets, inplace=True) + log.info( + f"Removing all missing or partly missing samples as indicated by 'nans_allowed' in {area_name}" + ) + + # apply filter query + query = area.get("label_query", None) + if query is not None: + labels.query(query, inplace=True) + if n_labels > len(labels): + log.warning(f"({n_labels - len(labels)} sample were " + f"filtered out according to: {query})") + + labels.set_index(np.arange(len(labels)), inplace=True) + return labels + + def set_label_stats_(self, save_processed: bool): + processed_dir = Path(os.path.join(self._data_path, self.dataset_opt.processed_folder)) + if save_processed: + processed_dir.mkdir(exist_ok=True) + means_file = processed_dir / "mean_targets.pt" + std_file = processed_dir / "std_targets.pt" + min_file = processed_dir / "min_targets.pt" + max_file = processed_dir / "max_targets.pt" + corr_file = processed_dir / "corr_targets.pt" + + self.mean_targets_ = torch.load(means_file) if means_file.exists() else \ + self.get_stat_targets_(np.nanmean, means_file if save_processed else None) + self.std_targets_ = torch.load(std_file) if std_file.exists() else \ + self.get_stat_targets_(np.nanstd, std_file if save_processed else None) + self.min_targets_ = torch.load(min_file) if min_file.exists() else \ + self.get_stat_targets_(np.nanmin, min_file if save_processed else None) + self.max_targets_ = torch.load(max_file) if max_file.exists() else \ + self.get_stat_targets_(np.nanmax, max_file if save_processed else None) + + self.corr_targets_ = torch.load(corr_file) if corr_file.exists() else \ + self.get_corr_targets_(corr_file if save_processed else None) + + def create_dataloaders( + self, + model: model_interface.DatasetInterface, + batch_size: int, + shuffle: bool, + drop_last: bool, + num_workers: int, + precompute_multi_scale: bool, + ): + if self.train_dataset and shuffle: + self.train_sampler = RandomSampler(self.train_dataset, batch_size, self.double_batch) + if drop_last is False: + log.warning("Cannot disable 'drop_last' with RandomSampler.") + super().create_dataloaders(model, batch_size, shuffle, drop_last, num_workers, precompute_multi_scale) + + def get_std_targets(self): + return self.std_targets_ + + def get_mean_targets(self): + return self.mean_targets_ + + def get_min_targets(self): + return self.min_targets_ + + def get_max_targets(self): + return self.max_targets_ + + def get_stat_targets_(self, stat_fn, file_name: (str, Path) = None): + dict = OrderedDict() + targets = [f"{target}_" if self.targets[target]["task"] == "classification" else target for target in + self.targets] + + dict["total"] = {} + if self.train_dataset is not None: + dict["total"].update({"train": [], }) + if self.val_dataset is not None: + dict["total"].update({"val": [], }) + if self.test_dataset is not None: + dict["total"].update({"test": [], }) + + for area_name in self.areas: + # TODO also uses labels that were not used due to too few points + sc = self.areas[area_name]["split_col"] + labels = self.areas[area_name]["labels"] + area_dict = {} + if self.train_dataset is not None and labels.query(f"{sc} == 'val'").shape[0] > 1: + values = labels.query(f"{sc} == 'train'")[targets].values + area_dict.update({"train": stat_fn(values, 0), }) + dict["total"]["train"].append(values) + if self.val_dataset is not None and labels.query(f"{sc} == 'val'").shape[0] > 1: + values = labels.query(f"{sc} == 'val'")[targets].values + area_dict.update({"val": stat_fn(values, 0), }) + dict["total"]["val"].append(values) + if self.test_dataset is not None and labels.query(f"{sc} == 'test'").shape[0] > 1: + values = labels.query(f"{sc} == 'test'")[targets].values + area_dict.update({"test": stat_fn(values, 0), }) + dict["total"]["test"].append(values) + + if len(area_dict) > 0: + dict[area_name] = area_dict + + if self.train_dataset is not None: + dict["total"]["train"] = stat_fn(np.concatenate(dict["total"]["train"], 0), 0) + if self.val_dataset is not None: + dict["total"]["val"] = stat_fn(np.concatenate(dict["total"]["val"], 0), 0) + if self.test_dataset is not None: + dict["total"]["test"] = stat_fn(np.concatenate(dict["total"]["test"], 0), 0) + + if file_name is not None: + torch.save(dict, file_name) + + return dict + + def get_corr_targets(self): + return self.corr_targets_ + + def get_corr_targets_(self, file_name: (str, Path) = None): + dict = OrderedDict() + targets = [f"{target}_" if self.targets[target]["task"] == "classification" else target for target in + self.targets] + + for area_name in self.areas: + sc = self.areas[area_name]["split_col"] + labels = self.areas[area_name]["labels"] + area_dict = {} + if self.train_dataset is not None and labels.query(f"{sc} == 'train'").shape[0] > 1: + area_dict.update({"train": labels.query(f"{sc} == 'train'")[targets].corr().values, }) + if self.val_dataset is not None and labels.query(f"{sc} == 'val'").shape[0] > 1: + area_dict.update( + {"val": labels.query(f"{sc} == 'val'")[targets].corr().values, } + ) + if self.test_dataset is not None and labels.query(f"{sc} == 'test'").shape[0] > 1: + area_dict.update( + {"test": labels.query(f"{sc} == 'test'")[targets].corr().values, } + ) + + if len(area_dict) > 0: + dict[area_name] = area_dict + + if file_name is not None: + torch.save(dict, file_name) + return dict + + def get_tracker(self, wandb_log: bool, tensorboard_log: bool): + """Factory method for the tracker + Arguments: + wandb_log - Log using weight and biases + tensorboard_log - Log using tensorboard + Returns: + [BaseTracker] -- tracker + """ + return InstanceTracker(self, wandb_log=wandb_log, use_tensorboard=tensorboard_log, + log_train_metrics=self.log_train_metrics) + + @property # type: ignore + @save_used_properties + def num_reg_classes(self) -> int: + if self.train_dataset: + return self.train_dataset.num_reg_classes + elif self.test_dataset is not None: + if isinstance(self.test_dataset, list): + return self.test_dataset[0].num_reg_classes + else: + return self.test_dataset.num_reg_classes + elif self.val_dataset is not None: + return self.val_dataset.num_reg_classes + else: + raise NotImplementedError() + + @property # type: ignore + @save_used_properties + def num_mol_classes(self) -> int: + if self.train_dataset: + return self.train_dataset.num_mol_classes + elif self.test_dataset is not None: + if isinstance(self.test_dataset, list): + return self.test_dataset[0].num_mol_classes + else: + return self.test_dataset.num_mol_classes + elif self.val_dataset is not None: + return self.val_dataset.num_mol_classes + else: + raise NotImplementedError() + + @property # type: ignore + @save_used_properties + def num_cls_classes(self) -> int: + if self.train_dataset: + return self.train_dataset.num_cls_classes + elif self.test_dataset is not None: + if isinstance(self.test_dataset, list): + return self.test_dataset[0].num_cls_classes + else: + return self.test_dataset.num_cls_classes + elif self.val_dataset is not None: + return self.val_dataset.num_cls_classes + else: + raise NotImplementedError() + + +class RandomSampler(Sampler[int]): + r"""Samples elements randomly. + + Args: + data_source (Dataset): dataset to sample from + batch_size (int): number of samples in a mini-batch + double_batch (bool): if each sample should in a batch should be returned twice (e.g., for self-supervision) + generator (Generator): Generator used in sampling. + """ + data_source: Sized + + def __init__(self, data_source: Sized, batch_size: int, double_batch: bool, generator=None) -> None: + super().__init__(data_source) + self.data_source = data_source + self.generator = generator + self._num_samples = None + self.batch_size = batch_size + self.double_batch = double_batch + + @property + def num_samples(self) -> int: + # dataset size might change at runtime + if self._num_samples is None: + return len(self.data_source) + return self._num_samples + + def __iter__(self) -> Iterator[int]: + if self.generator is None: + seed = int(torch.empty((), dtype=torch.int64).random_().item()) + generator = torch.Generator() + generator.manual_seed(seed) + else: + generator = self.generator + + iterator = torch.randperm(self.num_samples, generator=generator).tolist() + if self.double_batch: + iterator = np.array([[k, k] for k in iterator]).flatten().tolist() + iterator = iterator[:(self.num_samples // self.batch_size) * self.batch_size] + + yield from iterator + + def __len__(self) -> int: + return self.num_samples + + def __repr__(self): + return "{}(batch_size={},double_batch={},generator={})".format( + self.__class__.__name__, self.batch_size, self.double_batch, self.generator, + ) diff --git a/torch-points3d/torch_points3d/datasets/instance/las_dataset_.py b/torch-points3d/torch_points3d/datasets/instance/las_dataset_.py new file mode 100644 index 0000000..4728bfd --- /dev/null +++ b/torch-points3d/torch_points3d/datasets/instance/las_dataset_.py @@ -0,0 +1,894 @@ +import os +import sys +from collections import OrderedDict +from functools import partial +from glob import glob +from itertools import chain, product +from pathlib import Path +from typing import Sized, Iterator + +import geopandas as gpd +import laspy +import numpy as np +import pandas as pd +import pyproj +import scipy.stats as scstats +import torch +from omegaconf import OmegaConf +from plyfile import PlyData +from shapely.geometry import Point +from sklearn.neighbors import KDTree +from torch.utils.data import Sampler +from torch_geometric.data import Dataset, Data +from tqdm.auto import tqdm + +from torch_points3d.datasets.base_dataset import BaseDataset, save_used_properties +from torch_points3d.metrics.instance_tracker import InstanceTracker +from torch_points3d.models import model_interface + + +def read_pt(pt_file, feature_cols, delimiter: str): + crs = None + has_features = len(feature_cols) > 0 + if Path(pt_file).suffix in [".las", ".laz"]: + backend = laspy.compression.LazBackend(0) + if not backend.is_available(): + backend = laspy.compression.LazBackend(1) + if not backend.is_available(): + backend = laspy.compression.LazBackend(2) + loaded_file = laspy.read(pt_file, laz_backend=backend) + pos = np.stack([loaded_file.x, loaded_file.y, loaded_file.z], 1) + if has_features: + features = np.stack([getattr(loaded_file, feature) for feature in feature_cols], 1) + else: + features = None + + # get crs + for vlr in loaded_file.header.vlrs: + if isinstance(vlr, laspy.vlrs.known.WktCoordinateSystemVlr): + crs = vlr.string + elif Path(pt_file).suffix in [".ply"]: + loaded_file = PlyData.read(pt_file) + pos = np.stack([loaded_file.elements[0]["x"], loaded_file.elements[0]["y"], loaded_file.elements[0]["z"]], 1) + if has_features: + features = np.stack([loaded_file.elements[0][feat] for feat in feature_cols], 1) + else: + features = None + else: + # try to read as csv + loaded_file = pd.read_csv( + pt_file, header=None, engine="pyarrow", delimiter=delimiter, dtype=np.float32, skip_blank_lines=True + ) + pos = loaded_file.values[:, :3] # assumes first 3 values are positions + if has_features: + features = loaded_file[feature_cols] + else: + features = None + + return pos, features, crs + + +class Las(Dataset): + """loads all las files into memory and creates samples based on a label_df""" + + def __init__( + self, root, areas: dict, split: str, stats=None, + xy_radius=15., + transform=None, targets=None, feature_cols=None, feature_scaling_dict: dict = None, + pre_transform=None, pre_filter=None, processed_folder="processed", + in_memory: bool = False, pos_dict: dict = None, features_dict: dict = None, pos_tree_dict: dict = None, + ): + self.root = root + self.split = split + + self.processed_folder = processed_folder + + if pos_dict is not None or pos_tree_dict is not None: + assert pos_dict is not None and pos_tree_dict is not None, \ + "if any of pos or pos_tree are given, both need to be there" + + assert (len(feature_cols) > 0 and (features_dict is not None)) or len(feature_cols) == 0, \ + "need to give features, if pos is given and there are features" + self.pos_dict = {} if pos_dict is None else pos_dict + self.features_dict = {} if features_dict is None else features_dict + self.pos_tree_dict = {} if pos_tree_dict is None else pos_tree_dict + + self.areas = areas + + self.targets = targets + self.feature_cols = [] if feature_cols is None else feature_cols + self.stats = [] if stats is None else stats + # difference between measurement and pointclouds taken + self.radius = xy_radius + + # different types of targets + self.reg_targets = [target for target in self.targets if self.targets[target]["task"] == "regression"] + self.cls_targets = [target for target in self.targets if self.targets[target]["task"] == "classification"] + self.cls_targets_ = [f"{target}_" for target in self.targets if + self.targets[target]["task"] == "classification"] + self.mol_targets = [target for target in self.targets if self.targets[target]["task"] == "mol"] + + # if not give, calculate on given data + if feature_scaling_dict is None: + feature_scaling_dict = { + area_name: + { # feature: (center, scale) + "num_returns": (0., 5.), + "return_num": (0., 5.), } + for area_name in areas + } + self.feature_scaling_dict = feature_scaling_dict + + self.in_memory = in_memory + if in_memory: + self.memory = {} + + super().__init__( + root, transform, pre_transform, pre_filter + ) + # check if all areas are actually processed + for area_name in areas: + area = areas[area_name] + labels = area["labels"].query(f"{area['split_col']} == '{self.split}'") + if len(labels) > 0 and not (Path(self.processed_dir) / self.split / area_name / "done.flag").exists(): + print(f'Resuming processing, since {area_name} is not complete!', file=sys.stderr) + self.process() + + @property + def processed_dir(self) -> str: + return os.path.join(self.root, self.processed_folder) + + @property + def raw_file_names(self): + files = list(chain(*[[area["pt_files"]] for area in self.areas.values()])) + return files + + @property + def has_labels(self) -> bool: + return self.split in ["val", "test"] + + @property + def processed_file_names(self): + path = Path(self.processed_dir) / self.split + files = glob(str(path / f"*/*.pt")) + return files + + @property + def num_samples(self): + n = 0 + for area_name in self.areas: + area = self.areas[area_name] + + if (Path(self.processed_dir) / self.split / area_name / "done.flag").exists(): + n += len(list((Path(self.processed_dir) / self.split / area_name).glob("*.pt"))) + else: + n += len(area["labels"].query(f"{area['split_col']} == '{self.split}'")) + + return n + + def process(self): + for area_name in self.areas: + flag = (Path(self.processed_dir) / self.split / area_name / "done.flag") + area = self.areas[area_name] + + print(f"### start processing area: '{area_name}'") + if not flag.exists(): + + labels = area["labels"].query(f"{area['split_col']} == '{self.split}'") + if len(labels) == 0: + continue + + if area["type"] == "scene": + # can prepare this beforehand + pos, features, inner_label_point_idx, label_point_idx, labels = \ + self.process_scene_area_(area_name, labels) + + ### TODO reintroduce feature scaling + # if feature in feature_scaling: + # center, scale = feature_scaling.get(feature, (0., 1.)) + # else: + # # fill with iqr scaling + # center = np.median(feat) + # scale = (np.quantile(feat, 0.75) - np.quantile(feat, 0.25)) * 1.349 + # feature_scaling[feature] = (center, scale) + # features_sample.append((feat - center) / scale) + + print("Save samples and calculate stats") + (Path(self.processed_dir) / self.split).mkdir(exist_ok=True) + (Path(self.processed_dir) / self.split / area_name).mkdir(exist_ok=True) + file_idx = 0 + for idx in tqdm(range(len(labels))): + sample = labels.iloc[idx] + if area["type"] == "object": + # only load objects here instead of bulk loading before to avoid memory issues + pos, features, crs = read_pt(sample["pt_file"], self.feature_cols, area["delimiter"]) + + if area.get("check_pt_crs", True) and crs is not None and \ + not pyproj.CRS.is_exact_same(labels.crs, crs): + sample = labels.to_crs(crs).iloc[idx] + + # find points + label_centers = [[sample.geometry.x, sample.geometry.y]] + tree = KDTree(pos[:, :2]) + point_idxs = tree.query_radius(label_centers, self.radius)[0] + inner_point_idx = tree.query_radius(label_centers, self.radius / 2.)[0] + + elif area["type"] == "scene": + point_idxs = label_point_idx[idx] + inner_point_idx = inner_label_point_idx[idx] + + file = Path(self.processed_dir) / self.split / area_name / f"{file_idx}.pt" + if file.exists(): + continue + data = self.save_data_( + area_name, idx, sample, pos, features, + point_idxs, inner_point_idx + ) + if data is not None: + torch.save(data, file) + file_idx += 1 + flag.touch() + + def process_scene_area_(self, area_name, labels): + area = self.areas[area_name] + pos_tree = self.pos_tree_dict.get(area_name, None) + + if not pos_tree: + print(f"Load Las files") + pt = [read_pt(las_file, self.feature_cols, area["delimiter"]) for las_file in area["pt_files"]] + + pos = np.concatenate([p[0] for p in pt], 0) + if len(self.feature_cols) > 0: + features = np.concatenate([p[1] for p in pt], 0) + else: + features = None + + crs = np.stack([p[2] for p in pt], 0) + assert np.all(crs[0] == crs_ for crs_ in crs), "pt_files of an area need to be in same crs currently" + crs = crs[0] + + # fit this into a KDTree + print("Creating KDTree") + pos_tree = KDTree(pos[:, :2]) + + self.pos_dict[area_name] = pos + self.pos_tree_dict[area_name] = pos_tree + self.features_dict[area_name] = features + print("Query KDTree") + # restrict to bounds + if area.get("check_pt_crs", True) and crs is not None and not pyproj.CRS.is_exact_same(labels.crs, crs): + labels = labels.to_crs(crs) + + label_centers = np.stack([labels.geometry.x, labels.geometry.y], 1) + radius = self.radius + label_point_idx = self.pos_tree_dict[area_name].query_radius(label_centers, radius) + inner_label_point_idx = self.pos_tree_dict[area_name].query_radius(label_centers, radius / 2.) + return self.pos_dict[area_name], self.features_dict[area_name], inner_label_point_idx, label_point_idx, labels + + @property + def num_classes(self) -> int: + if not hasattr(self, "num_classes_"): + num_reg_classes = 0 + num_mol_classes = 0 + num_cls_classes = [] + if self.targets: + for target in self.targets: + task = self.targets[target]["task"] + if task == "classification": + num_cls_classes.append(len(self.targets[target]["class_names"])) + elif task == "regression": + num_reg_classes += 1 + elif task.lower() == "mol": + num_mixtures = self.targets[target].get("num_mixtures", 1) + num_mol_classes += num_mixtures * 3 + + self.num_reg_classes_ = num_reg_classes + self.num_mol_classes_ = num_mol_classes + self.num_cls_classes_ = num_cls_classes + + self.num_classes_ = self.num_reg_classes + self.num_mol_classes + int(np.sum(self.num_cls_classes)) + + return self.num_classes_ + + @property + def num_reg_classes(self) -> int: + if not hasattr(self, "num_reg_classes_"): + # init by calling num_classes + _ = self.num_classes + + return self.num_reg_classes_ + + @property + def num_mol_classes(self) -> int: + if not hasattr(self, "num_mol_classes_"): + # init by calling num_classes + _ = self.num_classes + + return self.num_mol_classes_ + + @property + def num_cls_classes(self) -> []: + if not hasattr(self, "num_cls_classes_"): + # init by calling num_classes + _ = self.num_classes + + return self.num_cls_classes_ + + def len(self): + return self.num_samples + + @staticmethod + def get_local_stats(points, postfix=""): + stats = {} + z = points[:, 2] + + z_stats = { + "h_mean": np.mean, + "h_std": np.std, + "h_coov": scstats.variation, + "h_kur": scstats.kurtosis, + "h_skew": scstats.skew, + } + + quantiles = [5, 10, 25, 50, 75, 90, 95, 99] + z_stats.update({f"h_q{i}": partial(np.quantile, q=i / 100) for i in quantiles}) + + def density_q(z, q): + # the proportion of points above the height percentiles + quant = np.quantile(z, q=q) + return len(z[z > quant]) / len(z) + + z_stats.update({f"d_q{i}": partial(density_q, q=i / 100) for i in quantiles}) + + tree = KDTree(points) + # create 1m grid spanning extend + xx = np.arange(points[:, 0].min(), points[:, 0].max(), 1) + yy = np.arange(points[:, 1].min(), points[:, 1].max(), 1) + zz = np.arange(points[:, 2].min(), points[:, 2].max(), 1) + grid = [[x, y, z] for x, y, z in product(xx, yy, zz)] + # get highest density in grid + if len(grid) > len(points): # use points directly if only few points present + grid = points + density = tree.kernel_density(grid, 1, kernel="gaussian").max() + stats["kde_h1"] = density + + for key in z_stats.keys(): + try: + value = z_stats[key](z) + except IndexError: + # return -1 if not enough values in quantiles + value = -1 + + stats[key + postfix] = value + + return stats + + def get(self, idx): + if self.in_memory: + if idx in self.memory.keys(): + data = self.memory[idx].clone() + else: + data = torch.load(self.processed_file_names[idx]) + self.memory[idx] = data.clone() + else: + data = torch.load(self.processed_file_names[idx]) + + del data.local_stats_keys + return data + + def save_data_(self, area_name: str, idx: int, sample, pos_: np.array, features_: np.array, + point_idxs: np.array, inner_point_idxs: np.array): + + x = self.center_pos(pos_[point_idxs], sample) + + data = { + "pos": x, + "height_m": sample["height_m"], + "mean_crown_diameter_m": sample["mean_crown_diameter_m"], + "DBH_cm": sample["DBH_cm"], + "species": sample["species"], + "source": sample["source"], + "date": sample["date"], + "quality": sample["quality"], + + } + + return data + + def covert_to_data_( + self, x, y_reg, y_reg_mask, y_mol, y_mol_mask, y_cls, y_cls_mask, features, area_name, local_stats, + local_stats_keys, stats + ): + x = torch.tensor(x, dtype=torch.float32) + y_reg = torch.tensor(y_reg, dtype=torch.float32) + y_reg_mask = torch.tensor(y_reg_mask, dtype=torch.bool) + y_mol = torch.tensor(y_mol, dtype=torch.float32) + y_mol_mask = torch.tensor(y_mol_mask, dtype=torch.bool) + y_cls[~y_cls_mask] = - 1 + y_cls = torch.tensor(y_cls, dtype=torch.long) + y_cls_mask = torch.tensor(y_cls_mask, dtype=torch.bool) + features = features if features is None else torch.tensor(features, dtype=torch.float32) + stats = torch.tensor(stats, dtype=torch.float32) + local_stats = torch.tensor(local_stats, dtype=torch.float32) + data = Data( + x=features, + y_reg=y_reg, y_reg_mask=y_reg_mask, + y_mol=y_mol, y_mol_mask=y_mol_mask, + y_cls=y_cls, y_cls_mask=y_cls_mask, + pos=x, stats=stats, area_name=area_name, + local_stats=local_stats, local_stats_keys=local_stats_keys + ) + return data + + def get_stats(self, x, inner_x, df): + # local stats + local_stats = self.get_local_stats(x) + local_stats.update(self.get_local_stats(inner_x, "_inner")) + local_stats_keys = list(local_stats.keys()) + local_stats = list(local_stats.values()) + # global stats + stats = df[self.stats] + return local_stats, local_stats_keys, stats + + def center_pos(self, x, df): + x_center = np.amin(x, axis=0, keepdims=True) + x_center[:, 0] = df.geometry.x + x_center[:, 1] = df.geometry.y + x -= x_center + return x + + +class LasDataset(BaseDataset): + def __init__(self, dataset_opt): + super().__init__(dataset_opt) + self.dataset_opt = dataset_opt + self.targets = dataset_opt.get("targets", None) + self.target_keys = list(self.targets.keys()) if self.targets is not None else None + self.features = dataset_opt.features + self.stats = dataset_opt.stats + self.xy_radius = dataset_opt.xy_radius + self.x_scale = dataset_opt.x_scale + self.y_scale = dataset_opt.y_scale + self.z_scale = dataset_opt.z_scale + self.double_batch = dataset_opt.get("double_batch", False) + self.log_train_metrics = dataset_opt.get("log_train_metrics", True) + + self.areas: dict = OrderedDict(OmegaConf.to_container(dataset_opt.areas)) + + self.reg_targets = [target for target in self.targets if self.targets[target]["task"] == "regression"] + self.reg_targets_idx = [self.targets[target]["task"] == "regression" for target in self.targets] + self.cls_targets = [target for target in self.targets if self.targets[target]["task"] == "classification"] + self.cls_targets_idx = [self.targets[target]["task"] == "classification" for target in self.targets] + self.cls_targets_ = [f"{target}_" for target in self.cls_targets] + self.mol_targets = [target for target in self.targets if self.targets[target]["task"] == "mol"] + self.mol_targets_idx = [self.targets[target]["task"] == "mol" for target in self.targets] + + processed_folder = dataset_opt.get("processed_folder", "processed") + + for area_name in self.areas: + area = self.areas[area_name] + + # set some standard params + area["delimiter"] = area.get("delimiter", dataset_opt.get("delimiter", ",")) + + # processing file lists + pt_files = area["pt_files"] + if isinstance(pt_files, (str, Path)): + pt_files = glob(str(Path(self._data_path) / "raw" / pt_files)) + elif isinstance(pt_files, list): + # iterating to list of files + unpacked_files = [] + for f in pt_files: + unpacked_files.extend(glob(str(Path(self._data_path) / "raw" / f))) + pt_files = unpacked_files + else: + raise Exception("pt_files need to be a str or a list of str (can use * expression)") + + labels = self.process_label_files_(area, area_name) + + labels.geometry = labels.centroid + + if area["type"] == "object": + # check if each label has a pt_file + def find_pt_file(id): + for ptf in pt_files: + # return first occurrence + if id in ptf: + return ptf + return "None" + + labels["pt_file"] = labels[area["pt_identifier"]].apply(find_pt_file) + + # removing sample without pt_file + n_samples = len(labels) + labels.query("pt_file != 'None'", inplace=True) + if len(labels) != n_samples: + print(f"Warning: {n_samples - len(labels)} removed due to missing pt_file") + + pt_files = labels["pt_file"].values.tolist() + + area["pt_files"] = pt_files + + split_col = area.get("split_col", dataset_opt.get("split_col", "split")) + area["split_col"] = split_col + # create split if fully labeled data available + if split_col not in labels.columns: + targets_must_be_present = np.array(area.get("targets_must_be_present", [True] * len(self.target_keys))) + lb = labels[np.array(self.target_keys)[targets_must_be_present]] + + # if no targets are fully available, only use this area for training + if (~targets_must_be_present).all() or lb.isna().all().all(): + labels[split_col] = "train" + else: + # no split available, create own + # only select those that have labels others are for training + partly_missing = lb.isna().all(1) + lables_partly_missing = labels[partly_missing] + lables_partly_missing[split_col] = "train" + + lables_full = labels[~partly_missing] + index = lables_full.index.values + + rs = np.random.RandomState(42) + val_ratio = area.get("val_ratio", .1) + test_ratio = area.get("test_ratio", .1) + + rs.shuffle(index) + + train_end = int(len(index) * (1 - (val_ratio + test_ratio))) + val_end = int(len(index) * (1 - test_ratio)) + train_idx = index[:train_end] + val_idx = index[train_end:val_end] + test_idx = index[val_end:] + + lables_full.loc[train_idx, split_col] = "train" + if val_ratio != 0 and len(val_idx) > 0: + lables_full.loc[val_idx, split_col] = "val" + if test_ratio != 0 and len(test_idx) > 0: + lables_full.loc[test_idx, split_col] = "test" + + labels = pd.concat([lables_partly_missing, lables_full]) + + if len(labels.query(f"['val', 'test'] in {split_col}")) == 0: + print(f"Warning: neither val nor test set present for {area_name}") + + area["labels"] = labels + val_set_avail = any( + [len(area["labels"].query(f"{area['split_col']} == 'val'")) > 0 for area in self.areas.values()]) + test_set_avail = any( + [len(area["labels"].query(f"{area['split_col']} == 'test'")) > 0 for area in self.areas.values()]) + + (self._data_path / (Path(processed_folder))).mkdir(exist_ok=True) + + feature_scaling_file = self._data_path / (Path(processed_folder) / "features_scaling.pt") + feature_scaling_dict = torch.load(feature_scaling_file) if feature_scaling_file.exists() else None + + in_memory = dataset_opt.get("in_memory", False) + + print("init train dataset") + self.train_dataset = Las( + self._data_path, areas=self.areas, split="train", + targets=self.targets, feature_cols=self.features, feature_scaling_dict=feature_scaling_dict, + stats=dataset_opt.stats, transform=self.train_transform, pre_transform=self.pre_transform, + processed_folder=processed_folder, + xy_radius=self.xy_radius, + in_memory=in_memory + ) + if not feature_scaling_file.exists(): + feature_scaling_dict = self.train_dataset.feature_scaling_dict + torch.save(feature_scaling_dict, feature_scaling_file) + + if val_set_avail: + print("init val dataset") + self.val_dataset = Las( + self._data_path, areas=self.areas, split="val", + targets=self.targets, feature_cols=self.features, feature_scaling_dict=feature_scaling_dict, + stats=dataset_opt.stats, transform=self.val_transform, pre_transform=self.pre_transform, + processed_folder=processed_folder, + xy_radius=self.xy_radius, + in_memory=in_memory, + pos_dict=self.train_dataset.pos_dict, features_dict=self.train_dataset.features_dict, + pos_tree_dict=self.train_dataset.pos_tree_dict + ) + + if test_set_avail: + print("init test dataset") + self.test_dataset = Las( + self._data_path, areas=self.areas, split="test", + targets=self.targets, feature_cols=self.features, feature_scaling_dict=feature_scaling_dict, + stats=dataset_opt.stats, transform=self.test_transform, pre_transform=self.pre_transform, + processed_folder=processed_folder, + xy_radius=self.xy_radius, + in_memory=in_memory, + pos_dict=self.train_dataset.pos_dict, features_dict=self.train_dataset.features_dict, + pos_tree_dict=self.train_dataset.pos_tree_dict + ) + + del self.train_dataset.pos_dict, self.train_dataset.pos_tree_dict, self.train_dataset.features_dict + + self.set_label_stats_() + + self.has_reg_targets = not np.isnan( + [area["train"][self.reg_targets_idx] for area in self.get_std_targets().values()]).all() + self.has_mol_targets = not np.isnan( + [area["train"][self.mol_targets_idx] for area in self.get_std_targets().values()]).all() + self.has_cls_targets = not np.isnan( + [area["train"][self.cls_targets_idx] for area in self.get_std_targets().values()]).all() + + def process_label_files_(self, area: dict, area_name: str): + label_files = area["label_files"] + # ensure labels file follows schemata: + # [file_1, ..., file_n] + if isinstance(label_files, (str, Path)): + label_files = [label_files] + + assert len(label_files) > 0, f"no labels given, check area {area_name}" + + labels = None + for lf in label_files: + lb = gpd.read_file(Path(self._data_path) / "raw" / lf) + + # put dummy point if no position exists (usually true for csv data) + lb.geometry = lb.geometry.apply(lambda g: Point(0, 0) if g is None else g) + + alias_targets = area.get("alias_targets", self.targets) + assert len(alias_targets) == len(self.targets), f"given target aliases for '{area_name}' have " \ + f"different lengths: {alias_targets} vs {self.targets}" + + target_metric_factor = area.get("target_metric_factor", None) + + # add targets if present else set to nan + for ori_target, alias_target in zip(self.targets, alias_targets): + task = self.targets[ori_target]["task"] + if alias_target in lb: + lb[ori_target] = lb[alias_target] + # assumes that classification targets will be not necessarily be numbers, but everything else is + if task in ["regression", "mol"]: + lb[ori_target] = pd.to_numeric(lb[ori_target], errors="coerce") + if target_metric_factor is not None: + lb[ori_target] *= target_metric_factor.get(ori_target, 1.0) + else: + lb[ori_target] = np.nan + + if task == "classification": + # also save numerical values according to given classes + lb[f"{ori_target}_"] = lb[ori_target].map( + self.targets[ori_target]["class_mapping"] + ).astype(float) + + # crs comparison + if labels is None: + labels = lb + crs = lb.crs + else: + if crs != lb.crs: + Warning("CRS of label files do not match, have to convert") + lb = lb.to_crs(crs) + labels = pd.concat([labels, lb]) + + # indicate fully/partly missing targets in label sample + n_labels = len(labels) + nans_allowed = area.get("nans_allowed", True) + fully_missing = labels[self.targets].isna().all(1).sum() + partly_missing = labels[self.targets].isna().any(1).sum() + partly_missing = abs(partly_missing - fully_missing) + if fully_missing > 0: + print(f"Info: {fully_missing} of {n_labels} labels fully missing in {area_name}") + if fully_missing == n_labels: + area["has_labels"] = False + if partly_missing > 0: + print(f"Info: {partly_missing} of {n_labels} labels partly missing in {area_name}") + if fully_missing + partly_missing == n_labels and not nans_allowed: + area["has_labels"] = False + + if not nans_allowed: + labels.dropna(axis=0, how="any", subset=self.targets, inplace=True) + print(f"Info: Removing all missing or partly missing samples as indicated by 'nans_allowed' in {area_name}") + + # apply filter query + query = area.get("label_query", None) + if query is not None: + labels.query(query, inplace=True) + if n_labels > len(labels): + Warning(f"Warning: ({n_labels - len(labels)} sample were " + f"filtered out according to: {query})") + + labels.set_index(np.arange(len(labels)), inplace=True) + return labels + + def set_label_stats_(self): + processed_dir = Path(os.path.join(self._data_path, self.dataset_opt.processed_folder)) + processed_dir.mkdir(exist_ok=True) + means_file = processed_dir / "mean_targets.pt" + std_file = processed_dir / "std_targets.pt" + min_file = processed_dir / "min_targets.pt" + max_file = processed_dir / "max_targets.pt" + corr_file = processed_dir / "corr_targets.pt" + + self.mean_targets_ = torch.load(means_file) if means_file.exists() else \ + self.get_stat_targets_(np.nanmean, means_file) + self.std_targets_ = torch.load(std_file) if std_file.exists() else \ + self.get_stat_targets_(np.nanstd, std_file) + self.min_targets_ = torch.load(min_file) if min_file.exists() else \ + self.get_stat_targets_(np.nanmin, min_file) + self.max_targets_ = torch.load(max_file) if max_file.exists() else \ + self.get_stat_targets_(np.nanmax, max_file) + + self.corr_targets_ = torch.load(corr_file) if corr_file.exists() else self.get_corr_targets_(corr_file) + + def create_dataloaders( + self, + model: model_interface.DatasetInterface, + batch_size: int, + shuffle: bool, + num_workers: int, + precompute_multi_scale: bool, + ): + self.train_sampler = RandomSampler(self.train_dataset, True, batch_size, self.double_batch) + super().create_dataloaders(model, batch_size, shuffle, num_workers, precompute_multi_scale) + + def get_std_targets(self): + return self.std_targets_ + + def get_mean_targets(self): + return self.mean_targets_ + + def get_min_targets(self): + return self.min_targets_ + + def get_max_targets(self): + return self.max_targets_ + + def get_stat_targets_(self, stat_fn, file_name: (str, Path) = None): + dict = OrderedDict() + targets = [f"{target}_" if self.targets[target]["task"] == "classification" else target for target in + self.targets] + + for area_name in self.areas: + # TODO also uses labels that were not used due to too few points + sc = self.areas[area_name]["split_col"] + labels = self.areas[area_name]["labels"] + dict[area_name] = {} + + dict[area_name] = {"train": stat_fn(labels.query(f"{sc} == 'train'")[targets].values, 0), } + if self.val_dataset is not None and labels.query(f"{sc} == 'val'").shape[0] > 1: + dict[area_name].update( + {"val": stat_fn(labels.query(f"{sc} == 'val'")[targets].values, 0), } + ) + if self.test_dataset is not None and labels.query(f"{sc} == 'test'").shape[0] > 1: + dict[area_name].update( + {"test": stat_fn(labels.query(f"{sc} == 'test'")[targets].values, 0), } + ) + + if file_name is not None: + torch.save(dict, file_name) + + return dict + + def get_corr_targets(self): + return self.corr_targets_ + + def get_corr_targets_(self, file_name: (str, Path) = None): + dict = OrderedDict() + targets = [f"{target}_" if self.targets[target]["task"] == "classification" else target for target in + self.targets] + + for area_name in self.areas: + sc = self.areas[area_name]["split_col"] + labels = self.areas[area_name]["labels"] + + dict[area_name] = {"train": labels.query(f"{sc} == 'train'")[targets].corr().values, } + if self.val_dataset is not None and labels.query(f"{sc} == 'val'").shape[0] > 1: + dict[area_name].update( + {"val": labels.query(f"{sc} == 'val'")[targets].corr().values, } + ) + if self.test_dataset is not None and labels.query(f"{sc} == 'test'").shape[0] > 1: + dict[area_name].update( + {"test": labels.query(f"{sc} == 'test'")[targets].corr().values, } + ) + + if file_name is not None: + torch.save(dict, file_name) + return dict + + def get_tracker(self, wandb_log: bool, tensorboard_log: bool): + """Factory method for the tracker + Arguments: + wandb_log - Log using weight and biases + tensorboard_log - Log using tensorboard + Returns: + [BaseTracker] -- tracker + """ + return InstanceTracker(self, wandb_log=wandb_log, use_tensorboard=tensorboard_log, + log_train_metrics=self.log_train_metrics) + + @property # type: ignore + @save_used_properties + def num_reg_classes(self) -> int: + if self.train_dataset: + return self.train_dataset.num_reg_classes + elif self.test_dataset is not None: + if isinstance(self.test_dataset, list): + return self.test_dataset[0].num_reg_classes + else: + return self.test_dataset.num_reg_classes + elif self.val_dataset is not None: + return self.val_dataset.num_reg_classes + else: + raise NotImplementedError() + + @property # type: ignore + @save_used_properties + def num_mol_classes(self) -> int: + if self.train_dataset: + return self.train_dataset.num_mol_classes + elif self.test_dataset is not None: + if isinstance(self.test_dataset, list): + return self.test_dataset[0].num_mol_classes + else: + return self.test_dataset.num_mol_classes + elif self.val_dataset is not None: + return self.val_dataset.num_mol_classes + else: + raise NotImplementedError() + + @property # type: ignore + @save_used_properties + def num_cls_classes(self) -> int: + if self.train_dataset: + return self.train_dataset.num_cls_classes + elif self.test_dataset is not None: + if isinstance(self.test_dataset, list): + return self.test_dataset[0].num_cls_classes + else: + return self.test_dataset.num_cls_classes + elif self.val_dataset is not None: + return self.val_dataset.num_cls_classes + else: + raise NotImplementedError() + + +class RandomSampler(Sampler[int]): + r"""Samples elements randomly. If without replacement, then sample from a shuffled dataset. + If with replacement, then user can specify :attr:`num_samples` to draw. + + Args: + data_source (Dataset): dataset to sample from + num_samples (int): number of samples to draw, default=`len(dataset)`. + generator (Generator): Generator used in sampling. + """ + data_source: Sized + + def __init__(self, data_source: Sized, drop_last: bool, batch_size: int, double_batch: bool) -> None: + super().__init__(data_source) + self.data_source = data_source + self.generator = None + self._num_samples = None + self.batch_size = batch_size + self.drop_last = drop_last + self.double_batch = double_batch + + @property + def num_samples(self) -> int: + # dataset size might change at runtime + if self._num_samples is None: + return len(self.data_source) + return self._num_samples + + def __iter__(self) -> Iterator[int]: + if self.generator is None: + seed = int(torch.empty((), dtype=torch.int64).random_().item()) + generator = torch.Generator() + generator.manual_seed(seed) + else: + generator = self.generator + + iterator = torch.randperm(self.num_samples, generator=generator).tolist() + if self.double_batch: + iterator = np.array([[k, k] for k in iterator]).flatten().tolist() + iterator = iterator[:(self.num_samples // self.batch_size) * self.batch_size] + + yield from iterator + + def __len__(self) -> int: + return self.num_samples diff --git a/torch-points3d/torch_points3d/datasets/multiscale_data.py b/torch-points3d/torch_points3d/datasets/multiscale_data.py new file mode 100644 index 0000000..c66a746 --- /dev/null +++ b/torch-points3d/torch_points3d/datasets/multiscale_data.py @@ -0,0 +1,165 @@ +from typing import List, Optional +import torch +import copy +import torch_geometric +from torch_geometric.data import Data +from torch_geometric.data import Batch + + +class MultiScaleData(Data): + def __init__( + self, + x=None, + y=None, + pos=None, + multiscale: Optional[List[Data]] = None, + upsample: Optional[List[Data]] = None, + **kwargs, + ): + super().__init__(x=x, y=y, pos=pos, multiscale=multiscale, upsample=upsample, **kwargs) + + def apply(self, func, *keys): + r"""Applies the function :obj:`func` to all tensor and Data attributes + :obj:`*keys`. If :obj:`*keys` is not given, :obj:`func` is applied to + all present attributes. + """ + for key, item in self(*keys): + if torch.is_tensor(item): + self[key] = func(item) + for scale in range(self.num_scales): + self.multiscale[scale] = self.multiscale[scale].apply(func) + + for up in range(self.num_upsample): + self.upsample[up] = self.upsample[up].apply(func) + return self + + @property + def num_scales(self): + """ Number of scales in the multiscale array + """ + return len(self.multiscale) if hasattr(self, "multiscale") and self.multiscale else 0 + + @property + def num_upsample(self): + """ Number of upsample operations + """ + return len(self.upsample) if hasattr(self, "upsample") and self.upsample else 0 + + @classmethod + def from_data(cls, data): + ms_data = cls() + for k, item in data: + ms_data[k] = item + return ms_data + + +class MultiScaleBatch(MultiScaleData): + @staticmethod + def from_data_list(data_list, follow_batch=[]): + r"""Constructs a batch object from a python list holding + :class:`torch_geometric.data.Data` objects. + The assignment vector :obj:`batch` is created on the fly. + Additionally, creates assignment batch vectors for each key in + :obj:`follow_batch`.""" + for data in data_list: + assert isinstance(data, MultiScaleData) + num_scales = data_list[0].num_scales + for data_entry in data_list: + assert data_entry.num_scales == num_scales, "All data objects should contain the same number of scales" + num_upsample = data_list[0].num_upsample + for data_entry in data_list: + assert data_entry.num_upsample == num_upsample, "All data objects should contain the same number of scales" + + # Build multiscale batches + multiscale = [] + for scale in range(num_scales): + ms_scale = [] + for data_entry in data_list: + ms_scale.append(data_entry.multiscale[scale]) + multiscale.append(from_data_list_token(ms_scale)) + + # Build upsample batches + upsample = [] + for scale in range(num_upsample): + upsample_scale = [] + for data_entry in data_list: + upsample_scale.append(data_entry.upsample[scale]) + upsample.append(from_data_list_token(upsample_scale)) + + # Create batch from non multiscale data + for data_entry in data_list: + del data_entry.multiscale + del data_entry.upsample + batch = Batch.from_data_list(data_list) + batch = MultiScaleBatch.from_data(batch) + batch.multiscale = multiscale + batch.upsample = upsample + + if torch_geometric.is_debug_enabled(): + batch.debug() + + return batch + + +def from_data_list_token(data_list, follow_batch=[]): + """ This is pretty a copy paste of the from data list of pytorch geometric + batch object with the difference that indexes that are negative are not incremented + """ + + keys = [set(data.keys) for data in data_list] + keys = list(set.union(*keys)) + assert "batch" not in keys + + batch = Batch() + batch.__data_class__ = data_list[0].__class__ + batch.__slices__ = {key: [0] for key in keys} + + for key in keys: + batch[key] = [] + + for key in follow_batch: + batch["{}_batch".format(key)] = [] + + cumsum = {key: 0 for key in keys} + batch.batch = [] + for i, data in enumerate(data_list): + for key in data.keys: + item = data[key] + if torch.is_tensor(item) and item.dtype != torch.bool and cumsum[key] > 0: + mask = item >= 0 + item[mask] = item[mask] + cumsum[key] + if torch.is_tensor(item): + size = item.size(data.__cat_dim__(key, data[key])) + else: + size = 1 + batch.__slices__[key].append(size + batch.__slices__[key][-1]) + cumsum[key] += data.__inc__(key, item) + batch[key].append(item) + + if key in follow_batch: + item = torch.full((size,), i, dtype=torch.long) + batch["{}_batch".format(key)].append(item) + + num_nodes = data.num_nodes + if num_nodes is not None: + item = torch.full((num_nodes,), i, dtype=torch.long) + batch.batch.append(item) + + if num_nodes is None: + batch.batch = None + + for key in batch.keys: + item = batch[key][0] + if torch.is_tensor(item): + batch[key] = torch.cat( + batch[key], dim=data_list[0].__cat_dim__(key, item)) + elif isinstance(item, int) or isinstance(item, float): + batch[key] = torch.tensor(batch[key]) + else: + raise ValueError( + "Unsupported attribute type {} : {}".format(type(item), item)) + + if torch_geometric.is_debug_enabled(): + batch.debug() + + return batch.contiguous() diff --git a/torch-points3d/torch_points3d/datasets/samplers.py b/torch-points3d/torch_points3d/datasets/samplers.py new file mode 100644 index 0000000..e7c6877 --- /dev/null +++ b/torch-points3d/torch_points3d/datasets/samplers.py @@ -0,0 +1,31 @@ +import torch +import numpy as np +from torch.utils.data import Sampler + +class BalancedRandomSampler(Sampler): + r"""This sampler is responsible for creating balanced batch based on the class distribution. + It is implementing a replacement=True strategy for indices selection + """ + def __init__(self, labels, replacement=True): + + self.num_samples = len(labels) + + self.idx_classes, self.counts = np.unique(labels, return_counts=True) + self.indices = { + idx: np.argwhere(labels == idx).flatten() for idx in self.idx_classes + } + + def __iter__(self): + indices = [] + for _ in range(self.num_samples): + idx = np.random.choice(self.idx_classes) + indice = int(np.random.choice(self.indices[idx])) + indices.append(indice) + return iter(indices) + + def __len__(self): + return self.num_samples + + def __repr__(self): + return "{}(num_samples={})".format(self.__class__.__name__, self.num_samples) + diff --git a/torch-points3d/torch_points3d/metrics/__init__.py b/torch-points3d/torch_points3d/metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/metrics/base_tracker.py b/torch-points3d/torch_points3d/metrics/base_tracker.py new file mode 100644 index 0000000..9da85e3 --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/base_tracker.py @@ -0,0 +1,138 @@ +import logging +import os +from typing import Dict, Any + +import torch +import torchnet as tnt +import wandb +from torch.utils.tensorboard import SummaryWriter + +from torch_points3d.models import model_interface + +log = logging.getLogger(__name__) + + +def meter_value(meter, dim=0): + return float(meter.value()[dim]) if meter.n > 0 else 0.0 + + +class BaseTracker: + def __init__(self, stage: str, wandb_log: bool, use_tensorboard: bool): + self._wandb = wandb_log + self._use_tensorboard = use_tensorboard + self._tensorboard_dir = os.path.join(os.getcwd(), "tensorboard") + self._n_iter = 0 + self._finalised = False + self._conv_type = None + + if self._use_tensorboard: + log.info( + "Access tensorboard with the following command ".format(self._tensorboard_dir) + ) + self._writer = SummaryWriter(log_dir=self._tensorboard_dir) + + def reset(self, stage="train"): + self._stage = stage + self._loss_meters = {} + self._finalised = False + + def get_loss(self) -> Dict[str, Any]: + metrics = {} + for key, loss_meter in self._loss_meters.items(): + value = meter_value(loss_meter, dim=0) + if value: + metrics[key] = meter_value(loss_meter, dim=0) + return metrics + + def get_metrics(self, verbose=False) -> Dict[str, Any]: + return self.get_loss() + + @property + def metric_func(self): + self._metric_func = {"loss": min} + return self._metric_func + + def track(self, model: model_interface.TrackerInterface, **kwargs): + if self._finalised: + raise RuntimeError("Cannot track new values with a finalised tracker, you need to reset it first") + losses = self._convert(model.get_current_losses()) + self._append_losses(losses) + + def finalise(self, *args, **kwargs): + """Lifecycle method that is called at the end of an epoch. Use this to compute + end of epoch metrics. + """ + self._finalised = True + + def _append_losses(self, losses): + for key, loss in losses.items(): + if loss is None: + continue + loss_key = "%s_%s" % (self._stage, key) + if loss_key not in self._loss_meters: + self._loss_meters[loss_key] = tnt.meter.AverageValueMeter() + self._loss_meters[loss_key].add(loss) + + @staticmethod + def _convert(x): + if torch.is_tensor(x): + return x.detach().cpu().numpy() + else: + return x + + def publish_to_tensorboard(self, metrics, step): + for metric_name, metric_value in metrics.items(): + if isinstance(metric_value, (wandb.Table, wandb.viz.CustomChart)): # don't add table to postfix + continue + metric_name = "{}/{}".format(metric_name.replace(self._stage + "_", ""), self._stage) + self._writer.add_scalar(metric_name, metric_value, step) + + @staticmethod + def _remove_stage_from_metric_keys(stage, metrics): + new_metrics = {} + for metric_name, metric_value in metrics.items(): + if isinstance(metric_value, (wandb.Table, wandb.viz.CustomChart)): # don't add table to postfix + continue + new_metrics[metric_name.replace(stage + "_", "")] = metric_value + return new_metrics + + def publish_to_wandb(self, metrics, epoch): + wandb_metrics = metrics.copy() + wandb_metrics["epoch"] = epoch + wandb.log(wandb_metrics) + + def publish_metrics(self, metrics, epoch): + if self._wandb: + self.publish_to_wandb(metrics, epoch) + + if self._use_tensorboard: + self.publish_to_tensorboard(metrics, epoch) + + def get_publish_metrics(self, epoch): + """Publishes the current metrics to wandb and tensorboard + Arguments: + step: current epoch + """ + metrics = self.get_metrics() + + return { + "stage": self._stage, + "epoch": epoch, + "current_metrics": self._remove_stage_from_metric_keys(self._stage, metrics), + "all_metrics": metrics + } + + def print_summary(self): + metrics = self.get_loss() + log.info("".join(["=" for i in range(50)])) + for key, value in metrics.items(): + log.info(" {} = {}".format(key, value)) + log.info("".join(["=" for i in range(50)])) + + @staticmethod + def _dict_to_str(dictionnary): + string = "{" + for key, value in dictionnary.items(): + string += "%s: %.2f," % (str(key), value) + string += "}" + return string diff --git a/torch-points3d/torch_points3d/metrics/colored_tqdm.py b/torch-points3d/torch_points3d/metrics/colored_tqdm.py new file mode 100644 index 0000000..d91676d --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/colored_tqdm.py @@ -0,0 +1,40 @@ +from tqdm.auto import tqdm +from collections import OrderedDict +from numbers import Number +import numpy as np + +from torch_points3d.utils.colors import COLORS + + +class Coloredtqdm(tqdm): + def set_postfix(self, ordered_dict=None, refresh=True, color=None, round=4, **kwargs): + postfix = OrderedDict([] if ordered_dict is None else ordered_dict) + + for key in sorted(kwargs.keys()): + postfix[key] = kwargs[key] + + for key in postfix.keys(): + if isinstance(postfix[key], Number): + postfix[key] = self.format_num_to_k(np.round(postfix[key], round), k=round + 1) + if isinstance(postfix[key], str): + postfix[key] = str(postfix[key]) + if len(postfix[key]) != round: + postfix[key] += (round - len(postfix[key])) * " " + + if color is not None: + self.postfix = color + else: + self.postfix = "" + + self.postfix += ", ".join(key + "=" + postfix[key] for key in postfix.keys()) + if color is not None: + self.postfix += COLORS.END_TOKEN + + if refresh: + self.refresh() + + def format_num_to_k(self, seq, k=4): + seq = str(seq) + length = len(seq) + out = seq + " " * (k - length) if length < k else seq + return out if length < k else seq[:k] diff --git a/torch-points3d/torch_points3d/metrics/confusion_matrix.py b/torch-points3d/torch_points3d/metrics/confusion_matrix.py new file mode 100644 index 0000000..139415a --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/confusion_matrix.py @@ -0,0 +1,118 @@ +import os + +import numpy as np +import torch + + +class ConfusionMatrix: + """Streaming interface to allow for any source of predictions. + Initialize it, count predictions one by one, then print confusion matrix and intersection-union score""" + + def __init__(self, cls_names): + self.cls_names = np.array(cls_names) + self.n_cls = len(cls_names) + self.confusion_matrix = None + + @staticmethod + def create_from_matrix(confusion_matrix): + assert confusion_matrix.shape[0] == confusion_matrix.shape[1] + matrix = ConfusionMatrix(confusion_matrix.shape[0]) + matrix.confusion_matrix = confusion_matrix + return matrix + + def count_predicted_batch(self, ground_truth_vec, predicted): + assert predicted.max() < self.n_cls + batch_confusion = torch.bincount( + self.n_cls * ground_truth_vec.int() + predicted, minlength=self.n_cls ** 2 + ).reshape(self.n_cls, self.n_cls) + if self.confusion_matrix is None: + self.confusion_matrix = batch_confusion + else: + self.confusion_matrix += batch_confusion + + def get_count(self, ground_truth, predicted): + """labels are integers from 0 to number_of_labels-1""" + return self.confusion_matrix[ground_truth][predicted] + + def get_confusion_matrix(self): + """returns list of lists of integers; use it as result[ground_truth][predicted] + to know how many samples of class ground_truth were reported as class predicted""" + return self.confusion_matrix + + def get_stats(self): + cmat = self.confusion_matrix + stats = {} + class_stats = {} + numel = cmat.sum(1) + mask = numel > 0 + if mask.sum() == 0: # nothing to log + return stats + tp = torch.diag(cmat)[mask] + stats["tp"] = tp.sum().item() + fp = (cmat.sum(0)[mask] - tp) + stats["fp"] = fp.sum().item() + fn = (cmat.sum(1)[mask] - tp) + stats["acc"] = (tp.sum() / numel.sum()).item() + + # macro statistics + acc = (tp / numel[mask]) + stats["macc"] = acc.mean().item() + + precision = tp / (tp + fp + torch.finfo(torch.float32).eps) + stats["precision"] = precision.mean().item() + + recall = tp / (tp + fn + torch.finfo(torch.float32).eps) + stats["recall"] = recall.mean().item() + + f1 = 2 * ((precision * recall) / (precision + recall + torch.finfo(torch.float32).eps)) + stats["f1"] = f1.mean().item() + + # class stats + for i, cls_name in enumerate(self.cls_names[mask.cpu()]): + class_stats["acc", cls_name] = acc[i].item() + class_stats["tp", cls_name] = tp[i].item() + class_stats["recall", cls_name] = recall[i].item() + class_stats["precision", cls_name] = precision[i].item() + class_stats["f1", cls_name] = f1[i].item() + + """ + # normalize conf matrix + cmat_sum = cmat.sum(axis=1, keepdim=True) + cmat_sum += cmat_sum == 0 # avoid nans by displaying 0 + cmatn = cmat / cmat_sum + """ + return stats, class_stats, cmat + + +def save_confusion_matrix(cm, path2save, ordered_names): + import seaborn as sns + import matplotlib.pyplot as plt + + sns.set(font_scale=5) + + template_path = os.path.join(path2save, "{}.svg") + # PRECISION + cmn = cm.astype("float") / cm.sum(axis=-1)[:, np.newaxis] + cmn[np.isnan(cmn) | np.isinf(cmn)] = 0 + fig, ax = plt.subplots(figsize=(31, 31)) + sns.heatmap( + cmn, annot=True, fmt=".2f", xticklabels=ordered_names, yticklabels=ordered_names, annot_kws={"size": 20} + ) + # g.set_xticklabels(g.get_xticklabels(), rotation = 35, fontsize = 20) + plt.ylabel("Actual") + plt.xlabel("Predicted") + path_precision = template_path.format("precision") + plt.savefig(path_precision, format="svg") + + # RECALL + cmn = cm.astype("float") / cm.sum(axis=0)[np.newaxis, :] + cmn[np.isnan(cmn) | np.isinf(cmn)] = 0 + fig, ax = plt.subplots(figsize=(31, 31)) + sns.heatmap( + cmn, annot=True, fmt=".2f", xticklabels=ordered_names, yticklabels=ordered_names, annot_kws={"size": 20} + ) + # g.set_xticklabels(g.get_xticklabels(), rotation = 35, fontsize = 20) + plt.ylabel("Actual") + plt.xlabel("Predicted") + path_recall = template_path.format("recall") + plt.savefig(path_recall, format="svg") diff --git a/torch-points3d/torch_points3d/metrics/instance_tracker.py b/torch-points3d/torch_points3d/metrics/instance_tracker.py new file mode 100644 index 0000000..e476d6f --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/instance_tracker.py @@ -0,0 +1,264 @@ +import logging +from collections import OrderedDict +from typing import Dict, Any + +import numpy as np +import torch +import wandb +from torchnet.meter import MSEMeter + +from torch_points3d.metrics.base_tracker import BaseTracker +from torch_points3d.metrics.confusion_matrix import ConfusionMatrix +from torch_points3d.metrics.meters.maemeter import MAEMeter +from torch_points3d.metrics.meters.r2meter import R2Meter +from torch_points3d.models import model_interface + + +class InstanceTracker(BaseTracker): + def __init__(self, dataset, stage="train", wandb_log=False, use_tensorboard: bool = False, + log_train_metrics: bool = True): + """ This is a generic tracker for instance prediction tasks. + It uses a confusion matrix in the back-end to track results. + Use the tracker to track an epoch. + You can use the reset function before you start a new epoch + Arguments: + dataset -- dataset to track (used for the number of classes) + Keyword Arguments: + stage {str} -- current stage. (train, validation, test, etc...) (default: {"train"}) + wandb_log {str} -- Log using weight and biases + """ + super(InstanceTracker, self).__init__(stage, wandb_log, use_tensorboard) + self.has_reg_targets = dataset.has_reg_targets + self.reg_targets_idx = dataset.reg_targets_idx + self.reg_targets = dataset.reg_targets + + self.has_mol_targets = dataset.has_mol_targets + self.mol_targets_idx = dataset.mol_targets_idx + self.mol_targets = dataset.mol_targets + + self.has_cls_targets = dataset.has_cls_targets + self.cls_targets = dataset.cls_targets + self.cls_targets_idx = dataset.cls_targets_idx + self.cls_names = OrderedDict({ + target_name: dataset.targets[target_name]["class_names"] for target_name in dataset.targets + if target_name in self.cls_targets + }) + + self.area_names = dataset.areas.keys() + self.area_name_map = OrderedDict({area_name: i for i, area_name in enumerate(self.area_names)}) + + self.n_targets = dataset.num_classes + + # for r2 score + self.target_means = dataset.get_mean_targets() + self.log_train_metrics = log_train_metrics + + self.reset(stage) + # Those map subsentences to their optimization functions + self._metric_goals = { + "loss": "minimize", + } + self._metric_func = { + "loss": min, + } + if self.has_reg_targets or self.has_mol_targets: + self._metric_goals.update({ + "_rmse": "minimize", + "_mae": "minimize", + "_r2": "maximize", + }) + self._metric_func.update({ + "_rmse": min, + # "mae": min, + # "r2": max, + }) + if self.has_reg_targets: + self._metric_func.update({"loss_reg": min}) + if self.has_mol_targets: + self._metric_func.update({"loss_mol": min}) + if self.has_cls_targets: + self._metric_goals.update({ + "acc": "maximize", + "macc": "maximize", + "_f1": "maximize", + }) + self._metric_func.update({ + # "acc": max, + # "macc": max, + "_f1": max, + "loss_cls": min, + }) + + if wandb_log: + self.wandb_metrics = [] + + def reset(self, stage="train"): + super().reset(stage=stage) + if (stage == "train" and self.log_train_metrics) or stage != "train": + area_names = [area_name for area_name in self.area_names + if self.target_means[area_name].get(stage, None) is not None] + area_names.append("total") + if self.has_reg_targets or self.has_mol_targets: + targets = self.reg_targets + self.mol_targets + targets_idx = np.logical_or(self.reg_targets_idx, self.mol_targets_idx) + self._rmse = {area_name: {} for area_name in area_names} + self._mae = {area_name: {} for area_name in area_names} + self._r2 = {area_name: {} for area_name in area_names} + for i, target_name in enumerate(targets): + for area_name in area_names: + if np.isnan(self.target_means[area_name][stage][targets_idx][i]).all(): + continue + self._rmse[area_name][target_name] = MSEMeter(root=True) + self._mae[area_name][target_name] = MAEMeter() + self._r2[area_name][target_name] = R2Meter(self.target_means[area_name][stage][targets_idx][i]) + + if self.has_cls_targets: + self._confusion_matrix = {area_name: {} for area_name in area_names} + + for i, target_name in enumerate(self.cls_targets): + for area_name in area_names: + if np.isnan(self.target_means[area_name][stage][self.cls_targets_idx][i]).all(): + continue + self._confusion_matrix[area_name][target_name] = ConfusionMatrix(self.cls_names[target_name]) + + @staticmethod + def detach_tensor(tensor): + if torch.torch.is_tensor(tensor): + tensor = tensor.detach() + return tensor + + def track(self, model: model_interface.InstanceTrackerInterface, **kwargs): + """ Add current model predictions (usually the result of a batch) to the tracking + """ + super().track(model) + + if (self._stage == "train" and self.log_train_metrics) or self._stage != "train": + areas = model.data_visual["area_name"] + areas = torch.tensor([self.area_name_map[an] for an in areas]) + + # regression + if self.has_reg_targets: + outputs = model.get_reg_output() + targets = model.get_reg_input() + + track_stats = self.track_numerical_stats + target_names = self.reg_targets + + self.track_iterate_areas_targets(areas, outputs, target_names, targets, track_stats) + + if self.has_mol_targets: + outputs = model.get_mol_output() + targets = model.get_mol_input() + + track_stats = self.track_numerical_stats + target_names = self.mol_targets + + self.track_iterate_areas_targets(areas, outputs, target_names, targets, track_stats) + + if self.has_cls_targets: + targets = model.get_cls_input() + outputs = torch.stack([cls_out.argmax(1) for cls_out in model.get_cls_output()], 1) + + track_stats = self.track_classification_stats + target_names = self.cls_names + + self.track_iterate_areas_targets(areas, outputs, target_names, targets, track_stats) + + def track_iterate_areas_targets(self, areas, outputs, target_names, targets, track_stats): + # ignore nan values + targets_nan = torch.isnan(targets) if targets.dtype == torch.float else targets == -1 + no_nans = ~targets_nan # ~(outputs_nan | targets_nan) + if no_nans.any(): + for i, target_name in enumerate(target_names): + no_nan = no_nans[:, i] + # skip if no real values are present + if not no_nan.any(): + continue + out = outputs[:, i][no_nan] + target = targets[:, i][no_nan] + area = areas[no_nan.cpu()] + + for area_name in self.area_names: + area_idx = area == self.area_name_map[area_name] + if area_idx.any(): + track_stats(area_idx, area_name, out, target, target_name) + track_stats(torch.ones_like(area_idx), "total", out, target, target_name) + + def track_classification_stats(self, area_idx, area_name, out, target, target_name): + self._confusion_matrix[area_name][target_name].count_predicted_batch(target[area_idx], out[area_idx]) + + def track_numerical_stats(self, area_idx, area_name, out, target, target_name): + self._rmse[area_name][target_name].add(out[area_idx], target[area_idx]) + self._mae[area_name][target_name].add(out[area_idx], target[area_idx]) + self._r2[area_name][target_name].add(out[area_idx], target[area_idx]) + + def get_metrics(self, verbose=False) -> Dict[str, Any]: + """ Returns a dictionary of all metrics and losses being tracked + """ + metrics = super().get_loss() + if (self._stage == "train" and self.log_train_metrics) or self._stage != "train": + area_names = list(self.area_names) + area_names.append("total") + for area_name in area_names: + if self.has_reg_targets or self.has_mol_targets: + if self._r2.get(area_name, None) is not None: + for target_name in self.reg_targets + self.mol_targets: + if self._r2[area_name].get(target_name, None) is None: + continue + metrics[f"{self._stage}_{area_name}_{target_name}_rmse"] = \ + self._rmse[area_name][target_name].value() + metrics[f"{self._stage}_{area_name}_{target_name}_mae"] = \ + self._mae[area_name][target_name].value() + metrics[f"{self._stage}_{area_name}_{target_name}_r2"] = \ + self._r2[area_name][target_name].value() + if self.has_cls_targets: + if self._confusion_matrix.get(area_name, None) is not None: + for target_name in self.cls_targets: + cmat_obj = self._confusion_matrix[area_name].get(target_name, None) + if cmat_obj is None or cmat_obj.confusion_matrix is None: + continue + + stats, class_stats, cmat = cmat_obj.get_stats() + for metric in stats: + metrics[f"{self._stage}_{area_name}_{target_name}_{metric}"] = stats[metric] + + for metric, cls_name in class_stats: + metrics[f"{self._stage}_{area_name}_{target_name}_{cls_name}:{metric}"] = \ + class_stats[metric, cls_name] + + if self._wandb: + data = [] + for i in range(cmat_obj.n_cls): + for j in range(cmat_obj.n_cls): + data.append([cmat_obj.cls_names[i], cmat_obj.cls_names[j], cmat[i, j]]) + + cmat_table = wandb.Table(columns=["Actual", "Predicted", "nPredictions"], data=data) + fields = {"Actual": "Actual", "Predicted": "Predicted", "nPredictions": "nPredictions"} + cmat_plot = wandb.plot_table( + "wandb/confusion_matrix/v1", + cmat_table, + fields, + {"title": f"{self._stage}; {area_name}; {target_name}"}, + ) + metrics[f"{self._stage}_{area_name}_{target_name}_cmat"] = cmat_plot + + if self._wandb: + # add metric to wandb if not there already + new_metrics = [metric for metric in metrics if metric not in self.wandb_metrics] + for metric in new_metrics: + m_func = [m for m in self._metric_goals if m in metric] + if len(m_func) == 0: + m_func = goal = None + else: + try: + m_func, goal = self._metric_goals[m_func[0]][:3], self._metric_goals[m_func[0]] + except Exception as e: + logging.warning(f"{str(e)}\n Something went wrong during wandb metric collection") + wandb.define_metric(metric, step_metric="epoch", summary=m_func, goal=goal) + self.wandb_metrics.append(metric) + + return metrics + + @property + def metric_func(self): + return self._metric_func diff --git a/torch-points3d/torch_points3d/metrics/meters.py b/torch-points3d/torch_points3d/metrics/meters.py new file mode 100644 index 0000000..e3aa634 --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/meters.py @@ -0,0 +1,155 @@ +import math +import torch + + +class Meter(object): + """Meters provide a way to keep track of important statistics in an online manner. + This class is abstract, but provides a standard interface for all meters to follow. + """ + + def reset(self): + """Resets the meter to default settings.""" + + def add(self, value): + """Log a new value to the meter + Args: + value: Next restult to include. + """ + + def value(self): + """Get the value of the meter in the current state.""" + + +# This code has been taken from Pytorch Torchnet has it contains a bug with an assert +# https://github.com/pytorch/tnt/issues/131 +class APMeter(Meter): + """ + The APMeter measures the average precision per class. + The APMeter is designed to operate on `NxK` Tensors `output` and + `target`, and optionally a `Nx1` Tensor weight where (1) the `output` + contains model output scores for `N` examples and `K` classes that ought to + be higher when the model is more convinced that the example should be + positively labeled, and smaller when the model believes the example should + be negatively labeled (for instance, the output of a sigmoid function); (2) + the `target` contains only values 0 (for negative examples) and 1 + (for positive examples); and (3) the `weight` ( > 0) represents weight for + each sample. + """ + + def __init__(self): + super(APMeter, self).__init__() + self.reset() + + def reset(self): + """Resets the meter with empty member variables""" + self.scores = torch.FloatTensor(torch.FloatStorage()) + self.targets = torch.LongTensor(torch.LongStorage()) + self.weights = torch.FloatTensor(torch.FloatStorage()) + + def add(self, output, target, weight=None): + """Add a new observation + Args: + output (Tensor): NxK tensor that for each of the N examples + indicates the probability of the example belonging to each of + the K classes, according to the model. The probabilities should + sum to one over all classes + target (Tensor): binary NxK tensort that encodes which of the K + classes are associated with the N-th input + (eg: a row [0, 1, 0, 1] indicates that the example is + associated with classes 2 and 4) + weight (optional, Tensor): Nx1 tensor representing the weight for + each example (each weight > 0) + """ + if not torch.is_tensor(output): + output = torch.from_numpy(output) + if not torch.is_tensor(target): + target = torch.from_numpy(target) + + if weight is not None: + if not torch.is_tensor(weight): + weight = torch.from_numpy(weight) + weight = weight.squeeze() + if output.dim() == 1: + output = output.view(-1, 1) + else: + assert ( + output.dim() == 2 + ), "wrong output size (should be 1D or 2D with one column \ + per class)" + if target.dim() == 1: + target = target.view(-1, 1) + else: + assert ( + target.dim() == 2 + ), "wrong target size (should be 1D or 2D with one column \ + per class)" + if weight is not None: + assert weight.dim() == 1, "Weight dimension should be 1" + assert weight.numel() == target.size(0), "Weight dimension 1 should be the same as that of target" + assert torch.min(weight) >= 0, "Weight should be non-negative only" + if self.scores.numel() > 0: + assert target.size(1) == self.targets.size( + 1 + ), "dimensions for output should match previously added examples." + + # make sure storage is of sufficient size + if self.scores.storage().size() < self.scores.numel() + output.numel(): + new_size = math.ceil(self.scores.storage().size() * 1.5) + new_weight_size = math.ceil(self.weights.storage().size() * 1.5) + self.scores.storage().resize_(int(new_size + output.numel())) + self.targets.storage().resize_(int(new_size + output.numel())) + if weight is not None: + self.weights.storage().resize_(int(new_weight_size + output.size(0))) + + # store scores and targets + offset = self.scores.size(0) if self.scores.dim() > 0 else 0 + self.scores.resize_(offset + output.size(0), output.size(1)) + self.targets.resize_(offset + target.size(0), target.size(1)) + self.scores.narrow(0, offset, output.size(0)).copy_(output) + self.targets.narrow(0, offset, target.size(0)).copy_(target) + + if weight is not None: + self.weights.resize_(offset + weight.size(0)) + self.weights.narrow(0, offset, weight.size(0)).copy_(weight) + + def value(self): + """Returns the model's average precision for each class + Return: + ap (FloatTensor): 1xK tensor, with avg precision for each class k + """ + + if self.scores.numel() == 0: + return 0 + ap = torch.zeros(self.scores.size(1)) + if hasattr(torch, "arange"): + rg = torch.arange(1, self.scores.size(0) + 1).float() + else: + rg = torch.range(1, self.scores.size(0)).float() + if self.weights.numel() > 0: + weight = self.weights.new(self.weights.size()) + weighted_truth = self.weights.new(self.weights.size()) + + # compute average precision for each class + for k in range(self.scores.size(1)): + # sort scores + scores = self.scores[:, k] + targets = self.targets[:, k] + _, sortind = torch.sort(scores, 0, True) + truth = targets[sortind] + if self.weights.numel() > 0: + weight = self.weights[sortind] + weighted_truth = truth.float() * weight + rg = weight.cumsum(0) + + # compute true positive sums + if self.weights.numel() > 0: + tp = weighted_truth.cumsum(0) + else: + tp = truth.float().cumsum(0) + + # compute precision curve + precision = tp.div(rg) + + # compute average precision + ap[k] = precision[truth.bool()].sum() / max(float(truth.sum()), 1) + return ap diff --git a/torch-points3d/torch_points3d/metrics/meters/__init__.py b/torch-points3d/torch_points3d/metrics/meters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/metrics/meters/apprxmeter.py b/torch-points3d/torch_points3d/metrics/meters/apprxmeter.py new file mode 100644 index 0000000..2c64211 --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/meters/apprxmeter.py @@ -0,0 +1,25 @@ +import torch + + +class APPRXMeter: + def __init__(self): + super(APPRXMeter, self).__init__() + self.reset() + + def reset(self): + self.n = 0 + self.target_sum = 0.0 + self.output_sum = 0.0 + + def add(self, output, target): + if not torch.is_tensor(output) and not torch.is_tensor(target): + output = torch.from_numpy(output) + target = torch.from_numpy(target) + self.n += output.numel() + self.target_sum += torch.sum(target).item() + self.output_sum += torch.sum(output).item() + + def value(self): + apprx = abs(1 - self.output_sum/self.target_sum) if self.n > 0 else 0.0 + + return apprx diff --git a/torch-points3d/torch_points3d/metrics/meters/maemeter.py b/torch-points3d/torch_points3d/metrics/meters/maemeter.py new file mode 100644 index 0000000..15dabe1 --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/meters/maemeter.py @@ -0,0 +1,22 @@ +import torch + + +class MAEMeter: + def __init__(self): + super(MAEMeter, self).__init__() + self.reset() + + def reset(self): + self.n = 0 + self.abssum = 0.0 + + def add(self, output, target): + if not torch.is_tensor(output) and not torch.is_tensor(target): + output = torch.from_numpy(output) + target = torch.from_numpy(target) + self.n += output.numel() + self.abssum += torch.sum(abs(output - target)).item() + + def value(self): + mae = self.abssum / max(1, self.n) + return mae diff --git a/torch-points3d/torch_points3d/metrics/meters/r2meter.py b/torch-points3d/torch_points3d/metrics/meters/r2meter.py new file mode 100644 index 0000000..766e27c --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/meters/r2meter.py @@ -0,0 +1,26 @@ +import torch + + +class R2Meter: + def __init__(self, target_mean): + super(R2Meter, self).__init__() + self.target_mean = target_mean + self.reset() + + def reset(self): + self.n = 0 + self.ressum = 0.0 + self.totsum = 0.0 + + def add(self, output, target): + if not torch.is_tensor(output) and not torch.is_tensor(target): + output = torch.from_numpy(output) + target = torch.from_numpy(target) + self.n += output.numel() + self.ressum += torch.sum((output - target) ** 2).item() + self.totsum += torch.sum((target - self.target_mean) ** 2).item() + + def value(self): + r2 = (1 - (self.ressum / self.totsum)) if self.n > 0 and self.totsum > 0 else 0.0 + + return r2 diff --git a/torch-points3d/torch_points3d/metrics/model_checkpoint.py b/torch-points3d/torch_points3d/metrics/model_checkpoint.py new file mode 100644 index 0000000..958b1f3 --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/model_checkpoint.py @@ -0,0 +1,375 @@ +import copy +import glob +import logging +import os +import shutil +from collections import OrderedDict +from typing import Any, Dict, List, Optional, Tuple, Union + +import torch +import wandb +from omegaconf import DictConfig +from omegaconf import OmegaConf +from torch.nn import DataParallel + +from torch_points3d.core.schedulers.bn_schedulers import instantiate_bn_scheduler +from torch_points3d.core.schedulers.lr_schedulers import instantiate_scheduler +from torch_points3d.models.base_model import BaseModel +from torch_points3d.models.model_factory import instantiate_model +from torch_points3d.utils.colors import COLORS, colored_print + +log = logging.getLogger(__name__) + + +class Checkpoint: + _LATEST = "latest" + + def __init__(self, checkpoint_file: str, save_every_iter: bool = True): + """ Checkpoint manager. Saves to working directory with check_name + Arguments + checkpoint_file {str} -- Path to the checkpoint + save_every_iter {bool} -- [description] (default: {True}) + """ + self._check_path = checkpoint_file + self._filled = False + self.run_config: Optional[Dict] = None + self.models: Dict[str, Any] = {} + self.stats: Dict[str, List[Any]] = {"train": [], "test": [], "val": []} + self.optimizer: Optional[Tuple[str, Any]] = None + self.grad_scale: Optional[Tuple[str, Any]] = None + self.schedulers: Dict[str, Any] = {} + self.dataset_properties: Dict = {} + + def save_objects(self, models_to_save: Dict[str, Any], stage, current_stat, optimizer, schedulers, grad_scale, + **kwargs): + """ Saves checkpoint with updated models for the given stage + """ + self.models = models_to_save + self.optimizer = (optimizer.__class__.__name__, optimizer.state_dict()) + self.schedulers = { + scheduler_name: [scheduler.scheduler_opt, scheduler.state_dict()] + for scheduler_name, scheduler in schedulers.items() + } + self.grad_scale = grad_scale.state_dict() + to_save = kwargs + for key, value in self.__dict__.items(): + if not key.startswith("_"): + to_save[key] = value + torch.save(to_save, self.path) + + @property + def path(self): + return self._check_path + + @staticmethod + def load(checkpoint_dir: str, checkpoint_name: str, run_config: Any, strict=False, resume=True): + """ Creates a new checkpoint object in the current working directory by loading the + checkpoint located at [checkpointdir]/[checkpoint_name].pt + """ + checkpoint_file = os.path.join(checkpoint_dir, checkpoint_name) + ".pt" + if not os.path.exists(checkpoint_file): + ckp = Checkpoint(checkpoint_file) + if strict or resume: + available_checkpoints = glob.glob(os.path.join(checkpoint_dir, "*.pt")) + message = "The provided path {} didn't contain the checkpoint_file {}".format( + checkpoint_dir, checkpoint_name + ".pt" + ) + if available_checkpoints: + message += "\nDid you mean {}?".format(os.path.basename(available_checkpoints[0])) + raise ValueError(message) + ckp.run_config = run_config + return ckp + else: + chkp_name = os.path.basename(checkpoint_file) + if resume: + shutil.copyfile( + checkpoint_file, chkp_name + ) # Copy checkpoint to new run directory to make sure we don't override + ckp = Checkpoint(chkp_name) + log.info("Loading checkpoint from {}".format(checkpoint_file)) + objects = torch.load(checkpoint_file, map_location="cpu") + for key, value in objects.items(): + setattr(ckp, key, value) + ckp._filled = True + return ckp + + @property + def is_empty(self): + return not self._filled + + def load_optim_sched(self, model: BaseModel, load_state=True): + if not self.is_empty: + optimizer_config = self.optimizer + + # initialize & load schedulers + schedulers_out = {} + schedulers_config = self.schedulers + for scheduler_type, (scheduler_opt, scheduler_state) in schedulers_config.items(): + if scheduler_type == "lr_scheduler": + optimizer = model.optimizer + scheduler = instantiate_scheduler(optimizer, OmegaConf.create(scheduler_opt)) + if load_state: + scheduler.load_state_dict(scheduler_state) + schedulers_out["lr_scheduler"] = scheduler + + elif scheduler_type == "bn_scheduler": + scheduler = instantiate_bn_scheduler(model, OmegaConf.create(scheduler_opt)) + if load_state: + scheduler.load_state_dict(scheduler_state) + schedulers_out["bn_scheduler"] = scheduler + + # load optimizer + model.schedulers = schedulers_out + if load_state: + model.optimizer.load_state_dict(optimizer_config[1]) + + def load_grad_scale(self, model: BaseModel, load_state=True): + # load grad scaler settings + if not self.is_empty and load_state and self.grad_scale is not None: + model.grad_scale.load_state_dict(self.grad_scale) + + def get_state_dict(self, weight_name): + if not self.is_empty: + try: + models = self.models + keys = [key.replace("best_", "") for key in models.keys()] + log.info("Available weights : {}".format(keys)) + try: + key_name = "best_{}".format(weight_name) + model = models[key_name] + log.info("Model loaded from {}:{}.".format(self._check_path, key_name)) + return model + except: + key_name = Checkpoint._LATEST + model = models[Checkpoint._LATEST] + log.info("Model loaded from {}:{}".format(self._check_path, key_name)) + return model + except: + raise Exception("This weight name isn't within the checkpoint ") + + +class ModelCheckpoint(object): + """ Create a checkpoint for a given model + + Arguments: + - load_dir: directory where to load the checkpoint from (if exists) + - check_name: Name of the checkpoint (without the .pt extension) + - selection_stage: Stage that is used for selecting the best model + - run_config: Config of the run. In resume mode, this gets discarded + - resume: Resume a previous training - this creates optimizers + - strict: If strict and checkpoint is empty then it raises a ValueError. Being in resume mode forces strict + """ + + def __init__( + self, + load_dir: str, + check_name: str, + selection_stage: str, + run_config: DictConfig = DictConfig({}), + resume=False, + strict=False, + resume_opt=None, + ): + # Conversion of run_config to save a dictionary and not a pickle of omegaconf + rc = OmegaConf.to_container(copy.deepcopy(run_config)) + self._checkpoint = Checkpoint.load(load_dir, check_name, run_config=rc, strict=strict, resume=resume) + self._resume = resume + if resume_opt is None: + resume_opt = resume + self._resume_opt = resume_opt + self._selection_stage = selection_stage + + def create_model(self, dataset, weight_name=Checkpoint._LATEST): + if not self.is_empty: + run_config = OmegaConf.create(copy.deepcopy(self._checkpoint.run_config)) + model = instantiate_model(run_config, dataset) + if hasattr(self._checkpoint, "model_props"): + for k, v in self._checkpoint.model_props.items(): + setattr(model, k, v) + delattr(self._checkpoint, "model_props") + self._initialize_model(run_config, model, weight_name) + return model + else: + raise ValueError("Checkpoint is empty") + + @property + def start_epoch(self): + if self._resume: + return self.get_starting_epoch() + else: + return 1 + + @property + def run_config(self): + return OmegaConf.create(self._checkpoint.run_config) + + @property + def data_config(self): + return OmegaConf.create(self._checkpoint.run_config).data + + @property + def selection_stage(self): + return self._selection_stage + + @selection_stage.setter + def selection_stage(self, value): + self._selection_stage = value + + @property + def is_empty(self): + return self._checkpoint.is_empty + + @property + def checkpoint_path(self): + return self._checkpoint.path + + @property + def dataset_properties(self) -> Dict: + return self._checkpoint.dataset_properties + + @dataset_properties.setter + def dataset_properties(self, dataset_properties: Union[Dict[str, Any], Dict]): + self._checkpoint.dataset_properties = dataset_properties + + def get_starting_epoch(self): + return len(self._checkpoint.stats["train"]) + 1 + + def _initialize_model(self, run_config: OmegaConf, model: BaseModel, weight_name): + if not self._checkpoint.is_empty: + state_dict = self._checkpoint.get_state_dict(weight_name) + model.load_state_dict(state_dict, strict=False) + model.init_optim(run_config) + model.init_schedulers(run_config) + model.init_grad_scaler(run_config) + + def find_func_from_metric_name(self, metric_name, default_metrics_func): + for token_name, func in default_metrics_func.items(): + if token_name in metric_name: + return func + raise Exception( + 'The metric name {} doesn t have a func to measure which one is best in {}. Example: For best_train_iou, {{"iou":max}}'.format( + metric_name, default_metrics_func + ) + ) + + def save_best_models_under_current_metrics( + self, model: BaseModel, metrics_holder: dict, metric_func_dict: dict, wandb_log: bool, **kwargs + ): + """[This function is responsible to save checkpoint under the current metrics and their associated DEFAULT_METRICS_FUNC] + Arguments: + model {[CheckpointInterface]} -- [Model] + metrics_holder {[Dict]} -- [Need to contain stage, epoch, current_metrics] + """ + metrics = metrics_holder["current_metrics"] + stage = metrics_holder["stage"] + epoch = metrics_holder["epoch"] + p_metrics = metrics_holder["all_metrics"] + + stats = self._checkpoint.stats + state_dict = copy.deepcopy(model.state_dict()) + + # if multi GPU, remove DataParallel part + if isinstance(model.model, DataParallel): + new_state_dict = OrderedDict() + for key in state_dict: + name = copy.copy(key) + if key[6:12] == "module": + name = name[:5] + name[12:] + new_state_dict[name] = state_dict[key] + state_dict = new_state_dict + + current_stat = {"epoch": epoch} + + log_metrics = {} + + models_to_save = self._checkpoint.models + if stage not in stats: + stats[stage] = [] + + if stage == "train": + models_to_save[Checkpoint._LATEST] = state_dict + else: + latest_stats = None if len(stats[stage]) == 0 else stats[stage][-1] + + msg = "" + improved_metric = 0 + if wandb_log: + log_metrics = {"epoch": epoch, } + + for metric_name, current_metric_value in metrics.items(): + if all(key not in metric_name for key in ["total_", "loss_"]): + continue + + current_stat[metric_name] = current_metric_value + + try: + metric_func = self.find_func_from_metric_name(metric_name, metric_func_dict) + except Exception: + continue # no metric function was defined, so it is only used for logging + + if latest_stats is None: + current_stat["best_{}".format(metric_name)] = current_metric_value + models_to_save["best_{}".format(metric_name)] = state_dict + else: + best_metric_from_stats = latest_stats.get("best_{}".format(metric_name), current_metric_value) + best_value = metric_func(best_metric_from_stats, current_metric_value) + current_stat["best_{}".format(metric_name)] = best_value + # This new value seems to be better under metric_func + if (self._selection_stage == stage) and (current_metric_value == best_value) \ + and (current_metric_value != best_metric_from_stats): # Update the model weights + + models_to_save["best_{}".format(metric_name)] = state_dict + + if wandb_log: + log_metrics[f"{stage}_best_{metric_name}"] = wandb.Table( + columns=["epoch", "metric", "value"] + ) + + [log_metrics[f"{stage}_best_{metric_name}"].add_data( + epoch, f"{stage}_{metric}", metrics[metric] + ) for metric in metrics] + for metric in p_metrics: + if "cmat" in metric: + cmat_plot = copy.deepcopy(p_metrics[metric]) + title = cmat_plot.string_fields + title["title"] = title["title"] + f"; best {metric_name}" + cmat_plot = wandb.plot_table( + "wandb/confusion_matrix/v1", + cmat_plot.table, + cmat_plot.fields, + title, + ) + log_metrics[f"{metric}_best_{metric_name}"] = cmat_plot + + msg += "{}: {} -> {}, ".format(metric_name, best_metric_from_stats, best_value) + improved_metric += 1 + + if improved_metric > 0: + colored_print(COLORS.VAL_COLOR, msg[:-2]) + + kwargs["model_props"] = { + "num_epochs": model.num_epochs, # type: ignore + "num_batches": model.num_batches, # type: ignore + "num_samples": model.num_samples, # type: ignore + } + + self._checkpoint.stats[stage].append(current_stat) + self._checkpoint.save_objects(models_to_save, stage, current_stat, model.optimizer, model.schedulers, + model.grad_scale, **kwargs) + + p_metrics.update(log_metrics) + + return p_metrics + + def validate(self, dataset_config): + """ A checkpoint is considered as valid if it can recreate the model from + a dataset config only """ + if dataset_config is not None: + for k, v in dataset_config.items(): + self.data_config[k] = v + try: + instantiate_model(OmegaConf.create(self.run_config), self.data_config) + except Exception as e: + return False + + return True diff --git a/torch-points3d/torch_points3d/metrics/object_detection_tracker.py b/torch-points3d/torch_points3d/metrics/object_detection_tracker.py new file mode 100644 index 0000000..44fffb5 --- /dev/null +++ b/torch-points3d/torch_points3d/metrics/object_detection_tracker.py @@ -0,0 +1,131 @@ +from typing import Dict, List, Any +import torchnet as tnt +import torch +from collections import OrderedDict + +from torch_points3d.models.model_interface import TrackerInterface +from torch_points3d.metrics.base_tracker import BaseTracker, meter_value +from torch_points3d.datasets.segmentation import IGNORE_LABEL + +from torch_points3d.datasets.object_detection.box_data import BoxData +from .box_detection.ap import eval_detection + + +class ObjectDetectionTracker(BaseTracker): + def __init__(self, dataset, stage="train", wandb_log=False, use_tensorboard: bool = False): + super(ObjectDetectionTracker, self).__init__(stage, wandb_log, use_tensorboard) + self._num_classes = dataset.num_classes + self._dataset = dataset + self.reset(stage) + self._metric_func = {"loss": min, "acc": max, "pos": max, "neg": min, "map": max} + + def reset(self, stage="train"): + super().reset(stage=stage) + self._pred_boxes: Dict[str, List[BoxData]] = {} + self._gt_boxes: Dict[str, List[BoxData]] = {} + self._rec: Dict[str, Dict[str, float]] = {} + self._ap: Dict[str, Dict[str, float]] = {} + self._neg_ratio = tnt.meter.AverageValueMeter() + self._obj_acc = tnt.meter.AverageValueMeter() + self._pos_ratio = tnt.meter.AverageValueMeter() + + @staticmethod + def detach_tensor(tensor): + if torch.torch.is_tensor(tensor): + tensor = tensor.detach() + return tensor + + def track(self, model: TrackerInterface, data=None, track_boxes=False, **kwargs): + """ Add current model predictions (usually the result of a batch) to the tracking + if tracking boxes, you must provide a labeled "data" object with the following attributes: + - id_scan: id of the scan to which the boxes belong to + - instance_box_cornerimport torchnet as tnts - gt box corners + - box_label_mask - mask for boxes (0 = no box) + - sem_cls_label - semantic label for each box + """ + super().track(model) + + outputs = model.get_output() + + total_num_proposal = outputs.objectness_label.shape[0] * outputs.objectness_label.shape[1] + pos_ratio = torch.sum(outputs.objectness_label.float()).item() / float(total_num_proposal) + self._pos_ratio.add(pos_ratio) + self._neg_ratio.add(torch.sum(outputs.objectness_mask.float()).item() / float(total_num_proposal) - pos_ratio) + + obj_pred_val = torch.argmax(outputs.objectness_scores, 2) # B,K + self._obj_acc.add( + torch.sum((obj_pred_val == outputs.objectness_label.long()).float() * outputs.objectness_mask).item() + / (torch.sum(outputs.objectness_mask) + 1e-6).item() + ) + + if data is None or self._stage == "train" or not track_boxes: + return + + self._add_box_pred(outputs, data, model.conv_type) + + def _add_box_pred(self, outputs, input_data, conv_type): + # Track box predictions + pred_boxes = outputs.get_boxes(self._dataset, apply_nms=True, duplicate_boxes=False) + if input_data.id_scan is None: + raise ValueError("Cannot track boxes without knowing in which scan they are") + + scan_ids = input_data.id_scan + assert len(scan_ids) == len(pred_boxes) + for idx, scan_id in enumerate(scan_ids): + # Predictions + self._pred_boxes[scan_id.item()] = pred_boxes[idx] + + # Ground truth + sample_mask = idx + gt_boxes = input_data.instance_box_corners[sample_mask] + gt_boxes = gt_boxes[input_data.box_label_mask[sample_mask]] + sample_labels = input_data.sem_cls_label[sample_mask] + gt_box_data = [BoxData(sample_labels[i].item(), gt_boxes[i]) for i in range(len(gt_boxes))] + self._gt_boxes[scan_id.item()] = gt_box_data + + def get_metrics(self, verbose=False) -> Dict[str, Any]: + """ Returns a dictionnary of all metrics and losses being tracked + """ + metrics = super().get_metrics(verbose) + + metrics["{}_acc".format(self._stage)] = meter_value(self._obj_acc) + metrics["{}_pos".format(self._stage)] = meter_value(self._pos_ratio) + metrics["{}_neg".format(self._stage)] = meter_value(self._neg_ratio) + + if self._has_box_data: + for thresh, ap in self._ap.items(): + mAP = sum(ap.values()) / len(ap) + metrics["{}_map{}".format(self._stage, thresh)] = mAP + + if verbose and self._has_box_data: + for thresh in self._ap: + metrics["{}_class_rec{}".format(self._stage, thresh)] = self._dict_to_str(self._rec[thresh]) + metrics["{}_class_ap{}".format(self._stage, thresh)] = self._dict_to_str(self._ap[thresh]) + + return metrics + + def finalise(self, track_boxes=False, overlap_thresholds=[0.25, 0.5], **kwargs): + if not track_boxes or len(self._gt_boxes) == 0: + return + + # Compute box detection metrics + self._ap = {} + self._rec = {} + for thresh in overlap_thresholds: + rec, _, ap = eval_detection(self._pred_boxes, self._gt_boxes, ovthresh=thresh) + self._ap[str(thresh)] = OrderedDict(sorted(ap.items())) + self._rec[str(thresh)] = OrderedDict({}) + for key, val in sorted(rec.items()): + try: + value = val[-1] + except TypeError: + value = val + self._rec[str(thresh)][key] = value + + @property + def _has_box_data(self): + return len(self._rec) + + @property + def metric_func(self): + return self._metric_func diff --git a/torch-points3d/torch_points3d/models/__init__.py b/torch-points3d/torch_points3d/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/models/base_architectures/__init__.py b/torch-points3d/torch_points3d/models/base_architectures/__init__.py new file mode 100644 index 0000000..8b4e6df --- /dev/null +++ b/torch-points3d/torch_points3d/models/base_architectures/__init__.py @@ -0,0 +1,2 @@ +from .unet import * +from .backbone import * diff --git a/torch-points3d/torch_points3d/models/base_architectures/backbone.py b/torch-points3d/torch_points3d/models/base_architectures/backbone.py new file mode 100644 index 0000000..e184182 --- /dev/null +++ b/torch-points3d/torch_points3d/models/base_architectures/backbone.py @@ -0,0 +1,138 @@ +import logging + +from omegaconf.dictconfig import DictConfig +from torch import nn + +from torch_points3d.datasets.base_dataset import BaseDataset +from torch_points3d.models.base_architectures import BaseFactory +from torch_points3d.models.base_model import BaseModel +from torch_points3d.utils.config import is_list + +log = logging.getLogger(__name__) + +SPECIAL_NAMES = ["radius", "max_num_neighbors", "block_names"] + + +############################# Backbone Base ################################### + + +class BackboneBasedModel(BaseModel): + """ + create a backbone-based generator: + This is simply an encoder + (can be used in classification, regression, metric learning and so one) + """ + + def _save_sampling_and_search(self, down_conv): + sampler = getattr(down_conv, "sampler", None) + if is_list(sampler): + self._spatial_ops_dict["sampler"] = sampler + self._spatial_ops_dict["sampler"] + else: + self._spatial_ops_dict["sampler"] = [sampler] + self._spatial_ops_dict["sampler"] + + neighbour_finder = getattr(down_conv, "neighbour_finder", None) + if is_list(neighbour_finder): + self._spatial_ops_dict["neighbour_finder"] = neighbour_finder + self._spatial_ops_dict["neighbour_finder"] + else: + self._spatial_ops_dict["neighbour_finder"] = [neighbour_finder] + self._spatial_ops_dict["neighbour_finder"] + + def __init__(self, opt, model_type, dataset: BaseDataset, modules_lib): + + """Construct a backbone generator (It is a simple down module) + Parameters: + opt - options for the network generation + model_type - type of the model to be generated + modules_lib - all modules that can be used in the backbone + + + opt is expected to contains the following keys: + * down_conv + """ + + super(BackboneBasedModel, self).__init__(opt) + self._spatial_ops_dict = {"neighbour_finder": [], "sampler": []} + + # detect which options format has been used to define the model + if is_list(opt.down_conv) or "down_conv_nn" not in opt.down_conv: + raise NotImplementedError + else: + self._init_from_compact_format(opt, model_type, dataset, modules_lib) + + def _get_from_kwargs(self, kwargs, name): + module = kwargs[name] + kwargs.pop(name) + return module + + def _init_from_compact_format(self, opt, model_type, dataset, modules_lib): + """Create a backbonebasedmodel from the compact options format - where the + same convolution is given for each layer, and arguments are given + in lists + """ + num_convs = len(opt.down_conv.down_conv_nn) + self.down_modules = nn.ModuleList() + factory_module_cls = self._get_factory(model_type, modules_lib) + down_conv_cls_name = opt.down_conv.module_name + self._factory_module = factory_module_cls(down_conv_cls_name, None, modules_lib) + # Down modules + for i in range(num_convs): + args = self._fetch_arguments(opt.down_conv, i, "DOWN") + conv_cls = self._get_from_kwargs(args, "conv_cls") + down_module = conv_cls(**args) + self._save_sampling_and_search(down_module) + self.down_modules.append(down_module) + + self.metric_loss_module, self.miner_module = BaseModel.get_metric_loss_and_miner( + getattr(opt, "metric_loss", None), getattr(opt, "miner", None) + ) + + def _get_factory(self, model_name, modules_lib) -> BaseFactory: + factory_module_cls = getattr(modules_lib, "{}Factory".format(model_name), None) + if factory_module_cls is None: + factory_module_cls = BaseFactory + return factory_module_cls + + def _fetch_arguments_from_list(self, opt, index): + """Fetch the arguments for a single convolution from multiple lists + of arguments - for models specified in the compact format. + """ + args = {} + for o, v in opt.items(): + name = str(o) + if is_list(v) and len(getattr(opt, o)) > 0: + if name[-1] == "s" and name not in SPECIAL_NAMES: + name = name[:-1] + v_index = v[index] + if is_list(v_index): + v_index = list(v_index) + args[name] = v_index + else: + if is_list(v): + v = list(v) + args[name] = v + return args + + def _fetch_arguments(self, conv_opt, index, flow="DOWN"): + """ Fetches arguments for building a convolution down + + Arguments: + conv_opt + index in sequential order (as they come in the config) + flow "DOWN" + """ + args = self._fetch_arguments_from_list(conv_opt, index) + args["conv_cls"] = self._factory_module.get_module(flow) + args["index"] = index + return args + + def _flatten_compact_options(self, opt): + """Converts from a dict of lists, to a list of dicts + """ + flattenedOpts = [] + + for index in range(int(1e6)): + try: + flattenedOpts.append(DictConfig(self._fetch_arguments_from_list(opt, index))) + except IndexError: + break + + return flattenedOpts diff --git a/torch-points3d/torch_points3d/models/base_architectures/unet.py b/torch-points3d/torch_points3d/models/base_architectures/unet.py new file mode 100644 index 0000000..27dd8b3 --- /dev/null +++ b/torch-points3d/torch_points3d/models/base_architectures/unet.py @@ -0,0 +1,516 @@ +import copy +import logging + +from omegaconf.dictconfig import DictConfig +from omegaconf.listconfig import ListConfig +from torch import nn + +from torch_points3d.core.common_modules.base_modules import Identity +from torch_points3d.datasets.base_dataset import BaseDataset +from torch_points3d.models.base_model import BaseModel +from torch_points3d.utils.config import is_list + +log = logging.getLogger(__name__) + + +SPECIAL_NAMES = ["radius", "max_num_neighbors", "block_names"] + + +class BaseFactory: + def __init__(self, module_name_down, module_name_up, modules_lib): + self.module_name_down = module_name_down + self.module_name_up = module_name_up + self.modules_lib = modules_lib + + def get_module(self, flow): + if flow.upper() == "UP": + return getattr(self.modules_lib, self.module_name_up, None) + else: + return getattr(self.modules_lib, self.module_name_down, None) + + +############################# UNET BASE ################################### + + +class UnetBasedModel(BaseModel): + """Create a Unet-based generator""" + + def _save_sampling_and_search(self, submodule): + sampler = getattr(submodule.down, "sampler", None) + if is_list(sampler): + self._spatial_ops_dict["sampler"] = sampler + self._spatial_ops_dict["sampler"] + else: + self._spatial_ops_dict["sampler"] = [sampler] + self._spatial_ops_dict["sampler"] + + neighbour_finder = getattr(submodule.down, "neighbour_finder", None) + if is_list(neighbour_finder): + self._spatial_ops_dict["neighbour_finder"] = neighbour_finder + self._spatial_ops_dict["neighbour_finder"] + else: + self._spatial_ops_dict["neighbour_finder"] = [neighbour_finder] + self._spatial_ops_dict["neighbour_finder"] + + upsample_op = getattr(submodule.up, "upsample_op", None) + if upsample_op: + self._spatial_ops_dict["upsample_op"].append(upsample_op) + + def __init__(self, opt, model_type, dataset: BaseDataset, modules_lib): + """Construct a Unet generator + Parameters: + opt - options for the network generation + model_type - type of the model to be generated + num_class - output of the network + modules_lib - all modules that can be used in the UNet + We construct the U-Net from the innermost layer to the outermost layer. + It is a recursive process. + + opt is expected to contains the following keys: + * down_conv + * up_conv + * OPTIONAL: innermost + """ + opt = copy.deepcopy(opt) + super(UnetBasedModel, self).__init__(opt) + self._spatial_ops_dict = {"neighbour_finder": [], "sampler": [], "upsample_op": []} + # detect which options format has been used to define the model + if type(opt.down_conv) is ListConfig or "down_conv_nn" not in opt.down_conv: + self._init_from_layer_list_format(opt, model_type, dataset, modules_lib) + else: + self._init_from_compact_format(opt, model_type, dataset, modules_lib) + + def _init_from_compact_format(self, opt, model_type, dataset, modules_lib): + """Create a unetbasedmodel from the compact options format - where the + same convolution is given for each layer, and arguments are given + in lists + """ + num_convs = len(opt.down_conv.down_conv_nn) + + # Factory for creating up and down modules + factory_module_cls = self._get_factory(model_type, modules_lib) + down_conv_cls_name = opt.down_conv.module_name + up_conv_cls_name = opt.up_conv.module_name + self._factory_module = factory_module_cls( + down_conv_cls_name, up_conv_cls_name, modules_lib + ) # Create the factory object + # construct unet structure + contains_global = hasattr(opt, "innermost") and opt.innermost is not None + if contains_global: + assert len(opt.down_conv.down_conv_nn) + 1 == len(opt.up_conv.up_conv_nn) + + args_up = self._fetch_arguments_from_list(opt.up_conv, 0) + args_up["up_conv_cls"] = self._factory_module.get_module("UP") + + unet_block = UnetSkipConnectionBlock( + args_up=args_up, + args_innermost=opt.innermost, + modules_lib=modules_lib, + submodule=None, + innermost=True, + ) # add the innermost layer + else: + unet_block = Identity() + + if num_convs > 1: + for index in range(num_convs - 1, 0, -1): + args_up, args_down = self._fetch_arguments_up_and_down(opt, index) + unet_block = UnetSkipConnectionBlock(args_up=args_up, args_down=args_down, submodule=unet_block) + self._save_sampling_and_search(unet_block) + else: + index = num_convs + + index -= 1 + args_up, args_down = self._fetch_arguments_up_and_down(opt, index) + self.model = UnetSkipConnectionBlock( + args_up=args_up, args_down=args_down, submodule=unet_block, outermost=True + ) # add the outermost layer + self._save_sampling_and_search(self.model) + + def _init_from_layer_list_format(self, opt, model_type, dataset, modules_lib): + """Create a unetbasedmodel from the layer list options format - where + each layer of the unet is specified separately + """ + + self._get_factory(model_type, modules_lib) + + down_conv_layers = ( + opt.down_conv if type(opt.down_conv) is ListConfig else self._flatten_compact_options(opt.down_conv) + ) + up_conv_layers = opt.up_conv if type(opt.up_conv) is ListConfig else self._flatten_compact_options(opt.up_conv) + num_convs = len(down_conv_layers) + + unet_block = [] + contains_global = hasattr(opt, "innermost") and opt.innermost is not None + if contains_global: + assert len(down_conv_layers) + 1 == len(up_conv_layers) + + up_layer = dict(up_conv_layers[0]) + up_layer["up_conv_cls"] = getattr(modules_lib, up_layer["module_name"]) + + unet_block = UnetSkipConnectionBlock( + args_up=up_layer, + args_innermost=opt.innermost, + modules_lib=modules_lib, + innermost=True, + ) + + for index in range(num_convs - 1, 0, -1): + down_layer = dict(down_conv_layers[index]) + up_layer = dict(up_conv_layers[num_convs - index]) + + down_layer["down_conv_cls"] = getattr(modules_lib, down_layer["module_name"]) + up_layer["up_conv_cls"] = getattr(modules_lib, up_layer["module_name"]) + + unet_block = UnetSkipConnectionBlock( + args_up=up_layer, + args_down=down_layer, + modules_lib=modules_lib, + submodule=unet_block, + ) + + up_layer = dict(up_conv_layers[-1]) + down_layer = dict(down_conv_layers[0]) + down_layer["down_conv_cls"] = getattr(modules_lib, down_layer["module_name"]) + up_layer["up_conv_cls"] = getattr(modules_lib, up_layer["module_name"]) + self.model = UnetSkipConnectionBlock( + args_up=up_layer, args_down=down_layer, submodule=unet_block, outermost=True + ) + + self._save_sampling_and_search(self.model) + + def _get_factory(self, model_name, modules_lib) -> BaseFactory: + factory_module_cls = getattr(modules_lib, "{}Factory".format(model_name), None) + if factory_module_cls is None: + factory_module_cls = BaseFactory + return factory_module_cls + + def _fetch_arguments_from_list(self, opt, index): + """Fetch the arguments for a single convolution from multiple lists + of arguments - for models specified in the compact format. + """ + args = {} + for o, v in opt.items(): + name = str(o) + if is_list(v) and len(getattr(opt, o)) > 0: + if name[-1] == "s" and name not in SPECIAL_NAMES: + name = name[:-1] + v_index = v[index] + if is_list(v_index): + v_index = list(v_index) + args[name] = v_index + else: + if is_list(v): + v = list(v) + args[name] = v + return args + + def _fetch_arguments_up_and_down(self, opt, index): + # Defines down arguments + args_down = self._fetch_arguments_from_list(opt.down_conv, index) + args_down["index"] = index + args_down["down_conv_cls"] = self._factory_module.get_module("DOWN") + + # Defines up arguments + idx = len(getattr(opt.up_conv, "up_conv_nn")) - index - 1 + args_up = self._fetch_arguments_from_list(opt.up_conv, idx) + args_up["index"] = index + args_up["up_conv_cls"] = self._factory_module.get_module("UP") + return args_up, args_down + + def _flatten_compact_options(self, opt): + """Converts from a dict of lists, to a list of dicts""" + flattenedOpts = [] + + for index in range(int(1e6)): + try: + flattenedOpts.append(DictConfig(self._fetch_arguments_from_list(opt, index))) + except IndexError: + break + + return flattenedOpts + + +class UnetSkipConnectionBlock(nn.Module): + """Defines the Unet submodule with skip connection. + X -------------------identity---------------------- + |-- downsampling -- |submodule| -- upsampling --| + + """ + + def get_from_kwargs(self, kwargs, name): + module = kwargs[name] + kwargs.pop(name) + return module + + def __init__( + self, + args_up=None, + args_down=None, + args_innermost=None, + modules_lib=None, + submodule=None, + outermost=False, + innermost=False, + ): + """Construct a Unet submodule with skip connections. + Parameters: + args_up -- arguments for up convs + args_down -- arguments for down convs + args_innermost -- arguments for innermost + submodule (UnetSkipConnectionBlock) -- previously defined submodules + outermost (bool) -- if this module is the outermost module + innermost (bool) -- if this module is the innermost module + """ + super(UnetSkipConnectionBlock, self).__init__() + + self.outermost = outermost + self.innermost = innermost + + if innermost: + assert outermost == False + module_name = self.get_from_kwargs(args_innermost, "module_name") + inner_module_cls = getattr(modules_lib, module_name) + self.inner = inner_module_cls(**args_innermost) + upconv_cls = self.get_from_kwargs(args_up, "up_conv_cls") + self.up = upconv_cls(**args_up) + else: + downconv_cls = self.get_from_kwargs(args_down, "down_conv_cls") + upconv_cls = self.get_from_kwargs(args_up, "up_conv_cls") + downconv = downconv_cls(**args_down) + upconv = upconv_cls(**args_up) + + self.down = downconv + self.submodule = submodule + self.up = upconv + + def forward(self, data, *args, **kwargs): + if self.innermost: + data_out = self.inner(data, **kwargs) + data = (data_out, data) + return self.up(data, **kwargs) + else: + data_out = self.down(data, **kwargs) + data_out2 = self.submodule(data_out, **kwargs) + data = (data_out2, data) + return self.up(data, **kwargs) + + +############################# UNWRAPPED UNET BASE ################################### + + +class UnwrappedUnetBasedModel(BaseModel): + """Create a Unet unwrapped generator""" + + def _save_sampling_and_search(self, down_conv): + sampler = getattr(down_conv, "sampler", None) + if is_list(sampler): + self._spatial_ops_dict["sampler"] += sampler + else: + self._spatial_ops_dict["sampler"].append(sampler) + + neighbour_finder = getattr(down_conv, "neighbour_finder", None) + if is_list(neighbour_finder): + self._spatial_ops_dict["neighbour_finder"] += neighbour_finder + else: + self._spatial_ops_dict["neighbour_finder"].append(neighbour_finder) + + def _save_upsample(self, up_conv): + upsample_op = getattr(up_conv, "upsample_op", None) + if upsample_op: + self._spatial_ops_dict["upsample_op"].append(upsample_op) + + def __init__(self, opt, model_type, dataset: BaseDataset, modules_lib): + """Construct a Unet unwrapped generator + + The layers will be appended within lists with the following names + * down_modules : Contains all the down module + * inner_modules : Contain one or more inner modules + * up_modules: Contains all the up module + + Parameters: + opt - options for the network generation + model_type - type of the model to be generated + num_class - output of the network + modules_lib - all modules that can be used in the UNet + + For a recursive implementation. See UnetBaseModel. + + opt is expected to contains the following keys: + * down_conv + * up_conv + * OPTIONAL: innermost + + """ + opt = copy.deepcopy(opt) + super(UnwrappedUnetBasedModel, self).__init__(opt) + # detect which options format has been used to define the model + self._spatial_ops_dict = {"neighbour_finder": [], "sampler": [], "upsample_op": []} + + if is_list(opt.down_conv) or "down_conv_nn" not in opt.down_conv: + raise NotImplementedError + else: + self._init_from_compact_format(opt, model_type, dataset, modules_lib) + + def _collect_sampling_ids(self, list_data): + def extract_matching_key(keys, start_token): + for key in keys: + if key.startswith(start_token): + return key + return None + + d = {} + if self.save_sampling_id: + for idx, data in enumerate(list_data): + key = extract_matching_key(data.keys, "sampling_id") + if key: + d[key] = getattr(data, key) + return d + + def _get_from_kwargs(self, kwargs, name): + module = kwargs[name] + kwargs.pop(name) + return module + + def _create_inner_modules(self, args_innermost, modules_lib): + inners = [] + if is_list(args_innermost): + for inner_opt in args_innermost: + module_name = self._get_from_kwargs(inner_opt, "module_name") + inner_module_cls = getattr(modules_lib, module_name) + inners.append(inner_module_cls(**inner_opt)) + + else: + module_name = self._get_from_kwargs(args_innermost, "module_name") + inner_module_cls = getattr(modules_lib, module_name) + inners.append(inner_module_cls(**args_innermost)) + + return inners + + def _init_from_compact_format(self, opt, model_type, dataset, modules_lib): + """Create a unetbasedmodel from the compact options format - where the + same convolution is given for each layer, and arguments are given + in lists + """ + + self.down_modules = nn.ModuleList() + self.inner_modules = nn.ModuleList() + self.up_modules = nn.ModuleList() + + self.save_sampling_id = opt.down_conv.get('save_sampling_id') + + # Factory for creating up and down modules + factory_module_cls = self._get_factory(model_type, modules_lib) + down_conv_cls_name = opt.down_conv.module_name + up_conv_cls_name = opt.up_conv.module_name if opt.get('up_conv') is not None else None + self._factory_module = factory_module_cls( + down_conv_cls_name, up_conv_cls_name, modules_lib + ) # Create the factory object + + # Loal module + contains_global = hasattr(opt, "innermost") and opt.innermost is not None + if contains_global: + inners = self._create_inner_modules(opt.innermost, modules_lib) + for inner in inners: + self.inner_modules.append(inner) + else: + self.inner_modules.append(Identity()) + + # Down modules + for i in range(len(opt.down_conv.down_conv_nn)): + args = self._fetch_arguments(opt.down_conv, i, "DOWN") + conv_cls = self._get_from_kwargs(args, "conv_cls") + down_module = conv_cls(**args) + self._save_sampling_and_search(down_module) + self.down_modules.append(down_module) + + # Up modules + if up_conv_cls_name: + for i in range(len(opt.up_conv.up_conv_nn)): + args = self._fetch_arguments(opt.up_conv, i, "UP") + conv_cls = self._get_from_kwargs(args, "conv_cls") + up_module = conv_cls(**args) + self._save_upsample(up_module) + self.up_modules.append(up_module) + + self.metric_loss_module, self.miner_module = BaseModel.get_metric_loss_and_miner( + getattr(opt, "metric_loss", None), getattr(opt, "miner", None) + ) + + def _get_factory(self, model_name, modules_lib) -> BaseFactory: + factory_module_cls = getattr(modules_lib, "{}Factory".format(model_name), None) + if factory_module_cls is None: + factory_module_cls = BaseFactory + return factory_module_cls + + def _fetch_arguments_from_list(self, opt, index): + """Fetch the arguments for a single convolution from multiple lists + of arguments - for models specified in the compact format. + """ + args = {} + for o, v in opt.items(): + name = str(o) + if is_list(v) and len(getattr(opt, o)) > 0: + if name[-1] == "s" and name not in SPECIAL_NAMES: + name = name[:-1] + v_index = v[index] + if is_list(v_index): + v_index = list(v_index) + args[name] = v_index + else: + if is_list(v): + v = list(v) + args[name] = v + return args + + def _fetch_arguments(self, conv_opt, index, flow): + """Fetches arguments for building a convolution (up or down) + + Arguments: + conv_opt + index in sequential order (as they come in the config) + flow "UP" or "DOWN" + """ + args = self._fetch_arguments_from_list(conv_opt, index) + args["conv_cls"] = self._factory_module.get_module(flow) + args["index"] = index + return args + + def _flatten_compact_options(self, opt): + """Converts from a dict of lists, to a list of dicts""" + flattenedOpts = [] + + for index in range(int(1e6)): + try: + flattenedOpts.append(DictConfig(self._fetch_arguments_from_list(opt, index))) + except IndexError: + break + + return flattenedOpts + + def forward(self, data, precomputed_down=None, precomputed_up=None, **kwargs): + """This method does a forward on the Unet assuming symmetrical skip connections + + Parameters + ---------- + data: torch.geometric.Data + Data object that contains all info required by the modules + precomputed_down: torch.geometric.Data + Precomputed data that will be passed to the down convs + precomputed_up: torch.geometric.Data + Precomputed data that will be passed to the up convs + """ + stack_down = [] + for i in range(len(self.down_modules) - 1): + data = self.down_modules[i](data, precomputed=precomputed_down) + stack_down.append(data) + data = self.down_modules[-1](data, precomputed=precomputed_down) + + if not isinstance(self.inner_modules[0], Identity): + stack_down.append(data) + data = self.inner_modules[0](data) + + sampling_ids = self._collect_sampling_ids(stack_down) + + for i in range(len(self.up_modules)): + data = self.up_modules[i]((data, stack_down.pop()), precomputed=precomputed_up) + + for key, value in sampling_ids.items(): + setattr(data, key, value) + return data diff --git a/torch-points3d/torch_points3d/models/base_model.py b/torch-points3d/torch_points3d/models/base_model.py new file mode 100644 index 0000000..94e653d --- /dev/null +++ b/torch-points3d/torch_points3d/models/base_model.py @@ -0,0 +1,438 @@ +import logging +import os +from collections import OrderedDict +from typing import Optional, Dict, Any, List + +import torch +from torch.optim.lr_scheduler import _LRScheduler +from torch.optim.optimizer import Optimizer + + +from torch_points3d.core.optimizer.adabelief import AdaBelief +from torch_points3d.core.regularizer import * +from torch_points3d.core.schedulers.bn_schedulers import instantiate_bn_scheduler +from torch_points3d.core.schedulers.lr_schedulers import instantiate_scheduler +from torch_points3d.utils.colors import colored_print, COLORS +from torch_points3d.utils.enums import SchedulerUpdateOn +from .model_interface import TrackerInterface, DatasetInterface, CheckpointInterface + +log = logging.getLogger(__name__) + + +class BaseModel(torch.nn.Module, TrackerInterface, DatasetInterface, CheckpointInterface): + """This class is an abstract base class (ABC) for models. + To create a subclass, you need to implement the following five functions: + -- <__init__>: initialize the class; first call BaseModel.__init__(self, opt). + -- : unpack data from dataset and apply preprocessing. + -- : produce intermediate results. + -- : calculate losses, gradients, and update network weights. + """ + + __REQUIRED_DATA__: List[str] = [] + __REQUIRED_LABELS__: List[str] = [] + + def __init__(self, opt): + """Initialize the BaseModel class. + Parameters: + opt (Option class)-- stores all the experiment flags; needs to be a subclass of BaseOptions + When creating your custom class, you need to implement your own initialization. + In this function, you should first call + Then, you need to define four lists: + -- self.loss_names (str list): specify the training losses that you want to plot and save. + -- self.model_names (str list): specify the images that you want to display and save. + -- self.visual_names (str list): define networks used in our training. + -- self.optimizers (optimizer list): define and initialize optimizers. You can define one optimizer for each network. If two networks are updated at the same time, you can use itertools.chain to group them. See cycle_gan_model.py for an example. + """ + super(BaseModel, self).__init__() + self.opt = opt + self.loss_names = [] + self.visual_names = [] + self.output = None + self.model = None + self._conv_type = opt.conv_type if hasattr(opt, 'conv_type') else None # Update to OmegaConv 2.0 + self._optimizer: Optional[Optimizer] = None + self._lr_scheduler: Optimizer[_LRScheduler] = None + self._bn_scheduler = None + self._spatial_ops_dict: Dict = {} + self._num_epochs = 0 + self._num_batches = 0 + self._num_samples = -1 + self._schedulers = {} + self._accumulated_gradient_step = 1 + self._grad_clip = -1 + self._grad_scale = None + self._supports_mixed = False + self._enable_mixed = False + self._update_lr_scheduler_on = "on_epoch" + self._update_bn_scheduler_on = "on_epoch" + + @property + def schedulers(self): + return self._schedulers + + @schedulers.setter + def schedulers(self, schedulers): + if schedulers: + self._schedulers = schedulers + for scheduler_name, scheduler in schedulers.items(): + setattr(self, "_{}".format(scheduler_name), scheduler) + + def _add_scheduler(self, scheduler_name, scheduler): + setattr(self, "_{}".format(scheduler_name), scheduler) + self._schedulers[scheduler_name] = scheduler + + @property + def optimizer(self): + return self._optimizer + + @optimizer.setter + def optimizer(self, optimizer): + self._optimizer = optimizer + + @property + def grad_scale(self): + return self._grad_scale + + @grad_scale.setter + def grad_scale(self, grad_scale): + self._grad_scale = grad_scale + + @property + def num_epochs(self): + return self._num_epochs + + @num_epochs.setter + def num_epochs(self, num_epochs): + self._num_epochs = num_epochs + + @property + def num_batches(self): + return self._num_batches + + @num_batches.setter + def num_batches(self, num_batches): + self._num_batches = num_batches + + @property + def num_samples(self): + return self._num_samples + + @num_samples.setter + def num_samples(self, num_samples): + self._num_samples = num_samples + + @property + def learning_rate(self): + for param_group in self.optimizer.param_groups: + return param_group["lr"] + + @property + def device(self): + return next(self.parameters()).device + + @property + def conv_type(self): + return self._conv_type + + @conv_type.setter + def conv_type(self, conv_type): + self._conv_type = conv_type + + def is_mixed_precision(self): + return self._supports_mixed and self._enable_mixed + + def set_input(self, input, device): + """Unpack input data from the dataloader and perform necessary pre-processing steps. + Parameters: + input (dict): includes the data itself and its metadata information. + """ + raise NotImplementedError + + def load_state_dict_with_same_shape(self, weights, strict=False): + model_state = self.state_dict() + filtered_weights = {k: v for k, v in weights.items() if k in model_state and v.size() == model_state[k].size()} + unmatched_weights = [k for k, v in weights.items() if k not in model_state or v.size() != model_state[k].size()] + + log.info("Loading weights:" + ", ".join(filtered_weights.keys())) + if len(unmatched_weights) > 0: + log.info("These weights did not match:" + ", ".join(unmatched_weights)) + self.load_state_dict(filtered_weights, strict=strict) + + def set_pretrained_weights(self): + path_pretrained = getattr(self.opt, "path_pretrained", None) + weight_name = getattr(self.opt, "weight_name", "latest") + + if path_pretrained is not None: + if not os.path.exists(path_pretrained): + raise FileNotFoundError("The path does not exist, it will not load any model") + else: + log.info("load pretrained weights from {}".format(path_pretrained)) + m = torch.load(path_pretrained, map_location="cpu")["models"][weight_name] + self.load_state_dict_with_same_shape(m, strict=False) + + def get_labels(self): + """returns a tensor of size ``[N_points]`` where each value is the label of a point""" + return getattr(self, "labels", None) + + def get_batch(self): + """returns a tensor of size ``[N_points]`` where each value is the batch index of a point""" + return getattr(self, "batch_idx", None) + + def get_output(self): + """returns a tensor of size ``[N_points,...]`` where each value is the output + of the network for a point (output of the last layer in general) + """ + return self.output + + def get_input(self): + """returns the last input that was given to the model or raises error""" + return getattr(self, "input") + + def forward(self, *args, **kwargs) -> Any: + """Run forward pass; called by both functions and .""" + raise NotImplementedError("You must implement your own forward") + + def _manage_optimizer_zero_grad(self): + if self._accumulated_gradient_step == 1: + self._optimizer.zero_grad() # clear existing gradients + return True + else: + if self._accumulated_gradient_count == self._accumulated_gradient_step: + self._accumulated_gradient_count = 0 + return True + + if self._accumulated_gradient_count == 0: + self._optimizer.zero_grad() # clear existing gradients + self._accumulated_gradient_count += 1 + return False + + def _do_scheduler_update(self, update_scheduler_on, scheduler, epoch, batch_size, num_batches): + if hasattr(self, update_scheduler_on): + update_scheduler_on = getattr(self, update_scheduler_on) + if update_scheduler_on is None: + raise Exception("The optimizer does not seems to be instantiated (instantiate_optimizers).") + + num_steps = 0 + step_size = epoch + if update_scheduler_on == SchedulerUpdateOn.ON_EPOCH.value: + num_steps = epoch - self._num_epochs + elif update_scheduler_on == SchedulerUpdateOn.ON_NUM_BATCH.value: + num_steps = 1 + step_size = self._num_batches / num_batches + elif update_scheduler_on == SchedulerUpdateOn.ON_NUM_SAMPLE.value: + num_steps = batch_size + + for _ in range(num_steps): + scheduler.step(step_size) + else: + raise Exception("The attributes {} should be defined within self".format(update_scheduler_on)) + + def optimize_parameters(self, epoch, batch_size, num_batches): + """Calculate losses, gradients, and update network weights; called in every training iteration""" + + with torch.cuda.amp.autocast(enabled=self.is_mixed_precision()): # enable autocasting if supported + self(epoch=epoch) # first call forward to calculate intermediate results + + self.loss = self._grad_scale.scale(self.loss / self._accumulated_gradient_step) # scale losses if needed + make_optimizer_step = self._manage_optimizer_zero_grad() # Accumulate gradient if option is up + self.backward() # calculate gradients + + if make_optimizer_step: + if self._grad_clip > 0: + self._grad_scale.unscale_(self._optimizer) # unscale losses to orig + torch.nn.utils.clip_grad_value_(self.parameters(), self._grad_clip) + + self._grad_scale.step(self._optimizer) # update parameters + self._grad_scale.update() # update scaling + + if self._lr_scheduler: + self._do_scheduler_update("_update_lr_scheduler_on", self._lr_scheduler, epoch, batch_size, num_batches) + + if self._bn_scheduler: + self._do_scheduler_update("_update_bn_scheduler_on", self._bn_scheduler, epoch, batch_size, num_batches) + + self._num_epochs = epoch + self._num_batches += 1 + self._num_samples += batch_size + + def backward(self): + """Calculate losses, gradients, and update network weights; called in every training iteration""" + # calculate the intermediate results if necessary; here self.output has been computed during function + # calculate loss given the input and intermediate results + self.loss.backward() # calculate gradients of network G w.r.t. loss_G + + def get_current_losses(self): + """Return training losses / errors. train.py will print out these errors on console""" + errors_ret = OrderedDict() + for name in self.loss_names: + if isinstance(name, str): + if hasattr(self, name): + try: + errors_ret[name] = float(getattr(self, name)) + except: + errors_ret[name] = None + return errors_ret + + def get_parameter_list(self) -> List[dict]: + return [{"params": self.parameters()}] + + def init_train_objects(self, config): + self.init_optim(config) + + self.init_schedulers(config) + + # Accumulated gradients + self._accumulated_gradient_step = self.get_from_opt( + config, ["training", "optim", "accumulated_gradient"], default_value=1 + ) + if self._accumulated_gradient_step > 1: + self._accumulated_gradient_count = 0 + # Gradient clipping + self._grad_clip = self.get_from_opt(config, ["training", "optim", "grad_clip"], default_value=-1) + + self.init_grad_scaler(config) + + def init_optim(self, config): + # Optimiser + optimizer_opt = self.get_from_opt( + config, + ["training", "optim", "optimizer"], + msg_err="optimizer needs to be defined within the training config", + ) + optimizer_cls_name = optimizer_opt.get("class") + if optimizer_cls_name == "AdaBelief": + optimizer_cls = AdaBelief + else: + optimizer_cls = getattr(torch.optim, optimizer_cls_name) + optimizer_params = {} + if hasattr(optimizer_opt, "params"): + optimizer_params = optimizer_opt.params + self._optimizer = optimizer_cls(self.get_parameter_list(), **optimizer_params) + + def init_grad_scaler(self, config): + # Gradient Scaling + self._enable_mixed = self.get_from_opt(config, ["training", "enable_mixed"], default_value=False) + self._enable_mixed = bool(self._enable_mixed) + if self._enable_mixed and not self._supports_mixed: + self._enable_mixed = False + log.warning("Mixed precision is not supported on this model, using default precision...") + elif self.is_mixed_precision(): + log.info("Model will use mixed precision") + self._grad_scale = torch.cuda.amp.GradScaler(enabled=self.is_mixed_precision()) + + def init_schedulers(self, config): + # LR Scheduler + scheduler_opt = self.get_from_opt(config, ["training", "optim", "lr_scheduler"]) + if scheduler_opt: + update_lr_scheduler_on = config.get('update_lr_scheduler_on') # Update to OmegaConf 2.0 + if update_lr_scheduler_on: + self._update_lr_scheduler_on = update_lr_scheduler_on + scheduler_opt.update_scheduler_on = self._update_lr_scheduler_on + lr_scheduler = instantiate_scheduler(self._optimizer, scheduler_opt) + self._add_scheduler("lr_scheduler", lr_scheduler) + # BN Scheduler + bn_scheduler_opt = self.get_from_opt(config, ["training", "optim", "bn_scheduler"]) + if bn_scheduler_opt: + update_bn_scheduler_on = config.get('update_bn_scheduler_on') # update to OmegaConf 2.0 + if update_bn_scheduler_on: + self._update_bn_scheduler_on = update_bn_scheduler_on + bn_scheduler_opt.update_scheduler_on = self._update_bn_scheduler_on + bn_scheduler = instantiate_bn_scheduler(self, bn_scheduler_opt) + self._add_scheduler("bn_scheduler", bn_scheduler) + + def get_regularization_loss(self, regularizer_type="L2", **kwargs): + loss = 0 + regularizer_cls = RegularizerTypes[regularizer_type.upper()].value + regularizer = regularizer_cls(self, **kwargs) + return regularizer.regularized_all_param(loss) + + def get_spatial_ops(self): + return self._spatial_ops_dict + + def enable_dropout_in_eval(self): + def search_from_key(modules): + for _, m in modules.items(): + if "Dropout" in m.__class__.__name__: + m.train() + search_from_key(m._modules) + + search_from_key(self._modules) + + def enable_bn_in_eval(self): + def search_from_key(modules): + for _, m in modules.items(): + if "BatchNorm" in m.__class__.__name__: + m.train() + search_from_key(m._modules) + + search_from_key(self._modules) + + def get_from_opt(self, opt, keys=[], default_value=None, msg_err=None, silent=True): + if len(keys) == 0: + raise Exception("Keys should not be empty") + value_out = default_value + + def search_with_keys(args, keys, value_out): + if len(keys) == 0: + value_out = args + return value_out + value = args[keys[0]] + return search_with_keys(value, keys[1:], value_out) + + try: + value_out = search_with_keys(opt, keys, value_out) + except Exception as e: + if msg_err: + raise Exception(str(msg_err)) + else: + if not silent: + log.exception(e) + value_out = default_value + return value_out + + def get_current_visuals(self): + """Return an OrderedDict containing associated tensors within visual_names""" + visual_ret = OrderedDict() + for name in self.visual_names: + if isinstance(name, str): + visual_ret[name] = getattr(self, name) + return visual_ret + + def log_optimizers(self): + colored_print(COLORS.Green, "Optimizer: {}".format(self._optimizer)) + colored_print(COLORS.Green, "Learning Rate Scheduler: {}".format(self._lr_scheduler)) + colored_print(COLORS.Green, "BatchNorm Scheduler: {}".format(self._bn_scheduler)) + colored_print(COLORS.Green, "Accumulated gradients: {}".format(self._accumulated_gradient_step)) + + def to(self, *args, **kwargs): + super().to(*args, *kwargs) + if self.optimizer: + for state in self.optimizer.state.values(): + for k, v in state.items(): + if isinstance(v, torch.Tensor): + state[k] = v.to(*args, **kwargs) + return self + + def verify_data(self, data, forward_only=False): + """Goes through the __REQUIRED_DATA__ and __REQUIRED_LABELS__ attribute of the model + and verifies that the passed data object contains all required members. + If something is missing it raises a KeyError exception. + """ + missing_keys = [] + required_attributes = self.__REQUIRED_DATA__ + if not forward_only: + required_attributes += self.__REQUIRED_LABELS__ + for attr in required_attributes: + if not hasattr(data, attr) or data[attr] is None: + missing_keys.append(attr) + if len(missing_keys): + raise KeyError( + "Missing attributes in your data object: {}. The model will fail to forward.".format(missing_keys) + ) + + def print_transforms(self): + message = "" + for attr in self.__dict__: + if "transform" in attr: + message += "{}{} {}= {}\n".format(COLORS.IPurple, attr, COLORS.END_NO_TOKEN, getattr(self, attr)) + print(message) diff --git a/torch-points3d/torch_points3d/models/instance/__init__.py b/torch-points3d/torch_points3d/models/instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/models/instance/base.py b/torch-points3d/torch_points3d/models/instance/base.py new file mode 100644 index 0000000..d2cd8a0 --- /dev/null +++ b/torch-points3d/torch_points3d/models/instance/base.py @@ -0,0 +1,728 @@ +import logging +from functools import partial +from typing import Any + +import numpy as np +import torch +import torch.nn.functional as F + +from torch_points3d.core.losses.focal_loss import focal_ce +from torch_points3d.core.losses.mixture_losses import discretized_mix_logistic_loss, to_one_hot, mix_gaussian_loss +from torch_points3d.models.base_architectures import BackboneBasedModel +from torch_points3d.models.base_model import BaseModel +from torch_points3d.models.model_interface import InstanceTrackerInterface + +log = logging.getLogger(__name__) + + +def mape(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + mask = y != 0 + error = torch.zeros_like(y) + error[mask] = torch.abs((y[mask] - x[mask]) / y[mask]) + + if reduce: + return error.mean() + else: + return error + + +def smape(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + error = ((y - x).abs() / (torch.abs(x) + torch.abs(y) + torch.finfo(torch.float16).eps)) + + if reduce: + return error.mean() + else: + return error + + +def smoothl1_zero(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + mask = y == 0 + error = torch.zeros_like(y) + # replace 0 with -1 + error[mask] = F.smooth_l1_loss(x[mask], -torch.ones_like(y)[mask], reduction="none") + error[~mask] = F.smooth_l1_loss(x[~mask], y[~mask], reduction="none") + + if reduce: + return error.mean() + else: + return error + + +def smoothl1_zero10(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + mask = y == 0 + error = torch.zeros_like(y) + # replace 0 with -1 + error[mask] = F.smooth_l1_loss(x[mask], -torch.ones_like(y)[mask] * 10, reduction="none") + error[~mask] = F.smooth_l1_loss(x[~mask], y[~mask], reduction="none") + + if reduce: + return error.mean() + else: + return error + + +def smoothl1_zero_db(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + ori_x = x[::2] + + y = y[::2] + aug_x = x[1::2] + + mask = y == 0 + error = torch.zeros_like(y) + # replace 0 with -1 + error[mask] = F.smooth_l1_loss(ori_x[mask], -torch.ones_like(y)[mask], reduction="none") + error[~mask] = F.smooth_l1_loss(ori_x[~mask], y[~mask], reduction="none") + + # huber loss + beta = 1.0 + diff = F.relu(y - aug_x) + error_aug = torch.where(diff < beta, 0.5 * diff ** 2 / beta, diff - 0.5 * beta) + + diff = F.relu(ori_x - aug_x) + error_augx = torch.where(diff < beta, 0.5 * diff ** 2 / beta, diff - 0.5 * beta) + + loss = 2 * error + .5 * error_aug + .5 * error_augx + + if reduce: + return loss.mean() + else: + return loss + + +def smoothl1_zero_db5(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + ori_x = x[::2] + + y = y[::2] + aug_x = x[1::2] + + mask = y == 0 + error = torch.zeros_like(y) + # replace 0 with -1 + error[mask] = F.smooth_l1_loss(ori_x[mask], -torch.ones_like(y)[mask], reduction="none") + error[~mask] = F.smooth_l1_loss(ori_x[~mask], y[~mask], reduction="none") + + # huber loss + beta = 1.0 + t = 0.01 + diff_aug = F.relu(y + t - aug_x) + error_aug = torch.where(diff_aug < beta, 0.5 * diff_aug ** 2 / beta, diff_aug - 0.5 * beta) + + diff_augx = F.relu(ori_x + t - aug_x) + error_augx = torch.where(diff_augx < beta, 0.5 * diff_augx ** 2 / beta, diff_augx - 0.5 * beta) + + if reduce: + loss = 2 * error.mean() + .5 * error_aug.sum() / ((diff_aug != 0).sum() + torch.finfo(torch.float16).eps) \ + + .5 * error_augx.sum() / ((diff_augx != 0).sum() + torch.finfo(torch.float16).eps) + else: + loss = 2 * error + .5 * error_aug + .5 * error_augx + + return loss + + +def smoothl1_zero_db4(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + ori_x = x[::2] + + y = y[::2] + aug_x = x[1::2] + + mask = y == 0 + error = torch.zeros_like(y) + # replace 0 with -1 + error[mask] = F.smooth_l1_loss(ori_x[mask], -torch.ones_like(y)[mask], reduction="none") + error[~mask] = F.smooth_l1_loss(ori_x[~mask], y[~mask], reduction="none") + + # huber loss + beta = 1.0 + diff_aug = F.relu(y - aug_x) + error_aug = torch.where(diff_aug < beta, 0.5 * diff_aug ** 2 / beta, diff_aug - 0.5 * beta) + + diff_augx = F.relu(ori_x - aug_x) + error_augx = torch.where(diff_augx < beta, 0.5 * diff_augx ** 2 / beta, diff_augx - 0.5 * beta) + + if reduce: + loss = 2 * error.mean() + .5 * error_aug.sum() / ((diff_aug != 0).sum() + torch.finfo(torch.float16).eps) \ + + .5 * error_augx.sum() / ((diff_augx != 0).sum() + torch.finfo(torch.float16).eps) + else: + loss = 2 * error + .5 * error_aug + .5 * error_augx + + return loss + + +def smoothl1_zero_db6(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + ori_x = x[::2] + + y = y[::2] + aug_x = x[1::2] + + mask = y == 0 + error = torch.zeros_like(y) + # replace 0 with -1 + error[mask] = F.smooth_l1_loss(ori_x[mask], -torch.ones_like(y)[mask], reduction="none") + error[~mask] = F.smooth_l1_loss(ori_x[~mask], y[~mask], reduction="none") + + # huber loss + beta = 1.0 + t = 0.00001 + diff_aug = F.relu(y + t - aug_x) + error_aug = torch.where(diff_aug < beta, 0.5 * diff_aug ** 2 / beta, diff_aug - 0.5 * beta) + + diff_augx = F.relu(ori_x + t - aug_x) + error_augx = torch.where(diff_augx < beta, 0.5 * diff_augx ** 2 / beta, diff_augx - 0.5 * beta) + + loss = 2 * error + .5 * error_aug + .5 * error_augx + if reduce: + loss = loss.sum() + + return loss + + +def smoothl1_zero_db7(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + ori_x = x[::2] + + y = y[::2] + aug_x = x[1::2] + + mask = y == 0 + error = torch.zeros_like(y) + # replace 0 with -1 + error[mask] = F.smooth_l1_loss(ori_x[mask], -torch.ones_like(y)[mask], reduction="none") + error[~mask] = F.smooth_l1_loss(ori_x[~mask], y[~mask], reduction="none") + + # huber loss + beta = 1.0 + diff_aug = F.relu(y - aug_x) + error_aug = torch.where(diff_aug < beta, 0.5 * diff_aug ** 2 / beta, diff_aug - 0.5 * beta) + + diff_augx = F.relu(ori_x - aug_x) + error_augx = torch.where(diff_augx < beta, 0.5 * diff_augx ** 2 / beta, diff_augx - 0.5 * beta) + + loss = 2 * error + .5 * error_aug + .5 * error_augx + if reduce: + loss = loss.sum() + + return loss + + +def smoothl1_zero_db3(x: torch.Tensor, y: torch.Tensor, reduce: bool = True): + ori_x = x[::2] + + y = y[::2] + aug_x = x[1::2] + + mask = y == 0 + error = torch.zeros_like(y) + # replace 0 with -1 + error[mask] = F.smooth_l1_loss(ori_x[mask], -torch.ones_like(y)[mask], reduction="none") + error[~mask] = F.smooth_l1_loss(ori_x[~mask], y[~mask], reduction="none") + + # huber loss + beta = 1.0 + t = 0.01 # minimal increase to + diff = F.relu(y + t - aug_x) + error_aug = torch.where(diff < beta, 0.5 * diff ** 2 / beta, diff - 0.5 * beta) + + diff = F.relu(ori_x + t - aug_x) + error_augx = torch.where(diff < beta, 0.5 * diff ** 2 / beta, diff - 0.5 * beta) + + loss = 2 * error + .5 * error_aug + .5 * error_augx + + if reduce: + return loss.mean() + else: + return loss + + +REG_LOSSES = { + "smoothl1": F.smooth_l1_loss, + "smoothl1_zero": smoothl1_zero, + "smoothl1_zero10": smoothl1_zero10, + "smoothl1_zero_db": smoothl1_zero_db, + "smoothl1_zero_db3": smoothl1_zero_db3, + "smoothl1_zero_db4": smoothl1_zero_db4, + "smoothl1_zero_db5": smoothl1_zero_db5, + "smoothl1_zero_db6": smoothl1_zero_db6, + "smoothl1_zero_db7": smoothl1_zero_db7, + "l2": F.mse_loss, + "l1": F.l1_loss, + "mape": mape, + "smape": smape, +} + +MOL_LOSSES = { + "dml": discretized_mix_logistic_loss, + "cml": mix_gaussian_loss, + # "clml": mix_loggaussian_loss, + "focal_dml": partial(discretized_mix_logistic_loss, gamma=2.0), + "focal_cml": partial(mix_gaussian_loss, gamma=2.0), + # "focal_clml": partial(mix_loggaussian_loss, gamma=2.0), +} + +CLS_LOSSES = { + "ce": partial(F.cross_entropy, label_smoothing=0.1), + "focal_ce": partial(focal_ce, label_smoothing=0.1), +} + + +def linear(x): return x + + +OUT_ACT = { + "linear": linear, + "elu": partial(F.elu, inplace=True), + "elu10": partial(F.elu, alpha=10, inplace=True), + "relu": partial(F.relu, inplace=True), +} + + +class InstanceBase(BaseModel, InstanceTrackerInterface): + def __init__(self, option, model_type, dataset, modules): + super().__init__(option) + self.visual_names = ["data_visual"] + + self.loss_fns = {} + self.has_reg_targets = dataset.has_reg_targets + self.has_mol_targets = dataset.has_mol_targets + self.has_cls_targets = dataset.has_cls_targets + self.reg_targets_idx = dataset.reg_targets_idx + self.mol_targets_idx = dataset.mol_targets_idx + self.cls_targets_idx = dataset.cls_targets_idx + + if self.has_reg_targets: + self.loss_names.append("loss_reg") + + self.get_task_weights_scale_center( + dataset, task="regression", short_task="reg", default_norm="standard", targets_idx=self.reg_targets_idx + ) + + self.reg_out_act = OUT_ACT[option.get("reg_out_activation", "linear").lower()] + self.reg_report_out_act = OUT_ACT[option.get("reg_out_report_activation", "linear").lower()] + + loss_strs = option.get("reg_loss_fn", "smoothl1") + if len(loss_strs) > 0: + loss_strs = loss_strs.split(",") + self.loss_fns["reg"] = [] + for loss_str in loss_strs: + loss = REG_LOSSES[loss_str] + self.loss_fns["reg"].append(loss) + + if self.has_mol_targets: + self.loss_names.append("loss_mol") + + self.get_task_weights_scale_center( + dataset, task="mol", short_task="mol", default_norm="min-max", targets_idx=self.mol_targets_idx + ) + + self.num_mixtures = [dataset.targets[target].get("num_mixtures", 1) for target in dataset.targets + if dataset.targets[target]["task"] == "mol"] + self.num_mol_intervals = np.array( + [dataset.targets[target].get("class_tol", .1) for target in dataset.targets + if dataset.targets[target]["task"] == "mol"]) + self.num_mol_intervals = np.round(self.mol_scale_targets[0] / self.num_mol_intervals) + # make even + self.num_mol_intervals += self.num_mol_intervals % 2 + + # self.use_logspace_out = False + + loss_strs = option.get("mol_loss_fn", "dml") + if len(loss_strs) > 0: + loss_strs = loss_strs.split(",") + self.loss_fns["mol"] = [] + for loss_str in loss_strs: + loss = MOL_LOSSES[loss_str] + # if loss_str == "clml": # model output directly in logspace + # self.use_logspace_out = True + self.loss_fns["mol"].append(loss) + else: + self.num_mixtures = [] + + if self.has_cls_targets: + self.loss_names.append("loss_cls") + loss_strs = option.get("cls_loss_fn", "ce") + + weights = [dataset.targets[target].get("weight", 1) for target in dataset.targets + if dataset.targets[target]["task"] == "classification"] + self.register_buffer("cls_weights", torch.tensor(weights, dtype=torch.float)) + + self.loss_fns["cls"] = [] + if len(loss_strs) > 0: + loss_strs = loss_strs.split(",") + for loss_str in loss_strs: + loss = CLS_LOSSES[loss_str] + self.loss_fns["cls"].append(loss) + + self.num_reg_classes = dataset.num_reg_classes + self.num_mol_classes = dataset.num_mol_classes + self.num_cls_classes = dataset.num_cls_classes + + # model overrides dataset settings + self.double_batch = option.get("double_batch", dataset.double_batch) + + def get_task_weights_scale_center(self, dataset, task, short_task, default_norm, targets_idx): + center = np.zeros(sum(targets_idx)) + scale = np.ones(sum(targets_idx)) + i = 0 + weights = [] + for target in dataset.targets: + if dataset.targets[target]["task"] == task: + weights.append(dataset.targets[target].get("weight", 1)) + normalization = dataset.targets[target].get("normalization", default_norm) + idx = np.zeros_like(targets_idx) + idx[i] = True + if normalization == "standard": + center[i] = self.get_dataset_avg_stat(dataset, "mean", default=0.0, feat_idx=idx) + scale[i] = self.get_dataset_avg_stat(dataset, "std", default=1.0, feat_idx=idx) + elif normalization == "min-max": + center[i] = self.get_dataset_avg_stat(dataset, "min", default=0.0, feat_idx=idx) + scale[i] = self.get_dataset_avg_stat(dataset, "max", default=1.0, feat_idx=idx) - center[i] + else: + if normalization != "none": + log.warning(f"'{normalization}' is not a valid normalization, using no normalization") + + center[i] = dataset.targets[target].get("center_override", center[i]) + scale[i] = dataset.targets[target].get("scale_override", scale[i]) + + scale[i] *= dataset.targets[target].get("scale_mult", 1.) + i += 1 + self.register_buffer(f"{short_task}_scale_targets", torch.tensor(scale.reshape(1, -1), dtype=torch.float)) + self.register_buffer(f"{short_task}_center_targets", torch.tensor(center.reshape(1, -1), dtype=torch.float)) + self.register_buffer(f"{short_task}_weights", torch.tensor(weights, dtype=torch.float)) + + def get_dataset_avg_stat(self, dataset, stat, default, feat_idx): + value = np.array([ + area["train"][feat_idx] for area in + getattr(dataset, f"get_{stat}_targets")().values() if "train" in area + ]) + nans = np.isnan(value) + + if nans.all(0).any(): + value = np.array([default] * len(feat_idx)) + log.warning(f"All training area with no valid {stat} value, setting to {default}. " + "This is fine if reloading amodel overrides this.") + return value + elif nans.all(0).any(): + idx = np.argwhere(nans.all(0)) + value[:, idx] = default + log.warning(f"Some training area with no valid {stat} value, setting the missing to {default}. " + "This is fine if reloading amodel overrides this.") + + return np.nanmean(value, 0) + + def set_input(self, data, device): + raise NotImplemented + + def convert_outputs(self, outputs): + reg_out = mol_out = cls_out = None + if outputs is not None: + + if self.has_reg_targets: + reg_out = self.reg_out_act(outputs[:, :self.num_reg_classes]) + if self.has_mol_targets: + mol_out = outputs[:, self.num_reg_classes: self.num_reg_classes + self.num_mol_classes] + # if self.use_logspace_out: + # nr_mix = mol_out.size(1) // 3 + # mol_out[:, nr_mix:2 * nr_mix] = F.softplus(mol_out[:, nr_mix:2 * nr_mix]) + if self.has_cls_targets: + cls_out = outputs[:, self.num_reg_classes + self.num_mol_classes:] + + return reg_out, mol_out, cls_out + + def forward(self, *args, **kwargs) -> Any: + raise NotImplemented + + def compute_loss(self): + raise NotImplemented + + def compute_reg_loss(self): + if self.has_reg_targets and len(self.loss_fns["reg"]) > 0 and self.reg_y_mask.any(): + self.loss_reg = 0 + # scaling by std to have equal grads + output = self.reg_out + labels = ((self.reg_y - self.reg_center_targets) / self.reg_scale_targets) + if self.training and self.double_batch: + output2 = self.reg_out2 + + if not self.reg_y_mask.all(): + output = output[self.reg_y_mask] + labels = labels[self.reg_y_mask] + if self.training and self.double_batch: + output2 = output2[self.reg_y_mask] + + for loss_fn in self.loss_fns["reg"]: + + if self.training and self.double_batch: + self.loss_reg += ( + (0.5 * loss_fn(output, labels, reduce=False)) + + (0.5 * loss_fn(output2, labels, reduce=False)) + ).mean() + else: + self.loss_reg += loss_fn(output, labels, reduce=True) + + self.loss += self.reg_weights.mean() * self.loss_reg + + def compute_mol_loss(self): + if self.has_mol_targets and len(self.loss_fns["mol"]) > 0 and self.mol_y_mask.any(): + # iterate through each mol task + i_mixtures = 0 + self.loss_mol = 0 + for i, (num_mixtures, num_classes) in enumerate(zip(self.num_mixtures, self.num_mol_intervals)): + mask = torch.zeros_like(self.mol_y_mask) + # only set mask for current task + mask[:, i: i + 1] = self.mol_y_mask[:, i: i + 1] + if (~mask).all(): + continue + out_mask = torch.zeros_like(self.mol_out).bool() + out_mask[:, i_mixtures * 3: (i_mixtures + num_mixtures) * 3] = \ + mask[:, i: i + 1].repeat_interleave(num_mixtures * 3, 1) + + output = self.mol_out[out_mask].reshape(-1, num_mixtures * 3) + labels = (self.mol_y[mask].reshape(-1, 1) - self.mol_center_targets[:, [i]]) / self.mol_scale_targets[:, [i]] + labels = labels * 2 - 1 # between -1 and 1 + + if self.training and self.double_batch: + output2 = self.mol_out2[out_mask].reshape(-1, num_mixtures * 3) + + loss_mol = 0 + for loss_fn in self.loss_fns["mol"]: + if self.training and self.double_batch: + loss_mol += ( + (0.5 * loss_fn(output, labels, num_classes=num_classes, reduce=False)) + + (0.5 * loss_fn(output2, labels, num_classes=num_classes, reduce=False)) + ).mean() + else: + loss_mol += loss_fn(output, labels, num_classes=num_classes, reduce=True) + self.loss += self.mol_weights[i] * loss_mol + self.loss_mol += loss_mol + i_mixtures += num_mixtures + + def compute_cls_loss(self): + if self.has_cls_targets and len(self.loss_fns["cls"]) > 0 and self.cls_y_mask.any(): + # iterate through each classification task + i_classes = 0 + self.loss_cls = 0 + for i, num_classes in enumerate(self.num_cls_classes): + mask = torch.zeros_like(self.cls_y_mask) + # only set mask for current task + mask[:, i: i + 1] = self.cls_y_mask[:, i: i + 1] + if (~mask).all(): + continue + out_mask = torch.zeros_like(self.cls_out).bool() + out_mask[:, i_classes: i_classes + num_classes] = mask[:, i: i + 1].repeat_interleave(num_classes, 1) + + output = self.cls_out[out_mask].reshape(-1, num_classes) + labels = self.cls_y[mask] + if self.training and self.double_batch: + output2 = self.cls_out2[out_mask].reshape(-1, num_classes) + + loss_cls = 0 + for loss_fn in self.loss_fns["cls"]: + if self.training and self.double_batch: + loss_cls += ( + (0.5 * loss_fn(output, labels, reduction="none")) + + (0.5 * loss_fn(output2, labels, reduction="none")) + ).mean() + else: + loss_cls += loss_fn(output, labels) + + self.loss += self.cls_weights[i] * loss_cls + self.loss_cls += loss_cls + i_classes = i_classes + num_classes + + def get_reg_output(self): + """ returns a tensor of size ``[N_points,N_regression_targets]`` where each value is the regression output + of the network for a point (output of the last layer in general) + """ + return self.reg_report_out_act(self.reg_out * self.reg_scale_targets + self.reg_center_targets) + + def get_mol_output(self, ensemble=True): + """ returns a tensor of size ``[N_points,N_mol_targets]`` where each value is the mixture of logits output + of the network for a point (output of the last layer in general) + """ + mol_out = [] + i_mixtures = 0 + + for i, num_mixtures in enumerate(self.num_mixtures): + mixture = self.mol_out[:, i_mixtures * 3: (i_mixtures + num_mixtures) * 3] + logits = mixture[:, : num_mixtures] + means = mixture[:, num_mixtures: num_mixtures * 2] + + if ensemble: + # ensemble mixture predictions + softmax = logits.softmax(-1) + else: + # use most important mixture prediction only + softmax = to_one_hot(logits.max(-1)[1], num_mixtures) + + mol_out.append(torch.clamp((means * softmax).sum(1), min=-1, max=1)) + + i_mixtures += num_mixtures + + mol_out = torch.stack(mol_out, 1) + return (((mol_out + 1) * self.mol_scale_targets) / 2.) + self.mol_center_targets + + def get_cls_output(self): + """ returns a list of tensors for each classification task, + each of size ``[N_points,...]`` where each value is the log probability output + of the network for a point (output of the last layer in general) + """ + cls_out = [] + cls_i = 0 + for num_cls in self.num_cls_classes: + cls_out.append(self.cls_out[:, cls_i: cls_i + num_cls]) + cls_i += num_cls + + return cls_out + + def get_reg_input(self): + """ returns the last regression input that was given to the model or raises error + """ + return self.reg_y + + def get_mol_input(self): + """ returns the last mixture of logits input that was given to the model or raises error + """ + return self.mol_y + + def get_cls_input(self): + """ returns the last classification input that was given to the model or raises error + """ + return self.cls_y + + def compute_instance_loss(self): + self.compute_reg_loss() + self.compute_mol_loss() + self.compute_cls_loss() + + def load_state_dict(self, state_dict: dict, strict: bool = True): + if not self.opt.get("override_target_stats", True): + remove_dict_entry(state_dict, "reg_scale_targets") + remove_dict_entry(state_dict, "reg_center_targets") + remove_dict_entry(state_dict, "mol_scale_targets") + remove_dict_entry(state_dict, "mol_center_targets") + remove_dict_entry(state_dict, "reg_weights") + remove_dict_entry(state_dict, "mol_weights") + remove_dict_entry(state_dict, "cls_weights") + + super().load_state_dict(state_dict, strict) + + +def remove_dict_entry(dict, key): + if key in dict: + del dict[key] + log.info(f"removed '{key}', will use dataset value instead") + return dict + + +class InstanceBackboneBasedModel(BackboneBasedModel): + def __init__(self, option, model_type, dataset, modules_lib): + super().__init__(option, model_type, dataset, modules_lib) + self.visual_names = ["data_visual"] + + self.loss_fns = {} + self.has_reg_targets = dataset.has_reg_targets + self.has_mol_targets = dataset.has_mol_targets + self.has_cls_targets = dataset.has_cls_targets + + if self.has_reg_targets or self.has_mol_targets or self.has_cls_targets: + + if dataset.has_reg_targets: + self.loss_names.append("loss_reg") + scale = np.nanmean([area["train"] for area in dataset.get_std_targets().values()]) + self.register_buffer("reg_scale_targets", torch.tensor(scale.reshape(1, -1), dtype=torch.float)) + loss_strs = option.get("reg_loss_fn", "smoothl1") + if len(loss_strs) > 0: + loss_strs = loss_strs.split(",") + for loss_str in loss_strs: + loss = REG_LOSSES[loss_str] + self.loss_fns["reg"].append(loss) + + if dataset.has_mol_targets: + self.loss_names.append("loss_mol") + min = np.nanmean([area["train"] for area in dataset.get_min_targets().values()]) + max = np.nanmean([area["train"] for area in dataset.get_max_targets().values()]) + self.register_buffer("min_targets", torch.tensor(min.reshape(1, -1), dtype=torch.float)) + self.register_buffer("max_targets", torch.tensor(max.reshape(1, -1), dtype=torch.float)) + loss_strs = option.get("mol_loss_fn", "dml") + if len(loss_strs) > 0: + loss_strs = loss_strs.split(",") + for loss_str in loss_strs: + loss = MOL_LOSSES[loss_str] + self.loss_fns["mol"].append(loss) + + if dataset.has_cls_targets: + self.loss_names.append("loss_cls") + loss_strs = option.get("cls_loss_fn", "ce") + if len(loss_strs) > 0: + loss_strs = loss_strs.split(",") + for loss_str in loss_strs: + loss = CLS_LOSSES[loss_str] + self.loss_fns["cls"].append(loss) + + def set_input(self, data, device): + raise NotImplemented + + def forward(self, *args, **kwargs) -> Any: + raise NotImplemented + + def compute_loss(self): + raise NotImplemented + + def backward(self): + """Calculate losses, gradients, and update network weights; called in every training iteration""" + # caculate the intermediate results if necessary; here self.output has been computed during function + # calculate loss given the input and intermediate results + self.loss.backward() # calculate gradients of network G w.r.t. loss_G + + +class Instance_MP(InstanceBackboneBasedModel): + def __init__(self, option, model_type, dataset, modules): + """Initialize this model class. + Parameters: + opt -- training/test options + A few things can be done here. + - (required) call the initialization function of BaseModel + - define loss function, visualization images, model names, and optimizers + """ + super().__init__(option, model_type, dataset, modules) # call the initialization method of RegressionBase + + nn = option.mlp_cls.nn + self.dropout = option.mlp_cls.get("dropout") + self.lin1 = torch.nn.Linear(nn[0], nn[1]) + self.lin2 = torch.nn.Linear(nn[2], nn[3]) + self.lin3 = torch.nn.Linear(nn[4], dataset.num_classes) + + def set_input(self, data, device): + """Unpack input data from the dataloader and perform necessary pre-processing steps. + Parameters: + input: a dictionary that contains the data itself and its metadata information. + """ + data = data.to(device) + self.input = data + self.labels = data.y + self.batch_idx = data.batch + + def compute_loss(self): + self.loss_regr = 0 + labels = self.labels.view(self.output.shape) + for loss_fn in self.loss_fns: + self.loss_regr += loss_fn(self.output, labels) + + self.loss = self.loss_regr + + def forward(self, *args, **kwargs) -> Any: + """Run forward pass. This will be called by both functions and .""" + data = self.down_modules[0](self.input) + + x = F.relu(self.lin1(data.x)) + x = F.dropout(x, p=self.dropout, training=bool(self.training)) + x = self.lin2(x) + x = F.dropout(x, p=self.dropout, training=bool(self.training)) + x = self.lin3(x) + self.output = x + + if self.labels is not None: + self.compute_loss() + + self.data_visual = self.input + self.data_visual.y = self.labels + self.data_visual.pred = self.output + return self.output diff --git a/torch-points3d/torch_points3d/models/instance/kpconv.py b/torch-points3d/torch_points3d/models/instance/kpconv.py new file mode 100755 index 0000000..b2ffebe --- /dev/null +++ b/torch-points3d/torch_points3d/models/instance/kpconv.py @@ -0,0 +1,291 @@ +import logging +from typing import List + +import numpy as np +import torch +from easydict import EasyDict +from torch import nn + +from torch_points3d.models.instance.base import InstanceBase + +from torch_points3d.modules.KPConv.architectures import KPCNN +from torch_points3d.modules.KPConv.common import batch_grid_subsampling, batch_neighbors + +from time import time + +log = logging.getLogger(__name__) + + +class SeparateLinear(torch.nn.Module): + + def __init__(self, in_channel, out_channels): + super(SeparateLinear, self).__init__() + if isinstance(out_channels, int): + self.linears = nn.ModuleList([nn.Linear(in_channel, 1, bias=True) for i in range(out_channels)]) + elif isinstance(out_channels, dict): + num_reg_classes = out_channels.get("num_reg_classes", 0) + num_mixtures = out_channels.get("num_mixtures", []) + num_cls_classes = out_channels.get("num_cls_classes", []) + + self.linears = [] + if num_reg_classes > 0: + self.linears += [torch.nn.Linear(in_channel, 1, bias=True) for i in range(num_reg_classes)] + if len(num_mixtures) > 0: + self.linears += [ + torch.nn.Linear(in_channel, num_mixtures * 3, bias=True) for i, num_mixtures in + enumerate(num_mixtures) + ] + if len(num_cls_classes) > 0: + self.linears += [ + torch.nn.Linear(in_channel, num_classes) for num_classes in num_cls_classes + ] + + self.linears = torch.nn.ModuleList(self.linears) + else: + self.linears = nn.ModuleList([nn.Linear(in_channel, 1, bias=True)]) + + def forward(self, x): + return torch.cat([lin(x) for lin in self.linears], 1) + + +class KPConv(InstanceBase): + def __init__(self, option, model_type, dataset, modules): + super(KPConv, self).__init__(option, model_type, dataset, modules) + + self.config = config = option.config + + self.model = KPCNN(config) + + self.neighborhood_limits = [] + in_channel = self.model.head_mlp.mlp.weight.shape[1] + self.head = self.init_head(in_channel) + + self.dataset_num_points = dataset.dataset_opt.fixed.num_points + self.model_num_points = option.get("num_points", None) + if self.model_num_points is None: + self.should_sample = False + else: + from openpoints.models.layers import furthest_point_sample + self.furthest_point_sample = furthest_point_sample + if self.model_num_points == 1024: + self.point_all = 1200 + elif self.model_num_points == 4096: + self.point_all = 4800 + elif self.model_num_points == 6144: + self.point_all = 6900 + elif self.model_num_points == 8192: + self.point_all = 8192 + elif self.model_num_points == 12288: + self.point_all = 12288 + elif self.model_num_points == 16384: + self.point_all = 16384 + else: + raise NotImplementedError() + self.should_sample = self.model_num_points < self.dataset_num_points + self._supports_mixed = True + + self.head_optim_settings = option.get("head_optim_settings", {}) + self.backbone_optim_settings = option.get("backbone_optim_settings", {}) + + def get_parameter_list(self) -> List[dict]: + params_list = [] + head_parameters = self.head.parameters() + + backbone_parameters = self.model.parameters() + + params_list.append({"params": head_parameters, **self.head_optim_settings}) + params_list.append({"params": backbone_parameters, **self.backbone_optim_settings}) + + return params_list + + def init_head(self, in_channel): + return SeparateLinear( + in_channel, { + "num_reg_classes": self.num_reg_classes, + "num_mixtures": self.num_mixtures, + "num_cls_classes": self.num_cls_classes + } + ) + + def set_input(self, data, device): + self.data_visual = data + + points = data['pos'] + features = data['x'] + + if self.should_sample: # point resampling strategy if same number of points + points = points.view(-1, self.dataset_num_points, points.shape[-1]).to(device) + features = features.view(-1, self.dataset_num_points, features.shape[-1]).to(device) + point_all = points.size(1) if points.size(1) < self.point_all else self.point_all + fps_idx = self.furthest_point_sample(points[:, :, :3].contiguous(), point_all) + fps_idx = fps_idx[:, np.random.choice(point_all, self.model_num_points, False)] + points = torch.gather(points, 1, fps_idx.unsqueeze(-1).long().expand(-1, -1, points.shape[-1])) + features = torch.gather(features, 1, fps_idx.unsqueeze(-1).long().expand(-1, -1, features.shape[-1])) + + self.batch_idx = data.batch + lengths = data.ptr[1:] - data.ptr[:-1] + + # TODO could to this in batch pre collate + self.input = EasyDict(self.prepare_inputs( + points.view(-1, 3).cpu().numpy(), + features.view(-1, features.shape[-1]).cpu().numpy(), + lengths.numpy().astype(np.int32), + device + )) + + if len(self.loss_fns) > 0: + bs = len(data) + if self.has_reg_targets and data.y_reg is not None: + self.reg_y_mask = data.y_reg_mask.to(device).view(bs, -1) + self.reg_y = data.y_reg.to(device).view(bs, -1) + if self.has_mol_targets and data.y_mol is not None: + self.mol_y_mask = data.y_mol_mask.to(device).view(bs, -1) + self.mol_y = data.y_mol.to(device).view(bs, -1) + if self.has_cls_targets and data.y_cls is not None: + self.cls_y_mask = data.y_cls_mask.to(device).view(bs, -1) + self.cls_y = data.y_cls.to(device).view(bs, -1) + + def big_neighborhood_filter(self, neighbors, layer): + """ + Filter neighborhoods with max number of neighbors. Limit is set to keep XX% of the neighborhoods untouched. + Limit is computed at initialization + """ + + # crop neighbors matrix + if len(self.neighborhood_limits) > 0: + return neighbors[:, :self.neighborhood_limits[layer]] + else: + return neighbors + + def prepare_inputs(self, stacked_points, stacked_features, stack_lengths, device): + + # Starting radius of convolutions + r_normal = self.config.first_subsampling_dl * self.config.conv_radius + + # Starting layer + layer_blocks = [] + + # Lists of inputs + input_points = [] + input_neighbors = [] + input_pools = [] + input_stack_lengths = [] + deform_layers = [] + + ###################### + # Loop over the blocks + ###################### + + arch = self.config.architecture + L = 0 + for block_i, block in enumerate(arch): + + # Get all blocks of the layer + if not ('pool' in block or 'strided' in block or 'global' in block or 'upsample' in block): + layer_blocks += [block] + continue + L += 1 + # Convolution neighbors indices + # ***************************** + + deform_layer = False + if layer_blocks: + # Convolutions are done in this layer, compute the neighbors with the good radius + if np.any(['deformable' in blck for blck in layer_blocks]): + r = r_normal * self.config.deform_radius / self.config.conv_radius + deform_layer = True + else: + r = r_normal + conv_i = batch_neighbors(stacked_points, stacked_points, stack_lengths, stack_lengths, r) + + else: + # This layer only perform pooling, no neighbors required + conv_i = np.zeros((0, 1), dtype=np.int32) + + # Pooling neighbors indices + # ************************* + + # If end of layer is a pooling operation + if 'pool' in block or 'strided' in block: + + # New subsampling length + dl = 2 * r_normal / self.config.conv_radius + + # Subsampled points + pool_p, pool_b = batch_grid_subsampling(stacked_points, stack_lengths, sampleDl=dl) + + # Radius of pooled neighbors + if 'deformable' in block: + r = r_normal * self.config.deform_radius / self.config.conv_radius + deform_layer = True + else: + r = r_normal + + # Subsample indices + pool_i = batch_neighbors(pool_p, stacked_points, pool_b, stack_lengths, r) + + else: + # No pooling in the end of this layer, no pooling indices required + pool_i = np.zeros((0, 1), dtype=np.int32) + pool_p = np.zeros((0, 1), dtype=np.float32) + pool_b = np.zeros((0,), dtype=np.int32) + + # Reduce size of neighbors matrices by eliminating the farthest point + conv_i = self.big_neighborhood_filter(conv_i, len(input_points)) + pool_i = self.big_neighborhood_filter(pool_i, len(input_points)) + + # Updating input lists + input_points += [stacked_points] + input_neighbors += [conv_i.astype(np.int64)] + input_pools += [pool_i.astype(np.int64)] + input_stack_lengths += [stack_lengths] + deform_layers += [deform_layer] + + # New points for next layer + stacked_points = pool_p + stack_lengths = pool_b + + # Update radius and reset blocks + r_normal *= 2 + layer_blocks = [] + + # Stop when meeting a global pooling or upsampling + if 'global' in block or 'upsample' in block: + break + + ############### + # Return inputs + ############### + + # Save deform layers + + # list of network inputs + li = input_points + input_neighbors + input_pools + input_stack_lengths + li += [stacked_features, ] + + # Extract input tensors from the list of numpy array + input = {} + ind = 0 + input["points"] = [torch.from_numpy(nparray).to(device) for nparray in li[ind:ind + L]] + ind += L + input["neighbors"] = [torch.from_numpy(nparray).to(device) for nparray in li[ind:ind + L]] + ind += L + input["pools"] = [torch.from_numpy(nparray).to(device) for nparray in li[ind:ind + L]] + ind += L + input["lengths"] = [torch.from_numpy(nparray).to(device) for nparray in li[ind:ind + L]] + ind += L + input["features"] = torch.from_numpy(li[ind]).to(device) + + return input + + def compute_loss(self): + self.loss = 0 + self.compute_instance_loss() + + def forward(self, *args, **kwargs): + out = self.model(self.input) + self.output = self.head(out) + self.reg_out, self.mol_out, self.cls_out = self.convert_outputs(self.output) + self.compute_loss() + + self.data_visual.pred = self.output diff --git a/torch-points3d/torch_points3d/models/instance/minkowski.py b/torch-points3d/torch_points3d/models/instance/minkowski.py new file mode 100644 index 0000000..b5d33d5 --- /dev/null +++ b/torch-points3d/torch_points3d/models/instance/minkowski.py @@ -0,0 +1,445 @@ +import logging +from typing import List + +import MinkowskiEngine as ME +import torch +import torch.nn.functional as F +from torch_geometric.data import Batch + +from torch_points3d.models.instance.base import InstanceBase +from torch_points3d.models.instance.semi_supervised_helper import gather, invariance_loss, variance_loss, \ + covariance_loss, barlow_loss +from torch_points3d.modules.MinkowskiEngine import initialize_minkowski_unet + +log = logging.getLogger(__name__) + + +class SeparateLinear(torch.nn.Module): + + def __init__(self, in_channel, num_reg_classes, num_mixtures, num_cls_classes): + super(SeparateLinear, self).__init__() + self.linears = [] + if num_reg_classes > 0: + self.linears += [torch.nn.Linear(in_channel, 1, bias=True) for i in range(num_reg_classes)] + if len(num_mixtures) > 0: + self.linears += [ + torch.nn.Linear(in_channel, num_mixtures * 3, bias=True) for i, num_mixtures in enumerate(num_mixtures) + ] + if len(num_cls_classes) > 0: + self.linears += [ + torch.nn.Linear(in_channel, num_classes) for num_classes in num_cls_classes + ] + + self.linears = torch.nn.ModuleList(self.linears) + + def forward(self, x): + return torch.cat([lin(x.F) for lin in self.linears], 1) + + +class MinkowskiBaselineModel(InstanceBase): + def __init__(self, option, model_type, dataset, modules): + super(MinkowskiBaselineModel, self).__init__(option, model_type, dataset, modules) + self.model = initialize_minkowski_unet( + option.model_name, dataset.feature_dimension, dataset.num_classes, activation=option.activation, + first_stride=option.first_stride, global_pool=option.global_pool, bias=option.get("bias", True), + bn_momentum=option.get("bn_momentum", 0.1), norm_type=option.get("norm_type", "bn"), + dropout=option.get("dropout", 0.0), drop_path=option.get("drop_path", 0.0), + **option.get("extra_options", {}) + ) + in_channel = self.model.final.linear.weight.shape[1] + self._supports_mixed = True + self.model.final = SeparateLinear(in_channel, self.num_reg_classes, self.num_mixtures, self.num_cls_classes) + + for m in self.model.final.linears: + torch.nn.init.trunc_normal_(m.weight, std=.02) + if m.bias is not None: + torch.nn.init.constant_(m.bias, 0) + + self.head_namespace = option.get("head_namespace", "final.linears") + self.head_optim_settings = option.get("head_optim_settings", {}) + self.backbone_optim_settings = option.get("backbone_optim_settings", {}) + + self.add_pos = option.get("add_pos", False) + + def get_parameter_list(self) -> List[dict]: + params_list = [] + head_parameters, backbone_parameters = [], [] + for name, param in self.model.named_parameters(): + if self.head_namespace in name: + head_parameters.append(param) + else: + backbone_parameters.append(param) + params_list.append({"params": head_parameters, **self.head_optim_settings}) + params_list.append({"params": backbone_parameters, **self.backbone_optim_settings}) + + return params_list + + def set_input(self, data, device): + self.batch_idx = data.batch.squeeze() + coords = torch.cat([data.batch.unsqueeze(-1).int(), data.coords.int()], -1) + self.data_visual = data + features = data.x + if self.add_pos: + features = torch.cat([data.pos, features], 1) + self.input = ME.SparseTensor(features=features, coordinates=coords, device=device) + + if len(self.loss_fns) > 0: + bs = len(data) + if self.has_reg_targets and data.y_reg is not None: + self.reg_y_mask = data.y_reg_mask.to(device).view(bs, -1) + self.reg_y = data.y_reg.to(device).view(bs, -1) + if self.has_mol_targets and data.y_mol is not None: + self.mol_y_mask = data.y_mol_mask.to(device).view(bs, -1) + self.mol_y = data.y_mol.to(device).view(bs, -1) + if self.has_cls_targets and data.y_cls is not None: + self.cls_y_mask = data.y_cls_mask.to(device).view(bs, -1) + self.cls_y = data.y_cls.to(device).view(bs, -1) + + def compute_loss(self): + self.loss = 0 + self.compute_instance_loss() + + def forward(self, *args, **kwargs): + self.output = self.model(self.input) + self.reg_out, self.mol_out, self.cls_out = self.convert_outputs(self.output) + self.compute_loss() + + +class MinkowskiVAEm2(InstanceBase): + # similar to Kingma et al. https://proceedings.neurips.cc/paper/2014/file/d523773c6b194f37b938d340d5d02232-Paper.pdf + + def __init__(self, option, model_type, dataset, modules): + super(MinkowskiVAEm2, self).__init__(option, model_type, dataset, modules) + self.model = initialize_minkowski_unet( + option.model_name, dataset.feature_dimension, dataset.num_classes, activation=option.activation, + z_channels=option.z_channels, dropout=option.dropout, backbone=option.backbone, + resolution=int(1 / dataset.dataset_opt.first_subsampling), first_stride=option.first_stride, + global_pool=option.global_pool, **option.get("extra_options", {}) + ) + self.loss_names.extend( + ["loss_vae", "loss_BCE", "loss_KLD", "loss_rec", "loss_r_entropy", "loss_r_cross_entropy"] + ) + self.KLD_beta = option.KLD_beta + self.reconstruction_beta = option.reconstruction_beta + self.regression_beta = option.regression_beta + self.num_reg_classes = dataset.num_reg_classes + self.num_mol_classes = dataset.num_mol_classes + self.num_cls_classes = dataset.num_cls_classes + + def set_input(self, data, device): + self.batch_idx = data.batch.squeeze() + coords = torch.cat([data.batch.unsqueeze(-1).int(), data.coords.int()], -1) + self.data_visual = data + self.input = ME.SparseTensor(features=data.x, coordinates=coords, device=device) + self.input_target = self.input.coordinate_map_key + self.labels_mask = data.y_mask.to(device).view(-1, self.model.out_channels) + self.labels = data.y.to(device).view(-1, self.model.out_channels) + + def compute_loss(self): + # VAE loss + # loss to check if correct pruning was applied + + self.loss_BCE = 0 + for out_cl, target in zip(self.out_cls, self.rec_targets): + curr_loss = F.binary_cross_entropy_with_logits(out_cl.F.squeeze(), target.type(out_cl.F.dtype)) + self.loss_BCE += curr_loss / len(self.out_cls) + + self.loss_KLD = self.KLD_beta * 0.5 * torch.mean( + torch.mean(self.z_logvar.F.exp() + self.z_mean.F.pow(2) - 1 - self.z_logvar.F, 1) + ) + # feature reconstruction error (removes last dim as it is assumed to be 1) + rec = self.reconstruction + rec.F[:, -1] = 1 + + loss_rec = (rec - self.input).F + loss_rec = loss_rec[loss_rec[:, -1] == 0][:, :-1] + use_l1 = loss_rec > 1.0 + self.loss_rec = (torch.pow(loss_rec, 2) * ~use_l1 + torch.abs(loss_rec) * use_l1).mean() + + self.loss_vae = self.loss_KLD + self.reconstruction_beta * (self.loss_BCE + self.loss_rec) + + self.loss_regr = 0 + self.loss_r_cross_entropy = 0 + self.loss_r_entropy = 0 + # only calculate loss if labels are set + if self.labels is not None: + if self.labels_mask.any(): + # scaling by std to have equal grads + cond = (self.cond.F / self.reg_scale_targets)[self.labels_mask] + labels = (self.labels / self.reg_scale_targets)[self.labels_mask] + for loss_fn in self.loss_fns: + self.loss_regr += loss_fn(cond, labels) / len(self.loss_fns) # scaling by std to have equal grads + + self.loss = self.regression_beta * (self.loss_regr + self.loss_r_entropy + self.loss_r_cross_entropy) \ + + self.loss_vae + + return self.loss + + def forward(self, *args, **kwargs): + (self.out_cls, self.rec_targets, self.reconstruction, + self.zs, self.z_mean, self.z_logvar, + self.cond_norm, self.cond) = self.model( + self.input, self.input_target, self.labels, self.labels_mask + ) + self.output = self.cond.F + self.compute_loss() + + self.data_visual.pred = self.output + + +class MinkowskiVAE(MinkowskiVAEm2): + # similar to VAE for regression paper https://arxiv.org/abs/1904.05948 + + def compute_loss(self): + # VAE loss + # loss to check if correct pruning was applied + + self.loss_BCE = 0 + for out_cl, target in zip(self.out_cls, self.rec_targets): + curr_loss = F.binary_cross_entropy_with_logits(out_cl.F.squeeze(), target.type(out_cl.F.dtype)) + self.loss_BCE += curr_loss / len(self.out_cls) + + self.loss_KLD = self.KLD_beta * 0.5 * torch.mean( + torch.mean(self.z_logvar.F.exp() + self.z_mean.F.pow(2) - 1 - self.z_logvar.F, 1) + ) + # feature reconstruction error (removes last dim as it is assumed to be 1) + rec = self.reconstruction + rec.F[:, -1] = 1 + + loss_rec = (rec - self.input).F # TODO maybe just include intersected points + loss_rec = loss_rec[loss_rec[:, -1] == 0][:, :-1] + use_l1 = loss_rec > 1.0 + self.loss_rec = (torch.pow(loss_rec, 2) * ~use_l1 + torch.abs(loss_rec) * use_l1).mean() + + self.loss_vae = self.loss_KLD + self.reconstruction_beta * (self.loss_BCE + self.loss_rec) + + self.loss_regr = 0 + self.loss_r_cross_entropy = 0 + self.loss_r_entropy = 0 + # only calculate loss if labels are set + if self.labels is not None: + shape = self.r_mean.F.shape + if self.labels_mask.any(): + r_mean = (self.r_mean.F / self.reg_scale_targets)[self.labels_mask] + r_logvar = (self.r_logvar.F / self.reg_scale_targets.pow(2).log())[self.labels_mask] + labels = (self.labels / self.reg_scale_targets)[self.labels_mask] + for loss_fn in self.loss_fns: + self.loss_regr += ((loss_fn(r_mean, labels, reduction="none") + / (r_logvar.detach().exp() ** 0.5)).mean() # scaling by std to have equal grads + / len(self.loss_fns)) + + # cross entropy + self.loss_r_cross_entropy += 0.5 * ( + ((F.mse_loss(r_mean, labels - 1e-6 / shape[0], reduction="none")) + / (torch.exp(r_logvar))) + (r_logvar)).mean() + + # use entropy if nans are present (assumes univariate Gaussians) + if not self.labels_mask.all(): + # r_mean = self.r_mean.F[~self.labels_mask.view(shape)] + r_logvar = self.r_logvar.F[~self.labels_mask] + # removing static vars + # self.loss_r_entropy += 0.5 * (r_logvar).mean() + ''' + intuition behind including the r_mean part: keeping the prediction constant but since the + factor is small, the decoder should still be able to change it (aka it is a regularization term) + ''' + self.loss_r_entropy += 0.5 * (r_logvar).mean() + # self.loss_r_entropy += 0.5 * (((F.smooth_l1_loss(r_mean, r_mean.detach() + 1e-6, reduction="none")) + # / (torch.exp(r_logvar))) + r_logvar).mean() + # http://gregorygundersen.com/blog/2020/09/01/gaussian-entropy + # self.loss_r_entropy += 0.5 * ( r_logvar + np.log(2) + np.log(np.pi)) + 0.5 + # this would be wikipedia + # self.loss_entropy += r_logvar + np.log(np.sqrt(2 * np.pi * np.e)) + + self.loss = self.regression_beta * (self.loss_regr + self.loss_r_entropy + self.loss_r_cross_entropy) \ + + self.loss_vae + + return self.loss + + def forward(self, *args, **kwargs): + (self.out_cls, self.rec_targets, self.reconstruction, + self.zs, self.z_mean, self.z_logvar, + self.rs, self.r_mean, self.r_logvar + ) = self.model(self.input, self.input_target) + self.output = self.r_mean.F + self.compute_loss() + + self.data_visual.pred = self.output + + +class MinkowskiBarlowTwins(InstanceBase): + def __init__(self, option, model_type, dataset, modules): + super(MinkowskiBarlowTwins, self).__init__(option, model_type, dataset, modules) + model_version = option.get("model_version", "standard") + self.reset_output = option.get("reset_output", True) + self.model = initialize_minkowski_unet( + option.model_name, dataset.feature_dimension, + { + "num_reg_classes": self.num_reg_classes, + "num_mixtures": self.num_mixtures, + "num_cls_classes": self.num_cls_classes + }, + activation=option.activation, + first_stride=option.first_stride, dropout=option.dropout, global_pool=option.global_pool, + mode=option.mode, model_version=model_version, proj_activation=option.proj_activation, + proj_layers=option.proj_layers, proj_last_norm=option.proj_last_norm, backbone=option.backbone, + detach_classifier=option.mode != "finetune" and model_version == "standard", + **option.get("extra_options", {}) + ) + + self.mode = option.mode + if self.mode not in ["finetune", "freeze"]: + self.loss_names.extend( + ["loss_self_supervised"] + ) + self.scale_loss = option.scale_loss + self.backbone_lr = option.backbone_lr + self._supports_mixed = True + + def get_parameter_list(self) -> List[dict]: + params_list = [] + classifier_parameters, model_parameters = [], [] + for name, param in self.model.named_parameters(): + if "encoder.final.classifier.linears" in name: + classifier_parameters.append(param) + else: + model_parameters.append(param) + + params_list.append({"params": classifier_parameters}) + if self.mode in ["finetune", "train"]: + model_dict = {"params": model_parameters} + if self.backbone_lr != "base_lr": + model_dict["lr"] = self.backbone_lr + params_list.append(model_dict) + + return params_list + + def set_pretrained_weights(self): + super().set_pretrained_weights() + if self.mode in ["finetune", "freeze"] and self.reset_output: + log.info(f"resetting weights for final prediction layer (since we are in {self.mode} mode)") + for m in self.model.encoder.final.classifier.linears: + m.weight.data.normal_(mean=0.0, std=0.01) + m.bias.data.zero_() + + def set_input(self, data, device): + self.batch_idx = data.batch.squeeze() + + if self.training and self.double_batch: + # augment data twice + data = data.to_data_list() + x1 = Batch.from_data_list(data[::2]) + x2 = Batch.from_data_list(data[1::2]) + coords2 = torch.cat([x2.batch.unsqueeze(-1).int(), x2.coords.int()], -1) + self.input2 = ME.SparseTensor(features=x2.x, coordinates=coords2, device=device) + else: + x1 = data + self.input2 = None + + bs = len(x1) + coords = torch.cat([x1.batch.unsqueeze(-1).int(), x1.coords.int()], -1) + self.data_visual = x1 + + self.input = ME.SparseTensor(features=x1.x, coordinates=coords, device=device) + + if len(self.loss_fns) > 0: + if self.has_reg_targets and x1.y_reg is not None: + self.reg_y_mask = x1.y_reg_mask.to(device).view(bs, -1) + self.reg_y = x1.y_reg.to(device).view(bs, -1) + if self.has_mol_targets and x1.y_mol is not None: + self.mol_y_mask = x1.y_mol_mask.to(device).view(bs, -1) + self.mol_y = x1.y_mol.to(device).view(bs, -1) + if self.has_cls_targets and x1.y_cls is not None: + self.cls_y_mask = x1.y_cls_mask.to(device).view(bs, -1) + self.cls_y = x1.y_cls.to(device).view(bs, -1) + + def compute_loss(self): + self.loss = 0 + self.compute_instance_loss() + if self.mode not in ["finetune", "freeze"]: + self.compute_self_supervised_loss() + + def compute_self_supervised_loss(self): + # barlow loss + # empirical cross-correlation matrix + self.loss_self_supervised = 0 + if self.training and self.double_batch: + self.loss_self_supervised += barlow_loss( + self.z1, self.z2, self.scale_loss["lambda"] + ) + self.loss += self.scale_loss["all"] * self.loss_self_supervised + + def compute_instance_loss(self): + self.compute_reg_loss() + self.compute_mol_loss() + self.compute_cls_loss() + + def forward(self, *args, **kwargs): + self.set_mode() + self.output, self.output2, self.z1, self.z2 = self.model(self.input, self.input2) + self.reg_out, self.mol_out, self.cls_out = self.convert_outputs(self.output) + self.reg_out2, self.mol_out2, self.cls_out2 = self.convert_outputs(self.output2) + + self.compute_loss() + self.data_visual.pred = self.output + + def set_mode(self): + if self.training: + if self.mode == "freeze": + self.model.requires_grad_(False) + self.model.encoder.final.classifier.requires_grad_(True) + self.model.encoder.eval() + self.model.encoder.final.classifier.train() + self.enable_dropout_in_eval() + + +class MinkowskiVICReg(MinkowskiBarlowTwins): + + def __init__(self, option, model_type, dataset, modules): + super(MinkowskiVICReg, self).__init__(option, model_type, dataset, modules) + + if self.mode not in ["finetune", "freeze"]: + self.loss_names.extend( + ["loss_invariance", "loss_variance", "loss_covariance"] + ) + + def compute_self_supervised_loss(self): + # barlow loss + # empirical cross-correlation matrix + self.loss_self_supervised = 0 + if self.training and self.mode == "train": + # from https://github.com/vturrisi/solo-learn/blob/6f19d5dc38fb6521e7fdd6aed5ac4a30ef8f3bd8/solo/losses/vicreg.py#L83 + z1, z2 = self.z1, self.z2 + # invariance loss + self.loss_invariance = invariance_loss(z1, z2) + + # vicreg's official code gathers the tensors here + # https://github.com/facebookresearch/vicreg/blob/main/main_vicreg.py + z1, z2 = gather(z1), gather(z2) + + # variance_loss + self.loss_variance = variance_loss(z1, z2) + self.loss_covariance = covariance_loss(z1, z2) + loss = self.scale_loss["invariance"] * self.loss_invariance + \ + self.scale_loss["variance"] * self.loss_variance + \ + self.scale_loss["covariance"] * self.loss_covariance + + self.loss_self_supervised += loss + self.loss += self.loss_self_supervised + + # def calc_VICReg_loss(self, z1, z2, G=None): + # # following https://arxiv.org/pdf/2205.11508.pdf + # N, D = z1.size() + # V = 2 + # if G is None: + # G = torch.zeros(V*N, V*N) # X′ ∈ R N ′×D′, V is the number of views + # i = torch.arange(0, N * V).repeat_interleave(V - 1) # row indices + # j= (i + torch.arange(1, V).repeat(N * V) * N).remainder(N * V) # column indices + # G[i, j] = 1 # unweighted graph connecting the rows of View_1(X′ ), . . . , View_V (X′) + # + # C = torch.cov(z.t()) + # eps = 1e-4 + # self.loss_variance += D - torch.diag(C).clamp(eps).sqrt().sum() + # i, j = G.nonzero(as_tuple=True) + # self.loss_invariance += (z[i] - z[j]).square().sum().inner(G[i, j]) / N + # self.loss_covariance += 2 * torch.triu(C, diagonal=1).square().sum() diff --git a/torch-points3d/torch_points3d/models/instance/pointnext.py b/torch-points3d/torch_points3d/models/instance/pointnext.py new file mode 100755 index 0000000..bf9fdf1 --- /dev/null +++ b/torch-points3d/torch_points3d/models/instance/pointnext.py @@ -0,0 +1,447 @@ +import logging +from typing import List + +import numpy as np +import torch +from torch import nn +from torch_geometric.data import Batch + +from openpoints.models.layers import create_act +from torch_points3d.core.common_modules import FastBatchNorm1d +from torch_points3d.models.instance.base import InstanceBase +from torch_points3d.models.instance.semi_supervised_helper import invariance_loss, gather, variance_loss, \ + covariance_loss, barlow_loss + +log = logging.getLogger(__name__) + + +class SeparateLinear(torch.nn.Module): + + def __init__(self, in_channel, out_channels): + super(SeparateLinear, self).__init__() + if isinstance(out_channels, int): + self.linears = nn.ModuleList([nn.Linear(in_channel, 1, bias=True) for i in range(out_channels)]) + elif isinstance(out_channels, dict): + num_reg_classes = out_channels.get("num_reg_classes", 0) + num_mixtures = out_channels.get("num_mixtures", []) + num_cls_classes = out_channels.get("num_cls_classes", []) + + self.linears = [] + if num_reg_classes > 0: + self.linears += [torch.nn.Linear(in_channel, 1, bias=True) for i in range(num_reg_classes)] + if len(num_mixtures) > 0: + self.linears += [ + torch.nn.Linear(in_channel, num_mixtures * 3, bias=True) for i, num_mixtures in + enumerate(num_mixtures) + ] + if len(num_cls_classes) > 0: + self.linears += [ + torch.nn.Linear(in_channel, num_classes) for num_classes in num_cls_classes + ] + + self.linears = torch.nn.ModuleList(self.linears) + else: + self.linears = nn.ModuleList([nn.Linear(in_channel, 1, bias=True)]) + + def forward(self, x): + return torch.cat([lin(x) for lin in self.linears], 1) + + +class PointNext(InstanceBase): + def __init__(self, option, model_type, dataset, modules): + super(PointNext, self).__init__(option, model_type, dataset, modules) + from openpoints.models import build_model_from_cfg + from openpoints.utils import EasyConfig + from openpoints.models.layers import furthest_point_sample + self.furthest_point_sample = furthest_point_sample + stride = option.stride + use_mlps = option.get("use_mlps", True) + radius_scaling = option.get("radius_scaling", 2.) + radius = option.get("radius", 0.1) + nsample = option.get("nsample", 32) + act = option.get("activation", "relu") + act_args = EasyConfig({'act': act}) + if act in ["elu", "celu"]: + act_args["alpha"] = 0.54 + + MODEL = { + "pointnet": EasyConfig({ + 'NAME': 'BaseCls', + 'encoder_args': EasyConfig({ + 'NAME': 'PointNetEncoder', + 'in_channels': dataset.feature_dimension, + 'is_seg': False, + 'input_transform': False, + }), + 'cls_args': EasyConfig({ + 'NAME': 'ClsHead', + 'num_classes': dataset.num_classes, + 'act_args': act_args, + 'mlps': [512, 256, 128, 128] if use_mlps else [], + }) + }), + "pointnext_s": EasyConfig({ + 'NAME': 'BaseCls', + 'encoder_args': EasyConfig({ + 'NAME': 'PointNextEncoder', + "blocks": [1, 1, 1, 1, 1, 1], + 'strides': [1, stride, stride, stride, stride, 1], + 'width': 32, + 'in_channels': dataset.feature_dimension, + 'radius': radius, + 'radius_scaling': radius_scaling, + 'sa_layers': 2, + 'sa_use_res': True, + 'nsample': nsample, + 'expansion': 4, + 'aggr_args': EasyConfig({'feature_type': 'dp_fj', 'reduction': 'max'}), + 'group_args': EasyConfig({'NAME': 'ballquery', 'normalize_dp': True}), + 'conv_args': EasyConfig({'order': 'conv-norm-act'}), + 'act_args': act_args, + 'norm_args': EasyConfig({'norm': 'bn'}) + }), + 'cls_args': EasyConfig({ + 'NAME': 'ClsHead', + 'num_classes': dataset.num_classes, + 'act_args': act_args, + 'mlps': [512, 256] if use_mlps else [], + 'norm_args': EasyConfig({'norm': 'bn1d'}) + }) + }), + "pointnext_b": EasyConfig({ + 'NAME': 'BaseCls', + 'encoder_args': EasyConfig({ + 'NAME': 'PointNextEncoder', + 'blocks': [1, 2, 3, 2, 1, 1], + 'strides': [1, stride, stride, stride, stride, 1], + 'width': 32, + 'in_channels': dataset.feature_dimension, + 'radius': radius, + 'radius_scaling': radius_scaling, + 'sa_layers': 1, + 'sa_use_res': False, + 'nsample': nsample, + 'expansion': 4, + 'aggr_args': EasyConfig({'feature_type': 'dp_fj', 'reduction': 'max'}), + 'group_args': EasyConfig({'NAME': 'ballquery', 'normalize_dp': True}), + 'conv_args': EasyConfig({'order': 'conv-norm-act'}), + 'act_args': act_args, + 'norm_args': EasyConfig({'norm': 'bn'}) + }), + 'cls_args': EasyConfig({ + 'NAME': 'ClsHead', + 'num_classes': dataset.num_classes, + 'act_args': act_args, + 'mlps': [512, 256] if use_mlps else [], + 'norm_args': EasyConfig({'norm': 'bn1d'}) + }) + }) + } + + cfg = MODEL[option.arch] + + cfg = EasyConfig(cfg) + self.model = build_model_from_cfg(cfg) + + in_channel = self.model.prediction.head[-1][0].weight.shape[1] + self.model.prediction.head[-1] = self.init_head(in_channel) + + self.dataset_num_points = dataset.dataset_opt.fixed.num_points + self.model_num_points = option.num_points + if self.model_num_points == 1024: + self.point_all = 1200 + elif self.model_num_points == 4096: + self.point_all = 4800 + elif self.model_num_points == 6144: + self.point_all = 6900 + elif self.model_num_points == 8192: + self.point_all = 8192 + elif self.model_num_points == 12288: + self.point_all = 12288 + elif self.model_num_points == 16384: + self.point_all = 16384 + else: + raise NotImplementedError() + self.should_sample = self.model_num_points < self.dataset_num_points + self._supports_mixed = True + + self.head_namespace = option.get("head_namespace", "linears") + self.head_optim_settings = option.get("head_optim_settings", {}) + self.backbone_optim_settings = option.get("backbone_optim_settings", {}) + + def get_parameter_list(self) -> List[dict]: + params_list = [] + head_parameters, backbone_parameters = [], [] + for name, param in self.model.named_parameters(): + if self.head_namespace in name: + head_parameters.append(param) + else: + backbone_parameters.append(param) + params_list.append({"params": head_parameters, **self.head_optim_settings}) + params_list.append({"params": backbone_parameters, **self.backbone_optim_settings}) + + return params_list + + def init_head(self, in_channel): + return SeparateLinear( + in_channel, { + "num_reg_classes": self.num_reg_classes, + "num_mixtures": self.num_mixtures, + "num_cls_classes": self.num_cls_classes + } + ) + + def set_input(self, data, device): + self.data_visual = data + + points = data['pos'].to(device) + points = points.view(-1, self.dataset_num_points, points.shape[-1]) + + features = data['x'].to(device) + features = features.view(-1, self.dataset_num_points, features.shape[-1]) + + # # debug + # from openpoints.dataset import vis_points + # import ipdb; ipdb.set_trace() + # vis_points(data['pos']) + + if self.should_sample: # point resampling strategy + point_all = points.size(1) if points.size(1) < self.point_all else self.point_all + fps_idx = self.furthest_point_sample(points[:, :, :3].contiguous(), point_all) + fps_idx = fps_idx[:, np.random.choice(point_all, self.model_num_points, False)] + points = torch.gather(points, 1, fps_idx.unsqueeze(-1).long().expand(-1, -1, points.shape[-1])) + features = torch.gather(features, 1, fps_idx.unsqueeze(-1).long().expand(-1, -1, features.shape[-1])) + + self.input = {"pos": points, "x": features.transpose(1, 2).contiguous()} + self.batch_idx = data.batch + + if len(self.loss_fns) > 0: + bs = len(data) + if self.has_reg_targets and data.y_reg is not None: + self.reg_y_mask = data.y_reg_mask.to(device).view(bs, -1) + self.reg_y = data.y_reg.to(device).view(bs, -1) + if self.has_mol_targets and data.y_mol is not None: + self.mol_y_mask = data.y_mol_mask.to(device).view(bs, -1) + self.mol_y = data.y_mol.to(device).view(bs, -1) + if self.has_cls_targets and data.y_cls is not None: + self.cls_y_mask = data.y_cls_mask.to(device).view(bs, -1) + self.cls_y = data.y_cls.to(device).view(bs, -1) + + def compute_loss(self): + self.loss = 0 + self.compute_instance_loss() + + def forward(self, *args, **kwargs): + self.output = self.model(self.input) + self.reg_out, self.mol_out, self.cls_out = self.convert_outputs(self.output) + self.compute_loss() + + self.data_visual.pred = self.output + + +class ProjClassifier(nn.Module): + def __init__(self, hidden_dim: int, proj_layers, out_channels: [int, dict], detach_classifier: bool, act_fn, + last_norm: bool): + nn.Module.__init__(self) + sizes = [hidden_dim] + list(proj_layers) + layers = [] + for i in range(len(sizes) - 2): + layers.append(nn.Linear(sizes[i], sizes[i + 1])) + layers.append(FastBatchNorm1d(sizes[i + 1])) + layers.append(act_fn) + layers.append(nn.Linear(sizes[-2], sizes[-1])) + if last_norm: + layers.append(FastBatchNorm1d(sizes[-1], affine=False)) + self.projector = nn.Sequential(*layers) + self.detach_classifier = detach_classifier + + self.classifier = SeparateLinear(hidden_dim, out_channels) + + def forward(self, x: torch.Tensor): + x_ = x.detach() if self.detach_classifier else x + return self.classifier(x_), self.projector(x) + + +class PointNextBarlowTwin(PointNext): + def __init__(self, option, model_type, dataset, modules): + model_version = option.get("model_version", "standard") + self.proj_layers = option.proj_layers + self.proj_last_norm = option.proj_last_norm + self.proj_activation = option.get("proj_activation", None) + if self.proj_activation is None: + self.proj_activation = option.get("activation", "relu") + self.detach_classifier = option.mode != "finetune" and model_version == "standard" + self.reset_output = option.get("reset_output", True) + + super().__init__(option, model_type, dataset, modules) + + self.mode = option.mode + if self.mode not in ["finetune", "freeze"]: + self.loss_names.extend( + ["loss_self_supervised"] + ) + self.scale_loss = option.scale_loss + self.backbone_lr = option.backbone_lr + + def init_head(self, in_channel): + self.act_fn = create_act(self.proj_activation) + return ProjClassifier( + in_channel, self.proj_layers, + { + "num_reg_classes": self.num_reg_classes, + "num_mixtures": self.num_mixtures, + "num_cls_classes": self.num_cls_classes + }, self.detach_classifier, self.act_fn, self.proj_last_norm + ) + + def get_parameter_list(self) -> List[dict]: + params_list = [] + classifier_parameters, model_parameters = [], [] + for name, param in self.model.named_parameters(): + if "prediction.head" in name: + classifier_parameters.append(param) + else: + model_parameters.append(param) + + params_list.append({"params": classifier_parameters}) + if self.mode in ["finetune", "train"]: + model_dict = {"params": model_parameters} + if self.backbone_lr != "base_lr": + model_dict["lr"] = self.backbone_lr + params_list.append(model_dict) + + return params_list + + def set_pretrained_weights(self): + super().set_pretrained_weights() + if self.mode in ["finetune", "freeze"] and self.reset_output: + log.info(f"resetting weights for final prediction layer (since we are in {self.mode} mode)") + for m in self.model.prediction.head[-1].classifier.linears: + m.weight.data.normal_(mean=0.0, std=0.01) + m.bias.data.zero_() + + def set_input(self, data, device): + + points = data['pos'].to(device) + points = points.view(-1, self.dataset_num_points, points.shape[-1]) + + features = data['x'].to(device) + features = features.view(-1, self.dataset_num_points, features.shape[-1]) + + # # debug + # from openpoints.vis3d import vis_points + # vis_points(data['pos'].cpu().numpy()[0]) + # import ipdb; ipdb.set_trace() + + if self.should_sample: # point resampling strategy + point_all = points.size(1) if points.size(1) < self.point_all else self.point_all + fps_idx = self.furthest_point_sample(points[:, :, :3].contiguous(), point_all) + fps_idx = fps_idx[:, np.random.choice(point_all, self.model_num_points, False)] + points = torch.gather(points, 1, fps_idx.unsqueeze(-1).long().expand(-1, -1, points.shape[-1])) + features = torch.gather(features, 1, fps_idx.unsqueeze(-1).long().expand(-1, -1, features.shape[-1])) + + self.batch_idx = data.batch + if self.training and self.double_batch: + self.input = {"pos": points[::2].contiguous(), "x": features[::2].transpose(1, 2).contiguous()} + self.input2 = {"pos": points[1::2].contiguous(), "x": features[1::2].transpose(1, 2).contiguous()} + data = Batch.from_data_list(data.to_data_list()[::2]) + else: + self.input = {"pos": points, "x": features.transpose(1, 2).contiguous()} + self.input2 = None + + self.data_visual = data + + if len(self.loss_fns) > 0: + bs = len(data) + if self.has_reg_targets and data.y_reg is not None: + self.reg_y_mask = data.y_reg_mask.to(device).view(bs, -1) + self.reg_y = data.y_reg.to(device).view(bs, -1) + if self.has_mol_targets and data.y_mol is not None: + self.mol_y_mask = data.y_mol_mask.to(device).view(bs, -1) + self.mol_y = data.y_mol.to(device).view(bs, -1) + if self.has_cls_targets and data.y_cls is not None: + self.cls_y_mask = data.y_cls_mask.to(device).view(bs, -1) + self.cls_y = data.y_cls.to(device).view(bs, -1) + + def compute_loss(self): + self.loss = 0 + self.compute_instance_loss() + + if self.mode not in ["finetune", "freeze"]: + self.compute_self_supervised_loss() + + def compute_self_supervised_loss(self): + # barlow loss + # empirical cross-correlation matrix + self.loss_self_supervised = 0 + if self.training and self.double_batch: + self.loss_self_supervised += barlow_loss( + self.z1, self.z2, self.scale_loss["lambda"] + ) + self.loss += self.scale_loss["all"] * self.loss_self_supervised + + def compute_instance_loss(self): + self.compute_reg_loss() + self.compute_mol_loss() + self.compute_cls_loss() + + def forward_(self, input1, input2): + class_out_1, z1 = self.model(input1) + if self.training and self.mode == "train": + class_out_2, z2 = self.model(input2) + else: + class_out_2, z2 = None, None + + return class_out_1, class_out_2, z1, z2 + + def forward(self, *args, **kwargs): + self.set_mode() + self.output, self.output2, self.z1, self.z2 = self.forward_(self.input, self.input2) + self.reg_out, self.mol_out, self.cls_out = self.convert_outputs(self.output) + self.reg_out2, self.mol_out2, self.cls_out2 = self.convert_outputs(self.output2) + + self.compute_loss() + self.data_visual.pred = self.output + + def set_mode(self): + if self.training: + if self.mode == "freeze": + self.model.requires_grad_(False) + self.model.prediction.head[-1].requires_grad_(True) + self.model.eval() + self.model.prediction.head[-1].train() + + +class PointNextVICReg(PointNextBarlowTwin): + + def __init__(self, option, model_type, dataset, modules): + super(PointNextVICReg, self).__init__(option, model_type, dataset, modules) + + if self.mode not in ["finetune", "freeze"]: + self.loss_names.extend( + ["loss_invariance", "loss_variance", "loss_covariance"] + ) + + def compute_self_supervised_loss(self): + # barlow loss + # empirical cross-correlation matrix + self.loss_self_supervised = 0 + if self.training and self.mode == "train": + # from https://github.com/vturrisi/solo-learn/blob/6f19d5dc38fb6521e7fdd6aed5ac4a30ef8f3bd8/solo/losses/vicreg.py#L83 + z1, z2 = self.z1, self.z2 + # invariance loss + self.loss_invariance = invariance_loss(z1, z2) + + # vicreg's official code gathers the tensors here + # https://github.com/facebookresearch/vicreg/blob/main/main_vicreg.py + z1, z2 = gather(z1), gather(z2) + + # variance_loss + self.loss_variance = variance_loss(z1, z2) + self.loss_covariance = covariance_loss(z1, z2) + loss = self.scale_loss["invariance"] * self.loss_invariance + \ + self.scale_loss["variance"] * self.loss_variance + \ + self.scale_loss["covariance"] * self.loss_covariance + + self.loss_self_supervised += loss + self.loss += self.loss_self_supervised diff --git a/torch-points3d/torch_points3d/models/instance/simplestnet.py b/torch-points3d/torch_points3d/models/instance/simplestnet.py new file mode 100755 index 0000000..1248c04 --- /dev/null +++ b/torch-points3d/torch_points3d/models/instance/simplestnet.py @@ -0,0 +1,107 @@ +import logging +from typing import List + +import torch +import torch.nn.functional as F +from torch import nn + +from torch_points3d.core.common_modules import FastBatchNorm1d +from torch_points3d.models.instance.base import InstanceBase + +log = logging.getLogger(__name__) + + +class SeparateLinear(torch.nn.Module): + + def __init__(self, in_channel, num_reg_classes, num_mixtures, num_cls_classes): + super(SeparateLinear, self).__init__() + self.linears = [] + if num_reg_classes > 0: + self.linears += [torch.nn.Linear(in_channel, 1, bias=True) for i in range(num_reg_classes)] + if len(num_mixtures) > 0: + self.linears += [ + torch.nn.Linear(in_channel, num_mixtures * 3, bias=True) for i, num_mixtures in + enumerate(num_mixtures) + ] + if len(num_cls_classes) > 0: + self.linears += [ + torch.nn.Linear(in_channel, num_classes) for num_classes in num_cls_classes + ] + + self.linears = torch.nn.ModuleList(self.linears) + + def forward(self, x): + return torch.cat([lin(x) for lin in self.linears], 1) + + +class SimplestNet(InstanceBase): + def __init__(self, option, model_type, dataset, modules): + super(SimplestNet, self).__init__(option, model_type, dataset, modules) + self.model = nn.Sequential( + nn.Conv1d(dataset.feature_dimension + 3, 64, 1), + nn.GELU(), + nn.BatchNorm1d(64), + nn.Conv1d(64, 128, 1), + nn.GELU(), + nn.BatchNorm1d(128), + nn.Conv1d(128, 128, 1), + nn.GELU(), + nn.BatchNorm1d(128), + ) + self.head = SeparateLinear(128, self.num_reg_classes, self.num_mixtures, self.num_cls_classes) + self.dataset_num_points = dataset.dataset_opt.fixed.num_points + self._supports_mixed = True + + self.head_namespace = option.get("head_namespace", "head.linears") + self.head_optim_settings = option.get("head_optim_settings", {}) + self.backbone_optim_settings = option.get("backbone_optim_settings", {}) + + def get_parameter_list(self) -> List[dict]: + params_list = [] + head_parameters, backbone_parameters = [], [] + for name, param in self.model.named_parameters(): + if self.head_namespace in name: + head_parameters.append(param) + else: + backbone_parameters.append(param) + params_list.append({"params": head_parameters, **self.head_optim_settings}) + params_list.append({"params": backbone_parameters, **self.backbone_optim_settings}) + + return params_list + + def set_input(self, data, device): + self.data_visual = data + points = data['pos'].to(device) + points = points.view(-1, self.dataset_num_points, points.shape[-1]) + + features = data['x'].to(device) + features = features.view(-1, self.dataset_num_points, features.shape[-1]) + + self.input = torch.cat([features, points], 2).moveaxis(2, 1) + self.batch_idx = data.batch + + if len(self.loss_fns) > 0: + bs = len(data) + if self.has_reg_targets and data.y_reg is not None: + self.reg_y_mask = data.y_reg_mask.to(device).view(bs, -1) + self.reg_y = data.y_reg.to(device).view(bs, -1) + if self.has_mol_targets and data.y_mol is not None: + self.mol_y_mask = data.y_mol_mask.to(device).view(bs, -1) + self.mol_y = data.y_mol.to(device).view(bs, -1) + if self.has_cls_targets and data.y_cls is not None: + self.cls_y_mask = data.y_cls_mask.to(device).view(bs, -1) + self.cls_y = data.y_cls.to(device).view(bs, -1) + + def compute_loss(self): + self.loss = 0 + self.compute_instance_loss() + + def forward(self, *args, **kwargs): + x = self.model(self.input) + x = F.adaptive_avg_pool1d(x, 1).squeeze(2) + self.output = self.head(x) + + self.reg_out, self.mol_out, self.cls_out = self.convert_outputs(self.output) + self.compute_loss() + + self.data_visual.pred = self.output diff --git a/torch-points3d/torch_points3d/models/instance/sparseconv3d.py b/torch-points3d/torch_points3d/models/instance/sparseconv3d.py new file mode 100644 index 0000000..b09517b --- /dev/null +++ b/torch-points3d/torch_points3d/models/instance/sparseconv3d.py @@ -0,0 +1,60 @@ +import logging + +from torch import nn +import torch + +from torch_points3d.models.instance.base import InstanceBase +from torch_points3d.models.regression.minkowski import SeparateLinear +from torch_points3d.modules.SparseConv3d.SENet import ResNetBase, NETWORK_CONFIGS + +log = logging.getLogger(__name__) + + +class SeparateLinear(nn.Module): + def __init__(self, in_channel, out_channels): + super(SeparateLinear, self).__init__() + self.linears = nn.ModuleList([nn.Linear(in_channel, 1, bias=True) for i in range(out_channels)]) + + def forward(self, x): + return torch.cat([lin(x) for lin in self.linears], 1) + + +class ResNetModel(InstanceBase): + def __init__(self, option, model_type, dataset, modules): + # call the initialization method + super().__init__(option, model_type, dataset, modules) + self.model = ResNetBase( + dataset.feature_dimension, dataset.num_classes, activation=option.activation, + first_stride=option.first_stride, dropout=option.dropout, global_pool=option.global_pool, + backend=option.backend, **NETWORK_CONFIGS[option.model_name]) + + in_channel = self.model.final.weight.shape[1] + out_channel = self.model.final.weight.shape[0] + self.model.final = SeparateLinear(in_channel, out_channel) + self._supports_mixed = self.model.snn.name == "torchsparse" + + def set_input(self, data, device): + self.batch_idx = data.batch.squeeze() + self.input = self.model.snn.SparseTensor(data.x, data.coords, data.batch, device) + if data.y is not None: + self.labels = data.y.to(device) + else: + self.labels = None + + self.data_visual = data + + def compute_loss(self): + self.loss_regr = 0 + labels = self.labels.view(self.output.shape) + for loss_fn in self.loss_fns: + self.loss_regr += (loss_fn(self.output, labels, reduction="none") / self.scale_targets).mean() + + self.loss_regr += self.get_internal_loss() + self.loss = self.loss_regr + + def forward(self, *args, **kwargs): + self.output = self.model(self.input) + if self.labels is not None: + self.compute_loss() + + self.data_visual.pred = self.output diff --git a/torch-points3d/torch_points3d/models/model_factory.py b/torch-points3d/torch_points3d/models/model_factory.py new file mode 100644 index 0000000..188c273 --- /dev/null +++ b/torch-points3d/torch_points3d/models/model_factory.py @@ -0,0 +1,44 @@ +import importlib + +from torch_points3d.utils.model_building_utils.model_definition_resolver import resolve_model +from .base_model import BaseModel + + +def instantiate_model(config, dataset) -> BaseModel: + """ Creates a model given a dataset and a training config. The config should contain the following: + - config.data.task: task that will be evaluated + - config.model_name: model to instantiate + - config.models: All models available + """ + + # Get task and model_name + task = config.data.task + tested_model_name = config.model_name + + # Find configs + models = config.get('models') + model_config = getattr(models, tested_model_name, None) + if model_config is None: + models_keys = models.keys() if models is not None else "" + raise Exception("The model_name {} isn t within {}".format(tested_model_name, list(models_keys))) + resolve_model(model_config, dataset, task) + + model_class = getattr(model_config, "class") + model_paths = model_class.split(".") + module = ".".join(model_paths[:-1]) + class_name = model_paths[-1] + model_module = ".".join(["torch_points3d.models", task, module]) + modellib = importlib.import_module(model_module) + + model_cls = None + for name, cls in modellib.__dict__.items(): + if name.lower() == class_name.lower(): + model_cls = cls + + if model_cls is None: + raise NotImplementedError( + "In %s.py, there should be a subclass of BaseDataset with class name that matches %s in lowercase." + % (model_module, class_name) + ) + model = model_cls(model_config, "dummy", dataset, modellib) + return model diff --git a/torch-points3d/torch_points3d/models/model_interface.py b/torch-points3d/torch_points3d/models/model_interface.py new file mode 100644 index 0000000..d5c44b7 --- /dev/null +++ b/torch-points3d/torch_points3d/models/model_interface.py @@ -0,0 +1,111 @@ +from abc import abstractmethod, abstractproperty, ABC + + +class CheckpointInterface(ABC): + """This class is a minimal interface class for models. + """ + + @abstractproperty # type: ignore + def schedulers(self): + pass + + @schedulers.setter + def schedulers(self, schedulers): + pass + + @abstractproperty # type: ignore + def optimizer(self): + pass + + @optimizer.setter + def optimizer(self, optimizer): + pass + + @abstractmethod + def state_dict(self): + pass + + @abstractmethod + def load_state_dict(self, state, strict=False): + pass + + +class DatasetInterface(ABC): + @abstractproperty + def conv_type(self): + pass + + def get_spatial_ops(self): + pass + + +class TrackerInterface(ABC): + @property + @abstractmethod + def conv_type(self): + pass + + @abstractmethod + def get_labels(self): + """ returns a tensor of size ``[N_points]`` where each value is the label of a point + """ + + @abstractmethod + def get_batch(self): + """ returns a tensor of size ``[N_points]`` where each value is the batch index of a point + """ + + @abstractmethod + def get_output(self): + """ returns a tensor of size ``[N_points,...]`` where each value is the output + of the network for a point (output of the last layer in general) + """ + + @abstractmethod + def get_input(self): + """ returns the last input that was given to the model or raises error + """ + + @abstractmethod + def get_current_losses(self): + """Return training losses / errors. train.py will print out these errors on console""" + + @abstractproperty + def device(self): + """ Returns the device onto which the model leaves (cpu or gpu) + """ + + +class InstanceTrackerInterface(TrackerInterface): + + @abstractmethod + def get_reg_output(self): + """ returns a tensor of size ``[N_points,...]`` where each value is the regression output + of the network for a point (output of the last layer in general) + """ + + @abstractmethod + def get_mol_output(self): + """ returns a tensor of size ``[N_points,...]`` where each value is the mixture of logits output + of the network for a point (output of the last layer in general) + """ + @abstractmethod + def get_cls_output(self): + """ returns a tensor of size ``[N_points,...]`` where each value is the classification output + of the network for a point (output of the last layer in general) + """ + + @abstractmethod + def get_reg_input(self): + """ returns the last regression input that was given to the model or raises error + """ + + @abstractmethod + def get_mol_input(self): + """ returns the last mixture of logits input that was given to the model or raises error + """ + + @abstractmethod + def get_cls_input(self): + """ returns the last classification input that was given to the model or raises error + """ \ No newline at end of file diff --git a/torch-points3d/torch_points3d/modules/KPConv/__init__.py b/torch-points3d/torch_points3d/modules/KPConv/__init__.py new file mode 100644 index 0000000..86e781c --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/__init__.py @@ -0,0 +1 @@ +# from https://github.com/HuguesTHOMAS/KPConv-PyTorch \ No newline at end of file diff --git a/torch-points3d/torch_points3d/modules/KPConv/architectures.py b/torch-points3d/torch_points3d/modules/KPConv/architectures.py new file mode 100644 index 0000000..d14450d --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/architectures.py @@ -0,0 +1,335 @@ +# +# +# 0=================================0 +# | Kernel Point Convolutions | +# 0=================================0 +# +# +# ---------------------------------------------------------------------------------------------------------------------- +# +# Define network architectures +# +# ---------------------------------------------------------------------------------------------------------------------- +# +# Hugues THOMAS - 06/03/2020 +# +from functools import partial + +from .blocks import * +import numpy as np + +ACTIVATIONS = { + "relu": partial(nn.ReLU, inplace=True), + "celu": partial(nn.CELU, inplace=True, alpha=0.54), + "silu": partial(nn.SiLU, inplace=True), + "swish": partial(nn.SiLU, inplace=True), + "elu": partial(nn.ELU, inplace=True, alpha=0.54), + "sigmoid": partial(nn.Sigmoid), + "tanh": partial(nn.Tanh), + "gelu": partial(nn.GELU), +} + +def p2p_fitting_regularizer(net): + fitting_loss = 0 + repulsive_loss = 0 + + for m in net.modules(): + + if isinstance(m, KPConv) and m.deformable: + + ############## + # Fitting loss + ############## + + # Get the distance to closest input point and normalize to be independant from layers + KP_min_d2 = m.min_d2 / (m.KP_extent ** 2) + + # Loss will be the square distance to closest input point. We use L1 because dist is already squared + fitting_loss += net.l1(KP_min_d2, torch.zeros_like(KP_min_d2)) + + ################ + # Repulsive loss + ################ + + # Normalized KP locations + KP_locs = m.deformed_KP / m.KP_extent + + # Point should not be close to each other + for i in range(net.K): + other_KP = torch.cat([KP_locs[:, :i, :], KP_locs[:, i + 1:, :]], dim=1).detach() + distances = torch.sqrt(torch.sum((other_KP - KP_locs[:, i:i + 1, :]) ** 2, dim=2)) + rep_loss = torch.sum(torch.clamp_max(distances - net.repulse_extent, max=0.0) ** 2, dim=1) + repulsive_loss += net.l1(rep_loss, torch.zeros_like(rep_loss)) / net.K + + return net.deform_fitting_power * (2 * fitting_loss + repulsive_loss) + + +class KPCNN(nn.Module): + """ + Class defining KPCNN + """ + + def __init__(self, config): + super(KPCNN, self).__init__() + + ##################### + # Network operations + ##################### + + # Current radius of convolution and feature dimension + layer = 0 + r = config.first_subsampling_dl * config.conv_radius + in_dim = config.in_features_dim + out_dim = config.first_features_dim + act_fn = ACTIVATIONS[config.activation] + self.act_fn = act_fn + self.K = config.num_kernel_points + + # Save all block operations in a list of modules + self.block_ops = nn.ModuleList() + + # Loop over consecutive blocks + block_in_layer = 0 + for block_i, block in enumerate(config.architecture): + + # Check equivariance + if ('equivariant' in block) and (not out_dim % 3 == 0): + raise ValueError('Equivariant block but features dimension is not a factor of 3') + + # Detect upsampling block to stop + if 'upsample' in block: + break + + # Apply the good block function defining tf ops + self.block_ops.append(block_decider( + block, r, in_dim, out_dim, layer, act_fn, config + )) + + # Index of block in this layer + block_in_layer += 1 + + # Update dimension of input from output + if 'simple' in block: + in_dim = out_dim // 2 + else: + in_dim = out_dim + + # Detect change to a subsampled layer + if 'pool' in block or 'strided' in block: + # Update radius and feature dimension for next layer + layer += 1 + r *= 2 + out_dim *= 2 + block_in_layer = 0 + + self.head_mlp = UnaryBlock(out_dim, 1024, act_fn, False, 0) + + ################ + # Network Losses + ################ + + self.deform_fitting_mode = config.deform_fitting_mode + self.deform_fitting_power = config.deform_fitting_power + self.deform_lr_factor = config.deform_lr_factor + self.repulse_extent = config.repulse_extent + self.reg_loss = 0 + self.l1 = nn.L1Loss() + + return + + def forward(self, batch): + # Save all block operations in a list of modules + x = batch.features.clone().detach() + + # Loop over consecutive blocks + for block_op in self.block_ops: + x = block_op(x, batch) + + # Head of network + x = self.head_mlp(x, batch) + + return x + + def internal_loss(self): + """ + Runs the internal loss on outputs of the model + :return: loss + """ + + # Regularization of deformable offsets + if self.deform_fitting_mode == 'point2point': + self.reg_loss = p2p_fitting_regularizer(self) + elif self.deform_fitting_mode == 'point2plane': + raise ValueError('point2plane fitting mode not implemented yet.') + else: + raise ValueError('Unknown fitting mode: ' + self.deform_fitting_mode) + + # Combined loss + return self.reg_loss + + +class KPFCNN(nn.Module): + """ + Class defining KPFCNN + """ + + def __init__(self, config, lbl_values, ign_lbls): + super(KPFCNN, self).__init__() + + ############ + # Parameters + ############ + + # Current radius of convolution and feature dimension + layer = 0 + r = config.first_subsampling_dl * config.conv_radius + in_dim = config.in_features_dim + out_dim = config.first_features_dim + act_fn = config.act_fn + self.act_fn = act_fn + self.K = config.num_kernel_points + self.C = len(lbl_values) - len(ign_lbls) + + ##################### + # List Encoder blocks + ##################### + + # Save all block operations in a list of modules + self.encoder_blocks = nn.ModuleList() + self.encoder_skip_dims = [] + self.encoder_skips = [] + + # Loop over consecutive blocks + for block_i, block in enumerate(config.architecture): + + # Check equivariance + if ('equivariant' in block) and (not out_dim % 3 == 0): + raise ValueError('Equivariant block but features dimension is not a factor of 3') + + # Detect change to next layer for skip connection + if np.any([tmp in block for tmp in ['pool', 'strided', 'upsample', 'global']]): + self.encoder_skips.append(block_i) + self.encoder_skip_dims.append(in_dim) + + # Detect upsampling block to stop + if 'upsample' in block: + break + + # Apply the good block function defining tf ops + self.encoder_blocks.append(block_decider( + block, r, in_dim, out_dim, layer, act_fn, config + )) + + # Update dimension of input from output + if 'simple' in block: + in_dim = out_dim // 2 + else: + in_dim = out_dim + + # Detect change to a subsampled layer + if 'pool' in block or 'strided' in block: + # Update radius and feature dimension for next layer + layer += 1 + r *= 2 + out_dim *= 2 + + ##################### + # List Decoder blocks + ##################### + + # Save all block operations in a list of modules + self.decoder_blocks = nn.ModuleList() + self.decoder_concats = [] + + # Find first upsampling block + start_i = 0 + for block_i, block in enumerate(config.architecture): + if 'upsample' in block: + start_i = block_i + break + + # Loop over consecutive blocks + for block_i, block in enumerate(config.architecture[start_i:]): + + # Add dimension of skip connection concat + if block_i > 0 and 'upsample' in config.architecture[start_i + block_i - 1]: + in_dim += self.encoder_skip_dims[layer] + self.decoder_concats.append(block_i) + + # Apply the good block function defining tf ops + self.decoder_blocks.append(block_decider( + block, r, in_dim, out_dim, layer, act_fn, config + )) + + # Update dimension of input from output + in_dim = out_dim + + # Detect change to a subsampled layer + if 'upsample' in block: + # Update radius and feature dimension for next layer + layer -= 1 + r *= 0.5 + out_dim = out_dim // 2 + + self.head_mlp = UnaryBlock(out_dim, config.first_features_dim, act_fn, False, 0) + + ################ + # Network Losses + ################ + + # List of valid labels (those not ignored in loss) + self.valid_labels = np.sort([c for c in lbl_values if c not in ign_lbls]) + + # Choose segmentation loss + if len(config.class_w) > 0: + class_w = torch.from_numpy(np.array(config.class_w, dtype=np.float32)) + self.criterion = torch.nn.CrossEntropyLoss(weight=class_w, ignore_index=-1) + else: + self.criterion = torch.nn.CrossEntropyLoss(ignore_index=-1) + self.deform_fitting_mode = config.deform_fitting_mode + self.deform_fitting_power = config.deform_fitting_power + self.deform_lr_factor = config.deform_lr_factor + self.repulse_extent = config.repulse_extent + self.reg_loss = 0 + self.l1 = nn.L1Loss() + + return + + def forward(self, batch): + + # Get input features + x = batch.features.clone().detach() + + # Loop over consecutive blocks + skip_x = [] + for block_i, block_op in enumerate(self.encoder_blocks): + if block_i in self.encoder_skips: + skip_x.append(x) + x = block_op(x, batch) + + for block_i, block_op in enumerate(self.decoder_blocks): + if block_i in self.decoder_concats: + x = torch.cat([x, skip_x.pop()], dim=1) + x = block_op(x, batch) + + # Head of network + x = self.head_mlp(x, batch) + + return x + + def internal_loss(self, ): + """ + Runs the internal loss on outputs of the model + :return: loss + """ + + # Regularization of deformable offsets + if self.deform_fitting_mode == 'point2point': + self.reg_loss = p2p_fitting_regularizer(self) + elif self.deform_fitting_mode == 'point2plane': + raise ValueError('point2plane fitting mode not implemented yet.') + else: + raise ValueError('Unknown fitting mode: ' + self.deform_fitting_mode) + + # Combined loss + return self.reg_loss diff --git a/torch-points3d/torch_points3d/modules/KPConv/blocks.py b/torch-points3d/torch_points3d/modules/KPConv/blocks.py new file mode 100644 index 0000000..aba4a47 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/blocks.py @@ -0,0 +1,738 @@ +# +# +# 0=================================0 +# | Kernel Point Convolutions | +# 0=================================0 +# +# +# ---------------------------------------------------------------------------------------------------------------------- +# +# Define network blocks +# +# ---------------------------------------------------------------------------------------------------------------------- +# +# Hugues THOMAS - 06/03/2020 +# + + +import math + +import torch +import torch.nn as nn +from torch.nn.init import kaiming_uniform_ +from torch.nn.parameter import Parameter + +from .kernel_points import load_kernels + + +# ---------------------------------------------------------------------------------------------------------------------- +# +# Simple functions +# \**********************/ +# + + +@torch.jit.script +def gather(x: torch.Tensor, idx: torch.Tensor, method: int = 2): + """ + implementation of a custom gather operation for faster backwards. + :param x: input with shape [N, D_1, ... D_d] + :param idx: indexing with shape [n_1, ..., n_m] + :param method: Choice of the method + :return: x[idx] with shape [n_1, ..., n_m, D_1, ... D_d] + """ + + if method == 0: + return x[idx] + elif method == 1: + x = x.unsqueeze(1) + x = x.expand((-1, idx.shape[-1], -1)) + idx = idx.unsqueeze(2) + idx = idx.expand((-1, -1, x.shape[-1])) + return x.gather(0, idx) + elif method == 2: + for i, ni in enumerate(idx.size()[1:]): + x = x.unsqueeze(i + 1) + new_s = list(x.size()) + new_s[i + 1] = ni + x = x.expand(new_s) + n = len(idx.size()) + for i, di in enumerate(x.size()[n:]): + idx = idx.unsqueeze(i + n) + new_s = list(idx.size()) + new_s[i + n] = di + idx = idx.expand(new_s) + return x.gather(0, idx) + else: + raise ValueError('Unkown method') + + +@torch.jit.script +def radius_gaussian(sq_r: torch.Tensor, sig: float, eps: float = 1e-9): + """ + Compute a radius gaussian (gaussian of distance) + :param sq_r: input radiuses [dn, ..., d1, d0] + :param sig: extents of gaussians [d1, d0] or [d0] or float + :return: gaussian of sq_r [dn, ..., d1, d0] + """ + return torch.exp(-sq_r / (2 * sig ** 2 + eps)) + + +@torch.jit.script +def closest_pool(x: torch.Tensor, inds: torch.Tensor): + """ + Pools features from the closest neighbors. WARNING: this function assumes the neighbors are ordered. + :param x: [n1, d] features matrix + :param inds: [n2, max_num] Only the first column is used for pooling + :return: [n2, d] pooled features matrix + """ + + # Add a last row with minimum features for shadow pools + x = torch.cat((x, torch.zeros_like(x[:1, :])), 0) + + # Get features for each pooling location [n2, d] + return gather(x, inds[:, 0]) + + +@torch.jit.script +def max_pool(x: torch.Tensor, inds: torch.Tensor): + """ + Pools features with the maximum values. + :param x: [n1, d] features matrix + :param inds: [n2, max_num] pooling indices + :return: [n2, d] pooled features matrix + """ + + # Add a last row with minimum features for shadow pools + x = torch.cat((x, torch.zeros_like(x[:1, :])), 0) + + # Get all features for each pooling location [n2, max_num, d] + pool_features = gather(x, inds) + + # Pool the maximum [n2, d] + max_features, _ = torch.max(pool_features, 1) + return max_features + + +@torch.jit.script +def global_average(x: torch.Tensor, batch_lengths: torch.Tensor): + """ + Block performing a global average over batch pooling + :param x: [N, D] input features + :param batch_lengths: [B] list of batch lengths + :return: [B, D] averaged features + """ + + # Loop over the clouds of the batch + averaged_features = [] + i0 = 0 + for b_i, length in enumerate(batch_lengths): + # Average features for each batch cloud + averaged_features.append(torch.mean(x[i0:i0 + length], dim=0)) + + # Increment for next cloud + i0 += length + + # Average features in each batch + return torch.stack(averaged_features) + + +@torch.jit.script +def global_sum(x: torch.Tensor, batch_lengths: torch.Tensor): + """ + Block performing a global average over batch pooling + :param x: [N, D] input features + :param batch_lengths: [B] list of batch lengths + :return: [B, D] averaged features + """ + + # Loop over the clouds of the batch + averaged_features = [] + i0 = 0 + for b_i, length in enumerate(batch_lengths): + # Average features for each batch cloud + averaged_features.append(torch.sum(x[i0:i0 + length], dim=0)) + + # Increment for next cloud + i0 += length + + # Average features in each batch + return torch.stack(averaged_features) + + +# ---------------------------------------------------------------------------------------------------------------------- +# +# KPConv class +# \******************/ +# + + +class KPConv(nn.Module): + + def __init__(self, kernel_size, p_dim, in_channels, out_channels, KP_extent, radius, + fixed_kernel_points='center', KP_influence='linear', aggregation_mode='sum', + deformable=False, modulated=False): + """ + Initialize parameters for KPConvDeformable. + :param kernel_size: Number of kernel points. + :param p_dim: dimension of the point space. + :param in_channels: dimension of input features. + :param out_channels: dimension of output features. + :param KP_extent: influence radius of each kernel point. + :param radius: radius used for kernel point init. Even for deformable, use the config.conv_radius + :param fixed_kernel_points: fix position of certain kernel points ('none', 'center' or 'verticals'). + :param KP_influence: influence function of the kernel points ('constant', 'linear', 'gaussian'). + :param aggregation_mode: choose to sum influences, or only keep the closest ('closest', 'sum'). + :param deformable: choose deformable or not + :param modulated: choose if kernel weights are modulated in addition to deformed + """ + super(KPConv, self).__init__() + + # Save parameters + self.K = kernel_size + self.p_dim = p_dim + self.in_channels = in_channels + self.out_channels = out_channels + self.radius = radius + self.KP_extent = KP_extent + self.fixed_kernel_points = fixed_kernel_points + self.KP_influence = KP_influence + self.aggregation_mode = aggregation_mode + self.deformable = deformable + self.modulated = modulated + + # Running variable containing deformed KP distance to input points. (used in regularization loss) + self.min_d2 = None + self.deformed_KP = None + self.offset_features = None + + # Initialize weights + self.weights = Parameter(torch.zeros((self.K, in_channels, out_channels), dtype=torch.float32), + requires_grad=True) + + # Initiate weights for offsets + if deformable: + if modulated: + self.offset_dim = (self.p_dim + 1) * self.K + else: + self.offset_dim = self.p_dim * self.K + self.offset_conv = KPConv(self.K, + self.p_dim, + self.in_channels, + self.offset_dim, + KP_extent, + radius, + fixed_kernel_points=fixed_kernel_points, + KP_influence=KP_influence, + aggregation_mode=aggregation_mode) + self.offset_bias = Parameter(torch.zeros(self.offset_dim, dtype=torch.float32), requires_grad=True) + + else: + self.offset_dim = None + self.offset_conv = None + self.offset_bias = None + + # Reset parameters + self.reset_parameters() + + # Initialize kernel points + self.kernel_points = self.init_KP() + + return + + def reset_parameters(self): + kaiming_uniform_(self.weights, a=math.sqrt(5)) + if self.deformable: + nn.init.zeros_(self.offset_bias) + return + + def init_KP(self): + """ + Initialize the kernel point positions in a sphere + :return: the tensor of kernel points + """ + + # Create one kernel disposition (as numpy array). Choose the KP distance to center thanks to the KP extent + K_points_numpy = load_kernels(self.radius, + self.K, + dimension=self.p_dim, + fixed=self.fixed_kernel_points) + + return Parameter(torch.tensor(K_points_numpy, dtype=torch.float32), + requires_grad=False) + + def forward(self, q_pts, s_pts, neighb_inds, x): + + ################### + # Offset generation + ################### + + if self.deformable: + + # Get offsets with a KPConv that only takes part of the features + self.offset_features = self.offset_conv(q_pts, s_pts, neighb_inds, x) + self.offset_bias + + if self.modulated: + + # Get offset (in normalized scale) from features + unscaled_offsets = self.offset_features[:, :self.p_dim * self.K] + unscaled_offsets = unscaled_offsets.view(-1, self.K, self.p_dim) + + # Get modulations + modulations = 2 * torch.sigmoid(self.offset_features[:, self.p_dim * self.K:]) + + else: + + # Get offset (in normalized scale) from features + unscaled_offsets = self.offset_features.view(-1, self.K, self.p_dim) + + # No modulations + modulations = None + + # Rescale offset for this layer + offsets = unscaled_offsets * self.KP_extent + + else: + offsets = None + modulations = None + + ###################### + # Deformed convolution + ###################### + + # Add a fake point in the last row for shadow neighbors + s_pts = torch.cat((s_pts, torch.zeros_like(s_pts[:1, :]) + 1e6), 0) + + # Get neighbor points [n_points, n_neighbors, dim] + neighbors = s_pts[neighb_inds, :] + + # Center every neighborhood + neighbors = neighbors - q_pts.unsqueeze(1) + + # Apply offsets to kernel points [n_points, n_kpoints, dim] + if self.deformable: + self.deformed_KP = offsets + self.kernel_points + deformed_K_points = self.deformed_KP.unsqueeze(1) + else: + deformed_K_points = self.kernel_points + + # Get all difference matrices [n_points, n_neighbors, n_kpoints, dim] + neighbors.unsqueeze_(2) + differences = neighbors - deformed_K_points + + # Get the square distances [n_points, n_neighbors, n_kpoints] + sq_distances = torch.sum(differences ** 2, dim=3) + + # Optimization by ignoring points outside a deformed KP range + if self.deformable: + + # Save distances for loss + self.min_d2, _ = torch.min(sq_distances, dim=1) + + # Boolean of the neighbors in range of a kernel point [n_points, n_neighbors] + in_range = torch.any(sq_distances < self.KP_extent ** 2, dim=2).type(torch.int32) + + # New value of max neighbors + new_max_neighb = torch.max(torch.sum(in_range, dim=1)) + + # For each row of neighbors, indices of the ones that are in range [n_points, new_max_neighb] + neighb_row_bool, neighb_row_inds = torch.topk(in_range, new_max_neighb.item(), dim=1) + + # Gather new neighbor indices [n_points, new_max_neighb] + new_neighb_inds = neighb_inds.gather(1, neighb_row_inds, sparse_grad=False) + + # Gather new distances to KP [n_points, new_max_neighb, n_kpoints] + neighb_row_inds.unsqueeze_(2) + neighb_row_inds = neighb_row_inds.expand(-1, -1, self.K) + sq_distances = sq_distances.gather(1, neighb_row_inds, sparse_grad=False) + + # New shadow neighbors have to point to the last shadow point + new_neighb_inds *= neighb_row_bool + new_neighb_inds -= (neighb_row_bool.type(torch.int64) - 1) * int(s_pts.shape[0] - 1) + else: + new_neighb_inds = neighb_inds + + # Get Kernel point influences [n_points, n_kpoints, n_neighbors] + if self.KP_influence == 'constant': + # Every point get an influence of 1. + all_weights = torch.ones_like(sq_distances) + all_weights = torch.transpose(all_weights, 1, 2) + + elif self.KP_influence == 'linear': + # Influence decrease linearly with the distance, and get to zero when d = KP_extent. + all_weights = torch.clamp(1 - torch.sqrt(sq_distances) / self.KP_extent, min=0.0) + all_weights = torch.transpose(all_weights, 1, 2) + + elif self.KP_influence == 'gaussian': + # Influence in gaussian of the distance. + sigma = self.KP_extent * 0.3 + all_weights = radius_gaussian(sq_distances, sigma) + all_weights = torch.transpose(all_weights, 1, 2) + else: + raise ValueError('Unknown influence function type (config.KP_influence)') + + # In case of closest mode, only the closest KP can influence each point + if self.aggregation_mode == 'closest': + neighbors_1nn = torch.argmin(sq_distances, dim=2) + all_weights *= torch.transpose(nn.functional.one_hot(neighbors_1nn, self.K), 1, 2) + + elif self.aggregation_mode != 'sum': + raise ValueError("Unknown convolution mode. Should be 'closest' or 'sum'") + + # Add a zero feature for shadow neighbors + x = torch.cat((x, torch.zeros_like(x[:1, :])), 0) + + # Get the features of each neighborhood [n_points, n_neighbors, in_fdim] + neighb_x = gather(x, new_neighb_inds) + + # Apply distance weights [n_points, n_kpoints, in_fdim] + weighted_features = torch.matmul(all_weights, neighb_x) + + # Apply modulations + if self.deformable and self.modulated: + weighted_features *= modulations.unsqueeze(2) + + # Apply network weights [n_kpoints, n_points, out_fdim] + weighted_features = weighted_features.permute((1, 0, 2)) + kernel_outputs = torch.matmul(weighted_features, self.weights) + + # Convolution sum [n_points, out_fdim] + return torch.sum(kernel_outputs, dim=0) + + def __repr__(self): + return 'KPConv(radius: {:.2f}, in_feat: {:d}, out_feat: {:d})'.format(self.radius, + self.in_channels, + self.out_channels) + + +# ---------------------------------------------------------------------------------------------------------------------- +# +# Complex blocks +# \********************/ +# + +def block_decider(block_name, + radius, + in_dim, + out_dim, + layer_ind, + act_fn, + config): + if block_name == 'unary': + return UnaryBlock(in_dim, out_dim, act_fn, config.use_batch_norm, config.batch_norm_momentum) + + elif block_name in ['simple', + 'simple_deformable', + 'simple_invariant', + 'simple_equivariant', + 'simple_strided', + 'simple_deformable_strided', + 'simple_invariant_strided', + 'simple_equivariant_strided']: + return SimpleBlock(block_name, in_dim, out_dim, radius, layer_ind, act_fn, config) + + elif block_name in ['resnetb', + 'resnetb_invariant', + 'resnetb_equivariant', + 'resnetb_deformable', + 'resnetb_strided', + 'resnetb_deformable_strided', + 'resnetb_equivariant_strided', + 'resnetb_invariant_strided']: + return ResnetBottleneckBlock(block_name, in_dim, out_dim, radius, layer_ind, act_fn, config) + + elif block_name == 'max_pool' or block_name == 'max_pool_wide': + return MaxPoolBlock(layer_ind) + + elif block_name == 'global_average': + return GlobalAverageBlock() + + elif block_name == 'global_sum': + return GlobalSumBlock() + + elif block_name == 'nearest_upsample': + return NearestUpsampleBlock(layer_ind) + + else: + raise ValueError('Unknown block name in the architecture definition : ' + block_name) + + +class BatchNormBlock(nn.Module): + + def __init__(self, in_dim, use_bn, bn_momentum): + """ + Initialize a batch normalization block. If network does not use batch normalization, replace with biases. + :param in_dim: dimension input features + :param use_bn: boolean indicating if we use Batch Norm + :param bn_momentum: Batch norm momentum + """ + super(BatchNormBlock, self).__init__() + self.bn_momentum = bn_momentum + self.use_bn = use_bn + self.in_dim = in_dim + if self.use_bn: + self.batch_norm = nn.BatchNorm1d(in_dim, momentum=bn_momentum) + else: + self.bias = Parameter(torch.zeros(in_dim, dtype=torch.float32), requires_grad=True) + return + + def reset_parameters(self): + nn.init.zeros_(self.bias) + + def forward(self, x): + if self.use_bn: + + x = x.unsqueeze(2) + x = x.transpose(0, 2) + x = self.batch_norm(x) + x = x.transpose(0, 2) + return x.squeeze() + else: + return x + self.bias + + def __repr__(self): + return 'BatchNormBlock(in_feat: {:d}, momentum: {:.3f}, only_bias: {:s})'.format(self.in_dim, + self.bn_momentum, + str(not self.use_bn)) + + +class UnaryBlock(nn.Module): + + def __init__(self, in_dim, out_dim, act_fn, use_bn, bn_momentum, no_relu=False): + """ + Initialize a standard unary block with its ReLU and BatchNorm. + :param in_dim: dimension input features + :param out_dim: dimension input features + :param act_fn: activation function (nn.Module) to initialize + :param use_bn: boolean indicating if we use Batch Norm + :param bn_momentum: Batch norm momentum + """ + + super(UnaryBlock, self).__init__() + self.bn_momentum = bn_momentum + self.use_bn = use_bn + self.no_relu = no_relu + self.in_dim = in_dim + self.out_dim = out_dim + self.mlp = nn.Linear(in_dim, out_dim, bias=False) + self.batch_norm = BatchNormBlock(out_dim, self.use_bn, self.bn_momentum) + if not no_relu: + self.act = act_fn() + return + + def forward(self, x, batch=None): + x = self.mlp(x) + x = self.batch_norm(x) + if not self.no_relu: + x = self.act(x) + return x + + def __repr__(self): + return 'UnaryBlock(in_feat: {:d}, out_feat: {:d}, BN: {:s}, ReLU: {:s})'.format(self.in_dim, + self.out_dim, + str(self.use_bn), + str(not self.no_relu)) + + +class SimpleBlock(nn.Module): + + def __init__(self, block_name, in_dim, out_dim, radius, layer_ind, act_fn, config): + """ + Initialize a simple convolution block with its ReLU and BatchNorm. + :param in_dim: dimension input features + :param out_dim: dimension input features + :param radius: current radius of convolution + :param act_fn: activation function (nn.Module) to initialize + :param config: parameters + """ + super(SimpleBlock, self).__init__() + + # get KP_extent from current radius + current_extent = radius * config.KP_extent / config.conv_radius + + # Get other parameters + self.bn_momentum = config.batch_norm_momentum + self.use_bn = config.use_batch_norm + self.layer_ind = layer_ind + self.block_name = block_name + self.in_dim = in_dim + self.out_dim = out_dim + + # Define the KPConv class + self.KPConv = KPConv(config.num_kernel_points, + config.in_points_dim, + in_dim, + out_dim // 2, + current_extent, + radius, + fixed_kernel_points=config.fixed_kernel_points, + KP_influence=config.KP_influence, + aggregation_mode=config.aggregation_mode, + deformable='deform' in block_name, + modulated=config.modulated) + + # Other operations + self.batch_norm = BatchNormBlock(out_dim // 2, self.use_bn, self.bn_momentum) + self.act = act_fn() + + return + + def forward(self, x, batch): + if 'strided' in self.block_name: + q_pts = batch.points[self.layer_ind + 1] + s_pts = batch.points[self.layer_ind] + neighb_inds = batch.pools[self.layer_ind] + else: + q_pts = batch.points[self.layer_ind] + s_pts = batch.points[self.layer_ind] + neighb_inds = batch.neighbors[self.layer_ind] + + x = self.KPConv(q_pts, s_pts, neighb_inds, x) + return self.act(self.batch_norm(x)) + + +class ResnetBottleneckBlock(nn.Module): + + def __init__(self, block_name, in_dim, out_dim, radius, layer_ind, act_fn, config): + """ + Initialize a resnet bottleneck block. + :param in_dim: dimension input features + :param out_dim: dimension input features + :param radius: current radius of convolution + :param act_fn: activation function (nn.Module) to initialize + :param config: parameters + """ + super(ResnetBottleneckBlock, self).__init__() + + # get KP_extent from current radius + current_extent = radius * config.KP_extent / config.conv_radius + + # Get other parameters + self.bn_momentum = config.batch_norm_momentum + self.use_bn = config.use_batch_norm + self.block_name = block_name + self.layer_ind = layer_ind + self.in_dim = in_dim + self.out_dim = out_dim + + # First downscaling mlp + if in_dim != out_dim // 4: + self.unary1 = UnaryBlock(in_dim, out_dim // 4, act_fn, self.use_bn, self.bn_momentum) + else: + self.unary1 = nn.Identity() + + # KPConv block + self.KPConv = KPConv(config.num_kernel_points, + config.in_points_dim, + out_dim // 4, + out_dim // 4, + current_extent, + radius, + fixed_kernel_points=config.fixed_kernel_points, + KP_influence=config.KP_influence, + aggregation_mode=config.aggregation_mode, + deformable='deform' in block_name, + modulated=config.modulated) + self.batch_norm_conv = BatchNormBlock(out_dim // 4, self.use_bn, self.bn_momentum) + + # Second upscaling mlp + self.unary2 = UnaryBlock(out_dim // 4, out_dim, act_fn, self.use_bn, self.bn_momentum, no_relu=True) + + # Shortcut optional mpl + if in_dim != out_dim: + self.unary_shortcut = UnaryBlock(in_dim, out_dim, act_fn, self.use_bn, self.bn_momentum, no_relu=True) + else: + self.unary_shortcut = nn.Identity() + + # Other operations + self.act = act_fn() + + return + + def forward(self, features, batch): + + if 'strided' in self.block_name: + q_pts = batch.points[self.layer_ind + 1] + s_pts = batch.points[self.layer_ind] + neighb_inds = batch.pools[self.layer_ind] + else: + q_pts = batch.points[self.layer_ind] + s_pts = batch.points[self.layer_ind] + neighb_inds = batch.neighbors[self.layer_ind] + + # First downscaling mlp + x = self.unary1(features) + + # Convolution + x = self.KPConv(q_pts, s_pts, neighb_inds, x) + x = self.act(self.batch_norm_conv(x)) + + # Second upscaling mlp + x = self.unary2(x) + + # Shortcut + if 'strided' in self.block_name: + shortcut = max_pool(features, neighb_inds) + else: + shortcut = features + shortcut = self.unary_shortcut(shortcut) + + return self.act(x + shortcut) + + +class GlobalAverageBlock(nn.Module): + + def __init__(self): + """ + Initialize a global average block with its ReLU and BatchNorm. + """ + super(GlobalAverageBlock, self).__init__() + return + + def forward(self, x, batch): + return global_average(x, batch.lengths[-1]) + + +class GlobalSumBlock(nn.Module): + + def __init__(self): + """ + Initialize a global average block with its ReLU and BatchNorm. + """ + super(GlobalSumBlock, self).__init__() + return + + def forward(self, x, batch): + return global_sum(x, batch.lengths[-1]) + + +class NearestUpsampleBlock(nn.Module): + + def __init__(self, layer_ind): + """ + Initialize a nearest upsampling block with its ReLU and BatchNorm. + """ + super(NearestUpsampleBlock, self).__init__() + self.layer_ind = layer_ind + return + + def forward(self, x, batch): + return closest_pool(x, batch.upsamples[self.layer_ind - 1]) + + def __repr__(self): + return 'NearestUpsampleBlock(layer: {:d} -> {:d})'.format(self.layer_ind, + self.layer_ind - 1) + + +class MaxPoolBlock(nn.Module): + + def __init__(self, layer_ind): + """ + Initialize a max pooling block with its ReLU and BatchNorm. + """ + super(MaxPoolBlock, self).__init__() + self.layer_ind = layer_ind + return + + def forward(self, x, batch): + return max_pool(x, batch.pools[self.layer_ind + 1]) diff --git a/torch-points3d/torch_points3d/modules/KPConv/common.py b/torch-points3d/torch_points3d/modules/KPConv/common.py new file mode 100644 index 0000000..bfdf8d1 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/common.py @@ -0,0 +1,157 @@ + +import numpy as np +from .kernel_points import create_3D_rotations + +import torch_points3d.modules.KPConv.cpp_wrappers.cpp_subsampling.grid_subsampling as cpp_subsampling +import torch_points3d.modules.KPConv.cpp_wrappers.cpp_neighbors.radius_neighbors as cpp_neighbors + +def grid_subsampling(points, features=None, labels=None, sampleDl=0.1, verbose=0): + """ + CPP wrapper for a grid subsampling (method = barycenter for points and features) + :param points: (N, 3) matrix of input points + :param features: optional (N, d) matrix of features (floating number) + :param labels: optional (N,) matrix of integer labels + :param sampleDl: parameter defining the size of grid voxels + :param verbose: 1 to display + :return: subsampled points, with features and/or labels depending on the input + """ + + if (features is None) and (labels is None): + return cpp_subsampling.subsample(points, + sampleDl=sampleDl, + verbose=verbose) + elif (labels is None): + return cpp_subsampling.subsample(points, + features=features, + sampleDl=sampleDl, + verbose=verbose) + elif (features is None): + return cpp_subsampling.subsample(points, + classes=labels, + sampleDl=sampleDl, + verbose=verbose) + else: + return cpp_subsampling.subsample(points, + features=features, + classes=labels, + sampleDl=sampleDl, + verbose=verbose) +def batch_grid_subsampling(points, batches_len, features=None, labels=None, + sampleDl=0.1, max_p=0, verbose=0, random_grid_orient=True): + """ + CPP wrapper for a grid subsampling (method = barycenter for points and features) + :param points: (N, 3) matrix of input points + :param features: optional (N, d) matrix of features (floating number) + :param labels: optional (N,) matrix of integer labels + :param sampleDl: parameter defining the size of grid voxels + :param verbose: 1 to display + :return: subsampled points, with features and/or labels depending on the input + """ + + R = None + B = len(batches_len) + if random_grid_orient: + + ######################################################## + # Create a random rotation matrix for each batch element + ######################################################## + + # Choose two random angles for the first vector in polar coordinates + theta = np.random.rand(B) * 2 * np.pi + phi = (np.random.rand(B) - 0.5) * np.pi + + # Create the first vector in carthesian coordinates + u = np.vstack([np.cos(theta) * np.cos(phi), np.sin(theta) * np.cos(phi), np.sin(phi)]) + + # Choose a random rotation angle + alpha = np.random.rand(B) * 2 * np.pi + + # Create the rotation matrix with this vector and angle + R = create_3D_rotations(u.T, alpha).astype(np.float32) + + ################# + # Apply rotations + ################# + + i0 = 0 + points = points.copy() + for bi, length in enumerate(batches_len): + # Apply the rotation + points[i0:i0 + length, :] = np.sum(np.expand_dims(points[i0:i0 + length, :], 2) * R[bi], axis=1) + i0 += length + + ####################### + # Subsample and realign + ####################### + + if (features is None) and (labels is None): + s_points, s_len = cpp_subsampling.subsample_batch(points, + batches_len, + sampleDl=sampleDl, + max_p=max_p, + verbose=verbose) + if random_grid_orient: + i0 = 0 + for bi, length in enumerate(s_len): + s_points[i0:i0 + length, :] = np.sum(np.expand_dims(s_points[i0:i0 + length, :], 2) * R[bi].T, axis=1) + i0 += length + return s_points, s_len + + elif (labels is None): + s_points, s_len, s_features = cpp_subsampling.subsample_batch(points, + batches_len, + features=features, + sampleDl=sampleDl, + max_p=max_p, + verbose=verbose) + if random_grid_orient: + i0 = 0 + for bi, length in enumerate(s_len): + # Apply the rotation + s_points[i0:i0 + length, :] = np.sum(np.expand_dims(s_points[i0:i0 + length, :], 2) * R[bi].T, axis=1) + i0 += length + return s_points, s_len, s_features + + elif (features is None): + s_points, s_len, s_labels = cpp_subsampling.subsample_batch(points, + batches_len, + classes=labels, + sampleDl=sampleDl, + max_p=max_p, + verbose=verbose) + if random_grid_orient: + i0 = 0 + for bi, length in enumerate(s_len): + # Apply the rotation + s_points[i0:i0 + length, :] = np.sum(np.expand_dims(s_points[i0:i0 + length, :], 2) * R[bi].T, axis=1) + i0 += length + return s_points, s_len, s_labels + + else: + s_points, s_len, s_features, s_labels = cpp_subsampling.subsample_batch(points, + batches_len, + features=features, + classes=labels, + sampleDl=sampleDl, + max_p=max_p, + verbose=verbose) + if random_grid_orient: + i0 = 0 + for bi, length in enumerate(s_len): + # Apply the rotation + s_points[i0:i0 + length, :] = np.sum(np.expand_dims(s_points[i0:i0 + length, :], 2) * R[bi].T, axis=1) + i0 += length + return s_points, s_len, s_features, s_labels + + +def batch_neighbors(queries, supports, q_batches, s_batches, radius): + """ + Computes neighbors for a batch of queries and supports + :param queries: (N1, 3) the query points + :param supports: (N2, 3) the support points + :param q_batches: (B) the list of lengths of batch elements in queries + :param s_batches: (B)the list of lengths of batch elements in supports + :param radius: float32 + :return: neighbors indices + """ + return cpp_neighbors.batch_query(queries, supports, q_batches, s_batches, radius=radius) diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/build.bat b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/build.bat new file mode 100644 index 0000000..8679a29 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/build.bat @@ -0,0 +1,5 @@ +@echo off +py setup.py build_ext --inplace + + +pause \ No newline at end of file diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/neighbors/neighbors.cpp b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/neighbors/neighbors.cpp new file mode 100644 index 0000000..bf22af8 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/neighbors/neighbors.cpp @@ -0,0 +1,333 @@ + +#include "neighbors.h" + + +void brute_neighbors(vector& queries, vector& supports, vector& neighbors_indices, float radius, int verbose) +{ + + // Initialize variables + // ****************** + + // square radius + float r2 = radius * radius; + + // indices + int i0 = 0; + + // Counting vector + int max_count = 0; + vector> tmp(queries.size()); + + // Search neigbors indices + // *********************** + + for (auto& p0 : queries) + { + int i = 0; + for (auto& p : supports) + { + if ((p0 - p).sq_norm() < r2) + { + tmp[i0].push_back(i); + if (tmp[i0].size() > max_count) + max_count = tmp[i0].size(); + } + i++; + } + i0++; + } + + // Reserve the memory + neighbors_indices.resize(queries.size() * max_count); + i0 = 0; + for (auto& inds : tmp) + { + for (int j = 0; j < max_count; j++) + { + if (j < inds.size()) + neighbors_indices[i0 * max_count + j] = inds[j]; + else + neighbors_indices[i0 * max_count + j] = -1; + } + i0++; + } + + return; +} + +void ordered_neighbors(vector& queries, + vector& supports, + vector& neighbors_indices, + float radius) +{ + + // Initialize variables + // ****************** + + // square radius + float r2 = radius * radius; + + // indices + int i0 = 0; + + // Counting vector + int max_count = 0; + float d2; + vector> tmp(queries.size()); + vector> dists(queries.size()); + + // Search neigbors indices + // *********************** + + for (auto& p0 : queries) + { + int i = 0; + for (auto& p : supports) + { + d2 = (p0 - p).sq_norm(); + if (d2 < r2) + { + // Find order of the new point + auto it = std::upper_bound(dists[i0].begin(), dists[i0].end(), d2); + int index = std::distance(dists[i0].begin(), it); + + // Insert element + dists[i0].insert(it, d2); + tmp[i0].insert(tmp[i0].begin() + index, i); + + // Update max count + if (tmp[i0].size() > max_count) + max_count = tmp[i0].size(); + } + i++; + } + i0++; + } + + // Reserve the memory + neighbors_indices.resize(queries.size() * max_count); + i0 = 0; + for (auto& inds : tmp) + { + for (int j = 0; j < max_count; j++) + { + if (j < inds.size()) + neighbors_indices[i0 * max_count + j] = inds[j]; + else + neighbors_indices[i0 * max_count + j] = -1; + } + i0++; + } + + return; +} + +void batch_ordered_neighbors(vector& queries, + vector& supports, + vector& q_batches, + vector& s_batches, + vector& neighbors_indices, + float radius) +{ + + // Initialize variables + // ****************** + + // square radius + float r2 = radius * radius; + + // indices + int i0 = 0; + + // Counting vector + int max_count = 0; + float d2; + vector> tmp(queries.size()); + vector> dists(queries.size()); + + // batch index + int b = 0; + int sum_qb = 0; + int sum_sb = 0; + + + // Search neigbors indices + // *********************** + + for (auto& p0 : queries) + { + // Check if we changed batch + if (i0 == sum_qb + q_batches[b]) + { + sum_qb += q_batches[b]; + sum_sb += s_batches[b]; + b++; + } + + // Loop only over the supports of current batch + vector::iterator p_it; + int i = 0; + for(p_it = supports.begin() + sum_sb; p_it < supports.begin() + sum_sb + s_batches[b]; p_it++ ) + { + d2 = (p0 - *p_it).sq_norm(); + if (d2 < r2) + { + // Find order of the new point + auto it = std::upper_bound(dists[i0].begin(), dists[i0].end(), d2); + int index = std::distance(dists[i0].begin(), it); + + // Insert element + dists[i0].insert(it, d2); + tmp[i0].insert(tmp[i0].begin() + index, sum_sb + i); + + // Update max count + if (tmp[i0].size() > max_count) + max_count = tmp[i0].size(); + } + i++; + } + i0++; + } + + // Reserve the memory + neighbors_indices.resize(queries.size() * max_count); + i0 = 0; + for (auto& inds : tmp) + { + for (int j = 0; j < max_count; j++) + { + if (j < inds.size()) + neighbors_indices[i0 * max_count + j] = inds[j]; + else + neighbors_indices[i0 * max_count + j] = supports.size(); + } + i0++; + } + + return; +} + + +void batch_nanoflann_neighbors(vector& queries, + vector& supports, + vector& q_batches, + vector& s_batches, + vector& neighbors_indices, + float radius) +{ + + // Initialize variables + // ****************** + + // indices + int i0 = 0; + + // Square radius + float r2 = radius * radius; + + // Counting vector + int max_count = 0; + float d2; + vector>> all_inds_dists(queries.size()); + + // batch index + int b = 0; + int sum_qb = 0; + int sum_sb = 0; + + // Nanoflann related variables + // *************************** + + // CLoud variable + PointCloud current_cloud; + + // Tree parameters + nanoflann::KDTreeSingleIndexAdaptorParams tree_params(10 /* max leaf */); + + // KDTree type definition + typedef nanoflann::KDTreeSingleIndexAdaptor< nanoflann::L2_Simple_Adaptor , + PointCloud, + 3 > my_kd_tree_t; + + // Pointer to trees + my_kd_tree_t* index; + + // Build KDTree for the first batch element + current_cloud.pts = vector(supports.begin() + sum_sb, supports.begin() + sum_sb + s_batches[b]); + index = new my_kd_tree_t(3, current_cloud, tree_params); + index->buildIndex(); + + + // Search neigbors indices + // *********************** + + // Search params + nanoflann::SearchParams search_params; + search_params.sorted = true; + + for (auto& p0 : queries) + { + + // Check if we changed batch + if (i0 == sum_qb + q_batches[b]) + { + sum_qb += q_batches[b]; + sum_sb += s_batches[b]; + b++; + + // Change the points + current_cloud.pts.clear(); + current_cloud.pts = vector(supports.begin() + sum_sb, supports.begin() + sum_sb + s_batches[b]); + + // Build KDTree of the current element of the batch + delete index; + index = new my_kd_tree_t(3, current_cloud, tree_params); + index->buildIndex(); + } + + // Initial guess of neighbors size + all_inds_dists[i0].reserve(max_count); + + // Find neighbors + float query_pt[3] = { p0.x, p0.y, p0.z}; + size_t nMatches = index->radiusSearch(query_pt, r2, all_inds_dists[i0], search_params); + + // Update max count + if (nMatches > max_count) + max_count = nMatches; + + // Increment query idx + i0++; + } + + // Reserve the memory + neighbors_indices.resize(queries.size() * max_count); + i0 = 0; + sum_sb = 0; + sum_qb = 0; + b = 0; + for (auto& inds_dists : all_inds_dists) + { + // Check if we changed batch + if (i0 == sum_qb + q_batches[b]) + { + sum_qb += q_batches[b]; + sum_sb += s_batches[b]; + b++; + } + + for (int j = 0; j < max_count; j++) + { + if (j < inds_dists.size()) + neighbors_indices[i0 * max_count + j] = inds_dists[j].first + sum_sb; + else + neighbors_indices[i0 * max_count + j] = supports.size(); + } + i0++; + } + + delete index; + + return; +} + diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/neighbors/neighbors.h b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/neighbors/neighbors.h new file mode 100644 index 0000000..ff612b0 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/neighbors/neighbors.h @@ -0,0 +1,29 @@ + + +#include "../../cpp_utils/cloud/cloud.h" +#include "../../cpp_utils/nanoflann/nanoflann.hpp" + +#include +#include + +using namespace std; + + +void ordered_neighbors(vector& queries, + vector& supports, + vector& neighbors_indices, + float radius); + +void batch_ordered_neighbors(vector& queries, + vector& supports, + vector& q_batches, + vector& s_batches, + vector& neighbors_indices, + float radius); + +void batch_nanoflann_neighbors(vector& queries, + vector& supports, + vector& q_batches, + vector& s_batches, + vector& neighbors_indices, + float radius); diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/setup.py b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/setup.py new file mode 100644 index 0000000..8f53a9c --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/setup.py @@ -0,0 +1,28 @@ +from distutils.core import setup, Extension +import numpy.distutils.misc_util + +# Adding OpenCV to project +# ************************ + +# Adding sources of the project +# ***************************** + +SOURCES = ["../cpp_utils/cloud/cloud.cpp", + "neighbors/neighbors.cpp", + "wrapper.cpp"] + +module = Extension(name="radius_neighbors", + sources=SOURCES, + extra_compile_args=['-std=c++11', + '-D_GLIBCXX_USE_CXX11_ABI=0']) + + +setup(ext_modules=[module], include_dirs=numpy.distutils.misc_util.get_numpy_include_dirs()) + + + + + + + + diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/wrapper.cpp b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/wrapper.cpp new file mode 100644 index 0000000..a4e2809 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_neighbors/wrapper.cpp @@ -0,0 +1,238 @@ +#include +#include +#include "neighbors/neighbors.h" +#include + + + +// docstrings for our module +// ************************* + +static char module_docstring[] = "This module provides two methods to compute radius neighbors from pointclouds or batch of pointclouds"; + +static char batch_query_docstring[] = "Method to get radius neighbors in a batch of stacked pointclouds"; + + +// Declare the functions +// ********************* + +static PyObject *batch_neighbors(PyObject *self, PyObject *args, PyObject *keywds); + + +// Specify the members of the module +// ********************************* + +static PyMethodDef module_methods[] = +{ + { "batch_query", (PyCFunction)batch_neighbors, METH_VARARGS | METH_KEYWORDS, batch_query_docstring }, + {NULL, NULL, 0, NULL} +}; + + +// Initialize the module +// ********************* + +static struct PyModuleDef moduledef = +{ + PyModuleDef_HEAD_INIT, + "radius_neighbors", // m_name + module_docstring, // m_doc + -1, // m_size + module_methods, // m_methods + NULL, // m_reload + NULL, // m_traverse + NULL, // m_clear + NULL, // m_free +}; + +PyMODINIT_FUNC PyInit_radius_neighbors(void) +{ + import_array(); + return PyModule_Create(&moduledef); +} + + +// Definition of the batch_subsample method +// ********************************** + +static PyObject* batch_neighbors(PyObject* self, PyObject* args, PyObject* keywds) +{ + + // Manage inputs + // ************* + + // Args containers + PyObject* queries_obj = NULL; + PyObject* supports_obj = NULL; + PyObject* q_batches_obj = NULL; + PyObject* s_batches_obj = NULL; + + // Keywords containers + static char* kwlist[] = { "queries", "supports", "q_batches", "s_batches", "radius", NULL }; + float radius = 0.1; + + // Parse the input + if (!PyArg_ParseTupleAndKeywords(args, keywds, "OOOO|$f", kwlist, &queries_obj, &supports_obj, &q_batches_obj, &s_batches_obj, &radius)) + { + PyErr_SetString(PyExc_RuntimeError, "Error parsing arguments"); + return NULL; + } + + + // Interpret the input objects as numpy arrays. + PyObject* queries_array = PyArray_FROM_OTF(queries_obj, NPY_FLOAT, NPY_IN_ARRAY); + PyObject* supports_array = PyArray_FROM_OTF(supports_obj, NPY_FLOAT, NPY_IN_ARRAY); + PyObject* q_batches_array = PyArray_FROM_OTF(q_batches_obj, NPY_INT, NPY_IN_ARRAY); + PyObject* s_batches_array = PyArray_FROM_OTF(s_batches_obj, NPY_INT, NPY_IN_ARRAY); + + // Verify data was load correctly. + if (queries_array == NULL) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting query points to numpy arrays of type float32"); + return NULL; + } + if (supports_array == NULL) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting support points to numpy arrays of type float32"); + return NULL; + } + if (q_batches_array == NULL) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting query batches to numpy arrays of type int32"); + return NULL; + } + if (s_batches_array == NULL) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting support batches to numpy arrays of type int32"); + return NULL; + } + + // Check that the input array respect the dims + if ((int)PyArray_NDIM(queries_array) != 2 || (int)PyArray_DIM(queries_array, 1) != 3) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : query.shape is not (N, 3)"); + return NULL; + } + if ((int)PyArray_NDIM(supports_array) != 2 || (int)PyArray_DIM(supports_array, 1) != 3) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : support.shape is not (N, 3)"); + return NULL; + } + if ((int)PyArray_NDIM(q_batches_array) > 1) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : queries_batches.shape is not (B,) "); + return NULL; + } + if ((int)PyArray_NDIM(s_batches_array) > 1) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : supports_batches.shape is not (B,) "); + return NULL; + } + if ((int)PyArray_DIM(q_batches_array, 0) != (int)PyArray_DIM(s_batches_array, 0)) + { + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong number of batch elements: different for queries and supports "); + return NULL; + } + + // Number of points + int Nq = (int)PyArray_DIM(queries_array, 0); + int Ns= (int)PyArray_DIM(supports_array, 0); + + // Number of batches + int Nb = (int)PyArray_DIM(q_batches_array, 0); + + // Call the C++ function + // ********************* + + // Convert PyArray to Cloud C++ class + vector queries; + vector supports; + vector q_batches; + vector s_batches; + queries = vector((PointXYZ*)PyArray_DATA(queries_array), (PointXYZ*)PyArray_DATA(queries_array) + Nq); + supports = vector((PointXYZ*)PyArray_DATA(supports_array), (PointXYZ*)PyArray_DATA(supports_array) + Ns); + q_batches = vector((int*)PyArray_DATA(q_batches_array), (int*)PyArray_DATA(q_batches_array) + Nb); + s_batches = vector((int*)PyArray_DATA(s_batches_array), (int*)PyArray_DATA(s_batches_array) + Nb); + + // Create result containers + vector neighbors_indices; + + // Compute results + //batch_ordered_neighbors(queries, supports, q_batches, s_batches, neighbors_indices, radius); + batch_nanoflann_neighbors(queries, supports, q_batches, s_batches, neighbors_indices, radius); + + // Check result + if (neighbors_indices.size() < 1) + { + PyErr_SetString(PyExc_RuntimeError, "Error"); + return NULL; + } + + // Manage outputs + // ************** + + // Maximal number of neighbors + int max_neighbors = neighbors_indices.size() / Nq; + + // Dimension of output containers + npy_intp* neighbors_dims = new npy_intp[2]; + neighbors_dims[0] = Nq; + neighbors_dims[1] = max_neighbors; + + // Create output array + PyObject* res_obj = PyArray_SimpleNew(2, neighbors_dims, NPY_INT); + PyObject* ret = NULL; + + // Fill output array with values + size_t size_in_bytes = Nq * max_neighbors * sizeof(int); + memcpy(PyArray_DATA(res_obj), neighbors_indices.data(), size_in_bytes); + + // Merge results + ret = Py_BuildValue("N", res_obj); + + // Clean up + // ******** + + Py_XDECREF(queries_array); + Py_XDECREF(supports_array); + Py_XDECREF(q_batches_array); + Py_XDECREF(s_batches_array); + + return ret; +} diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/grid_subsampling/grid_subsampling.cpp b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/grid_subsampling/grid_subsampling.cpp new file mode 100644 index 0000000..24276bb --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/grid_subsampling/grid_subsampling.cpp @@ -0,0 +1,211 @@ + +#include "grid_subsampling.h" + + +void grid_subsampling(vector& original_points, + vector& subsampled_points, + vector& original_features, + vector& subsampled_features, + vector& original_classes, + vector& subsampled_classes, + float sampleDl, + int verbose) { + + // Initialize variables + // ****************** + + // Number of points in the cloud + size_t N = original_points.size(); + + // Dimension of the features + size_t fdim = original_features.size() / N; + size_t ldim = original_classes.size() / N; + + // Limits of the cloud + PointXYZ minCorner = min_point(original_points); + PointXYZ maxCorner = max_point(original_points); + PointXYZ originCorner = floor(minCorner * (1/sampleDl)) * sampleDl; + + // Dimensions of the grid + size_t sampleNX = (size_t)floor((maxCorner.x - originCorner.x) / sampleDl) + 1; + size_t sampleNY = (size_t)floor((maxCorner.y - originCorner.y) / sampleDl) + 1; + //size_t sampleNZ = (size_t)floor((maxCorner.z - originCorner.z) / sampleDl) + 1; + + // Check if features and classes need to be processed + bool use_feature = original_features.size() > 0; + bool use_classes = original_classes.size() > 0; + + + // Create the sampled map + // ********************** + + // Verbose parameters + int i = 0; + int nDisp = N / 100; + + // Initialize variables + size_t iX, iY, iZ, mapIdx; + unordered_map data; + + for (auto& p : original_points) + { + // Position of point in sample map + iX = (size_t)floor((p.x - originCorner.x) / sampleDl); + iY = (size_t)floor((p.y - originCorner.y) / sampleDl); + iZ = (size_t)floor((p.z - originCorner.z) / sampleDl); + mapIdx = iX + sampleNX*iY + sampleNX*sampleNY*iZ; + + // If not already created, create key + if (data.count(mapIdx) < 1) + data.emplace(mapIdx, SampledData(fdim, ldim)); + + // Fill the sample map + if (use_feature && use_classes) + data[mapIdx].update_all(p, original_features.begin() + i * fdim, original_classes.begin() + i * ldim); + else if (use_feature) + data[mapIdx].update_features(p, original_features.begin() + i * fdim); + else if (use_classes) + data[mapIdx].update_classes(p, original_classes.begin() + i * ldim); + else + data[mapIdx].update_points(p); + + // Display + i++; + if (verbose > 1 && i%nDisp == 0) + std::cout << "\rSampled Map : " << std::setw(3) << i / nDisp << "%"; + + } + + // Divide for barycentre and transfer to a vector + subsampled_points.reserve(data.size()); + if (use_feature) + subsampled_features.reserve(data.size() * fdim); + if (use_classes) + subsampled_classes.reserve(data.size() * ldim); + for (auto& v : data) + { + subsampled_points.push_back(v.second.point * (1.0 / v.second.count)); + if (use_feature) + { + float count = (float)v.second.count; + transform(v.second.features.begin(), + v.second.features.end(), + v.second.features.begin(), + [count](float f) { return f / count;}); + subsampled_features.insert(subsampled_features.end(),v.second.features.begin(),v.second.features.end()); + } + if (use_classes) + { + for (int i = 0; i < ldim; i++) + subsampled_classes.push_back(max_element(v.second.labels[i].begin(), v.second.labels[i].end(), + [](const pair&a, const pair&b){return a.second < b.second;})->first); + } + } + + return; +} + + +void batch_grid_subsampling(vector& original_points, + vector& subsampled_points, + vector& original_features, + vector& subsampled_features, + vector& original_classes, + vector& subsampled_classes, + vector& original_batches, + vector& subsampled_batches, + float sampleDl, + int max_p) +{ + // Initialize variables + // ****************** + + int b = 0; + int sum_b = 0; + + // Number of points in the cloud + size_t N = original_points.size(); + + // Dimension of the features + size_t fdim = original_features.size() / N; + size_t ldim = original_classes.size() / N; + + // Handle max_p = 0 + if (max_p < 1) + max_p = N; + + // Loop over batches + // ***************** + + for (b = 0; b < original_batches.size(); b++) + { + + // Extract batch points features and labels + vector b_o_points = vector(original_points.begin () + sum_b, + original_points.begin () + sum_b + original_batches[b]); + + vector b_o_features; + if (original_features.size() > 0) + { + b_o_features = vector(original_features.begin () + sum_b * fdim, + original_features.begin () + (sum_b + original_batches[b]) * fdim); + } + + vector b_o_classes; + if (original_classes.size() > 0) + { + b_o_classes = vector(original_classes.begin () + sum_b * ldim, + original_classes.begin () + sum_b + original_batches[b] * ldim); + } + + + // Create result containers + vector b_s_points; + vector b_s_features; + vector b_s_classes; + + // Compute subsampling on current batch + grid_subsampling(b_o_points, + b_s_points, + b_o_features, + b_s_features, + b_o_classes, + b_s_classes, + sampleDl, + 0); + + // Stack batches points features and labels + // **************************************** + + // If too many points remove some + if (b_s_points.size() <= max_p) + { + subsampled_points.insert(subsampled_points.end(), b_s_points.begin(), b_s_points.end()); + + if (original_features.size() > 0) + subsampled_features.insert(subsampled_features.end(), b_s_features.begin(), b_s_features.end()); + + if (original_classes.size() > 0) + subsampled_classes.insert(subsampled_classes.end(), b_s_classes.begin(), b_s_classes.end()); + + subsampled_batches.push_back(b_s_points.size()); + } + else + { + subsampled_points.insert(subsampled_points.end(), b_s_points.begin(), b_s_points.begin() + max_p); + + if (original_features.size() > 0) + subsampled_features.insert(subsampled_features.end(), b_s_features.begin(), b_s_features.begin() + max_p * fdim); + + if (original_classes.size() > 0) + subsampled_classes.insert(subsampled_classes.end(), b_s_classes.begin(), b_s_classes.begin() + max_p * ldim); + + subsampled_batches.push_back(max_p); + } + + // Stack new batch lengths + sum_b += original_batches[b]; + } + + return; +} diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/grid_subsampling/grid_subsampling.h b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/grid_subsampling/grid_subsampling.h new file mode 100644 index 0000000..37f775d --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/grid_subsampling/grid_subsampling.h @@ -0,0 +1,101 @@ + + +#include "../../cpp_utils/cloud/cloud.h" + +#include +#include + +using namespace std; + +class SampledData +{ +public: + + // Elements + // ******** + + int count; + PointXYZ point; + vector features; + vector> labels; + + + // Methods + // ******* + + // Constructor + SampledData() + { + count = 0; + point = PointXYZ(); + } + + SampledData(const size_t fdim, const size_t ldim) + { + count = 0; + point = PointXYZ(); + features = vector(fdim); + labels = vector>(ldim); + } + + // Method Update + void update_all(const PointXYZ p, vector::iterator f_begin, vector::iterator l_begin) + { + count += 1; + point += p; + transform (features.begin(), features.end(), f_begin, features.begin(), plus()); + int i = 0; + for(vector::iterator it = l_begin; it != l_begin + labels.size(); ++it) + { + labels[i][*it] += 1; + i++; + } + return; + } + void update_features(const PointXYZ p, vector::iterator f_begin) + { + count += 1; + point += p; + transform (features.begin(), features.end(), f_begin, features.begin(), plus()); + return; + } + void update_classes(const PointXYZ p, vector::iterator l_begin) + { + count += 1; + point += p; + int i = 0; + for(vector::iterator it = l_begin; it != l_begin + labels.size(); ++it) + { + labels[i][*it] += 1; + i++; + } + return; + } + void update_points(const PointXYZ p) + { + count += 1; + point += p; + return; + } +}; + +void grid_subsampling(vector& original_points, + vector& subsampled_points, + vector& original_features, + vector& subsampled_features, + vector& original_classes, + vector& subsampled_classes, + float sampleDl, + int verbose); + +void batch_grid_subsampling(vector& original_points, + vector& subsampled_points, + vector& original_features, + vector& subsampled_features, + vector& original_classes, + vector& subsampled_classes, + vector& original_batches, + vector& subsampled_batches, + float sampleDl, + int max_p); + diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/setup.py b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/setup.py new file mode 100644 index 0000000..3206299 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/setup.py @@ -0,0 +1,28 @@ +from distutils.core import setup, Extension +import numpy.distutils.misc_util + +# Adding OpenCV to project +# ************************ + +# Adding sources of the project +# ***************************** + +SOURCES = ["../cpp_utils/cloud/cloud.cpp", + "grid_subsampling/grid_subsampling.cpp", + "wrapper.cpp"] + +module = Extension(name="grid_subsampling", + sources=SOURCES, + extra_compile_args=['-std=c++11', + '-D_GLIBCXX_USE_CXX11_ABI=0']) + + +setup(ext_modules=[module], include_dirs=numpy.distutils.misc_util.get_numpy_include_dirs()) + + + + + + + + diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/wrapper.cpp b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/wrapper.cpp new file mode 100644 index 0000000..8a92aaa --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_subsampling/wrapper.cpp @@ -0,0 +1,566 @@ +#include +#include +#include "grid_subsampling/grid_subsampling.h" +#include + + + +// docstrings for our module +// ************************* + +static char module_docstring[] = "This module provides an interface for the subsampling of a batch of stacked pointclouds"; + +static char subsample_docstring[] = "function subsampling a pointcloud"; + +static char subsample_batch_docstring[] = "function subsampling a batch of stacked pointclouds"; + + +// Declare the functions +// ********************* + +static PyObject *cloud_subsampling(PyObject* self, PyObject* args, PyObject* keywds); +static PyObject *batch_subsampling(PyObject *self, PyObject *args, PyObject *keywds); + + +// Specify the members of the module +// ********************************* + +static PyMethodDef module_methods[] = +{ + { "subsample", (PyCFunction)cloud_subsampling, METH_VARARGS | METH_KEYWORDS, subsample_docstring }, + { "subsample_batch", (PyCFunction)batch_subsampling, METH_VARARGS | METH_KEYWORDS, subsample_batch_docstring }, + {NULL, NULL, 0, NULL} +}; + + +// Initialize the module +// ********************* + +static struct PyModuleDef moduledef = +{ + PyModuleDef_HEAD_INIT, + "grid_subsampling", // m_name + module_docstring, // m_doc + -1, // m_size + module_methods, // m_methods + NULL, // m_reload + NULL, // m_traverse + NULL, // m_clear + NULL, // m_free +}; + +PyMODINIT_FUNC PyInit_grid_subsampling(void) +{ + import_array(); + return PyModule_Create(&moduledef); +} + + +// Definition of the batch_subsample method +// ********************************** + +static PyObject* batch_subsampling(PyObject* self, PyObject* args, PyObject* keywds) +{ + + // Manage inputs + // ************* + + // Args containers + PyObject* points_obj = NULL; + PyObject* features_obj = NULL; + PyObject* classes_obj = NULL; + PyObject* batches_obj = NULL; + + // Keywords containers + static char* kwlist[] = { "points", "batches", "features", "classes", "sampleDl", "method", "max_p", "verbose", NULL }; + float sampleDl = 0.1; + const char* method_buffer = "barycenters"; + int verbose = 0; + int max_p = 0; + + // Parse the input + if (!PyArg_ParseTupleAndKeywords(args, keywds, "OO|$OOfsii", kwlist, &points_obj, &batches_obj, &features_obj, &classes_obj, &sampleDl, &method_buffer, &max_p, &verbose)) + { + PyErr_SetString(PyExc_RuntimeError, "Error parsing arguments"); + return NULL; + } + + // Get the method argument + string method(method_buffer); + + // Interpret method + if (method.compare("barycenters") && method.compare("voxelcenters")) + { + PyErr_SetString(PyExc_RuntimeError, "Error parsing method. Valid method names are \"barycenters\" and \"voxelcenters\" "); + return NULL; + } + + // Check if using features or classes + bool use_feature = true, use_classes = true; + if (features_obj == NULL) + use_feature = false; + if (classes_obj == NULL) + use_classes = false; + + // Interpret the input objects as numpy arrays. + PyObject* points_array = PyArray_FROM_OTF(points_obj, NPY_FLOAT, NPY_IN_ARRAY); + PyObject* batches_array = PyArray_FROM_OTF(batches_obj, NPY_INT, NPY_IN_ARRAY); + PyObject* features_array = NULL; + PyObject* classes_array = NULL; + if (use_feature) + features_array = PyArray_FROM_OTF(features_obj, NPY_FLOAT, NPY_IN_ARRAY); + if (use_classes) + classes_array = PyArray_FROM_OTF(classes_obj, NPY_INT, NPY_IN_ARRAY); + + // Verify data was load correctly. + if (points_array == NULL) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting input points to numpy arrays of type float32"); + return NULL; + } + if (batches_array == NULL) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting input batches to numpy arrays of type int32"); + return NULL; + } + if (use_feature && features_array == NULL) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting input features to numpy arrays of type float32"); + return NULL; + } + if (use_classes && classes_array == NULL) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting input classes to numpy arrays of type int32"); + return NULL; + } + + // Check that the input array respect the dims + if ((int)PyArray_NDIM(points_array) != 2 || (int)PyArray_DIM(points_array, 1) != 3) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : points.shape is not (N, 3)"); + return NULL; + } + if ((int)PyArray_NDIM(batches_array) > 1) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : batches.shape is not (B,) "); + return NULL; + } + if (use_feature && ((int)PyArray_NDIM(features_array) != 2)) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : features.shape is not (N, d)"); + return NULL; + } + + if (use_classes && (int)PyArray_NDIM(classes_array) > 2) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : classes.shape is not (N,) or (N, d)"); + return NULL; + } + + // Number of points + int N = (int)PyArray_DIM(points_array, 0); + + // Number of batches + int Nb = (int)PyArray_DIM(batches_array, 0); + + // Dimension of the features + int fdim = 0; + if (use_feature) + fdim = (int)PyArray_DIM(features_array, 1); + + //Dimension of labels + int ldim = 1; + if (use_classes && (int)PyArray_NDIM(classes_array) == 2) + ldim = (int)PyArray_DIM(classes_array, 1); + + // Check that the input array respect the number of points + if (use_feature && (int)PyArray_DIM(features_array, 0) != N) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : features.shape is not (N, d)"); + return NULL; + } + if (use_classes && (int)PyArray_DIM(classes_array, 0) != N) + { + Py_XDECREF(points_array); + Py_XDECREF(batches_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : classes.shape is not (N,) or (N, d)"); + return NULL; + } + + + // Call the C++ function + // ********************* + + // Create pyramid + if (verbose > 0) + cout << "Computing cloud pyramid with support points: " << endl; + + + // Convert PyArray to Cloud C++ class + vector original_points; + vector original_batches; + vector original_features; + vector original_classes; + original_points = vector((PointXYZ*)PyArray_DATA(points_array), (PointXYZ*)PyArray_DATA(points_array) + N); + original_batches = vector((int*)PyArray_DATA(batches_array), (int*)PyArray_DATA(batches_array) + Nb); + if (use_feature) + original_features = vector((float*)PyArray_DATA(features_array), (float*)PyArray_DATA(features_array) + N * fdim); + if (use_classes) + original_classes = vector((int*)PyArray_DATA(classes_array), (int*)PyArray_DATA(classes_array) + N * ldim); + + // Subsample + vector subsampled_points; + vector subsampled_features; + vector subsampled_classes; + vector subsampled_batches; + batch_grid_subsampling(original_points, + subsampled_points, + original_features, + subsampled_features, + original_classes, + subsampled_classes, + original_batches, + subsampled_batches, + sampleDl, + max_p); + + // Check result + if (subsampled_points.size() < 1) + { + PyErr_SetString(PyExc_RuntimeError, "Error"); + return NULL; + } + + // Manage outputs + // ************** + + // Dimension of input containers + npy_intp* point_dims = new npy_intp[2]; + point_dims[0] = subsampled_points.size(); + point_dims[1] = 3; + npy_intp* feature_dims = new npy_intp[2]; + feature_dims[0] = subsampled_points.size(); + feature_dims[1] = fdim; + npy_intp* classes_dims = new npy_intp[2]; + classes_dims[0] = subsampled_points.size(); + classes_dims[1] = ldim; + npy_intp* batches_dims = new npy_intp[1]; + batches_dims[0] = Nb; + + // Create output array + PyObject* res_points_obj = PyArray_SimpleNew(2, point_dims, NPY_FLOAT); + PyObject* res_batches_obj = PyArray_SimpleNew(1, batches_dims, NPY_INT); + PyObject* res_features_obj = NULL; + PyObject* res_classes_obj = NULL; + PyObject* ret = NULL; + + // Fill output array with values + size_t size_in_bytes = subsampled_points.size() * 3 * sizeof(float); + memcpy(PyArray_DATA(res_points_obj), subsampled_points.data(), size_in_bytes); + size_in_bytes = Nb * sizeof(int); + memcpy(PyArray_DATA(res_batches_obj), subsampled_batches.data(), size_in_bytes); + if (use_feature) + { + size_in_bytes = subsampled_points.size() * fdim * sizeof(float); + res_features_obj = PyArray_SimpleNew(2, feature_dims, NPY_FLOAT); + memcpy(PyArray_DATA(res_features_obj), subsampled_features.data(), size_in_bytes); + } + if (use_classes) + { + size_in_bytes = subsampled_points.size() * ldim * sizeof(int); + res_classes_obj = PyArray_SimpleNew(2, classes_dims, NPY_INT); + memcpy(PyArray_DATA(res_classes_obj), subsampled_classes.data(), size_in_bytes); + } + + + // Merge results + if (use_feature && use_classes) + ret = Py_BuildValue("NNNN", res_points_obj, res_batches_obj, res_features_obj, res_classes_obj); + else if (use_feature) + ret = Py_BuildValue("NNN", res_points_obj, res_batches_obj, res_features_obj); + else if (use_classes) + ret = Py_BuildValue("NNN", res_points_obj, res_batches_obj, res_classes_obj); + else + ret = Py_BuildValue("NN", res_points_obj, res_batches_obj); + + // Clean up + // ******** + + Py_DECREF(points_array); + Py_DECREF(batches_array); + Py_XDECREF(features_array); + Py_XDECREF(classes_array); + + return ret; +} + +// Definition of the subsample method +// **************************************** + +static PyObject* cloud_subsampling(PyObject* self, PyObject* args, PyObject* keywds) +{ + + // Manage inputs + // ************* + + // Args containers + PyObject* points_obj = NULL; + PyObject* features_obj = NULL; + PyObject* classes_obj = NULL; + + // Keywords containers + static char* kwlist[] = { "points", "features", "classes", "sampleDl", "method", "verbose", NULL }; + float sampleDl = 0.1; + const char* method_buffer = "barycenters"; + int verbose = 0; + + // Parse the input + if (!PyArg_ParseTupleAndKeywords(args, keywds, "O|$OOfsi", kwlist, &points_obj, &features_obj, &classes_obj, &sampleDl, &method_buffer, &verbose)) + { + PyErr_SetString(PyExc_RuntimeError, "Error parsing arguments"); + return NULL; + } + + // Get the method argument + string method(method_buffer); + + // Interpret method + if (method.compare("barycenters") && method.compare("voxelcenters")) + { + PyErr_SetString(PyExc_RuntimeError, "Error parsing method. Valid method names are \"barycenters\" and \"voxelcenters\" "); + return NULL; + } + + // Check if using features or classes + bool use_feature = true, use_classes = true; + if (features_obj == NULL) + use_feature = false; + if (classes_obj == NULL) + use_classes = false; + + // Interpret the input objects as numpy arrays. + PyObject* points_array = PyArray_FROM_OTF(points_obj, NPY_FLOAT, NPY_IN_ARRAY); + PyObject* features_array = NULL; + PyObject* classes_array = NULL; + if (use_feature) + features_array = PyArray_FROM_OTF(features_obj, NPY_FLOAT, NPY_IN_ARRAY); + if (use_classes) + classes_array = PyArray_FROM_OTF(classes_obj, NPY_INT, NPY_IN_ARRAY); + + // Verify data was load correctly. + if (points_array == NULL) + { + Py_XDECREF(points_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting input points to numpy arrays of type float32"); + return NULL; + } + if (use_feature && features_array == NULL) + { + Py_XDECREF(points_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting input features to numpy arrays of type float32"); + return NULL; + } + if (use_classes && classes_array == NULL) + { + Py_XDECREF(points_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Error converting input classes to numpy arrays of type int32"); + return NULL; + } + + // Check that the input array respect the dims + if ((int)PyArray_NDIM(points_array) != 2 || (int)PyArray_DIM(points_array, 1) != 3) + { + Py_XDECREF(points_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : points.shape is not (N, 3)"); + return NULL; + } + if (use_feature && ((int)PyArray_NDIM(features_array) != 2)) + { + Py_XDECREF(points_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : features.shape is not (N, d)"); + return NULL; + } + + if (use_classes && (int)PyArray_NDIM(classes_array) > 2) + { + Py_XDECREF(points_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : classes.shape is not (N,) or (N, d)"); + return NULL; + } + + // Number of points + int N = (int)PyArray_DIM(points_array, 0); + + // Dimension of the features + int fdim = 0; + if (use_feature) + fdim = (int)PyArray_DIM(features_array, 1); + + //Dimension of labels + int ldim = 1; + if (use_classes && (int)PyArray_NDIM(classes_array) == 2) + ldim = (int)PyArray_DIM(classes_array, 1); + + // Check that the input array respect the number of points + if (use_feature && (int)PyArray_DIM(features_array, 0) != N) + { + Py_XDECREF(points_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : features.shape is not (N, d)"); + return NULL; + } + if (use_classes && (int)PyArray_DIM(classes_array, 0) != N) + { + Py_XDECREF(points_array); + Py_XDECREF(classes_array); + Py_XDECREF(features_array); + PyErr_SetString(PyExc_RuntimeError, "Wrong dimensions : classes.shape is not (N,) or (N, d)"); + return NULL; + } + + + // Call the C++ function + // ********************* + + // Create pyramid + if (verbose > 0) + cout << "Computing cloud pyramid with support points: " << endl; + + + // Convert PyArray to Cloud C++ class + vector original_points; + vector original_features; + vector original_classes; + original_points = vector((PointXYZ*)PyArray_DATA(points_array), (PointXYZ*)PyArray_DATA(points_array) + N); + if (use_feature) + original_features = vector((float*)PyArray_DATA(features_array), (float*)PyArray_DATA(features_array) + N * fdim); + if (use_classes) + original_classes = vector((int*)PyArray_DATA(classes_array), (int*)PyArray_DATA(classes_array) + N * ldim); + + // Subsample + vector subsampled_points; + vector subsampled_features; + vector subsampled_classes; + grid_subsampling(original_points, + subsampled_points, + original_features, + subsampled_features, + original_classes, + subsampled_classes, + sampleDl, + verbose); + + // Check result + if (subsampled_points.size() < 1) + { + PyErr_SetString(PyExc_RuntimeError, "Error"); + return NULL; + } + + // Manage outputs + // ************** + + // Dimension of input containers + npy_intp* point_dims = new npy_intp[2]; + point_dims[0] = subsampled_points.size(); + point_dims[1] = 3; + npy_intp* feature_dims = new npy_intp[2]; + feature_dims[0] = subsampled_points.size(); + feature_dims[1] = fdim; + npy_intp* classes_dims = new npy_intp[2]; + classes_dims[0] = subsampled_points.size(); + classes_dims[1] = ldim; + + // Create output array + PyObject* res_points_obj = PyArray_SimpleNew(2, point_dims, NPY_FLOAT); + PyObject* res_features_obj = NULL; + PyObject* res_classes_obj = NULL; + PyObject* ret = NULL; + + // Fill output array with values + size_t size_in_bytes = subsampled_points.size() * 3 * sizeof(float); + memcpy(PyArray_DATA(res_points_obj), subsampled_points.data(), size_in_bytes); + if (use_feature) + { + size_in_bytes = subsampled_points.size() * fdim * sizeof(float); + res_features_obj = PyArray_SimpleNew(2, feature_dims, NPY_FLOAT); + memcpy(PyArray_DATA(res_features_obj), subsampled_features.data(), size_in_bytes); + } + if (use_classes) + { + size_in_bytes = subsampled_points.size() * ldim * sizeof(int); + res_classes_obj = PyArray_SimpleNew(2, classes_dims, NPY_INT); + memcpy(PyArray_DATA(res_classes_obj), subsampled_classes.data(), size_in_bytes); + } + + + // Merge results + if (use_feature && use_classes) + ret = Py_BuildValue("NNN", res_points_obj, res_features_obj, res_classes_obj); + else if (use_feature) + ret = Py_BuildValue("NN", res_points_obj, res_features_obj); + else if (use_classes) + ret = Py_BuildValue("NN", res_points_obj, res_classes_obj); + else + ret = Py_BuildValue("N", res_points_obj); + + // Clean up + // ******** + + Py_DECREF(points_array); + Py_XDECREF(features_array); + Py_XDECREF(classes_array); + + return ret; +} \ No newline at end of file diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/cloud/cloud.cpp b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/cloud/cloud.cpp new file mode 100644 index 0000000..c285140 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/cloud/cloud.cpp @@ -0,0 +1,67 @@ +// +// +// 0==========================0 +// | Local feature test | +// 0==========================0 +// +// version 1.0 : +// > +// +//--------------------------------------------------- +// +// Cloud source : +// Define usefull Functions/Methods +// +//---------------------------------------------------- +// +// Hugues THOMAS - 10/02/2017 +// + + +#include "cloud.h" + + +// Getters +// ******* + +PointXYZ max_point(std::vector points) +{ + // Initialize limits + PointXYZ maxP(points[0]); + + // Loop over all points + for (auto p : points) + { + if (p.x > maxP.x) + maxP.x = p.x; + + if (p.y > maxP.y) + maxP.y = p.y; + + if (p.z > maxP.z) + maxP.z = p.z; + } + + return maxP; +} + +PointXYZ min_point(std::vector points) +{ + // Initialize limits + PointXYZ minP(points[0]); + + // Loop over all points + for (auto p : points) + { + if (p.x < minP.x) + minP.x = p.x; + + if (p.y < minP.y) + minP.y = p.y; + + if (p.z < minP.z) + minP.z = p.z; + } + + return minP; +} \ No newline at end of file diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/cloud/cloud.h b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/cloud/cloud.h new file mode 100644 index 0000000..99d4e19 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/cloud/cloud.h @@ -0,0 +1,185 @@ +// +// +// 0==========================0 +// | Local feature test | +// 0==========================0 +// +// version 1.0 : +// > +// +//--------------------------------------------------- +// +// Cloud header +// +//---------------------------------------------------- +// +// Hugues THOMAS - 10/02/2017 +// + + +# pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + + + + +// Point class +// *********** + + +class PointXYZ +{ +public: + + // Elements + // ******** + + float x, y, z; + + + // Methods + // ******* + + // Constructor + PointXYZ() { x = 0; y = 0; z = 0; } + PointXYZ(float x0, float y0, float z0) { x = x0; y = y0; z = z0; } + + // array type accessor + float operator [] (int i) const + { + if (i == 0) return x; + else if (i == 1) return y; + else return z; + } + + // opperations + float dot(const PointXYZ P) const + { + return x * P.x + y * P.y + z * P.z; + } + + float sq_norm() + { + return x*x + y*y + z*z; + } + + PointXYZ cross(const PointXYZ P) const + { + return PointXYZ(y*P.z - z*P.y, z*P.x - x*P.z, x*P.y - y*P.x); + } + + PointXYZ& operator+=(const PointXYZ& P) + { + x += P.x; + y += P.y; + z += P.z; + return *this; + } + + PointXYZ& operator-=(const PointXYZ& P) + { + x -= P.x; + y -= P.y; + z -= P.z; + return *this; + } + + PointXYZ& operator*=(const float& a) + { + x *= a; + y *= a; + z *= a; + return *this; + } +}; + + +// Point Opperations +// ***************** + +inline PointXYZ operator + (const PointXYZ A, const PointXYZ B) +{ + return PointXYZ(A.x + B.x, A.y + B.y, A.z + B.z); +} + +inline PointXYZ operator - (const PointXYZ A, const PointXYZ B) +{ + return PointXYZ(A.x - B.x, A.y - B.y, A.z - B.z); +} + +inline PointXYZ operator * (const PointXYZ P, const float a) +{ + return PointXYZ(P.x * a, P.y * a, P.z * a); +} + +inline PointXYZ operator * (const float a, const PointXYZ P) +{ + return PointXYZ(P.x * a, P.y * a, P.z * a); +} + +inline std::ostream& operator << (std::ostream& os, const PointXYZ P) +{ + return os << "[" << P.x << ", " << P.y << ", " << P.z << "]"; +} + +inline bool operator == (const PointXYZ A, const PointXYZ B) +{ + return A.x == B.x && A.y == B.y && A.z == B.z; +} + +inline PointXYZ floor(const PointXYZ P) +{ + return PointXYZ(std::floor(P.x), std::floor(P.y), std::floor(P.z)); +} + + +PointXYZ max_point(std::vector points); +PointXYZ min_point(std::vector points); + + +struct PointCloud +{ + + std::vector pts; + + // Must return the number of data points + inline size_t kdtree_get_point_count() const { return pts.size(); } + + // Returns the dim'th component of the idx'th point in the class: + // Since this is inlined and the "dim" argument is typically an immediate value, the + // "if/else's" are actually solved at compile time. + inline float kdtree_get_pt(const size_t idx, const size_t dim) const + { + if (dim == 0) return pts[idx].x; + else if (dim == 1) return pts[idx].y; + else return pts[idx].z; + } + + // Optional bounding-box computation: return false to default to a standard bbox computation loop. + // Return true if the BBOX was already computed by the class and returned in "bb" so it can be avoided to redo it again. + // Look at bb.size() to find out the expected dimensionality (e.g. 2 or 3 for point clouds) + template + bool kdtree_get_bbox(BBOX& /* bb */) const { return false; } + +}; + + + + + + + + + + + diff --git a/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/nanoflann/nanoflann.hpp b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/nanoflann/nanoflann.hpp new file mode 100644 index 0000000..8d2ab6c --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/cpp_wrappers/cpp_utils/nanoflann/nanoflann.hpp @@ -0,0 +1,2043 @@ +/*********************************************************************** + * Software License Agreement (BSD License) + * + * Copyright 2008-2009 Marius Muja (mariusm@cs.ubc.ca). All rights reserved. + * Copyright 2008-2009 David G. Lowe (lowe@cs.ubc.ca). All rights reserved. + * Copyright 2011-2016 Jose Luis Blanco (joseluisblancoc@gmail.com). + * All rights reserved. + * + * THE BSD LICENSE + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + *************************************************************************/ + +/** \mainpage nanoflann C++ API documentation + * nanoflann is a C++ header-only library for building KD-Trees, mostly + * optimized for 2D or 3D point clouds. + * + * nanoflann does not require compiling or installing, just an + * #include in your code. + * + * See: + * - C++ API organized by modules + * - Online README + * - Doxygen + * documentation + */ + +#ifndef NANOFLANN_HPP_ +#define NANOFLANN_HPP_ + +#include +#include +#include +#include // for abs() +#include // for fwrite() +#include // for abs() +#include +#include // std::reference_wrapper +#include +#include + +/** Library version: 0xMmP (M=Major,m=minor,P=patch) */ +#define NANOFLANN_VERSION 0x130 + +// Avoid conflicting declaration of min/max macros in windows headers +#if !defined(NOMINMAX) && \ + (defined(_WIN32) || defined(_WIN32_) || defined(WIN32) || defined(_WIN64)) +#define NOMINMAX +#ifdef max +#undef max +#undef min +#endif +#endif + +namespace nanoflann { +/** @addtogroup nanoflann_grp nanoflann C++ library for ANN + * @{ */ + +/** the PI constant (required to avoid MSVC missing symbols) */ +template T pi_const() { + return static_cast(3.14159265358979323846); +} + +/** + * Traits if object is resizable and assignable (typically has a resize | assign + * method) + */ +template struct has_resize : std::false_type {}; + +template +struct has_resize().resize(1), 0)> + : std::true_type {}; + +template struct has_assign : std::false_type {}; + +template +struct has_assign().assign(1, 0), 0)> + : std::true_type {}; + +/** + * Free function to resize a resizable object + */ +template +inline typename std::enable_if::value, void>::type +resize(Container &c, const size_t nElements) { + c.resize(nElements); +} + +/** + * Free function that has no effects on non resizable containers (e.g. + * std::array) It raises an exception if the expected size does not match + */ +template +inline typename std::enable_if::value, void>::type +resize(Container &c, const size_t nElements) { + if (nElements != c.size()) + throw std::logic_error("Try to change the size of a std::array."); +} + +/** + * Free function to assign to a container + */ +template +inline typename std::enable_if::value, void>::type +assign(Container &c, const size_t nElements, const T &value) { + c.assign(nElements, value); +} + +/** + * Free function to assign to a std::array + */ +template +inline typename std::enable_if::value, void>::type +assign(Container &c, const size_t nElements, const T &value) { + for (size_t i = 0; i < nElements; i++) + c[i] = value; +} + +/** @addtogroup result_sets_grp Result set classes + * @{ */ +template +class KNNResultSet { +public: + typedef _DistanceType DistanceType; + typedef _IndexType IndexType; + typedef _CountType CountType; + +private: + IndexType *indices; + DistanceType *dists; + CountType capacity; + CountType count; + +public: + inline KNNResultSet(CountType capacity_) + : indices(0), dists(0), capacity(capacity_), count(0) {} + + inline void init(IndexType *indices_, DistanceType *dists_) { + indices = indices_; + dists = dists_; + count = 0; + if (capacity) + dists[capacity - 1] = (std::numeric_limits::max)(); + } + + inline CountType size() const { return count; } + + inline bool full() const { return count == capacity; } + + /** + * Called during search to add an element matching the criteria. + * @return true if the search should be continued, false if the results are + * sufficient + */ + inline bool addPoint(DistanceType dist, IndexType index) { + CountType i; + for (i = count; i > 0; --i) { +#ifdef NANOFLANN_FIRST_MATCH // If defined and two points have the same + // distance, the one with the lowest-index will be + // returned first. + if ((dists[i - 1] > dist) || + ((dist == dists[i - 1]) && (indices[i - 1] > index))) { +#else + if (dists[i - 1] > dist) { +#endif + if (i < capacity) { + dists[i] = dists[i - 1]; + indices[i] = indices[i - 1]; + } + } else + break; + } + if (i < capacity) { + dists[i] = dist; + indices[i] = index; + } + if (count < capacity) + count++; + + // tell caller that the search shall continue + return true; + } + + inline DistanceType worstDist() const { return dists[capacity - 1]; } +}; + +/** operator "<" for std::sort() */ +struct IndexDist_Sorter { + /** PairType will be typically: std::pair */ + template + inline bool operator()(const PairType &p1, const PairType &p2) const { + return p1.second < p2.second; + } +}; + +/** + * A result-set class used when performing a radius based search. + */ +template +class RadiusResultSet { +public: + typedef _DistanceType DistanceType; + typedef _IndexType IndexType; + +public: + const DistanceType radius; + + std::vector> &m_indices_dists; + + inline RadiusResultSet( + DistanceType radius_, + std::vector> &indices_dists) + : radius(radius_), m_indices_dists(indices_dists) { + init(); + } + + inline void init() { clear(); } + inline void clear() { m_indices_dists.clear(); } + + inline size_t size() const { return m_indices_dists.size(); } + + inline bool full() const { return true; } + + /** + * Called during search to add an element matching the criteria. + * @return true if the search should be continued, false if the results are + * sufficient + */ + inline bool addPoint(DistanceType dist, IndexType index) { + if (dist < radius) + m_indices_dists.push_back(std::make_pair(index, dist)); + return true; + } + + inline DistanceType worstDist() const { return radius; } + + /** + * Find the worst result (furtherest neighbor) without copying or sorting + * Pre-conditions: size() > 0 + */ + std::pair worst_item() const { + if (m_indices_dists.empty()) + throw std::runtime_error("Cannot invoke RadiusResultSet::worst_item() on " + "an empty list of results."); + typedef + typename std::vector>::const_iterator + DistIt; + DistIt it = std::max_element(m_indices_dists.begin(), m_indices_dists.end(), + IndexDist_Sorter()); + return *it; + } +}; + +/** @} */ + +/** @addtogroup loadsave_grp Load/save auxiliary functions + * @{ */ +template +void save_value(FILE *stream, const T &value, size_t count = 1) { + fwrite(&value, sizeof(value), count, stream); +} + +template +void save_value(FILE *stream, const std::vector &value) { + size_t size = value.size(); + fwrite(&size, sizeof(size_t), 1, stream); + fwrite(&value[0], sizeof(T), size, stream); +} + +template +void load_value(FILE *stream, T &value, size_t count = 1) { + size_t read_cnt = fread(&value, sizeof(value), count, stream); + if (read_cnt != count) { + throw std::runtime_error("Cannot read from file"); + } +} + +template void load_value(FILE *stream, std::vector &value) { + size_t size; + size_t read_cnt = fread(&size, sizeof(size_t), 1, stream); + if (read_cnt != 1) { + throw std::runtime_error("Cannot read from file"); + } + value.resize(size); + read_cnt = fread(&value[0], sizeof(T), size, stream); + if (read_cnt != size) { + throw std::runtime_error("Cannot read from file"); + } +} +/** @} */ + +/** @addtogroup metric_grp Metric (distance) classes + * @{ */ + +struct Metric {}; + +/** Manhattan distance functor (generic version, optimized for + * high-dimensionality data sets). Corresponding distance traits: + * nanoflann::metric_L1 \tparam T Type of the elements (e.g. double, float, + * uint8_t) \tparam _DistanceType Type of distance variables (must be signed) + * (e.g. float, double, int64_t) + */ +template +struct L1_Adaptor { + typedef T ElementType; + typedef _DistanceType DistanceType; + + const DataSource &data_source; + + L1_Adaptor(const DataSource &_data_source) : data_source(_data_source) {} + + inline DistanceType evalMetric(const T *a, const size_t b_idx, size_t size, + DistanceType worst_dist = -1) const { + DistanceType result = DistanceType(); + const T *last = a + size; + const T *lastgroup = last - 3; + size_t d = 0; + + /* Process 4 items with each loop for efficiency. */ + while (a < lastgroup) { + const DistanceType diff0 = + std::abs(a[0] - data_source.kdtree_get_pt(b_idx, d++)); + const DistanceType diff1 = + std::abs(a[1] - data_source.kdtree_get_pt(b_idx, d++)); + const DistanceType diff2 = + std::abs(a[2] - data_source.kdtree_get_pt(b_idx, d++)); + const DistanceType diff3 = + std::abs(a[3] - data_source.kdtree_get_pt(b_idx, d++)); + result += diff0 + diff1 + diff2 + diff3; + a += 4; + if ((worst_dist > 0) && (result > worst_dist)) { + return result; + } + } + /* Process last 0-3 components. Not needed for standard vector lengths. */ + while (a < last) { + result += std::abs(*a++ - data_source.kdtree_get_pt(b_idx, d++)); + } + return result; + } + + template + inline DistanceType accum_dist(const U a, const V b, const size_t) const { + return std::abs(a - b); + } +}; + +/** Squared Euclidean distance functor (generic version, optimized for + * high-dimensionality data sets). Corresponding distance traits: + * nanoflann::metric_L2 \tparam T Type of the elements (e.g. double, float, + * uint8_t) \tparam _DistanceType Type of distance variables (must be signed) + * (e.g. float, double, int64_t) + */ +template +struct L2_Adaptor { + typedef T ElementType; + typedef _DistanceType DistanceType; + + const DataSource &data_source; + + L2_Adaptor(const DataSource &_data_source) : data_source(_data_source) {} + + inline DistanceType evalMetric(const T *a, const size_t b_idx, size_t size, + DistanceType worst_dist = -1) const { + DistanceType result = DistanceType(); + const T *last = a + size; + const T *lastgroup = last - 3; + size_t d = 0; + + /* Process 4 items with each loop for efficiency. */ + while (a < lastgroup) { + const DistanceType diff0 = a[0] - data_source.kdtree_get_pt(b_idx, d++); + const DistanceType diff1 = a[1] - data_source.kdtree_get_pt(b_idx, d++); + const DistanceType diff2 = a[2] - data_source.kdtree_get_pt(b_idx, d++); + const DistanceType diff3 = a[3] - data_source.kdtree_get_pt(b_idx, d++); + result += diff0 * diff0 + diff1 * diff1 + diff2 * diff2 + diff3 * diff3; + a += 4; + if ((worst_dist > 0) && (result > worst_dist)) { + return result; + } + } + /* Process last 0-3 components. Not needed for standard vector lengths. */ + while (a < last) { + const DistanceType diff0 = *a++ - data_source.kdtree_get_pt(b_idx, d++); + result += diff0 * diff0; + } + return result; + } + + template + inline DistanceType accum_dist(const U a, const V b, const size_t) const { + return (a - b) * (a - b); + } +}; + +/** Squared Euclidean (L2) distance functor (suitable for low-dimensionality + * datasets, like 2D or 3D point clouds) Corresponding distance traits: + * nanoflann::metric_L2_Simple \tparam T Type of the elements (e.g. double, + * float, uint8_t) \tparam _DistanceType Type of distance variables (must be + * signed) (e.g. float, double, int64_t) + */ +template +struct L2_Simple_Adaptor { + typedef T ElementType; + typedef _DistanceType DistanceType; + + const DataSource &data_source; + + L2_Simple_Adaptor(const DataSource &_data_source) + : data_source(_data_source) {} + + inline DistanceType evalMetric(const T *a, const size_t b_idx, + size_t size) const { + DistanceType result = DistanceType(); + for (size_t i = 0; i < size; ++i) { + const DistanceType diff = a[i] - data_source.kdtree_get_pt(b_idx, i); + result += diff * diff; + } + return result; + } + + template + inline DistanceType accum_dist(const U a, const V b, const size_t) const { + return (a - b) * (a - b); + } +}; + +/** SO2 distance functor + * Corresponding distance traits: nanoflann::metric_SO2 + * \tparam T Type of the elements (e.g. double, float) + * \tparam _DistanceType Type of distance variables (must be signed) (e.g. + * float, double) orientation is constrained to be in [-pi, pi] + */ +template +struct SO2_Adaptor { + typedef T ElementType; + typedef _DistanceType DistanceType; + + const DataSource &data_source; + + SO2_Adaptor(const DataSource &_data_source) : data_source(_data_source) {} + + inline DistanceType evalMetric(const T *a, const size_t b_idx, + size_t size) const { + return accum_dist(a[size - 1], data_source.kdtree_get_pt(b_idx, size - 1), + size - 1); + } + + /** Note: this assumes that input angles are already in the range [-pi,pi] */ + template + inline DistanceType accum_dist(const U a, const V b, const size_t) const { + DistanceType result = DistanceType(), PI = pi_const(); + result = b - a; + if (result > PI) + result -= 2 * PI; + else if (result < -PI) + result += 2 * PI; + return result; + } +}; + +/** SO3 distance functor (Uses L2_Simple) + * Corresponding distance traits: nanoflann::metric_SO3 + * \tparam T Type of the elements (e.g. double, float) + * \tparam _DistanceType Type of distance variables (must be signed) (e.g. + * float, double) + */ +template +struct SO3_Adaptor { + typedef T ElementType; + typedef _DistanceType DistanceType; + + L2_Simple_Adaptor distance_L2_Simple; + + SO3_Adaptor(const DataSource &_data_source) + : distance_L2_Simple(_data_source) {} + + inline DistanceType evalMetric(const T *a, const size_t b_idx, + size_t size) const { + return distance_L2_Simple.evalMetric(a, b_idx, size); + } + + template + inline DistanceType accum_dist(const U a, const V b, const size_t idx) const { + return distance_L2_Simple.accum_dist(a, b, idx); + } +}; + +/** Metaprogramming helper traits class for the L1 (Manhattan) metric */ +struct metric_L1 : public Metric { + template struct traits { + typedef L1_Adaptor distance_t; + }; +}; +/** Metaprogramming helper traits class for the L2 (Euclidean) metric */ +struct metric_L2 : public Metric { + template struct traits { + typedef L2_Adaptor distance_t; + }; +}; +/** Metaprogramming helper traits class for the L2_simple (Euclidean) metric */ +struct metric_L2_Simple : public Metric { + template struct traits { + typedef L2_Simple_Adaptor distance_t; + }; +}; +/** Metaprogramming helper traits class for the SO3_InnerProdQuat metric */ +struct metric_SO2 : public Metric { + template struct traits { + typedef SO2_Adaptor distance_t; + }; +}; +/** Metaprogramming helper traits class for the SO3_InnerProdQuat metric */ +struct metric_SO3 : public Metric { + template struct traits { + typedef SO3_Adaptor distance_t; + }; +}; + +/** @} */ + +/** @addtogroup param_grp Parameter structs + * @{ */ + +/** Parameters (see README.md) */ +struct KDTreeSingleIndexAdaptorParams { + KDTreeSingleIndexAdaptorParams(size_t _leaf_max_size = 10) + : leaf_max_size(_leaf_max_size) {} + + size_t leaf_max_size; +}; + +/** Search options for KDTreeSingleIndexAdaptor::findNeighbors() */ +struct SearchParams { + /** Note: The first argument (checks_IGNORED_) is ignored, but kept for + * compatibility with the FLANN interface */ + SearchParams(int checks_IGNORED_ = 32, float eps_ = 0, bool sorted_ = true) + : checks(checks_IGNORED_), eps(eps_), sorted(sorted_) {} + + int checks; //!< Ignored parameter (Kept for compatibility with the FLANN + //!< interface). + float eps; //!< search for eps-approximate neighbours (default: 0) + bool sorted; //!< only for radius search, require neighbours sorted by + //!< distance (default: true) +}; +/** @} */ + +/** @addtogroup memalloc_grp Memory allocation + * @{ */ + +/** + * Allocates (using C's malloc) a generic type T. + * + * Params: + * count = number of instances to allocate. + * Returns: pointer (of type T*) to memory buffer + */ +template inline T *allocate(size_t count = 1) { + T *mem = static_cast(::malloc(sizeof(T) * count)); + return mem; +} + +/** + * Pooled storage allocator + * + * The following routines allow for the efficient allocation of storage in + * small chunks from a specified pool. Rather than allowing each structure + * to be freed individually, an entire pool of storage is freed at once. + * This method has two advantages over just using malloc() and free(). First, + * it is far more efficient for allocating small objects, as there is + * no overhead for remembering all the information needed to free each + * object or consolidating fragmented memory. Second, the decision about + * how long to keep an object is made at the time of allocation, and there + * is no need to track down all the objects to free them. + * + */ + +const size_t WORDSIZE = 16; +const size_t BLOCKSIZE = 8192; + +class PooledAllocator { + /* We maintain memory alignment to word boundaries by requiring that all + allocations be in multiples of the machine wordsize. */ + /* Size of machine word in bytes. Must be power of 2. */ + /* Minimum number of bytes requested at a time from the system. Must be + * multiple of WORDSIZE. */ + + size_t remaining; /* Number of bytes left in current block of storage. */ + void *base; /* Pointer to base of current block of storage. */ + void *loc; /* Current location in block to next allocate memory. */ + + void internal_init() { + remaining = 0; + base = NULL; + usedMemory = 0; + wastedMemory = 0; + } + +public: + size_t usedMemory; + size_t wastedMemory; + + /** + Default constructor. Initializes a new pool. + */ + PooledAllocator() { internal_init(); } + + /** + * Destructor. Frees all the memory allocated in this pool. + */ + ~PooledAllocator() { free_all(); } + + /** Frees all allocated memory chunks */ + void free_all() { + while (base != NULL) { + void *prev = + *(static_cast(base)); /* Get pointer to prev block. */ + ::free(base); + base = prev; + } + internal_init(); + } + + /** + * Returns a pointer to a piece of new memory of the given size in bytes + * allocated from the pool. + */ + void *malloc(const size_t req_size) { + /* Round size up to a multiple of wordsize. The following expression + only works for WORDSIZE that is a power of 2, by masking last bits of + incremented size to zero. + */ + const size_t size = (req_size + (WORDSIZE - 1)) & ~(WORDSIZE - 1); + + /* Check whether a new block must be allocated. Note that the first word + of a block is reserved for a pointer to the previous block. + */ + if (size > remaining) { + + wastedMemory += remaining; + + /* Allocate new storage. */ + const size_t blocksize = + (size + sizeof(void *) + (WORDSIZE - 1) > BLOCKSIZE) + ? size + sizeof(void *) + (WORDSIZE - 1) + : BLOCKSIZE; + + // use the standard C malloc to allocate memory + void *m = ::malloc(blocksize); + if (!m) { + fprintf(stderr, "Failed to allocate memory.\n"); + return NULL; + } + + /* Fill first word of new block with pointer to previous block. */ + static_cast(m)[0] = base; + base = m; + + size_t shift = 0; + // int size_t = (WORDSIZE - ( (((size_t)m) + sizeof(void*)) & + // (WORDSIZE-1))) & (WORDSIZE-1); + + remaining = blocksize - sizeof(void *) - shift; + loc = (static_cast(m) + sizeof(void *) + shift); + } + void *rloc = loc; + loc = static_cast(loc) + size; + remaining -= size; + + usedMemory += size; + + return rloc; + } + + /** + * Allocates (using this pool) a generic type T. + * + * Params: + * count = number of instances to allocate. + * Returns: pointer (of type T*) to memory buffer + */ + template T *allocate(const size_t count = 1) { + T *mem = static_cast(this->malloc(sizeof(T) * count)); + return mem; + } +}; +/** @} */ + +/** @addtogroup nanoflann_metaprog_grp Auxiliary metaprogramming stuff + * @{ */ + +/** Used to declare fixed-size arrays when DIM>0, dynamically-allocated vectors + * when DIM=-1. Fixed size version for a generic DIM: + */ +template struct array_or_vector_selector { + typedef std::array container_t; +}; +/** Dynamic size version */ +template struct array_or_vector_selector<-1, T> { + typedef std::vector container_t; +}; + +/** @} */ + +/** kd-tree base-class + * + * Contains the member functions common to the classes KDTreeSingleIndexAdaptor + * and KDTreeSingleIndexDynamicAdaptor_. + * + * \tparam Derived The name of the class which inherits this class. + * \tparam DatasetAdaptor The user-provided adaptor (see comments above). + * \tparam Distance The distance metric to use, these are all classes derived + * from nanoflann::Metric \tparam DIM Dimensionality of data points (e.g. 3 for + * 3D points) \tparam IndexType Will be typically size_t or int + */ + +template +class KDTreeBaseClass { + +public: + /** Frees the previously-built index. Automatically called within + * buildIndex(). */ + void freeIndex(Derived &obj) { + obj.pool.free_all(); + obj.root_node = NULL; + obj.m_size_at_index_build = 0; + } + + typedef typename Distance::ElementType ElementType; + typedef typename Distance::DistanceType DistanceType; + + /*--------------------- Internal Data Structures --------------------------*/ + struct Node { + /** Union used because a node can be either a LEAF node or a non-leaf node, + * so both data fields are never used simultaneously */ + union { + struct leaf { + IndexType left, right; //!< Indices of points in leaf node + } lr; + struct nonleaf { + int divfeat; //!< Dimension used for subdivision. + DistanceType divlow, divhigh; //!< The values used for subdivision. + } sub; + } node_type; + Node *child1, *child2; //!< Child nodes (both=NULL mean its a leaf node) + }; + + typedef Node *NodePtr; + + struct Interval { + ElementType low, high; + }; + + /** + * Array of indices to vectors in the dataset. + */ + std::vector vind; + + NodePtr root_node; + + size_t m_leaf_max_size; + + size_t m_size; //!< Number of current points in the dataset + size_t m_size_at_index_build; //!< Number of points in the dataset when the + //!< index was built + int dim; //!< Dimensionality of each data point + + /** Define "BoundingBox" as a fixed-size or variable-size container depending + * on "DIM" */ + typedef + typename array_or_vector_selector::container_t BoundingBox; + + /** Define "distance_vector_t" as a fixed-size or variable-size container + * depending on "DIM" */ + typedef typename array_or_vector_selector::container_t + distance_vector_t; + + /** The KD-tree used to find neighbours */ + + BoundingBox root_bbox; + + /** + * Pooled memory allocator. + * + * Using a pooled memory allocator is more efficient + * than allocating memory directly when there is a large + * number small of memory allocations. + */ + PooledAllocator pool; + + /** Returns number of points in dataset */ + size_t size(const Derived &obj) const { return obj.m_size; } + + /** Returns the length of each point in the dataset */ + size_t veclen(const Derived &obj) { + return static_cast(DIM > 0 ? DIM : obj.dim); + } + + /// Helper accessor to the dataset points: + inline ElementType dataset_get(const Derived &obj, size_t idx, + int component) const { + return obj.dataset.kdtree_get_pt(idx, component); + } + + /** + * Computes the inde memory usage + * Returns: memory used by the index + */ + size_t usedMemory(Derived &obj) { + return obj.pool.usedMemory + obj.pool.wastedMemory + + obj.dataset.kdtree_get_point_count() * + sizeof(IndexType); // pool memory and vind array memory + } + + void computeMinMax(const Derived &obj, IndexType *ind, IndexType count, + int element, ElementType &min_elem, + ElementType &max_elem) { + min_elem = dataset_get(obj, ind[0], element); + max_elem = dataset_get(obj, ind[0], element); + for (IndexType i = 1; i < count; ++i) { + ElementType val = dataset_get(obj, ind[i], element); + if (val < min_elem) + min_elem = val; + if (val > max_elem) + max_elem = val; + } + } + + /** + * Create a tree node that subdivides the list of vecs from vind[first] + * to vind[last]. The routine is called recursively on each sublist. + * + * @param left index of the first vector + * @param right index of the last vector + */ + NodePtr divideTree(Derived &obj, const IndexType left, const IndexType right, + BoundingBox &bbox) { + NodePtr node = obj.pool.template allocate(); // allocate memory + + /* If too few exemplars remain, then make this a leaf node. */ + if ((right - left) <= static_cast(obj.m_leaf_max_size)) { + node->child1 = node->child2 = NULL; /* Mark as leaf node. */ + node->node_type.lr.left = left; + node->node_type.lr.right = right; + + // compute bounding-box of leaf points + for (int i = 0; i < (DIM > 0 ? DIM : obj.dim); ++i) { + bbox[i].low = dataset_get(obj, obj.vind[left], i); + bbox[i].high = dataset_get(obj, obj.vind[left], i); + } + for (IndexType k = left + 1; k < right; ++k) { + for (int i = 0; i < (DIM > 0 ? DIM : obj.dim); ++i) { + if (bbox[i].low > dataset_get(obj, obj.vind[k], i)) + bbox[i].low = dataset_get(obj, obj.vind[k], i); + if (bbox[i].high < dataset_get(obj, obj.vind[k], i)) + bbox[i].high = dataset_get(obj, obj.vind[k], i); + } + } + } else { + IndexType idx; + int cutfeat; + DistanceType cutval; + middleSplit_(obj, &obj.vind[0] + left, right - left, idx, cutfeat, cutval, + bbox); + + node->node_type.sub.divfeat = cutfeat; + + BoundingBox left_bbox(bbox); + left_bbox[cutfeat].high = cutval; + node->child1 = divideTree(obj, left, left + idx, left_bbox); + + BoundingBox right_bbox(bbox); + right_bbox[cutfeat].low = cutval; + node->child2 = divideTree(obj, left + idx, right, right_bbox); + + node->node_type.sub.divlow = left_bbox[cutfeat].high; + node->node_type.sub.divhigh = right_bbox[cutfeat].low; + + for (int i = 0; i < (DIM > 0 ? DIM : obj.dim); ++i) { + bbox[i].low = std::min(left_bbox[i].low, right_bbox[i].low); + bbox[i].high = std::max(left_bbox[i].high, right_bbox[i].high); + } + } + + return node; + } + + void middleSplit_(Derived &obj, IndexType *ind, IndexType count, + IndexType &index, int &cutfeat, DistanceType &cutval, + const BoundingBox &bbox) { + const DistanceType EPS = static_cast(0.00001); + ElementType max_span = bbox[0].high - bbox[0].low; + for (int i = 1; i < (DIM > 0 ? DIM : obj.dim); ++i) { + ElementType span = bbox[i].high - bbox[i].low; + if (span > max_span) { + max_span = span; + } + } + ElementType max_spread = -1; + cutfeat = 0; + for (int i = 0; i < (DIM > 0 ? DIM : obj.dim); ++i) { + ElementType span = bbox[i].high - bbox[i].low; + if (span > (1 - EPS) * max_span) { + ElementType min_elem, max_elem; + computeMinMax(obj, ind, count, i, min_elem, max_elem); + ElementType spread = max_elem - min_elem; + ; + if (spread > max_spread) { + cutfeat = i; + max_spread = spread; + } + } + } + // split in the middle + DistanceType split_val = (bbox[cutfeat].low + bbox[cutfeat].high) / 2; + ElementType min_elem, max_elem; + computeMinMax(obj, ind, count, cutfeat, min_elem, max_elem); + + if (split_val < min_elem) + cutval = min_elem; + else if (split_val > max_elem) + cutval = max_elem; + else + cutval = split_val; + + IndexType lim1, lim2; + planeSplit(obj, ind, count, cutfeat, cutval, lim1, lim2); + + if (lim1 > count / 2) + index = lim1; + else if (lim2 < count / 2) + index = lim2; + else + index = count / 2; + } + + /** + * Subdivide the list of points by a plane perpendicular on axe corresponding + * to the 'cutfeat' dimension at 'cutval' position. + * + * On return: + * dataset[ind[0..lim1-1]][cutfeat]cutval + */ + void planeSplit(Derived &obj, IndexType *ind, const IndexType count, + int cutfeat, DistanceType &cutval, IndexType &lim1, + IndexType &lim2) { + /* Move vector indices for left subtree to front of list. */ + IndexType left = 0; + IndexType right = count - 1; + for (;;) { + while (left <= right && dataset_get(obj, ind[left], cutfeat) < cutval) + ++left; + while (right && left <= right && + dataset_get(obj, ind[right], cutfeat) >= cutval) + --right; + if (left > right || !right) + break; // "!right" was added to support unsigned Index types + std::swap(ind[left], ind[right]); + ++left; + --right; + } + /* If either list is empty, it means that all remaining features + * are identical. Split in the middle to maintain a balanced tree. + */ + lim1 = left; + right = count - 1; + for (;;) { + while (left <= right && dataset_get(obj, ind[left], cutfeat) <= cutval) + ++left; + while (right && left <= right && + dataset_get(obj, ind[right], cutfeat) > cutval) + --right; + if (left > right || !right) + break; // "!right" was added to support unsigned Index types + std::swap(ind[left], ind[right]); + ++left; + --right; + } + lim2 = left; + } + + DistanceType computeInitialDistances(const Derived &obj, + const ElementType *vec, + distance_vector_t &dists) const { + assert(vec); + DistanceType distsq = DistanceType(); + + for (int i = 0; i < (DIM > 0 ? DIM : obj.dim); ++i) { + if (vec[i] < obj.root_bbox[i].low) { + dists[i] = obj.distance.accum_dist(vec[i], obj.root_bbox[i].low, i); + distsq += dists[i]; + } + if (vec[i] > obj.root_bbox[i].high) { + dists[i] = obj.distance.accum_dist(vec[i], obj.root_bbox[i].high, i); + distsq += dists[i]; + } + } + return distsq; + } + + void save_tree(Derived &obj, FILE *stream, NodePtr tree) { + save_value(stream, *tree); + if (tree->child1 != NULL) { + save_tree(obj, stream, tree->child1); + } + if (tree->child2 != NULL) { + save_tree(obj, stream, tree->child2); + } + } + + void load_tree(Derived &obj, FILE *stream, NodePtr &tree) { + tree = obj.pool.template allocate(); + load_value(stream, *tree); + if (tree->child1 != NULL) { + load_tree(obj, stream, tree->child1); + } + if (tree->child2 != NULL) { + load_tree(obj, stream, tree->child2); + } + } + + /** Stores the index in a binary file. + * IMPORTANT NOTE: The set of data points is NOT stored in the file, so when + * loading the index object it must be constructed associated to the same + * source of data points used while building it. See the example: + * examples/saveload_example.cpp \sa loadIndex */ + void saveIndex_(Derived &obj, FILE *stream) { + save_value(stream, obj.m_size); + save_value(stream, obj.dim); + save_value(stream, obj.root_bbox); + save_value(stream, obj.m_leaf_max_size); + save_value(stream, obj.vind); + save_tree(obj, stream, obj.root_node); + } + + /** Loads a previous index from a binary file. + * IMPORTANT NOTE: The set of data points is NOT stored in the file, so the + * index object must be constructed associated to the same source of data + * points used while building the index. See the example: + * examples/saveload_example.cpp \sa loadIndex */ + void loadIndex_(Derived &obj, FILE *stream) { + load_value(stream, obj.m_size); + load_value(stream, obj.dim); + load_value(stream, obj.root_bbox); + load_value(stream, obj.m_leaf_max_size); + load_value(stream, obj.vind); + load_tree(obj, stream, obj.root_node); + } +}; + +/** @addtogroup kdtrees_grp KD-tree classes and adaptors + * @{ */ + +/** kd-tree static index + * + * Contains the k-d trees and other information for indexing a set of points + * for nearest-neighbor matching. + * + * The class "DatasetAdaptor" must provide the following interface (can be + * non-virtual, inlined methods): + * + * \code + * // Must return the number of data poins + * inline size_t kdtree_get_point_count() const { ... } + * + * + * // Must return the dim'th component of the idx'th point in the class: + * inline T kdtree_get_pt(const size_t idx, const size_t dim) const { ... } + * + * // Optional bounding-box computation: return false to default to a standard + * bbox computation loop. + * // Return true if the BBOX was already computed by the class and returned + * in "bb" so it can be avoided to redo it again. + * // Look at bb.size() to find out the expected dimensionality (e.g. 2 or 3 + * for point clouds) template bool kdtree_get_bbox(BBOX &bb) const + * { + * bb[0].low = ...; bb[0].high = ...; // 0th dimension limits + * bb[1].low = ...; bb[1].high = ...; // 1st dimension limits + * ... + * return true; + * } + * + * \endcode + * + * \tparam DatasetAdaptor The user-provided adaptor (see comments above). + * \tparam Distance The distance metric to use: nanoflann::metric_L1, + * nanoflann::metric_L2, nanoflann::metric_L2_Simple, etc. \tparam DIM + * Dimensionality of data points (e.g. 3 for 3D points) \tparam IndexType Will + * be typically size_t or int + */ +template +class KDTreeSingleIndexAdaptor + : public KDTreeBaseClass< + KDTreeSingleIndexAdaptor, + Distance, DatasetAdaptor, DIM, IndexType> { +public: + /** Deleted copy constructor*/ + KDTreeSingleIndexAdaptor( + const KDTreeSingleIndexAdaptor + &) = delete; + + /** + * The dataset used by this index + */ + const DatasetAdaptor &dataset; //!< The source of our data + + const KDTreeSingleIndexAdaptorParams index_params; + + Distance distance; + + typedef typename nanoflann::KDTreeBaseClass< + nanoflann::KDTreeSingleIndexAdaptor, + Distance, DatasetAdaptor, DIM, IndexType> + BaseClassRef; + + typedef typename BaseClassRef::ElementType ElementType; + typedef typename BaseClassRef::DistanceType DistanceType; + + typedef typename BaseClassRef::Node Node; + typedef Node *NodePtr; + + typedef typename BaseClassRef::Interval Interval; + /** Define "BoundingBox" as a fixed-size or variable-size container depending + * on "DIM" */ + typedef typename BaseClassRef::BoundingBox BoundingBox; + + /** Define "distance_vector_t" as a fixed-size or variable-size container + * depending on "DIM" */ + typedef typename BaseClassRef::distance_vector_t distance_vector_t; + + /** + * KDTree constructor + * + * Refer to docs in README.md or online in + * https://github.com/jlblancoc/nanoflann + * + * The KD-Tree point dimension (the length of each point in the datase, e.g. 3 + * for 3D points) is determined by means of: + * - The \a DIM template parameter if >0 (highest priority) + * - Otherwise, the \a dimensionality parameter of this constructor. + * + * @param inputData Dataset with the input features + * @param params Basically, the maximum leaf node size + */ + KDTreeSingleIndexAdaptor(const int dimensionality, + const DatasetAdaptor &inputData, + const KDTreeSingleIndexAdaptorParams ¶ms = + KDTreeSingleIndexAdaptorParams()) + : dataset(inputData), index_params(params), distance(inputData) { + BaseClassRef::root_node = NULL; + BaseClassRef::m_size = dataset.kdtree_get_point_count(); + BaseClassRef::m_size_at_index_build = BaseClassRef::m_size; + BaseClassRef::dim = dimensionality; + if (DIM > 0) + BaseClassRef::dim = DIM; + BaseClassRef::m_leaf_max_size = params.leaf_max_size; + + // Create a permutable array of indices to the input vectors. + init_vind(); + } + + /** + * Builds the index + */ + void buildIndex() { + BaseClassRef::m_size = dataset.kdtree_get_point_count(); + BaseClassRef::m_size_at_index_build = BaseClassRef::m_size; + init_vind(); + this->freeIndex(*this); + BaseClassRef::m_size_at_index_build = BaseClassRef::m_size; + if (BaseClassRef::m_size == 0) + return; + computeBoundingBox(BaseClassRef::root_bbox); + BaseClassRef::root_node = + this->divideTree(*this, 0, BaseClassRef::m_size, + BaseClassRef::root_bbox); // construct the tree + } + + /** \name Query methods + * @{ */ + + /** + * Find set of nearest neighbors to vec[0:dim-1]. Their indices are stored + * inside the result object. + * + * Params: + * result = the result object in which the indices of the + * nearest-neighbors are stored vec = the vector for which to search the + * nearest neighbors + * + * \tparam RESULTSET Should be any ResultSet + * \return True if the requested neighbors could be found. + * \sa knnSearch, radiusSearch + */ + template + bool findNeighbors(RESULTSET &result, const ElementType *vec, + const SearchParams &searchParams) const { + assert(vec); + if (this->size(*this) == 0) + return false; + if (!BaseClassRef::root_node) + throw std::runtime_error( + "[nanoflann] findNeighbors() called before building the index."); + float epsError = 1 + searchParams.eps; + + distance_vector_t + dists; // fixed or variable-sized container (depending on DIM) + auto zero = static_cast(0); + assign(dists, (DIM > 0 ? DIM : BaseClassRef::dim), + zero); // Fill it with zeros. + DistanceType distsq = this->computeInitialDistances(*this, vec, dists); + + searchLevel(result, vec, BaseClassRef::root_node, distsq, dists, + epsError); // "count_leaf" parameter removed since was neither + // used nor returned to the user. + + return result.full(); + } + + /** + * Find the "num_closest" nearest neighbors to the \a query_point[0:dim-1]. + * Their indices are stored inside the result object. \sa radiusSearch, + * findNeighbors \note nChecks_IGNORED is ignored but kept for compatibility + * with the original FLANN interface. \return Number `N` of valid points in + * the result set. Only the first `N` entries in `out_indices` and + * `out_distances_sq` will be valid. Return may be less than `num_closest` + * only if the number of elements in the tree is less than `num_closest`. + */ + size_t knnSearch(const ElementType *query_point, const size_t num_closest, + IndexType *out_indices, DistanceType *out_distances_sq, + const int /* nChecks_IGNORED */ = 10) const { + nanoflann::KNNResultSet resultSet(num_closest); + resultSet.init(out_indices, out_distances_sq); + this->findNeighbors(resultSet, query_point, nanoflann::SearchParams()); + return resultSet.size(); + } + + /** + * Find all the neighbors to \a query_point[0:dim-1] within a maximum radius. + * The output is given as a vector of pairs, of which the first element is a + * point index and the second the corresponding distance. Previous contents of + * \a IndicesDists are cleared. + * + * If searchParams.sorted==true, the output list is sorted by ascending + * distances. + * + * For a better performance, it is advisable to do a .reserve() on the vector + * if you have any wild guess about the number of expected matches. + * + * \sa knnSearch, findNeighbors, radiusSearchCustomCallback + * \return The number of points within the given radius (i.e. indices.size() + * or dists.size() ) + */ + size_t + radiusSearch(const ElementType *query_point, const DistanceType &radius, + std::vector> &IndicesDists, + const SearchParams &searchParams) const { + RadiusResultSet resultSet(radius, IndicesDists); + const size_t nFound = + radiusSearchCustomCallback(query_point, resultSet, searchParams); + if (searchParams.sorted) + std::sort(IndicesDists.begin(), IndicesDists.end(), IndexDist_Sorter()); + return nFound; + } + + /** + * Just like radiusSearch() but with a custom callback class for each point + * found in the radius of the query. See the source of RadiusResultSet<> as a + * start point for your own classes. \sa radiusSearch + */ + template + size_t radiusSearchCustomCallback( + const ElementType *query_point, SEARCH_CALLBACK &resultSet, + const SearchParams &searchParams = SearchParams()) const { + this->findNeighbors(resultSet, query_point, searchParams); + return resultSet.size(); + } + + /** @} */ + +public: + /** Make sure the auxiliary list \a vind has the same size than the current + * dataset, and re-generate if size has changed. */ + void init_vind() { + // Create a permutable array of indices to the input vectors. + BaseClassRef::m_size = dataset.kdtree_get_point_count(); + if (BaseClassRef::vind.size() != BaseClassRef::m_size) + BaseClassRef::vind.resize(BaseClassRef::m_size); + for (size_t i = 0; i < BaseClassRef::m_size; i++) + BaseClassRef::vind[i] = i; + } + + void computeBoundingBox(BoundingBox &bbox) { + resize(bbox, (DIM > 0 ? DIM : BaseClassRef::dim)); + if (dataset.kdtree_get_bbox(bbox)) { + // Done! It was implemented in derived class + } else { + const size_t N = dataset.kdtree_get_point_count(); + if (!N) + throw std::runtime_error("[nanoflann] computeBoundingBox() called but " + "no data points found."); + for (int i = 0; i < (DIM > 0 ? DIM : BaseClassRef::dim); ++i) { + bbox[i].low = bbox[i].high = this->dataset_get(*this, 0, i); + } + for (size_t k = 1; k < N; ++k) { + for (int i = 0; i < (DIM > 0 ? DIM : BaseClassRef::dim); ++i) { + if (this->dataset_get(*this, k, i) < bbox[i].low) + bbox[i].low = this->dataset_get(*this, k, i); + if (this->dataset_get(*this, k, i) > bbox[i].high) + bbox[i].high = this->dataset_get(*this, k, i); + } + } + } + } + + /** + * Performs an exact search in the tree starting from a node. + * \tparam RESULTSET Should be any ResultSet + * \return true if the search should be continued, false if the results are + * sufficient + */ + template + bool searchLevel(RESULTSET &result_set, const ElementType *vec, + const NodePtr node, DistanceType mindistsq, + distance_vector_t &dists, const float epsError) const { + /* If this is a leaf node, then do check and return. */ + if ((node->child1 == NULL) && (node->child2 == NULL)) { + // count_leaf += (node->lr.right-node->lr.left); // Removed since was + // neither used nor returned to the user. + DistanceType worst_dist = result_set.worstDist(); + for (IndexType i = node->node_type.lr.left; i < node->node_type.lr.right; + ++i) { + const IndexType index = BaseClassRef::vind[i]; // reorder... : i; + DistanceType dist = distance.evalMetric( + vec, index, (DIM > 0 ? DIM : BaseClassRef::dim)); + if (dist < worst_dist) { + if (!result_set.addPoint(dist, BaseClassRef::vind[i])) { + // the resultset doesn't want to receive any more points, we're done + // searching! + return false; + } + } + } + return true; + } + + /* Which child branch should be taken first? */ + int idx = node->node_type.sub.divfeat; + ElementType val = vec[idx]; + DistanceType diff1 = val - node->node_type.sub.divlow; + DistanceType diff2 = val - node->node_type.sub.divhigh; + + NodePtr bestChild; + NodePtr otherChild; + DistanceType cut_dist; + if ((diff1 + diff2) < 0) { + bestChild = node->child1; + otherChild = node->child2; + cut_dist = distance.accum_dist(val, node->node_type.sub.divhigh, idx); + } else { + bestChild = node->child2; + otherChild = node->child1; + cut_dist = distance.accum_dist(val, node->node_type.sub.divlow, idx); + } + + /* Call recursively to search next level down. */ + if (!searchLevel(result_set, vec, bestChild, mindistsq, dists, epsError)) { + // the resultset doesn't want to receive any more points, we're done + // searching! + return false; + } + + DistanceType dst = dists[idx]; + mindistsq = mindistsq + cut_dist - dst; + dists[idx] = cut_dist; + if (mindistsq * epsError <= result_set.worstDist()) { + if (!searchLevel(result_set, vec, otherChild, mindistsq, dists, + epsError)) { + // the resultset doesn't want to receive any more points, we're done + // searching! + return false; + } + } + dists[idx] = dst; + return true; + } + +public: + /** Stores the index in a binary file. + * IMPORTANT NOTE: The set of data points is NOT stored in the file, so when + * loading the index object it must be constructed associated to the same + * source of data points used while building it. See the example: + * examples/saveload_example.cpp \sa loadIndex */ + void saveIndex(FILE *stream) { this->saveIndex_(*this, stream); } + + /** Loads a previous index from a binary file. + * IMPORTANT NOTE: The set of data points is NOT stored in the file, so the + * index object must be constructed associated to the same source of data + * points used while building the index. See the example: + * examples/saveload_example.cpp \sa loadIndex */ + void loadIndex(FILE *stream) { this->loadIndex_(*this, stream); } + +}; // class KDTree + +/** kd-tree dynamic index + * + * Contains the k-d trees and other information for indexing a set of points + * for nearest-neighbor matching. + * + * The class "DatasetAdaptor" must provide the following interface (can be + * non-virtual, inlined methods): + * + * \code + * // Must return the number of data poins + * inline size_t kdtree_get_point_count() const { ... } + * + * // Must return the dim'th component of the idx'th point in the class: + * inline T kdtree_get_pt(const size_t idx, const size_t dim) const { ... } + * + * // Optional bounding-box computation: return false to default to a standard + * bbox computation loop. + * // Return true if the BBOX was already computed by the class and returned + * in "bb" so it can be avoided to redo it again. + * // Look at bb.size() to find out the expected dimensionality (e.g. 2 or 3 + * for point clouds) template bool kdtree_get_bbox(BBOX &bb) const + * { + * bb[0].low = ...; bb[0].high = ...; // 0th dimension limits + * bb[1].low = ...; bb[1].high = ...; // 1st dimension limits + * ... + * return true; + * } + * + * \endcode + * + * \tparam DatasetAdaptor The user-provided adaptor (see comments above). + * \tparam Distance The distance metric to use: nanoflann::metric_L1, + * nanoflann::metric_L2, nanoflann::metric_L2_Simple, etc. \tparam DIM + * Dimensionality of data points (e.g. 3 for 3D points) \tparam IndexType Will + * be typically size_t or int + */ +template +class KDTreeSingleIndexDynamicAdaptor_ + : public KDTreeBaseClass, + Distance, DatasetAdaptor, DIM, IndexType> { +public: + /** + * The dataset used by this index + */ + const DatasetAdaptor &dataset; //!< The source of our data + + KDTreeSingleIndexAdaptorParams index_params; + + std::vector &treeIndex; + + Distance distance; + + typedef typename nanoflann::KDTreeBaseClass< + nanoflann::KDTreeSingleIndexDynamicAdaptor_, + Distance, DatasetAdaptor, DIM, IndexType> + BaseClassRef; + + typedef typename BaseClassRef::ElementType ElementType; + typedef typename BaseClassRef::DistanceType DistanceType; + + typedef typename BaseClassRef::Node Node; + typedef Node *NodePtr; + + typedef typename BaseClassRef::Interval Interval; + /** Define "BoundingBox" as a fixed-size or variable-size container depending + * on "DIM" */ + typedef typename BaseClassRef::BoundingBox BoundingBox; + + /** Define "distance_vector_t" as a fixed-size or variable-size container + * depending on "DIM" */ + typedef typename BaseClassRef::distance_vector_t distance_vector_t; + + /** + * KDTree constructor + * + * Refer to docs in README.md or online in + * https://github.com/jlblancoc/nanoflann + * + * The KD-Tree point dimension (the length of each point in the datase, e.g. 3 + * for 3D points) is determined by means of: + * - The \a DIM template parameter if >0 (highest priority) + * - Otherwise, the \a dimensionality parameter of this constructor. + * + * @param inputData Dataset with the input features + * @param params Basically, the maximum leaf node size + */ + KDTreeSingleIndexDynamicAdaptor_( + const int dimensionality, const DatasetAdaptor &inputData, + std::vector &treeIndex_, + const KDTreeSingleIndexAdaptorParams ¶ms = + KDTreeSingleIndexAdaptorParams()) + : dataset(inputData), index_params(params), treeIndex(treeIndex_), + distance(inputData) { + BaseClassRef::root_node = NULL; + BaseClassRef::m_size = 0; + BaseClassRef::m_size_at_index_build = 0; + BaseClassRef::dim = dimensionality; + if (DIM > 0) + BaseClassRef::dim = DIM; + BaseClassRef::m_leaf_max_size = params.leaf_max_size; + } + + /** Assignment operator definiton */ + KDTreeSingleIndexDynamicAdaptor_ + operator=(const KDTreeSingleIndexDynamicAdaptor_ &rhs) { + KDTreeSingleIndexDynamicAdaptor_ tmp(rhs); + std::swap(BaseClassRef::vind, tmp.BaseClassRef::vind); + std::swap(BaseClassRef::m_leaf_max_size, tmp.BaseClassRef::m_leaf_max_size); + std::swap(index_params, tmp.index_params); + std::swap(treeIndex, tmp.treeIndex); + std::swap(BaseClassRef::m_size, tmp.BaseClassRef::m_size); + std::swap(BaseClassRef::m_size_at_index_build, + tmp.BaseClassRef::m_size_at_index_build); + std::swap(BaseClassRef::root_node, tmp.BaseClassRef::root_node); + std::swap(BaseClassRef::root_bbox, tmp.BaseClassRef::root_bbox); + std::swap(BaseClassRef::pool, tmp.BaseClassRef::pool); + return *this; + } + + /** + * Builds the index + */ + void buildIndex() { + BaseClassRef::m_size = BaseClassRef::vind.size(); + this->freeIndex(*this); + BaseClassRef::m_size_at_index_build = BaseClassRef::m_size; + if (BaseClassRef::m_size == 0) + return; + computeBoundingBox(BaseClassRef::root_bbox); + BaseClassRef::root_node = + this->divideTree(*this, 0, BaseClassRef::m_size, + BaseClassRef::root_bbox); // construct the tree + } + + /** \name Query methods + * @{ */ + + /** + * Find set of nearest neighbors to vec[0:dim-1]. Their indices are stored + * inside the result object. + * + * Params: + * result = the result object in which the indices of the + * nearest-neighbors are stored vec = the vector for which to search the + * nearest neighbors + * + * \tparam RESULTSET Should be any ResultSet + * \return True if the requested neighbors could be found. + * \sa knnSearch, radiusSearch + */ + template + bool findNeighbors(RESULTSET &result, const ElementType *vec, + const SearchParams &searchParams) const { + assert(vec); + if (this->size(*this) == 0) + return false; + if (!BaseClassRef::root_node) + return false; + float epsError = 1 + searchParams.eps; + + // fixed or variable-sized container (depending on DIM) + distance_vector_t dists; + // Fill it with zeros. + assign(dists, (DIM > 0 ? DIM : BaseClassRef::dim), + static_cast(0)); + DistanceType distsq = this->computeInitialDistances(*this, vec, dists); + + searchLevel(result, vec, BaseClassRef::root_node, distsq, dists, + epsError); // "count_leaf" parameter removed since was neither + // used nor returned to the user. + + return result.full(); + } + + /** + * Find the "num_closest" nearest neighbors to the \a query_point[0:dim-1]. + * Their indices are stored inside the result object. \sa radiusSearch, + * findNeighbors \note nChecks_IGNORED is ignored but kept for compatibility + * with the original FLANN interface. \return Number `N` of valid points in + * the result set. Only the first `N` entries in `out_indices` and + * `out_distances_sq` will be valid. Return may be less than `num_closest` + * only if the number of elements in the tree is less than `num_closest`. + */ + size_t knnSearch(const ElementType *query_point, const size_t num_closest, + IndexType *out_indices, DistanceType *out_distances_sq, + const int /* nChecks_IGNORED */ = 10) const { + nanoflann::KNNResultSet resultSet(num_closest); + resultSet.init(out_indices, out_distances_sq); + this->findNeighbors(resultSet, query_point, nanoflann::SearchParams()); + return resultSet.size(); + } + + /** + * Find all the neighbors to \a query_point[0:dim-1] within a maximum radius. + * The output is given as a vector of pairs, of which the first element is a + * point index and the second the corresponding distance. Previous contents of + * \a IndicesDists are cleared. + * + * If searchParams.sorted==true, the output list is sorted by ascending + * distances. + * + * For a better performance, it is advisable to do a .reserve() on the vector + * if you have any wild guess about the number of expected matches. + * + * \sa knnSearch, findNeighbors, radiusSearchCustomCallback + * \return The number of points within the given radius (i.e. indices.size() + * or dists.size() ) + */ + size_t + radiusSearch(const ElementType *query_point, const DistanceType &radius, + std::vector> &IndicesDists, + const SearchParams &searchParams) const { + RadiusResultSet resultSet(radius, IndicesDists); + const size_t nFound = + radiusSearchCustomCallback(query_point, resultSet, searchParams); + if (searchParams.sorted) + std::sort(IndicesDists.begin(), IndicesDists.end(), IndexDist_Sorter()); + return nFound; + } + + /** + * Just like radiusSearch() but with a custom callback class for each point + * found in the radius of the query. See the source of RadiusResultSet<> as a + * start point for your own classes. \sa radiusSearch + */ + template + size_t radiusSearchCustomCallback( + const ElementType *query_point, SEARCH_CALLBACK &resultSet, + const SearchParams &searchParams = SearchParams()) const { + this->findNeighbors(resultSet, query_point, searchParams); + return resultSet.size(); + } + + /** @} */ + +public: + void computeBoundingBox(BoundingBox &bbox) { + resize(bbox, (DIM > 0 ? DIM : BaseClassRef::dim)); + + if (dataset.kdtree_get_bbox(bbox)) { + // Done! It was implemented in derived class + } else { + const size_t N = BaseClassRef::m_size; + if (!N) + throw std::runtime_error("[nanoflann] computeBoundingBox() called but " + "no data points found."); + for (int i = 0; i < (DIM > 0 ? DIM : BaseClassRef::dim); ++i) { + bbox[i].low = bbox[i].high = + this->dataset_get(*this, BaseClassRef::vind[0], i); + } + for (size_t k = 1; k < N; ++k) { + for (int i = 0; i < (DIM > 0 ? DIM : BaseClassRef::dim); ++i) { + if (this->dataset_get(*this, BaseClassRef::vind[k], i) < bbox[i].low) + bbox[i].low = this->dataset_get(*this, BaseClassRef::vind[k], i); + if (this->dataset_get(*this, BaseClassRef::vind[k], i) > bbox[i].high) + bbox[i].high = this->dataset_get(*this, BaseClassRef::vind[k], i); + } + } + } + } + + /** + * Performs an exact search in the tree starting from a node. + * \tparam RESULTSET Should be any ResultSet + */ + template + void searchLevel(RESULTSET &result_set, const ElementType *vec, + const NodePtr node, DistanceType mindistsq, + distance_vector_t &dists, const float epsError) const { + /* If this is a leaf node, then do check and return. */ + if ((node->child1 == NULL) && (node->child2 == NULL)) { + // count_leaf += (node->lr.right-node->lr.left); // Removed since was + // neither used nor returned to the user. + DistanceType worst_dist = result_set.worstDist(); + for (IndexType i = node->node_type.lr.left; i < node->node_type.lr.right; + ++i) { + const IndexType index = BaseClassRef::vind[i]; // reorder... : i; + if (treeIndex[index] == -1) + continue; + DistanceType dist = distance.evalMetric( + vec, index, (DIM > 0 ? DIM : BaseClassRef::dim)); + if (dist < worst_dist) { + if (!result_set.addPoint( + static_cast(dist), + static_cast( + BaseClassRef::vind[i]))) { + // the resultset doesn't want to receive any more points, we're done + // searching! + return; // false; + } + } + } + return; + } + + /* Which child branch should be taken first? */ + int idx = node->node_type.sub.divfeat; + ElementType val = vec[idx]; + DistanceType diff1 = val - node->node_type.sub.divlow; + DistanceType diff2 = val - node->node_type.sub.divhigh; + + NodePtr bestChild; + NodePtr otherChild; + DistanceType cut_dist; + if ((diff1 + diff2) < 0) { + bestChild = node->child1; + otherChild = node->child2; + cut_dist = distance.accum_dist(val, node->node_type.sub.divhigh, idx); + } else { + bestChild = node->child2; + otherChild = node->child1; + cut_dist = distance.accum_dist(val, node->node_type.sub.divlow, idx); + } + + /* Call recursively to search next level down. */ + searchLevel(result_set, vec, bestChild, mindistsq, dists, epsError); + + DistanceType dst = dists[idx]; + mindistsq = mindistsq + cut_dist - dst; + dists[idx] = cut_dist; + if (mindistsq * epsError <= result_set.worstDist()) { + searchLevel(result_set, vec, otherChild, mindistsq, dists, epsError); + } + dists[idx] = dst; + } + +public: + /** Stores the index in a binary file. + * IMPORTANT NOTE: The set of data points is NOT stored in the file, so when + * loading the index object it must be constructed associated to the same + * source of data points used while building it. See the example: + * examples/saveload_example.cpp \sa loadIndex */ + void saveIndex(FILE *stream) { this->saveIndex_(*this, stream); } + + /** Loads a previous index from a binary file. + * IMPORTANT NOTE: The set of data points is NOT stored in the file, so the + * index object must be constructed associated to the same source of data + * points used while building the index. See the example: + * examples/saveload_example.cpp \sa loadIndex */ + void loadIndex(FILE *stream) { this->loadIndex_(*this, stream); } +}; + +/** kd-tree dynaimic index + * + * class to create multiple static index and merge their results to behave as + * single dynamic index as proposed in Logarithmic Approach. + * + * Example of usage: + * examples/dynamic_pointcloud_example.cpp + * + * \tparam DatasetAdaptor The user-provided adaptor (see comments above). + * \tparam Distance The distance metric to use: nanoflann::metric_L1, + * nanoflann::metric_L2, nanoflann::metric_L2_Simple, etc. \tparam DIM + * Dimensionality of data points (e.g. 3 for 3D points) \tparam IndexType Will + * be typically size_t or int + */ +template +class KDTreeSingleIndexDynamicAdaptor { +public: + typedef typename Distance::ElementType ElementType; + typedef typename Distance::DistanceType DistanceType; + +protected: + size_t m_leaf_max_size; + size_t treeCount; + size_t pointCount; + + /** + * The dataset used by this index + */ + const DatasetAdaptor &dataset; //!< The source of our data + + std::vector treeIndex; //!< treeIndex[idx] is the index of tree in which + //!< point at idx is stored. treeIndex[idx]=-1 + //!< means that point has been removed. + + KDTreeSingleIndexAdaptorParams index_params; + + int dim; //!< Dimensionality of each data point + + typedef KDTreeSingleIndexDynamicAdaptor_ + index_container_t; + std::vector index; + +public: + /** Get a const ref to the internal list of indices; the number of indices is + * adapted dynamically as the dataset grows in size. */ + const std::vector &getAllIndices() const { return index; } + +private: + /** finds position of least significant unset bit */ + int First0Bit(IndexType num) { + int pos = 0; + while (num & 1) { + num = num >> 1; + pos++; + } + return pos; + } + + /** Creates multiple empty trees to handle dynamic support */ + void init() { + typedef KDTreeSingleIndexDynamicAdaptor_ + my_kd_tree_t; + std::vector index_( + treeCount, my_kd_tree_t(dim /*dim*/, dataset, treeIndex, index_params)); + index = index_; + } + +public: + Distance distance; + + /** + * KDTree constructor + * + * Refer to docs in README.md or online in + * https://github.com/jlblancoc/nanoflann + * + * The KD-Tree point dimension (the length of each point in the datase, e.g. 3 + * for 3D points) is determined by means of: + * - The \a DIM template parameter if >0 (highest priority) + * - Otherwise, the \a dimensionality parameter of this constructor. + * + * @param inputData Dataset with the input features + * @param params Basically, the maximum leaf node size + */ + KDTreeSingleIndexDynamicAdaptor(const int dimensionality, + const DatasetAdaptor &inputData, + const KDTreeSingleIndexAdaptorParams ¶ms = + KDTreeSingleIndexAdaptorParams(), + const size_t maximumPointCount = 1000000000U) + : dataset(inputData), index_params(params), distance(inputData) { + treeCount = static_cast(std::log2(maximumPointCount)); + pointCount = 0U; + dim = dimensionality; + treeIndex.clear(); + if (DIM > 0) + dim = DIM; + m_leaf_max_size = params.leaf_max_size; + init(); + const size_t num_initial_points = dataset.kdtree_get_point_count(); + if (num_initial_points > 0) { + addPoints(0, num_initial_points - 1); + } + } + + /** Deleted copy constructor*/ + KDTreeSingleIndexDynamicAdaptor( + const KDTreeSingleIndexDynamicAdaptor &) = delete; + + /** Add points to the set, Inserts all points from [start, end] */ + void addPoints(IndexType start, IndexType end) { + size_t count = end - start + 1; + treeIndex.resize(treeIndex.size() + count); + for (IndexType idx = start; idx <= end; idx++) { + int pos = First0Bit(pointCount); + index[pos].vind.clear(); + treeIndex[pointCount] = pos; + for (int i = 0; i < pos; i++) { + for (int j = 0; j < static_cast(index[i].vind.size()); j++) { + index[pos].vind.push_back(index[i].vind[j]); + if (treeIndex[index[i].vind[j]] != -1) + treeIndex[index[i].vind[j]] = pos; + } + index[i].vind.clear(); + index[i].freeIndex(index[i]); + } + index[pos].vind.push_back(idx); + index[pos].buildIndex(); + pointCount++; + } + } + + /** Remove a point from the set (Lazy Deletion) */ + void removePoint(size_t idx) { + if (idx >= pointCount) + return; + treeIndex[idx] = -1; + } + + /** + * Find set of nearest neighbors to vec[0:dim-1]. Their indices are stored + * inside the result object. + * + * Params: + * result = the result object in which the indices of the + * nearest-neighbors are stored vec = the vector for which to search the + * nearest neighbors + * + * \tparam RESULTSET Should be any ResultSet + * \return True if the requested neighbors could be found. + * \sa knnSearch, radiusSearch + */ + template + bool findNeighbors(RESULTSET &result, const ElementType *vec, + const SearchParams &searchParams) const { + for (size_t i = 0; i < treeCount; i++) { + index[i].findNeighbors(result, &vec[0], searchParams); + } + return result.full(); + } +}; + +/** An L2-metric KD-tree adaptor for working with data directly stored in an + * Eigen Matrix, without duplicating the data storage. Each row in the matrix + * represents a point in the state space. + * + * Example of usage: + * \code + * Eigen::Matrix mat; + * // Fill out "mat"... + * + * typedef KDTreeEigenMatrixAdaptor< Eigen::Matrix > + * my_kd_tree_t; const int max_leaf = 10; my_kd_tree_t mat_index(mat, max_leaf + * ); mat_index.index->buildIndex(); mat_index.index->... \endcode + * + * \tparam DIM If set to >0, it specifies a compile-time fixed dimensionality + * for the points in the data set, allowing more compiler optimizations. \tparam + * Distance The distance metric to use: nanoflann::metric_L1, + * nanoflann::metric_L2, nanoflann::metric_L2_Simple, etc. + */ +template +struct KDTreeEigenMatrixAdaptor { + typedef KDTreeEigenMatrixAdaptor self_t; + typedef typename MatrixType::Scalar num_t; + typedef typename MatrixType::Index IndexType; + typedef + typename Distance::template traits::distance_t metric_t; + typedef KDTreeSingleIndexAdaptor + index_t; + + index_t *index; //! The kd-tree index for the user to call its methods as + //! usual with any other FLANN index. + + /// Constructor: takes a const ref to the matrix object with the data points + KDTreeEigenMatrixAdaptor(const size_t dimensionality, + const std::reference_wrapper &mat, + const int leaf_max_size = 10) + : m_data_matrix(mat) { + const auto dims = mat.get().cols(); + if (size_t(dims) != dimensionality) + throw std::runtime_error( + "Error: 'dimensionality' must match column count in data matrix"); + if (DIM > 0 && int(dims) != DIM) + throw std::runtime_error( + "Data set dimensionality does not match the 'DIM' template argument"); + index = + new index_t(static_cast(dims), *this /* adaptor */, + nanoflann::KDTreeSingleIndexAdaptorParams(leaf_max_size)); + index->buildIndex(); + } + +public: + /** Deleted copy constructor */ + KDTreeEigenMatrixAdaptor(const self_t &) = delete; + + ~KDTreeEigenMatrixAdaptor() { delete index; } + + const std::reference_wrapper m_data_matrix; + + /** Query for the \a num_closest closest points to a given point (entered as + * query_point[0:dim-1]). Note that this is a short-cut method for + * index->findNeighbors(). The user can also call index->... methods as + * desired. \note nChecks_IGNORED is ignored but kept for compatibility with + * the original FLANN interface. + */ + inline void query(const num_t *query_point, const size_t num_closest, + IndexType *out_indices, num_t *out_distances_sq, + const int /* nChecks_IGNORED */ = 10) const { + nanoflann::KNNResultSet resultSet(num_closest); + resultSet.init(out_indices, out_distances_sq); + index->findNeighbors(resultSet, query_point, nanoflann::SearchParams()); + } + + /** @name Interface expected by KDTreeSingleIndexAdaptor + * @{ */ + + const self_t &derived() const { return *this; } + self_t &derived() { return *this; } + + // Must return the number of data points + inline size_t kdtree_get_point_count() const { + return m_data_matrix.get().rows(); + } + + // Returns the dim'th component of the idx'th point in the class: + inline num_t kdtree_get_pt(const IndexType idx, size_t dim) const { + return m_data_matrix.get().coeff(idx, IndexType(dim)); + } + + // Optional bounding-box computation: return false to default to a standard + // bbox computation loop. + // Return true if the BBOX was already computed by the class and returned in + // "bb" so it can be avoided to redo it again. Look at bb.size() to find out + // the expected dimensionality (e.g. 2 or 3 for point clouds) + template bool kdtree_get_bbox(BBOX & /*bb*/) const { + return false; + } + + /** @} */ + +}; // end of KDTreeEigenMatrixAdaptor + /** @} */ + +/** @} */ // end of grouping +} // namespace nanoflann + +#endif /* NANOFLANN_HPP_ */ diff --git a/torch-points3d/torch_points3d/modules/KPConv/kernel_points.py b/torch-points3d/torch_points3d/modules/KPConv/kernel_points.py new file mode 100644 index 0000000..ecdae37 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/KPConv/kernel_points.py @@ -0,0 +1,413 @@ +# +# +# 0=================================0 +# | Kernel Point Convolutions | +# 0=================================0 +# +# +# ---------------------------------------------------------------------------------------------------------------------- +# +# Functions handling the disposition of kernel points. +# +# ---------------------------------------------------------------------------------------------------------------------- +# +# Hugues THOMAS - 11/06/2018 +# +import logging + +# ------------------------------------------------------------------------------------------ +# +# Imports and global variables +# \**********************************/ +# + + +# Import numpy package and name it "np" +import numpy as np +from os import makedirs +from os.path import join, exists + + +# ------------------------------------------------------------------------------------------ +# +# Functions +# \***************/ +# +# + +def create_3D_rotations(axis, angle): + """ + Create rotation matrices from a list of axes and angles. Code from wikipedia on quaternions + :param axis: float32[N, 3] + :param angle: float32[N,] + :return: float32[N, 3, 3] + """ + + t1 = np.cos(angle) + t2 = 1 - t1 + t3 = axis[:, 0] * axis[:, 0] + t6 = t2 * axis[:, 0] + t7 = t6 * axis[:, 1] + t8 = np.sin(angle) + t9 = t8 * axis[:, 2] + t11 = t6 * axis[:, 2] + t12 = t8 * axis[:, 1] + t15 = axis[:, 1] * axis[:, 1] + t19 = t2 * axis[:, 1] * axis[:, 2] + t20 = t8 * axis[:, 0] + t24 = axis[:, 2] * axis[:, 2] + R = np.stack([t1 + t2 * t3, + t7 - t9, + t11 + t12, + t7 + t9, + t1 + t2 * t15, + t19 - t20, + t11 - t12, + t19 + t20, + t1 + t2 * t24], axis=1) + + return np.reshape(R, (-1, 3, 3)) + + +def spherical_Lloyd(radius, num_cells, dimension=3, fixed='center', approximation='monte-carlo', + approx_n=5000, max_iter=500, momentum=0.9, verbose=0): + """ + Creation of kernel point via Lloyd algorithm. We use an approximation of the algorithm, and compute the Voronoi + cell centers with discretization of space. The exact formula is not trivial with part of the sphere as sides. + :param radius: Radius of the kernels + :param num_cells: Number of cell (kernel points) in the Voronoi diagram. + :param dimension: dimension of the space + :param fixed: fix position of certain kernel points ('none', 'center' or 'verticals') + :param approximation: Approximation method for Lloyd's algorithm ('discretization', 'monte-carlo') + :param approx_n: Number of point used for approximation. + :param max_iter: Maximum nu;ber of iteration for the algorithm. + :param momentum: Momentum of the low pass filter smoothing kernel point positions + :param verbose: display option + :return: points [num_kernels, num_points, dimension] + """ + + ####################### + # Parameters definition + ####################### + + # Radius used for optimization (points are rescaled afterwards) + radius0 = 1.0 + + ####################### + # Kernel initialization + ####################### + + # Random kernel points (Uniform distribution in a sphere) + kernel_points = np.zeros((0, dimension)) + while kernel_points.shape[0] < num_cells: + new_points = np.random.rand(num_cells, dimension) * 2 * radius0 - radius0 + kernel_points = np.vstack((kernel_points, new_points)) + d2 = np.sum(np.power(kernel_points, 2), axis=1) + kernel_points = kernel_points[np.logical_and(d2 < radius0 ** 2, (0.9 * radius0) ** 2 < d2), :] + kernel_points = kernel_points[:num_cells, :].reshape((num_cells, -1)) + + # Optional fixing + if fixed == 'center': + kernel_points[0, :] *= 0 + if fixed == 'verticals': + kernel_points[:3, :] *= 0 + kernel_points[1, -1] += 2 * radius0 / 3 + kernel_points[2, -1] -= 2 * radius0 / 3 + + ############################## + # Approximation initialization + ############################## + + # Initialize discretization in this method is chosen + if approximation == 'discretization': + side_n = int(np.floor(approx_n ** (1. / dimension))) + dl = 2 * radius0 / side_n + coords = np.arange(-radius0 + dl / 2, radius0, dl) + if dimension == 2: + x, y = np.meshgrid(coords, coords) + X = np.vstack((np.ravel(x), np.ravel(y))).T + elif dimension == 3: + x, y, z = np.meshgrid(coords, coords, coords) + X = np.vstack((np.ravel(x), np.ravel(y), np.ravel(z))).T + elif dimension == 4: + x, y, z, t = np.meshgrid(coords, coords, coords, coords) + X = np.vstack((np.ravel(x), np.ravel(y), np.ravel(z), np.ravel(t))).T + else: + raise ValueError('Unsupported dimension (max is 4)') + elif approximation == 'monte-carlo': + X = np.zeros((0, dimension)) + else: + raise ValueError('Wrong approximation method chosen: "{:s}"'.format(approximation)) + + # Only points inside the sphere are used + d2 = np.sum(np.power(X, 2), axis=1) + X = X[d2 < radius0 * radius0, :] + + ##################### + # Kernel optimization + ##################### + + # Warning if at least one kernel point has no cell + warning = False + + # moving vectors of kernel points saved to detect convergence + max_moves = np.zeros((0,)) + + for iter in range(max_iter): + + # In the case of monte-carlo, renew the sampled points + if approximation == 'monte-carlo': + X = np.random.rand(approx_n, dimension) * 2 * radius0 - radius0 + d2 = np.sum(np.power(X, 2), axis=1) + X = X[d2 < radius0 * radius0, :] + + # Get the distances matrix [n_approx, K, dim] + differences = np.expand_dims(X, 1) - kernel_points + sq_distances = np.sum(np.square(differences), axis=2) + + # Compute cell centers + cell_inds = np.argmin(sq_distances, axis=1) + centers = [] + for c in range(num_cells): + bool_c = (cell_inds == c) + num_c = np.sum(bool_c.astype(np.int32)) + if num_c > 0: + centers.append(np.sum(X[bool_c, :], axis=0) / num_c) + else: + warning = True + centers.append(kernel_points[c]) + + # Update kernel points with low pass filter to smooth mote carlo + centers = np.vstack(centers) + moves = (1 - momentum) * (centers - kernel_points) + kernel_points += moves + + # Check moves for convergence + max_moves = np.append(max_moves, np.max(np.linalg.norm(moves, axis=1))) + + # Optional fixing + if fixed == 'center': + kernel_points[0, :] *= 0 + if fixed == 'verticals': + kernel_points[0, :] *= 0 + kernel_points[:3, :-1] *= 0 + + if verbose: + logging.log('iter {:5d} / max move = {:f}'.format(iter, np.max(np.linalg.norm(moves, axis=1)))) + if warning: + logging.warning('{t least one point has no cell') + + # Rescale kernels with real radius + return kernel_points * radius + + +def kernel_point_optimization_debug(radius, num_points, num_kernels=1, dimension=3, + fixed='center', ratio=0.66, verbose=0): + """ + Creation of kernel point via optimization of potentials. + :param radius: Radius of the kernels + :param num_points: points composing kernels + :param num_kernels: number of wanted kernels + :param dimension: dimension of the space + :param fixed: fix position of certain kernel points ('none', 'center' or 'verticals') + :param ratio: ratio of the radius where you want the kernels points to be placed + :param verbose: display option + :return: points [num_kernels, num_points, dimension] + """ + + ####################### + # Parameters definition + ####################### + + # Radius used for optimization (points are rescaled afterwards) + radius0 = 1 + diameter0 = 2 + + # Factor multiplicating gradients for moving points (~learning rate) + moving_factor = 1e-2 + continuous_moving_decay = 0.9995 + + # Gradient threshold to stop optimization + thresh = 1e-5 + + # Gradient clipping value + clip = 0.05 * radius0 + + ####################### + # Kernel initialization + ####################### + + # Random kernel points + kernel_points = np.random.rand(num_kernels * num_points - 1, dimension) * diameter0 - radius0 + while (kernel_points.shape[0] < num_kernels * num_points): + new_points = np.random.rand(num_kernels * num_points - 1, dimension) * diameter0 - radius0 + kernel_points = np.vstack((kernel_points, new_points)) + d2 = np.sum(np.power(kernel_points, 2), axis=1) + kernel_points = kernel_points[d2 < 0.5 * radius0 * radius0, :] + kernel_points = kernel_points[:num_kernels * num_points, :].reshape((num_kernels, num_points, -1)) + + # Optional fixing + if fixed == 'center': + kernel_points[:, 0, :] *= 0 + if fixed == 'verticals': + kernel_points[:, :3, :] *= 0 + kernel_points[:, 1, -1] += 2 * radius0 / 3 + kernel_points[:, 2, -1] -= 2 * radius0 / 3 + + ##################### + # Kernel optimization + ##################### + + saved_gradient_norms = np.zeros((10000, num_kernels)) + old_gradient_norms = np.zeros((num_kernels, num_points)) + step = -1 + while step < 10000: + + # Increment + step += 1 + + # Compute gradients + # ***************** + + # Derivative of the sum of potentials of all points + A = np.expand_dims(kernel_points, axis=2) + B = np.expand_dims(kernel_points, axis=1) + interd2 = np.sum(np.power(A - B, 2), axis=-1) + inter_grads = (A - B) / (np.power(np.expand_dims(interd2, -1), 3 / 2) + 1e-6) + inter_grads = np.sum(inter_grads, axis=1) + + # Derivative of the radius potential + circle_grads = 10 * kernel_points + + # All gradients + gradients = inter_grads + circle_grads + + if fixed == 'verticals': + gradients[:, 1:3, :-1] = 0 + + # Stop condition + # ************** + + # Compute norm of gradients + gradients_norms = np.sqrt(np.sum(np.power(gradients, 2), axis=-1)) + saved_gradient_norms[step, :] = np.max(gradients_norms, axis=1) + + # Stop if all moving points are gradients fixed (low gradients diff) + + if fixed == 'center' and np.max(np.abs(old_gradient_norms[:, 1:] - gradients_norms[:, 1:])) < thresh: + break + elif fixed == 'verticals' and np.max(np.abs(old_gradient_norms[:, 3:] - gradients_norms[:, 3:])) < thresh: + break + elif np.max(np.abs(old_gradient_norms - gradients_norms)) < thresh: + break + old_gradient_norms = gradients_norms + + # Move points + # *********** + + # Clip gradient to get moving dists + moving_dists = np.minimum(moving_factor * gradients_norms, clip) + + # Fix central point + if fixed == 'center': + moving_dists[:, 0] = 0 + if fixed == 'verticals': + moving_dists[:, 0] = 0 + + # Move points + kernel_points -= np.expand_dims(moving_dists, -1) * gradients / np.expand_dims(gradients_norms + 1e-6, -1) + + if verbose: + logging.log('step {:5d} / max grad = {:f}'.format(step, np.max(gradients_norms[:, 3:]))) + + # moving factor decay + moving_factor *= continuous_moving_decay + + # Remove unused lines in the saved gradients + if step < 10000: + saved_gradient_norms = saved_gradient_norms[:step + 1, :] + + # Rescale radius to fit the wanted ratio of radius + r = np.sqrt(np.sum(np.power(kernel_points, 2), axis=-1)) + kernel_points *= ratio / np.mean(r[:, 1:]) + + # Rescale kernels with real radius + return kernel_points * radius, saved_gradient_norms + + +def load_kernels(radius, num_kpoints, dimension, fixed, lloyd=False): + # Kernel directory + kernel_dir = 'kernels/dispositions' + if not exists(kernel_dir): + makedirs(kernel_dir) + + # To many points switch to Lloyds + if num_kpoints > 30: + lloyd = True + + # Kernel_file + kernel_file = join(kernel_dir, 'k_{:03d}_{:s}_{:d}D.ply'.format(num_kpoints, fixed, dimension)) + + # Check if already done + if not exists(kernel_file): + if lloyd: + # Create kernels + kernel_points = spherical_Lloyd(1.0, + num_kpoints, + dimension=dimension, + fixed=fixed, + verbose=0) + + else: + # Create kernels + kernel_points, grad_norms = kernel_point_optimization_debug(1.0, + num_kpoints, + num_kernels=100, + dimension=dimension, + fixed=fixed, + verbose=0) + + # Find best candidate + best_k = np.argmin(grad_norms[-1, :]) + + # Save points + kernel_points = kernel_points[best_k, :, :] + + # Random roations for the kernel + # N.B. 4D random rotations not supported yet + R = np.eye(dimension) + theta = np.random.rand() * 2 * np.pi + if dimension == 2: + if fixed != 'vertical': + c, s = np.cos(theta), np.sin(theta) + R = np.array([[c, -s], [s, c]], dtype=np.float32) + + elif dimension == 3: + if fixed != 'vertical': + c, s = np.cos(theta), np.sin(theta) + R = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]], dtype=np.float32) + + else: + phi = (np.random.rand() - 0.5) * np.pi + + # Create the first vector in carthesian coordinates + u = np.array([np.cos(theta) * np.cos(phi), np.sin(theta) * np.cos(phi), np.sin(phi)]) + + # Choose a random rotation angle + alpha = np.random.rand() * 2 * np.pi + + # Create the rotation matrix with this vector and angle + R = create_3D_rotations(np.reshape(u, (1, -1)), np.reshape(alpha, (1, -1)))[0] + + R = R.astype(np.float32) + + # Add a small noise + kernel_points = kernel_points + np.random.normal(scale=0.01, size=kernel_points.shape) + + # Scale kernels + kernel_points = radius * kernel_points + + # Rotate kernels + kernel_points = np.matmul(kernel_points, R) + + return kernel_points.astype(np.float32) diff --git a/torch-points3d/torch_points3d/modules/MinkowskiEngine/PointNet.py b/torch-points3d/torch_points3d/modules/MinkowskiEngine/PointNet.py new file mode 100644 index 0000000..c8a4faa --- /dev/null +++ b/torch-points3d/torch_points3d/modules/MinkowskiEngine/PointNet.py @@ -0,0 +1,49 @@ +import MinkowskiEngine as ME +import torch +import torch.nn as nn +from torch.cuda.amp import custom_fwd + +from .common import ACTIVATIONS, GLOBAL_POOL + + +class MinkowskiPointNet(nn.Module): + def __init__(self, in_channels, out_channels, activation="relu", global_pool="max", embedding_channel=1024, D=3, + dropout=0.0, bn_momentum=.1, + **kwargs): + super().__init__() + self.act_fn = ACTIVATIONS[activation]() + + self.blocks = nn.Sequential( + ME.MinkowskiLinear(D + in_channels, 64, bias=False), + ME.MinkowskiBatchNorm(64, momentum=bn_momentum), + self.act_fn, + + ME.MinkowskiLinear(64, 128, bias=False), + ME.MinkowskiBatchNorm(128, momentum=bn_momentum), + self.act_fn, + + ME.MinkowskiLinear(128, embedding_channel, bias=False), + ME.MinkowskiBatchNorm(embedding_channel, momentum=bn_momentum), + self.act_fn, + ) + self.global_pool = GLOBAL_POOL[global_pool]() + + self.mlp = nn.Sequential( + ME.MinkowskiLinear(embedding_channel, 512, bias=False), + ME.MinkowskiBatchNorm(512, momentum=bn_momentum), + self.act_fn, + + ME.MinkowskiLinear(512, 256, bias=False), + ME.MinkowskiBatchNorm(256, momentum=bn_momentum), + self.act_fn, + ) + self.dp1 = ME.MinkowskiDropout(dropout) + self.final = ME.MinkowskiLinear(256, out_channels, bias=True) + + @custom_fwd(cast_inputs=torch.float32) + def forward(self, x): + x = self.blocks(x) + x = self.global_pool(x) + x = self.mlp(x) + x = self.dp1(x) + return self.final(x) diff --git a/torch-points3d/torch_points3d/modules/MinkowskiEngine/SENet.py b/torch-points3d/torch_points3d/modules/MinkowskiEngine/SENet.py new file mode 100644 index 0000000..64280f1 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/MinkowskiEngine/SENet.py @@ -0,0 +1,194 @@ +from functools import partial + +import MinkowskiEngine as ME +import torch.nn as nn +from MinkowskiEngine import MinkowskiNormalization as N + +from .common import ConvNormActivation, MinkowskiLayerNorm, ACTIVATIONS, GLOBAL_POOL +from .resnet_block import BasicBlock, Bottleneck +from .senet_block import SEBasicBlock, SEBottleneck + + + + +class ResNetBase(nn.Module): + BLOCK = None + LAYERS = () + INIT_DIM = 64 + PLANES = (64, 128, 256, 512) + + def __init__(self, in_channels, out_channels, activation="relu", D=3, first_stride=2, dropout=0.0, drop_path=0.0, + bn_momentum=0.1, norm_type="bn", global_pool="mean", use_gn=False, bias=True, **kwargs): + nn.Module.__init__(self) + self.D = D + self.bias = bias + self.bn_momentum = bn_momentum + self.cross_dims = [] + self.drop_path = drop_path + assert self.BLOCK is not None, "BLOCK is not defined" + assert self.PLANES is not None, "PLANES is not defined" + assert self.STRIDES is not None, "STRIDES is not defined" + + self.act_fn = ACTIVATIONS[activation]() + self.norm_type = norm_type + if norm_type == "bn": + self.norm_layer = partial(N.MinkowskiBatchNorm, momentum=bn_momentum) + elif norm_type == "bn_no_affine": + self.norm_layer = partial(N.MinkowskiBatchNorm, momentum=bn_momentum, affine=False) + elif norm_type == "in": + self.norm_layer = N.MinkowskiInstanceNorm + elif norm_type == "ln": + self.norm_layer = MinkowskiLayerNorm + else: + raise NotImplementedError(f"Choose either 'bn', 'in', or 'ln'. Given: {norm_type}") + + self.inplanes = self.INIT_DIM + first_out_planes = self.inplanes + self.blocks = [ + nn.Sequential( + ConvNormActivation( + in_channels, first_out_planes, kernel_size=7, stride=first_stride, D=D, + bias=bias, activation_layer=self.act_fn, norm_layer=self.norm_layer + ), + ME.MinkowskiMaxPooling(kernel_size=3, stride=2, dimension=D) + ) + ] + + for planes, layers, stride in zip(self.PLANES, self.LAYERS, self.STRIDES): + self.blocks.append( + self._make_layer(self.BLOCK, planes, layers, stride=stride) + ) + self.blocks = nn.ModuleList(self.blocks) + + self.glob_avg = GLOBAL_POOL[global_pool]() # dimension=D) + if dropout > 0: + self.glob_avg = nn.Sequential( + self.glob_avg, + ME.MinkowskiDropout(dropout), + ) + + self.final = ME.MinkowskiLinear(self.inplanes, out_channels, bias=True) + + self.apply(self.init_weights) + + @staticmethod + def init_weights(m): + if isinstance(m, ME.MinkowskiBatchNorm): + nn.init.constant_(m.bn.weight, 1) + nn.init.constant_(m.bn.bias, 0) + + if isinstance(m, ME.MinkowskiConvolution): + nn.init.trunc_normal_(m.kernel, std=.02) + if m.bias is not None: + nn.init.constant_(m.bias, 0) + if isinstance(m, ME.MinkowskiLinear): + nn.init.trunc_normal_(m.linear.weight, std=.02) + if m.linear.bias is not None: + nn.init.constant_(m.linear.bias, 0) + + + def _make_layer(self, block, planes, blocks, stride=1, dilation=1): + downsample = None + if stride != 1 or self.inplanes != planes * block.expansion: + downsample = nn.Sequential( + ME.MinkowskiConvolution( + self.inplanes, planes * block.expansion, kernel_size=1, stride=stride, dimension=self.D, + dilation=1, bias=self.bias, + ), + self.norm_layer(planes * block.expansion), + ) + layers = [block( + self.inplanes, planes, self.act_fn, stride=stride, dilation=dilation, downsample=downsample, + dimension=self.D, drop_path=self.drop_path, bias=self.bias, norm_layer=self.norm_layer + )] + self.inplanes = planes * block.expansion + for i in range(1, blocks): + layers.append(block( + self.inplanes, planes, self.act_fn, stride=1, dilation=dilation, dimension=self.D, + drop_path=self.drop_path, bias=self.bias, norm_layer=self.norm_layer + )) + + return nn.Sequential(*layers) + + def forward(self, x): + for block in self.blocks: + x = block(x) + + x = self.glob_avg(x) + return self.final(x) + + +class ResNet14_(ResNetBase): + BLOCK = BasicBlock + LAYERS = (1, 1, 1, 1) + STRIDES = (1, 2, 2, 2) + + +class ResNet18_(ResNetBase): + BLOCK = BasicBlock + LAYERS = (2, 2, 2, 2) + STRIDES = (1, 2, 2, 2) + + +class ResNet34_(ResNetBase): + BLOCK = BasicBlock + LAYERS = (3, 4, 6, 3) + STRIDES = (1, 2, 2, 2) + + +class ResNet50_(ResNetBase): + BLOCK = Bottleneck + LAYERS = (3, 4, 6, 3) + STRIDES = (1, 2, 2, 2) + + +class ResNet101_(ResNetBase): + BLOCK = Bottleneck + LAYERS = (3, 4, 23, 3) + STRIDES = (1, 2, 2, 2) + + +class SENet14(ResNetBase): + BLOCK = SEBasicBlock + LAYERS = (1, 1, 1, 1) + STRIDES = (1, 2, 2, 2) + + +class SENet17_6deep(ResNetBase): + BLOCK = SEBasicBlock + LAYERS = (1, 1, 1, 1, 2, 1) + STRIDES = (1, 2, 2, 2, 2, 2) + INIT_DIM = 32 + PLANES = (32, 64, 128, 256, 512, 1024) + + +class SENet17_5deep(ResNetBase): + BLOCK = SEBasicBlock + LAYERS = (1, 1, 1, 2, 2) + STRIDES = (1, 2, 2, 2, 2) + INIT_DIM = 64 + PLANES = (64, 128, 256, 512, 1024) + + +class SENet18(ResNetBase): + BLOCK = SEBasicBlock + LAYERS = (2, 2, 2, 2) + STRIDES = (1, 2, 2, 2) + + +class SENet34(ResNetBase): + BLOCK = SEBasicBlock + LAYERS = (3, 4, 6, 3) + STRIDES = (1, 2, 2, 2) + + +class SENet50(ResNetBase): + BLOCK = SEBottleneck + LAYERS = (3, 4, 6, 3) + STRIDES = (1, 2, 2, 2) + + +class SENet101(ResNetBase): + BLOCK = SEBottleneck + LAYERS = (3, 4, 23, 3) + STRIDES = (1, 2, 2, 2) diff --git a/torch-points3d/torch_points3d/modules/MinkowskiEngine/__init__.py b/torch-points3d/torch_points3d/modules/MinkowskiEngine/__init__.py new file mode 100644 index 0000000..1227285 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/MinkowskiEngine/__init__.py @@ -0,0 +1,25 @@ +import sys + + +from .networks import * +from .SENet import * +from .VAE import * +from .barlow import * +from .UNet import * +from .res16unet import * +from .resunet import * +from .PointNet import MinkowskiPointNet + +_custom_models = sys.modules[__name__] + + +def initialize_minkowski_unet( + model_name, in_channels, out_channels, D=3, conv1_kernel_size=3, **kwargs +): + net_cls = getattr(_custom_models, model_name) + return net_cls( + in_channels=in_channels, out_channels=out_channels, D=D, conv1_kernel_size=conv1_kernel_size, **kwargs + ) + + + diff --git a/torch-points3d/torch_points3d/modules/MinkowskiEngine/api_modules.py b/torch-points3d/torch_points3d/modules/MinkowskiEngine/api_modules.py new file mode 100644 index 0000000..137b546 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/MinkowskiEngine/api_modules.py @@ -0,0 +1,311 @@ +import sys + +import MinkowskiEngine as ME +import torch + +from torch_points3d.core.common_modules import Seq + + +class ResBlock(ME.MinkowskiNetwork): + """ + Basic ResNet type block + + Parameters + ---------- + input_nc: + Number of input channels + output_nc: + number of output channels + convolution + Either MinkowskConvolution or MinkowskiConvolutionTranspose + dimension: + Dimension of the spatial grid + """ + + def __init__(self, input_nc, output_nc, convolution, dimension=3): + ME.MinkowskiNetwork.__init__(self, dimension) + self.block = ( + Seq() + .append( + convolution( + in_channels=input_nc, + out_channels=output_nc, + kernel_size=3, + stride=1, + dilation=1, + bias=False, + dimension=dimension, + ) + ) + .append(ME.MinkowskiBatchNorm(output_nc)) + .append(ME.MinkowskiReLU()) + .append( + convolution( + in_channels=output_nc, + out_channels=output_nc, + kernel_size=3, + stride=1, + dilation=1, + bias=False, + dimension=dimension, + ) + ) + .append(ME.MinkowskiBatchNorm(output_nc)) + .append(ME.MinkowskiReLU()) + ) + + if input_nc != output_nc: + self.downsample = ( + Seq() + .append( + convolution( + in_channels=input_nc, + out_channels=output_nc, + kernel_size=1, + stride=1, + dilation=1, + bias=False, + dimension=dimension, + ) + ) + .append(ME.MinkowskiBatchNorm(output_nc)) + ) + else: + self.downsample = None + + def forward(self, x): + out = self.block(x) + if self.downsample: + out += self.downsample(x) + else: + out += x + return out + + +class BottleneckBlock(ME.MinkowskiNetwork): + """ + Bottleneck block with residual + """ + + def __init__(self, input_nc, output_nc, convolution, dimension=3, reduction=4): + self.block = ( + Seq() + .append( + convolution( + in_channels=input_nc, + out_channels=output_nc // reduction, + kernel_size=1, + stride=1, + dilation=1, + bias=False, + dimension=dimension, + ) + ) + .append(ME.MinkowskiBatchNorm(output_nc // reduction)) + .append(ME.MinkowskiReLU()) + .append( + convolution( + output_nc // reduction, + output_nc // reduction, + kernel_size=3, + stride=1, + dilation=1, + bias=False, + dimension=dimension, + ) + ) + .append(ME.MinkowskiBatchNorm(output_nc // reduction)) + .append(ME.MinkowskiReLU()) + .append( + convolution( + output_nc // reduction, + output_nc, + kernel_size=1, + stride=1, + dilation=1, + bias=False, + dimension=dimension, + ) + ) + .append(ME.MinkowskiBatchNorm(output_nc)) + .append(ME.MinkowskiReLU()) + ) + + if input_nc != output_nc: + self.downsample = ( + Seq() + .append( + convolution( + in_channels=input_nc, + out_channels=output_nc, + kernel_size=1, + stride=1, + dilation=1, + bias=False, + dimension=dimension, + ) + ) + .append(ME.MinkowskiBatchNorm(output_nc)) + ) + else: + self.downsample = None + + def forward(self, x): + out = self.block(x) + if self.downsample: + out += self.downsample(x) + else: + out += x + return out + + +class SELayer(torch.nn.Module): + """ + Squeeze and excite layer + + Parameters + ---------- + channel: + size of the input and output + reduction: + magnitude of the compression + D: + dimension of the kernels + """ + + def __init__(self, channel, reduction=16, dimension=3): + # Global coords does not require coords_key + super(SELayer, self).__init__() + self.fc = torch.nn.Sequential( + ME.MinkowskiLinear(channel, channel // reduction), + ME.MinkowskiReLU(), + ME.MinkowskiLinear(channel // reduction, channel), + ME.MinkowskiSigmoid(), + ) + self.pooling = ME.MinkowskiGlobalPooling() + self.broadcast_mul = ME.MinkowskiBroadcastMultiplication() + + def forward(self, x): + y = self.pooling(x) + y = self.fc(y) + return self.broadcast_mul(x, y) + + +class SEBlock(ResBlock): + """ + ResBlock with SE layer + """ + + def __init__(self, input_nc, output_nc, convolution, dimension=3, reduction=16): + super().__init__(input_nc, output_nc, convolution, dimension=3) + self.SE = SELayer(output_nc, reduction=reduction, dimension=dimension) + + def forward(self, x): + out = self.block(x) + out = self.SE(out) + if self.downsample: + out += self.downsample(x) + else: + out += x + return out + + +class SEBottleneckBlock(BottleneckBlock): + """ + BottleneckBlock with SE layer + """ + + def __init__(self, input_nc, output_nc, convolution, dimension=3, reduction=16): + super().__init__(input_nc, output_nc, convolution, dimension=3, reduction=4) + self.SE = SELayer(output_nc, reduction=reduction, dimension=dimension) + + def forward(self, x): + out = self.block(x) + out = self.SE(out) + if self.downsample: + out += self.downsample(x) + else: + out += x + return out + + +_res_blocks = sys.modules[__name__] + + +class ResNetDown(ME.MinkowskiNetwork): + """ + Resnet block that looks like + + in --- strided conv ---- Block ---- sum --[... N times] + | | + |-- 1x1 - BN --| + """ + + CONVOLUTION = ME.MinkowskiConvolution + + def __init__( + self, down_conv_nn=[], kernel_size=2, dilation=1, dimension=3, stride=2, N=1, block="ResBlock", **kwargs + ): + block = getattr(_res_blocks, block) + ME.MinkowskiNetwork.__init__(self, dimension) + if stride > 1: + conv1_output = down_conv_nn[0] + else: + conv1_output = down_conv_nn[1] + + self.conv_in = ( + Seq() + .append( + self.CONVOLUTION( + in_channels=down_conv_nn[0], + out_channels=conv1_output, + kernel_size=kernel_size, + stride=stride, + dilation=dilation, + bias=False, + dimension=dimension, + ) + ) + .append(ME.MinkowskiBatchNorm(conv1_output)) + .append(ME.MinkowskiReLU()) + ) + + if N > 0: + self.blocks = Seq() + for _ in range(N): + self.blocks.append(block(conv1_output, down_conv_nn[1], self.CONVOLUTION, dimension=dimension)) + conv1_output = down_conv_nn[1] + else: + self.blocks = None + + def forward(self, x): + out = self.conv_in(x) + if self.blocks: + out = self.blocks(out) + return out + + +class ResNetUp(ResNetDown): + """ + Same as Down conv but for the Decoder + """ + + CONVOLUTION = ME.MinkowskiConvolutionTranspose + + def __init__(self, up_conv_nn=[], kernel_size=2, dilation=1, dimension=3, stride=2, N=1, **kwargs): + super().__init__( + down_conv_nn=up_conv_nn, + kernel_size=kernel_size, + dilation=dilation, + dimension=dimension, + stride=stride, + N=N, + **kwargs + ) + + def forward(self, x, skip): + if skip is not None: + inp = ME.cat(x, skip) + else: + inp = x + return super().forward(inp) diff --git a/torch-points3d/torch_points3d/modules/MinkowskiEngine/common.py b/torch-points3d/torch_points3d/modules/MinkowskiEngine/common.py new file mode 100644 index 0000000..293788e --- /dev/null +++ b/torch-points3d/torch_points3d/modules/MinkowskiEngine/common.py @@ -0,0 +1,386 @@ +import collections +import random +from enum import Enum +from functools import partial + +import MinkowskiEngine as ME +import torch +import torch.nn as nn +from MinkowskiEngine import MinkowskiNonlinearity as NL +from MinkowskiEngine import SparseTensor + + +class NormType(Enum): + BATCH_NORM = 0 + INSTANCE_NORM = 1 + INSTANCE_BATCH_NORM = 2 + + +def get_norm(norm_type, n_channels, D, bn_momentum=0.1): + if norm_type == NormType.BATCH_NORM: + return ME.MinkowskiBatchNorm(n_channels, momentum=bn_momentum) + elif norm_type == NormType.INSTANCE_NORM: + return ME.MinkowskiInstanceNorm(n_channels) + elif norm_type == NormType.INSTANCE_BATCH_NORM: + return nn.Sequential( + ME.MinkowskiInstanceNorm(n_channels), ME.MinkowskiBatchNorm(n_channels, momentum=bn_momentum) + ) + else: + raise ValueError(f"Norm type: {norm_type} not supported") + + +ACTIVATIONS = { + "relu": partial(NL.MinkowskiReLU, inplace=True), + "celu": partial(NL.MinkowskiCELU, inplace=True, alpha=0.54), + "silu": partial(NL.MinkowskiSiLU, inplace=True), + "swish": partial(NL.MinkowskiSiLU, inplace=True), + "elu": partial(NL.MinkowskiELU, inplace=True, alpha=0.54), + "sigmoid": partial(NL.MinkowskiSigmoid), + "tanh": partial(NL.MinkowskiTanh), + "siren": partial(NL.MinkowskiSinusoidal), + "gelu": partial(NL.MinkowskiGELU), +} + +GLOBAL_POOL = { + "max": ME.MinkowskiGlobalMaxPooling, + "mean": ME.MinkowskiGlobalAvgPooling, + "sum": ME.MinkowskiGlobalSumPooling, +} + + +class ConvType(Enum): + """ + Define the kernel region type + """ + + HYPERCUBE = 0, "HYPERCUBE" + SPATIAL_HYPERCUBE = 1, "SPATIAL_HYPERCUBE" + SPATIO_TEMPORAL_HYPERCUBE = 2, "SPATIO_TEMPORAL_HYPERCUBE" + HYPERCROSS = 3, "HYPERCROSS" + SPATIAL_HYPERCROSS = 4, "SPATIAL_HYPERCROSS" + SPATIO_TEMPORAL_HYPERCROSS = 5, "SPATIO_TEMPORAL_HYPERCROSS" + SPATIAL_HYPERCUBE_TEMPORAL_HYPERCROSS = 6, "SPATIAL_HYPERCUBE_TEMPORAL_HYPERCROSS " + + def __new__(cls, value, name): + member = object.__new__(cls) + member._value_ = value + member.fullname = name + return member + + def __int__(self): + return self.value + + +# Covert the ConvType var to a RegionType var +conv_to_region_type = { + # kernel_size = [k, k, k, 1] + ConvType.HYPERCUBE: ME.RegionType.HYPER_CUBE, + ConvType.SPATIAL_HYPERCUBE: ME.RegionType.HYPER_CUBE, + ConvType.SPATIO_TEMPORAL_HYPERCUBE: ME.RegionType.HYPER_CUBE, + ConvType.HYPERCROSS: ME.RegionType.HYPER_CROSS, + ConvType.SPATIAL_HYPERCROSS: ME.RegionType.HYPER_CROSS, + ConvType.SPATIO_TEMPORAL_HYPERCROSS: ME.RegionType.HYPER_CROSS, + ConvType.SPATIAL_HYPERCUBE_TEMPORAL_HYPERCROSS: ME.RegionType.CUSTOM, +} + +int_to_region_type = {0: ME.RegionType.HYPER_CUBE, 1: ME.RegionType.HYPER_CROSS, 2: ME.RegionType.CUSTOM} + + +def convert_region_type(region_type): + """ + Convert the integer region_type to the corresponding RegionType enum object. + """ + return int_to_region_type[region_type] + + +def convert_conv_type(conv_type, kernel_size, D): + assert isinstance(conv_type, ConvType), "conv_type must be of ConvType" + region_type = conv_to_region_type[conv_type] + axis_types = None + if conv_type == ConvType.SPATIAL_HYPERCUBE: + # No temporal convolution + if isinstance(kernel_size, collections.Sequence): + kernel_size = kernel_size[:3] + else: + kernel_size = [kernel_size, ] * 3 + if D == 4: + kernel_size.append(1) + elif conv_type == ConvType.SPATIO_TEMPORAL_HYPERCUBE: + # conv_type conversion already handled + assert D == 4 + elif conv_type == ConvType.HYPERCUBE: + # conv_type conversion already handled + pass + elif conv_type == ConvType.SPATIAL_HYPERCROSS: + if isinstance(kernel_size, collections.Sequence): + kernel_size = kernel_size[:3] + else: + kernel_size = [kernel_size, ] * 3 + if D == 4: + kernel_size.append(1) + elif conv_type == ConvType.HYPERCROSS: + # conv_type conversion already handled + pass + elif conv_type == ConvType.SPATIO_TEMPORAL_HYPERCROSS: + # conv_type conversion already handled + assert D == 4 + elif conv_type == ConvType.SPATIAL_HYPERCUBE_TEMPORAL_HYPERCROSS: + # Define the CUBIC conv kernel for spatial dims and CROSS conv for temp dim + if D < 4: + region_type = ME.RegionType.HYPER_CUBE + else: + axis_types = [ME.RegionType.HYPER_CUBE, ] * 3 + if D == 4: + axis_types.append(ME.RegionType.HYPER_CROSS) + return region_type, axis_types, kernel_size + + +def conv(in_planes, out_planes, kernel_size, stride=1, dilation=1, bias=False, conv_type=ConvType.HYPERCUBE, D=-1): + assert D > 0, "Dimension must be a positive integer" + region_type, axis_types, kernel_size = convert_conv_type(conv_type, kernel_size, D) + kernel_generator = ME.KernelGenerator( + kernel_size, stride, dilation, region_type=region_type, axis_types=axis_types, dimension=D + ) + + return ME.MinkowskiConvolution( + in_channels=in_planes, + out_channels=out_planes, + kernel_size=kernel_size, + stride=stride, + dilation=dilation, + bias=bias, + kernel_generator=kernel_generator, + dimension=D, + ) + + +def conv_tr( + in_planes, out_planes, kernel_size, upsample_stride=1, dilation=1, bias=False, conv_type=ConvType.HYPERCUBE, + D=-1 +): + assert D > 0, "Dimension must be a positive integer" + region_type, axis_types, kernel_size = convert_conv_type(conv_type, kernel_size, D) + kernel_generator = ME.KernelGenerator( + kernel_size, upsample_stride, dilation, region_type=region_type, axis_types=axis_types, dimension=D + ) + + return ME.MinkowskiConvolutionTranspose( + in_channels=in_planes, + out_channels=out_planes, + kernel_size=kernel_size, + stride=upsample_stride, + dilation=dilation, + bias=bias, + kernel_generator=kernel_generator, + dimension=D, + ) + + +def avg_pool(kernel_size, stride=1, dilation=1, conv_type=ConvType.HYPERCUBE, in_coords_key=None, D=-1): + assert D > 0, "Dimension must be a positive integer" + region_type, axis_types, kernel_size = convert_conv_type(conv_type, kernel_size, D) + kernel_generator = ME.KernelGenerator( + kernel_size, stride, dilation, region_type=region_type, axis_types=axis_types, dimension=D + ) + + return ME.MinkowskiAvgPooling( + kernel_size=kernel_size, stride=stride, dilation=dilation, kernel_generator=kernel_generator, dimension=D + ) + + +def avg_unpool(kernel_size, stride=1, dilation=1, conv_type=ConvType.HYPERCUBE, D=-1): + assert D > 0, "Dimension must be a positive integer" + region_type, axis_types, kernel_size = convert_conv_type(conv_type, kernel_size, D) + kernel_generator = ME.KernelGenerator( + kernel_size, stride, dilation, region_type=region_type, axis_types=axis_types, dimension=D + ) + + return ME.MinkowskiAvgUnpooling( + kernel_size=kernel_size, stride=stride, dilation=dilation, kernel_generator=kernel_generator, dimension=D + ) + + +def sum_pool(kernel_size, stride=1, dilation=1, conv_type=ConvType.HYPERCUBE, D=-1): + assert D > 0, "Dimension must be a positive integer" + region_type, axis_types, kernel_size = convert_conv_type(conv_type, kernel_size, D) + kernel_generator = ME.KernelGenerator( + kernel_size, stride, dilation, region_type=region_type, axis_types=axis_types, dimension=D + ) + + return ME.MinkowskiSumPooling( + kernel_size=kernel_size, stride=stride, dilation=dilation, kernel_generator=kernel_generator, dimension=D + ) + + +class ConvNormActivation(nn.Module): + def __init__(self, input_channels, out_channels, kernel_size, stride, norm_layer, + activation_layer, bias, D): + super().__init__() + self.conv = ME.MinkowskiConvolution( + input_channels, out_channels, kernel_size=kernel_size, stride=stride, dimension=D, bias=bias + ) + self.norm = norm_layer(out_channels) + self.act = nn.Identity() if activation_layer is None else activation_layer + + def forward(self, x): + return self.act(self.norm(self.conv(x))) + + +def batch_norm(X, moving_mean, moving_var, gamma, beta, training, momentum, eps, meanpool): + # Use is_grad_enabled to determine whether we are in training mode + if not torch.is_grad_enabled() or not training: + # In prediction mode, use mean and variance obtained by moving average + X_hat = (X.F - moving_mean) / torch.sqrt(moving_var + eps) + else: + assert len(X.shape) in (2, 4) + + # When using a fully connected layer, calculate the mean and + # variance on the feature dimension + mean = meanpool(X).F.mean(0) + diff = X.F - mean + var = (diff ** 2).mean(0) + + # In training mode, the current mean and variance are used + X_hat = diff / torch.sqrt(var + eps) + # Update the mean and variance using moving average + if training: + moving_mean = (1.0 - momentum) * moving_mean + momentum * mean + moving_var = (1.0 - momentum) * moving_var + momentum * var + return gamma * X_hat + beta # Scale and shift + + +class MinkowskiBatchNorm(nn.BatchNorm1d): + r"""A batch normalization layer for a sparse tensor. + + See the pytorch :attr:`torch.nn.BatchNorm1d` for more details. + """ + + def __init__( + self, + num_features, + eps=1e-5, + momentum=0.1, + affine=True, + track_running_stats=True, + ): + super(MinkowskiBatchNorm, self).__init__(num_features, eps, momentum, affine, track_running_stats) + self.meanpool = ME.MinkowskiGlobalAvgPooling() + + def forward(self, input_): + input = input_.F + self._check_input_dim(input) + + if self.training and self.track_running_stats: + if self.num_batches_tracked is not None: # type: ignore[has-type] + self.num_batches_tracked.add_(1) # type: ignore[has-type] + + r""" + Decide whether the mini-batch stats should be used for normalization rather than the buffers. + Mini-batch stats are used in training mode, and in eval mode when buffers are None. + """ + if self.training: + bn_training = True + else: + bn_training = (self.running_mean is None) and (self.running_var is None) + + r""" + Buffers are only updated if they are to be tracked and we are in training mode. Thus they only need to be + passed when the update should occur (i.e. in training mode when they are tracked), or when buffer stats are + used for normalization (i.e. in eval mode when buffers are not None). + """ + output = batch_norm( + input_, + # If buffers are not to be tracked, ensure that they won't be updated + self.running_mean if not self.training or self.track_running_stats else None, + self.running_var if not self.training or self.track_running_stats else None, + self.weight, + self.bias, + bn_training, + self.momentum, + self.eps, + self.meanpool + ) + + return SparseTensor( + output, + coordinate_map_key=input_.coordinate_map_key, + coordinate_manager=input_.coordinate_manager, + ) + + def __repr__(self): + s = "({}, eps={}, momentum={}, affine={}, track_running_stats={})".format( + self.num_features, + self.eps, + self.momentum, + self.affine, + self.track_running_stats, + ) + return self.__class__.__name__ + s + + +# from https://github.com/facebookresearch/ConvNeXt-V2/blob/main/models/utils.py +class MinkowskiGRN(nn.Module): + """ GRN layer for sparse tensors. + """ + + def __init__(self, dim): + super().__init__() + self.gamma = nn.Parameter(torch.zeros(1, dim)) + self.beta = nn.Parameter(torch.zeros(1, dim)) + + def forward(self, x): + cm = x.coordinate_manager + in_key = x.coordinate_map_key + + Gx = torch.norm(x.F, p=2, dim=0, keepdim=True) + Nx = Gx / (Gx.mean(dim=-1, keepdim=True) + 1e-6) + return SparseTensor( + self.gamma * (x.F * Nx) + self.beta + x.F, + coordinate_map_key=in_key, + coordinate_manager=cm + ) + + +class MinkowskiDropPath(nn.Module): + """ Drop Path for sparse tensors. + """ + + def __init__(self, drop_prob: float = 0., scale_by_keep: bool = True): + super(MinkowskiDropPath, self).__init__() + self.drop_prob = drop_prob + self.scale_by_keep = scale_by_keep + + def forward(self, x): + if not self.training: + return x + keep_prob = 1 - self.drop_prob + mask = torch.cat([ + torch.ones(len(_)) if random.uniform(0, 1) > self.drop_prob + else torch.zeros(len(_)) for _ in x.decomposed_coordinates + ]).view(-1, 1).to(x.device) + if keep_prob > 0.0 and self.scale_by_keep: + mask.div_(keep_prob) + return SparseTensor( + x.F * mask, + coordinate_map_key=x.coordinate_map_key, + coordinate_manager=x.coordinate_manager) + + +class MinkowskiLayerNorm(nn.Module): + """ Channel-wise layer normalization for sparse tensors. + """ + + def __init__( + self, + normalized_shape, + eps=1e-6, + ): + super(MinkowskiLayerNorm, self).__init__() + self.ln = nn.LayerNorm(normalized_shape, eps=eps) + + def forward(self, input): + output = self.ln(input.F) + return SparseTensor( + output, + coordinate_map_key=input.coordinate_map_key, + coordinate_manager=input.coordinate_manager) diff --git a/torch-points3d/torch_points3d/modules/MinkowskiEngine/modules.py b/torch-points3d/torch_points3d/modules/MinkowskiEngine/modules.py new file mode 100644 index 0000000..3add6a7 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/MinkowskiEngine/modules.py @@ -0,0 +1,378 @@ +import torch.nn as nn +import MinkowskiEngine as ME +from .common import ConvType, NormType + +from torch_points3d.utils.config import is_list + + +class BasicBlock(nn.Module): + """This module implements a basic residual convolution block using MinkowskiEngine + + Parameters + ---------- + inplanes: int + Input dimension + planes: int + Output dimension + dilation: int + Dilation value + downsample: nn.Module + If provided, downsample will be applied on input before doing residual addition + bn_momentum: float + Input dimension + """ + + EXPANSION = 1 + + def __init__(self, inplanes, planes, stride=1, dilation=1, downsample=None, bn_momentum=0.1, dimension=-1): + super(BasicBlock, self).__init__() + assert dimension > 0 + + self.conv1 = ME.MinkowskiConvolution( + inplanes, planes, kernel_size=3, stride=stride, dilation=dilation, dimension=dimension + ) + self.norm1 = ME.MinkowskiBatchNorm(planes, momentum=bn_momentum) + self.conv2 = ME.MinkowskiConvolution( + planes, planes, kernel_size=3, stride=1, dilation=dilation, dimension=dimension + ) + self.norm2 = ME.MinkowskiBatchNorm(planes, momentum=bn_momentum) + self.relu = ME.MinkowskiReLU(inplace=True) + self.downsample = downsample + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.norm1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.norm2(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +class Bottleneck(nn.Module): + EXPANSION = 4 + + def __init__(self, inplanes, planes, stride=1, dilation=1, downsample=None, bn_momentum=0.1, dimension=-1): + super(Bottleneck, self).__init__() + assert dimension > 0 + + self.conv1 = ME.MinkowskiConvolution(inplanes, planes, kernel_size=1, dimension=dimension) + self.norm1 = ME.MinkowskiBatchNorm(planes, momentum=bn_momentum) + + self.conv2 = ME.MinkowskiConvolution( + planes, planes, kernel_size=3, stride=stride, dilation=dilation, dimension=dimension + ) + self.norm2 = ME.MinkowskiBatchNorm(planes, momentum=bn_momentum) + + self.conv3 = ME.MinkowskiConvolution(planes, planes * self.EXPANSION, kernel_size=1, dimension=dimension) + self.norm3 = ME.MinkowskiBatchNorm(planes * self.EXPANSION, momentum=bn_momentum) + + self.relu = ME.MinkowskiReLU(inplace=True) + self.downsample = downsample + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.norm1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.norm2(out) + out = self.relu(out) + + out = self.conv3(out) + out = self.norm3(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +class BaseResBlock(nn.Module): + def __init__( + self, + feat_in, + feat_mid, + feat_out, + kernel_sizes=[], + strides=[], + dilations=[], + has_biases=[], + kernel_generators=[], + kernel_size=3, + stride=1, + dilation=1, + bias=False, + kernel_generator=None, + norm_layer=ME.MinkowskiBatchNorm, + activation=ME.MinkowskiReLU, + bn_momentum=0.1, + dimension=-1, + **kwargs + ): + + super(BaseResBlock, self).__init__() + assert dimension > 0 + + modules = [] + + convolutions_dim = [[feat_in, feat_mid], [feat_mid, feat_mid], [feat_mid, feat_out]] + + kernel_sizes = self.create_arguments_list(kernel_sizes, kernel_size) + strides = self.create_arguments_list(strides, stride) + dilations = self.create_arguments_list(dilations, dilation) + has_biases = self.create_arguments_list(has_biases, bias) + kernel_generators = self.create_arguments_list(kernel_generators, kernel_generator) + + for conv_dim, kernel_size, stride, dilation, has_bias, kernel_generator in zip( + convolutions_dim, kernel_sizes, strides, dilations, has_biases, kernel_generators + ): + + modules.append( + ME.MinkowskiConvolution( + conv_dim[0], + conv_dim[1], + kernel_size=kernel_size, + stride=stride, + dilation=dilation, + bias=has_bias, + kernel_generator=kernel_generator, + dimension=dimension, + ) + ) + + if norm_layer: + modules.append(norm_layer(conv_dim[1], momentum=bn_momentum)) + + if activation: + modules.append(activation(inplace=True)) + + self.conv = nn.Sequential(*modules) + + @staticmethod + def create_arguments_list(arg_list, arg): + if len(arg_list) == 3: + return arg_list + return [arg for _ in range(3)] + + def forward(self, x): + return x, self.conv(x) + + +class ResnetBlockDown(BaseResBlock): + def __init__( + self, + down_conv_nn=[], + kernel_sizes=[], + strides=[], + dilations=[], + kernel_size=3, + stride=1, + dilation=1, + norm_layer=ME.MinkowskiBatchNorm, + activation=ME.MinkowskiReLU, + bn_momentum=0.1, + dimension=-1, + down_stride=2, + **kwargs + ): + + super(ResnetBlockDown, self).__init__( + down_conv_nn[0], + down_conv_nn[1], + down_conv_nn[2], + kernel_sizes=kernel_sizes, + strides=strides, + dilations=dilations, + kernel_size=kernel_size, + stride=stride, + dilation=dilation, + norm_layer=norm_layer, + activation=activation, + bn_momentum=bn_momentum, + dimension=dimension, + ) + + self.downsample = nn.Sequential( + ME.MinkowskiConvolution( + down_conv_nn[0], down_conv_nn[2], kernel_size=2, stride=down_stride, dimension=dimension + ), + ME.MinkowskiBatchNorm(down_conv_nn[2]), + ) + + def forward(self, x): + + residual, x = super().forward(x) + + return self.downsample(residual) + x + + +class ResnetBlockUp(BaseResBlock): + def __init__( + self, + up_conv_nn=[], + kernel_sizes=[], + strides=[], + dilations=[], + kernel_size=3, + stride=1, + dilation=1, + norm_layer=ME.MinkowskiBatchNorm, + activation=ME.MinkowskiReLU, + bn_momentum=0.1, + dimension=-1, + up_stride=2, + skip=True, + **kwargs + ): + + self.skip = skip + + super(ResnetBlockUp, self).__init__( + up_conv_nn[0], + up_conv_nn[1], + up_conv_nn[2], + kernel_sizes=kernel_sizes, + strides=strides, + dilations=dilations, + kernel_size=kernel_size, + stride=stride, + dilation=dilation, + norm_layer=norm_layer, + activation=activation, + bn_momentum=bn_momentum, + dimension=dimension, + ) + + self.upsample = ME.MinkowskiConvolutionTranspose( + up_conv_nn[0], up_conv_nn[2], kernel_size=2, stride=up_stride, dimension=dimension + ) + + def forward(self, x, x_skip): + residual, x = super().forward(x) + + x = self.upsample(residual) + x + + if self.skip: + return ME.cat(x, x_skip) + else: + return x + + +class SELayer(nn.Module): + def __init__(self, channel, reduction=16, D=-1): + # Global coords does not require coords_key + super(SELayer, self).__init__() + self.fc = nn.Sequential( + ME.MinkowskiLinear(channel, channel // reduction), + ME.MinkowskiReLU(inplace=True), + ME.MinkowskiLinear(channel // reduction, channel), + ME.MinkowskiSigmoid(), + ) + self.pooling = ME.MinkowskiGlobalPooling(dimension=D) + self.broadcast_mul = ME.MinkowskiBroadcastMultiplication(dimension=D) + + def forward(self, x): + y = self.pooling(x) + y = self.fc(y) + return self.broadcast_mul(x, y) + + +class SEBasicBlock(BasicBlock): + def __init__( + self, inplanes, planes, stride=1, dilation=1, downsample=None, conv_type=ConvType.HYPERCUBE, reduction=16, D=-1 + ): + super(SEBasicBlock, self).__init__( + inplanes, planes, stride=stride, dilation=dilation, downsample=downsample, conv_type=conv_type, D=D + ) + self.se = SELayer(planes, reduction=reduction, D=D) + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.norm1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.norm2(out) + out = self.se(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +class SEBasicBlockBN(SEBasicBlock): + NORM_TYPE = NormType.BATCH_NORM + + +class SEBasicBlockIN(SEBasicBlock): + NORM_TYPE = NormType.INSTANCE_NORM + + +class SEBasicBlockIBN(SEBasicBlock): + NORM_TYPE = NormType.INSTANCE_BATCH_NORM + + +class SEBottleneck(Bottleneck): + def __init__( + self, inplanes, planes, stride=1, dilation=1, downsample=None, conv_type=ConvType.HYPERCUBE, D=3, reduction=16 + ): + super(SEBottleneck, self).__init__( + inplanes, planes, stride=stride, dilation=dilation, downsample=downsample, conv_type=conv_type, D=D + ) + self.se = SELayer(planes * self.expansion, reduction=reduction, D=D) + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.norm1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.norm2(out) + out = self.relu(out) + + out = self.conv3(out) + out = self.norm3(out) + out = self.se(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +class SEBottleneckBN(SEBottleneck): + NORM_TYPE = NormType.BATCH_NORM + + +class SEBottleneckIN(SEBottleneck): + NORM_TYPE = NormType.INSTANCE_NORM + + +class SEBottleneckIBN(SEBottleneck): + NORM_TYPE = NormType.INSTANCE_BATCH_NORM diff --git a/torch-points3d/torch_points3d/modules/MinkowskiEngine/networks.py b/torch-points3d/torch_points3d/modules/MinkowskiEngine/networks.py new file mode 100644 index 0000000..ea22972 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/MinkowskiEngine/networks.py @@ -0,0 +1,310 @@ +import torch.nn as nn + +import MinkowskiEngine as ME +from .modules import BasicBlock, Bottleneck + + +class ResNetBase(nn.Module): + BLOCK = None + LAYERS = () + INIT_DIM = 64 + PLANES = (64, 128, 256, 512) + + def __init__(self, in_channels, out_channels, D=3, **kwargs): + nn.Module.__init__(self) + self.D = D + assert self.BLOCK is not None, "BLOCK is not defined" + assert self.PLANES is not None, "PLANES is not defined" + self.network_initialization(in_channels, out_channels, D) + self.weight_initialization() + + def network_initialization(self, in_channels, out_channels, D): + + self.inplanes = self.INIT_DIM + self.conv1 = ME.MinkowskiConvolution(in_channels, self.inplanes, kernel_size=5, stride=2, dimension=D) + + self.bn1 = ME.MinkowskiBatchNorm(self.inplanes) + self.relu = ME.MinkowskiReLU(inplace=True) + + self.pool = ME.MinkowskiAvgPooling(kernel_size=2, stride=2, dimension=D) + + self.layer1 = self._make_layer(self.BLOCK, self.PLANES[0], self.LAYERS[0], stride=2) + self.layer2 = self._make_layer(self.BLOCK, self.PLANES[1], self.LAYERS[1], stride=2) + self.layer3 = self._make_layer(self.BLOCK, self.PLANES[2], self.LAYERS[2], stride=2) + self.layer4 = self._make_layer(self.BLOCK, self.PLANES[3], self.LAYERS[3], stride=2) + + self.conv5 = ME.MinkowskiConvolution(self.inplanes, self.inplanes, kernel_size=3, stride=3, dimension=D) + self.bn5 = ME.MinkowskiBatchNorm(self.inplanes) + + self.glob_avg = ME.MinkowskiGlobalMaxPooling()#dimension=D) + + self.final = ME.MinkowskiLinear(self.inplanes, out_channels, bias=True) + + def weight_initialization(self): + for m in self.modules(): + if isinstance(m, ME.MinkowskiConvolution): + ME.utils.kaiming_normal_(m.kernel, mode="fan_out", nonlinearity="relu") + + if isinstance(m, ME.MinkowskiBatchNorm): + nn.init.constant_(m.bn.weight, 1) + nn.init.constant_(m.bn.bias, 0) + + def _make_layer(self, block, planes, blocks, stride=1, dilation=1, bn_momentum=0.1): + downsample = None + if stride != 1 or self.inplanes != planes * block.EXPANSION: + downsample = nn.Sequential( + ME.MinkowskiConvolution( + self.inplanes, planes * block.EXPANSION, kernel_size=1, stride=stride, dimension=self.D + ), + ME.MinkowskiBatchNorm(planes * block.EXPANSION), + ) + layers = [] + layers.append( + block(self.inplanes, planes, stride=stride, dilation=dilation, downsample=downsample, dimension=self.D) + ) + self.inplanes = planes * block.EXPANSION + for i in range(1, blocks): + layers.append(block(self.inplanes, planes, stride=1, dilation=dilation, dimension=self.D)) + + return nn.Sequential(*layers) + + def forward(self, x): + x = self.conv1(x) + x = self.bn1(x) + x = self.relu(x) + x = self.pool(x) + + x = self.layer1(x) + x = self.layer2(x) + x = self.layer3(x) + x = self.layer4(x) + + x = self.conv5(x) + x = self.bn5(x) + x = self.relu(x) + + x = self.glob_avg(x) + return self.final(x) + + +class ResNet14(ResNetBase): + BLOCK = BasicBlock + LAYERS = (1, 1, 1, 1) + + +class ResNet18(ResNetBase): + BLOCK = BasicBlock + LAYERS = (2, 2, 2, 2) + + +class ResNet34(ResNetBase): + BLOCK = BasicBlock + LAYERS = (3, 4, 6, 3) + + +class ResNet50(ResNetBase): + BLOCK = Bottleneck + LAYERS = (3, 4, 6, 3) + + +class ResNet101(ResNetBase): + BLOCK = Bottleneck + LAYERS = (3, 4, 23, 3) + + +class MinkUNetBase(ResNetBase): + BLOCK = None + PLANES = None + DILATIONS = (1, 1, 1, 1, 1, 1, 1, 1) + LAYERS = (2, 2, 2, 2, 2, 2, 2, 2) + INIT_DIM = 32 + OUT_TENSOR_STRIDE = 1 + + # To use the model, must call initialize_coords before forward pass. + # Once data is processed, call clear to reset the model before calling + # initialize_coords + def __init__(self, in_channels, out_channels, D=3, **kwargs): + ResNetBase.__init__(self, in_channels, out_channels, D) + + def network_initialization(self, in_channels, out_channels, D): + # Output of the first conv concated to conv6 + self.inplanes = self.INIT_DIM + self.conv0p1s1 = ME.MinkowskiConvolution(in_channels, self.inplanes, kernel_size=5, dimension=D) + + self.bn0 = ME.MinkowskiBatchNorm(self.inplanes) + + self.conv1p1s2 = ME.MinkowskiConvolution(self.inplanes, self.inplanes, kernel_size=2, stride=2, dimension=D) + self.bn1 = ME.MinkowskiBatchNorm(self.inplanes) + + self.block1 = self._make_layer(self.BLOCK, self.PLANES[0], self.LAYERS[0]) + + self.conv2p2s2 = ME.MinkowskiConvolution(self.inplanes, self.inplanes, kernel_size=2, stride=2, dimension=D) + self.bn2 = ME.MinkowskiBatchNorm(self.inplanes) + + self.block2 = self._make_layer(self.BLOCK, self.PLANES[1], self.LAYERS[1]) + + self.conv3p4s2 = ME.MinkowskiConvolution(self.inplanes, self.inplanes, kernel_size=2, stride=2, dimension=D) + + self.bn3 = ME.MinkowskiBatchNorm(self.inplanes) + self.block3 = self._make_layer(self.BLOCK, self.PLANES[2], self.LAYERS[2]) + + self.conv4p8s2 = ME.MinkowskiConvolution(self.inplanes, self.inplanes, kernel_size=2, stride=2, dimension=D) + self.bn4 = ME.MinkowskiBatchNorm(self.inplanes) + self.block4 = self._make_layer(self.BLOCK, self.PLANES[3], self.LAYERS[3]) + + self.convtr4p16s2 = ME.MinkowskiConvolutionTranspose( + self.inplanes, self.PLANES[4], kernel_size=2, stride=2, dimension=D + ) + self.bntr4 = ME.MinkowskiBatchNorm(self.PLANES[4]) + + self.inplanes = self.PLANES[4] + self.PLANES[2] * self.BLOCK.EXPANSION + self.block5 = self._make_layer(self.BLOCK, self.PLANES[4], self.LAYERS[4]) + self.convtr5p8s2 = ME.MinkowskiConvolutionTranspose( + self.inplanes, self.PLANES[5], kernel_size=2, stride=2, dimension=D + ) + self.bntr5 = ME.MinkowskiBatchNorm(self.PLANES[5]) + + self.inplanes = self.PLANES[5] + self.PLANES[1] * self.BLOCK.EXPANSION + self.block6 = self._make_layer(self.BLOCK, self.PLANES[5], self.LAYERS[5]) + self.convtr6p4s2 = ME.MinkowskiConvolutionTranspose( + self.inplanes, self.PLANES[6], kernel_size=2, stride=2, dimension=D + ) + self.bntr6 = ME.MinkowskiBatchNorm(self.PLANES[6]) + + self.inplanes = self.PLANES[6] + self.PLANES[0] * self.BLOCK.EXPANSION + self.block7 = self._make_layer(self.BLOCK, self.PLANES[6], self.LAYERS[6]) + self.convtr7p2s2 = ME.MinkowskiConvolutionTranspose( + self.inplanes, self.PLANES[7], kernel_size=2, stride=2, dimension=D + ) + self.bntr7 = ME.MinkowskiBatchNorm(self.PLANES[7]) + + self.inplanes = self.PLANES[7] + self.INIT_DIM + self.block8 = self._make_layer(self.BLOCK, self.PLANES[7], self.LAYERS[7]) + + self.final = ME.MinkowskiConvolution(self.PLANES[7], out_channels, kernel_size=1, bias=True, dimension=D) + self.relu = ME.MinkowskiReLU(inplace=True) + + def forward(self, x): + out = self.conv0p1s1(x) + out = self.bn0(out) + out_p1 = self.relu(out) + + out = self.conv1p1s2(out_p1) + out = self.bn1(out) + out = self.relu(out) + out_b1p2 = self.block1(out) + + out = self.conv2p2s2(out_b1p2) + out = self.bn2(out) + out = self.relu(out) + out_b2p4 = self.block2(out) + + out = self.conv3p4s2(out_b2p4) + out = self.bn3(out) + out = self.relu(out) + out_b3p8 = self.block3(out) + + # tensor_stride=16 + out = self.conv4p8s2(out_b3p8) + out = self.bn4(out) + out = self.relu(out) + out = self.block4(out) + + # tensor_stride=8 + out = self.convtr4p16s2(out) + out = self.bntr4(out) + out = self.relu(out) + + out = ME.cat(out, out_b3p8) + out = self.block5(out) + + # tensor_stride=4 + out = self.convtr5p8s2(out) + out = self.bntr5(out) + out = self.relu(out) + + out = ME.cat(out, out_b2p4) + out = self.block6(out) + + # tensor_stride=2 + out = self.convtr6p4s2(out) + out = self.bntr6(out) + out = self.relu(out) + + out = ME.cat(out, out_b1p2) + out = self.block7(out) + + # tensor_stride=1 + out = self.convtr7p2s2(out) + out = self.bntr7(out) + out = self.relu(out) + + out = ME.cat(out, out_p1) + out = self.block8(out) + + return self.final(out) + + +class MinkUNet14(MinkUNetBase): + BLOCK = BasicBlock + LAYERS = (1, 1, 1, 1, 1, 1, 1, 1) + + +class MinkUNet18(MinkUNetBase): + BLOCK = BasicBlock + LAYERS = (2, 2, 2, 2, 2, 2, 2, 2) + + +class MinkUNet34(MinkUNetBase): + BLOCK = BasicBlock + LAYERS = (2, 3, 4, 6, 2, 2, 2, 2) + + +class MinkUNet50(MinkUNetBase): + BLOCK = Bottleneck + LAYERS = (2, 3, 4, 6, 2, 2, 2, 2) + + +class MinkUNet101(MinkUNetBase): + BLOCK = Bottleneck + LAYERS = (2, 3, 4, 23, 2, 2, 2, 2) + + +class MinkUNet14A(MinkUNet14): + PLANES = (32, 64, 128, 256, 128, 128, 96, 96) + + +class MinkUNet14B(MinkUNet14): + PLANES = (32, 64, 128, 256, 128, 128, 128, 128) + + +class MinkUNet14C(MinkUNet14): + PLANES = (32, 64, 128, 256, 192, 192, 128, 128) + + +class MinkUNet14D(MinkUNet14): + PLANES = (32, 64, 128, 256, 384, 384, 384, 384) + + +class MinkUNet18A(MinkUNet18): + PLANES = (32, 64, 128, 256, 128, 128, 96, 96) + + +class MinkUNet18B(MinkUNet18): + PLANES = (32, 64, 128, 256, 128, 128, 128, 128) + + +class MinkUNet18D(MinkUNet18): + PLANES = (32, 64, 128, 256, 384, 384, 384, 384) + + +class MinkUNet34A(MinkUNet34): + PLANES = (32, 64, 128, 256, 256, 128, 64, 64) + + +class MinkUNet34B(MinkUNet34): + PLANES = (32, 64, 128, 256, 256, 128, 64, 32) + + +class MinkUNet34C(MinkUNet34): + PLANES = (32, 64, 128, 256, 256, 128, 96, 96) diff --git a/torch-points3d/torch_points3d/modules/MinkowskiEngine/senet_block.py b/torch-points3d/torch_points3d/modules/MinkowskiEngine/senet_block.py new file mode 100644 index 0000000..5692d34 --- /dev/null +++ b/torch-points3d/torch_points3d/modules/MinkowskiEngine/senet_block.py @@ -0,0 +1,147 @@ +# Copyright (c) Chris Choy (chrischoy@ai.stanford.edu). +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Please cite "4D Spatio-Temporal ConvNets: Minkowski Convolutional Neural +# Networks", CVPR'19 (https://arxiv.org/abs/1904.08755) if you use any part +# of the code. +import torch +import torch.nn as nn + +import MinkowskiEngine as ME +from torch.cuda.amp import custom_fwd + +from .resnet_block import BasicBlock, Bottleneck + + +class SELayer(nn.Module): + + def __init__(self, channel, act_fn, reduction=16, dimension=-1): + # Global coords does not require coords_key + super(SELayer, self).__init__() + self.fc = nn.Sequential( + ME.MinkowskiLinear(channel, channel // reduction), + act_fn, + ME.MinkowskiLinear(channel // reduction, channel), + ME.MinkowskiSigmoid()) + self.pooling = ME.MinkowskiGlobalPooling() + self.broadcast_mul = ME.MinkowskiBroadcastMultiplication() + + @custom_fwd(cast_inputs=torch.float32) + def forward(self, x): + y = self.pooling(x) + y = self.fc(y) + return self.broadcast_mul(x, y) + + +class SEBasicBlock(BasicBlock): + + def __init__(self, + inplanes, + planes, + act_fn, + norm_layer, + stride=1, + dilation=1, + downsample=None, + reduction=16, + drop_path=0.0, + bias: bool = True, + dimension=-1): + super(SEBasicBlock, self).__init__( + inplanes, + planes, + act_fn, + norm_layer, + stride=stride, + dilation=dilation, + downsample=downsample, + drop_path=drop_path, + bias=bias, + dimension=dimension) + self.se = SELayer(planes, act_fn, reduction=reduction, dimension=dimension) + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.norm1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.norm2(out) + out = self.se(out) + + residual = self.downsample(residual) + + out = self.drop_path(out) + residual + out = self.relu(out) + + return out + + +class SEBottleneck(Bottleneck): + + def __init__(self, + inplanes, + planes, + act_fn, + norm_layer, + stride=1, + dilation=1, + downsample=None, + dimension=-1, + drop_path=0.0, + bias: bool = True, + reduction=16): + super(SEBottleneck, self).__init__( + inplanes, + planes, + act_fn, + norm_layer, + stride=stride, + dilation=dilation, + downsample=downsample, + drop_path=drop_path, + bias=bias, + dimension=dimension) + self.se = SELayer(planes * self.expansion, act_fn, reduction=reduction, dimension=dimension) + + @custom_fwd(cast_inputs=torch.float32) + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.norm1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.norm2(out) + out = self.relu(out) + + out = self.conv3(out) + out = self.norm3(out) + out = self.se(out) + + residual = self.downsample(residual) + + out = self.drop_path(out) + residual + out = self.relu(out) + + return out diff --git a/torch-points3d/torch_points3d/modules/__init__.py b/torch-points3d/torch_points3d/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torch-points3d/torch_points3d/trainer.py b/torch-points3d/torch_points3d/trainer.py new file mode 100644 index 0000000..305a808 --- /dev/null +++ b/torch-points3d/torch_points3d/trainer.py @@ -0,0 +1,521 @@ +import copy +import logging +import os +import time +from contextlib import nullcontext + +import torch + +torch.multiprocessing.set_sharing_strategy('file_system') +import torch.autograd.profiler +# PyTorch Profiler import +import torch.profiler +from omegaconf import ListConfig +from torch import nn + +from torch_points3d.datasets.base_dataset import BaseDataset +# Import building function for model and dataset +from torch_points3d.datasets.dataset_factory import instantiate_dataset +# Import from metrics +from torch_points3d.metrics.base_tracker import BaseTracker +from torch_points3d.metrics.colored_tqdm import Coloredtqdm as Ctq +from torch_points3d.metrics.model_checkpoint import ModelCheckpoint +# Import BaseModel / BaseDataset for type checking +from torch_points3d.models.base_model import BaseModel +from torch_points3d.models.model_factory import instantiate_model +# Utils import +from torch_points3d.utils.colors import COLORS +from torch_points3d.utils.wandb_utils import Wandb +from torch_points3d.visualization import Visualizer + +log = logging.getLogger(__name__) + + +class Trainer: + """ + TorchPoints3d Trainer handles the logic between + - BaseModel, + - Dataset and its Tracker + - A custom ModelCheckpoint + - A custom Visualizer + It supports MC dropout - multiple voting_runs for val / test datasets + """ + + def __init__(self, cfg): + self._cfg = cfg + self._initialize_trainer() + + def _initialize_trainer(self): + if not self.has_training: + self._cfg.training = self._cfg + resume = bool(self._cfg.checkpoint_dir) + else: + resume = bool(self._cfg.training.checkpoint_dir) + + # Enable CUDNN BACKEND + torch.backends.cudnn.enabled = self.enable_cudnn + + # Get device + self._multi_gpu = False + if (isinstance(self._cfg.training.cuda, ListConfig) or self._cfg.training.cuda > -1) \ + and torch.cuda.is_available(): + device = "cuda" + if isinstance(self._cfg.training.cuda, ListConfig): + self._multi_gpu = True + else: + device = "cpu" + self._device = torch.device(device) + log.info("DEVICE : {}".format(self._device)) + + # Profiling + if self.profiling: + # Set the num_workers as torch.utils.bottleneck doesn't work well with it + self._cfg.training.num_workers = 0 + + # Start Wandb if public + if self.wandb_log: + Wandb.launch(self._cfg, self._cfg.training.wandb.public and self.wandb_log) + + # Checkpoint + + self._checkpoint: ModelCheckpoint = ModelCheckpoint( + self._cfg.training.checkpoint_dir, + self._cfg.model_name, + self._cfg.training.weight_name, + run_config=self._cfg, + resume=resume, + resume_opt=self._cfg.get("resume_opt", None) + ) + + # Create model and datasets + # always freshly init dataset instead of using checkpoint cfg + self._dataset: BaseDataset = instantiate_dataset(self._cfg.data) + if not self._checkpoint.is_empty: + self._model: BaseModel = self._checkpoint.create_model( + self._dataset, weight_name=self._cfg.training.weight_name + ) + self._model = self._model.to(self._device) + if self.has_training: + if self._model.optimizer is None and self.has_training: + self._model.init_optim(self._cfg) + else: + # workaround for https://github.com/pytorch/pytorch/issues/80809 for pytorch 1.12 + opt_dict = self._model.optimizer.state_dict() + + base_lr = self._cfg.training.optim.base_lr + + def nested_iter(dict_obj): + for k, v in dict_obj.items(): + if isinstance(v, dict): + nested_iter(v) + elif isinstance(v, list): + nested_iter((dict(zip(['list_' + str(i) for i in range(len(v))], v)))) + else: + if 'step' in k: + try: + if isinstance(v, torch.Tensor): + tst = v.cpu() + assert torch.all(tst == v) + else: + tst = v + except: + pass + dict_obj[k] = tst + elif "initial_lr" in k: + dict_obj[k] = base_lr + + nested_iter(opt_dict) + self._model.optimizer.load_state_dict(opt_dict) + if len(self._model.schedulers) == 0: + self._model.init_schedulers(self._cfg) + else: + self._checkpoint._checkpoint.load_optim_sched(self._model, load_state=self._checkpoint._resume_opt) + if self._model.grad_scale is None: + self._model.init_grad_scaler(self._cfg) + else: + self._checkpoint._checkpoint.load_grad_scale(self._model, load_state=self._checkpoint._resume_opt) + else: + self._model: BaseModel = instantiate_model(copy.deepcopy(self._cfg), self._dataset) + self._model.init_train_objects(self._cfg) + self._model.set_pretrained_weights() + if not self._checkpoint.validate(self._dataset.used_properties): + log.warning( + "The model will not be able to be used from pretrained weights without the corresponding dataset. Current properties are {}".format( + self._dataset.used_properties + ) + ) + self._model = self._model.to(self._device) + + if self._multi_gpu: + self._model.model = nn.DataParallel(self._model.model, device_ids=self._cfg.training.cuda) + + self._checkpoint.dataset_properties = self._dataset.used_properties + + log.info(self._model) + + self._model.log_optimizers() + log.info("Model size = %i", sum(param.numel() for param in self._model.parameters() if param.requires_grad)) + + # Set dataloaders + self._dataset.create_dataloaders( + self._model, + self._cfg.training.batch_size, + self._cfg.training.get("shuffle", True), + self._cfg.training.get("drop_last", True), + self._cfg.training.num_workers, + self.precompute_multi_scale, + ) + log.info(self._dataset) + + # Verify attributes in dataset + if self._dataset.has_train_loader: + dataset = self._dataset.train_dataset[0] + elif self._dataset.has_val_loader: + dataset = self._dataset.val_dataset[0] + else: + dataset = self._dataset.test_dataset[0] + + self._model.verify_data(dataset) + + # Choose selection stage + selection_stage = getattr(self._cfg, "selection_stage", "") + self._checkpoint.selection_stage = self._dataset.resolve_saving_stage(selection_stage) + self._tracker: BaseTracker = self._dataset.get_tracker(self.wandb_log, self.tensorboard_log) + + if self.wandb_log: + Wandb.launch(self._cfg, not self._cfg.training.wandb.public and self.wandb_log) + + # Run training / evaluation + if self.has_visualization: + self._visualizer = Visualizer( + self._cfg.visualization, self._dataset.num_batches, self._dataset.batch_size, os.getcwd(), self._tracker + ) + + def train(self): + self._is_training = True + + for epoch in range(self._checkpoint.start_epoch, self._cfg.training.epochs): + log.info("EPOCH %i / %i", epoch, self._cfg.training.epochs) + + self._train_epoch(epoch) + + if self.profiling: + return 0 + + if epoch % self.eval_frequency != 0: + continue + + if self._dataset.has_val_loader: + self._test_epoch(epoch, "val") + + if self._dataset.has_test_loader: + self._test_epoch(epoch, "test") + + # Single test evaluation in resume case + if self._checkpoint.start_epoch > self._cfg.training.epochs: + if self._dataset.has_test_loader: + self._test_epoch(epoch, "test") + + def eval(self, stage_name): + self._is_training = False + + epoch = self._checkpoint.start_epoch + if getattr(self._dataset, f"has_{stage_name}_loader"): + self._test_epoch(epoch, stage_name) + metrics = self._tracker.get_publish_metrics(epoch) + self._tracker.publish_metrics(metrics["all_metrics"], epoch) + else: + log.warning(f"No {stage_name} dataset") + + def iterate_epochs(self, epochs: int): + self._is_training = True + + for epoch in range(1, epochs + 1): + self._iterate_epoch(epoch, is_train=True) + + def _iterate_epoch(self, epoch: int, is_train: bool): + + if is_train: + self._model.train() + else: + self._model.eval() + self._tracker.reset("train") + self._visualizer.reset(epoch, "train") + train_loader = self._dataset.train_dataloader + + with self.profiler_profile(epoch) as prof: + iter_data_time = time.time() + with Ctq(train_loader) as tq_train_loader: + for i, data in enumerate(tq_train_loader): + t_data = time.time() - iter_data_time + iter_start_time = time.time() + + with self.profiler_record_function('train_step'): + with torch.no_grad(): + self._model.set_input(data, self._device) + # enable autocasting if supported + with torch.cuda.amp.autocast(enabled=self._model.is_mixed_precision()): + self._model(epoch=epoch) # iterate through model + + with self.profiler_record_function('track/log/visualize'): + if i % 10 == 0: + with torch.no_grad(): + self._tracker.track(self._model, data=data, **self.tracker_options) + + tq_train_loader.set_postfix( + **self._tracker.get_loss(), + data_loading=float(t_data), + iteration=float(time.time() - iter_start_time), + color=COLORS.TRAIN_COLOR + ) + + if self._visualizer.is_active: + self._visualizer.save_visuals(self._model, train_loader) + + iter_data_time = time.time() + + if self.pytorch_profiler_log: + prof.step() + + if self.early_break: + break + + self._finalize_epoch(epoch, ) + + def _finalize_epoch(self, epoch): + self._tracker.finalise(**self.tracker_options) + if self._is_training: + metrics = self._tracker.get_publish_metrics(epoch) + p_metrics = self._checkpoint.save_best_models_under_current_metrics( + self._model, metrics, self._tracker.metric_func, self.wandb_log + ) + self._tracker.publish_metrics(p_metrics, epoch) + + if self.wandb_log and self._cfg.training.wandb.public: + Wandb.add_file(self._checkpoint.checkpoint_path) + if self._tracker._stage == "train": + log.info("Learning rate = %f" % self._model.learning_rate) + else: + if self.has_visualization: + + if self._tracker._stage == "test": + loaders = self._dataset.test_dataloaders + elif self._tracker._stage == "val": + loaders = [self._dataset.val_dataloader] + elif self._tracker._stage == "train": + loaders = [self._dataset.train_dataloader] + + self._visualizer.finalize_epoch(loaders) + + def _train_epoch(self, epoch: int): + + self._model.train() + self._tracker.reset("train") + self._visualizer.reset(epoch, "train") + train_loader = self._dataset.train_dataloader + n_batches = len(train_loader) + + with self.profiler_profile(epoch) as prof: + iter_data_time = time.time() + with Ctq(train_loader) as tq_train_loader: + for i, data in enumerate(tq_train_loader): + t_data = time.time() - iter_data_time + iter_start_time = time.time() + + with self.profiler_record_function('train_step'): + self._model.set_input(data, self._device) + self._model.optimize_parameters( + epoch, self._dataset.batch_size, + self._dataset.num_batches[self._dataset.train_dataset.name] + ) + + with self.profiler_record_function('track/log/visualize'): + if i % 10 == 0: + with torch.no_grad(): + self._tracker.track(self._model, data=data, **self.tracker_options) + + tq_train_loader.set_postfix( + **self._tracker.get_loss(), + data_loading=float(t_data), + iteration=float(time.time() - iter_start_time), + color=COLORS.TRAIN_COLOR + ) + + if self._visualizer.is_active: + self._visualizer.save_visuals(self._model, train_loader) + + iter_data_time = time.time() + + if self.pytorch_profiler_log: + prof.step() + + if self.early_break: + break + + if self.profiling: + if i > self.num_batches: + return 0 + + self._finalize_epoch(epoch) + + def _test_epoch(self, epoch, stage_name: str): + voting_runs = self._cfg.get("voting_runs", 1) + if stage_name == "test": + loaders = self._dataset.test_dataloaders + elif stage_name == "val": + loaders = [self._dataset.val_dataloader] + elif stage_name == "train": + loaders = [self._dataset.train_dataloader] + else: + raise NotImplemented(f"The following stage is not implemented: {stage_name}") + + self._model.eval() + if self.enable_dropout: + self._model.enable_dropout_in_eval() + + if self.enable_bn: + self._model.enable_bn_in_eval() + + for loader in loaders: + stage_name = loader.dataset.name + self._tracker.reset(stage_name) + if self.has_visualization: + self._visualizer.reset(epoch, stage_name) + if not self._dataset.has_labels(stage_name) and not self.tracker_options.get( + "make_submission", False + ): # No label, no submission -> do nothing + log.warning("No forward will be run on dataset %s." % stage_name) + continue + + with self.profiler_profile(epoch) as prof: + for i in range(voting_runs): + with Ctq(loader) as tq_loader: + for data in tq_loader: + with torch.no_grad(): + with self.profiler_record_function('test_step'): + self._model.set_input(data, self._device) + with torch.cuda.amp.autocast(enabled=self._model.is_mixed_precision()): + self._model.forward(epoch=epoch) + + with self.profiler_record_function('track/log/visualize'): + self._tracker.track(self._model, data=data, **self.tracker_options) + tq_loader.set_postfix(**self._tracker.get_loss(), color=COLORS.TEST_COLOR) + + if self.has_visualization and self._visualizer.is_active: + self._visualizer.save_visuals(self._model, loader) + + if self.pytorch_profiler_log: + prof.step() + + if self.early_break: + break + + if self.profiling: + if i > self.num_batches: + return 0 + + self._finalize_epoch(epoch) + self._tracker.print_summary() + + @property + def early_break(self): + return getattr(self._cfg.debugging, "early_break", False) and self._is_training + + @property + def profiling(self): + return getattr(self._cfg.debugging, "profiling", False) + + @property + def num_batches(self): + return getattr(self._cfg.debugging, "num_batches", 50) + + @property + def enable_cudnn(self): + return getattr(self._cfg.training, "enable_cudnn", True) + + @property + def enable_dropout(self): + return getattr(self._cfg, "enable_dropout", False) + + @property + def enable_bn(self): + return getattr(self._cfg, "enable_bn", False) + + @property + def has_visualization(self): + return getattr(self._cfg, "visualization", False) + + @property + def has_tensorboard(self): + return getattr(self._cfg.training, "tensorboard", False) + + _has_training = None + + @property + def has_training(self): + if self._has_training is None: + self._has_training = getattr(self._cfg, "training", None) is not None + return self._has_training + + @property + def precompute_multi_scale(self): + return self._model.conv_type == "PARTIAL_DENSE" and getattr(self._cfg.training, "precompute_multi_scale", False) + + @property + def wandb_log(self): + if getattr(self._cfg.training, "wandb", False): + return getattr(self._cfg.training.wandb, "log", False) + else: + return False + + @property + def tensorboard_log(self): + if self.has_tensorboard: + return getattr(self._cfg.training.tensorboard, "log", False) + else: + return False + + @property + def pytorch_profiler_log(self): + if self.tensorboard_log: + if getattr(self._cfg.training.tensorboard, "pytorch_profiler", False): + return getattr(self._cfg.training.tensorboard.pytorch_profiler, "log", False) + return False + + # pyTorch Profiler + def profiler_profile(self, epoch): + if (self.pytorch_profiler_log and ( + getattr(self._cfg.training.tensorboard.pytorch_profiler, "nb_epoch", 3) == 0 or epoch <= getattr( + self._cfg.training.tensorboard.pytorch_profiler, "nb_epoch", 3))): + return torch.profiler.profile( + activities=[torch.profiler.ProfilerActivity.CPU, torch.profiler.ProfilerActivity.CUDA] \ + if isinstance(self._cfg.training.cuda, ListConfig) or self._cfg.training.cuda > -1 else \ + [torch.profiler.ProfilerActivity.CPU], + schedule=torch.profiler.schedule( + skip_first=getattr(self._cfg.training.tensorboard.pytorch_profiler, "skip_first", 10), + wait=getattr(self._cfg.training.tensorboard.pytorch_profiler, "wait", 5), + warmup=getattr(self._cfg.training.tensorboard.pytorch_profiler, "warmup", 3), + active=getattr(self._cfg.training.tensorboard.pytorch_profiler, "active", 5), + repeat=getattr(self._cfg.training.tensorboard.pytorch_profiler, "repeat", 0)), + on_trace_ready=torch.profiler.tensorboard_trace_handler(self._tracker._tensorboard_dir), + record_shapes=getattr(self._cfg.training.tensorboard.pytorch_profiler, "record_shapes", True), + profile_memory=getattr(self._cfg.training.tensorboard.pytorch_profiler, "profile_memory", True), + with_stack=getattr(self._cfg.training.tensorboard.pytorch_profiler, "with_stack", True), + with_flops=getattr(self._cfg.training.tensorboard.pytorch_profiler, "with_flops", True) + ) + else: + return nullcontext(type('', (), {"step": lambda self: None})()) + + def profiler_record_function(self, name: str): + if self.pytorch_profiler_log: + return torch.autograd.profiler.record_function(name) + else: + return nullcontext() + + @property + def tracker_options(self): + return self._cfg.get("tracker_options", {}) + + @property + def eval_frequency(self): + return self._cfg.get("eval_frequency", 1) diff --git a/torch-points3d/torch_points3d/utils/__init__.py b/torch-points3d/torch_points3d/utils/__init__.py new file mode 100644 index 0000000..7d71942 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/__init__.py @@ -0,0 +1,6 @@ +from .colors import * +from .config import * +from .enums import * +from .running_stats import * +from .timer import * +from .transform_utils import * diff --git a/torch-points3d/torch_points3d/utils/box_utils.py b/torch-points3d/torch_points3d/utils/box_utils.py new file mode 100644 index 0000000..4131022 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/box_utils.py @@ -0,0 +1,236 @@ +import torch +import numpy as np +from scipy.spatial import ConvexHull + +from .geometry import euler_angles_to_rotation_matrix + + +def box_corners_from_param(box_size, heading_angle, center): + """ Generates box corners from a parameterised box. + box_size is array(size_x,size_y,size_z), heading_angle is radius clockwise from pos x axis, center is xyz of box center + output (8,3) array for 3D box corners + """ + R = euler_angles_to_rotation_matrix(torch.tensor([0.0, 0.0, float(heading_angle)])) + if torch.is_tensor(box_size): + box_size = box_size.float() + l, w, h = box_size + x_corners = torch.tensor([-l / 2, l / 2, l / 2, -l / 2, -l / 2, l / 2, l / 2, -l / 2]) + y_corners = torch.tensor([-w / 2, -w / 2, w / 2, w / 2, -w / 2, -w / 2, w / 2, w / 2]) + z_corners = torch.tensor([-h / 2, -h / 2, -h / 2, -h / 2, h / 2, h / 2, h / 2, h / 2]) + corners_3d = R @ torch.stack([x_corners, y_corners, z_corners]) + corners_3d[0, :] = corners_3d[0, :] + center[0] + corners_3d[1, :] = corners_3d[1, :] + center[1] + corners_3d[2, :] = corners_3d[2, :] + center[2] + corners_3d = corners_3d.T + return corners_3d + + +def nms_samecls(boxes, classes, scores, overlap_threshold=0.25): + """ Returns the list of boxes that are kept after nms. + A box is suppressed only if it overlaps with + another box of the same class that has a higher score + + Parameters + ---------- + boxes : [num_boxes, 6] + xmin, ymin, zmin, xmax, ymax, zmax + classes : [num_shapes] + Class of each box + scores : [num_shapes,] + score of each box + overlap_threshold : float, optional + [description], by default 0.25 + """ + if torch.is_tensor(boxes): + boxes = boxes.cpu().numpy() + if torch.is_tensor(scores): + scores = scores.cpu().numpy() + if torch.is_tensor(classes): + classes = classes.cpu().numpy() + + x1 = boxes[:, 0] + y1 = boxes[:, 1] + z1 = boxes[:, 2] + x2 = boxes[:, 3] + y2 = boxes[:, 4] + z2 = boxes[:, 5] + area = (x2 - x1) * (y2 - y1) * (z2 - z1) + + I = np.argsort(scores) + pick = [] + while I.size != 0: + last = I.size + i = I[-1] + pick.append(i) + + xx1 = np.maximum(x1[i], x1[I[: last - 1]]) + yy1 = np.maximum(y1[i], y1[I[: last - 1]]) + zz1 = np.maximum(z1[i], z1[I[: last - 1]]) + xx2 = np.minimum(x2[i], x2[I[: last - 1]]) + yy2 = np.minimum(y2[i], y2[I[: last - 1]]) + zz2 = np.minimum(z2[i], z2[I[: last - 1]]) + cls1 = classes[i] + cls2 = classes[I[: last - 1]] + + l = np.maximum(0, xx2 - xx1) + w = np.maximum(0, yy2 - yy1) + h = np.maximum(0, zz2 - zz1) + + inter = l * w * h + o = inter / (area[i] + area[I[: last - 1]] - inter) + o = o * (cls1 == cls2) + + I = np.delete(I, np.concatenate(([last - 1], np.where(o > overlap_threshold)[0]))) + + return pick + + +def box3d_iou(corners1, corners2): + """ Compute 3D bounding box IoU. + + Input: + corners1: array (8,3), assume up direction is Z + corners2: array (8,3), assume up direction is Z + Output: + iou: 3D bounding box IoU + """ + # corner points are in counter clockwise order + assert corners1.shape == (8, 3) + assert corners2.shape == (8, 3) + rect1 = np.asarray([(corners1[i, 0], corners1[i, 1]) for i in range(4)]) + rect2 = np.asarray([(corners2[i, 0], corners2[i, 1]) for i in range(4)]) + inter_area = intersection_area(rect1, rect2) + z_min = max(corners1[0, 2], corners2[0, 2]) + z_max = min(corners1[4, 2], corners2[4, 2]) + inter_vol = inter_area * max(0.0, z_max - z_min) + vol1 = box3d_vol(corners1) + vol2 = box3d_vol(corners2) + iou = inter_vol / (vol1 + vol2 - inter_vol) + return iou + + +def box3d_vol(corners): + """ corners: (8,3). No order required""" + corners = np.asarray(corners) + a = np.sqrt(np.sum((corners[0, :] - corners[1, :]) ** 2)) + b = np.sqrt(np.sum((corners[1, :] - corners[2, :]) ** 2)) + c = np.sqrt(np.sum((corners[0, :] - corners[4, :]) ** 2)) + return a * b * c + + +def intersection_area(p1, p2): + """ Compute area of two convex hull's intersection area. + p1,p2 are a list of (x,y) tuples of hull vertices. + return intersection volume + """ + assert len(p1[0]) == 2 and len(p2[0]) == 2 + inter_p = polygon_clip(p1, p2) + if inter_p is not None: + hull_inter = ConvexHull(inter_p) + return hull_inter.volume + else: + return 0.0 + + +def polygon_clip(subjectPolygon, clipPolygon): + """ Clip a polygon with another polygon. + + Ref: https://rosettacode.org/wiki/Sutherland-Hodgman_polygon_clipping#Python + + Args: + subjectPolygon: a list of (x,y) 2d points, any polygon. + clipPolygon: a list of (x,y) 2d points, has to be *convex* + Note: + **points have to be counter-clockwise ordered** + + Return: + a list of (x,y) vertex point for the intersection polygon. + """ + + def inside(p): + return (cp2[0] - cp1[0]) * (p[1] - cp1[1]) > (cp2[1] - cp1[1]) * (p[0] - cp1[0]) + + def computeIntersection(): + dc = [cp1[0] - cp2[0], cp1[1] - cp2[1]] + dp = [s[0] - e[0], s[1] - e[1]] + n1 = cp1[0] * cp2[1] - cp1[1] * cp2[0] + n2 = s[0] * e[1] - s[1] * e[0] + n3 = 1.0 / (dc[0] * dp[1] - dc[1] * dp[0]) + return [(n1 * dp[0] - n2 * dc[0]) * n3, (n1 * dp[1] - n2 * dc[1]) * n3] + + outputList = subjectPolygon + cp1 = clipPolygon[-1] + + for clipVertex in clipPolygon: + cp2 = clipVertex + inputList = outputList + outputList = [] + s = inputList[-1] + + for subjectVertex in inputList: + e = subjectVertex + if inside(e): + if not inside(s): + outputList.append(computeIntersection()) + outputList.append(e) + elif inside(s): + outputList.append(computeIntersection()) + s = e + cp1 = cp2 + if len(outputList) == 0: + return None + return outputList + + +################################################################################################ +# Intersection area without scipy. Could be used with numba +################################################################################################ + + +def intersection_area_noscipy(p1, p2): + """ Compute area of two convex hull's intersection area. + p1,p2 are a list of (x,y) tuples of hull vertices. + return intersection volume + """ + assert len(p1[0]) == 2 and len(p2[0]) == 2 + inter_p = polygon_clip(p1, p2) + if inter_p is not None: + hull_inter = np.asarray(convex_hull_graham(inter_p)) + area = polygon_area(hull_inter[:, 0], hull_inter[:, 1]) + return area + else: + return 0.0 + + +# Function to know if we have a CCW turn +def RightTurn(p1, p2, p3): + if (p3[1] - p1[1]) * (p2[0] - p1[0]) >= (p2[1] - p1[1]) * (p3[0] - p1[0]): + return False + return True + + +# Main algorithm: +def convex_hull_graham(P): + P.sort() # Sort the set of points + L_upper = [P[0], P[1]] # Initialize upper part + # Compute the upper part of the hull + for i in range(2, len(P)): + L_upper.append(P[i]) + while len(L_upper) > 2 and not RightTurn(L_upper[-1], L_upper[-2], L_upper[-3]): + del L_upper[-2] + L_lower = [P[-1], P[-2]] # Initialize the lower part + # Compute the lower part of the hull + for i in range(len(P) - 3, -1, -1): + L_lower.append(P[i]) + while len(L_lower) > 2 and not RightTurn(L_lower[-1], L_lower[-2], L_lower[-3]): + del L_lower[-2] + del L_lower[0] + del L_lower[-1] + L = L_upper + L_lower # Build the full hull + return L + + +def polygon_area(x, y): + correction = x[-1] * y[0] - y[-1] * x[0] + main_area = np.dot(x[:-1], y[1:]) - np.dot(y[:-1], x[1:]) + return 0.5 * np.abs(main_area + correction) diff --git a/torch-points3d/torch_points3d/utils/colors.py b/torch-points3d/torch_points3d/utils/colors.py new file mode 100644 index 0000000..d153361 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/colors.py @@ -0,0 +1,89 @@ +import logging + +log = logging.getLogger(__name__) + + +class COLORS: + """[This class is used to color the bash shell by using {} {} {} with 'COLORS.{}, text, COLORS.END_TOKEN'] + """ + + TRAIN_COLOR = "\033[0;92m" + VAL_COLOR = "\033[0;94m" + TEST_COLOR = "\033[0;93m" + BEST_COLOR = "\033[0;92m" + + END_TOKEN = "\033[0m)" + END_NO_TOKEN = "\033[0m" + + Black = "\033[0;30m" # Black + Red = "\033[0;31m" # Red + Green = "\033[0;32m" # Green + Yellow = "\033[0;33m" # Yellow + Blue = "\033[0;34m" # Blue + Purple = "\033[0;35m" # Purple + Cyan = "\033[0;36m" # Cyan + White = "\033[0;37m" # White + + # Bold + BBlack = "\033[1;30m" # Black + BRed = "\033[1;31m" # Red + BGreen = "\033[1;32m" # Green + BYellow = "\033[1;33m" # Yellow + BBlue = "\033[1;34m" # Blue + BPurple = "\033[1;35m" # Purple + BCyan = "\033[1;36m" # Cyan + BWhite = "\033[1;37m" # White + + # Underline + UBlack = "\033[4;30m" # Black + URed = "\033[4;31m" # Red + UGreen = "\033[4;32m" # Green + UYellow = "\033[4;33m" # Yellow + UBlue = "\033[4;34m" # Blue + UPurple = "\033[4;35m" # Purple + UCyan = "\033[4;36m" # Cyan + UWhite = "\033[4;37m" # White + + # Background + On_Black = "\033[40m" # Black + On_Red = "\033[41m" # Red + On_Green = "\033[42m" # Green + On_Yellow = "\033[43m" # Yellow + On_Blue = "\033[44m" # Blue + On_Purple = "\033[45m" # Purple + On_Cyan = "\033[46m" # Cyan + On_White = "\033[47m" # White + + # High Intensty + IBlack = "\033[0;90m" # Black + IRed = "\033[0;91m" # Red + IGreen = "\033[0;92m" # Green + IYellow = "\033[0;93m" # Yellow + IBlue = "\033[0;94m" # Blue + IPurple = "\033[0;95m" # Purple + ICyan = "\033[0;96m" # Cyan + IWhite = "\033[0;97m" # White + + # Bold High Intensty + BIBlack = "\033[1;90m" # Black + BIRed = "\033[1;91m" # Red + BIGreen = "\033[1;92m" # Green + BIYellow = "\033[1;93m" # Yellow + BIBlue = "\033[1;94m" # Blue + BIPurple = "\033[1;95m" # Purple + BICyan = "\033[1;96m" # Cyan + BIWhite = "\033[1;97m" # White + + # High Intensty backgrounds + On_IBlack = "\033[0;100m" # Black + On_IRed = "\033[0;101m" # Red + On_IGreen = "\033[0;102m" # Green + On_IYellow = "\033[0;103m" # Yellow + On_IBlue = "\033[0;104m" # Blue + On_IPurple = "\033[10;95m" # Purple + On_ICyan = "\033[0;106m" # Cyan + On_IWhite = "\033[0;107m" # White + + +def colored_print(color, msg): + log.info(color + msg + COLORS.END_NO_TOKEN) diff --git a/torch-points3d/torch_points3d/utils/config.py b/torch-points3d/torch_points3d/utils/config.py new file mode 100644 index 0000000..06c4377 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/config.py @@ -0,0 +1,75 @@ +import numpy as np +from typing import List +import shutil +import matplotlib.pyplot as plt +import os +from os import path as osp +import torch +import logging +from collections import namedtuple +from omegaconf import OmegaConf +from omegaconf.listconfig import ListConfig +from omegaconf.dictconfig import DictConfig +from .enums import ConvolutionFormat +from torch_points3d.utils.debugging_vars import DEBUGGING_VARS +from torch_points3d.utils.colors import COLORS, colored_print +import subprocess + +log = logging.getLogger(__name__) + + +class ConvolutionFormatFactory: + @staticmethod + def check_is_dense_format(conv_type): + if ( + conv_type.lower() == ConvolutionFormat.PARTIAL_DENSE.value.lower() + or conv_type.lower() == ConvolutionFormat.MESSAGE_PASSING.value.lower() + or conv_type.lower() == ConvolutionFormat.SPARSE.value.lower() + ): + return False + elif conv_type.lower() == ConvolutionFormat.DENSE.value.lower(): + return True + else: + raise NotImplementedError("Conv type {} not supported".format(conv_type)) + + +class Option: + """This class is used to enable accessing arguments as attributes without having OmaConf. + It is used along convert_to_base_obj function + """ + + def __init__(self, opt): + for key, value in opt.items(): + setattr(self, key, value) + + +def convert_to_base_obj(opt): + return Option(OmegaConf.to_container(opt)) + + +def set_debugging_vars_to_global(cfg): + for key in cfg.keys(): + key_upper = key.upper() + if key_upper in DEBUGGING_VARS.keys(): + DEBUGGING_VARS[key_upper] = cfg[key] + log.info(DEBUGGING_VARS) + + +def is_list(entity): + return isinstance(entity, list) or isinstance(entity, ListConfig) + + +def is_iterable(entity): + return isinstance(entity, list) or isinstance(entity, ListConfig) or isinstance(entity, tuple) + + +def is_dict(entity): + return isinstance(entity, dict) or isinstance(entity, DictConfig) + + +def create_symlink_from_eval_to_train(eval_checkpoint_dir): + root = os.path.join(os.getcwd(), "evals") + if not os.path.exists(root): + os.makedirs(root) + num_files = len(os.listdir(root)) + 1 + os.symlink(eval_checkpoint_dir, os.path.join(root, "eval_{}".format(num_files))) diff --git a/torch-points3d/torch_points3d/utils/debugging_vars.py b/torch-points3d/torch_points3d/utils/debugging_vars.py new file mode 100644 index 0000000..41c582c --- /dev/null +++ b/torch-points3d/torch_points3d/utils/debugging_vars.py @@ -0,0 +1,48 @@ +import numpy as np + +DEBUGGING_VARS = {"FIND_NEIGHBOUR_DIST": False} + + +def extract_histogram(spatial_ops, normalize=True): + out = [] + for idx, nf in enumerate(spatial_ops["neighbour_finder"]): + dist_meters = nf.dist_meters + temp = {} + for dist_meter in dist_meters: + hist = dist_meter.histogram.copy() + if normalize: + hist /= hist.sum() + temp[str(dist_meter.radius)] = hist.tolist() + dist_meter.reset() + out.append(temp) + return out + + +class DistributionNeighbour(object): + def __init__(self, radius, bins=1000): + self._radius = radius + self._bins = bins + self._histogram = np.zeros(self._bins) + + def reset(self): + self._histogram = np.zeros(self._bins) + + @property + def radius(self): + return self._radius + + @property + def histogram(self): + return self._histogram + + @property + def histogram_non_zero(self): + idx = len(self._histogram) - np.cumsum(self._histogram[::-1]).nonzero()[0][0] + return self._histogram[:idx] + + def add_valid_neighbours(self, points): + for num_valid in points: + self._histogram[num_valid] += 1 + + def __repr__(self): + return "{}(radius={}, bins={})".format(self.__class__.__name__, self._radius, self._bins) diff --git a/torch-points3d/torch_points3d/utils/download.py b/torch-points3d/torch_points3d/utils/download.py new file mode 100644 index 0000000..de47a46 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/download.py @@ -0,0 +1,38 @@ +import os +import os.path as osp +from six.moves import urllib +import ssl + + +def download_url(url, folder, log=True): + r"""Downloads the content of an URL to a specific folder. + + Args: + url (string): The url. + folder (string): The folder. + log (bool, optional): If :obj:`False`, will not print anything to the + console. (default: :obj:`True`) + """ + + filename = url.rpartition("/")[2] + path = osp.join(folder, filename) + + if osp.exists(path): # pragma: no cover + if log: + print("Using exist file", filename) + return path + + if log: + print("Downloading", url) + + try: + os.makedirs(folder) + except: + pass + context = ssl._create_unverified_context() + data = urllib.request.urlopen(url, context=context) + + with open(path, "wb") as f: + f.write(data.read()) + + return path diff --git a/torch-points3d/torch_points3d/utils/enums.py b/torch-points3d/torch_points3d/utils/enums.py new file mode 100644 index 0000000..f9de27f --- /dev/null +++ b/torch-points3d/torch_points3d/utils/enums.py @@ -0,0 +1,14 @@ +import enum + + +class SchedulerUpdateOn(enum.Enum): + ON_EPOCH = "on_epoch" + ON_NUM_BATCH = "on_num_batch" + ON_NUM_SAMPLE = "on_num_sample" + + +class ConvolutionFormat(enum.Enum): + DENSE = "dense" + PARTIAL_DENSE = "partial_dense" + MESSAGE_PASSING = "message_passing" + SPARSE = "sparse" diff --git a/torch-points3d/torch_points3d/utils/geometry.py b/torch-points3d/torch_points3d/utils/geometry.py new file mode 100644 index 0000000..02a40b9 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/geometry.py @@ -0,0 +1,51 @@ +import torch +import random + + +def euler_angles_to_rotation_matrix(theta, random_order=False): + R_x = torch.tensor( + [[1, 0, 0], [0, torch.cos(theta[0]), -torch.sin(theta[0])], [0, torch.sin(theta[0]), torch.cos(theta[0])]] + ) + + R_y = torch.tensor( + [[torch.cos(theta[1]), 0, torch.sin(theta[1])], [0, 1, 0], [-torch.sin(theta[1]), 0, torch.cos(theta[1])]] + ) + + R_z = torch.tensor( + [[torch.cos(theta[2]), -torch.sin(theta[2]), 0], [torch.sin(theta[2]), torch.cos(theta[2]), 0], [0, 0, 1]] + ) + + matrices = [R_x, R_y, R_z] + if random_order: + random.shuffle(matrices) + R = torch.mm(matrices[2], torch.mm(matrices[1], matrices[0])) + return R + + +def get_cross_product_matrix(k): + return torch.tensor([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]], device=k.device) + + +def rodrigues(axis, theta): + """ + given an axis of norm one and an angle, compute the rotation matrix using rodrigues formula + source : https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula + """ + K = get_cross_product_matrix(axis) + t = torch.tensor([theta], device=axis.device) + R = torch.eye(3, device=axis.device) + torch.sin(t) * K + (1 - torch.cos(t)) * K.mm(K) + return R + + +def get_trans(x): + """ + get the rotation matrix from the vector representation using the rodrigues formula + """ + T = torch.eye(4, device=x.device) + T[:3, 3] = x[3:] + axis = x[:3] + theta = torch.norm(axis) + if theta > 0: + axis = axis / theta + T[:3, :3] = rodrigues(axis, theta) + return T diff --git a/torch-points3d/torch_points3d/utils/mock.py b/torch-points3d/torch_points3d/utils/mock.py new file mode 100644 index 0000000..b2c54a1 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/mock.py @@ -0,0 +1,74 @@ +import torch +from torch_geometric.data import Data, Batch + +from torch_points3d.datasets.batch import SimpleBatch +from torch_points3d.core.data_transform import MultiScaleTransform +from torch_points3d.datasets.multiscale_data import MultiScaleBatch + + +class MockDatasetConfig(object): + def __init__(self): + pass + + def keys(self): + return [] + + def get(self, dataset_name, default): + return None + + +class MockDataset(torch.utils.data.Dataset): + def __init__(self, feature_size=0, transform=None, num_points=100): + self.feature_dimension = feature_size + self.num_classes = 10 + self.num_points = num_points + self.batch_size = 2 + self.weight_classes = None + if feature_size > 0: + self._feature = torch.tensor([range(feature_size) for i in range(self.num_points)], dtype=torch.float,) + else: + self._feature = None + self._y = torch.tensor([0 for i in range(self.num_points)], dtype=torch.long) + self._category = torch.ones((self.num_points,), dtype=torch.long) + self._ms_transform = None + self._transform = transform + + def __len__(self): + return self.num_points + + def len(self): + return len(self) + + @property + def datalist(self): + torch.manual_seed(0) + torch.randn((self.num_points, 3)) + datalist = [ + Data(pos=torch.randn((self.num_points, 3)), x=self._feature, y=self._y, category=self._category) + for i in range(self.batch_size) + ] + if self._transform: + datalist = [self._transform(d.clone()) for d in datalist] + if self._ms_transform: + datalist = [self._ms_transform(d.clone()) for d in datalist] + return datalist + + def __getitem__(self, index): + return SimpleBatch.from_data_list(self.datalist) + + @property + def class_to_segments(self): + return {"class1": [0, 1, 2, 3, 4, 5], "class2": [6, 7, 8, 9]} + + def set_strategies(self, model): + strategies = model.get_spatial_ops() + transform = MultiScaleTransform(strategies) + self._ms_transform = transform + + +class MockDatasetGeometric(MockDataset): + def __getitem__(self, index): + if self._ms_transform: + return MultiScaleBatch.from_data_list(self.datalist) + else: + return Batch.from_data_list(self.datalist) diff --git a/torch-points3d/torch_points3d/utils/model_building_utils/activation_resolver.py b/torch-points3d/torch_points3d/utils/model_building_utils/activation_resolver.py new file mode 100644 index 0000000..e89b994 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/model_building_utils/activation_resolver.py @@ -0,0 +1,19 @@ +import torch.nn + +from torch_points3d.utils.config import is_dict + + +def get_activation(act_opt, create_cls=True): + if is_dict(act_opt): + act_opt = dict(act_opt) + act = getattr(torch.nn, act_opt["name"]) + del act_opt["name"] + args = dict(act_opt) + else: + act = getattr(torch.nn, act_opt) + args = {} + + if create_cls: + return act(**args) + else: + return act diff --git a/torch-points3d/torch_points3d/utils/model_building_utils/model_definition_resolver.py b/torch-points3d/torch_points3d/utils/model_building_utils/model_definition_resolver.py new file mode 100644 index 0000000..5d6118c --- /dev/null +++ b/torch-points3d/torch_points3d/utils/model_building_utils/model_definition_resolver.py @@ -0,0 +1,51 @@ +from omegaconf.dictconfig import DictConfig +from omegaconf.listconfig import ListConfig + + +def resolve_model(model_config, dataset, tested_task): + """ Parses the model config and evaluates any expression that may contain constants + """ + # placeholders to subsitute + constants = { + "FEAT": max(dataset.feature_dimension, 0), + "TASK": tested_task, + "N_CLS": dataset.num_classes if hasattr(dataset, "num_classes") else None, + } + + # user defined constants to substitute + if "define_constants" in model_config.keys(): + constants.update(dict(model_config.define_constants)) + + resolve(model_config, constants) + + +def resolve(obj, constants): + """ Resolves expressions and constants in obj. + returns False if obj is a ListConfig or DictConfig, True is obj is a primative type. + """ + if type(obj) == DictConfig: + it = (k for k in obj) + elif type(obj) == ListConfig: + it = range(len(obj)) + else: + # obj is a single element + return True + + # recursively resolve all children of obj + for k in it: + + # if obj[k] is a primitive type, evaluate it + if resolve(obj[k], constants): + if type(obj[k]) is str and obj[k] != "path_pretrained": + try: + obj[k] = eval(obj[k], constants) + except NameError: + # we tried to resolve a string which isn't an expression + pass + except ValueError: + # we tried to resolve a string which is also a builtin (e.g. max) + pass + except Exception as e: + print(e) + + return False diff --git a/torch-points3d/torch_points3d/utils/model_building_utils/resolver_utils.py b/torch-points3d/torch_points3d/utils/model_building_utils/resolver_utils.py new file mode 100644 index 0000000..dee5023 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/model_building_utils/resolver_utils.py @@ -0,0 +1,15 @@ +import collections + +# from https://stackoverflow.com/questions/6027558/flatten-nested-dictionaries-compressing-keys +# flattens nested dicts to a single dict, with keys concatenated +# e.g. flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 5, 'y' : 10}}, 'd': [1, 2, 3]}) +# {'a': 1, 'c_a': 2, 'c_b_x': 5, 'd': [1, 2, 3], 'c_b_y': 10} +def flatten_dict(d, parent_key="", sep="_"): + items = [] + for k, v in d.items(): + new_key = parent_key + sep + k if parent_key else k + if isinstance(v, collections.abc.MutableMapping): + items.extend(flatten_dict(v, new_key, sep=sep).items()) + else: + items.append((new_key, v)) + return dict(items) diff --git a/torch-points3d/torch_points3d/utils/o3d_utils.py b/torch-points3d/torch_points3d/utils/o3d_utils.py new file mode 100644 index 0000000..86d2c47 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/o3d_utils.py @@ -0,0 +1,44 @@ +import open3d +import random + + +def get_random_color(pastel_factor=0.5): + return [(x + pastel_factor) / (1.0 + pastel_factor) for x in [random.uniform(0, 1.0) for i in [1, 2, 3]]] + + +def color_distance(c1, c2): + return sum([abs(x[0] - x[1]) for x in zip(c1, c2)]) + + +def generate_new_color(existing_colors, pastel_factor=0.5): + max_distance = None + best_color = None + for i in range(0, 100): + color = get_random_color(pastel_factor=pastel_factor) + if not existing_colors: + return color + best_distance = min([color_distance(color, c) for c in existing_colors]) + if not max_distance or best_distance > max_distance: + max_distance = best_distance + best_color = color + return best_color + + +def torch2o3d(data, color=[1, 0, 0]): + xyz = data.pos + norm = getattr(data, "norm", None) + pcd = open3d.geometry.PointCloud() + pcd.points = open3d.utility.Vector3dVector(xyz.detach().cpu().numpy()) + if norm is not None: + pcd.normals = open3d.utility.Vector3dVector(norm.detach().cpu().numpy()) + pcd.paint_uniform_color(color) + return pcd + + +def apply_mask(d, mask, skip_keys=[]): + data = d.clone() + size_pos = len(data.pos) + for k in data.keys: + if size_pos == len(data[k]) and k not in skip_keys: + data[k] = data[k][mask] + return data diff --git a/torch-points3d/torch_points3d/utils/registration.py b/torch-points3d/torch_points3d/utils/registration.py new file mode 100644 index 0000000..d75dc89 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/registration.py @@ -0,0 +1,163 @@ +""" +registration toolbox (algorithm for some registration algorithm) +Implemented: fast_global_registration +teaser +""" +import open3d +import numpy as np +import torch +from torch_points3d.utils.geometry import get_trans +from torch_geometric.nn import knn + + +def get_matches(feat_source, feat_target, sym=False): + + matches = knn(feat_target, feat_source, k=1).T + if sym: + match_inv = knn(feat_source, feat_target, k=1).T + mask = match_inv[matches[:, 1], 1] == torch.arange(matches.shape[0], device=feat_source.device) + return matches[mask] + else: + return matches + + +def estimate_transfo(xyz, xyz_target): + """ + estimate the rotation and translation using Kabsch algorithm + Parameters: + xyz : + xyz_target: + """ + assert xyz.shape == xyz.shape + xyz_c = xyz - xyz.mean(0) + xyz_target_c = xyz_target - xyz_target.mean(0) + Q = xyz_c.T.mm(xyz_target_c) / len(xyz) + U, S, V = torch.svd(Q) + d = torch.det(V.mm(U.T)) + diag = torch.diag(torch.tensor([1, 1, d], device=xyz.device)) + R = V.mm(diag).mm(U.T) + t = xyz_target.mean(0) - R @ xyz.mean(0) + T = torch.eye(4, device=xyz.device) + T[:3, :3] = R + T[:3, 3] = t + return T + + +def get_geman_mclure_weight(xyz, xyz_target, mu): + """ + compute the weights defined here for the iterative reweighted least square. + http://vladlen.info/papers/fast-global-registration.pdf + """ + norm2 = torch.norm(xyz_target - xyz, dim=1) ** 2 + return (mu / (mu + norm2)).view(-1, 1) + + +def get_matrix_system(xyz, xyz_target, weight): + """ + Build matrix of size 3N x 6 and b of size 3N + + xyz size N x 3 + xyz_target size N x 3 + weight size N + the matrix is minus cross product matrix concatenate with the identity (rearanged). + """ + assert xyz.shape == xyz_target.shape + A_x = torch.zeros(xyz.shape[0], 6, device=xyz.device) + A_y = torch.zeros(xyz.shape[0], 6, device=xyz.device) + A_z = torch.zeros(xyz.shape[0], 6, device=xyz.device) + b_x = weight.view(-1) * (xyz_target[:, 0] - xyz[:, 0]) + b_y = weight.view(-1) * (xyz_target[:, 1] - xyz[:, 1]) + b_z = weight.view(-1) * (xyz_target[:, 2] - xyz[:, 2]) + A_x[:, 1] = weight.view(-1) * xyz[:, 2] + A_x[:, 2] = -weight.view(-1) * xyz[:, 1] + A_x[:, 3] = weight.view(-1) * 1 + A_y[:, 0] = -weight.view(-1) * xyz[:, 2] + A_y[:, 2] = weight.view(-1) * xyz[:, 0] + A_y[:, 4] = weight.view(-1) * 1 + A_z[:, 0] = weight.view(-1) * xyz[:, 1] + A_z[:, 1] = -weight.view(-1) * xyz[:, 0] + A_z[:, 5] = weight.view(-1) * 1 + return torch.cat([A_x, A_y, A_z], 0), torch.cat([b_x, b_y, b_z], 0).view(-1, 1) + + +def fast_global_registration(xyz, xyz_target, mu_init=1, num_iter=20): + """ + estimate the rotation and translation using Fast Global Registration algorithm (M estimator for robust estimation) + http://vladlen.info/papers/fast-global-registration.pdf + """ + assert xyz.shape == xyz_target.shape + + T_res = torch.eye(4, device=xyz.device) + mu = mu_init + source = xyz.clone() + weight = torch.ones(len(source), 1, device=xyz.device) + for i in range(num_iter): + if i > 0 and i % 5 == 0: + mu /= 2.0 + A, b = get_matrix_system(source, xyz_target, weight) + x = torch.linalg.solve(A.T.mm(A), A.T @ b) + T = get_trans(x.view(-1)) + source = source.mm(T[:3, :3].T) + T[:3, 3] + T_res = T @ T_res + weight = get_geman_mclure_weight(source, xyz_target, mu) + return T_res + + +def teaser_pp_registration( + xyz, + xyz_target, + noise_bound=0.05, + cbar2=1, + rotation_gnc_factor=1.4, + rotation_max_iterations=100, + rotation_cost_threshold=1e-12, +): + assert xyz.shape == xyz_target.shape + import teaserpp_python + + # Populating the parameters + solver_params = teaserpp_python.RobustRegistrationSolver.Params() + solver_params.cbar2 = cbar2 + solver_params.noise_bound = noise_bound + solver_params.estimate_scaling = False + solver_params.rotation_estimation_algorithm = ( + teaserpp_python.RobustRegistrationSolver.ROTATION_ESTIMATION_ALGORITHM.GNC_TLS + ) + solver_params.rotation_gnc_factor = rotation_gnc_factor + solver_params.rotation_max_iterations = rotation_max_iterations + solver_params.rotation_cost_threshold = rotation_cost_threshold + + solver = teaserpp_python.RobustRegistrationSolver(solver_params) + + solver.solve(xyz.T.detach().cpu().numpy(), xyz_target.T.detach().cpu().numpy()) + + solution = solver.getSolution() + T_res = torch.eye(4, device=xyz.device) + T_res[:3, :3] = torch.from_numpy(solution.rotation).to(xyz.device) + T_res[:3, 3] = torch.from_numpy(solution.translation).to(xyz.device) + return T_res + + +def ransac_registration(xyz, xyz_target, distance_threshold=0.05, num_iterations=80000): + """ + use Open3D version of RANSAC + """ + pcd = open3d.geometry.PointCloud() + pcd.points = open3d.utility.Vector3dVector(xyz.detach().cpu().numpy()) + + pcd_t = open3d.geometry.PointCloud() + pcd_t.points = open3d.utility.Vector3dVector(xyz_target.detach().cpu().numpy()) + rang = np.arange(len(xyz)) + corres = np.stack((rang, rang), axis=1) + corres = open3d.utility.Vector2iVector(corres) + result = open3d.pipelines.registration.registration_ransac_based_on_correspondence( + pcd, + pcd_t, + corres, + distance_threshold, + estimation_method=open3d.pipelines.registration.TransformationEstimationPointToPoint(False), + ransac_n=4, + criteria=open3d.pipelines.registration.RANSACConvergenceCriteria(4000000, num_iterations), + ) + + return torch.from_numpy(result.transformation).float() diff --git a/torch-points3d/torch_points3d/utils/running_stats.py b/torch-points3d/torch_points3d/utils/running_stats.py new file mode 100644 index 0000000..2c2b0b7 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/running_stats.py @@ -0,0 +1,35 @@ +import numpy as np + + +class RunningStats: + def __init__(self): + self.n = 0 + self.old_m = 0 + self.new_m = 0 + self.old_s = 0 + self.new_s = 0 + + def clear(self): + self.n = 0 + + def push(self, x): + self.n += 1 + + if self.n == 1: + self.old_m = self.new_m = x + self.old_s = 0 + else: + self.new_m = self.old_m + (x - self.old_m) / self.n + self.new_s = self.old_s + (x - self.old_m) * (x - self.new_m) + + self.old_m = self.new_m + self.old_s = self.new_s + + def mean(self): + return self.new_m if self.n else 0.0 + + def variance(self): + return self.new_s / (self.n - 1) if self.n > 1 else 0.0 + + def std(self): + return np.sqrt(self.variance()) diff --git a/torch-points3d/torch_points3d/utils/timer.py b/torch-points3d/torch_points3d/utils/timer.py new file mode 100644 index 0000000..6e9debe --- /dev/null +++ b/torch-points3d/torch_points3d/utils/timer.py @@ -0,0 +1,53 @@ +from time import time +from collections import defaultdict +import functools +from .running_stats import RunningStats + +FunctionStats: defaultdict = defaultdict(RunningStats) + + +def time_func(*outer_args, **outer_kwargs): + print_rec = outer_kwargs.get("print_rec", 100) + measure_runtime = outer_kwargs.get("measure_runtime", False) + name = outer_kwargs.get("name", "") + + def time_func_inner(func): + @functools.wraps(func) + def func_wrapper(*args, **kwargs): + if measure_runtime: + func_name = name if name else func.__name__ + if FunctionStats.get(func_name, None) is not None: + if FunctionStats[func_name].n % print_rec == 0: + stats = FunctionStats[func_name] + stats_mean = stats.mean() + print( + "{} run in {} | {} over {} runs".format( + func_name, stats_mean, stats_mean * stats.n, stats.n + ) + ) + # print('{} run in {} +/- {} over {} runs'.format(func.__name__, stats.mean(), stats.std(), stats.n)) + t0 = time() + out = func(*args, **kwargs) + diff = time() - t0 + FunctionStats[func_name].push(diff) + return out + else: + return func(*args, **kwargs) + + return func_wrapper + + return time_func_inner + + +@time_func(print_rec=50, measure_runtime=True) +def do_nothing(): + pass + + +def iteration(): + for _ in range(10000): + do_nothing() + + +if __name__ == "__main__": + iteration() diff --git a/torch-points3d/torch_points3d/utils/transform_utils.py b/torch-points3d/torch_points3d/utils/transform_utils.py new file mode 100644 index 0000000..8529a3f --- /dev/null +++ b/torch-points3d/torch_points3d/utils/transform_utils.py @@ -0,0 +1,39 @@ +import numpy as np + + +class SamplingStrategy(object): + + STRATEGIES = ["random", "freq_class_based"] + CLASS_WEIGHT_METHODS = ["sqrt"] + + def __init__(self, strategy="random", class_weight_method="sqrt"): + + if strategy.lower() in self.STRATEGIES: + self._strategy = strategy.lower() + + if class_weight_method.lower() in self.CLASS_WEIGHT_METHODS: + self._class_weight_method = class_weight_method.lower() + + def __call__(self, data): + + if self._strategy == "random": + random_center = np.random.randint(0, len(data.pos)) + + elif self._strategy == "freq_class_based": + labels = np.asarray(data.y) + uni, uni_counts = np.unique(np.asarray(data.y), return_counts=True) + uni_counts = uni_counts.mean() / uni_counts + if self._class_weight_method == "sqrt": + uni_counts = np.sqrt(uni_counts) + uni_counts /= np.sum(uni_counts) + chosen_label = np.random.choice(uni, p=uni_counts) + random_center = np.random.choice(np.argwhere(labels == chosen_label).flatten()) + else: + raise NotImplementedError + + return random_center + + def __repr__(self): + return "{}(strategy={}, class_weight_method={})".format( + self.__class__.__name__, self._strategy, self._class_weight_method + ) diff --git a/torch-points3d/torch_points3d/utils/wandb_utils.py b/torch-points3d/torch_points3d/utils/wandb_utils.py new file mode 100644 index 0000000..4de45c5 --- /dev/null +++ b/torch-points3d/torch_points3d/utils/wandb_utils.py @@ -0,0 +1,109 @@ +import os +import shutil +import subprocess + + +class WandbUrls: + def __init__(self, url): + hash = url.split("/")[-2] + project = url.split("/")[-3] + entity = url.split("/")[-4] + + self.weight_url = url + self.log_url = "https://app.wandb.ai/{}/{}/runs/{}/logs".format(entity, project, hash) + self.chart_url = "https://app.wandb.ai/{}/{}/runs/{}".format(entity, project, hash) + self.overview_url = "https://app.wandb.ai/{}/{}/runs/{}/overview".format(entity, project, hash) + self.hydra_config_url = "https://app.wandb.ai/{}/{}/runs/{}/files/hydra-config.yaml".format( + entity, project, hash + ) + self.overrides_url = "https://app.wandb.ai/{}/{}/runs/{}/files/overrides.yaml".format(entity, project, hash) + + def __repr__(self): + msg = "=================================================== WANDB URLS ===================================================================\n" + for k, v in self.__dict__.items(): + msg += "{}: {}\n".format(k.upper(), v) + msg += "=================================================================================================================================\n" + return msg + + +class Wandb: + IS_ACTIVE = False + + @staticmethod + def set_urls_to_model(model, url): + wandb_urls = WandbUrls(url) + model.wandb = wandb_urls + + @staticmethod + def _set_to_wandb_args(wandb_args, cfg, name): + var = getattr(cfg.training.wandb, name, None) + if var: + wandb_args[name] = var + + @staticmethod + def launch(cfg, launch: bool): + if launch: + import wandb + + Wandb.IS_ACTIVE = True + + model_config = getattr(cfg.models, cfg.model_name, None) + model_class = getattr(model_config, "class", "loaded model") + tested_dataset_class = getattr(cfg.data, "class") + optim = cfg.training.get("optim", None) + otimizer_class = getattr(optim.optimizer, "class") if optim is not None else "loaded model" + lr_scheduler = cfg.training.get("lr_scheduler", None) + scheduler_class = getattr(lr_scheduler, "class") if lr_scheduler is not None else "loaded model" + tags = [ + cfg.model_name, + model_class.split(".")[0], + tested_dataset_class, + otimizer_class, + scheduler_class, + ] + + wandb_args = {} + wandb_args["project"] = cfg.training.wandb.project + wandb_args["tags"] = tags + wandb_args["resume"] = "allow" + Wandb._set_to_wandb_args(wandb_args, cfg, "name") + Wandb._set_to_wandb_args(wandb_args, cfg, "entity") + Wandb._set_to_wandb_args(wandb_args, cfg, "notes") + Wandb._set_to_wandb_args(wandb_args, cfg, "config") + Wandb._set_to_wandb_args(wandb_args, cfg, "id") + + try: + commit_sha = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("ascii").strip() + gitdiff = subprocess.check_output(["git", "diff", "--", "':!notebooks'"]).decode() + except: + commit_sha = "n/a" + gitdiff = "" + + config = wandb_args.get("config", {}) + wandb_args["config"] = { + **config, + "run_path": os.getcwd(), + "commit": commit_sha, + } + + wandb_args["settings"] = wandb.Settings(start_method="fork") + + wandb.init(**wandb_args) + shutil.copyfile( + os.path.join(os.getcwd(), ".hydra/config.yaml"), os.path.join(os.getcwd(), ".hydra/hydra-config.yaml") + ) + wandb.save(os.path.join(os.getcwd(), ".hydra/hydra-config.yaml")) + wandb.save(os.path.join(os.getcwd(), ".hydra/overrides.yaml")) + + with open("change.patch", "w") as f: + f.write(gitdiff) + wandb.save(os.path.join(os.getcwd(), "change.patch")) + + @staticmethod + def add_file(file_path: str): + if not Wandb.IS_ACTIVE: + raise RuntimeError("wandb is inactive, please launch first.") + import wandb + + filename = os.path.basename(file_path) + shutil.copyfile(file_path, os.path.join(wandb.run.dir, filename)) diff --git a/torch-points3d/torch_points3d/visualization/__init__.py b/torch-points3d/torch_points3d/visualization/__init__.py new file mode 100644 index 0000000..9f2b5a4 --- /dev/null +++ b/torch-points3d/torch_points3d/visualization/__init__.py @@ -0,0 +1,2 @@ +from .visualizer import * +from .experiment_manager import ExperimentManager diff --git a/torch-points3d/torch_points3d/visualization/experiment_manager.py b/torch-points3d/torch_points3d/visualization/experiment_manager.py new file mode 100644 index 0000000..12451ed --- /dev/null +++ b/torch-points3d/torch_points3d/visualization/experiment_manager.py @@ -0,0 +1,191 @@ +import os +from glob import glob +from collections import defaultdict +import torch +from plyfile import PlyData, PlyElement +from numpy.lib import recfunctions as rfn +from torch_points3d.utils.colors import COLORS +import numpy as np + + +def colored_print(color, msg): + print(color + msg + COLORS.END_NO_TOKEN) + + +class ExperimentFolder: + + POS_KEYS = ["x", "y", "z"] + + def __init__(self, run_path): + self._run_path = run_path + self._model_name = None + self._stats = None + self._find_files() + + def _find_files(self): + self._files = os.listdir(self._run_path) + + def __repr__(self): + return self._run_path.split("outputs")[1] + + @property + def model_name(self): + return self._model_name + + @property + def epochs(self): + return os.listdir(self._viz_path) + + def get_splits(self, epoch): + return os.listdir(os.path.join(self._viz_path, str(epoch))) + + def get_files(self, epoch, split): + return os.listdir(os.path.join(self._viz_path, str(epoch), split)) + + def load_ply(self, epoch, split, file): + self._data_name = "data_{}_{}_{}".format(epoch, split, file) + if not hasattr(self, self._data_name): + path_to_ply = os.path.join(self._viz_path, str(epoch), split, file) + if os.path.exists(path_to_ply): + plydata = PlyData.read(path_to_ply) + arr = np.asarray([e.data for e in plydata.elements]) + names = list(arr.dtype.names) + pos_indices = [names.index(n) for n in self.POS_KEYS] + non_pos_indices = {n: names.index(n) for n in names if n not in self.POS_KEYS} + arr_ = rfn.structured_to_unstructured(arr).squeeze() + xyz = arr_[:, pos_indices] + data = {"xyz": xyz, "columns": non_pos_indices.keys(), "name": self._data_name} + for n, i in non_pos_indices.items(): + data[n] = arr_[:, i] + setattr(self, self._data_name, data) + else: + print("The file doesn' t exist: Wierd !") + else: + return getattr(self, self._data_name) + + @property + def current_pointcloud(self): + return getattr(self, self._data_name) + + @property + def contains_viz(self): + if not hasattr(self, "_contains_viz"): + for f in self._files: + if "viz" in f: + self._viz_path = os.path.join(self._run_path, "viz") + vizs = os.listdir(self._viz_path) + self._contains_viz = len(vizs) > 0 + return self._contains_viz + self._contains_viz = False + return self._contains_viz + else: + return self._contains_viz + + @property + def contains_trained_model(self): + if not hasattr(self, "_contains_trained_model"): + for f in self._files: + if ".pt" in f: + self._contains_trained_model = True + self._model_name = f + return self._contains_trained_model + self._contains_trained_model = False + return self._contains_trained_model + else: + return self._contains_trained_model + + def extract_stats(self): + path_to_checkpoint = os.path.join(self._run_path, self.model_name) + stats = torch.load(path_to_checkpoint)["stats"] + self._stats = stats + num_epoch = len(stats["train"]) + stats_dict = defaultdict(dict) + for split_name in stats.keys(): + if len(stats[split_name]) > 0: + latest_epoch = stats[split_name][-1] + for metric_name in latest_epoch.keys(): + if "best" in metric_name: + stats_dict[metric_name][split_name] = latest_epoch[metric_name] + return num_epoch, stats_dict + + +class ExperimentManager(object): + def __init__(self, experiments_root): + self._experiments_root = experiments_root + self._collect_experiments() + + def _collect_experiments(self): + self._experiment_with_models = defaultdict(list) + run_paths = glob(os.path.join(self._experiments_root, "outputs", "*", "*")) + for run_path in run_paths: + experiment = ExperimentFolder(run_path) + if experiment.contains_trained_model: + self._experiment_with_models[experiment.model_name].append(experiment) + + self._find_experiments_with_viz() + + def _find_experiments_with_viz(self): + if not hasattr(self, "_experiment_with_viz"): + self._experiment_with_viz = defaultdict(list) + for model_name in self._experiment_with_models.keys(): + for experiment in self._experiment_with_models[model_name]: + if experiment.contains_viz: + self._experiment_with_viz[experiment.model_name].append(experiment) + + @property + def model_name_wviz(self): + keys = list(self._experiment_with_viz.keys()) + return [k.replace(".pt", "") for k in keys] + + @property + def current_pointcloud(self): + return self._current_experiment.current_pointcloud + + def load_ply_file(self, file): + if hasattr(self, "_current_split"): + self._current_file = file + self._current_experiment.load_ply(self._current_epoch, self._current_split, self._current_file) + else: + return [] + + def from_split_to_file(self, split_name): + if hasattr(self, "_current_epoch"): + self._current_split = split_name + return self._current_experiment.get_files(self._current_epoch, self._current_split) + else: + return [] + + def from_epoch_to_split(self, epoch): + if hasattr(self, "_current_experiment"): + self._current_epoch = epoch + return self._current_experiment.get_splits(self._current_epoch) + else: + return [] + + def from_paths_to_epoch(self, run_path): + for exp in self._current_exps: + if str(run_path) == str(exp.__repr__()): + self._current_experiment = exp + return sorted(self._current_experiment.epochs) + + def get_model_wviz_paths(self, model_path): + model_name = model_path + ".pt" + self._current_exps = self._experiment_with_viz[model_name] + return self._current_exps + + def display_stats(self): + print("") + for model_name in self._experiment_with_models.keys(): + colored_print(COLORS.Green, str(model_name)) + for experiment in self._experiment_with_models[model_name]: + print(experiment) + num_epoch, stats = experiment.extract_stats() + colored_print(COLORS.Red, "Epoch: {}".format(num_epoch)) + for metric_name in stats: + sentence = "" + for split_name in stats[metric_name].keys(): + sentence += "{}: {}, ".format(split_name, stats[metric_name][split_name]) + metric_sentence = metric_name + "({})".format(sentence[:-2]) + colored_print(COLORS.BBlue, metric_sentence) + print("") + print("") diff --git a/torch-points3d/torch_points3d/visualization/visualizer.py b/torch-points3d/torch_points3d/visualization/visualizer.py new file mode 100644 index 0000000..fa2b3f8 --- /dev/null +++ b/torch-points3d/torch_points3d/visualization/visualizer.py @@ -0,0 +1,414 @@ +import logging +import os +from itertools import product +from math import log10, ceil +from pathlib import Path + +import numpy as np +import pandas as pd +import torch +import wandb +from matplotlib.cm import get_cmap +from plyfile import PlyData, PlyElement + +from torch_points3d.utils.config import is_list + +log = logging.getLogger(__name__) + + +class Visualizer: + """Initialize the Visualizer class. + Parameters: + viz_conf (OmegaConf Dictionary) -- stores all config for the visualizer + num_batches (dict) -- This dictionary maps stage_name to #batches + batch_size (int) -- Current batch size usef + save_dir (str) -- The path used by hydra to store the experiment + + This class is responsible to save visuals into different formats. Currently supported formats are: + ply -- Either an ascii or binary ply file, with the labels and gt stored as columns + tensorboard -- Visualize point cloud in tensorboard + wandb -- Upload point cloud to wandb. WARNING: This can become very slow, both in training and on the web. + Make sure you properly limit the num_samples_per_epoch and wandb_max_points. + csv -- creates a csv with predictions + + The configuration looks like this: + visualization: + activate: False # Whether to activate the visualizer + format: ["ply", "tensorboard"] # 'pointcloud' is deprecated, use 'ply' instead + num_samples_per_epoch: 2 # If negative, it will save all elements + deterministic: True # False -> Randomly sample elements from epoch to epoch + deterministic_seed: 0 # Random seed used to generate consistant keys if deterministic is True + saved_keys: # Mapping from Data Object to structured numpy + pos: [['x', 'float'], ['y', 'float'], ['z', 'float']] + y: [['l', 'float']] + pred: [['p', 'float']] + indices: # List of indices to be saved (support "train", "test", "val") + train: [0, 3] + # Format specific options: + ply_format: binary_big_endian # PLY format (support "binary_big_endian", "binary_little_endian", "ascii") + tensorboard_mesh: # Mapping from mesh name and propety use to color + label: 'y' + prediction: 'pred' + wandb_max_points: 10000 # Limits the size of the cloud that gets uploaded by random sampling. + # "-1" saves the entire cloud + wandb_cmap: # Applies a color map to the point cloud. Allows custom coloring of different classes. + - [0, 0, 0] # class 0 + - [255, 255, 255] # class 1 + - [128, 128, 128] # class 2 + """ + + def __init__(self, viz_conf, num_batches, batch_size, save_dir, tracker): + # From configuration and dataset + for stage_name, stage_num_sample in num_batches.items(): + setattr(self, "{}_num_batches".format(stage_name), stage_num_sample) + self._batch_size = batch_size + self._activate = viz_conf.activate + self._format = [viz_conf.format] if not is_list(viz_conf.format) else viz_conf.format + self._num_samples_per_epoch = int(viz_conf.num_samples_per_epoch) + self._deterministic = viz_conf.deterministic + self._seed = viz_conf.deterministic_seed if viz_conf.deterministic_seed is not None else 0 + self._tracker = tracker + + self._saved_keys = viz_conf.saved_keys + self._tensorboard_mesh = {} + self.save_dir = save_dir + self._viz_path = os.path.join(save_dir, "viz") + + # Internal state + self._stage = None + self._current_epoch = None + + # format-specific initialization + if "ply" in self._format: + self._ply_format = viz_conf.ply_format if viz_conf.ply_format is not None else "binary_big_endian" + + if "tensorboard" in self._format: + if not tracker._use_tensorboard: + log.warning("Tensorboard visualization specified, but tensorboard isn't active.") + else: + self._tensorboard_mesh = viz_conf.tensorboard_mesh + + # SummaryWriter for tensorboard loging + self._writer = tracker._writer + + if "wandb" in self._format: + if not self._tracker._wandb: + log.warning("Wandb visualization specified, but Wandb isn't active.") + else: + self._wandb_cmap = viz_conf.get("wandb_cmap", None) + self._max_points = viz_conf.wandb_max_points if viz_conf.get("wandb_max_points", + None) is not None else -1 + + if "gpkg" in self._format or "csv" in self._format: + self.dfs = [] + + self._indices = {} + self._contains_indices = False + + try: + indices = getattr(viz_conf, "indices", None) + except: + indices = None + + if indices: + for split in ["train", "test", "val"]: + if split in indices: + split_indices = indices[split] + self._indices[split] = np.asarray(split_indices) + self._contains_indices = True + + def finalize_epoch(self, loaders): + if "gpkg" in self._format or "csv" in self._format: + df = pd.concat(self.dfs) + + for loader in loaders: + + dataset = loader.dataset + for area_name in np.unique(df.area): + area_pred = df.query("area == @area_name").set_index("label_idx").drop("area", axis=1) + + if "csv" in self._format: + dff = area_pred.query(f"stage == '{self._stage}'") + del dff["stage"] + file = self.save_dir / Path(f"{area_name}_{self._stage}_preds.csv") + dff.to_csv(file, mode='a', header=not file.exists()) + + if "gpkg" in self._format: + file = self.save_dir / Path(f"{area_name}_preds.gpkg") + area_label = dataset.areas.get(area_name, None) + if area_label is None: + continue + area_label = area_label["labels"] + area_df = area_label.join(area_pred, rsuffix="_pred", how="inner") + area_df = area_df.query(f"stage == '{self._stage}'") + del area_df["stage"] + area_df.to_file(file, mode='a' if file.exists() else "w") + + def get_indices(self, stage): + """This function is responsible to calculate the indices to be saved""" + if self._contains_indices: + return + stage_num_batches = getattr(self, "{}_num_batches".format(stage)) + total_items = (stage_num_batches - 1) * self._batch_size + if stage_num_batches > 0: + if self._num_samples_per_epoch < 0: # All elements should be saved. + if stage_num_batches > 0: + self._indices[stage] = np.arange(total_items) + else: + self._indices[stage] = None + else: + if self._num_samples_per_epoch > total_items: + log.warning("Number of samples to save is higher than the number of available elements") + self._indices[stage] = self._rng.permutation(total_items)[: self._num_samples_per_epoch] + + @property + def is_active(self): + return self._activate + + def reset(self, epoch, stage): + """This function is responsible to restore the visualizer + to start a new epoch on a new stage + """ + self._current_epoch = epoch + self._seen_batch = 0 + self._stage = stage + if self._deterministic: + self._rng = np.random.default_rng(self._seed) + else: + self._rng = np.random.default_rng() + if self._activate: + self.get_indices(stage) + + def _extract_from_PYG(self, item, pos_idx): + num_samples = item.batch.shape[0] + batch_mask = item.batch == pos_idx + out_data = {} + for k in item.keys: + if torch.is_tensor(item[k]) and (k in self._saved_keys.keys() or k in self._tensorboard_mesh.values()): + if item[k].shape[0] == num_samples: + out_data[k] = item[k][batch_mask] + return out_data + + def _extract_from_dense(self, item, pos_idx): + assert ( # TODO only true if task is segmentation + item.y.shape[0] == item.pos.shape[0] + ), "y and pos should have the same number of samples. Something is probably wrong with your data to visualise" + num_samples = item.y.shape[0] + out_data = {} + for k in item.keys: + if torch.is_tensor(item[k]) and (k in self._saved_keys.keys() or k in self._tensorboard_mesh.values()): + if item[k].shape[0] == num_samples: + out_data[k] = item[k][pos_idx] + return out_data + + def _dict_to_structured_npy(self, item): + item.keys() + out = [] + dtypes = [] + for k, v in item.items(): + v_npy = v.detach().cpu().numpy() + if len(v_npy.shape) == 1: + v_npy = v_npy[..., np.newaxis] + for dtype in self._saved_keys[k]: + dtypes.append(dtype) + out.append(v_npy) + + out = np.concatenate(out, axis=-1) + dtypes = np.dtype([tuple(d) for d in dtypes]) + return np.asarray([tuple(o) for o in out], dtype=dtypes) + + def save_visuals(self, model, loader): + """This function is responsible to save the data into .ply objects + Parameters: + model -- Contains the model including visuals + loader - Contains the dataloader + Make sure the saved_keys within the config maps to the Data attributes. + """ + if self._stage in self._indices: + visuals = model.get_current_visuals() + + if any(format in self._format for format in ["csv", "gpkg", "ply"]): + dataset = loader.dataset + pred = {} + data_vis = model.data_visual + if model.has_reg_targets: + preds = model.get_reg_output().detach().cpu().numpy() + for i, pred_name in enumerate(dataset.reg_targets): + pred[pred_name] = preds[:, i] + if model.has_mol_targets: + preds = model.get_mol_output().detach().cpu().numpy() + for i, pred_name in enumerate(dataset.mol_targets): + pred[pred_name] = preds[:, i] + if model.has_cls_targets: + preds = model.get_cls_output() + for i, pred_name in enumerate(dataset.cls_targets): + pred[pred_name] = preds[i].argmax(1).detach().cpu().numpy() + pred[f"{pred_name}_prob"] = preds[i].softmax(1).detach().cpu().numpy().max(1) + pred["area"] = data_vis.area_name + + label_idx_ = [idx[0] for idx in data_vis.label_idx] + pred["label_idx"] = label_idx_ + + df = pd.DataFrame(pred) + + if "gpkg" in self._format or "csv" in self._format: + df["stage"] = self._stage + self.dfs.append(df) + + is_ply = "ply" in self._format + if is_ply: + viz_path = Path(self._viz_path) + viz_path.mkdir(exist_ok=True) + for i, (_, sample) in enumerate(df.iterrows()): + area_name = sample["area"] + area_path = viz_path / area_name + area_path.mkdir(exist_ok=True) + label_idx = sample['label_idx'] + file = area_path / f"{label_idx}.ply" + out_item = self._extract_from_PYG(data_vis, i) + out_item = self._dict_to_structured_npy(out_item) + self.save_ply(out_item, f"{area_name}_{label_idx}", file) + + if all(format not in self._format for format in ["wandb", "tensorboard"]): + return + stage_num_batches = getattr(self, "{}_num_batches".format(self._stage)) + batch_indices = self._indices[self._stage] // self._batch_size + pos_indices = self._indices[self._stage] % self._batch_size + for idx in np.argwhere(self._seen_batch == batch_indices).flatten(): + pos_idx = pos_indices[idx] + for visual_name, item in visuals.items(): + if hasattr(item, "batch") and item.batch is not None: # The PYG dataloader has been used + out_item = self._extract_from_PYG(item, pos_idx) + else: + out_item = self._extract_from_dense(item, pos_idx) + + if "tensorboard" in self._format and self._tracker._use_tensorboard: + self.save_tensorboard(out_item, visual_name, stage_num_batches) + + out_item = self._dict_to_structured_npy(out_item) + gt_name = "{}_{}_{}_gt".format(self._current_epoch, self._seen_batch, pos_idx) + pred_name = "{}_{}_{}".format(self._current_epoch, self._seen_batch, pos_idx) + + if "wandb" in self._format and self._tracker._wandb: + self.save_wandb(out_item, gt_name, pred_name) + + self._seen_batch += 1 + + def save_ply(self, npy_array, visual_name, filename): + + el = PlyElement.describe(npy_array, visual_name) + if self._ply_format == "ascii": + PlyData([el], text=True).write(filename) + elif self._ply_format == "binary_little_endian": + PlyData([el], byte_order="<").write(filename) + elif self._ply_format == "binary_big_endian": + PlyData([el], byte_order=">").write(filename) + else: + PlyData([el]).write(filename) + + def save_tensorboard(self, out_item, visual_name, stage_num_batches): + pos = out_item["pos"].detach().cpu().unsqueeze(0) + colors = get_cmap("tab10") + config_dict = {"material": {"size": 0.3}} + + for label, k in self._tensorboard_mesh.items(): + value = out_item[k].detach().cpu() + + if len(value.shape) == 2 and value.shape[1] == 3: + if value.min() >= 0 and value.max() <= 1: + value = (255 * value).type(torch.uint8).unsqueeze(0) + else: + value = value.type(torch.uint8).unsqueeze(0) + elif len(value.shape) == 1 and value.shape[0] == 1: + value = np.tile((255 * colors(value.numpy() % 10))[:, 0:3].astype(np.uint8), (pos.shape[0], 1)).reshape( + (1, -1, 3) + ) + elif len(value.shape) == 1 or value.shape[1] == 1: + value = (255 * colors(value.numpy() % 10))[:, 0:3].astype(np.uint8).reshape((1, -1, 3)) + else: + continue + + self._writer.add_mesh( + self._stage + "/" + visual_name + "/" + label, + pos, + colors=value, + config_dict=config_dict, + global_step=(self._current_epoch - 1) * (10 ** ceil(log10(stage_num_batches + 1))) + self._seen_batch, + ) + + def gen_bb_corners(self, points): + points_min = np.min(points, axis=0) + points_max = np.max(points, axis=0) + points_min_max = np.stack([points_min, points_max], axis=0) + + bb_points = [] + for x, y, z in [i for i in product(range(2), repeat=3)]: # 2^3 binary combination table + bb_points.append([points_min_max[x, 0], points_min_max[y, 1], points_min_max[z, 2]]) + return bb_points + + def apply_cmap(self, val): + out = np.zeros((val.shape[0], 3), dtype=int) + for label, color in enumerate(self._wandb_cmap): + out[val == label] = color + return out + + PRED_COLOR = [255, 0, 0] # red + GT_COLOR = [124, 255, 0] # green + + # https://docs.wandb.ai/guides/track/log/media#3d-visualizations + def save_wandb(self, out_item, gt_name, pred_name): + if self._max_points > 0: + out_item = out_item[self._rng.permutation(len(out_item))[: self._max_points]] + if self._wandb_cmap is None: + assert (out_item["p"].max() + 1) <= 14, "Wandb classes must be in 1-14" + assert (out_item["l"].max() + 1) <= 14, "Wandb classes must be in 1-14" + + pred_points = np.stack([out_item["x"], out_item["y"], out_item["z"], out_item["p"] + 1], axis=1) + gt_points = np.stack([out_item["x"], out_item["y"], out_item["z"], out_item["l"] + 1], axis=1) + else: + pred_colors = self.apply_cmap(out_item["p"]) + gt_colors = self.apply_cmap(out_item["l"]) + pred_points = np.stack( + [out_item["x"], out_item["y"], out_item["z"], pred_colors[:, 0], pred_colors[:, 1], pred_colors[:, 2]], + axis=1, + ) + gt_points = np.stack( + [out_item["x"], out_item["y"], out_item["z"], gt_colors[:, 0], gt_colors[:, 1], gt_colors[:, 2]], axis=1 + ) + + corners = self.gen_bb_corners(pred_points) + + pred_scene = wandb.Object3D( + { + "type": "lidar/beta", + "points": pred_points, + "boxes": np.array( # draw 3d boxes + [ + { + "corners": corners, + "label": pred_name, + "color": self.PRED_COLOR, + } + ] + ), + } + ) + gt_scene = wandb.Object3D( + { + "type": "lidar/beta", + "points": gt_points, + "boxes": np.array( # draw 3d boxes + [ + { + "corners": corners, + "label": gt_name, + "color": self.GT_COLOR, + } + ] + ), + } + ) + + gt_scene_name = "{}/gt".format(self._stage) + pred_scene_name = "{}/pred".format(self._stage) + wandb.log({pred_scene_name: pred_scene, gt_scene_name: gt_scene, "epoch": self._current_epoch}) diff --git a/torch-points3d/train.py b/torch-points3d/train.py new file mode 100644 index 0000000..79b97fc --- /dev/null +++ b/torch-points3d/train.py @@ -0,0 +1,22 @@ +import hydra +from hydra.core.global_hydra import GlobalHydra +from omegaconf import OmegaConf +from torch_points3d.trainer import Trainer + + +@hydra.main(config_path="conf", config_name="config") +def main(cfg): + OmegaConf.set_struct(cfg, False) # This allows getattr and hasattr methods to function correctly + if cfg.pretty_print: + print(OmegaConf.to_yaml(cfg)) + + trainer = Trainer(cfg) + trainer.train() + # + # # https://github.com/facebookresearch/hydra/issues/440 + GlobalHydra.get_state().clear() + return 0 + + +if __name__ == "__main__": + main()