From f55ebc5f02c4a9da2bc46d553a0ef5c298c25ed9 Mon Sep 17 00:00:00 2001 From: John Mount Date: Sun, 28 Aug 2022 08:57:10 -0700 Subject: [PATCH] move data science tools wvu https://github.com/WinVector/wvu --- README.ipynb | 436 ---- README.md | 255 +-- coverage.txt | 26 +- output_10_0.png | Bin 18512 -> 0 bytes output_12_0.png | Bin 17640 -> 0 bytes output_13_0.png | Bin 11210 -> 0 bytes output_7_1.png | Bin 16318 -> 0 bytes output_9_0.png | Bin 17929 -> 0 bytes pkg/README.txt | 4 +- pkg/build/lib/wvpy/util.py | 982 --------- pkg/dist/wvpy-0.3.6-py3-none-any.whl | Bin 17041 -> 9232 bytes pkg/dist/wvpy-0.3.6.tar.gz | Bin 16098 -> 91552 bytes pkg/docs/search.js | 2 +- pkg/docs/wvpy.html | 36 +- pkg/docs/wvpy/jtools.html | 1816 ++++++++-------- pkg/docs/wvpy/pysheet.html | 568 ++--- pkg/docs/wvpy/render_workbook.html | 337 +-- pkg/docs/wvpy/util.html | 3026 -------------------------- pkg/setup.py | 11 +- pkg/tests/test_cross_plan1.py | 23 - pkg/tests/test_cross_predict.py | 36 - pkg/tests/test_deviance_calc.py | 13 - pkg/tests/test_eval_fn_pre_row.py | 13 - pkg/tests/test_match_auc.py | 7 - pkg/tests/test_onehot.py | 59 - pkg/tests/test_perm_score_vars.py | 30 - pkg/tests/test_plots.py | 132 -- pkg/tests/test_se.py | 12 - pkg/tests/test_search_grid.py | 17 - pkg/tests/test_stats1.py | 55 - pkg/tests/test_threshold_stats.py | 50 - pkg/tests/test_typs_in_frame.py | 19 - pkg/wvpy.egg-info/PKG-INFO | 4 +- pkg/wvpy.egg-info/SOURCES.txt | 13 +- pkg/wvpy.egg-info/requires.txt | 7 - pkg/wvpy/util.py | 982 --------- wvpy_dev_env.yaml | 23 +- 37 files changed, 1434 insertions(+), 7560 deletions(-) delete mode 100644 README.ipynb delete mode 100644 output_10_0.png delete mode 100644 output_12_0.png delete mode 100644 output_13_0.png delete mode 100644 output_7_1.png delete mode 100644 output_9_0.png delete mode 100644 pkg/build/lib/wvpy/util.py delete mode 100644 pkg/docs/wvpy/util.html delete mode 100644 pkg/tests/test_cross_plan1.py delete mode 100644 pkg/tests/test_cross_predict.py delete mode 100644 pkg/tests/test_deviance_calc.py delete mode 100644 pkg/tests/test_eval_fn_pre_row.py delete mode 100644 pkg/tests/test_match_auc.py delete mode 100644 pkg/tests/test_onehot.py delete mode 100644 pkg/tests/test_perm_score_vars.py delete mode 100644 pkg/tests/test_plots.py delete mode 100644 pkg/tests/test_se.py delete mode 100644 pkg/tests/test_search_grid.py delete mode 100644 pkg/tests/test_stats1.py delete mode 100644 pkg/tests/test_threshold_stats.py delete mode 100644 pkg/tests/test_typs_in_frame.py delete mode 100644 pkg/wvpy/util.py diff --git a/README.ipynb b/README.ipynb deleted file mode 100644 index ab6e516..0000000 --- a/README.ipynb +++ /dev/null @@ -1,436 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "pycharm": { - "name": "#%% md\n" - } - }, - "source": [ - "[wvpy](https://github.com/WinVector/wvpy) is a simple \n", - "set of utilities for doint and teaching data science and machine learning methods.\n", - "They are not replacements for the obvious methods in sklearn.\n", - "\n", - "Some notes on the Jupyter sheet runner can be found [here](https://win-vector.com/2022/08/20/an-effective-personal-jupyter-data-science-workflow/)\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'0.2.7'" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import numpy.random\n", - "import pandas\n", - "import wvpy.util\n", - "\n", - "wvpy.__version__" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "pycharm": { - "name": "#%% md\n" - } - }, - "source": [ - "Illustration of cross-method plan." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - }, - "pycharm": { - "name": "#%%\n" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "[{'train': [3, 4, 6, 8, 9], 'test': [0, 1, 2, 5, 7]},\n", - " {'train': [0, 1, 2, 5, 7], 'test': [3, 4, 6, 8, 9]}]" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "wvpy.util.mk_cross_plan(10,2)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Plotting example" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Help on function plot_roc in module wvpy.util:\n", - "\n", - "plot_roc(prediction, istrue, title='Receiver operating characteristic plot', *, truth_target=True, ideal_line_color=None, extra_points=None, show=True)\n", - " Plot a ROC curve of numeric prediction against boolean istrue.\n", - " \n", - " :param prediction: column of numeric predictions\n", - " :param istrue: column of items to predict\n", - " :param title: plot title\n", - " :param truth_target: value to consider target or true.\n", - " :param ideal_line_color: if not None, color of ideal line\n", - " :param extra_points: data frame of additional point to annotate graph, columns fpr, tpr, label\n", - " :param show: logical, if True call matplotlib.pyplot.show()\n", - " :return: calculated area under the curve, plot produced by call.\n", - " \n", - " Example:\n", - " \n", - " import pandas\n", - " import wvpy.util\n", - " \n", - " d = pandas.DataFrame({\n", - " 'x': [1, 2, 3, 4, 5],\n", - " 'y': [False, False, True, True, False]\n", - " })\n", - " \n", - " wvpy.util.plot_roc(\n", - " prediction=d['x'],\n", - " istrue=d['y'],\n", - " ideal_line_color='lightgrey'\n", - " )\n", - " \n", - " wvpy.util.plot_roc(\n", - " prediction=d['x'],\n", - " istrue=d['y'],\n", - " extra_points=pandas.DataFrame({\n", - " 'tpr': [0, 1],\n", - " 'fpr': [0, 1],\n", - " 'label': ['AAA', 'BBB']\n", - " })\n", - " )\n", - "\n" - ] - } - ], - "source": [ - "help(wvpy.util.plot_roc)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "d = pandas.concat([\n", - " pandas.DataFrame({\n", - " 'x': numpy.random.normal(size=1000),\n", - " 'y': numpy.random.choice([True, False], \n", - " p=(0.02, 0.98), \n", - " size=1000, \n", - " replace=True)}),\n", - " pandas.DataFrame({\n", - " 'x': numpy.random.normal(size=200) + 5,\n", - " 'y': numpy.random.choice([True, False], \n", - " size=200, \n", - " replace=True)}),\n", - "])" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "0.9120429144865746" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "wvpy.util.plot_roc(\n", - " prediction=d.x,\n", - " istrue=d.y,\n", - " ideal_line_color=\"DarkGrey\",\n", - " title='Example ROC plot')" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Help on function threshold_plot in module wvpy.util:\n", - "\n", - "threshold_plot(d: pandas.core.frame.DataFrame, pred_var, truth_var, truth_target=True, threshold_range=(-inf, inf), plotvars=('precision', 'recall'), title='Measures as a function of threshold', *, show=True)\n", - " Produce multiple facet plot relating the performance of using a threshold greater than or equal to\n", - " different values at predicting a truth target.\n", - " \n", - " :param d: pandas.DataFrame to plot\n", - " :param pred_var: name of column of numeric predictions\n", - " :param truth_var: name of column with reference truth\n", - " :param truth_target: value considered true\n", - " :param threshold_range: x-axis range to plot\n", - " :param plotvars: list of metrics to plot, must come from ['threshold', 'count', 'fraction', 'precision',\n", - " 'true_positive_rate', 'false_positive_rate', 'true_negative_rate', 'false_negative_rate',\n", - " 'recall', 'sensitivity', 'specificity']\n", - " :param title: title for plot\n", - " :param show: logical, if True call matplotlib.pyplot.show()\n", - " :return: None, plot produced as a side effect\n", - " \n", - " Example:\n", - " \n", - " import pandas\n", - " import wvpy.util\n", - " \n", - " d = pandas.DataFrame({\n", - " 'x': [1, 2, 3, 4, 5],\n", - " 'y': [False, False, True, True, False]\n", - " })\n", - " \n", - " wvpy.util.threshold_plot(\n", - " d,\n", - " pred_var='x',\n", - " truth_var='y',\n", - " plotvars=(\"sensitivity\", \"specificity\"),\n", - " )\n", - "\n" - ] - } - ], - "source": [ - "help(wvpy.util.threshold_plot)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "wvpy.util.threshold_plot(\n", - " d,\n", - " pred_var='x',\n", - " truth_var='y',\n", - " plotvars=(\"sensitivity\", \"specificity\"),\n", - " title = \"example plot\"\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "\n", - "wvpy.util.threshold_plot(\n", - " d,\n", - " pred_var='x',\n", - " truth_var='y',\n", - " plotvars=(\"precision\", \"recall\"),\n", - " title = \"example plot\"\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Help on function gain_curve_plot in module wvpy.util:\n", - "\n", - "gain_curve_plot(prediction, outcome, title='Gain curve plot', *, show=True)\n", - " plot cumulative outcome as a function of prediction order (descending)\n", - " \n", - " :param prediction: vector of numeric predictions\n", - " :param outcome: vector of actual values\n", - " :param title: plot title\n", - " :param show: logical, if True call matplotlib.pyplot.show()\n", - " :return: None\n", - "\n" - ] - } - ], - "source": [ - "help(wvpy.util.gain_curve_plot)" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - }, - "pycharm": { - "name": "#%%\n" - } - }, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "wvpy.util.gain_curve_plot(\n", - " prediction=d['x'],\n", - " outcome=d['y'],\n", - " title = \"gain curve plot\"\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "wvpy.util.lift_curve_plot(\n", - " prediction=d['x'],\n", - " outcome=d['y'],\n", - " title = \"lift curve plot\"\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "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.9.4" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/README.md b/README.md index 937ab84..427eb9f 100644 --- a/README.md +++ b/README.md @@ -1,258 +1,9 @@ -[wvpy](https://github.com/WinVector/wvpy) is a simple -set of utilities for teaching data science and machine learning methods. -They are not replacements for the obvious methods in sklearn. -Some notes on the Jupyter sheet runner can be found [here](https://win-vector.com/2022/08/20/an-effective-personal-jupyter-data-science-workflow/) +wvpy tools for converting Jupyter notebooks to and from Python files. +Text and video tutotials here: [https://win-vector.com/2022/08/20/an-effective-personal-jupyter-data-science-workflow/](https://win-vector.com/2022/08/20/an-effective-personal-jupyter-data-science-workflow/). -```python -import numpy.random -import pandas -import wvpy.util +Many of the data science functions have been moved to wvu [https://github.com/WinVector/wvu](https://win-vector.com/2022/08/20/an-effective-personal-jupyter-data-science-workflow/). -wvpy.__version__ -``` - - - '0.2.7' - - - -Illustration of cross-method plan. - - -```python -wvpy.util.mk_cross_plan(10,2) -``` - - - - - [{'train': [1, 2, 3, 4, 9], 'test': [0, 5, 6, 7, 8]}, - {'train': [0, 5, 6, 7, 8], 'test': [1, 2, 3, 4, 9]}] - - - -Plotting example - - -```python -help(wvpy.util.plot_roc) -``` - - Help on function plot_roc in module wvpy.util: - - plot_roc(prediction, istrue, title='Receiver operating characteristic plot', *, truth_target=True, ideal_line_color=None, extra_points=None, show=True) - Plot a ROC curve of numeric prediction against boolean istrue. - - :param prediction: column of numeric predictions - :param istrue: column of items to predict - :param title: plot title - :param truth_target: value to consider target or true. - :param ideal_line_color: if not None, color of ideal line - :param extra_points: data frame of additional point to annotate graph, columns fpr, tpr, label - :param show: logical, if True call matplotlib.pyplot.show() - :return: calculated area under the curve, plot produced by call. - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - ideal_line_color='lightgrey' - ) - - wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - extra_points=pandas.DataFrame({ - 'tpr': [0, 1], - 'fpr': [0, 1], - 'label': ['AAA', 'BBB'] - }) - ) - - - - -```python -d = pandas.concat([ - pandas.DataFrame({ - 'x': numpy.random.normal(size=1000), - 'y': numpy.random.choice([True, False], - p=(0.02, 0.98), - size=1000, - replace=True)}), - pandas.DataFrame({ - 'x': numpy.random.normal(size=200) + 5, - 'y': numpy.random.choice([True, False], - size=200, - replace=True)}), -]) -``` - - -```python -wvpy.util.plot_roc( - prediction=d.x, - istrue=d.y, - ideal_line_color="DarkGrey", - title='Example ROC plot') -``` - - -
- - - - -![png](output_7_1.png) - - - - - - - 0.903298366883511 - - - - -```python -help(wvpy.util.threshold_plot) -``` - - Help on function threshold_plot in module wvpy.util: - - threshold_plot(d: pandas.core.frame.DataFrame, pred_var, truth_var, truth_target=True, threshold_range=(-inf, inf), plotvars=('precision', 'recall'), title='Measures as a function of threshold', *, show=True) - Produce multiple facet plot relating the performance of using a threshold greater than or equal to - different values at predicting a truth target. - - :param d: pandas.DataFrame to plot - :param pred_var: name of column of numeric predictions - :param truth_var: name of column with reference truth - :param truth_target: value considered true - :param threshold_range: x-axis range to plot - :param plotvars: list of metrics to plot, must come from ['threshold', 'count', 'fraction', 'precision', - 'true_positive_rate', 'false_positive_rate', 'true_negative_rate', 'false_negative_rate', - 'recall', 'sensitivity', 'specificity'] - :param title: title for plot - :param show: logical, if True call matplotlib.pyplot.show() - :return: None, plot produced as a side effect - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.threshold_plot( - d, - pred_var='x', - truth_var='y', - plotvars=("sensitivity", "specificity"), - ) - - - - -```python -wvpy.util.threshold_plot( - d, - pred_var='x', - truth_var='y', - plotvars=("sensitivity", "specificity"), - title = "example plot" - ) -``` - - - -![png](output_9_0.png) - - - - -```python - -wvpy.util.threshold_plot( - d, - pred_var='x', - truth_var='y', - plotvars=("precision", "recall"), - title = "example plot" - ) -``` - - - -![png](output_10_0.png) - - - - -```python -help(wvpy.util.gain_curve_plot) -``` - - Help on function gain_curve_plot in module wvpy.util: - - gain_curve_plot(prediction, outcome, title='Gain curve plot', *, show=True) - plot cumulative outcome as a function of prediction order (descending) - - :param prediction: vector of numeric predictions - :param outcome: vector of actual values - :param title: plot title - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - - - -```python -wvpy.util.gain_curve_plot( - prediction=d['x'], - outcome=d['y'], - title = "gain curve plot" -) -``` - - - -![png](output_12_0.png) - - - - -```python -wvpy.util.lift_curve_plot( - prediction=d['x'], - outcome=d['y'], - title = "lift curve plot" -) -``` - - - -![png](output_13_0.png) - - - - -```python - -``` diff --git a/coverage.txt b/coverage.txt index 57e3377..b6505f2 100644 --- a/coverage.txt +++ b/coverage.txt @@ -1,34 +1,20 @@ ============================= test session starts ============================== -platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 +platform darwin -- Python 3.9.12, pytest-7.1.1, pluggy-1.0.0 rootdir: /Users/johnmount/Documents/work/wvpy/pkg plugins: anyio-3.5.0, cov-3.0.0 -collected 20 items +collected 4 items -tests/test_cross_plan1.py . [ 5%] -tests/test_cross_predict.py .. [ 15%] -tests/test_deviance_calc.py . [ 20%] -tests/test_eval_fn_pre_row.py . [ 25%] -tests/test_match_auc.py . [ 30%] -tests/test_nb_fns.py .... [ 50%] -tests/test_onehot.py .. [ 60%] -tests/test_perm_score_vars.py . [ 65%] -tests/test_plots.py . [ 70%] -tests/test_se.py . [ 75%] -tests/test_search_grid.py .. [ 85%] -tests/test_stats1.py . [ 90%] -tests/test_threshold_stats.py . [ 95%] -tests/test_typs_in_frame.py . [100%] +tests/test_nb_fns.py .... [100%] ----------- coverage: platform darwin, python 3.9.7-final-0 ----------- +---------- coverage: platform darwin, python 3.9.12-final-0 ---------- Name Stmts Miss Cover --------------------------------------------- wvpy/__init__.py 3 0 100% wvpy/jtools.py 206 76 63% wvpy/pysheet.py 99 99 0% wvpy/render_workbook.py 54 54 0% -wvpy/util.py 321 7 98% --------------------------------------------- -TOTAL 683 236 65% +TOTAL 362 229 37% -============================= 20 passed in 12.71s ============================== +============================== 4 passed in 8.92s =============================== diff --git a/output_10_0.png b/output_10_0.png deleted file mode 100644 index 792c68c85a153436fb0965453884db1b7e7d0e1d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18512 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 z 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 diff --git a/output_7_1.png b/output_7_1.png deleted file mode 100644 index b65a7a7aebaa74b7459a3d10e0cf04eed6259c98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16318 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�aDLuk0bwsD^kGnVe-&idZ5pihO*fn~%r&Dwrt~! z!e0idioifSb}mAjVG&1-A=kW=w>YF=TwQ++w!;16ge@;rK5#7!F-2cvi@gLIXOOF@Q6t)9UM=LJ1w%sD&xWpZz86! zVe!q}zi{m;DRUa?w4(}I0Y$Hyl4jd#)rYkC8%z1h263lCMsy)Antvv4shbQ_kt%*e zN@O;_5nUL-*Q-O4)^C|I+-XGk^Zbl*g7uJ2-AvR~=i`F`{Ez_8HLnfV>iX8(CSAfZ zo0$OjV6}yfMDomiGAd%Ar}`Etc1R?OEgWR{;?_>phjq%q2%|2~56NEh!DoNvWu&Ll zu?`R@kS`UC$gfYErwzIvX3}1ED@~Iy8dKL3K6YYGz0ljOnp&Y3>2;VshKZhXj%H}E z+g-DeDM{FDh{Y=a!pvk@^ZR<4PV(bd%r9WS>q&a+mxdcDcjDL(l&04Jih} zc3oC)NK@mjFx9(mtQ;{Qv3}r8BY*seqdmmjmwhJ}kyB@Bzo$_qOy%+(QAch~ z&LxXfN>HWa=NWdvN(D$f@pta0JFAOq3La?TcnnsmS>(+*fjJ9XHm`6{-{aNA1Vy?B z>?vx>%X5|TZMtft1|Va@*=dE;fwWoYYspWUr4JM0Yh6~jFnPR9A6M|EncJVAz_cI+ zlg@pQmH`j>3gv%;Zq5-JQY}m=_n$<0Bm~bzj3E%PRZ#jsdAaVr@yO%)i3Ib9Sv5@3 zyRuulB9v0uhZJ*Rwv1mLBOVX@D`j3$`Sv5FHRFhF+cFyvTVDo}Bn$Pdh-5R(v^e#H zo%?)^CbBVi=2UMu&F$jC{TP#!8O&F4{n%FaEVAdW#nUt>z&I4fYY#gYFmh*ZuG_SK z3dxb457KkirFlo^g>0tt$8R3sx!GHA*nFyNcdE61_Rv-4Cgb7g>;^Cnb z2M<<8aBrcjQ*n1OSH}2By6nCZw)=_@`BTQ1A;hj68N$Q@2)3>qx*-{)B|Ji^t3h3z zwhy#9LH=Jf1MZA}71J8=j@_^=4fTA7K;3|@Il(Zf*N65PxX_NXbuW=?YMnXUm( zT6x^wRie3HL2Eooe9Eye3#VI|8}b7;TOOl|a4NF|&ONqjP=tr!B_oC3!$-;c$3I>y zjk@|JeBFTe;T?*EXEpeA&q714$z9)JmP=;q9k8cOR?HoL{Y`kb(`}xS-r0Xpp@xH~ zJ={A%K$;o>YHKfX?{mkkJzomaDx~fL>qiCq;g^_`Lp zU3q(Wyoh*g{A6rZNG&0rGd3AAlD?U&l%}L2LmjEw(vthdQ;t(qi}o?$imtHUyWg8W zk#`Q2nB4NY2zhVq$_(r^KrcS=aV#u5yBCs6K@{7BxQVgMK=Wo#of1kDRMiO@$iUnP z|L2y&50c08rk&;>Z`ia{Wi?w{viqA0>w_CBMr5%SaDIEnHp|+%eWLV^!atsHz1bF; zagsGQf&c{BTffbJp=eGR?8?Dtymgn<^WDwhL^Ebrfvvw(6NYGj=#Te&i+&oXbeeI8 zvtON)dFqgqBHBw^Yt?_Adq zCo|(^onVH*)m>W@aiZmUX?NSEUUJn4u*P8g3%^nd2I5s9S#55nqpyl%jX+^dltF61 z>POKY?hZ$eFj=`keL19^T{kZeL*0+hkI@#bFRZu=^cqW_&%Qxd(YnDT=RD?a7hcM8 zhNWY})NWQYp@ea5pV5QF0whJBp6aOcx!9=edP$+P4*H}f>%S~oaYGpVwRQdv=n<=q zY&L}Bv0-AfM8S_)ljF4)eQF*OaJ~We^kb))Aa1z+K((BmKv_O&HFjy(7CUpF^!mtm z3Mhvs42gETXMu>LrcWJ(kAL+j$22{IkGmx+W)uUURHHdA^QSTzk*9fG4Lkfj0ABDD z$B(l%xpQaq`IV>f>h@^*2VW!ty?^{EyQO3K3LC>?ZGY9k97i7dNW}rF#NNOn--8{i z(@-D2*>mis_|d)d&&CVmCuy~x%GCzQD6%b7q5TwM6as?G637`8BWelPCf{Wa=s5%H zfag1_O=X?hmaoP@V2-0JDDz1lqQd?POtPs>;xsP^v&D?w-0gCMFQS$=w zM7}FL3@_}1;d8^rt&I_XbghHU^7sMy$v~g*733n)xz!`5?L{lsK=&`2i4ycij0N%9 zT{Ba61{<2+RM)+xh@4MqJl;>UXS2S~>TY}O%{KuRLe%nA0fKMIQ3-R$dVub2bO<4SRFM9eKO!^+v+4e$w&MX$lBgnq_e^u3 zxU@tnvDP|3uf=ffuW?OBD`=ePHu|upFC|Z-ELuLky}p{l^NK)+>Xg-#CKagciX{(P z>bvJb-$iJtJTO5Yb_T0MELlf&ZmRQ*mCss#5ihVoN6cVvv?_st&ZF33nxz087GLBl zOIYu3R}+_V4*H5bO-;>)DzX*dxvT}@meYStD5$P}&5j`~f7h+kdfmZ4WBrQ`pBtRf zH{$#}hqmT#Moa3OrvCML_0JQ?{jjs!<8D~;8$+M&6LO&-*Ej;5^$kQFX>;@{Hbz&qx@N0imUVhBNHc5@oQM1Lu{t*JerPaQAThdl>5 z0d$TF>AbiFF1H~sb_CP z`)K<1>A~uG(EWxkm8*dZ=SkW^5pra$iH&lI1k;%R@%9)KNFoTqN+rn>MH9ZT&f@rZ z&fIl0b@=b?qJxfxS1k{k5@*|6L|0R2<6Xd``Y`=Q^AcbM#*SAXe{N}WV}xnOb*vby z4(X68X+dVJ32M}E-x91R@W8i7-ra}=l&G38GaArDuS`CfaoAgaTGlxwG6r(b@C^_o zAfwed1??@LH#qxz=k)jF`8IgR*K@p6gl;xf`p&du@@~@V;K_&?L%)Q<&)EdpLkqv& zenO7k@U7|Xjjz9@QAX8=?k0hgNX}Wq&zxg6nB7!a%7`lb@EAML6YzMeHg7!wr0(&r zIwr!4`}<3~uUEu#05tML_m|!ZXNK$|@qAUZK^Q;h**L~O@bepLb6+*nB>pc6v_c|x z(a@+UQVAP2+LrV_l@Db1i6-}Ep(*3f%R4Uh_eKd3aE)n<``E&|bNQ)DeTJE-)meLs z_e4q0*0&P4qg5g-PC;4ba4TY{bYdt z{bijpRa}D`OnPTI^WbBQ;n@%WtMArsJ`+J(hbl*nQT3N}G?8A5K_to{Enu?m%gf6# zq)hawrIOIe6$fvJq5Wz9Uy`|B+f5&>oQJB(+;epOp8q{Vs^Lf-F7Ay_d;R(~jLHBM zRBO-~a4R=a0vLZ&Nh^!$-6bJHPAgZAmjcRv+^5?{(5NWb_z=1#>RHqmn!ei<6;%xj zQSMD=;OSm`C`bjbltHdVt;EBj?L*j=989e3kuqweD`e{$|IEPgyQ+B*_M@+AkdB8=w)U|q`v@UvG z?g)ebTSj$5#Fx~KXfyC!kwD&tS0*YZ#W=!-NK}3C!jug*Hgh((a0j6%9ZNt$nB*h& zWRUO}c9arqzY38k30w{JU1&{;rJaFw6=;q#!3@z5dtxJl#B{j&RkbxES|7rEqZh*Y z%-f+qrdhHvCml!s|GMeP9q3UVLZ7aFV>8$u7NF(zyb%^~>ytOQZY7isN*RcCh&&hA z+4;U&&=?D@AAPYs(S@#2A75g<7z+ECgNbP#;>ZBXu9$qrklJv3^ID0SZhK_gjcZ<) zwj-Km$Z)(WiNEFNz7$R;M-7o}UWod9LnVcBk!+2U1R0IDA#P)9NhaQi#w#%nhF$TB z*g6fLv$-K^eo7}H5vY1T@NMdML(*mGo$MKH3_a=sRsZ9Tp|>dR7`!0q=K4x-B{>(R zRE#@>G0Ake_sBG1;we!$#j)a*INFY(7hQs-Yt2W;9e+$2RL)3jKjU^+OD0aO5%IOO zBGX}9k%$4x(*>LdY&Sl_nFOe%wy-l6(G{ZBs5;aGm@H=*DJ@<75E%}N6rH!J@y)*2 zGmC8|ilUoAT3qFPIh!f@mxvkJo^;*#jU?w&O%2QfxHd*Jm z=&~`{K4qf$aJD!3ZI*lJU;Ys^18$>-S8 zf!ky>6d}Us%;~6sVd8zPvdz9A=?XDjHf95NN|X|~Ap1s*bE#v9+V*`j-K^QyzXfL5 zyA4M_yO;<#DUR5Qbb`2mqGG1CvE#HYd96;fPj0MZ{phk6+jdvLUU=Wrbx;-44Dv}m z&b1XvBJHF1$We;$N=h-onOxAn5X&|kaYv~V_fRZN%P0&E{6!mi4{ zB>z%k_qTpJ@iWdbCgl2G`sqe&Ad%SlzFaalU%dNK!^}z)73?Kj`DQ~qomj^%YL80N z|4~&K<=2XirlY8wn6gV>K&rqq!LNkOG-3p5{*j$kN#`+jQH4YbT`o^N&8)&G}03AKkz#diqgOHf^OqVVRsT_ zZAj(FZPX5kq`TGK_e$^6xGZ!h8b7%VF!JRkQ1|1DDM6tEE~!`iWwu#oG#!lViSBaH zqeR3tnz#+s`f4AL>hb~n!_qy+_U!w{8@fweWTiV+A;3Pq&#~jr#967DHdUTJC*|5Y zNdM$%9{TPl4%b#$Jpc-%WmqfH+leVhq!bofV44|Zn0P1-#=08elFX=XQ zw{!fd3qbI1-+g%Vu0HAAWbo=LoqJW>wjh3GRP6!nFV+x7N4ed9gW)F(0JsZU_rHd3 z&Rk>2jn~%0SlsRZX<2+G*QftL?}K%-E$d#TR# zRu20498;10JqiR{;{KLLjRYm{Qz{kPJ=Z*eO0S?7#QOkP(b)cN>FCzRfQyklg# z;`ZJ8ON5!*a>7Sli}u!4(j}mIsBUR^GM`0DLxY)@L2=)i>JoC9isNmnbMnIP`?(g} zx`+OmQlNj7TdsW6Ep=H?MAPHm^W1XQ>dVBAS*dvl%ai%nw~b0}cz^N{pNHl=jW5o9 z!O@SXcjKK37&9_myKPmU`q=d&vS>Ai@f`O|-8I1I>F1!o=lcU@5Kxa>Y#+?)60LIri1kWf8*xF(UIASWQGIZd1BmrM0evf=h4t$jq9}yFxenUhw9Gu8TiK=5VKsody*n`u zjo6T#v1rbdv6`=Yjs4;*2bHgfQyM6@#Lqx>#po;P>jZG-+ zPIx$diG!f6>mI*ZF30Y+(A@wtXPu&#{E4&88)uueIh&CGxbe~=ER{{~L%#bjb%Jn_ z-)wrkl(r3r@@^VM;ghTpZJyJg>6bd}Z74G}pm+X9Y;n%pz zljpZWXS6KyLENdo1`z;|7T~J$0ON=B!Uo8`FspvslKp{6W_0wktLom~U`6&>VtmTa zteMBZH*$MYxHg1)2j)S~^J_|2btxa8+70ZCz1ABBv`csEGR!87?^g=;rYR>cR)WQ zz^=fF06+AQfVh%o@~3I$Lry++&|rMf4r|>VoOBlznbHBmv5f!rkpzJJcP6?s-@C?` zW7CPnS8g>AHN$gm?;xShQv&HgGyORte0FQ8#-0s; z;McdpWyu>YesAUuRn6brnfke=t)ksd&m-K8CD59Vj!K}Xzjx2QV(!HKMfp_QvpOpg zyuyK#XlHMdV+s?r25MjXW%@*g_w?!Ki2Oivr}Mit(Xour&fFM_^kFRmBeGZM>jn;I zWmA^{K1z!XNVeSiQ=>0>|6|Nq#rLkJ4=?WAnW%X$U_BKT`03Z!l}wKfU)FEUq$`jY zdIbhS1SPQ%g9u?5#1%;TUrMYwUIxwn0g&|g@|WHs!hk9lUmrNz}Ch;trTY&U_2hQGa%^r-ALHM`g>YH@D=NbU(4?QA zyu*|)#O4kX{ED&IWLd$N#6j17|mh#6zk{9adNjSMrTcM@svNl zS-d~o9@!jze@hG>3L^s|QoJHx>OH~H(+ZgEIGP!OF@ZMMrEZYJ_6?ySQ|RR!%w53q z_>{S$vUUVOx?rsQ=TkdBrr^pd}I*7ICBk4*6VuTCi?tB~`N z)Ao~FlO#D&bcP+m46k}-M3=^UR!pomLmh0L#2}a)kdH^8`aD-IK-Koh?g@%!k`%FL zG+xM}E+?NPW@!uw>m-o3w%2cL&a7VHzqT^Q??~VngF$-@PHXpwPG%*cHbY_)RI^RT z&bKN<6w5@)kv{--96!CK)Cin*3~+Jbt5;oc@*3=W-8=gCcQY-13!#5-+i-qZ_L%ka}VbSf)ef#I}v4BnAu*ex(>d#zg8`$ceow~1)-m;vylA8bLF_~7K4r+pr3s3 zme6LdK-zd>eB)6lH0PpQJ7oh+!z_B4-jku_GgdB+DZTd;ErRU^Vtl``@`VFnIT+P5 zd{L&2lxsO6vd_dw9I2ChvG__5$1Yl51vp-^EwG z)QgFMdsqo1@gk_1Z@0L0H@0zQhrUZnx*}h@=9@D3%qHBA)^O0Gzqbu?_(d@J;SJ*{ z@LWiU%UOFHcAUtK3`+pDx=Y?{nRKrt#<3p#J#T-`T`&Gebq-_ z#cMMHi2wd0pnV7o4x<6&hDW`oAd3!@uF>$jyW^wYyR*wFd58{;9;VF*N+949-fyk4 zRVGj&`+#Q#1?q}Cy-e3(#QamScUpv1z z-2z0a18nR4v$*uUw>G}1cP_2kRQ!jx#p)qU2i%en19HgB&gWXgok@M{`Jt}YRv=?} zzzOl1R!g1MT&GB zCn;W{S0J++FVrN_!-h3Bn~|nRdeijXKY#rP?mf|T*sb_&ZFT}<5>wgUBbdDL8F0!) zg!8BY=HXvjOXg3n!WaZF@dK0W({5S^*usF@OT@rc5s?eR_nm1LdiB>?1G%JhjRIN?_56!Whe8=)Ja5hvz?|l~r(O`xH*2t%>7^pWS+{XZaQ(A_sNayBGc^p{RE_rjbWj9Nj+u-}(xL zV?K$>Ll^?I70*eS<5<&c9}czbssKGM)XzMbFg38MSu#i1gMoEJ*#2!0%jgQsY&g#P z!hO2yTGxMF2loB}w`hd^xjs>{eDY-yD8a9k7t_wEW0uqZKFDQ|odCS*-9NdpUQB)Y z5J%&AK}P7J;q3^DHsB{li!uiGW0PGHh&ZnPD~i2cbWCLY_d?fk&Z3yAbioS92R2^~ z6GQtj=Iqg~2@IB-eAUiypsa$8lu_XkZqP74JQH9i-<~&d!U_qsJo-9DOR({TLdtvu zgwE}P%+*wZVDBFkxSaZEL(UOXQv}W!wu5S^IJUR8B&T8fdDQAXvElO@ za%wVCO`X-SxMtOhCbxNx?gHhX?zAovz|DjCx1@yick^q8K-&Vt%nFec5fUVY1T-K6 zw{S;FS%HV=0Pjx-p(~Gc8E0t{`eFBouCrRI^Bft2ane0MIBMhfb7g9+Mt{U>Ytxqg zXd_|$J{=V=J=SIRvf36K(B`FkMwqFKGD}>es)w_eE7?#N=e|kAFko^3wc6m?XF}i{ zy*oK*lf-#G=$PWvu&G;Z%z;B#y<1PU90~-6EN(gKE8B1=39TBb^Ev1@QkFP>=NESS zP_~Q{?q{9;00GdIu)J^9Pk_asbg*@-e=&l&58E2{$T&gT$ilRT2|r-FFnIMeP=g8< zEM!((_qVisjV->nEp+V*Jj<#nk^4DEpK+`7Oun|_qn#Eq*!bfEUn8Q1qjx}#ceWi| z{Xj6TOyrIx0~alazdXA<*rkBd5j z`a!g-vU7p9)y;Tbm9(x}U?iXd;4V*N4em9@+2YBD@RQf+PB4F1DI$2}epsvTNbyGijXuw|d0jtwHmM0oi&tN~p$VNOYN2x@XtDMe2MjiISL(2!cA$g5(C8#7f$~TPg?0DsCCHCEE@7R4f2QPJT?yc$9QGN z^W&v|agBbUOwGMK(>Ql`z@a>|hUA3q;0&m|q$d)d2QBgG28MbVBsdxFMU znGk<%o;0%T>&&xi|D!FFpIw7f4E|eN=1UN@Xpc$8Dkt_$2A}%uc{@09q4|b*hS`Dp z4@N>dP|jUN(ZJduU428skAVmlpt>c_|K_)`mDv#ftT9p%&Xwau((@a4k#IJ3&)Il6TcAvRZ6!qFInzQHd@f0!cSYSeHvf*PS2m&wkW zKIxONPhH&E2P^xaJ0!HJm_;tX!njXkv2%1z{8~j*eLfXktIm`xB6N59ii1O0Pkwx8 z7DGmDL;G!;FHP<*j%O{Nn5MiDmIh8#P;s)&KGecbT{z5!yi+5tun0JBL!-c4Rnoh|q+0q~Z@Ce@*Q0<>JdJkKH*#05n z1mPkaOaeAZXxz4_jUs3{B~ZGRpVmN#=H(FvHT~vCcQYEBz%6~?jr6Kh)chMT{0!e3 zBEK^+zI$>ZE-eQFyhZdn^twh2??CVTV;fVFt6EO!M6MTC>0Z1_(b2Dk*mH~ z2+$BRWIqp&*!r47{c9%Q)lgZKa61~{c96mTI&6$wPvOE5TiM37cQ72=? zV2=n7ddN;q;PK=)(9b3*^3m(qfd}bk9SkS+)%!^B*8Lg<)*6smpMrgLu-%{KW1z+N zKBNkJO{<}92{N@a^GK%0;^)U44PNTvH4p#PF{IvlwPw}qS<87Q;}5rFFpDyW(T?^?^ZXxJNiL!*z<_1J$* zAHSYSupNhuy8v9~Qz8F~t^k6SBYcPHyf0_=-#Znm>g}%lTS}G#>b7(`VP%X9E`x=b zvKdKVRwQ8CZNwzSihG%n3Xcw_8HV5W&kj>NgoI zfj`T2U_E$F;WEYa3ITrHtUBSEQ@SBI2ZvD@t>O6XKJz_UK0Ms?#o9QTPD^I?2s|&< zJIW>C>U-#Ej@D}#z@YxO+3HDZ0I>CWO+rk8gS0=)jH@T|b-nQuKAsO+0B}ebRjk`M z>O(TmoGD=4CuWJoHh;A(snUfU=V}abgJxV|Ie=_zTuZYD>$Lc@klGg&>|p`_amb7! zp)m$qIR1(aUeGgWx=&hsh75_%gFPn-5$1H^2i3eqpr06i4i@Wb(h3Vfk0jWfW)(fp zRX^9=iVS2N8n|y|xlEw~#dD1Rr6b?^|=i8v)>&g0Tyd^!D#KfHIR=-4_ zkjrp~-3LzCH(}FSmGvU$YJ-DAbsC@@A^c_1rq*}+W1q|{j`4EV|5R%hbBHwXaGE@)p{+Cmw}>h^LKibZCdZH1IC_!iL`syno26k5Il^8NIF zCHe8f2N~+s$t3U2V6U-+6V~CPu+diu@zMx}J0em9guIV#Mw>UX!B_O`X;(@mNr8v` zue?(t4|n>}1PNW>-pxV=ivXtTen=k?VJZN%qXEf}?^QF6)|CXL$L)ONOSzYx4?CJR z$|vuu0S*qhn4dEK@ml)X_FLSA!#|6M=ZWYjz)$v3N>Qv{mCa|h9RU~W3ohItGHW_o zm$28>0Cz#y7UK#UCe)fp5Fl0oD`aP0NdkHzbfJeko3FZ=Vx2pqe;IJ{2-JeH{;wIu zH!`Zj_0rbSoYVWecddE|f^Nn{Ro!bK=*qZNl)+qsj!)_z$hdts{_-?&2~c0v@0*eU z8yK?BZIm(8YL?;EP5^D42<`B$H3 zIVeK<(zjH+k8ok|%z^%-ZzW-aS3Y0mNgMo!Cb8X6%jm!gk}wj3yrPnUqV2MKFzId1 z;@{3puM=Q5gU+I&mPUrdUeesBzU^TIlk$4}nb4f<0)jriIO(g)-vEPjh<+0mUhtQl zw__h{w@QFZ+USCf0(b2`^8$az=XuCa6Rm%?P^qgIA4>m0KmQ~0>>mj@!$&_9c8_8` zJmoF4O7fGJrRCrH@teQIWMl=tQ?}{odz^@=m>Rzn(gN6U{5h}jV=GvOH?v1Jg0GxZ zSWHa$xd0#BzvX1%F~-rZGSWwOzXDoQ!Y6V)CNBJf4>ZjP=>8&DSB8U2f`Kz#-BS2( z1?htTsDoz+&}*-h*;Az>upWfn?)RZbPY_I+5K~t4KiJlFbUStc{dB%L7yQoR#cMQ=(_-M3KAtC4(0s3LgsP0J{MPI6L#wPW1gdH<*SSP ze=`E~J(sS+ZGbcvgI9`=@ZNha`_2ntAwG9~Zia9JuyHPVwgbkH8ecQxl?h zp^q$yJbV+^L$q|`eJj}g42l03`kOSE2DTPRnyN%-PpA?U@tYUD=4qy4Amiqy#{2! zDeN1?S=fqDUc^!UCV%-*H_8M)`v4mL{9bNhq$KJ*T%1$_DH!<;bxU9QhVE$Lem8cd zF@y!axrPf%I3wZA>HFdwfp!!1y5JSnYz79^{tMd|ARHOns}&XIvu-+WpH-cbiTdGf zG$DrHr%%>!Ga?7gt*n7>UkD0DZ6Fs{Ll}8y|5jKuV zqieYEh_vP6DEq7zp6XlNXv@DWZM-wPD=P2;Zf|otCHe-=EGJ-Z@CV1dDY~F@(~ES= zYb$xeXYO760##!3*#w%46vo&N*=DAcUr&uwBOxK@Bb6I|Hr)1d*z(w zo70C|r=t>@OR|K0=ATG~X2F(qOn?1b*uCMPk}^#o78Yn1^NQ* zfMFDIM`ztfQg_DXqagXfXfCLgGi+88IT2}yz*xZcn-BxC$aYHmEjyU143fMf)CSI% zyW#-N@qqQiCrU+e`?k5=L9hVh&N;%569|y^9C4&@?5tcFc9Twv?S0kw;^6px=fL`% zIbmC&?P}6D^xRkG>k8dUnhPAZbz};Nz&7(n1ePQecnL^UXsH9>o)T=1@HswPiEXaa@YC)AsBPZKZMCrHALC+8eV;COrq}yJ$I!{Q zKv-HDI9fqkDKdbvVTc2j01IHfAu+l8qa&;=+BWigPJ-O*M!yJ31I7$R^32zjuq9?R zipmeAqP|hGzSnjje3=Z__61!^j%|VyujN!3A`lO#u0MpiM~}_& Dict[str, List[type]]: - """ - Report what type as seen as values in a Pandas data frame. - - :param d: Pandas data frame to inspect, not altered. - :return: dictionary mapping column names to order lists of types found in column. - """ - assert isinstance(d, pandas.DataFrame) - type_dict_map = { - col_name: {str(type(v)): type(v) for v in d[col_name]} - for col_name in d.columns - } - type_dict = { - col_name: [type_set[k] for k in sorted(list(type_set.keys()))] - for col_name, type_set in type_dict_map.items() - } - return type_dict - - -# noinspection PyPep8Naming -def cross_predict_model( - fitter, X: pandas.DataFrame, y: pandas.Series, plan: List -) -> numpy.ndarray: - """ - train a model y~X using the cross validation plan and return predictions - - :param fitter: sklearn model we can call .fit() on - :param X: explanatory variables, pandas DataFrame - :param y: dependent variable, pandas Series - :param plan: cross validation plan from mk_cross_plan() - :return: vector of simulated out of sample predictions - """ - - assert isinstance(X, pandas.DataFrame) - assert isinstance(y, pandas.Series) - assert isinstance(plan, List) - preds = None - for pi in plan: - model = fitter.fit(X.iloc[pi["train"], :], y.iloc[pi["train"]]) - predg = model.predict(X.iloc[pi["test"], :]) - # patch results in - if preds is None: - preds = numpy.asarray([None] * X.shape[0], dtype=numpy.asarray(predg).dtype) - preds[pi["test"]] = predg - return preds - - -# noinspection PyPep8Naming -def cross_predict_model_proba( - fitter, X: pandas.DataFrame, y: pandas.Series, plan: List -) -> pandas.DataFrame: - """ - train a model y~X using the cross validation plan and return probability matrix - - :param fitter: sklearn model we can call .fit() on - :param X: explanatory variables, pandas DataFrame - :param y: dependent variable, pandas Series - :param plan: cross validation plan from mk_cross_plan() - :return: matrix of simulated out of sample predictions - """ - - assert isinstance(X, pandas.DataFrame) - assert isinstance(y, pandas.Series) - assert isinstance(plan, List) - preds = None - for pi in plan: - model = fitter.fit(X.iloc[pi["train"], :], y.iloc[pi["train"]]) - predg = model.predict_proba(X.iloc[pi["test"], :]) - # patch results in - if preds is None: - preds = numpy.zeros((X.shape[0], predg.shape[1])) - for j in range(preds.shape[1]): - preds[pi["test"], j] = predg[:, j] - preds = pandas.DataFrame(preds) - preds.columns = list(fitter.classes_) - return preds - - -def mean_deviance(predictions, istrue, *, eps=1.0e-6): - """ - compute per-row deviance of predictions versus istrue - - :param predictions: vector of probability preditions - :param istrue: vector of True/False outcomes to be predicted - :param eps: how close to zero or one we clip predictions - :return: vector of per-row deviances - """ - - istrue = numpy.asarray(istrue) - predictions = numpy.asarray(predictions) - mass_on_correct = numpy.where(istrue, predictions, 1 - predictions) - mass_on_correct = numpy.maximum(mass_on_correct, eps) - return -2 * sum(numpy.log(mass_on_correct)) / len(istrue) - - -def mean_null_deviance(istrue, *, eps=1.0e-6): - """ - compute per-row nulll deviance of predictions versus istrue - - :param istrue: vector of True/False outcomes to be predicted - :param eps: how close to zero or one we clip predictions - :return: mean null deviance of using prevalence as the prediction. - """ - - istrue = numpy.asarray(istrue) - p = numpy.zeros(len(istrue)) + numpy.mean(istrue) - return mean_deviance(predictions=p, istrue=istrue, eps=eps) - - -def mk_cross_plan(n: int, k: int) -> List: - """ - Randomly split range(n) into k train/test groups such that test groups partition range(n). - - :param n: integer > 1 - :param k: integer > 1 - :return: list of train/test dictionaries - - Example: - - import wvpy.util - - wvpy.util.mk_cross_plan(10, 3) - """ - grp = [i % k for i in range(n)] - numpy.random.shuffle(grp) - plan = [ - { - "train": [i for i in range(n) if grp[i] != j], - "test": [i for i in range(n) if grp[i] == j], - } - for j in range(k) - ] - return plan - - -# https://win-vector.com/2020/09/13/why-working-with-auc-is-more-powerful-than-one-might-think/ -def matching_roc_area_curve(auc: float) -> dict: - """ - Find an ROC curve with a given area with form of y = 1 - (1 - (1 - x) ** q) ** (1 / q). - - :param auc: area to match - :return: dictionary of ideal x, y series matching area - """ - step = 0.01 - eval_pts = numpy.arange(0, 1 + step, step) - q_eps = 1e-6 - q_low = 0.0 - q_high = 1.0 - while q_low + q_eps < q_high: - q_mid = (q_low + q_high) / 2.0 - q_mid_area = numpy.mean(1 - (1 - (1 - eval_pts) ** q_mid) ** (1 / q_mid)) - if q_mid_area <= auc: - q_high = q_mid - else: - q_low = q_mid - q = (q_low + q_high) / 2.0 - return { - "auc": auc, - "q": q, - "x": 1 - eval_pts, - "y": 1 - (1 - (1 - eval_pts) ** q) ** (1 / q), - } - - -# https://scikit-learn.org/stable/auto_examples/model_selection/plot_roc.html -def plot_roc( - prediction, - istrue, - title="Receiver operating characteristic plot", - *, - truth_target=True, - ideal_line_color=None, - extra_points=None, - show=True, -): - """ - Plot a ROC curve of numeric prediction against boolean istrue. - - :param prediction: column of numeric predictions - :param istrue: column of items to predict - :param title: plot title - :param truth_target: value to consider target or true. - :param ideal_line_color: if not None, color of ideal line - :param extra_points: data frame of additional point to annotate graph, columns fpr, tpr, label - :param show: logical, if True call matplotlib.pyplot.show() - :return: calculated area under the curve, plot produced by call. - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - ideal_line_color='lightgrey' - ) - - wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - ideal_line_color='lightgrey', - extra_points=pandas.DataFrame({ - 'tpr': [0, 1], - 'fpr': [0, 1], - 'label': ['AAA', 'BBB'] - }) - ) - """ - prediction = numpy.asarray(prediction) - istrue = numpy.asarray(istrue) == truth_target - fpr, tpr, _ = sklearn.metrics.roc_curve(istrue, prediction) - auc = sklearn.metrics.auc(fpr, tpr) - ideal_curve = None - if ideal_line_color is not None: - ideal_curve = matching_roc_area_curve(auc) - matplotlib.pyplot.figure() - lw = 2 - matplotlib.pyplot.gcf().clear() - fig1, ax1 = matplotlib.pyplot.subplots() - ax1.set_aspect("equal") - matplotlib.pyplot.plot( - fpr, - tpr, - color="darkorange", - lw=lw, - label="ROC curve (area = {0:0.2f})" "".format(auc), - ) - matplotlib.pyplot.fill_between(fpr, tpr, color="orange", alpha=0.3) - matplotlib.pyplot.plot([0, 1], [0, 1], color="navy", lw=lw, linestyle="--") - if extra_points is not None: - matplotlib.pyplot.scatter(extra_points.fpr, extra_points.tpr, color="red") - if "label" in extra_points.columns: - tpr = extra_points.tpr.to_list() - fpr = extra_points.fpr.to_list() - label = extra_points.label.to_list() - for i in range(extra_points.shape[0]): - txt = label[i] - if txt is not None: - ax1.annotate(txt, (fpr[i], tpr[i])) - if ideal_curve is not None: - matplotlib.pyplot.plot( - ideal_curve["x"], ideal_curve["y"], linestyle="--", color=ideal_line_color - ) - matplotlib.pyplot.xlim([0.0, 1.0]) - matplotlib.pyplot.ylim([0.0, 1.0]) - matplotlib.pyplot.xlabel("False Positive Rate (1-Specificity)") - matplotlib.pyplot.ylabel("True Positive Rate (Sensitivity)") - matplotlib.pyplot.title(title) - matplotlib.pyplot.legend(loc="lower right") - if show: - matplotlib.pyplot.show() - return auc - - -def dual_density_plot( - probs, - istrue, - title="Double density plot", - *, - truth_target=True, - positive_label="positive examples", - negative_label="negative examples", - ylabel="density of examples", - xlabel="model score", - show=True, -): - """ - Plot a dual density plot of numeric prediction probs against boolean istrue. - - :param probs: vector of numeric predictions. - :param istrue: truth vector - :param title: title of plot - :param truth_target: value considerd true - :param positive_label=label for positive class - :param negative_label=label for negative class - :param ylabel=y axis label - :param xlabel=x axis label - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.dual_density_plot( - probs=d['x'], - istrue=d['y'], - ) - """ - probs = numpy.asarray(probs) - istrue = numpy.asarray(istrue) == truth_target - matplotlib.pyplot.gcf().clear() - preds_on_positive = [ - probs[i] for i in range(len(probs)) if istrue[i] == truth_target - ] - preds_on_negative = [ - probs[i] for i in range(len(probs)) if not istrue[i] == truth_target - ] - seaborn.kdeplot(preds_on_positive, label=positive_label, shade=True) - seaborn.kdeplot(preds_on_negative, label=negative_label, shade=True) - matplotlib.pyplot.ylabel(ylabel) - matplotlib.pyplot.xlabel(xlabel) - matplotlib.pyplot.title(title) - matplotlib.pyplot.legend() - if show: - matplotlib.pyplot.show() - - -def dual_hist_plot(probs, istrue, title="Dual Histogram Plot", *, truth_target=True, show=True): - """ - plot a dual histogram plot of numeric prediction probs against boolean istrue - - :param probs: vector of numeric predictions. - :param istrue: truth vector - :param title: title of plot - :param truth_target: value to consider in class - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.dual_hist_plot( - probs=d['x'], - istrue=d['y'], - ) - """ - probs = numpy.asarray(probs) - istrue = numpy.asarray(istrue) == truth_target - matplotlib.pyplot.gcf().clear() - pf = pandas.DataFrame({"prob": probs, "istrue": istrue}) - g = seaborn.FacetGrid(pf, row="istrue", height=4, aspect=3) - bins = numpy.arange(0, 1.1, 0.1) - g.map(matplotlib.pyplot.hist, "prob", bins=bins) - matplotlib.pyplot.title(title) - if show: - matplotlib.pyplot.show() - - -def dual_density_plot_proba1( - probs, - istrue, - title="Double density plot", - *, - truth_target=True, - positive_label="positive examples", - negative_label="negative examples", - ylabel="density of examples", - xlabel="model score", - show=True, -): - """ - Plot a dual density plot of numeric prediction probs[:,1] against boolean istrue. - - :param probs: matrix of numeric predictions (as returned from predict_proba()) - :param istrue: truth target - :param title: title of plot - :param truth_target: value considered true - :param positive_label=label for positive class - :param negative_label=label for negative class - :param ylabel=y axis label - :param xlabel=x axis label - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - d['x0'] = 1 - d['x'] - pmat = numpy.asarray(d.loc[:, ['x0', 'x']]) - - wvpy.util.dual_density_plot_proba1( - probs=pmat, - istrue=d['y'], - ) - """ - istrue = numpy.asarray(istrue) - probs = numpy.asarray(probs) - matplotlib.pyplot.gcf().clear() - preds_on_positive = [ - probs[i, 1] for i in range(len(probs)) if istrue[i] == truth_target - ] - preds_on_negative = [ - probs[i, 1] for i in range(len(probs)) if not istrue[i] == truth_target - ] - seaborn.kdeplot(preds_on_positive, label=positive_label, shade=True) - seaborn.kdeplot(preds_on_negative, label=negative_label, shade=True) - matplotlib.pyplot.ylabel(ylabel) - matplotlib.pyplot.xlabel(xlabel) - matplotlib.pyplot.title(title) - matplotlib.pyplot.legend() - if show: - matplotlib.pyplot.show() - - -def dual_hist_plot_proba1(probs, istrue, *, show=True): - """ - plot a dual histogram plot of numeric prediction probs[:,1] against boolean istrue - - :param probs: vector of probability predictions - :param istrue: vector of ground truth to condition on - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - d['x0'] = 1 - d['x'] - pmat = numpy.asarray(d.loc[:, ['x0', 'x']]) - - wvpy.util.dual_hist_plot_proba1( - probs=pmat, - istrue=d['y'], - ) - """ - istrue = numpy.asarray(istrue) - probs = numpy.asarray(probs) - matplotlib.pyplot.gcf().clear() - pf = pandas.DataFrame( - {"prob": [probs[i, 1] for i in range(probs.shape[0])], "istrue": istrue} - ) - g = seaborn.FacetGrid(pf, row="istrue", height=4, aspect=3) - bins = numpy.arange(0, 1.1, 0.1) - g.map(matplotlib.pyplot.hist, "prob", bins=bins) - if show: - matplotlib.pyplot.show() - - -def gain_curve_plot(prediction, outcome, title="Gain curve plot", *, show=True): - """ - plot cumulative outcome as a function of prediction order (descending) - - :param prediction: vector of numeric predictions - :param outcome: vector of actual values - :param title: plot title - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [0, 0, 1, 1, 0] - }) - - wvpy.util.gain_curve_plot( - prediction=d['x'], - outcome=d['y'], - ) - """ - - df = pandas.DataFrame( - { - "prediction": numpy.array(prediction).copy(), - "outcome": numpy.array(outcome).copy(), - } - ) - - # compute the gain curve - df.sort_values(["prediction"], ascending=[False], inplace=True) - df["fraction_of_observations_by_prediction"] = ( - numpy.arange(df.shape[0]) + 1.0 - ) / df.shape[0] - df["cumulative_outcome"] = df["outcome"].cumsum() - df["cumulative_outcome_fraction"] = df["cumulative_outcome"] / numpy.max( - df["cumulative_outcome"] - ) - - # compute the wizard curve - df.sort_values(["outcome"], ascending=[False], inplace=True) - df["fraction_of_observations_by_wizard"] = ( - numpy.arange(df.shape[0]) + 1.0 - ) / df.shape[0] - - df["cumulative_outcome_by_wizard"] = df["outcome"].cumsum() - df["cumulative_outcome_fraction_wizard"] = df[ - "cumulative_outcome_by_wizard" - ] / numpy.max(df["cumulative_outcome_by_wizard"]) - - seaborn.lineplot( - x="fraction_of_observations_by_wizard", - y="cumulative_outcome_fraction_wizard", - color="gray", - linestyle="--", - data=df, - ) - - seaborn.lineplot( - x="fraction_of_observations_by_prediction", - y="cumulative_outcome_fraction", - data=df, - ) - - seaborn.lineplot(x=[0, 1], y=[0, 1], color="red") - matplotlib.pyplot.xlabel("fraction of observations by sort criterion") - matplotlib.pyplot.ylabel("cumulative outcome fraction") - matplotlib.pyplot.title(title) - if show: - matplotlib.pyplot.show() - - -def lift_curve_plot(prediction, outcome, title="Lift curve plot", *, show=True): - """ - plot lift as a function of prediction order (descending) - - :param prediction: vector of numeric predictions - :param outcome: vector of actual values - :param title: plot title - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [0, 0, 1, 1, 0] - }) - - wvpy.util.lift_curve_plot( - prediction=d['x'], - outcome=d['y'], - ) - """ - - df = pandas.DataFrame( - { - "prediction": numpy.array(prediction).copy(), - "outcome": numpy.array(outcome).copy(), - } - ) - - # compute the gain curve - df.sort_values(["prediction"], ascending=[False], inplace=True) - df["fraction_of_observations_by_prediction"] = ( - numpy.arange(df.shape[0]) + 1.0 - ) / df.shape[0] - df["cumulative_outcome"] = df["outcome"].cumsum() - df["cumulative_outcome_fraction"] = df["cumulative_outcome"] / numpy.max( - df["cumulative_outcome"] - ) - - # move to lift - df["lift"] = ( - df["cumulative_outcome_fraction"] / df["fraction_of_observations_by_prediction"] - ) - seaborn.lineplot(x="fraction_of_observations_by_prediction", y="lift", data=df) - matplotlib.pyplot.axhline(y=1, color="red") - matplotlib.pyplot.title(title) - if show: - matplotlib.pyplot.show() - - -# https://stackoverflow.com/questions/5228158/cartesian-product-of-a-dictionary-of-lists -def search_grid(inp: dict) -> List: - """ - build a cross product of all named dictionary entries - - :param inp: dictionary of value lists - :return: list of value dictionaries - """ - - gen = (dict(zip(inp.keys(), values)) for values in itertools.product(*inp.values())) - return [ci for ci in gen] - - -def grid_to_df(grid: List) -> pandas.DataFrame: - """ - convert a search_grid list of maps to a pandas data frame - - :param grid: list of combos - :return: data frame with one row per combo - """ - - n = len(grid) - keys = [ki for ki in grid[1].keys()] - return pandas.DataFrame({ki: [grid[i][ki] for i in range(n)] for ki in keys}) - - -def eval_fn_per_row(f, x2, df: pandas.DataFrame) -> List: - """ - evaluate f(row-as-map, x2) for rows in df - - :param f: function to evaluate - :param x2: extra argument - :param df: data frame to take rows from - :return: list of evaluations - """ - - assert isinstance(df, pandas.DataFrame) - return [f({k: df.loc[i, k] for k in df.columns}, x2) for i in range(df.shape[0])] - - -def perm_score_vars(d: pandas.DataFrame, istrue, model, modelvars: List[str], k=5): - """ - evaluate model~istrue on d permuting each of the modelvars and return variable importances - - :param d: data source (copied) - :param istrue: y-target - :param model: model to evaluate - :param modelvars: names of variables to permute - :param k: number of permutations - :return: score data frame - """ - - d2 = d[modelvars].copy() - d2.reset_index(inplace=True, drop=True) - istrue = numpy.asarray(istrue) - preds = model.predict_proba(d2[modelvars]) - basedev = mean_deviance(preds[:, 1], istrue) - - def perm_score_var(victim): - """Permutation score column named victim""" - dorig = numpy.array(d2[victim].copy()) - dnew = numpy.array(d2[victim].copy()) - - def perm_score_var_once(): - """apply fn once, used for list comprehension""" - numpy.random.shuffle(dnew) - d2[victim] = dnew - predsp = model.predict_proba(d2[modelvars]) - permdev = mean_deviance(predsp[:, 1], istrue) - return permdev - - # noinspection PyUnusedLocal - devs = [perm_score_var_once() for rep in range(k)] - d2[victim] = dorig - return numpy.mean(devs), statistics.stdev(devs) - - stats = [perm_score_var(victim) for victim in modelvars] - vf = pandas.DataFrame({"var": modelvars}) - vf["importance"] = [di[0] - basedev for di in stats] - vf["importance_dev"] = [di[1] for di in stats] - vf.sort_values(by=["importance"], ascending=False, inplace=True) - vf = vf.reset_index(inplace=False, drop=True) - return vf - - -def threshold_statistics( - d: pandas.DataFrame, *, model_predictions: str, yvalues: str, y_target=True -) -> pandas.DataFrame: - """ - Compute a number of threshold statistics of how well model predictions match a truth target. - - :param d: pandas.DataFrame to take values from - :param model_predictions: name of predictions column - :param yvalues: name of truth values column - :param y_target: value considered to be true - :return: summary statistic frame, include before and after pseudo-observations - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.threshold_statistics( - d, - model_predictions='x', - yvalues='y', - ) - """ - # make a thin frame to re-sort for cumulative statistics - sorted_frame = pandas.DataFrame( - {"threshold": d[model_predictions].copy(), "truth": d[yvalues] == y_target} - ) - sorted_frame["orig_index"] = sorted_frame.index + 0 - sorted_frame.sort_values( - ["threshold", "orig_index"], ascending=[False, True], inplace=True - ) - sorted_frame.reset_index(inplace=True, drop=True) - sorted_frame["notY"] = 1 - sorted_frame["truth"] # falses - sorted_frame["one"] = 1 - del sorted_frame["orig_index"] - - # pseudo-observation to get end-case (accept nothing case) - eps = 1.0e-6 - sorted_frame = pandas.concat( - [ - pandas.DataFrame( - { - "threshold": [sorted_frame["threshold"].max() + eps], - "truth": [False], - "notY": [0], - "one": [0], - } - ), - sorted_frame, - pandas.DataFrame( - { - "threshold": [sorted_frame["threshold"].min() - eps], - "truth": [False], - "notY": [0], - "one": [0], - } - ), - ] - ) - sorted_frame.reset_index(inplace=True, drop=True) - - # basic cumulative facts - sorted_frame["count"] = sorted_frame["one"].cumsum() # predicted true so far - sorted_frame["fraction"] = sorted_frame["count"] / max(1, sorted_frame["one"].sum()) - sorted_frame["precision"] = sorted_frame["truth"].cumsum() / sorted_frame[ - "count" - ].clip(lower=1) - sorted_frame["true_positive_rate"] = sorted_frame["truth"].cumsum() / max( - 1, sorted_frame["truth"].sum() - ) - sorted_frame["false_positive_rate"] = sorted_frame["notY"].cumsum() / max( - 1, sorted_frame["notY"].sum() - ) - sorted_frame["true_negative_rate"] = ( - sorted_frame["notY"].sum() - sorted_frame["notY"].cumsum() - ) / max(1, sorted_frame["notY"].sum()) - sorted_frame["false_negative_rate"] = ( - sorted_frame["truth"].sum() - sorted_frame["truth"].cumsum() - ) / max(1, sorted_frame["truth"].sum()) - sorted_frame["accuracy"] = ( - sorted_frame["truth"].cumsum() # true positive count - + sorted_frame["notY"].sum() - - sorted_frame["notY"].cumsum() # true negative count - ) / sorted_frame["one"].sum() - - # approximate cdf work - sorted_frame["cdf"] = 1 - sorted_frame["fraction"] - - # derived facts and synonyms - sorted_frame["recall"] = sorted_frame["true_positive_rate"] - sorted_frame["sensitivity"] = sorted_frame["recall"] - sorted_frame["specificity"] = 1 - sorted_frame["false_positive_rate"] - - # re-order for neatness - sorted_frame["new_index"] = sorted_frame.index.copy() - sorted_frame.sort_values(["new_index"], ascending=[False], inplace=True) - sorted_frame.reset_index(inplace=True, drop=True) - - # clean up - del sorted_frame["notY"] - del sorted_frame["one"] - del sorted_frame["new_index"] - del sorted_frame["truth"] - return sorted_frame - - -def threshold_plot( - d: pandas.DataFrame, - pred_var: str, - truth_var: str, - truth_target: bool = True, - threshold_range: Iterable[float] = (-math.inf, math.inf), - plotvars: Iterable[str] = ("precision", "recall"), - title: str = "Measures as a function of threshold", - *, - show: bool = True, -) -> None: - """ - Produce multiple facet plot relating the performance of using a threshold greater than or equal to - different values at predicting a truth target. - - :param d: pandas.DataFrame to plot - :param pred_var: name of column of numeric predictions - :param truth_var: name of column with reference truth - :param truth_target: value considered true - :param threshold_range: x-axis range to plot - :param plotvars: list of metrics to plot, must come from ['threshold', 'count', 'fraction', - 'true_positive_rate', 'false_positive_rate', 'true_negative_rate', 'false_negative_rate', - 'precision', 'recall', 'sensitivity', 'specificity', 'accuracy'] - :param title: title for plot - :param show: logical, if True call matplotlib.pyplot.show() - :return: None, plot produced as a side effect - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.threshold_plot( - d, - pred_var='x', - truth_var='y', - plotvars=("sensitivity", "specificity"), - ) - """ - if isinstance(plotvars, str): - plotvars = [plotvars] - else: - plotvars = list(plotvars) - assert isinstance(plotvars, list) - assert len(plotvars) > 0 - assert all([isinstance(v, str) for v in plotvars]) - threshold_range = list(threshold_range) - assert len(threshold_range) == 2 - frame = d[[pred_var, truth_var]].copy() - frame.reset_index(inplace=True, drop=True) - frame["outcol"] = frame[truth_var] == truth_target - - prt_frame = threshold_statistics( - frame, model_predictions=pred_var, yvalues="outcol", - ) - bad_plot_vars = set(plotvars) - set(prt_frame.columns) - if len(bad_plot_vars) > 0: - raise ValueError( - "allowed plotting variables are: " - + str(prt_frame.columns) - + ", " - + str(bad_plot_vars) - + " unexpected." - ) - - selector = (threshold_range[0] <= prt_frame.threshold) & ( - prt_frame.threshold <= threshold_range[1] - ) - to_plot = prt_frame.loc[selector, :] - - if len(plotvars) > 1: - reshaper = RecordMap( - blocks_out=RecordSpecification( - pandas.DataFrame({"measure": plotvars, "value": plotvars}), - control_table_keys=["measure"], - record_keys=["threshold"], - ) - ) - prtlong = reshaper.transform(to_plot) - grid = seaborn.FacetGrid( - prtlong, row="measure", row_order=plotvars, aspect=2, sharey=False - ) - grid = grid.map(matplotlib.pyplot.plot, "threshold", "value") - grid.set(ylabel=None) - matplotlib.pyplot.subplots_adjust(top=0.9) - grid.fig.suptitle(title) - else: - # can plot off primary frame - seaborn.lineplot( - data=to_plot, x="threshold", y=plotvars[0], - ) - matplotlib.pyplot.suptitle(title) - matplotlib.pyplot.title(f"measure = {plotvars[0]}") - - if show: - matplotlib.pyplot.show() - - -def fit_onehot_enc( - d: pandas.DataFrame, *, categorical_var_names: Iterable[str] -) -> dict: - """ - Fit a sklearn OneHot Encoder to categorical_var_names columns. - Note: we suggest preferring vtreat ( https://github.com/WinVector/pyvtreat ) over this example code. - - :param d: training data - :param categorical_var_names: list of column names to learn transform from - :return: encoding bundle dictionary, see apply_onehot_enc() for use. - """ - assert isinstance(d, pandas.DataFrame) - assert not isinstance( - categorical_var_names, str - ) # single name, should be in a list - categorical_var_names = list(categorical_var_names) # clean copy - assert numpy.all([isinstance(v, str) for v in categorical_var_names]) - assert len(categorical_var_names) > 0 - enc = sklearn.preprocessing.OneHotEncoder( - categories="auto", drop=None, sparse=False, handle_unknown="ignore" # default - ) - enc.fit(d[categorical_var_names]) - produced_column_names = list(enc.get_feature_names_out()) - # return the structure - encoder_bundle = { - "categorical_var_names": categorical_var_names, - "enc": enc, - "produced_column_names": produced_column_names, - } - return encoder_bundle - - -def apply_onehot_enc(d: pandas.DataFrame, *, encoder_bundle: dict) -> pandas.DataFrame: - """ - Apply a one hot encoding bundle to a data frame. - - :param d: input data frame - :param encoder_bundle: transform specification, built by fit_onehot_enc() - :return: transformed data frame - """ - assert isinstance(d, pandas.DataFrame) - assert isinstance(encoder_bundle, dict) - # one hot re-code columns, preserving column names info - one_hotted = pandas.DataFrame( - encoder_bundle["enc"].transform(d[encoder_bundle["categorical_var_names"]]) - ) - one_hotted.columns = encoder_bundle["produced_column_names"] - # copy over non-invovled columns - cat_set = set(encoder_bundle["categorical_var_names"]) - complementary_columns = [c for c in d.columns if c not in cat_set] - res = pandas.concat([d[complementary_columns], one_hotted], axis=1) - return res - - -# https://stackoverflow.com/a/56695622/6901725 -# from https://stackoverflow.com/questions/11130156/suppress-stdout-stderr-print-from-python-functions -class suppress_stdout_stderr(object): - ''' - A context manager for doing a "deep suppression" of stdout and stderr in - Python, i.e. will suppress all print, even if the print originates in a - compiled C/Fortran sub-function. - This will not suppress raised exceptions, since exceptions are printed - to stderr just before a script exits, and after the context manager has - exited (at least, I think that is why it lets exceptions through). - - ''' - def __init__(self): - # Open a pair of null files - self.null_fds = [os.open(os.devnull, os.O_RDWR) for x in range(2)] - # Save the actual stdout (1) and stderr (2) file descriptors. - self.save_fds = (os.dup(1), os.dup(2)) - - def __enter__(self): - # Assign the null pointers to stdout and stderr. - os.dup2(self.null_fds[0], 1) - os.dup2(self.null_fds[1], 2) - - def __exit__(self, *_): - # Re-assign the real stdout/stderr back to (1) and (2) - os.dup2(self.save_fds[0], 1) - os.dup2(self.save_fds[1], 2) - # Close the null files - os.close(self.null_fds[0]) - os.close(self.null_fds[1]) diff --git a/pkg/dist/wvpy-0.3.6-py3-none-any.whl b/pkg/dist/wvpy-0.3.6-py3-none-any.whl index 29f2460ec02861ea58bf8f4e366dc83d45ef0ef3..1408cade3659a88c3be6f92f06ae98ad5aae8205 100644 GIT binary patch delta 1171 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 diff --git a/pkg/dist/wvpy-0.3.6.tar.gz b/pkg/dist/wvpy-0.3.6.tar.gz index 3f2e8365e421272d96ec0d0f0e568b1cb97f1760..ecd5dcb476764c4d8c030cfd1a8f32a90a88a347 100644 GIT binary patch literal 91552 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

Z0aab`X| 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 r=e[o],s=0;i>s&&(r=this._queue[s](r,o,e),void 0!==r&&null!==r);s++);void 0!==r&&null!==r&&t.push(r)}return t},t.Pipeline.prototype.reset=function(){this._queue=[]},t.Pipeline.prototype.get=function(){return this._queue},t.Pipeline.prototype.toJSON=function(){return this._queue.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Index=function(){this._fields=[],this._ref="id",this.pipeline=new t.Pipeline,this.documentStore=new t.DocumentStore,this.index={},this.eventEmitter=new t.EventEmitter,this._idfCache={},this.on("add","remove","update",function(){this._idfCache={}}.bind(this))},t.Index.prototype.on=function(){var e=Array.prototype.slice.call(arguments);return this.eventEmitter.addListener.apply(this.eventEmitter,e)},t.Index.prototype.off=function(e,t){return this.eventEmitter.removeListener(e,t)},t.Index.load=function(e){e.version!==t.version&&t.utils.warn("version mismatch: current "+t.version+" importing "+e.version);var n=new this;n._fields=e.fields,n._ref=e.ref,n.documentStore=t.DocumentStore.load(e.documentStore),n.pipeline=t.Pipeline.load(e.pipeline),n.index={};for(var i in e.index)n.index[i]=t.InvertedIndex.load(e.index[i]);return n},t.Index.prototype.addField=function(e){return this._fields.push(e),this.index[e]=new t.InvertedIndex,this},t.Index.prototype.setRef=function(e){return this._ref=e,this},t.Index.prototype.saveDocument=function(e){return this.documentStore=new t.DocumentStore(e),this},t.Index.prototype.addDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.addDoc(i,e),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));this.documentStore.addFieldLength(i,n,o.length);var r={};o.forEach(function(e){e in r?r[e]+=1:r[e]=1},this);for(var s in r){var u=r[s];u=Math.sqrt(u),this.index[n].addToken(s,{ref:i,tf:u})}},this),n&&this.eventEmitter.emit("add",e,this)}},t.Index.prototype.removeDocByRef=function(e){if(e&&this.documentStore.isDocStored()!==!1&&this.documentStore.hasDoc(e)){var t=this.documentStore.getDoc(e);this.removeDoc(t,!1)}},t.Index.prototype.removeDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.hasDoc(i)&&(this.documentStore.removeDoc(i),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));o.forEach(function(e){this.index[n].removeToken(e,i)},this)},this),n&&this.eventEmitter.emit("remove",e,this))}},t.Index.prototype.updateDoc=function(e,t){var t=void 0===t?!0:t;this.removeDocByRef(e[this._ref],!1),this.addDoc(e,!1),t&&this.eventEmitter.emit("update",e,this)},t.Index.prototype.idf=function(e,t){var n="@"+t+"/"+e;if(Object.prototype.hasOwnProperty.call(this._idfCache,n))return this._idfCache[n];var i=this.index[t].getDocFreq(e),o=1+Math.log(this.documentStore.length/(i+1));return this._idfCache[n]=o,o},t.Index.prototype.getFields=function(){return this._fields.slice()},t.Index.prototype.search=function(e,n){if(!e)return[];e="string"==typeof e?{any:e}:JSON.parse(JSON.stringify(e));var i=null;null!=n&&(i=JSON.stringify(n));for(var o=new t.Configuration(i,this.getFields()).get(),r={},s=Object.keys(e),u=0;u0&&t.push(e);for(var i in n)"docs"!==i&&"df"!==i&&this.expandToken(e+i,t,n[i]);return t},t.InvertedIndex.prototype.toJSON=function(){return{root:this.root}},t.Configuration=function(e,n){var e=e||"";if(void 0==n||null==n)throw new Error("fields should not be null");this.config={};var i;try{i=JSON.parse(e),this.buildUserConfig(i,n)}catch(o){t.utils.warn("user configuration parse failed, will use default configuration"),this.buildDefaultConfig(n)}},t.Configuration.prototype.buildDefaultConfig=function(e){this.reset(),e.forEach(function(e){this.config[e]={boost:1,bool:"OR",expand:!1}},this)},t.Configuration.prototype.buildUserConfig=function(e,n){var i="OR",o=!1;if(this.reset(),"bool"in e&&(i=e.bool||i),"expand"in e&&(o=e.expand||o),"fields"in e)for(var r in e.fields)if(n.indexOf(r)>-1){var s=e.fields[r],u=o;void 0!=s.expand&&(u=s.expand),this.config[r]={boost:s.boost||0===s.boost?s.boost:1,bool:s.bool||i,expand:u}}else t.utils.warn("field name in user configuration not found in index instance fields");else this.addAllFields2UserConfig(i,o,n)},t.Configuration.prototype.addAllFields2UserConfig=function(e,t,n){n.forEach(function(n){this.config[n]={boost:1,bool:e,expand:t}},this)},t.Configuration.prototype.get=function(){return this.config},t.Configuration.prototype.reset=function(){this.config={}},lunr.SortedSet=function(){this.length=0,this.elements=[]},lunr.SortedSet.load=function(e){var t=new this;return t.elements=e,t.length=e.length,t},lunr.SortedSet.prototype.add=function(){var e,t;for(e=0;e1;){if(r===e)return o;e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o]}return r===e?o:-1},lunr.SortedSet.prototype.locationFor=function(e){for(var t=0,n=this.elements.length,i=n-t,o=t+Math.floor(i/2),r=this.elements[o];i>1;)e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o];return r>e?o:e>r?o+1:void 0},lunr.SortedSet.prototype.intersect=function(e){for(var t=new lunr.SortedSet,n=0,i=0,o=this.length,r=e.length,s=this.elements,u=e.elements;;){if(n>o-1||i>r-1)break;s[n]!==u[i]?s[n]u[i]&&i++:(t.add(s[n]),n++,i++)}return t},lunr.SortedSet.prototype.clone=function(){var e=new lunr.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},lunr.SortedSet.prototype.union=function(e){var t,n,i;this.length>=e.length?(t=this,n=e):(t=e,n=this),i=t.clone();for(var o=0,r=n.toArray();oThishttps://github.com/WinVector/wvpy is a package of example files for teaching data science.

\n"}, {"fullname": "wvpy.jtools", "modulename": "wvpy.jtools", "type": "module", "doc": "

\n"}, {"fullname": "wvpy.jtools.pretty_format_python", "modulename": "wvpy.jtools", "qualname": "pretty_format_python", "type": "function", "doc": "

Format Python code, using black.

\n\n

:param python_txt: Python code\n:param black_mode: options for black\n:return: formatted Python code

\n", "signature": "(python_txt: str, *, black_mode=None) -> str", "funcdef": "def"}, {"fullname": "wvpy.jtools.convert_py_code_to_notebook", "modulename": "wvpy.jtools", "qualname": "convert_py_code_to_notebook", "type": "function", "doc": "

Convert python text to a notebook. \n\"''' begin text\" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)\n\"''' # end text\" ends text, and starts a new code block (triple double quotes also allowed)\n\"'''end code'''\" ends code blocks, and starts a new code block (triple double quotes also allowed)

\n\n

:param text: Python text to convert.\n:param use_black: if True use black to re-format Python code\n:return: a notebook

\n", "signature": "(\n text: str,\n *,\n use_black: bool = False\n) -> nbformat.notebooknode.NotebookNode", "funcdef": "def"}, {"fullname": "wvpy.jtools.prepend_code_cell_to_notebook", "modulename": "wvpy.jtools", "qualname": "prepend_code_cell_to_notebook", "type": "function", "doc": "

Prepend a code cell to a Jupyter notebook.

\n\n

:param nb: Jupyter notebook to alter\n:param code_text: Python source code to add\n:return: new notebook

\n", "signature": "(\n nb: nbformat.notebooknode.NotebookNode,\n *,\n code_text: str\n) -> nbformat.notebooknode.NotebookNode", "funcdef": "def"}, {"fullname": "wvpy.jtools.convert_py_file_to_notebook", "modulename": "wvpy.jtools", "qualname": "convert_py_file_to_notebook", "type": "function", "doc": "

Convert python text to a notebook. \n\"''' begin text\" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)\n\"''' # end text\" ends text, and starts a new code block (triple double quotes also allowed)\n\"'''end code'''\" ends code blocks, and starts a new code block (triple double quotes also allowed)

\n\n

:param py_file: Path to python source file.\n:param ipynb_file: Path to notebook result file.\n:param use_black: if True use black to re-format Python code\n:return: nothing

\n", "signature": "(py_file: str, *, ipynb_file: str, use_black: bool = False) -> None", "funcdef": "def"}, {"fullname": "wvpy.jtools.convert_notebook_code_to_py", "modulename": "wvpy.jtools", "qualname": "convert_notebook_code_to_py", "type": "function", "doc": "

Convert ipython notebook inputs to a py code. \n\"''' begin text\" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)\n\"''' # end text\" ends text, and starts a new code block (triple double quotes also allowed)\n\"'''end code'''\" ends code blocks, and starts a new code block (triple double quotes also allowed)

\n\n

:param nb: notebook\n:param use_black: if True use black to re-format Python code\n:return: Python source code

\n", "signature": "(\n nb: nbformat.notebooknode.NotebookNode,\n *,\n use_black: bool = False\n) -> str", "funcdef": "def"}, {"fullname": "wvpy.jtools.convert_notebook_file_to_py", "modulename": "wvpy.jtools", "qualname": "convert_notebook_file_to_py", "type": "function", "doc": "

Convert ipython notebook inputs to a py file. \n\"''' begin text\" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)\n\"''' # end text\" ends text, and starts a new code block (triple double quotes also allowed)\n\"'''end code'''\" ends code blocks, and starts a new code block (triple double quotes also allowed)

\n\n

:param ipynb_file: Path to notebook input file.\n:param py_file: Path to python result file.\n:param use_black: if True use black to re-format Python code\n:return: nothing

\n", "signature": "(ipynb_file: str, *, py_file: str, use_black: bool = False) -> None", "funcdef": "def"}, {"fullname": "wvpy.jtools.render_as_html", "modulename": "wvpy.jtools", "qualname": "render_as_html", "type": "function", "doc": "

Render a Jupyter notebook in the current directory as HTML.\nExceptions raised in the rendering notebook are allowed to pass trough.

\n\n

:param notebook_file_name: name of source file, must end with .ipynb or .py (or type gotten from file system)\n:param output_suffix: optional name to add to result name\n:param timeout: Maximum time in seconds each notebook cell is allowed to run.\n passed to nbconvert.preprocessors.ExecutePreprocessor.\n:param kernel_name: Jupyter kernel to use. passed to nbconvert.preprocessors.ExecutePreprocessor.\n:param verbose logical, if True print while running \n:param init_code: Python init code for first cell\n:param exclude_input: if True, exclude input cells\n:param prompt_strip_regexp: regexp to strip prompts, only used if exclude_input is True\n:param convert_to_pdf: if True convert HTML to PDF, and delete HTML\n:return: None

\n", "signature": "(\n notebook_file_name: str,\n *,\n output_suffix: Optional[str] = None,\n timeout: int = 60000,\n kernel_name: Optional[str] = None,\n verbose: bool = True,\n init_code: Optional[str] = None,\n exclude_input: bool = False,\n prompt_strip_regexp: Optional[str] = '<\\\\s*div\\\\s+class\\\\s*=\\\\s*\"jp-OutputPrompt[^<>]*>[^<>]*Out[^<>]*<\\\\s*/div\\\\s*>',\n convert_to_pdf: bool = False\n) -> None", "funcdef": "def"}, {"fullname": "wvpy.jtools.JTask", "modulename": "wvpy.jtools", "qualname": "JTask", "type": "class", "doc": "

\n"}, {"fullname": "wvpy.jtools.JTask.__init__", "modulename": "wvpy.jtools", "qualname": "JTask.__init__", "type": "function", "doc": "

\n", "signature": "(\n self,\n sheet_name: str,\n output_suffix: Optional[str] = None,\n exclude_input: bool = True,\n init_code: Optional[str] = None,\n path_prefix: str = ''\n)", "funcdef": "def"}, {"fullname": "wvpy.jtools.job_fn", "modulename": "wvpy.jtools", "qualname": "job_fn", "type": "function", "doc": "

\n", "signature": "(arg: wvpy.jtools.JTask)", "funcdef": "def"}, {"fullname": "wvpy.pysheet", "modulename": "wvpy.pysheet", "type": "module", "doc": "

\n"}, {"fullname": "wvpy.pysheet.pysheet", "modulename": "wvpy.pysheet", "qualname": "pysheet", "type": "function", "doc": "

Convert between .ipynb and .py files.

\n\n

:param infiles: list of file names to process\n:param quiet: if True do the work quietly\n:param delete: if True, delete input\n:param black: if True, use black to re-format Python code cells\n:return: 0 if successful

\n", "signature": "(\n infiles: Iterable[str],\n *,\n quiet: bool = False,\n delete: bool = False,\n black: bool = False\n) -> int", "funcdef": "def"}, {"fullname": "wvpy.render_workbook", "modulename": "wvpy.render_workbook", "type": "module", "doc": "

\n"}, {"fullname": "wvpy.render_workbook.render_workbook", "modulename": "wvpy.render_workbook", "qualname": "render_workbook", "type": "function", "doc": "

Render a list of Jupyter notebooks.

\n\n

:param infiles: list of file names to process\n:param quiet: if true do the work quietly\n:param strip_input: if true strip input cells and cell numbering\n:return: 0 if successful

\n", "signature": "(\n infiles: Iterable[str],\n *,\n quiet: bool = False,\n strip_input: bool = True\n) -> int", "funcdef": "def"}, {"fullname": "wvpy.util", "modulename": "wvpy.util", "type": "module", "doc": "

Utility functions for teaching data science.

\n"}, {"fullname": "wvpy.util.types_in_frame", "modulename": "wvpy.util", "qualname": "types_in_frame", "type": "function", "doc": "

Report what type as seen as values in a Pandas data frame.

\n\n

:param d: Pandas data frame to inspect, not altered.\n:return: dictionary mapping column names to order lists of types found in column.

\n", "signature": "(d: pandas.core.frame.DataFrame) -> Dict[str, List[type]]", "funcdef": "def"}, {"fullname": "wvpy.util.cross_predict_model", "modulename": "wvpy.util", "qualname": "cross_predict_model", "type": "function", "doc": "

train a model y~X using the cross validation plan and return predictions

\n\n

:param fitter: sklearn model we can call .fit() on\n:param X: explanatory variables, pandas DataFrame\n:param y: dependent variable, pandas Series\n:param plan: cross validation plan from mk_cross_plan()\n:return: vector of simulated out of sample predictions

\n", "signature": "(\n fitter,\n X: pandas.core.frame.DataFrame,\n y: pandas.core.series.Series,\n plan: List\n) -> numpy.ndarray", "funcdef": "def"}, {"fullname": "wvpy.util.cross_predict_model_proba", "modulename": "wvpy.util", "qualname": "cross_predict_model_proba", "type": "function", "doc": "

train a model y~X using the cross validation plan and return probability matrix

\n\n

:param fitter: sklearn model we can call .fit() on\n:param X: explanatory variables, pandas DataFrame\n:param y: dependent variable, pandas Series\n:param plan: cross validation plan from mk_cross_plan()\n:return: matrix of simulated out of sample predictions

\n", "signature": "(\n fitter,\n X: pandas.core.frame.DataFrame,\n y: pandas.core.series.Series,\n plan: List\n) -> pandas.core.frame.DataFrame", "funcdef": "def"}, {"fullname": "wvpy.util.mean_deviance", "modulename": "wvpy.util", "qualname": "mean_deviance", "type": "function", "doc": "

compute per-row deviance of predictions versus istrue

\n\n

:param predictions: vector of probability preditions\n:param istrue: vector of True/False outcomes to be predicted\n:param eps: how close to zero or one we clip predictions\n:return: vector of per-row deviances

\n", "signature": "(predictions, istrue, *, eps=1e-06)", "funcdef": "def"}, {"fullname": "wvpy.util.mean_null_deviance", "modulename": "wvpy.util", "qualname": "mean_null_deviance", "type": "function", "doc": "

compute per-row nulll deviance of predictions versus istrue

\n\n

:param istrue: vector of True/False outcomes to be predicted\n:param eps: how close to zero or one we clip predictions\n:return: mean null deviance of using prevalence as the prediction.

\n", "signature": "(istrue, *, eps=1e-06)", "funcdef": "def"}, {"fullname": "wvpy.util.mk_cross_plan", "modulename": "wvpy.util", "qualname": "mk_cross_plan", "type": "function", "doc": "

Randomly split range(n) into k train/test groups such that test groups partition range(n).

\n\n

:param n: integer > 1\n:param k: integer > 1\n:return: list of train/test dictionaries

\n\n

Example:

\n\n

import wvpy.util

\n\n

wvpy.util.mk_cross_plan(10, 3)

\n", "signature": "(n: int, k: int) -> List", "funcdef": "def"}, {"fullname": "wvpy.util.matching_roc_area_curve", "modulename": "wvpy.util", "qualname": "matching_roc_area_curve", "type": "function", "doc": "

Find an ROC curve with a given area with form of y = 1 - (1 - (1 - x) * q) * (1 / q).

\n\n

:param auc: area to match\n:return: dictionary of ideal x, y series matching area

\n", "signature": "(auc: float) -> dict", "funcdef": "def"}, {"fullname": "wvpy.util.plot_roc", "modulename": "wvpy.util", "qualname": "plot_roc", "type": "function", "doc": "

Plot a ROC curve of numeric prediction against boolean istrue.

\n\n

:param prediction: column of numeric predictions\n:param istrue: column of items to predict\n:param title: plot title\n:param truth_target: value to consider target or true.\n:param ideal_line_color: if not None, color of ideal line\n:param extra_points: data frame of additional point to annotate graph, columns fpr, tpr, label\n:param show: logical, if True call matplotlib.pyplot.show()\n:return: calculated area under the curve, plot produced by call.

\n\n

Example:

\n\n

import pandas\nimport wvpy.util

\n\n

d = pandas.DataFrame({\n 'x': [1, 2, 3, 4, 5],\n 'y': [False, False, True, True, False]\n})

\n\n

wvpy.util.plot_roc(\n prediction=d['x'],\n istrue=d['y'],\n ideal_line_color='lightgrey'\n)

\n\n

wvpy.util.plot_roc(\n prediction=d['x'],\n istrue=d['y'],\n ideal_line_color='lightgrey',\n extra_points=pandas.DataFrame({\n 'tpr': [0, 1],\n 'fpr': [0, 1],\n 'label': ['AAA', 'BBB']\n })\n)

\n", "signature": "(\n prediction,\n istrue,\n title='Receiver operating characteristic plot',\n *,\n truth_target=True,\n ideal_line_color=None,\n extra_points=None,\n show=True\n)", "funcdef": "def"}, {"fullname": "wvpy.util.dual_density_plot", "modulename": "wvpy.util", "qualname": "dual_density_plot", "type": "function", "doc": "

Plot a dual density plot of numeric prediction probs against boolean istrue.

\n\n

:param probs: vector of numeric predictions.\n:param istrue: truth vector\n:param title: title of plot\n:param truth_target: value considerd true\n:param positive_label=label for positive class\n:param negative_label=label for negative class\n:param ylabel=y axis label\n:param xlabel=x axis label\n:param show: logical, if True call matplotlib.pyplot.show()\n:return: None

\n\n

Example:

\n\n

import pandas\nimport wvpy.util

\n\n

d = pandas.DataFrame({\n 'x': [1, 2, 3, 4, 5],\n 'y': [False, False, True, True, False]\n})

\n\n

wvpy.util.dual_density_plot(\n probs=d['x'],\n istrue=d['y'],\n)

\n", "signature": "(\n probs,\n istrue,\n title='Double density plot',\n *,\n truth_target=True,\n positive_label='positive examples',\n negative_label='negative examples',\n ylabel='density of examples',\n xlabel='model score',\n show=True\n)", "funcdef": "def"}, {"fullname": "wvpy.util.dual_hist_plot", "modulename": "wvpy.util", "qualname": "dual_hist_plot", "type": "function", "doc": "

plot a dual histogram plot of numeric prediction probs against boolean istrue

\n\n

:param probs: vector of numeric predictions.\n:param istrue: truth vector\n:param title: title of plot\n:param truth_target: value to consider in class\n:param show: logical, if True call matplotlib.pyplot.show()\n:return: None

\n\n

Example:

\n\n

import pandas\nimport wvpy.util

\n\n

d = pandas.DataFrame({\n 'x': [.1, .2, .3, .4, .5],\n 'y': [False, False, True, True, False]\n})

\n\n

wvpy.util.dual_hist_plot(\n probs=d['x'],\n istrue=d['y'],\n)

\n", "signature": "(\n probs,\n istrue,\n title='Dual Histogram Plot',\n *,\n truth_target=True,\n show=True\n)", "funcdef": "def"}, {"fullname": "wvpy.util.dual_density_plot_proba1", "modulename": "wvpy.util", "qualname": "dual_density_plot_proba1", "type": "function", "doc": "

Plot a dual density plot of numeric prediction probs[:,1] against boolean istrue.

\n\n

:param probs: matrix of numeric predictions (as returned from predict_proba())\n:param istrue: truth target\n:param title: title of plot\n:param truth_target: value considered true\n:param positive_label=label for positive class\n:param negative_label=label for negative class\n:param ylabel=y axis label\n:param xlabel=x axis label\n:param show: logical, if True call matplotlib.pyplot.show()\n:return: None

\n\n

Example:

\n\n

d = pandas.DataFrame({\n 'x': [.1, .2, .3, .4, .5],\n 'y': [False, False, True, True, False]\n})\nd['x0'] = 1 - d['x']\npmat = numpy.asarray(d.loc[:, ['x0', 'x']])

\n\n

wvpy.util.dual_density_plot_proba1(\n probs=pmat,\n istrue=d['y'],\n)

\n", "signature": "(\n probs,\n istrue,\n title='Double density plot',\n *,\n truth_target=True,\n positive_label='positive examples',\n negative_label='negative examples',\n ylabel='density of examples',\n xlabel='model score',\n show=True\n)", "funcdef": "def"}, {"fullname": "wvpy.util.dual_hist_plot_proba1", "modulename": "wvpy.util", "qualname": "dual_hist_plot_proba1", "type": "function", "doc": "

plot a dual histogram plot of numeric prediction probs[:,1] against boolean istrue

\n\n

:param probs: vector of probability predictions\n:param istrue: vector of ground truth to condition on\n:param show: logical, if True call matplotlib.pyplot.show()\n:return: None

\n\n

Example:

\n\n

d = pandas.DataFrame({\n 'x': [.1, .2, .3, .4, .5],\n 'y': [False, False, True, True, False]\n})\nd['x0'] = 1 - d['x']\npmat = numpy.asarray(d.loc[:, ['x0', 'x']])

\n\n

wvpy.util.dual_hist_plot_proba1(\n probs=pmat,\n istrue=d['y'],\n)

\n", "signature": "(probs, istrue, *, show=True)", "funcdef": "def"}, {"fullname": "wvpy.util.gain_curve_plot", "modulename": "wvpy.util", "qualname": "gain_curve_plot", "type": "function", "doc": "

plot cumulative outcome as a function of prediction order (descending)

\n\n

:param prediction: vector of numeric predictions\n:param outcome: vector of actual values\n:param title: plot title\n:param show: logical, if True call matplotlib.pyplot.show()\n:return: None

\n\n

Example:

\n\n

d = pandas.DataFrame({\n 'x': [.1, .2, .3, .4, .5],\n 'y': [0, 0, 1, 1, 0]\n})

\n\n

wvpy.util.gain_curve_plot(\n prediction=d['x'],\n outcome=d['y'],\n)

\n", "signature": "(prediction, outcome, title='Gain curve plot', *, show=True)", "funcdef": "def"}, {"fullname": "wvpy.util.lift_curve_plot", "modulename": "wvpy.util", "qualname": "lift_curve_plot", "type": "function", "doc": "

plot lift as a function of prediction order (descending)

\n\n

:param prediction: vector of numeric predictions\n:param outcome: vector of actual values\n:param title: plot title\n:param show: logical, if True call matplotlib.pyplot.show()\n:return: None

\n\n

Example:

\n\n

d = pandas.DataFrame({\n 'x': [.1, .2, .3, .4, .5],\n 'y': [0, 0, 1, 1, 0]\n})

\n\n

wvpy.util.lift_curve_plot(\n prediction=d['x'],\n outcome=d['y'],\n)

\n", "signature": "(prediction, outcome, title='Lift curve plot', *, show=True)", "funcdef": "def"}, {"fullname": "wvpy.util.search_grid", "modulename": "wvpy.util", "qualname": "search_grid", "type": "function", "doc": "

build a cross product of all named dictionary entries

\n\n

:param inp: dictionary of value lists\n:return: list of value dictionaries

\n", "signature": "(inp: dict) -> List", "funcdef": "def"}, {"fullname": "wvpy.util.grid_to_df", "modulename": "wvpy.util", "qualname": "grid_to_df", "type": "function", "doc": "

convert a search_grid list of maps to a pandas data frame

\n\n

:param grid: list of combos\n:return: data frame with one row per combo

\n", "signature": "(grid: List) -> pandas.core.frame.DataFrame", "funcdef": "def"}, {"fullname": "wvpy.util.eval_fn_per_row", "modulename": "wvpy.util", "qualname": "eval_fn_per_row", "type": "function", "doc": "

evaluate f(row-as-map, x2) for rows in df

\n\n

:param f: function to evaluate\n:param x2: extra argument\n:param df: data frame to take rows from\n:return: list of evaluations

\n", "signature": "(f, x2, df: pandas.core.frame.DataFrame) -> List", "funcdef": "def"}, {"fullname": "wvpy.util.perm_score_vars", "modulename": "wvpy.util", "qualname": "perm_score_vars", "type": "function", "doc": "

evaluate model~istrue on d permuting each of the modelvars and return variable importances

\n\n

:param d: data source (copied)\n:param istrue: y-target\n:param model: model to evaluate\n:param modelvars: names of variables to permute\n:param k: number of permutations\n:return: score data frame

\n", "signature": "(\n d: pandas.core.frame.DataFrame,\n istrue,\n model,\n modelvars: List[str],\n k=5\n)", "funcdef": "def"}, {"fullname": "wvpy.util.threshold_statistics", "modulename": "wvpy.util", "qualname": "threshold_statistics", "type": "function", "doc": "

Compute a number of threshold statistics of how well model predictions match a truth target.

\n\n

:param d: pandas.DataFrame to take values from\n:param model_predictions: name of predictions column\n:param yvalues: name of truth values column\n:param y_target: value considered to be true\n:return: summary statistic frame, include before and after pseudo-observations

\n\n

Example:

\n\n

import pandas\nimport wvpy.util

\n\n

d = pandas.DataFrame({\n 'x': [1, 2, 3, 4, 5],\n 'y': [False, False, True, True, False]\n})

\n\n

wvpy.util.threshold_statistics(\n d,\n model_predictions='x',\n yvalues='y',\n)

\n", "signature": "(\n d: pandas.core.frame.DataFrame,\n *,\n model_predictions: str,\n yvalues: str,\n y_target=True\n) -> pandas.core.frame.DataFrame", "funcdef": "def"}, {"fullname": "wvpy.util.threshold_plot", "modulename": "wvpy.util", "qualname": "threshold_plot", "type": "function", "doc": "

Produce multiple facet plot relating the performance of using a threshold greater than or equal to\ndifferent values at predicting a truth target.

\n\n

:param d: pandas.DataFrame to plot\n:param pred_var: name of column of numeric predictions\n:param truth_var: name of column with reference truth\n:param truth_target: value considered true\n:param threshold_range: x-axis range to plot\n:param plotvars: list of metrics to plot, must come from ['threshold', 'count', 'fraction',\n 'true_positive_rate', 'false_positive_rate', 'true_negative_rate', 'false_negative_rate',\n 'precision', 'recall', 'sensitivity', 'specificity', 'accuracy']\n:param title: title for plot\n:param show: logical, if True call matplotlib.pyplot.show()\n:return: None, plot produced as a side effect

\n\n

Example:

\n\n

import pandas\nimport wvpy.util

\n\n

d = pandas.DataFrame({\n 'x': [1, 2, 3, 4, 5],\n 'y': [False, False, True, True, False]\n})

\n\n

wvpy.util.threshold_plot(\n d,\n pred_var='x',\n truth_var='y',\n plotvars=(\"sensitivity\", \"specificity\"),\n)

\n", "signature": "(\n d: pandas.core.frame.DataFrame,\n pred_var: str,\n truth_var: str,\n truth_target: bool = True,\n threshold_range: Iterable[float] = (-inf, inf),\n plotvars: Iterable[str] = ('precision', 'recall'),\n title: str = 'Measures as a function of threshold',\n *,\n show: bool = True\n) -> None", "funcdef": "def"}, {"fullname": "wvpy.util.fit_onehot_enc", "modulename": "wvpy.util", "qualname": "fit_onehot_enc", "type": "function", "doc": "

Fit a sklearn OneHot Encoder to categorical_var_names columns.\nNote: we suggest preferring vtreat ( https://github.com/WinVector/pyvtreat ) over this example code.

\n\n

:param d: training data\n:param categorical_var_names: list of column names to learn transform from\n:return: encoding bundle dictionary, see apply_onehot_enc() for use.

\n", "signature": "(\n d: pandas.core.frame.DataFrame,\n *,\n categorical_var_names: Iterable[str]\n) -> dict", "funcdef": "def"}, {"fullname": "wvpy.util.apply_onehot_enc", "modulename": "wvpy.util", "qualname": "apply_onehot_enc", "type": "function", "doc": "

Apply a one hot encoding bundle to a data frame.

\n\n

:param d: input data frame\n:param encoder_bundle: transform specification, built by fit_onehot_enc()\n:return: transformed data frame

\n", "signature": "(\n d: pandas.core.frame.DataFrame,\n *,\n encoder_bundle: dict\n) -> pandas.core.frame.DataFrame", "funcdef": "def"}, {"fullname": "wvpy.util.suppress_stdout_stderr", "modulename": "wvpy.util", "qualname": "suppress_stdout_stderr", "type": "class", "doc": "

A context manager for doing a \"deep suppression\" of stdout and stderr in\nPython, i.e. will suppress all print, even if the print originates in a\ncompiled C/Fortran sub-function.\n This will not suppress raised exceptions, since exceptions are printed\nto stderr just before a script exits, and after the context manager has\nexited (at least, I think that is why it lets exceptions through).

\n"}, {"fullname": "wvpy.util.suppress_stdout_stderr.__init__", "modulename": "wvpy.util", "qualname": "suppress_stdout_stderr.__init__", "type": "function", "doc": "

\n", "signature": "(self)", "funcdef": "def"}]; + /** pdoc search index */const docs = [{"fullname": "wvpy", "modulename": "wvpy", "type": "module", "doc": "

Thishttps://github.com/WinVector/wvpy is a package of example files for teaching data science.

\n"}, {"fullname": "wvpy.jtools", "modulename": "wvpy.jtools", "type": "module", "doc": "

\n"}, {"fullname": "wvpy.jtools.pretty_format_python", "modulename": "wvpy.jtools", "qualname": "pretty_format_python", "type": "function", "doc": "

Format Python code, using black.

\n\n
Parameters
\n\n
    \n
  • python_txt: Python code
  • \n
  • black_mode: options for black
  • \n
\n\n
Returns
\n\n
\n

formatted Python code

\n
\n", "signature": "(python_txt: str, *, black_mode=None) -> str", "funcdef": "def"}, {"fullname": "wvpy.jtools.convert_py_code_to_notebook", "modulename": "wvpy.jtools", "qualname": "convert_py_code_to_notebook", "type": "function", "doc": "

Convert python text to a notebook. \n\"''' begin text\" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)\n\"''' # end text\" ends text, and starts a new code block (triple double quotes also allowed)\n\"'''end code'''\" ends code blocks, and starts a new code block (triple double quotes also allowed)

\n\n
Parameters
\n\n
    \n
  • text: Python text to convert.
  • \n
  • use_black: if True use black to re-format Python code
  • \n
\n\n
Returns
\n\n
\n

a notebook

\n
\n", "signature": "(\n text: str,\n *,\n use_black: bool = False\n) -> nbformat.notebooknode.NotebookNode", "funcdef": "def"}, {"fullname": "wvpy.jtools.prepend_code_cell_to_notebook", "modulename": "wvpy.jtools", "qualname": "prepend_code_cell_to_notebook", "type": "function", "doc": "

Prepend a code cell to a Jupyter notebook.

\n\n
Parameters
\n\n
    \n
  • nb: Jupyter notebook to alter
  • \n
  • code_text: Python source code to add
  • \n
\n\n
Returns
\n\n
\n

new notebook

\n
\n", "signature": "(\n nb: nbformat.notebooknode.NotebookNode,\n *,\n code_text: str\n) -> nbformat.notebooknode.NotebookNode", "funcdef": "def"}, {"fullname": "wvpy.jtools.convert_py_file_to_notebook", "modulename": "wvpy.jtools", "qualname": "convert_py_file_to_notebook", "type": "function", "doc": "

Convert python text to a notebook. \n\"''' begin text\" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)\n\"''' # end text\" ends text, and starts a new code block (triple double quotes also allowed)\n\"'''end code'''\" ends code blocks, and starts a new code block (triple double quotes also allowed)

\n\n
Parameters
\n\n
    \n
  • py_file: Path to python source file.
  • \n
  • ipynb_file: Path to notebook result file.
  • \n
  • use_black: if True use black to re-format Python code
  • \n
\n\n
Returns
\n\n
\n

nothing

\n
\n", "signature": "(py_file: str, *, ipynb_file: str, use_black: bool = False) -> None", "funcdef": "def"}, {"fullname": "wvpy.jtools.convert_notebook_code_to_py", "modulename": "wvpy.jtools", "qualname": "convert_notebook_code_to_py", "type": "function", "doc": "

Convert ipython notebook inputs to a py code. \n\"''' begin text\" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)\n\"''' # end text\" ends text, and starts a new code block (triple double quotes also allowed)\n\"'''end code'''\" ends code blocks, and starts a new code block (triple double quotes also allowed)

\n\n
Parameters
\n\n
    \n
  • nb: notebook
  • \n
  • use_black: if True use black to re-format Python code
  • \n
\n\n
Returns
\n\n
\n

Python source code

\n
\n", "signature": "(\n nb: nbformat.notebooknode.NotebookNode,\n *,\n use_black: bool = False\n) -> str", "funcdef": "def"}, {"fullname": "wvpy.jtools.convert_notebook_file_to_py", "modulename": "wvpy.jtools", "qualname": "convert_notebook_file_to_py", "type": "function", "doc": "

Convert ipython notebook inputs to a py file. \n\"''' begin text\" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)\n\"''' # end text\" ends text, and starts a new code block (triple double quotes also allowed)\n\"'''end code'''\" ends code blocks, and starts a new code block (triple double quotes also allowed)

\n\n
Parameters
\n\n
    \n
  • ipynb_file: Path to notebook input file.
  • \n
  • py_file: Path to python result file.
  • \n
  • use_black: if True use black to re-format Python code
  • \n
\n\n
Returns
\n\n
\n

nothing

\n
\n", "signature": "(ipynb_file: str, *, py_file: str, use_black: bool = False) -> None", "funcdef": "def"}, {"fullname": "wvpy.jtools.render_as_html", "modulename": "wvpy.jtools", "qualname": "render_as_html", "type": "function", "doc": "

Render a Jupyter notebook in the current directory as HTML.\nExceptions raised in the rendering notebook are allowed to pass trough.

\n\n
Parameters
\n\n
    \n
  • notebook_file_name: name of source file, must end with .ipynb or .py (or type gotten from file system)
  • \n
  • output_suffix: optional name to add to result name
  • \n
  • timeout: Maximum time in seconds each notebook cell is allowed to run.\n passed to nbconvert.preprocessors.ExecutePreprocessor.
  • \n
  • kernel_name: Jupyter kernel to use. passed to nbconvert.preprocessors.ExecutePreprocessor.\n:param verbose logical, if True print while running
  • \n
  • init_code: Python init code for first cell
  • \n
  • exclude_input: if True, exclude input cells
  • \n
  • prompt_strip_regexp: regexp to strip prompts, only used if exclude_input is True
  • \n
  • convert_to_pdf: if True convert HTML to PDF, and delete HTML
  • \n
\n\n
Returns
\n\n
\n

None

\n
\n", "signature": "(\n notebook_file_name: str,\n *,\n output_suffix: Optional[str] = None,\n timeout: int = 60000,\n kernel_name: Optional[str] = None,\n verbose: bool = True,\n init_code: Optional[str] = None,\n exclude_input: bool = False,\n prompt_strip_regexp: Optional[str] = '<\\\\s*div\\\\s+class\\\\s*=\\\\s*\"jp-OutputPrompt[^<>]*>[^<>]*Out[^<>]*<\\\\s*/div\\\\s*>',\n convert_to_pdf: bool = False\n) -> None", "funcdef": "def"}, {"fullname": "wvpy.jtools.JTask", "modulename": "wvpy.jtools", "qualname": "JTask", "type": "class", "doc": "

\n"}, {"fullname": "wvpy.jtools.JTask.__init__", "modulename": "wvpy.jtools", "qualname": "JTask.__init__", "type": "function", "doc": "

\n", "signature": "(\n self,\n sheet_name: str,\n output_suffix: Optional[str] = None,\n exclude_input: bool = True,\n init_code: Optional[str] = None,\n path_prefix: str = ''\n)", "funcdef": "def"}, {"fullname": "wvpy.jtools.job_fn", "modulename": "wvpy.jtools", "qualname": "job_fn", "type": "function", "doc": "

\n", "signature": "(arg: wvpy.jtools.JTask)", "funcdef": "def"}, {"fullname": "wvpy.pysheet", "modulename": "wvpy.pysheet", "type": "module", "doc": "

\n"}, {"fullname": "wvpy.pysheet.pysheet", "modulename": "wvpy.pysheet", "qualname": "pysheet", "type": "function", "doc": "

Convert between .ipynb and .py files.

\n\n
Parameters
\n\n
    \n
  • infiles: list of file names to process
  • \n
  • quiet: if True do the work quietly
  • \n
  • delete: if True, delete input
  • \n
  • black: if True, use black to re-format Python code cells
  • \n
\n\n
Returns
\n\n
\n

0 if successful

\n
\n", "signature": "(\n infiles: Iterable[str],\n *,\n quiet: bool = False,\n delete: bool = False,\n black: bool = False\n) -> int", "funcdef": "def"}, {"fullname": "wvpy.render_workbook", "modulename": "wvpy.render_workbook", "type": "module", "doc": "

\n"}, {"fullname": "wvpy.render_workbook.render_workbook", "modulename": "wvpy.render_workbook", "qualname": "render_workbook", "type": "function", "doc": "

Render a list of Jupyter notebooks.

\n\n
Parameters
\n\n
    \n
  • infiles: list of file names to process
  • \n
  • quiet: if true do the work quietly
  • \n
  • strip_input: if true strip input cells and cell numbering
  • \n
\n\n
Returns
\n\n
\n

0 if successful

\n
\n", "signature": "(\n infiles: Iterable[str],\n *,\n quiet: bool = False,\n strip_input: bool = True\n) -> int", "funcdef": "def"}]; // mirrored in build-search-index.js (part 1) // Also split on html tags. this is a cheap heuristic, but good enough. diff --git a/pkg/docs/wvpy.html b/pkg/docs/wvpy.html index 446ef9b..779081c 100644 --- a/pkg/docs/wvpy.html +++ b/pkg/docs/wvpy.html @@ -3,14 +3,14 @@ - + wvpy API documentation - - + +
-
+

wvpy

Thishttps://github.com/WinVector/wvpy is a package of example files for teaching data science.

-
- View Source -
__docformat__ = "restructuredtext"
-__version__ = "0.3.6"
+                        
 
-__doc__ = """
-This<https://github.com/WinVector/wvpy> is a package of example files for teaching data science.
-"""
-
+ + +
1__docformat__ = "restructuredtext"
+2__version__ = "0.3.6"
+3
+4__doc__ = """
+5This<https://github.com/WinVector/wvpy> is a package of example files for teaching data science.
+6"""
+
-
diff --git a/pkg/docs/wvpy/jtools.html b/pkg/docs/wvpy/jtools.html index 3700770..0f0dc5e 100644 --- a/pkg/docs/wvpy/jtools.html +++ b/pkg/docs/wvpy/jtools.html @@ -3,14 +3,14 @@ - + wvpy.jtools API documentation - - + +
-
+

wvpy.jtools

-
- View Source -
import re
-import datetime
-import os
-import nbformat
-import nbconvert.preprocessors
-
-from typing import Optional
-
-have_pdf_kit = False
-try:
-    import pdfkit
-    have_pdf_kit = True
-except ModuleNotFoundError:
-    pass
-
-have_black = False
-try:
-    import black
-    have_black = True
-except ModuleNotFoundError:
-    pass
-
-
-# noinspection PyBroadException
-def pretty_format_python(python_txt: str, *, black_mode=None) -> str:
-    """
-    Format Python code, using black.
-
-    :param python_txt: Python code
-    :param black_mode: options for black
-    :return: formatted Python code
-    """
-    assert have_black
-    assert isinstance(python_txt, str)
-    formatted_python = python_txt.strip('\n') + '\n'
-    if len(formatted_python.strip()) > 0:
-        if black_mode is None:
-            black_mode = black.FileMode()
-        try:
-            formatted_python = black.format_str(formatted_python, mode=black_mode)
-            formatted_python = formatted_python.strip('\n') + '\n'
-        except Exception:
-            pass
-    return formatted_python
-
-
-def convert_py_code_to_notebook(
-    text: str,
-    *,
-    use_black:bool = False) -> nbformat.notebooknode.NotebookNode:
-    """
-    Convert python text to a notebook. 
-    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
-    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
-    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
-
-    :param text: Python text to convert.
-    :param use_black: if True use black to re-format Python code
-    :return: a notebook 
-    """
-    # https://stackoverflow.com/a/23729611/6901725
-    # https://nbviewer.org/gist/fperez/9716279
-    assert isinstance(text, str)
-    assert isinstance(use_black, bool)
-    lines = text.splitlines()
-    begin_text_regexp = re.compile(r"^\s*r?((''')|(\"\"\"))\s*begin\s+text\s*$")
-    end_text_regexp = re.compile(r"^\s*r?((''')|(\"\"\"))\s*#\s*end\s+text\s*$")
-    end_code_regexp = re.compile(r"(^\s*r?'''\s*end\s+code\s*'''\s*$)|(^\s*r?\"\"\"\s*end\s+code\s*\"\"\"\s*$)")
-    nbf_v = nbformat.v4
-    nb = nbf_v.new_notebook()
-    # run a little code collecting state machine
-    cells = []
-    collecting_python = []
-    collecting_text = None
-    lines.append(None)  # append an ending sentinel
-    # scan input
-    for line in lines:
-        if line is None:
-            is_end = True
-            text_start = False
-            code_start = False
-            code_end = False
-        else:
-            is_end = False
-            text_start = begin_text_regexp.match(line)
-            code_start = end_text_regexp.match(line)
-            code_end = end_code_regexp.match(line)
-        if is_end or text_start or code_start or code_end:
-            if (collecting_python is not None) and (len(collecting_python) > 0):
-                python_block = ('\n'.join(collecting_python)).strip('\n') + '\n'
-                if len(python_block.strip()) > 0:
-                    if use_black and have_black:
-                        python_block = pretty_format_python(python_block)
-                    cells.append(nbf_v.new_code_cell(python_block))
-            if (collecting_text is not None) and (len(collecting_text) > 0):
-                txt_block = ('\n'.join(collecting_text)).strip('\n') + '\n'
-                if len(txt_block.strip()) > 0:
-                    cells.append(nbf_v.new_markdown_cell(txt_block))
-            collecting_python = None
-            collecting_text = None
-            if not is_end:
-                if text_start:
-                    collecting_text = []
-                else:
-                    collecting_python = []
-        else:
-            if collecting_python is not None:
-                collecting_python.append(line)
-            if collecting_text is not None:
-                collecting_text.append(line)
-    nb['cells'] = cells
-    return nb
-
-
-def prepend_code_cell_to_notebook(
-    nb: nbformat.notebooknode.NotebookNode,
-    *,
-    code_text: str,
-) -> nbformat.notebooknode.NotebookNode:
-    """
-    Prepend a code cell to a Jupyter notebook.
-
-    :param nb: Jupyter notebook to alter
-    :param code_text: Python source code to add
-    :return: new notebook
-    """
-    nbf_v = nbformat.v4
-    nb_out = nbf_v.new_notebook()
-    nb_out['cells'] = [nbf_v.new_code_cell(code_text)] + list(nb.cells)
-    return nb_out
-
-
-def convert_py_file_to_notebook(
-    py_file: str, 
-    *,
-    ipynb_file: str,
-    use_black: bool = False,
-    ) -> None:
-    """
-    Convert python text to a notebook. 
-    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
-    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
-    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
-
-    :param py_file: Path to python source file.
-    :param ipynb_file: Path to notebook result file.
-    :param use_black: if True use black to re-format Python code
-    :return: nothing 
-    """
-    assert isinstance(py_file, str)
-    assert isinstance(ipynb_file, str)
-    assert isinstance(use_black, bool)
-    assert py_file != ipynb_file  # prevent clobber
-    with open(py_file, 'r') as f:
-        text = f.read()
-    nb = convert_py_code_to_notebook(text, use_black=use_black)
-    with open(ipynb_file, 'w') as f:
-        nbformat.write(nb, f)
-
-
-def convert_notebook_code_to_py(
-    nb: nbformat.notebooknode.NotebookNode,
-    *,
-    use_black: bool = False,
-    ) -> str:
-    """
-    Convert ipython notebook inputs to a py code. 
-    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
-    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
-    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
-
-    :param nb: notebook
-    :param use_black: if True use black to re-format Python code
-    :return: Python source code
-    """
-    assert isinstance(use_black, bool)
-    res = []
-    code_needs_end = False
-    for cell in nb.cells:
-        if len(cell.source.strip()) > 0:
-            if cell.cell_type == 'code':
-                if code_needs_end:
-                    res.append('\n"""end code"""\n')
-                py_text = cell.source.strip('\n') + '\n'
-                if use_black and have_black:
-                    py_text = pretty_format_python(py_text)
-                res.append(py_text)
-                code_needs_end = True
-            else:
-                res.append('\n""" begin text')
-                res.append(cell.source.strip('\n'))
-                res.append('"""  # end text\n')
-                code_needs_end = False
-    res_text = '\n' + ('\n'.join(res)).strip('\n') + '\n\n'
-    return res_text
-
-
-def convert_notebook_file_to_py(
-    ipynb_file: str,
-    *,  
-    py_file: str,
-    use_black: bool = False,
-    ) -> None:
-    """
-    Convert ipython notebook inputs to a py file. 
-    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
-    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
-    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
-
-    :param ipynb_file: Path to notebook input file.
-    :param py_file: Path to python result file.
-    :param use_black: if True use black to re-format Python code
-    :return: nothing
-    """
-    assert isinstance(py_file, str)
-    assert isinstance(ipynb_file, str)
-    assert isinstance(use_black, bool)
-    assert py_file != ipynb_file  # prevent clobber
-    with open(ipynb_file, "rb") as f:
-        nb = nbformat.read(f, as_version=4)
-    py_source = convert_notebook_code_to_py(nb, use_black=use_black)
-    with open(py_file, 'w') as f:
-        f.write(py_source)        
-
-
-# https://nbconvert.readthedocs.io/en/latest/execute_api.html
-# https://nbconvert.readthedocs.io/en/latest/nbconvert_library.html
-# HTML element we are trying to delete: 
-#   <div class="jp-OutputPrompt jp-OutputArea-prompt">Out[5]:</div>
-def render_as_html(
-    notebook_file_name: str,
-    *,
-    output_suffix: Optional[str] = None,
-    timeout:int = 60000,
-    kernel_name: Optional[str] = None,
-    verbose: bool = True,
-    init_code: Optional[str] = None,
-    exclude_input: bool = False,
-    prompt_strip_regexp: Optional[str] = r'<\s*div\s+class\s*=\s*"jp-OutputPrompt[^<>]*>[^<>]*Out[^<>]*<\s*/div\s*>',
-    convert_to_pdf: bool = False,
-) -> None:
-    """
-    Render a Jupyter notebook in the current directory as HTML.
-    Exceptions raised in the rendering notebook are allowed to pass trough.
-
-    :param notebook_file_name: name of source file, must end with .ipynb or .py (or type gotten from file system)
-    :param output_suffix: optional name to add to result name
-    :param timeout: Maximum time in seconds each notebook cell is allowed to run.
-                    passed to nbconvert.preprocessors.ExecutePreprocessor.
-    :param kernel_name: Jupyter kernel to use. passed to nbconvert.preprocessors.ExecutePreprocessor.
-    :param verbose logical, if True print while running 
-    :param init_code: Python init code for first cell
-    :param exclude_input: if True, exclude input cells
-    :param prompt_strip_regexp: regexp to strip prompts, only used if exclude_input is True
-    :param convert_to_pdf: if True convert HTML to PDF, and delete HTML
-    :return: None
-    """
-    assert isinstance(notebook_file_name, str)
-    assert isinstance(convert_to_pdf, bool)
-    # deal with no suffix case
-    if (not notebook_file_name.endswith(".ipynb")) and (not notebook_file_name.endswith(".py")):
-        py_name = notebook_file_name + ".py"
-        py_exists = os.path.exists(py_name)
-        ipynb_name = notebook_file_name + ".ipynb"
-        ipynb_exists = os.path.exists(ipynb_name)
-        if (py_exists + ipynb_exists) != 1:
-            raise ValueError('{ipynb_exists}: if file suffix is not specified then exactly one of .py or .ipynb file must exist')
-        if ipynb_exists:
-            notebook_file_name = notebook_file_name + '.ipynb'
-        else:
-            notebook_file_name = notebook_file_name + '.py'
-    # get the input
-    if notebook_file_name.endswith(".ipynb"):
-        suffix = ".ipynb"
-        with open(notebook_file_name, "rb") as f:
-            nb = nbformat.read(f, as_version=4)
-    elif notebook_file_name.endswith(".py"):
-        suffix = ".py"
-        with open(notebook_file_name, 'r') as f:
-            text = f.read()
-        nb = convert_py_code_to_notebook(text)
-    else:
-        raise ValueError('{ipynb_exists}: file must end with .py or .ipynb')
-    # do the conversion
-    if init_code is not None:
-        assert isinstance(init_code, str)
-        nb = prepend_code_cell_to_notebook(
-            nb, 
-            code_text=f'\n\n{init_code}\n\n')
-    html_name = os.path.basename(notebook_file_name)
-    html_name = html_name.removesuffix(suffix)
-    exec_note = ""
-    if output_suffix is not None:
-        assert isinstance(output_suffix, str)
-        html_name = html_name + output_suffix
-        exec_note = f'"{output_suffix}"'
-    html_name = html_name + ".html"
-    try:
-        os.remove(html_name)
-    except FileNotFoundError:
-        pass
-    caught = None
-    try:
-        if verbose:
-            print(
-                f'start render_as_html "{notebook_file_name}" {exec_note} {datetime.datetime.now()}'
-            )
-        if kernel_name is not None:
-            ep = nbconvert.preprocessors.ExecutePreprocessor(
-                timeout=timeout, kernel_name=kernel_name
-            )
-        else:
-            ep = nbconvert.preprocessors.ExecutePreprocessor(timeout=timeout)
-        nb_res, nb_resources = ep.preprocess(nb)
-        html_exporter = nbconvert.HTMLExporter(exclude_input=exclude_input)
-        html_body, html_resources = html_exporter.from_notebook_node(nb_res)
-        if exclude_input and (prompt_strip_regexp is not None):
-            # strip output prompts
-            html_body = re.sub(
-                prompt_strip_regexp,
-                ' ',
-                html_body)
-        if not convert_to_pdf:
-            with open(html_name, "wt") as f:
-                f.write(html_body)
-        else:
-            assert have_pdf_kit
-            pdf_name = html_name.removesuffix('.html') + '.pdf'
-            pdfkit.from_string(html_body, pdf_name)
-    except Exception as e:
-        caught = e
-    if caught is not None:
-        raise caught
-    if verbose:
-        print(f'\tdone render_as_html "{html_name}" {datetime.datetime.now()}')
-
-
-class JTask:
-    def __init__(
-        self,
-        sheet_name: str,
-        output_suffix: Optional[str] = None,
-        exclude_input: bool = True,
-        init_code: Optional[str] = None,
-        path_prefix: str = "",
-    ) -> None:
-        assert isinstance(sheet_name, str)
-        assert isinstance(output_suffix, (str, type(None)))
-        assert isinstance(exclude_input, bool)
-        assert isinstance(init_code, (str, type(None)))
-        assert isinstance(path_prefix, str)
-        self.sheet_name = sheet_name
-        self.output_suffix = output_suffix
-        self.exclude_input = exclude_input
-        self.init_code = init_code
-        self.path_prefix = path_prefix
-
-    def __str__(self) -> str:
-        return f'JTask(sheet_name="{self.sheet_name}", output_suffix="{self.output_suffix}", exclude_input="{self.exclude_input}", init_code="""{self.init_code}""", path_prefix="{self.path_prefix}")'
-
-    def __repr__(self) -> str:
-        return self.__str__()
-
-
-def job_fn(arg: JTask):
-    assert isinstance(arg, JTask)
-    # render notebook
-    try:
-        render_as_html(
-            arg.path_prefix + arg.sheet_name,
-            exclude_input=arg.exclude_input,
-            output_suffix=arg.output_suffix,
-            init_code=arg.init_code,
-        )
-    except Exception as e:
-        print(f"{arg} caught {e}")
-
- -
+ + + + +
  1import re
+  2import datetime
+  3import os
+  4import nbformat
+  5import nbconvert.preprocessors
+  6
+  7from typing import Optional
+  8
+  9have_pdf_kit = False
+ 10try:
+ 11    import pdfkit
+ 12    have_pdf_kit = True
+ 13except ModuleNotFoundError:
+ 14    pass
+ 15
+ 16have_black = False
+ 17try:
+ 18    import black
+ 19    have_black = True
+ 20except ModuleNotFoundError:
+ 21    pass
+ 22
+ 23
+ 24# noinspection PyBroadException
+ 25def pretty_format_python(python_txt: str, *, black_mode=None) -> str:
+ 26    """
+ 27    Format Python code, using black.
+ 28
+ 29    :param python_txt: Python code
+ 30    :param black_mode: options for black
+ 31    :return: formatted Python code
+ 32    """
+ 33    assert have_black
+ 34    assert isinstance(python_txt, str)
+ 35    formatted_python = python_txt.strip('\n') + '\n'
+ 36    if len(formatted_python.strip()) > 0:
+ 37        if black_mode is None:
+ 38            black_mode = black.FileMode()
+ 39        try:
+ 40            formatted_python = black.format_str(formatted_python, mode=black_mode)
+ 41            formatted_python = formatted_python.strip('\n') + '\n'
+ 42        except Exception:
+ 43            pass
+ 44    return formatted_python
+ 45
+ 46
+ 47def convert_py_code_to_notebook(
+ 48    text: str,
+ 49    *,
+ 50    use_black:bool = False) -> nbformat.notebooknode.NotebookNode:
+ 51    """
+ 52    Convert python text to a notebook. 
+ 53    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
+ 54    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
+ 55    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
+ 56
+ 57    :param text: Python text to convert.
+ 58    :param use_black: if True use black to re-format Python code
+ 59    :return: a notebook 
+ 60    """
+ 61    # https://stackoverflow.com/a/23729611/6901725
+ 62    # https://nbviewer.org/gist/fperez/9716279
+ 63    assert isinstance(text, str)
+ 64    assert isinstance(use_black, bool)
+ 65    lines = text.splitlines()
+ 66    begin_text_regexp = re.compile(r"^\s*r?((''')|(\"\"\"))\s*begin\s+text\s*$")
+ 67    end_text_regexp = re.compile(r"^\s*r?((''')|(\"\"\"))\s*#\s*end\s+text\s*$")
+ 68    end_code_regexp = re.compile(r"(^\s*r?'''\s*end\s+code\s*'''\s*$)|(^\s*r?\"\"\"\s*end\s+code\s*\"\"\"\s*$)")
+ 69    nbf_v = nbformat.v4
+ 70    nb = nbf_v.new_notebook()
+ 71    # run a little code collecting state machine
+ 72    cells = []
+ 73    collecting_python = []
+ 74    collecting_text = None
+ 75    lines.append(None)  # append an ending sentinel
+ 76    # scan input
+ 77    for line in lines:
+ 78        if line is None:
+ 79            is_end = True
+ 80            text_start = False
+ 81            code_start = False
+ 82            code_end = False
+ 83        else:
+ 84            is_end = False
+ 85            text_start = begin_text_regexp.match(line)
+ 86            code_start = end_text_regexp.match(line)
+ 87            code_end = end_code_regexp.match(line)
+ 88        if is_end or text_start or code_start or code_end:
+ 89            if (collecting_python is not None) and (len(collecting_python) > 0):
+ 90                python_block = ('\n'.join(collecting_python)).strip('\n') + '\n'
+ 91                if len(python_block.strip()) > 0:
+ 92                    if use_black and have_black:
+ 93                        python_block = pretty_format_python(python_block)
+ 94                    cells.append(nbf_v.new_code_cell(python_block))
+ 95            if (collecting_text is not None) and (len(collecting_text) > 0):
+ 96                txt_block = ('\n'.join(collecting_text)).strip('\n') + '\n'
+ 97                if len(txt_block.strip()) > 0:
+ 98                    cells.append(nbf_v.new_markdown_cell(txt_block))
+ 99            collecting_python = None
+100            collecting_text = None
+101            if not is_end:
+102                if text_start:
+103                    collecting_text = []
+104                else:
+105                    collecting_python = []
+106        else:
+107            if collecting_python is not None:
+108                collecting_python.append(line)
+109            if collecting_text is not None:
+110                collecting_text.append(line)
+111    nb['cells'] = cells
+112    return nb
+113
+114
+115def prepend_code_cell_to_notebook(
+116    nb: nbformat.notebooknode.NotebookNode,
+117    *,
+118    code_text: str,
+119) -> nbformat.notebooknode.NotebookNode:
+120    """
+121    Prepend a code cell to a Jupyter notebook.
+122
+123    :param nb: Jupyter notebook to alter
+124    :param code_text: Python source code to add
+125    :return: new notebook
+126    """
+127    nbf_v = nbformat.v4
+128    nb_out = nbf_v.new_notebook()
+129    nb_out['cells'] = [nbf_v.new_code_cell(code_text)] + list(nb.cells)
+130    return nb_out
+131
+132
+133def convert_py_file_to_notebook(
+134    py_file: str, 
+135    *,
+136    ipynb_file: str,
+137    use_black: bool = False,
+138    ) -> None:
+139    """
+140    Convert python text to a notebook. 
+141    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
+142    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
+143    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
+144
+145    :param py_file: Path to python source file.
+146    :param ipynb_file: Path to notebook result file.
+147    :param use_black: if True use black to re-format Python code
+148    :return: nothing 
+149    """
+150    assert isinstance(py_file, str)
+151    assert isinstance(ipynb_file, str)
+152    assert isinstance(use_black, bool)
+153    assert py_file != ipynb_file  # prevent clobber
+154    with open(py_file, 'r') as f:
+155        text = f.read()
+156    nb = convert_py_code_to_notebook(text, use_black=use_black)
+157    with open(ipynb_file, 'w') as f:
+158        nbformat.write(nb, f)
+159
+160
+161def convert_notebook_code_to_py(
+162    nb: nbformat.notebooknode.NotebookNode,
+163    *,
+164    use_black: bool = False,
+165    ) -> str:
+166    """
+167    Convert ipython notebook inputs to a py code. 
+168    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
+169    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
+170    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
+171
+172    :param nb: notebook
+173    :param use_black: if True use black to re-format Python code
+174    :return: Python source code
+175    """
+176    assert isinstance(use_black, bool)
+177    res = []
+178    code_needs_end = False
+179    for cell in nb.cells:
+180        if len(cell.source.strip()) > 0:
+181            if cell.cell_type == 'code':
+182                if code_needs_end:
+183                    res.append('\n"""end code"""\n')
+184                py_text = cell.source.strip('\n') + '\n'
+185                if use_black and have_black:
+186                    py_text = pretty_format_python(py_text)
+187                res.append(py_text)
+188                code_needs_end = True
+189            else:
+190                res.append('\n""" begin text')
+191                res.append(cell.source.strip('\n'))
+192                res.append('"""  # end text\n')
+193                code_needs_end = False
+194    res_text = '\n' + ('\n'.join(res)).strip('\n') + '\n\n'
+195    return res_text
+196
+197
+198def convert_notebook_file_to_py(
+199    ipynb_file: str,
+200    *,  
+201    py_file: str,
+202    use_black: bool = False,
+203    ) -> None:
+204    """
+205    Convert ipython notebook inputs to a py file. 
+206    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
+207    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
+208    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
+209
+210    :param ipynb_file: Path to notebook input file.
+211    :param py_file: Path to python result file.
+212    :param use_black: if True use black to re-format Python code
+213    :return: nothing
+214    """
+215    assert isinstance(py_file, str)
+216    assert isinstance(ipynb_file, str)
+217    assert isinstance(use_black, bool)
+218    assert py_file != ipynb_file  # prevent clobber
+219    with open(ipynb_file, "rb") as f:
+220        nb = nbformat.read(f, as_version=4)
+221    py_source = convert_notebook_code_to_py(nb, use_black=use_black)
+222    with open(py_file, 'w') as f:
+223        f.write(py_source)        
+224
+225
+226# https://nbconvert.readthedocs.io/en/latest/execute_api.html
+227# https://nbconvert.readthedocs.io/en/latest/nbconvert_library.html
+228# HTML element we are trying to delete: 
+229#   <div class="jp-OutputPrompt jp-OutputArea-prompt">Out[5]:</div>
+230def render_as_html(
+231    notebook_file_name: str,
+232    *,
+233    output_suffix: Optional[str] = None,
+234    timeout:int = 60000,
+235    kernel_name: Optional[str] = None,
+236    verbose: bool = True,
+237    init_code: Optional[str] = None,
+238    exclude_input: bool = False,
+239    prompt_strip_regexp: Optional[str] = r'<\s*div\s+class\s*=\s*"jp-OutputPrompt[^<>]*>[^<>]*Out[^<>]*<\s*/div\s*>',
+240    convert_to_pdf: bool = False,
+241) -> None:
+242    """
+243    Render a Jupyter notebook in the current directory as HTML.
+244    Exceptions raised in the rendering notebook are allowed to pass trough.
+245
+246    :param notebook_file_name: name of source file, must end with .ipynb or .py (or type gotten from file system)
+247    :param output_suffix: optional name to add to result name
+248    :param timeout: Maximum time in seconds each notebook cell is allowed to run.
+249                    passed to nbconvert.preprocessors.ExecutePreprocessor.
+250    :param kernel_name: Jupyter kernel to use. passed to nbconvert.preprocessors.ExecutePreprocessor.
+251    :param verbose logical, if True print while running 
+252    :param init_code: Python init code for first cell
+253    :param exclude_input: if True, exclude input cells
+254    :param prompt_strip_regexp: regexp to strip prompts, only used if exclude_input is True
+255    :param convert_to_pdf: if True convert HTML to PDF, and delete HTML
+256    :return: None
+257    """
+258    assert isinstance(notebook_file_name, str)
+259    assert isinstance(convert_to_pdf, bool)
+260    # deal with no suffix case
+261    if (not notebook_file_name.endswith(".ipynb")) and (not notebook_file_name.endswith(".py")):
+262        py_name = notebook_file_name + ".py"
+263        py_exists = os.path.exists(py_name)
+264        ipynb_name = notebook_file_name + ".ipynb"
+265        ipynb_exists = os.path.exists(ipynb_name)
+266        if (py_exists + ipynb_exists) != 1:
+267            raise ValueError('{ipynb_exists}: if file suffix is not specified then exactly one of .py or .ipynb file must exist')
+268        if ipynb_exists:
+269            notebook_file_name = notebook_file_name + '.ipynb'
+270        else:
+271            notebook_file_name = notebook_file_name + '.py'
+272    # get the input
+273    if notebook_file_name.endswith(".ipynb"):
+274        suffix = ".ipynb"
+275        with open(notebook_file_name, "rb") as f:
+276            nb = nbformat.read(f, as_version=4)
+277    elif notebook_file_name.endswith(".py"):
+278        suffix = ".py"
+279        with open(notebook_file_name, 'r') as f:
+280            text = f.read()
+281        nb = convert_py_code_to_notebook(text)
+282    else:
+283        raise ValueError('{ipynb_exists}: file must end with .py or .ipynb')
+284    # do the conversion
+285    if init_code is not None:
+286        assert isinstance(init_code, str)
+287        nb = prepend_code_cell_to_notebook(
+288            nb, 
+289            code_text=f'\n\n{init_code}\n\n')
+290    html_name = os.path.basename(notebook_file_name)
+291    html_name = html_name.removesuffix(suffix)
+292    exec_note = ""
+293    if output_suffix is not None:
+294        assert isinstance(output_suffix, str)
+295        html_name = html_name + output_suffix
+296        exec_note = f'"{output_suffix}"'
+297    html_name = html_name + ".html"
+298    try:
+299        os.remove(html_name)
+300    except FileNotFoundError:
+301        pass
+302    caught = None
+303    try:
+304        if verbose:
+305            print(
+306                f'start render_as_html "{notebook_file_name}" {exec_note} {datetime.datetime.now()}'
+307            )
+308        if kernel_name is not None:
+309            ep = nbconvert.preprocessors.ExecutePreprocessor(
+310                timeout=timeout, kernel_name=kernel_name
+311            )
+312        else:
+313            ep = nbconvert.preprocessors.ExecutePreprocessor(timeout=timeout)
+314        nb_res, nb_resources = ep.preprocess(nb)
+315        html_exporter = nbconvert.HTMLExporter(exclude_input=exclude_input)
+316        html_body, html_resources = html_exporter.from_notebook_node(nb_res)
+317        if exclude_input and (prompt_strip_regexp is not None):
+318            # strip output prompts
+319            html_body = re.sub(
+320                prompt_strip_regexp,
+321                ' ',
+322                html_body)
+323        if not convert_to_pdf:
+324            with open(html_name, "wt") as f:
+325                f.write(html_body)
+326        else:
+327            assert have_pdf_kit
+328            pdf_name = html_name.removesuffix('.html') + '.pdf'
+329            pdfkit.from_string(html_body, pdf_name)
+330    except Exception as e:
+331        caught = e
+332    if caught is not None:
+333        raise caught
+334    if verbose:
+335        print(f'\tdone render_as_html "{html_name}" {datetime.datetime.now()}')
+336
+337
+338class JTask:
+339    def __init__(
+340        self,
+341        sheet_name: str,
+342        output_suffix: Optional[str] = None,
+343        exclude_input: bool = True,
+344        init_code: Optional[str] = None,
+345        path_prefix: str = "",
+346    ) -> None:
+347        assert isinstance(sheet_name, str)
+348        assert isinstance(output_suffix, (str, type(None)))
+349        assert isinstance(exclude_input, bool)
+350        assert isinstance(init_code, (str, type(None)))
+351        assert isinstance(path_prefix, str)
+352        self.sheet_name = sheet_name
+353        self.output_suffix = output_suffix
+354        self.exclude_input = exclude_input
+355        self.init_code = init_code
+356        self.path_prefix = path_prefix
+357
+358    def __str__(self) -> str:
+359        return f'JTask(sheet_name="{self.sheet_name}", output_suffix="{self.output_suffix}", exclude_input="{self.exclude_input}", init_code="""{self.init_code}""", path_prefix="{self.path_prefix}")'
+360
+361    def __repr__(self) -> str:
+362        return self.__str__()
+363
+364
+365def job_fn(arg: JTask):
+366    assert isinstance(arg, JTask)
+367    # render notebook
+368    try:
+369        render_as_html(
+370            arg.path_prefix + arg.sheet_name,
+371            exclude_input=arg.exclude_input,
+372            output_suffix=arg.output_suffix,
+373            init_code=arg.init_code,
+374        )
+375    except Exception as e:
+376        print(f"{arg} caught {e}")
+
+
-
#   + +
+ + def + pretty_format_python(python_txt: str, *, black_mode=None) -> str: + + - - def - pretty_format_python(python_txt: str, *, black_mode=None) -> str:
+ +
28def pretty_format_python(python_txt: str, *, black_mode=None) -> str:
+29    """
+30    Format Python code, using black.
+31
+32    :param python_txt: Python code
+33    :param black_mode: options for black
+34    :return: formatted Python code
+35    """
+36    assert have_black
+37    assert isinstance(python_txt, str)
+38    formatted_python = python_txt.strip('\n') + '\n'
+39    if len(formatted_python.strip()) > 0:
+40        if black_mode is None:
+41            black_mode = black.FileMode()
+42        try:
+43            formatted_python = black.format_str(formatted_python, mode=black_mode)
+44            formatted_python = formatted_python.strip('\n') + '\n'
+45        except Exception:
+46            pass
+47    return formatted_python
+
-
- View Source -
def pretty_format_python(python_txt: str, *, black_mode=None) -> str:
-    """
-    Format Python code, using black.
-
-    :param python_txt: Python code
-    :param black_mode: options for black
-    :return: formatted Python code
-    """
-    assert have_black
-    assert isinstance(python_txt, str)
-    formatted_python = python_txt.strip('\n') + '\n'
-    if len(formatted_python.strip()) > 0:
-        if black_mode is None:
-            black_mode = black.FileMode()
-        try:
-            formatted_python = black.format_str(formatted_python, mode=black_mode)
-            formatted_python = formatted_python.strip('\n') + '\n'
-        except Exception:
-            pass
-    return formatted_python
-
- -

Format Python code, using black.

-

:param python_txt: Python code -:param black_mode: options for black -:return: formatted Python code

+
Parameters
+ +
    +
  • python_txt: Python code
  • +
  • black_mode: options for black
  • +
+ +
Returns
+ +
+

formatted Python code

+
-
#   - - - def - convert_py_code_to_notebook( - text: str, - *, - use_black: bool = False -) -> nbformat.notebooknode.NotebookNode: + +
+ + def + convert_py_code_to_notebook( text: str, *, use_black: bool = False) -> nbformat.notebooknode.NotebookNode: + + +
+ +
 50def convert_py_code_to_notebook(
+ 51    text: str,
+ 52    *,
+ 53    use_black:bool = False) -> nbformat.notebooknode.NotebookNode:
+ 54    """
+ 55    Convert python text to a notebook. 
+ 56    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
+ 57    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
+ 58    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
+ 59
+ 60    :param text: Python text to convert.
+ 61    :param use_black: if True use black to re-format Python code
+ 62    :return: a notebook 
+ 63    """
+ 64    # https://stackoverflow.com/a/23729611/6901725
+ 65    # https://nbviewer.org/gist/fperez/9716279
+ 66    assert isinstance(text, str)
+ 67    assert isinstance(use_black, bool)
+ 68    lines = text.splitlines()
+ 69    begin_text_regexp = re.compile(r"^\s*r?((''')|(\"\"\"))\s*begin\s+text\s*$")
+ 70    end_text_regexp = re.compile(r"^\s*r?((''')|(\"\"\"))\s*#\s*end\s+text\s*$")
+ 71    end_code_regexp = re.compile(r"(^\s*r?'''\s*end\s+code\s*'''\s*$)|(^\s*r?\"\"\"\s*end\s+code\s*\"\"\"\s*$)")
+ 72    nbf_v = nbformat.v4
+ 73    nb = nbf_v.new_notebook()
+ 74    # run a little code collecting state machine
+ 75    cells = []
+ 76    collecting_python = []
+ 77    collecting_text = None
+ 78    lines.append(None)  # append an ending sentinel
+ 79    # scan input
+ 80    for line in lines:
+ 81        if line is None:
+ 82            is_end = True
+ 83            text_start = False
+ 84            code_start = False
+ 85            code_end = False
+ 86        else:
+ 87            is_end = False
+ 88            text_start = begin_text_regexp.match(line)
+ 89            code_start = end_text_regexp.match(line)
+ 90            code_end = end_code_regexp.match(line)
+ 91        if is_end or text_start or code_start or code_end:
+ 92            if (collecting_python is not None) and (len(collecting_python) > 0):
+ 93                python_block = ('\n'.join(collecting_python)).strip('\n') + '\n'
+ 94                if len(python_block.strip()) > 0:
+ 95                    if use_black and have_black:
+ 96                        python_block = pretty_format_python(python_block)
+ 97                    cells.append(nbf_v.new_code_cell(python_block))
+ 98            if (collecting_text is not None) and (len(collecting_text) > 0):
+ 99                txt_block = ('\n'.join(collecting_text)).strip('\n') + '\n'
+100                if len(txt_block.strip()) > 0:
+101                    cells.append(nbf_v.new_markdown_cell(txt_block))
+102            collecting_python = None
+103            collecting_text = None
+104            if not is_end:
+105                if text_start:
+106                    collecting_text = []
+107                else:
+108                    collecting_python = []
+109        else:
+110            if collecting_python is not None:
+111                collecting_python.append(line)
+112            if collecting_text is not None:
+113                collecting_text.append(line)
+114    nb['cells'] = cells
+115    return nb
+
-
- View Source -
def convert_py_code_to_notebook(
-    text: str,
-    *,
-    use_black:bool = False) -> nbformat.notebooknode.NotebookNode:
-    """
-    Convert python text to a notebook. 
-    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
-    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
-    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
-
-    :param text: Python text to convert.
-    :param use_black: if True use black to re-format Python code
-    :return: a notebook 
-    """
-    # https://stackoverflow.com/a/23729611/6901725
-    # https://nbviewer.org/gist/fperez/9716279
-    assert isinstance(text, str)
-    assert isinstance(use_black, bool)
-    lines = text.splitlines()
-    begin_text_regexp = re.compile(r"^\s*r?((''')|(\"\"\"))\s*begin\s+text\s*$")
-    end_text_regexp = re.compile(r"^\s*r?((''')|(\"\"\"))\s*#\s*end\s+text\s*$")
-    end_code_regexp = re.compile(r"(^\s*r?'''\s*end\s+code\s*'''\s*$)|(^\s*r?\"\"\"\s*end\s+code\s*\"\"\"\s*$)")
-    nbf_v = nbformat.v4
-    nb = nbf_v.new_notebook()
-    # run a little code collecting state machine
-    cells = []
-    collecting_python = []
-    collecting_text = None
-    lines.append(None)  # append an ending sentinel
-    # scan input
-    for line in lines:
-        if line is None:
-            is_end = True
-            text_start = False
-            code_start = False
-            code_end = False
-        else:
-            is_end = False
-            text_start = begin_text_regexp.match(line)
-            code_start = end_text_regexp.match(line)
-            code_end = end_code_regexp.match(line)
-        if is_end or text_start or code_start or code_end:
-            if (collecting_python is not None) and (len(collecting_python) > 0):
-                python_block = ('\n'.join(collecting_python)).strip('\n') + '\n'
-                if len(python_block.strip()) > 0:
-                    if use_black and have_black:
-                        python_block = pretty_format_python(python_block)
-                    cells.append(nbf_v.new_code_cell(python_block))
-            if (collecting_text is not None) and (len(collecting_text) > 0):
-                txt_block = ('\n'.join(collecting_text)).strip('\n') + '\n'
-                if len(txt_block.strip()) > 0:
-                    cells.append(nbf_v.new_markdown_cell(txt_block))
-            collecting_python = None
-            collecting_text = None
-            if not is_end:
-                if text_start:
-                    collecting_text = []
-                else:
-                    collecting_python = []
-        else:
-            if collecting_python is not None:
-                collecting_python.append(line)
-            if collecting_text is not None:
-                collecting_text.append(line)
-    nb['cells'] = cells
-    return nb
-
- -

Convert python text to a notebook. "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed) "''' # end text" ends text, and starts a new code block (triple double quotes also allowed) "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)

-

:param text: Python text to convert. -:param use_black: if True use black to re-format Python code -:return: a notebook

+
Parameters
+ +
    +
  • text: Python text to convert.
  • +
  • use_black: if True use black to re-format Python code
  • +
+ +
Returns
+ +
+

a notebook

+
-
#   - - - def - prepend_code_cell_to_notebook( - nb: nbformat.notebooknode.NotebookNode, - *, - code_text: str -) -> nbformat.notebooknode.NotebookNode: + +
+ + def + prepend_code_cell_to_notebook( nb: nbformat.notebooknode.NotebookNode, *, code_text: str) -> nbformat.notebooknode.NotebookNode: + + +
+ +
118def prepend_code_cell_to_notebook(
+119    nb: nbformat.notebooknode.NotebookNode,
+120    *,
+121    code_text: str,
+122) -> nbformat.notebooknode.NotebookNode:
+123    """
+124    Prepend a code cell to a Jupyter notebook.
+125
+126    :param nb: Jupyter notebook to alter
+127    :param code_text: Python source code to add
+128    :return: new notebook
+129    """
+130    nbf_v = nbformat.v4
+131    nb_out = nbf_v.new_notebook()
+132    nb_out['cells'] = [nbf_v.new_code_cell(code_text)] + list(nb.cells)
+133    return nb_out
+
-
- View Source -
def prepend_code_cell_to_notebook(
-    nb: nbformat.notebooknode.NotebookNode,
-    *,
-    code_text: str,
-) -> nbformat.notebooknode.NotebookNode:
-    """
-    Prepend a code cell to a Jupyter notebook.
-
-    :param nb: Jupyter notebook to alter
-    :param code_text: Python source code to add
-    :return: new notebook
-    """
-    nbf_v = nbformat.v4
-    nb_out = nbf_v.new_notebook()
-    nb_out['cells'] = [nbf_v.new_code_cell(code_text)] + list(nb.cells)
-    return nb_out
-
- -

Prepend a code cell to a Jupyter notebook.

-

:param nb: Jupyter notebook to alter -:param code_text: Python source code to add -:return: new notebook

+
Parameters
+ +
    +
  • nb: Jupyter notebook to alter
  • +
  • code_text: Python source code to add
  • +
+ +
Returns
+ +
+

new notebook

+
-
#   + +
+ + def + convert_py_file_to_notebook(py_file: str, *, ipynb_file: str, use_black: bool = False) -> None: + + - - def - convert_py_file_to_notebook(py_file: str, *, ipynb_file: str, use_black: bool = False) -> None:
+ +
136def convert_py_file_to_notebook(
+137    py_file: str, 
+138    *,
+139    ipynb_file: str,
+140    use_black: bool = False,
+141    ) -> None:
+142    """
+143    Convert python text to a notebook. 
+144    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
+145    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
+146    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
+147
+148    :param py_file: Path to python source file.
+149    :param ipynb_file: Path to notebook result file.
+150    :param use_black: if True use black to re-format Python code
+151    :return: nothing 
+152    """
+153    assert isinstance(py_file, str)
+154    assert isinstance(ipynb_file, str)
+155    assert isinstance(use_black, bool)
+156    assert py_file != ipynb_file  # prevent clobber
+157    with open(py_file, 'r') as f:
+158        text = f.read()
+159    nb = convert_py_code_to_notebook(text, use_black=use_black)
+160    with open(ipynb_file, 'w') as f:
+161        nbformat.write(nb, f)
+
-
- View Source -
def convert_py_file_to_notebook(
-    py_file: str, 
-    *,
-    ipynb_file: str,
-    use_black: bool = False,
-    ) -> None:
-    """
-    Convert python text to a notebook. 
-    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
-    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
-    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
-
-    :param py_file: Path to python source file.
-    :param ipynb_file: Path to notebook result file.
-    :param use_black: if True use black to re-format Python code
-    :return: nothing 
-    """
-    assert isinstance(py_file, str)
-    assert isinstance(ipynb_file, str)
-    assert isinstance(use_black, bool)
-    assert py_file != ipynb_file  # prevent clobber
-    with open(py_file, 'r') as f:
-        text = f.read()
-    nb = convert_py_code_to_notebook(text, use_black=use_black)
-    with open(ipynb_file, 'w') as f:
-        nbformat.write(nb, f)
-
- -

Convert python text to a notebook. "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed) "''' # end text" ends text, and starts a new code block (triple double quotes also allowed) "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)

-

:param py_file: Path to python source file. -:param ipynb_file: Path to notebook result file. -:param use_black: if True use black to re-format Python code -:return: nothing

+
Parameters
+ +
    +
  • py_file: Path to python source file.
  • +
  • ipynb_file: Path to notebook result file.
  • +
  • use_black: if True use black to re-format Python code
  • +
+ +
Returns
+ +
+

nothing

+
-
#   - - - def - convert_notebook_code_to_py( - nb: nbformat.notebooknode.NotebookNode, - *, - use_black: bool = False -) -> str: + +
+ + def + convert_notebook_code_to_py( nb: nbformat.notebooknode.NotebookNode, *, use_black: bool = False) -> str: + + +
+ +
164def convert_notebook_code_to_py(
+165    nb: nbformat.notebooknode.NotebookNode,
+166    *,
+167    use_black: bool = False,
+168    ) -> str:
+169    """
+170    Convert ipython notebook inputs to a py code. 
+171    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
+172    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
+173    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
+174
+175    :param nb: notebook
+176    :param use_black: if True use black to re-format Python code
+177    :return: Python source code
+178    """
+179    assert isinstance(use_black, bool)
+180    res = []
+181    code_needs_end = False
+182    for cell in nb.cells:
+183        if len(cell.source.strip()) > 0:
+184            if cell.cell_type == 'code':
+185                if code_needs_end:
+186                    res.append('\n"""end code"""\n')
+187                py_text = cell.source.strip('\n') + '\n'
+188                if use_black and have_black:
+189                    py_text = pretty_format_python(py_text)
+190                res.append(py_text)
+191                code_needs_end = True
+192            else:
+193                res.append('\n""" begin text')
+194                res.append(cell.source.strip('\n'))
+195                res.append('"""  # end text\n')
+196                code_needs_end = False
+197    res_text = '\n' + ('\n'.join(res)).strip('\n') + '\n\n'
+198    return res_text
+
-
- View Source -
def convert_notebook_code_to_py(
-    nb: nbformat.notebooknode.NotebookNode,
-    *,
-    use_black: bool = False,
-    ) -> str:
-    """
-    Convert ipython notebook inputs to a py code. 
-    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
-    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
-    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
-
-    :param nb: notebook
-    :param use_black: if True use black to re-format Python code
-    :return: Python source code
-    """
-    assert isinstance(use_black, bool)
-    res = []
-    code_needs_end = False
-    for cell in nb.cells:
-        if len(cell.source.strip()) > 0:
-            if cell.cell_type == 'code':
-                if code_needs_end:
-                    res.append('\n"""end code"""\n')
-                py_text = cell.source.strip('\n') + '\n'
-                if use_black and have_black:
-                    py_text = pretty_format_python(py_text)
-                res.append(py_text)
-                code_needs_end = True
-            else:
-                res.append('\n""" begin text')
-                res.append(cell.source.strip('\n'))
-                res.append('"""  # end text\n')
-                code_needs_end = False
-    res_text = '\n' + ('\n'.join(res)).strip('\n') + '\n\n'
-    return res_text
-
- -

Convert ipython notebook inputs to a py code. "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed) "''' # end text" ends text, and starts a new code block (triple double quotes also allowed) "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)

-

:param nb: notebook -:param use_black: if True use black to re-format Python code -:return: Python source code

+
Parameters
+ +
    +
  • nb: notebook
  • +
  • use_black: if True use black to re-format Python code
  • +
+ +
Returns
+ +
+

Python source code

+
-
#   + +
+ + def + convert_notebook_file_to_py(ipynb_file: str, *, py_file: str, use_black: bool = False) -> None: + + - - def - convert_notebook_file_to_py(ipynb_file: str, *, py_file: str, use_black: bool = False) -> None:
+ +
201def convert_notebook_file_to_py(
+202    ipynb_file: str,
+203    *,  
+204    py_file: str,
+205    use_black: bool = False,
+206    ) -> None:
+207    """
+208    Convert ipython notebook inputs to a py file. 
+209    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
+210    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
+211    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
+212
+213    :param ipynb_file: Path to notebook input file.
+214    :param py_file: Path to python result file.
+215    :param use_black: if True use black to re-format Python code
+216    :return: nothing
+217    """
+218    assert isinstance(py_file, str)
+219    assert isinstance(ipynb_file, str)
+220    assert isinstance(use_black, bool)
+221    assert py_file != ipynb_file  # prevent clobber
+222    with open(ipynb_file, "rb") as f:
+223        nb = nbformat.read(f, as_version=4)
+224    py_source = convert_notebook_code_to_py(nb, use_black=use_black)
+225    with open(py_file, 'w') as f:
+226        f.write(py_source)        
+
-
- View Source -
def convert_notebook_file_to_py(
-    ipynb_file: str,
-    *,  
-    py_file: str,
-    use_black: bool = False,
-    ) -> None:
-    """
-    Convert ipython notebook inputs to a py file. 
-    "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed)
-    "''' # end text" ends text, and starts a new code block (triple double quotes also allowed)
-    "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)
-
-    :param ipynb_file: Path to notebook input file.
-    :param py_file: Path to python result file.
-    :param use_black: if True use black to re-format Python code
-    :return: nothing
-    """
-    assert isinstance(py_file, str)
-    assert isinstance(ipynb_file, str)
-    assert isinstance(use_black, bool)
-    assert py_file != ipynb_file  # prevent clobber
-    with open(ipynb_file, "rb") as f:
-        nb = nbformat.read(f, as_version=4)
-    py_source = convert_notebook_code_to_py(nb, use_black=use_black)
-    with open(py_file, 'w') as f:
-        f.write(py_source)        
-
- -

Convert ipython notebook inputs to a py file. "''' begin text" ends any open blocks, and starts a new markdown block (triple double quotes also allowed) "''' # end text" ends text, and starts a new code block (triple double quotes also allowed) "'''end code'''" ends code blocks, and starts a new code block (triple double quotes also allowed)

-

:param ipynb_file: Path to notebook input file. -:param py_file: Path to python result file. -:param use_black: if True use black to re-format Python code -:return: nothing

+
Parameters
+ +
    +
  • ipynb_file: Path to notebook input file.
  • +
  • py_file: Path to python result file.
  • +
  • use_black: if True use black to re-format Python code
  • +
+ +
Returns
+ +
+

nothing

+
-
#   - - - def - render_as_html( - notebook_file_name: str, - *, - output_suffix: Optional[str] = None, - timeout: int = 60000, - kernel_name: Optional[str] = None, - verbose: bool = True, - init_code: Optional[str] = None, - exclude_input: bool = False, - prompt_strip_regexp: Optional[str] = '<\\s*div\\s+class\\s*=\\s*"jp-OutputPrompt[^<>]*>[^<>]*Out[^<>]*<\\s*/div\\s*>', - convert_to_pdf: bool = False -) -> None: + +
+ + def + render_as_html( notebook_file_name: str, *, output_suffix: Optional[str] = None, timeout: int = 60000, kernel_name: Optional[str] = None, verbose: bool = True, init_code: Optional[str] = None, exclude_input: bool = False, prompt_strip_regexp: Optional[str] = '<\\s*div\\s+class\\s*=\\s*"jp-OutputPrompt[^<>]*>[^<>]*Out[^<>]*<\\s*/div\\s*>', convert_to_pdf: bool = False) -> None: + + +
+ +
233def render_as_html(
+234    notebook_file_name: str,
+235    *,
+236    output_suffix: Optional[str] = None,
+237    timeout:int = 60000,
+238    kernel_name: Optional[str] = None,
+239    verbose: bool = True,
+240    init_code: Optional[str] = None,
+241    exclude_input: bool = False,
+242    prompt_strip_regexp: Optional[str] = r'<\s*div\s+class\s*=\s*"jp-OutputPrompt[^<>]*>[^<>]*Out[^<>]*<\s*/div\s*>',
+243    convert_to_pdf: bool = False,
+244) -> None:
+245    """
+246    Render a Jupyter notebook in the current directory as HTML.
+247    Exceptions raised in the rendering notebook are allowed to pass trough.
+248
+249    :param notebook_file_name: name of source file, must end with .ipynb or .py (or type gotten from file system)
+250    :param output_suffix: optional name to add to result name
+251    :param timeout: Maximum time in seconds each notebook cell is allowed to run.
+252                    passed to nbconvert.preprocessors.ExecutePreprocessor.
+253    :param kernel_name: Jupyter kernel to use. passed to nbconvert.preprocessors.ExecutePreprocessor.
+254    :param verbose logical, if True print while running 
+255    :param init_code: Python init code for first cell
+256    :param exclude_input: if True, exclude input cells
+257    :param prompt_strip_regexp: regexp to strip prompts, only used if exclude_input is True
+258    :param convert_to_pdf: if True convert HTML to PDF, and delete HTML
+259    :return: None
+260    """
+261    assert isinstance(notebook_file_name, str)
+262    assert isinstance(convert_to_pdf, bool)
+263    # deal with no suffix case
+264    if (not notebook_file_name.endswith(".ipynb")) and (not notebook_file_name.endswith(".py")):
+265        py_name = notebook_file_name + ".py"
+266        py_exists = os.path.exists(py_name)
+267        ipynb_name = notebook_file_name + ".ipynb"
+268        ipynb_exists = os.path.exists(ipynb_name)
+269        if (py_exists + ipynb_exists) != 1:
+270            raise ValueError('{ipynb_exists}: if file suffix is not specified then exactly one of .py or .ipynb file must exist')
+271        if ipynb_exists:
+272            notebook_file_name = notebook_file_name + '.ipynb'
+273        else:
+274            notebook_file_name = notebook_file_name + '.py'
+275    # get the input
+276    if notebook_file_name.endswith(".ipynb"):
+277        suffix = ".ipynb"
+278        with open(notebook_file_name, "rb") as f:
+279            nb = nbformat.read(f, as_version=4)
+280    elif notebook_file_name.endswith(".py"):
+281        suffix = ".py"
+282        with open(notebook_file_name, 'r') as f:
+283            text = f.read()
+284        nb = convert_py_code_to_notebook(text)
+285    else:
+286        raise ValueError('{ipynb_exists}: file must end with .py or .ipynb')
+287    # do the conversion
+288    if init_code is not None:
+289        assert isinstance(init_code, str)
+290        nb = prepend_code_cell_to_notebook(
+291            nb, 
+292            code_text=f'\n\n{init_code}\n\n')
+293    html_name = os.path.basename(notebook_file_name)
+294    html_name = html_name.removesuffix(suffix)
+295    exec_note = ""
+296    if output_suffix is not None:
+297        assert isinstance(output_suffix, str)
+298        html_name = html_name + output_suffix
+299        exec_note = f'"{output_suffix}"'
+300    html_name = html_name + ".html"
+301    try:
+302        os.remove(html_name)
+303    except FileNotFoundError:
+304        pass
+305    caught = None
+306    try:
+307        if verbose:
+308            print(
+309                f'start render_as_html "{notebook_file_name}" {exec_note} {datetime.datetime.now()}'
+310            )
+311        if kernel_name is not None:
+312            ep = nbconvert.preprocessors.ExecutePreprocessor(
+313                timeout=timeout, kernel_name=kernel_name
+314            )
+315        else:
+316            ep = nbconvert.preprocessors.ExecutePreprocessor(timeout=timeout)
+317        nb_res, nb_resources = ep.preprocess(nb)
+318        html_exporter = nbconvert.HTMLExporter(exclude_input=exclude_input)
+319        html_body, html_resources = html_exporter.from_notebook_node(nb_res)
+320        if exclude_input and (prompt_strip_regexp is not None):
+321            # strip output prompts
+322            html_body = re.sub(
+323                prompt_strip_regexp,
+324                ' ',
+325                html_body)
+326        if not convert_to_pdf:
+327            with open(html_name, "wt") as f:
+328                f.write(html_body)
+329        else:
+330            assert have_pdf_kit
+331            pdf_name = html_name.removesuffix('.html') + '.pdf'
+332            pdfkit.from_string(html_body, pdf_name)
+333    except Exception as e:
+334        caught = e
+335    if caught is not None:
+336        raise caught
+337    if verbose:
+338        print(f'\tdone render_as_html "{html_name}" {datetime.datetime.now()}')
+
-
- View Source -
def render_as_html(
-    notebook_file_name: str,
-    *,
-    output_suffix: Optional[str] = None,
-    timeout:int = 60000,
-    kernel_name: Optional[str] = None,
-    verbose: bool = True,
-    init_code: Optional[str] = None,
-    exclude_input: bool = False,
-    prompt_strip_regexp: Optional[str] = r'<\s*div\s+class\s*=\s*"jp-OutputPrompt[^<>]*>[^<>]*Out[^<>]*<\s*/div\s*>',
-    convert_to_pdf: bool = False,
-) -> None:
-    """
-    Render a Jupyter notebook in the current directory as HTML.
-    Exceptions raised in the rendering notebook are allowed to pass trough.
-
-    :param notebook_file_name: name of source file, must end with .ipynb or .py (or type gotten from file system)
-    :param output_suffix: optional name to add to result name
-    :param timeout: Maximum time in seconds each notebook cell is allowed to run.
-                    passed to nbconvert.preprocessors.ExecutePreprocessor.
-    :param kernel_name: Jupyter kernel to use. passed to nbconvert.preprocessors.ExecutePreprocessor.
-    :param verbose logical, if True print while running 
-    :param init_code: Python init code for first cell
-    :param exclude_input: if True, exclude input cells
-    :param prompt_strip_regexp: regexp to strip prompts, only used if exclude_input is True
-    :param convert_to_pdf: if True convert HTML to PDF, and delete HTML
-    :return: None
-    """
-    assert isinstance(notebook_file_name, str)
-    assert isinstance(convert_to_pdf, bool)
-    # deal with no suffix case
-    if (not notebook_file_name.endswith(".ipynb")) and (not notebook_file_name.endswith(".py")):
-        py_name = notebook_file_name + ".py"
-        py_exists = os.path.exists(py_name)
-        ipynb_name = notebook_file_name + ".ipynb"
-        ipynb_exists = os.path.exists(ipynb_name)
-        if (py_exists + ipynb_exists) != 1:
-            raise ValueError('{ipynb_exists}: if file suffix is not specified then exactly one of .py or .ipynb file must exist')
-        if ipynb_exists:
-            notebook_file_name = notebook_file_name + '.ipynb'
-        else:
-            notebook_file_name = notebook_file_name + '.py'
-    # get the input
-    if notebook_file_name.endswith(".ipynb"):
-        suffix = ".ipynb"
-        with open(notebook_file_name, "rb") as f:
-            nb = nbformat.read(f, as_version=4)
-    elif notebook_file_name.endswith(".py"):
-        suffix = ".py"
-        with open(notebook_file_name, 'r') as f:
-            text = f.read()
-        nb = convert_py_code_to_notebook(text)
-    else:
-        raise ValueError('{ipynb_exists}: file must end with .py or .ipynb')
-    # do the conversion
-    if init_code is not None:
-        assert isinstance(init_code, str)
-        nb = prepend_code_cell_to_notebook(
-            nb, 
-            code_text=f'\n\n{init_code}\n\n')
-    html_name = os.path.basename(notebook_file_name)
-    html_name = html_name.removesuffix(suffix)
-    exec_note = ""
-    if output_suffix is not None:
-        assert isinstance(output_suffix, str)
-        html_name = html_name + output_suffix
-        exec_note = f'"{output_suffix}"'
-    html_name = html_name + ".html"
-    try:
-        os.remove(html_name)
-    except FileNotFoundError:
-        pass
-    caught = None
-    try:
-        if verbose:
-            print(
-                f'start render_as_html "{notebook_file_name}" {exec_note} {datetime.datetime.now()}'
-            )
-        if kernel_name is not None:
-            ep = nbconvert.preprocessors.ExecutePreprocessor(
-                timeout=timeout, kernel_name=kernel_name
-            )
-        else:
-            ep = nbconvert.preprocessors.ExecutePreprocessor(timeout=timeout)
-        nb_res, nb_resources = ep.preprocess(nb)
-        html_exporter = nbconvert.HTMLExporter(exclude_input=exclude_input)
-        html_body, html_resources = html_exporter.from_notebook_node(nb_res)
-        if exclude_input and (prompt_strip_regexp is not None):
-            # strip output prompts
-            html_body = re.sub(
-                prompt_strip_regexp,
-                ' ',
-                html_body)
-        if not convert_to_pdf:
-            with open(html_name, "wt") as f:
-                f.write(html_body)
-        else:
-            assert have_pdf_kit
-            pdf_name = html_name.removesuffix('.html') + '.pdf'
-            pdfkit.from_string(html_body, pdf_name)
-    except Exception as e:
-        caught = e
-    if caught is not None:
-        raise caught
-    if verbose:
-        print(f'\tdone render_as_html "{html_name}" {datetime.datetime.now()}')
-
- -

Render a Jupyter notebook in the current directory as HTML. Exceptions raised in the rendering notebook are allowed to pass trough.

-

:param notebook_file_name: name of source file, must end with .ipynb or .py (or type gotten from file system) -:param output_suffix: optional name to add to result name -:param timeout: Maximum time in seconds each notebook cell is allowed to run. - passed to nbconvert.preprocessors.ExecutePreprocessor. -:param kernel_name: Jupyter kernel to use. passed to nbconvert.preprocessors.ExecutePreprocessor. -:param verbose logical, if True print while running -:param init_code: Python init code for first cell -:param exclude_input: if True, exclude input cells -:param prompt_strip_regexp: regexp to strip prompts, only used if exclude_input is True -:param convert_to_pdf: if True convert HTML to PDF, and delete HTML -:return: None

+
Parameters
+ +
    +
  • notebook_file_name: name of source file, must end with .ipynb or .py (or type gotten from file system)
  • +
  • output_suffix: optional name to add to result name
  • +
  • timeout: Maximum time in seconds each notebook cell is allowed to run. + passed to nbconvert.preprocessors.ExecutePreprocessor.
  • +
  • kernel_name: Jupyter kernel to use. passed to nbconvert.preprocessors.ExecutePreprocessor. +:param verbose logical, if True print while running
  • +
  • init_code: Python init code for first cell
  • +
  • exclude_input: if True, exclude input cells
  • +
  • prompt_strip_regexp: regexp to strip prompts, only used if exclude_input is True
  • +
  • convert_to_pdf: if True convert HTML to PDF, and delete HTML
  • +
+ +
Returns
+ +
+

None

+
-
- #   + +
+ + class + JTask: + + - - class - JTask:
+ +
341class JTask:
+342    def __init__(
+343        self,
+344        sheet_name: str,
+345        output_suffix: Optional[str] = None,
+346        exclude_input: bool = True,
+347        init_code: Optional[str] = None,
+348        path_prefix: str = "",
+349    ) -> None:
+350        assert isinstance(sheet_name, str)
+351        assert isinstance(output_suffix, (str, type(None)))
+352        assert isinstance(exclude_input, bool)
+353        assert isinstance(init_code, (str, type(None)))
+354        assert isinstance(path_prefix, str)
+355        self.sheet_name = sheet_name
+356        self.output_suffix = output_suffix
+357        self.exclude_input = exclude_input
+358        self.init_code = init_code
+359        self.path_prefix = path_prefix
+360
+361    def __str__(self) -> str:
+362        return f'JTask(sheet_name="{self.sheet_name}", output_suffix="{self.output_suffix}", exclude_input="{self.exclude_input}", init_code="""{self.init_code}""", path_prefix="{self.path_prefix}")'
+363
+364    def __repr__(self) -> str:
+365        return self.__str__()
+
-
- View Source -
class JTask:
-    def __init__(
-        self,
-        sheet_name: str,
-        output_suffix: Optional[str] = None,
-        exclude_input: bool = True,
-        init_code: Optional[str] = None,
-        path_prefix: str = "",
-    ) -> None:
-        assert isinstance(sheet_name, str)
-        assert isinstance(output_suffix, (str, type(None)))
-        assert isinstance(exclude_input, bool)
-        assert isinstance(init_code, (str, type(None)))
-        assert isinstance(path_prefix, str)
-        self.sheet_name = sheet_name
-        self.output_suffix = output_suffix
-        self.exclude_input = exclude_input
-        self.init_code = init_code
-        self.path_prefix = path_prefix
-
-    def __str__(self) -> str:
-        return f'JTask(sheet_name="{self.sheet_name}", output_suffix="{self.output_suffix}", exclude_input="{self.exclude_input}", init_code="""{self.init_code}""", path_prefix="{self.path_prefix}")'
-
-    def __repr__(self) -> str:
-        return self.__str__()
-
- -
-
#   - - - JTask( - sheet_name: str, - output_suffix: Optional[str] = None, - exclude_input: bool = True, - init_code: Optional[str] = None, - path_prefix: str = '' -) + +
+ + JTask( sheet_name: str, output_suffix: Optional[str] = None, exclude_input: bool = True, init_code: Optional[str] = None, path_prefix: str = '') + + +
+ +
342    def __init__(
+343        self,
+344        sheet_name: str,
+345        output_suffix: Optional[str] = None,
+346        exclude_input: bool = True,
+347        init_code: Optional[str] = None,
+348        path_prefix: str = "",
+349    ) -> None:
+350        assert isinstance(sheet_name, str)
+351        assert isinstance(output_suffix, (str, type(None)))
+352        assert isinstance(exclude_input, bool)
+353        assert isinstance(init_code, (str, type(None)))
+354        assert isinstance(path_prefix, str)
+355        self.sheet_name = sheet_name
+356        self.output_suffix = output_suffix
+357        self.exclude_input = exclude_input
+358        self.init_code = init_code
+359        self.path_prefix = path_prefix
+
-
- View Source -
    def __init__(
-        self,
-        sheet_name: str,
-        output_suffix: Optional[str] = None,
-        exclude_input: bool = True,
-        init_code: Optional[str] = None,
-        path_prefix: str = "",
-    ) -> None:
-        assert isinstance(sheet_name, str)
-        assert isinstance(output_suffix, (str, type(None)))
-        assert isinstance(exclude_input, bool)
-        assert isinstance(init_code, (str, type(None)))
-        assert isinstance(path_prefix, str)
-        self.sheet_name = sheet_name
-        self.output_suffix = output_suffix
-        self.exclude_input = exclude_input
-        self.init_code = init_code
-        self.path_prefix = path_prefix
-
- -
-
#   + +
+ + def + job_fn(arg: wvpy.jtools.JTask) + + - - def - job_fn(arg: wvpy.jtools.JTask):
+ +
368def job_fn(arg: JTask):
+369    assert isinstance(arg, JTask)
+370    # render notebook
+371    try:
+372        render_as_html(
+373            arg.path_prefix + arg.sheet_name,
+374            exclude_input=arg.exclude_input,
+375            output_suffix=arg.output_suffix,
+376            init_code=arg.init_code,
+377        )
+378    except Exception as e:
+379        print(f"{arg} caught {e}")
+
-
- View Source -
def job_fn(arg: JTask):
-    assert isinstance(arg, JTask)
-    # render notebook
-    try:
-        render_as_html(
-            arg.path_prefix + arg.sheet_name,
-            exclude_input=arg.exclude_input,
-            output_suffix=arg.output_suffix,
-            init_code=arg.init_code,
-        )
-    except Exception as e:
-        print(f"{arg} caught {e}")
-
- -
diff --git a/pkg/docs/wvpy/pysheet.html b/pkg/docs/wvpy/pysheet.html index 28c3206..fd39317 100644 --- a/pkg/docs/wvpy/pysheet.html +++ b/pkg/docs/wvpy/pysheet.html @@ -3,14 +3,14 @@ - + wvpy.pysheet API documentation - - + +
-
+

wvpy.pysheet

-
- View Source -
# run with:
-#    python -m wvpy.pysheet test.py
-#    python -m wvpy.pysheet test.ipynb
-
-from typing import Iterable
-import argparse
-import os
-import shutil
-import sys
-import traceback
-from wvpy.jtools import convert_py_file_to_notebook, convert_notebook_file_to_py
-
-
-def pysheet(
-    infiles: Iterable[str],
-    *,
-    quiet: bool = False,
-    delete: bool = False,
-    black: bool = False,
-) -> int:
-    """
-    Convert between .ipynb and .py files.
-
-    :param infiles: list of file names to process
-    :param quiet: if True do the work quietly
-    :param delete: if True, delete input
-    :param black: if True, use black to re-format Python code cells
-    :return: 0 if successful 
-    """
-    # some pre-checks
-    assert not isinstance(infiles, str)  # common error
-    infiles = list(infiles)
-    assert len(infiles) > 0
-    assert len(set(infiles)) == len(infiles)
-    assert isinstance(quiet, bool)
-    assert isinstance(delete, bool)
-    assert isinstance(black, bool)
-    # set up the work request
-    base_names_seen = set()
-    input_suffices_seen = set()
-    tasks = []
-    other_suffix = {'.py': '.ipynb', '.ipynb': '.py'}
-    for input_file_name in infiles:
-        assert isinstance(input_file_name, str)
-        assert len(input_file_name) > 0
-        suffix_seen = 'error'  # placeholder/sentinel
-        base_name = input_file_name
-        if input_file_name.endswith('.py'):
-            suffix_seen = '.py'
-            base_name = input_file_name.removesuffix(suffix_seen)
-        elif input_file_name.endswith('.ipynb'):
-            suffix_seen = '.ipynb'
-            base_name = input_file_name.removesuffix(suffix_seen)
-        else:
-            py_exists = os.path.exists(input_file_name + '.py')
-            ipynb_exists = os.path.exists(input_file_name + '.ipynb')
-            if py_exists == ipynb_exists:
-                raise ValueError(f'{base_name}: if no suffix is specified, then exactly one of the .py or ipynb file forms must be present')
-            if py_exists:
-                suffix_seen = '.py'
-            else:
-                suffix_seen = '.ipynb'
-            input_file_name = input_file_name + suffix_seen
-        assert os.path.exists(input_file_name)
-        assert suffix_seen in other_suffix.keys()  # expected suffix
-        assert base_name not in base_names_seen  # each base file name only used once
-        base_names_seen.add(base_name)
-        input_suffices_seen.add(suffix_seen)
-        if len(input_suffices_seen) != 1:    # only one direction of conversion in batch job
-            raise ValueError(f"conversion job may only have one input suffix: {input_suffices_seen}")
-        output_file_name = base_name + other_suffix[suffix_seen]
-        tasks.append((input_file_name, output_file_name))
-    # do the work
-    for input_file_name, output_file_name in tasks:
-        if not quiet:
-            print(f'from "{input_file_name}" to "{output_file_name}"')
-        # back up result target if present
-        if os.path.exists(output_file_name):
-            output_backup_file = f'{output_file_name}~'
-            if not quiet:
-                print(f'   copying previous output target "{output_file_name}" to "{output_backup_file}"')
-            shutil.copy2(output_file_name, output_backup_file)
-        # convert
-        if input_file_name.endswith('.py'):
-            if not quiet:
-                print(f"   converting Python {input_file_name} to Jupyter notebook {output_file_name}")
-            convert_py_file_to_notebook(
-                py_file=input_file_name,
-                ipynb_file=output_file_name,
-                use_black=black,
-            )
-        elif input_file_name.endswith('.ipynb'):
-            if not quiet:
-                print(f'   converting Jupyter notebook "{input_file_name}" to Python "{output_file_name}"')
-            convert_notebook_file_to_py(
-                ipynb_file=input_file_name,
-                py_file=output_file_name,
-                use_black=black,
-            )
-        else:
-            raise ValueError("input file name must end with .py or .ipynb")
-        # do any deletions
-        if delete:
-            input_backup_file = f'{input_file_name}~'
-            if not quiet:
-                print(f"   moving input {input_file_name} to {input_backup_file}")
-            try:
-                os.remove(input_backup_file)
-            except FileNotFoundError:
-                pass
-            os.rename(input_file_name, input_backup_file)
-        if not quiet:
-            print()
-    return 0
-
-
-if __name__ == '__main__':
-    try:
-        parser = argparse.ArgumentParser(description="Convert between .py and .ipynb or back (can have suffix, or guess suffix)")
-        parser.add_argument('--quiet', action='store_true', help='quite operation')
-        parser.add_argument('--delete', action='store_true', help='delete input file')
-        parser.add_argument('--black', action='store_true', help='use black to re-format cells')
-        parser.add_argument(
-            'infile', 
-            metavar='infile', 
-            type=str, 
-            nargs='+',
-            help='name of input file(s)')
-        args = parser.parse_args()
-        # some pre-checks
-        assert len(args.infile) > 0
-        assert len(set(args.infile)) == len(args.infile)
-        assert isinstance(args.quiet, bool)
-        assert isinstance(args.delete, bool)
-        assert isinstance(args.black, bool)
-        ret = pysheet(
-            infiles=args.infile,
-            quiet=args.quiet,
-            delete=args.delete,
-            black=args.black,
-        )
-        sys.exit(ret)
-    except AssertionError:
-        _, _, tb = sys.exc_info()
-        tb_info = traceback.extract_tb(tb)
-        filename, line, func, text = tb_info[-1]
-        print(f'Assertion failed {filename}:{line} (caller {func}) in statement {text}')
-    except Exception as ex:
-        print(ex)
-    sys.exit(-1)
-
- -
+ + + + +
  1# run with:
+  2#    python -m wvpy.pysheet test.py
+  3#    python -m wvpy.pysheet test.ipynb
+  4
+  5from typing import Iterable
+  6import argparse
+  7import os
+  8import shutil
+  9import sys
+ 10import traceback
+ 11from wvpy.jtools import convert_py_file_to_notebook, convert_notebook_file_to_py
+ 12
+ 13
+ 14def pysheet(
+ 15    infiles: Iterable[str],
+ 16    *,
+ 17    quiet: bool = False,
+ 18    delete: bool = False,
+ 19    black: bool = False,
+ 20) -> int:
+ 21    """
+ 22    Convert between .ipynb and .py files.
+ 23
+ 24    :param infiles: list of file names to process
+ 25    :param quiet: if True do the work quietly
+ 26    :param delete: if True, delete input
+ 27    :param black: if True, use black to re-format Python code cells
+ 28    :return: 0 if successful 
+ 29    """
+ 30    # some pre-checks
+ 31    assert not isinstance(infiles, str)  # common error
+ 32    infiles = list(infiles)
+ 33    assert len(infiles) > 0
+ 34    assert len(set(infiles)) == len(infiles)
+ 35    assert isinstance(quiet, bool)
+ 36    assert isinstance(delete, bool)
+ 37    assert isinstance(black, bool)
+ 38    # set up the work request
+ 39    base_names_seen = set()
+ 40    input_suffices_seen = set()
+ 41    tasks = []
+ 42    other_suffix = {'.py': '.ipynb', '.ipynb': '.py'}
+ 43    for input_file_name in infiles:
+ 44        assert isinstance(input_file_name, str)
+ 45        assert len(input_file_name) > 0
+ 46        suffix_seen = 'error'  # placeholder/sentinel
+ 47        base_name = input_file_name
+ 48        if input_file_name.endswith('.py'):
+ 49            suffix_seen = '.py'
+ 50            base_name = input_file_name.removesuffix(suffix_seen)
+ 51        elif input_file_name.endswith('.ipynb'):
+ 52            suffix_seen = '.ipynb'
+ 53            base_name = input_file_name.removesuffix(suffix_seen)
+ 54        else:
+ 55            py_exists = os.path.exists(input_file_name + '.py')
+ 56            ipynb_exists = os.path.exists(input_file_name + '.ipynb')
+ 57            if py_exists == ipynb_exists:
+ 58                raise ValueError(f'{base_name}: if no suffix is specified, then exactly one of the .py or ipynb file forms must be present')
+ 59            if py_exists:
+ 60                suffix_seen = '.py'
+ 61            else:
+ 62                suffix_seen = '.ipynb'
+ 63            input_file_name = input_file_name + suffix_seen
+ 64        assert os.path.exists(input_file_name)
+ 65        assert suffix_seen in other_suffix.keys()  # expected suffix
+ 66        assert base_name not in base_names_seen  # each base file name only used once
+ 67        base_names_seen.add(base_name)
+ 68        input_suffices_seen.add(suffix_seen)
+ 69        if len(input_suffices_seen) != 1:    # only one direction of conversion in batch job
+ 70            raise ValueError(f"conversion job may only have one input suffix: {input_suffices_seen}")
+ 71        output_file_name = base_name + other_suffix[suffix_seen]
+ 72        tasks.append((input_file_name, output_file_name))
+ 73    # do the work
+ 74    for input_file_name, output_file_name in tasks:
+ 75        if not quiet:
+ 76            print(f'from "{input_file_name}" to "{output_file_name}"')
+ 77        # back up result target if present
+ 78        if os.path.exists(output_file_name):
+ 79            output_backup_file = f'{output_file_name}~'
+ 80            if not quiet:
+ 81                print(f'   copying previous output target "{output_file_name}" to "{output_backup_file}"')
+ 82            shutil.copy2(output_file_name, output_backup_file)
+ 83        # convert
+ 84        if input_file_name.endswith('.py'):
+ 85            if not quiet:
+ 86                print(f"   converting Python {input_file_name} to Jupyter notebook {output_file_name}")
+ 87            convert_py_file_to_notebook(
+ 88                py_file=input_file_name,
+ 89                ipynb_file=output_file_name,
+ 90                use_black=black,
+ 91            )
+ 92        elif input_file_name.endswith('.ipynb'):
+ 93            if not quiet:
+ 94                print(f'   converting Jupyter notebook "{input_file_name}" to Python "{output_file_name}"')
+ 95            convert_notebook_file_to_py(
+ 96                ipynb_file=input_file_name,
+ 97                py_file=output_file_name,
+ 98                use_black=black,
+ 99            )
+100        else:
+101            raise ValueError("input file name must end with .py or .ipynb")
+102        # do any deletions
+103        if delete:
+104            input_backup_file = f'{input_file_name}~'
+105            if not quiet:
+106                print(f"   moving input {input_file_name} to {input_backup_file}")
+107            try:
+108                os.remove(input_backup_file)
+109            except FileNotFoundError:
+110                pass
+111            os.rename(input_file_name, input_backup_file)
+112        if not quiet:
+113            print()
+114    return 0
+115
+116
+117if __name__ == '__main__':
+118    try:
+119        parser = argparse.ArgumentParser(description="Convert between .py and .ipynb or back (can have suffix, or guess suffix)")
+120        parser.add_argument('--quiet', action='store_true', help='quite operation')
+121        parser.add_argument('--delete', action='store_true', help='delete input file')
+122        parser.add_argument('--black', action='store_true', help='use black to re-format cells')
+123        parser.add_argument(
+124            'infile', 
+125            metavar='infile', 
+126            type=str, 
+127            nargs='+',
+128            help='name of input file(s)')
+129        args = parser.parse_args()
+130        # some pre-checks
+131        assert len(args.infile) > 0
+132        assert len(set(args.infile)) == len(args.infile)
+133        assert isinstance(args.quiet, bool)
+134        assert isinstance(args.delete, bool)
+135        assert isinstance(args.black, bool)
+136        ret = pysheet(
+137            infiles=args.infile,
+138            quiet=args.quiet,
+139            delete=args.delete,
+140            black=args.black,
+141        )
+142        sys.exit(ret)
+143    except AssertionError:
+144        _, _, tb = sys.exc_info()
+145        tb_info = traceback.extract_tb(tb)
+146        filename, line, func, text = tb_info[-1]
+147        print(f'Assertion failed {filename}:{line} (caller {func}) in statement {text}')
+148    except Exception as ex:
+149        print(ex)
+150    sys.exit(-1)
+
+
-
#   - - - def - pysheet( - infiles: Iterable[str], - *, - quiet: bool = False, - delete: bool = False, - black: bool = False -) -> int: + +
+ + def + pysheet( infiles: Iterable[str], *, quiet: bool = False, delete: bool = False, black: bool = False) -> int: + + +
+ +
 16def pysheet(
+ 17    infiles: Iterable[str],
+ 18    *,
+ 19    quiet: bool = False,
+ 20    delete: bool = False,
+ 21    black: bool = False,
+ 22) -> int:
+ 23    """
+ 24    Convert between .ipynb and .py files.
+ 25
+ 26    :param infiles: list of file names to process
+ 27    :param quiet: if True do the work quietly
+ 28    :param delete: if True, delete input
+ 29    :param black: if True, use black to re-format Python code cells
+ 30    :return: 0 if successful 
+ 31    """
+ 32    # some pre-checks
+ 33    assert not isinstance(infiles, str)  # common error
+ 34    infiles = list(infiles)
+ 35    assert len(infiles) > 0
+ 36    assert len(set(infiles)) == len(infiles)
+ 37    assert isinstance(quiet, bool)
+ 38    assert isinstance(delete, bool)
+ 39    assert isinstance(black, bool)
+ 40    # set up the work request
+ 41    base_names_seen = set()
+ 42    input_suffices_seen = set()
+ 43    tasks = []
+ 44    other_suffix = {'.py': '.ipynb', '.ipynb': '.py'}
+ 45    for input_file_name in infiles:
+ 46        assert isinstance(input_file_name, str)
+ 47        assert len(input_file_name) > 0
+ 48        suffix_seen = 'error'  # placeholder/sentinel
+ 49        base_name = input_file_name
+ 50        if input_file_name.endswith('.py'):
+ 51            suffix_seen = '.py'
+ 52            base_name = input_file_name.removesuffix(suffix_seen)
+ 53        elif input_file_name.endswith('.ipynb'):
+ 54            suffix_seen = '.ipynb'
+ 55            base_name = input_file_name.removesuffix(suffix_seen)
+ 56        else:
+ 57            py_exists = os.path.exists(input_file_name + '.py')
+ 58            ipynb_exists = os.path.exists(input_file_name + '.ipynb')
+ 59            if py_exists == ipynb_exists:
+ 60                raise ValueError(f'{base_name}: if no suffix is specified, then exactly one of the .py or ipynb file forms must be present')
+ 61            if py_exists:
+ 62                suffix_seen = '.py'
+ 63            else:
+ 64                suffix_seen = '.ipynb'
+ 65            input_file_name = input_file_name + suffix_seen
+ 66        assert os.path.exists(input_file_name)
+ 67        assert suffix_seen in other_suffix.keys()  # expected suffix
+ 68        assert base_name not in base_names_seen  # each base file name only used once
+ 69        base_names_seen.add(base_name)
+ 70        input_suffices_seen.add(suffix_seen)
+ 71        if len(input_suffices_seen) != 1:    # only one direction of conversion in batch job
+ 72            raise ValueError(f"conversion job may only have one input suffix: {input_suffices_seen}")
+ 73        output_file_name = base_name + other_suffix[suffix_seen]
+ 74        tasks.append((input_file_name, output_file_name))
+ 75    # do the work
+ 76    for input_file_name, output_file_name in tasks:
+ 77        if not quiet:
+ 78            print(f'from "{input_file_name}" to "{output_file_name}"')
+ 79        # back up result target if present
+ 80        if os.path.exists(output_file_name):
+ 81            output_backup_file = f'{output_file_name}~'
+ 82            if not quiet:
+ 83                print(f'   copying previous output target "{output_file_name}" to "{output_backup_file}"')
+ 84            shutil.copy2(output_file_name, output_backup_file)
+ 85        # convert
+ 86        if input_file_name.endswith('.py'):
+ 87            if not quiet:
+ 88                print(f"   converting Python {input_file_name} to Jupyter notebook {output_file_name}")
+ 89            convert_py_file_to_notebook(
+ 90                py_file=input_file_name,
+ 91                ipynb_file=output_file_name,
+ 92                use_black=black,
+ 93            )
+ 94        elif input_file_name.endswith('.ipynb'):
+ 95            if not quiet:
+ 96                print(f'   converting Jupyter notebook "{input_file_name}" to Python "{output_file_name}"')
+ 97            convert_notebook_file_to_py(
+ 98                ipynb_file=input_file_name,
+ 99                py_file=output_file_name,
+100                use_black=black,
+101            )
+102        else:
+103            raise ValueError("input file name must end with .py or .ipynb")
+104        # do any deletions
+105        if delete:
+106            input_backup_file = f'{input_file_name}~'
+107            if not quiet:
+108                print(f"   moving input {input_file_name} to {input_backup_file}")
+109            try:
+110                os.remove(input_backup_file)
+111            except FileNotFoundError:
+112                pass
+113            os.rename(input_file_name, input_backup_file)
+114        if not quiet:
+115            print()
+116    return 0
+
-
- View Source -
def pysheet(
-    infiles: Iterable[str],
-    *,
-    quiet: bool = False,
-    delete: bool = False,
-    black: bool = False,
-) -> int:
-    """
-    Convert between .ipynb and .py files.
-
-    :param infiles: list of file names to process
-    :param quiet: if True do the work quietly
-    :param delete: if True, delete input
-    :param black: if True, use black to re-format Python code cells
-    :return: 0 if successful 
-    """
-    # some pre-checks
-    assert not isinstance(infiles, str)  # common error
-    infiles = list(infiles)
-    assert len(infiles) > 0
-    assert len(set(infiles)) == len(infiles)
-    assert isinstance(quiet, bool)
-    assert isinstance(delete, bool)
-    assert isinstance(black, bool)
-    # set up the work request
-    base_names_seen = set()
-    input_suffices_seen = set()
-    tasks = []
-    other_suffix = {'.py': '.ipynb', '.ipynb': '.py'}
-    for input_file_name in infiles:
-        assert isinstance(input_file_name, str)
-        assert len(input_file_name) > 0
-        suffix_seen = 'error'  # placeholder/sentinel
-        base_name = input_file_name
-        if input_file_name.endswith('.py'):
-            suffix_seen = '.py'
-            base_name = input_file_name.removesuffix(suffix_seen)
-        elif input_file_name.endswith('.ipynb'):
-            suffix_seen = '.ipynb'
-            base_name = input_file_name.removesuffix(suffix_seen)
-        else:
-            py_exists = os.path.exists(input_file_name + '.py')
-            ipynb_exists = os.path.exists(input_file_name + '.ipynb')
-            if py_exists == ipynb_exists:
-                raise ValueError(f'{base_name}: if no suffix is specified, then exactly one of the .py or ipynb file forms must be present')
-            if py_exists:
-                suffix_seen = '.py'
-            else:
-                suffix_seen = '.ipynb'
-            input_file_name = input_file_name + suffix_seen
-        assert os.path.exists(input_file_name)
-        assert suffix_seen in other_suffix.keys()  # expected suffix
-        assert base_name not in base_names_seen  # each base file name only used once
-        base_names_seen.add(base_name)
-        input_suffices_seen.add(suffix_seen)
-        if len(input_suffices_seen) != 1:    # only one direction of conversion in batch job
-            raise ValueError(f"conversion job may only have one input suffix: {input_suffices_seen}")
-        output_file_name = base_name + other_suffix[suffix_seen]
-        tasks.append((input_file_name, output_file_name))
-    # do the work
-    for input_file_name, output_file_name in tasks:
-        if not quiet:
-            print(f'from "{input_file_name}" to "{output_file_name}"')
-        # back up result target if present
-        if os.path.exists(output_file_name):
-            output_backup_file = f'{output_file_name}~'
-            if not quiet:
-                print(f'   copying previous output target "{output_file_name}" to "{output_backup_file}"')
-            shutil.copy2(output_file_name, output_backup_file)
-        # convert
-        if input_file_name.endswith('.py'):
-            if not quiet:
-                print(f"   converting Python {input_file_name} to Jupyter notebook {output_file_name}")
-            convert_py_file_to_notebook(
-                py_file=input_file_name,
-                ipynb_file=output_file_name,
-                use_black=black,
-            )
-        elif input_file_name.endswith('.ipynb'):
-            if not quiet:
-                print(f'   converting Jupyter notebook "{input_file_name}" to Python "{output_file_name}"')
-            convert_notebook_file_to_py(
-                ipynb_file=input_file_name,
-                py_file=output_file_name,
-                use_black=black,
-            )
-        else:
-            raise ValueError("input file name must end with .py or .ipynb")
-        # do any deletions
-        if delete:
-            input_backup_file = f'{input_file_name}~'
-            if not quiet:
-                print(f"   moving input {input_file_name} to {input_backup_file}")
-            try:
-                os.remove(input_backup_file)
-            except FileNotFoundError:
-                pass
-            os.rename(input_file_name, input_backup_file)
-        if not quiet:
-            print()
-    return 0
-
- -

Convert between .ipynb and .py files.

-

:param infiles: list of file names to process -:param quiet: if True do the work quietly -:param delete: if True, delete input -:param black: if True, use black to re-format Python code cells -:return: 0 if successful

+
Parameters
+ +
    +
  • infiles: list of file names to process
  • +
  • quiet: if True do the work quietly
  • +
  • delete: if True, delete input
  • +
  • black: if True, use black to re-format Python code cells
  • +
+ +
Returns
+ +
+

0 if successful

+
diff --git a/pkg/docs/wvpy/render_workbook.html b/pkg/docs/wvpy/render_workbook.html index feb198e..c737a72 100644 --- a/pkg/docs/wvpy/render_workbook.html +++ b/pkg/docs/wvpy/render_workbook.html @@ -3,14 +3,14 @@ - + wvpy.render_workbook API documentation - - + +
-
+

wvpy.render_workbook

-
- View Source -
# run with:
-#    python -m wvpy.render_workbook test.py
-#    python -m wvpy.render_workbook test.ipynb
-
-from typing import Iterable
-import argparse
-import os
-import sys
-import traceback
-from wvpy.jtools import render_as_html
-
-
-def render_workbook(
-    infiles: Iterable[str],
-    *,
-    quiet: bool = False,
-    strip_input: bool = True,
-) -> int:
-    """
-    Render a list of Jupyter notebooks.
-
-    :param infiles: list of file names to process
-    :param quiet: if true do the work quietly
-    :param strip_input: if true strip input cells and cell numbering
-    :return: 0 if successful 
-    """
-    # checks
-    assert isinstance(quiet, bool)
-    assert isinstance(strip_input, bool)
-    assert len(infiles) > 0
-    assert len(set(infiles)) == len(infiles)
-    assert not isinstance(infiles, str)  # common error
-    infiles = list(infiles)
-    tasks = []
-    for input_file_name in infiles:
-        assert isinstance(input_file_name, str)
-        assert len(input_file_name) > 0
-        assert not input_file_name.endswith('.html')
-        assert not input_file_name.endswith('.pdf')
-        if not (input_file_name.endswith('.py') or input_file_name.endswith('.ipynb')):
-            py_exists = os.path.exists(input_file_name + '.py')
-            ipynb_exists = os.path.exists(input_file_name + '.ipynb')
-            if py_exists == ipynb_exists:
-                raise ValueError("if no suffix is specified, then exactly one of the .py or ipynb file forms must be present")
-            if py_exists:
-                input_file_name = input_file_name + '.py'
-            else:
-                input_file_name = input_file_name + '.ipynb'
-        assert input_file_name.endswith('.py') or input_file_name.endswith('.ipynb')
-        assert os.path.exists(input_file_name)
-        tasks.append(input_file_name)
-    # do the work
-    for input_file_name in tasks:
-        render_as_html(
-            input_file_name, 
-            exclude_input=strip_input, 
-            verbose=quiet == False)
-    return 0
-
-
-if __name__ == '__main__':
-    try:
-        parser = argparse.ArgumentParser(description="Render .py or .ipynb to .html by executing in Jupyter")
-        parser.add_argument(
-            'infile', 
-            metavar='infile', 
-            type=str, 
-            nargs='+',
-            help='name of input file(s)')
-        parser.add_argument('--strip_input', action='store_true', help="strip input cells and cell markers")
-        parser.add_argument('--quiet', action='store_true', help='quiet operation')
-        args = parser.parse_args()
-        # checks
-        assert isinstance(args.quiet, bool)
-        assert isinstance(args.strip_input, bool)
-        assert len(args.infile) > 0
-        assert len(set(args.infile)) == len(args.infile)
-        ret = render_workbook(
-            quiet=quiet,
-            strip_input=strip_input,
-            infiles=args.infile,
-        )
-        sys.exit(ret)
-    except AssertionError:
-        _, _, tb = sys.exc_info()
-        tb_info = traceback.extract_tb(tb)
-        filename, line, func, text = tb_info[-1]
-        print(f'Assertion failed {filename}:{line} (caller {func}) in statement {text}')
-    except Exception as ex:
-        print(ex)
-    sys.exit(-1)
-
- -
+ + + + +
 1# run with:
+ 2#    python -m wvpy.render_workbook test.py
+ 3#    python -m wvpy.render_workbook test.ipynb
+ 4
+ 5from typing import Iterable
+ 6import argparse
+ 7import os
+ 8import sys
+ 9import traceback
+10from wvpy.jtools import render_as_html
+11
+12
+13def render_workbook(
+14    infiles: Iterable[str],
+15    *,
+16    quiet: bool = False,
+17    strip_input: bool = True,
+18) -> int:
+19    """
+20    Render a list of Jupyter notebooks.
+21
+22    :param infiles: list of file names to process
+23    :param quiet: if true do the work quietly
+24    :param strip_input: if true strip input cells and cell numbering
+25    :return: 0 if successful 
+26    """
+27    # checks
+28    assert isinstance(quiet, bool)
+29    assert isinstance(strip_input, bool)
+30    assert len(infiles) > 0
+31    assert len(set(infiles)) == len(infiles)
+32    assert not isinstance(infiles, str)  # common error
+33    infiles = list(infiles)
+34    tasks = []
+35    for input_file_name in infiles:
+36        assert isinstance(input_file_name, str)
+37        assert len(input_file_name) > 0
+38        assert not input_file_name.endswith('.html')
+39        assert not input_file_name.endswith('.pdf')
+40        if not (input_file_name.endswith('.py') or input_file_name.endswith('.ipynb')):
+41            py_exists = os.path.exists(input_file_name + '.py')
+42            ipynb_exists = os.path.exists(input_file_name + '.ipynb')
+43            if py_exists == ipynb_exists:
+44                raise ValueError("if no suffix is specified, then exactly one of the .py or ipynb file forms must be present")
+45            if py_exists:
+46                input_file_name = input_file_name + '.py'
+47            else:
+48                input_file_name = input_file_name + '.ipynb'
+49        assert input_file_name.endswith('.py') or input_file_name.endswith('.ipynb')
+50        assert os.path.exists(input_file_name)
+51        tasks.append(input_file_name)
+52    # do the work
+53    for input_file_name in tasks:
+54        render_as_html(
+55            input_file_name, 
+56            exclude_input=strip_input, 
+57            verbose=quiet == False)
+58    return 0
+59
+60
+61if __name__ == '__main__':
+62    try:
+63        parser = argparse.ArgumentParser(description="Render .py or .ipynb to .html by executing in Jupyter")
+64        parser.add_argument(
+65            'infile', 
+66            metavar='infile', 
+67            type=str, 
+68            nargs='+',
+69            help='name of input file(s)')
+70        parser.add_argument('--strip_input', action='store_true', help="strip input cells and cell markers")
+71        parser.add_argument('--quiet', action='store_true', help='quiet operation')
+72        args = parser.parse_args()
+73        # checks
+74        assert isinstance(args.quiet, bool)
+75        assert isinstance(args.strip_input, bool)
+76        assert len(args.infile) > 0
+77        assert len(set(args.infile)) == len(args.infile)
+78        ret = render_workbook(
+79            quiet=quiet,
+80            strip_input=strip_input,
+81            infiles=args.infile,
+82        )
+83        sys.exit(ret)
+84    except AssertionError:
+85        _, _, tb = sys.exc_info()
+86        tb_info = traceback.extract_tb(tb)
+87        filename, line, func, text = tb_info[-1]
+88        print(f'Assertion failed {filename}:{line} (caller {func}) in statement {text}')
+89    except Exception as ex:
+90        print(ex)
+91    sys.exit(-1)
+
+
-
#   - - - def - render_workbook( - infiles: Iterable[str], - *, - quiet: bool = False, - strip_input: bool = True -) -> int: + +
+ + def + render_workbook( infiles: Iterable[str], *, quiet: bool = False, strip_input: bool = True) -> int: + + +
+ +
15def render_workbook(
+16    infiles: Iterable[str],
+17    *,
+18    quiet: bool = False,
+19    strip_input: bool = True,
+20) -> int:
+21    """
+22    Render a list of Jupyter notebooks.
+23
+24    :param infiles: list of file names to process
+25    :param quiet: if true do the work quietly
+26    :param strip_input: if true strip input cells and cell numbering
+27    :return: 0 if successful 
+28    """
+29    # checks
+30    assert isinstance(quiet, bool)
+31    assert isinstance(strip_input, bool)
+32    assert len(infiles) > 0
+33    assert len(set(infiles)) == len(infiles)
+34    assert not isinstance(infiles, str)  # common error
+35    infiles = list(infiles)
+36    tasks = []
+37    for input_file_name in infiles:
+38        assert isinstance(input_file_name, str)
+39        assert len(input_file_name) > 0
+40        assert not input_file_name.endswith('.html')
+41        assert not input_file_name.endswith('.pdf')
+42        if not (input_file_name.endswith('.py') or input_file_name.endswith('.ipynb')):
+43            py_exists = os.path.exists(input_file_name + '.py')
+44            ipynb_exists = os.path.exists(input_file_name + '.ipynb')
+45            if py_exists == ipynb_exists:
+46                raise ValueError("if no suffix is specified, then exactly one of the .py or ipynb file forms must be present")
+47            if py_exists:
+48                input_file_name = input_file_name + '.py'
+49            else:
+50                input_file_name = input_file_name + '.ipynb'
+51        assert input_file_name.endswith('.py') or input_file_name.endswith('.ipynb')
+52        assert os.path.exists(input_file_name)
+53        tasks.append(input_file_name)
+54    # do the work
+55    for input_file_name in tasks:
+56        render_as_html(
+57            input_file_name, 
+58            exclude_input=strip_input, 
+59            verbose=quiet == False)
+60    return 0
+
-
- View Source -
def render_workbook(
-    infiles: Iterable[str],
-    *,
-    quiet: bool = False,
-    strip_input: bool = True,
-) -> int:
-    """
-    Render a list of Jupyter notebooks.
-
-    :param infiles: list of file names to process
-    :param quiet: if true do the work quietly
-    :param strip_input: if true strip input cells and cell numbering
-    :return: 0 if successful 
-    """
-    # checks
-    assert isinstance(quiet, bool)
-    assert isinstance(strip_input, bool)
-    assert len(infiles) > 0
-    assert len(set(infiles)) == len(infiles)
-    assert not isinstance(infiles, str)  # common error
-    infiles = list(infiles)
-    tasks = []
-    for input_file_name in infiles:
-        assert isinstance(input_file_name, str)
-        assert len(input_file_name) > 0
-        assert not input_file_name.endswith('.html')
-        assert not input_file_name.endswith('.pdf')
-        if not (input_file_name.endswith('.py') or input_file_name.endswith('.ipynb')):
-            py_exists = os.path.exists(input_file_name + '.py')
-            ipynb_exists = os.path.exists(input_file_name + '.ipynb')
-            if py_exists == ipynb_exists:
-                raise ValueError("if no suffix is specified, then exactly one of the .py or ipynb file forms must be present")
-            if py_exists:
-                input_file_name = input_file_name + '.py'
-            else:
-                input_file_name = input_file_name + '.ipynb'
-        assert input_file_name.endswith('.py') or input_file_name.endswith('.ipynb')
-        assert os.path.exists(input_file_name)
-        tasks.append(input_file_name)
-    # do the work
-    for input_file_name in tasks:
-        render_as_html(
-            input_file_name, 
-            exclude_input=strip_input, 
-            verbose=quiet == False)
-    return 0
-
- -

Render a list of Jupyter notebooks.

-

:param infiles: list of file names to process -:param quiet: if true do the work quietly -:param strip_input: if true strip input cells and cell numbering -:return: 0 if successful

+
Parameters
+ +
    +
  • infiles: list of file names to process
  • +
  • quiet: if true do the work quietly
  • +
  • strip_input: if true strip input cells and cell numbering
  • +
+ +
Returns
+ +
+

0 if successful

+
diff --git a/pkg/docs/wvpy/util.html b/pkg/docs/wvpy/util.html deleted file mode 100644 index adf2afa..0000000 --- a/pkg/docs/wvpy/util.html +++ /dev/null @@ -1,3026 +0,0 @@ - - - - - - - wvpy.util API documentation - - - - - - - - - -
-
-

-wvpy.util

- -

Utility functions for teaching data science.

-
- -
- View Source -
"""
-Utility functions for teaching data science.
-"""
-
-from typing import Dict, Iterable, List, Tuple
-
-import re
-import os
-import numpy
-import statistics
-import matplotlib
-import matplotlib.pyplot
-import seaborn
-import sklearn
-import sklearn.metrics
-import sklearn.preprocessing
-import itertools
-import pandas
-import math
-from data_algebra.cdata import RecordMap, RecordSpecification
-
-
-def types_in_frame(d: pandas.DataFrame) -> Dict[str, List[type]]:
-    """
-    Report what type as seen as values in a Pandas data frame.
-    
-    :param d: Pandas data frame to inspect, not altered.
-    :return: dictionary mapping column names to order lists of types found in column.
-    """
-    assert isinstance(d, pandas.DataFrame)
-    type_dict_map = {
-        col_name: {str(type(v)): type(v) for v in d[col_name]}
-            for col_name in d.columns
-    }
-    type_dict = {
-        col_name: [type_set[k] for k in sorted(list(type_set.keys()))]
-            for col_name, type_set in type_dict_map.items()
-    }
-    return type_dict
-
-
-# noinspection PyPep8Naming
-def cross_predict_model(
-    fitter, X: pandas.DataFrame, y: pandas.Series, plan: List
-) -> numpy.ndarray:
-    """
-    train a model y~X using the cross validation plan and return predictions
-
-    :param fitter: sklearn model we can call .fit() on
-    :param X: explanatory variables, pandas DataFrame
-    :param y: dependent variable, pandas Series
-    :param plan: cross validation plan from mk_cross_plan()
-    :return: vector of simulated out of sample predictions
-    """
-
-    assert isinstance(X, pandas.DataFrame)
-    assert isinstance(y, pandas.Series)
-    assert isinstance(plan, List)
-    preds = None
-    for pi in plan:
-        model = fitter.fit(X.iloc[pi["train"], :], y.iloc[pi["train"]])
-        predg = model.predict(X.iloc[pi["test"], :])
-        # patch results in
-        if preds is None:
-            preds = numpy.asarray([None] * X.shape[0], dtype=numpy.asarray(predg).dtype)
-        preds[pi["test"]] = predg
-    return preds
-
-
-# noinspection PyPep8Naming
-def cross_predict_model_proba(
-    fitter, X: pandas.DataFrame, y: pandas.Series, plan: List
-) -> pandas.DataFrame:
-    """
-    train a model y~X using the cross validation plan and return probability matrix
-
-    :param fitter: sklearn model we can call .fit() on
-    :param X: explanatory variables, pandas DataFrame
-    :param y: dependent variable, pandas Series
-    :param plan: cross validation plan from mk_cross_plan()
-    :return: matrix of simulated out of sample predictions
-    """
-
-    assert isinstance(X, pandas.DataFrame)
-    assert isinstance(y, pandas.Series)
-    assert isinstance(plan, List)
-    preds = None
-    for pi in plan:
-        model = fitter.fit(X.iloc[pi["train"], :], y.iloc[pi["train"]])
-        predg = model.predict_proba(X.iloc[pi["test"], :])
-        # patch results in
-        if preds is None:
-            preds = numpy.zeros((X.shape[0], predg.shape[1]))
-        for j in range(preds.shape[1]):
-            preds[pi["test"], j] = predg[:, j]
-    preds = pandas.DataFrame(preds)
-    preds.columns = list(fitter.classes_)
-    return preds
-
-
-def mean_deviance(predictions, istrue, *, eps=1.0e-6):
-    """
-    compute per-row deviance of predictions versus istrue
-
-    :param predictions: vector of probability preditions
-    :param istrue: vector of True/False outcomes to be predicted
-    :param eps: how close to zero or one we clip predictions
-    :return: vector of per-row deviances
-    """
-
-    istrue = numpy.asarray(istrue)
-    predictions = numpy.asarray(predictions)
-    mass_on_correct = numpy.where(istrue, predictions, 1 - predictions)
-    mass_on_correct = numpy.maximum(mass_on_correct, eps)
-    return -2 * sum(numpy.log(mass_on_correct)) / len(istrue)
-
-
-def mean_null_deviance(istrue, *, eps=1.0e-6):
-    """
-    compute per-row nulll deviance of predictions versus istrue
-
-    :param istrue: vector of True/False outcomes to be predicted
-    :param eps: how close to zero or one we clip predictions
-    :return: mean null deviance of using prevalence as the prediction.
-    """
-
-    istrue = numpy.asarray(istrue)
-    p = numpy.zeros(len(istrue)) + numpy.mean(istrue)
-    return mean_deviance(predictions=p, istrue=istrue, eps=eps)
-
-
-def mk_cross_plan(n: int, k: int) -> List:
-    """
-    Randomly split range(n) into k train/test groups such that test groups partition range(n).
-
-    :param n: integer > 1
-    :param k: integer > 1
-    :return: list of train/test dictionaries
-
-    Example:
-
-    import wvpy.util
-
-    wvpy.util.mk_cross_plan(10, 3)
-    """
-    grp = [i % k for i in range(n)]
-    numpy.random.shuffle(grp)
-    plan = [
-        {
-            "train": [i for i in range(n) if grp[i] != j],
-            "test": [i for i in range(n) if grp[i] == j],
-        }
-        for j in range(k)
-    ]
-    return plan
-
-
-# https://win-vector.com/2020/09/13/why-working-with-auc-is-more-powerful-than-one-might-think/
-def matching_roc_area_curve(auc: float) -> dict:
-    """
-    Find an ROC curve with a given area with form of y = 1 - (1 - (1 - x) ** q) ** (1 / q).
-
-    :param auc: area to match
-    :return: dictionary of ideal x, y series matching area
-    """
-    step = 0.01
-    eval_pts = numpy.arange(0, 1 + step, step)
-    q_eps = 1e-6
-    q_low = 0.0
-    q_high = 1.0
-    while q_low + q_eps < q_high:
-        q_mid = (q_low + q_high) / 2.0
-        q_mid_area = numpy.mean(1 - (1 - (1 - eval_pts) ** q_mid) ** (1 / q_mid))
-        if q_mid_area <= auc:
-            q_high = q_mid
-        else:
-            q_low = q_mid
-    q = (q_low + q_high) / 2.0
-    return {
-        "auc": auc,
-        "q": q,
-        "x": 1 - eval_pts,
-        "y": 1 - (1 - (1 - eval_pts) ** q) ** (1 / q),
-    }
-
-
-# https://scikit-learn.org/stable/auto_examples/model_selection/plot_roc.html
-def plot_roc(
-    prediction,
-    istrue,
-    title="Receiver operating characteristic plot",
-    *,
-    truth_target=True,
-    ideal_line_color=None,
-    extra_points=None,
-    show=True,
-):
-    """
-    Plot a ROC curve of numeric prediction against boolean istrue.
-
-    :param prediction: column of numeric predictions
-    :param istrue: column of items to predict
-    :param title: plot title
-    :param truth_target: value to consider target or true.
-    :param ideal_line_color: if not None, color of ideal line
-    :param extra_points: data frame of additional point to annotate graph, columns fpr, tpr, label
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: calculated area under the curve, plot produced by call.
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [1, 2, 3, 4, 5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.plot_roc(
-        prediction=d['x'],
-        istrue=d['y'],
-        ideal_line_color='lightgrey'
-    )
-
-    wvpy.util.plot_roc(
-        prediction=d['x'],
-        istrue=d['y'],
-        ideal_line_color='lightgrey',
-        extra_points=pandas.DataFrame({
-            'tpr': [0, 1],
-            'fpr': [0, 1],
-            'label': ['AAA', 'BBB']
-        })
-    )
-    """
-    prediction = numpy.asarray(prediction)
-    istrue = numpy.asarray(istrue) == truth_target
-    fpr, tpr, _ = sklearn.metrics.roc_curve(istrue, prediction)
-    auc = sklearn.metrics.auc(fpr, tpr)
-    ideal_curve = None
-    if ideal_line_color is not None:
-        ideal_curve = matching_roc_area_curve(auc)
-    matplotlib.pyplot.figure()
-    lw = 2
-    matplotlib.pyplot.gcf().clear()
-    fig1, ax1 = matplotlib.pyplot.subplots()
-    ax1.set_aspect("equal")
-    matplotlib.pyplot.plot(
-        fpr,
-        tpr,
-        color="darkorange",
-        lw=lw,
-        label="ROC curve (area = {0:0.2f})" "".format(auc),
-    )
-    matplotlib.pyplot.fill_between(fpr, tpr, color="orange", alpha=0.3)
-    matplotlib.pyplot.plot([0, 1], [0, 1], color="navy", lw=lw, linestyle="--")
-    if extra_points is not None:
-        matplotlib.pyplot.scatter(extra_points.fpr, extra_points.tpr, color="red")
-        if "label" in extra_points.columns:
-            tpr = extra_points.tpr.to_list()
-            fpr = extra_points.fpr.to_list()
-            label = extra_points.label.to_list()
-            for i in range(extra_points.shape[0]):
-                txt = label[i]
-                if txt is not None:
-                    ax1.annotate(txt, (fpr[i], tpr[i]))
-    if ideal_curve is not None:
-        matplotlib.pyplot.plot(
-            ideal_curve["x"], ideal_curve["y"], linestyle="--", color=ideal_line_color
-        )
-    matplotlib.pyplot.xlim([0.0, 1.0])
-    matplotlib.pyplot.ylim([0.0, 1.0])
-    matplotlib.pyplot.xlabel("False Positive Rate (1-Specificity)")
-    matplotlib.pyplot.ylabel("True Positive Rate (Sensitivity)")
-    matplotlib.pyplot.title(title)
-    matplotlib.pyplot.legend(loc="lower right")
-    if show:
-        matplotlib.pyplot.show()
-    return auc
-
-
-def dual_density_plot(
-    probs,
-    istrue,
-    title="Double density plot",
-    *,
-    truth_target=True,
-    positive_label="positive examples",
-    negative_label="negative examples",
-    ylabel="density of examples",
-    xlabel="model score",
-    show=True,
-):
-    """
-    Plot a dual density plot of numeric prediction probs against boolean istrue.
-
-    :param probs: vector of numeric predictions.
-    :param istrue: truth vector
-    :param title: title of plot
-    :param truth_target: value considerd true
-    :param positive_label=label for positive class
-    :param negative_label=label for negative class
-    :param ylabel=y axis label
-    :param xlabel=x axis label
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [1, 2, 3, 4, 5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.dual_density_plot(
-        probs=d['x'],
-        istrue=d['y'],
-    )
-    """
-    probs = numpy.asarray(probs)
-    istrue = numpy.asarray(istrue) == truth_target
-    matplotlib.pyplot.gcf().clear()
-    preds_on_positive = [
-        probs[i] for i in range(len(probs)) if istrue[i] == truth_target
-    ]
-    preds_on_negative = [
-        probs[i] for i in range(len(probs)) if not istrue[i] == truth_target
-    ]
-    seaborn.kdeplot(preds_on_positive, label=positive_label, shade=True)
-    seaborn.kdeplot(preds_on_negative, label=negative_label, shade=True)
-    matplotlib.pyplot.ylabel(ylabel)
-    matplotlib.pyplot.xlabel(xlabel)
-    matplotlib.pyplot.title(title)
-    matplotlib.pyplot.legend()
-    if show:
-        matplotlib.pyplot.show()
-
-
-def dual_hist_plot(probs, istrue, title="Dual Histogram Plot", *, truth_target=True, show=True):
-    """
-    plot a dual histogram plot of numeric prediction probs against boolean istrue
-
-    :param probs: vector of numeric predictions.
-    :param istrue: truth vector
-    :param title: title of plot
-    :param truth_target: value to consider in class
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.dual_hist_plot(
-        probs=d['x'],
-        istrue=d['y'],
-    )
-    """
-    probs = numpy.asarray(probs)
-    istrue = numpy.asarray(istrue) == truth_target
-    matplotlib.pyplot.gcf().clear()
-    pf = pandas.DataFrame({"prob": probs, "istrue": istrue})
-    g = seaborn.FacetGrid(pf, row="istrue", height=4, aspect=3)
-    bins = numpy.arange(0, 1.1, 0.1)
-    g.map(matplotlib.pyplot.hist, "prob", bins=bins)
-    matplotlib.pyplot.title(title)
-    if show:
-        matplotlib.pyplot.show()
-
-
-def dual_density_plot_proba1(
-    probs,
-    istrue,
-    title="Double density plot",
-    *,
-    truth_target=True,
-    positive_label="positive examples",
-    negative_label="negative examples",
-    ylabel="density of examples",
-    xlabel="model score",
-    show=True,
-):
-    """
-    Plot a dual density plot of numeric prediction probs[:,1] against boolean istrue.
-
-    :param probs: matrix of numeric predictions (as returned from predict_proba())
-    :param istrue: truth target
-    :param title: title of plot
-    :param truth_target: value considered true
-    :param positive_label=label for positive class
-    :param negative_label=label for negative class
-    :param ylabel=y axis label
-    :param xlabel=x axis label
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [False, False, True, True, False]
-    })
-    d['x0'] = 1 - d['x']
-    pmat = numpy.asarray(d.loc[:, ['x0', 'x']])
-
-    wvpy.util.dual_density_plot_proba1(
-        probs=pmat,
-        istrue=d['y'],
-    )
-    """
-    istrue = numpy.asarray(istrue)
-    probs = numpy.asarray(probs)
-    matplotlib.pyplot.gcf().clear()
-    preds_on_positive = [
-        probs[i, 1] for i in range(len(probs)) if istrue[i] == truth_target
-    ]
-    preds_on_negative = [
-        probs[i, 1] for i in range(len(probs)) if not istrue[i] == truth_target
-    ]
-    seaborn.kdeplot(preds_on_positive, label=positive_label, shade=True)
-    seaborn.kdeplot(preds_on_negative, label=negative_label, shade=True)
-    matplotlib.pyplot.ylabel(ylabel)
-    matplotlib.pyplot.xlabel(xlabel)
-    matplotlib.pyplot.title(title)
-    matplotlib.pyplot.legend()
-    if show:
-        matplotlib.pyplot.show()
-
-
-def dual_hist_plot_proba1(probs, istrue, *, show=True):
-    """
-    plot a dual histogram plot of numeric prediction probs[:,1] against boolean istrue
-
-    :param probs: vector of probability predictions
-    :param istrue: vector of ground truth to condition on
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [False, False, True, True, False]
-    })
-    d['x0'] = 1 - d['x']
-    pmat = numpy.asarray(d.loc[:, ['x0', 'x']])
-
-    wvpy.util.dual_hist_plot_proba1(
-        probs=pmat,
-        istrue=d['y'],
-    )
-    """
-    istrue = numpy.asarray(istrue)
-    probs = numpy.asarray(probs)
-    matplotlib.pyplot.gcf().clear()
-    pf = pandas.DataFrame(
-        {"prob": [probs[i, 1] for i in range(probs.shape[0])], "istrue": istrue}
-    )
-    g = seaborn.FacetGrid(pf, row="istrue", height=4, aspect=3)
-    bins = numpy.arange(0, 1.1, 0.1)
-    g.map(matplotlib.pyplot.hist, "prob", bins=bins)
-    if show:
-        matplotlib.pyplot.show()
-
-
-def gain_curve_plot(prediction, outcome, title="Gain curve plot", *, show=True):
-    """
-    plot cumulative outcome as a function of prediction order (descending)
-
-    :param prediction: vector of numeric predictions
-    :param outcome: vector of actual values
-    :param title: plot title
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [0, 0, 1, 1, 0]
-    })
-
-    wvpy.util.gain_curve_plot(
-        prediction=d['x'],
-        outcome=d['y'],
-    )
-    """
-
-    df = pandas.DataFrame(
-        {
-            "prediction": numpy.array(prediction).copy(),
-            "outcome": numpy.array(outcome).copy(),
-        }
-    )
-
-    # compute the gain curve
-    df.sort_values(["prediction"], ascending=[False], inplace=True)
-    df["fraction_of_observations_by_prediction"] = (
-        numpy.arange(df.shape[0]) + 1.0
-    ) / df.shape[0]
-    df["cumulative_outcome"] = df["outcome"].cumsum()
-    df["cumulative_outcome_fraction"] = df["cumulative_outcome"] / numpy.max(
-        df["cumulative_outcome"]
-    )
-
-    # compute the wizard curve
-    df.sort_values(["outcome"], ascending=[False], inplace=True)
-    df["fraction_of_observations_by_wizard"] = (
-        numpy.arange(df.shape[0]) + 1.0
-    ) / df.shape[0]
-
-    df["cumulative_outcome_by_wizard"] = df["outcome"].cumsum()
-    df["cumulative_outcome_fraction_wizard"] = df[
-        "cumulative_outcome_by_wizard"
-    ] / numpy.max(df["cumulative_outcome_by_wizard"])
-
-    seaborn.lineplot(
-        x="fraction_of_observations_by_wizard",
-        y="cumulative_outcome_fraction_wizard",
-        color="gray",
-        linestyle="--",
-        data=df,
-    )
-
-    seaborn.lineplot(
-        x="fraction_of_observations_by_prediction",
-        y="cumulative_outcome_fraction",
-        data=df,
-    )
-
-    seaborn.lineplot(x=[0, 1], y=[0, 1], color="red")
-    matplotlib.pyplot.xlabel("fraction of observations by sort criterion")
-    matplotlib.pyplot.ylabel("cumulative outcome fraction")
-    matplotlib.pyplot.title(title)
-    if show:
-        matplotlib.pyplot.show()
-
-
-def lift_curve_plot(prediction, outcome, title="Lift curve plot", *, show=True):
-    """
-    plot lift as a function of prediction order (descending)
-
-    :param prediction: vector of numeric predictions
-    :param outcome: vector of actual values
-    :param title: plot title
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [0, 0, 1, 1, 0]
-    })
-
-    wvpy.util.lift_curve_plot(
-        prediction=d['x'],
-        outcome=d['y'],
-    )
-    """
-
-    df = pandas.DataFrame(
-        {
-            "prediction": numpy.array(prediction).copy(),
-            "outcome": numpy.array(outcome).copy(),
-        }
-    )
-
-    # compute the gain curve
-    df.sort_values(["prediction"], ascending=[False], inplace=True)
-    df["fraction_of_observations_by_prediction"] = (
-        numpy.arange(df.shape[0]) + 1.0
-    ) / df.shape[0]
-    df["cumulative_outcome"] = df["outcome"].cumsum()
-    df["cumulative_outcome_fraction"] = df["cumulative_outcome"] / numpy.max(
-        df["cumulative_outcome"]
-    )
-
-    # move to lift
-    df["lift"] = (
-        df["cumulative_outcome_fraction"] / df["fraction_of_observations_by_prediction"]
-    )
-    seaborn.lineplot(x="fraction_of_observations_by_prediction", y="lift", data=df)
-    matplotlib.pyplot.axhline(y=1, color="red")
-    matplotlib.pyplot.title(title)
-    if show:
-        matplotlib.pyplot.show()
-
-
-# https://stackoverflow.com/questions/5228158/cartesian-product-of-a-dictionary-of-lists
-def search_grid(inp: dict) -> List:
-    """
-    build a cross product of all named dictionary entries
-
-    :param inp: dictionary of value lists
-    :return: list of value dictionaries
-    """
-
-    gen = (dict(zip(inp.keys(), values)) for values in itertools.product(*inp.values()))
-    return [ci for ci in gen]
-
-
-def grid_to_df(grid: List) -> pandas.DataFrame:
-    """
-    convert a search_grid list of maps to a pandas data frame
-
-    :param grid: list of combos
-    :return: data frame with one row per combo
-    """
-
-    n = len(grid)
-    keys = [ki for ki in grid[1].keys()]
-    return pandas.DataFrame({ki: [grid[i][ki] for i in range(n)] for ki in keys})
-
-
-def eval_fn_per_row(f, x2, df: pandas.DataFrame) -> List:
-    """
-    evaluate f(row-as-map, x2) for rows in df
-
-    :param f: function to evaluate
-    :param x2: extra argument
-    :param df: data frame to take rows from
-    :return: list of evaluations
-    """
-
-    assert isinstance(df, pandas.DataFrame)
-    return [f({k: df.loc[i, k] for k in df.columns}, x2) for i in range(df.shape[0])]
-
-
-def perm_score_vars(d: pandas.DataFrame, istrue, model, modelvars: List[str], k=5):
-    """
-    evaluate model~istrue on d permuting each of the modelvars and return variable importances
-
-    :param d: data source (copied)
-    :param istrue: y-target
-    :param model: model to evaluate
-    :param modelvars: names of variables to permute
-    :param k: number of permutations
-    :return: score data frame
-    """
-
-    d2 = d[modelvars].copy()
-    d2.reset_index(inplace=True, drop=True)
-    istrue = numpy.asarray(istrue)
-    preds = model.predict_proba(d2[modelvars])
-    basedev = mean_deviance(preds[:, 1], istrue)
-
-    def perm_score_var(victim):
-        """Permutation score column named victim"""
-        dorig = numpy.array(d2[victim].copy())
-        dnew = numpy.array(d2[victim].copy())
-
-        def perm_score_var_once():
-            """apply fn once, used for list comprehension"""
-            numpy.random.shuffle(dnew)
-            d2[victim] = dnew
-            predsp = model.predict_proba(d2[modelvars])
-            permdev = mean_deviance(predsp[:, 1], istrue)
-            return permdev
-
-        # noinspection PyUnusedLocal
-        devs = [perm_score_var_once() for rep in range(k)]
-        d2[victim] = dorig
-        return numpy.mean(devs), statistics.stdev(devs)
-
-    stats = [perm_score_var(victim) for victim in modelvars]
-    vf = pandas.DataFrame({"var": modelvars})
-    vf["importance"] = [di[0] - basedev for di in stats]
-    vf["importance_dev"] = [di[1] for di in stats]
-    vf.sort_values(by=["importance"], ascending=False, inplace=True)
-    vf = vf.reset_index(inplace=False, drop=True)
-    return vf
-
-
-def threshold_statistics(
-    d: pandas.DataFrame, *, model_predictions: str, yvalues: str, y_target=True
-) -> pandas.DataFrame:
-    """
-    Compute a number of threshold statistics of how well model predictions match a truth target.
-
-    :param d: pandas.DataFrame to take values from
-    :param model_predictions: name of predictions column
-    :param yvalues: name of truth values column
-    :param y_target: value considered to be true
-    :return: summary statistic frame, include before and after pseudo-observations
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [1, 2, 3, 4, 5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.threshold_statistics(
-        d,
-        model_predictions='x',
-        yvalues='y',
-    )
-    """
-    # make a thin frame to re-sort for cumulative statistics
-    sorted_frame = pandas.DataFrame(
-        {"threshold": d[model_predictions].copy(), "truth": d[yvalues] == y_target}
-    )
-    sorted_frame["orig_index"] = sorted_frame.index + 0
-    sorted_frame.sort_values(
-        ["threshold", "orig_index"], ascending=[False, True], inplace=True
-    )
-    sorted_frame.reset_index(inplace=True, drop=True)
-    sorted_frame["notY"] = 1 - sorted_frame["truth"]  # falses
-    sorted_frame["one"] = 1
-    del sorted_frame["orig_index"]
-
-    # pseudo-observation to get end-case (accept nothing case)
-    eps = 1.0e-6
-    sorted_frame = pandas.concat(
-        [
-            pandas.DataFrame(
-                {
-                    "threshold": [sorted_frame["threshold"].max() + eps],
-                    "truth": [False],
-                    "notY": [0],
-                    "one": [0],
-                }
-            ),
-            sorted_frame,
-            pandas.DataFrame(
-                {
-                    "threshold": [sorted_frame["threshold"].min() - eps],
-                    "truth": [False],
-                    "notY": [0],
-                    "one": [0],
-                }
-            ),
-        ]
-    )
-    sorted_frame.reset_index(inplace=True, drop=True)
-
-    # basic cumulative facts
-    sorted_frame["count"] = sorted_frame["one"].cumsum()  # predicted true so far
-    sorted_frame["fraction"] = sorted_frame["count"] / max(1, sorted_frame["one"].sum())
-    sorted_frame["precision"] = sorted_frame["truth"].cumsum() / sorted_frame[
-        "count"
-    ].clip(lower=1)
-    sorted_frame["true_positive_rate"] = sorted_frame["truth"].cumsum() / max(
-        1, sorted_frame["truth"].sum()
-    )
-    sorted_frame["false_positive_rate"] = sorted_frame["notY"].cumsum() / max(
-        1, sorted_frame["notY"].sum()
-    )
-    sorted_frame["true_negative_rate"] = (
-        sorted_frame["notY"].sum() - sorted_frame["notY"].cumsum()
-    ) / max(1, sorted_frame["notY"].sum())
-    sorted_frame["false_negative_rate"] = (
-        sorted_frame["truth"].sum() - sorted_frame["truth"].cumsum()
-    ) / max(1, sorted_frame["truth"].sum())
-    sorted_frame["accuracy"] = (
-        sorted_frame["truth"].cumsum()  # true positive count
-        + sorted_frame["notY"].sum()
-        - sorted_frame["notY"].cumsum()  # true negative count
-    ) / sorted_frame["one"].sum()
-
-    # approximate cdf work
-    sorted_frame["cdf"] = 1 - sorted_frame["fraction"]
-
-    # derived facts and synonyms
-    sorted_frame["recall"] = sorted_frame["true_positive_rate"]
-    sorted_frame["sensitivity"] = sorted_frame["recall"]
-    sorted_frame["specificity"] = 1 - sorted_frame["false_positive_rate"]
-
-    # re-order for neatness
-    sorted_frame["new_index"] = sorted_frame.index.copy()
-    sorted_frame.sort_values(["new_index"], ascending=[False], inplace=True)
-    sorted_frame.reset_index(inplace=True, drop=True)
-
-    # clean up
-    del sorted_frame["notY"]
-    del sorted_frame["one"]
-    del sorted_frame["new_index"]
-    del sorted_frame["truth"]
-    return sorted_frame
-
-
-def threshold_plot(
-    d: pandas.DataFrame,
-    pred_var: str,
-    truth_var: str,
-    truth_target: bool = True,
-    threshold_range: Iterable[float] = (-math.inf, math.inf),
-    plotvars: Iterable[str] = ("precision", "recall"),
-    title: str = "Measures as a function of threshold",
-    *,
-    show: bool = True,
-) -> None:
-    """
-    Produce multiple facet plot relating the performance of using a threshold greater than or equal to
-    different values at predicting a truth target.
-
-    :param d: pandas.DataFrame to plot
-    :param pred_var: name of column of numeric predictions
-    :param truth_var: name of column with reference truth
-    :param truth_target: value considered true
-    :param threshold_range: x-axis range to plot
-    :param plotvars: list of metrics to plot, must come from ['threshold', 'count', 'fraction',
-        'true_positive_rate', 'false_positive_rate', 'true_negative_rate', 'false_negative_rate',
-        'precision', 'recall', 'sensitivity', 'specificity', 'accuracy']
-    :param title: title for plot
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None, plot produced as a side effect
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [1, 2, 3, 4, 5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.threshold_plot(
-        d,
-        pred_var='x',
-        truth_var='y',
-        plotvars=("sensitivity", "specificity"),
-    )
-    """
-    if isinstance(plotvars, str):
-        plotvars = [plotvars]
-    else:
-        plotvars = list(plotvars)
-    assert isinstance(plotvars, list)
-    assert len(plotvars) > 0
-    assert all([isinstance(v, str) for v in plotvars])
-    threshold_range = list(threshold_range)
-    assert len(threshold_range) == 2
-    frame = d[[pred_var, truth_var]].copy()
-    frame.reset_index(inplace=True, drop=True)
-    frame["outcol"] = frame[truth_var] == truth_target
-
-    prt_frame = threshold_statistics(
-        frame, model_predictions=pred_var, yvalues="outcol",
-    )
-    bad_plot_vars = set(plotvars) - set(prt_frame.columns)
-    if len(bad_plot_vars) > 0:
-        raise ValueError(
-            "allowed plotting variables are: "
-            + str(prt_frame.columns)
-            + ", "
-            + str(bad_plot_vars)
-            + " unexpected."
-        )
-
-    selector = (threshold_range[0] <= prt_frame.threshold) & (
-        prt_frame.threshold <= threshold_range[1]
-    )
-    to_plot = prt_frame.loc[selector, :]
-
-    if len(plotvars) > 1:
-        reshaper = RecordMap(
-            blocks_out=RecordSpecification(
-                pandas.DataFrame({"measure": plotvars, "value": plotvars}),
-                control_table_keys=["measure"],
-                record_keys=["threshold"],
-            )
-        )
-        prtlong = reshaper.transform(to_plot)
-        grid = seaborn.FacetGrid(
-            prtlong, row="measure", row_order=plotvars, aspect=2, sharey=False
-        )
-        grid = grid.map(matplotlib.pyplot.plot, "threshold", "value")
-        grid.set(ylabel=None)
-        matplotlib.pyplot.subplots_adjust(top=0.9)
-        grid.fig.suptitle(title)
-    else:
-        # can plot off primary frame
-        seaborn.lineplot(
-            data=to_plot, x="threshold", y=plotvars[0],
-        )
-        matplotlib.pyplot.suptitle(title)
-        matplotlib.pyplot.title(f"measure = {plotvars[0]}")
-
-    if show:
-        matplotlib.pyplot.show()
-
-
-def fit_onehot_enc(
-    d: pandas.DataFrame, *, categorical_var_names: Iterable[str]
-) -> dict:
-    """
-    Fit a sklearn OneHot Encoder to categorical_var_names columns.
-    Note: we suggest preferring vtreat ( https://github.com/WinVector/pyvtreat ) over this example code.
-
-    :param d: training data
-    :param categorical_var_names: list of column names to learn transform from
-    :return: encoding bundle dictionary, see apply_onehot_enc() for use.
-    """
-    assert isinstance(d, pandas.DataFrame)
-    assert not isinstance(
-        categorical_var_names, str
-    )  # single name, should be in a list
-    categorical_var_names = list(categorical_var_names)  # clean copy
-    assert numpy.all([isinstance(v, str) for v in categorical_var_names])
-    assert len(categorical_var_names) > 0
-    enc = sklearn.preprocessing.OneHotEncoder(
-        categories="auto", drop=None, sparse=False, handle_unknown="ignore"  # default
-    )
-    enc.fit(d[categorical_var_names])
-    produced_column_names = list(enc.get_feature_names_out())
-    # return the structure
-    encoder_bundle = {
-        "categorical_var_names": categorical_var_names,
-        "enc": enc,
-        "produced_column_names": produced_column_names,
-    }
-    return encoder_bundle
-
-
-def apply_onehot_enc(d: pandas.DataFrame, *, encoder_bundle: dict) -> pandas.DataFrame:
-    """
-    Apply a one hot encoding bundle to a data frame.
-
-    :param d: input data frame
-    :param encoder_bundle: transform specification, built by fit_onehot_enc()
-    :return: transformed data frame
-    """
-    assert isinstance(d, pandas.DataFrame)
-    assert isinstance(encoder_bundle, dict)
-    # one hot re-code columns, preserving column names info
-    one_hotted = pandas.DataFrame(
-        encoder_bundle["enc"].transform(d[encoder_bundle["categorical_var_names"]])
-    )
-    one_hotted.columns = encoder_bundle["produced_column_names"]
-    # copy over non-invovled columns
-    cat_set = set(encoder_bundle["categorical_var_names"])
-    complementary_columns = [c for c in d.columns if c not in cat_set]
-    res = pandas.concat([d[complementary_columns], one_hotted], axis=1)
-    return res
-
-
-# https://stackoverflow.com/a/56695622/6901725
-# from https://stackoverflow.com/questions/11130156/suppress-stdout-stderr-print-from-python-functions
-class suppress_stdout_stderr(object):
-    '''
-    A context manager for doing a "deep suppression" of stdout and stderr in
-    Python, i.e. will suppress all print, even if the print originates in a
-    compiled C/Fortran sub-function.
-       This will not suppress raised exceptions, since exceptions are printed
-    to stderr just before a script exits, and after the context manager has
-    exited (at least, I think that is why it lets exceptions through).
-
-    '''
-    def __init__(self):
-        # Open a pair of null files
-        self.null_fds = [os.open(os.devnull, os.O_RDWR) for x in range(2)]
-        # Save the actual stdout (1) and stderr (2) file descriptors.
-        self.save_fds = (os.dup(1), os.dup(2))
-
-    def __enter__(self):
-        # Assign the null pointers to stdout and stderr.
-        os.dup2(self.null_fds[0], 1)
-        os.dup2(self.null_fds[1], 2)
-
-    def __exit__(self, *_):
-        # Re-assign the real stdout/stderr back to (1) and (2)
-        os.dup2(self.save_fds[0], 1)
-        os.dup2(self.save_fds[1], 2)
-        # Close the null files
-        os.close(self.null_fds[0])
-        os.close(self.null_fds[1])
-
- -
- -
-
-
#   - - - def - types_in_frame(d: pandas.core.frame.DataFrame) -> Dict[str, List[type]]: -
- -
- View Source -
def types_in_frame(d: pandas.DataFrame) -> Dict[str, List[type]]:
-    """
-    Report what type as seen as values in a Pandas data frame.
-    
-    :param d: Pandas data frame to inspect, not altered.
-    :return: dictionary mapping column names to order lists of types found in column.
-    """
-    assert isinstance(d, pandas.DataFrame)
-    type_dict_map = {
-        col_name: {str(type(v)): type(v) for v in d[col_name]}
-            for col_name in d.columns
-    }
-    type_dict = {
-        col_name: [type_set[k] for k in sorted(list(type_set.keys()))]
-            for col_name, type_set in type_dict_map.items()
-    }
-    return type_dict
-
- -
- -

Report what type as seen as values in a Pandas data frame.

- -

:param d: Pandas data frame to inspect, not altered. -:return: dictionary mapping column names to order lists of types found in column.

-
- - -
-
-
#   - - - def - cross_predict_model( - fitter, - X: pandas.core.frame.DataFrame, - y: pandas.core.series.Series, - plan: List -) -> numpy.ndarray: -
- -
- View Source -
def cross_predict_model(
-    fitter, X: pandas.DataFrame, y: pandas.Series, plan: List
-) -> numpy.ndarray:
-    """
-    train a model y~X using the cross validation plan and return predictions
-
-    :param fitter: sklearn model we can call .fit() on
-    :param X: explanatory variables, pandas DataFrame
-    :param y: dependent variable, pandas Series
-    :param plan: cross validation plan from mk_cross_plan()
-    :return: vector of simulated out of sample predictions
-    """
-
-    assert isinstance(X, pandas.DataFrame)
-    assert isinstance(y, pandas.Series)
-    assert isinstance(plan, List)
-    preds = None
-    for pi in plan:
-        model = fitter.fit(X.iloc[pi["train"], :], y.iloc[pi["train"]])
-        predg = model.predict(X.iloc[pi["test"], :])
-        # patch results in
-        if preds is None:
-            preds = numpy.asarray([None] * X.shape[0], dtype=numpy.asarray(predg).dtype)
-        preds[pi["test"]] = predg
-    return preds
-
- -
- -

train a model y~X using the cross validation plan and return predictions

- -

:param fitter: sklearn model we can call .fit() on -:param X: explanatory variables, pandas DataFrame -:param y: dependent variable, pandas Series -:param plan: cross validation plan from mk_cross_plan() -:return: vector of simulated out of sample predictions

-
- - -
-
-
#   - - - def - cross_predict_model_proba( - fitter, - X: pandas.core.frame.DataFrame, - y: pandas.core.series.Series, - plan: List -) -> pandas.core.frame.DataFrame: -
- -
- View Source -
def cross_predict_model_proba(
-    fitter, X: pandas.DataFrame, y: pandas.Series, plan: List
-) -> pandas.DataFrame:
-    """
-    train a model y~X using the cross validation plan and return probability matrix
-
-    :param fitter: sklearn model we can call .fit() on
-    :param X: explanatory variables, pandas DataFrame
-    :param y: dependent variable, pandas Series
-    :param plan: cross validation plan from mk_cross_plan()
-    :return: matrix of simulated out of sample predictions
-    """
-
-    assert isinstance(X, pandas.DataFrame)
-    assert isinstance(y, pandas.Series)
-    assert isinstance(plan, List)
-    preds = None
-    for pi in plan:
-        model = fitter.fit(X.iloc[pi["train"], :], y.iloc[pi["train"]])
-        predg = model.predict_proba(X.iloc[pi["test"], :])
-        # patch results in
-        if preds is None:
-            preds = numpy.zeros((X.shape[0], predg.shape[1]))
-        for j in range(preds.shape[1]):
-            preds[pi["test"], j] = predg[:, j]
-    preds = pandas.DataFrame(preds)
-    preds.columns = list(fitter.classes_)
-    return preds
-
- -
- -

train a model y~X using the cross validation plan and return probability matrix

- -

:param fitter: sklearn model we can call .fit() on -:param X: explanatory variables, pandas DataFrame -:param y: dependent variable, pandas Series -:param plan: cross validation plan from mk_cross_plan() -:return: matrix of simulated out of sample predictions

-
- - -
-
-
#   - - - def - mean_deviance(predictions, istrue, *, eps=1e-06): -
- -
- View Source -
def mean_deviance(predictions, istrue, *, eps=1.0e-6):
-    """
-    compute per-row deviance of predictions versus istrue
-
-    :param predictions: vector of probability preditions
-    :param istrue: vector of True/False outcomes to be predicted
-    :param eps: how close to zero or one we clip predictions
-    :return: vector of per-row deviances
-    """
-
-    istrue = numpy.asarray(istrue)
-    predictions = numpy.asarray(predictions)
-    mass_on_correct = numpy.where(istrue, predictions, 1 - predictions)
-    mass_on_correct = numpy.maximum(mass_on_correct, eps)
-    return -2 * sum(numpy.log(mass_on_correct)) / len(istrue)
-
- -
- -

compute per-row deviance of predictions versus istrue

- -

:param predictions: vector of probability preditions -:param istrue: vector of True/False outcomes to be predicted -:param eps: how close to zero or one we clip predictions -:return: vector of per-row deviances

-
- - -
-
-
#   - - - def - mean_null_deviance(istrue, *, eps=1e-06): -
- -
- View Source -
def mean_null_deviance(istrue, *, eps=1.0e-6):
-    """
-    compute per-row nulll deviance of predictions versus istrue
-
-    :param istrue: vector of True/False outcomes to be predicted
-    :param eps: how close to zero or one we clip predictions
-    :return: mean null deviance of using prevalence as the prediction.
-    """
-
-    istrue = numpy.asarray(istrue)
-    p = numpy.zeros(len(istrue)) + numpy.mean(istrue)
-    return mean_deviance(predictions=p, istrue=istrue, eps=eps)
-
- -
- -

compute per-row nulll deviance of predictions versus istrue

- -

:param istrue: vector of True/False outcomes to be predicted -:param eps: how close to zero or one we clip predictions -:return: mean null deviance of using prevalence as the prediction.

-
- - -
-
-
#   - - - def - mk_cross_plan(n: int, k: int) -> List: -
- -
- View Source -
def mk_cross_plan(n: int, k: int) -> List:
-    """
-    Randomly split range(n) into k train/test groups such that test groups partition range(n).
-
-    :param n: integer > 1
-    :param k: integer > 1
-    :return: list of train/test dictionaries
-
-    Example:
-
-    import wvpy.util
-
-    wvpy.util.mk_cross_plan(10, 3)
-    """
-    grp = [i % k for i in range(n)]
-    numpy.random.shuffle(grp)
-    plan = [
-        {
-            "train": [i for i in range(n) if grp[i] != j],
-            "test": [i for i in range(n) if grp[i] == j],
-        }
-        for j in range(k)
-    ]
-    return plan
-
- -
- -

Randomly split range(n) into k train/test groups such that test groups partition range(n).

- -

:param n: integer > 1 -:param k: integer > 1 -:return: list of train/test dictionaries

- -

Example:

- -

import wvpy.util

- -

wvpy.util.mk_cross_plan(10, 3)

-
- - -
-
-
#   - - - def - matching_roc_area_curve(auc: float) -> dict: -
- -
- View Source -
def matching_roc_area_curve(auc: float) -> dict:
-    """
-    Find an ROC curve with a given area with form of y = 1 - (1 - (1 - x) ** q) ** (1 / q).
-
-    :param auc: area to match
-    :return: dictionary of ideal x, y series matching area
-    """
-    step = 0.01
-    eval_pts = numpy.arange(0, 1 + step, step)
-    q_eps = 1e-6
-    q_low = 0.0
-    q_high = 1.0
-    while q_low + q_eps < q_high:
-        q_mid = (q_low + q_high) / 2.0
-        q_mid_area = numpy.mean(1 - (1 - (1 - eval_pts) ** q_mid) ** (1 / q_mid))
-        if q_mid_area <= auc:
-            q_high = q_mid
-        else:
-            q_low = q_mid
-    q = (q_low + q_high) / 2.0
-    return {
-        "auc": auc,
-        "q": q,
-        "x": 1 - eval_pts,
-        "y": 1 - (1 - (1 - eval_pts) ** q) ** (1 / q),
-    }
-
- -
- -

Find an ROC curve with a given area with form of y = 1 - (1 - (1 - x) * q) * (1 / q).

- -

:param auc: area to match -:return: dictionary of ideal x, y series matching area

-
- - -
-
-
#   - - - def - plot_roc( - prediction, - istrue, - title='Receiver operating characteristic plot', - *, - truth_target=True, - ideal_line_color=None, - extra_points=None, - show=True -): -
- -
- View Source -
def plot_roc(
-    prediction,
-    istrue,
-    title="Receiver operating characteristic plot",
-    *,
-    truth_target=True,
-    ideal_line_color=None,
-    extra_points=None,
-    show=True,
-):
-    """
-    Plot a ROC curve of numeric prediction against boolean istrue.
-
-    :param prediction: column of numeric predictions
-    :param istrue: column of items to predict
-    :param title: plot title
-    :param truth_target: value to consider target or true.
-    :param ideal_line_color: if not None, color of ideal line
-    :param extra_points: data frame of additional point to annotate graph, columns fpr, tpr, label
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: calculated area under the curve, plot produced by call.
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [1, 2, 3, 4, 5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.plot_roc(
-        prediction=d['x'],
-        istrue=d['y'],
-        ideal_line_color='lightgrey'
-    )
-
-    wvpy.util.plot_roc(
-        prediction=d['x'],
-        istrue=d['y'],
-        ideal_line_color='lightgrey',
-        extra_points=pandas.DataFrame({
-            'tpr': [0, 1],
-            'fpr': [0, 1],
-            'label': ['AAA', 'BBB']
-        })
-    )
-    """
-    prediction = numpy.asarray(prediction)
-    istrue = numpy.asarray(istrue) == truth_target
-    fpr, tpr, _ = sklearn.metrics.roc_curve(istrue, prediction)
-    auc = sklearn.metrics.auc(fpr, tpr)
-    ideal_curve = None
-    if ideal_line_color is not None:
-        ideal_curve = matching_roc_area_curve(auc)
-    matplotlib.pyplot.figure()
-    lw = 2
-    matplotlib.pyplot.gcf().clear()
-    fig1, ax1 = matplotlib.pyplot.subplots()
-    ax1.set_aspect("equal")
-    matplotlib.pyplot.plot(
-        fpr,
-        tpr,
-        color="darkorange",
-        lw=lw,
-        label="ROC curve (area = {0:0.2f})" "".format(auc),
-    )
-    matplotlib.pyplot.fill_between(fpr, tpr, color="orange", alpha=0.3)
-    matplotlib.pyplot.plot([0, 1], [0, 1], color="navy", lw=lw, linestyle="--")
-    if extra_points is not None:
-        matplotlib.pyplot.scatter(extra_points.fpr, extra_points.tpr, color="red")
-        if "label" in extra_points.columns:
-            tpr = extra_points.tpr.to_list()
-            fpr = extra_points.fpr.to_list()
-            label = extra_points.label.to_list()
-            for i in range(extra_points.shape[0]):
-                txt = label[i]
-                if txt is not None:
-                    ax1.annotate(txt, (fpr[i], tpr[i]))
-    if ideal_curve is not None:
-        matplotlib.pyplot.plot(
-            ideal_curve["x"], ideal_curve["y"], linestyle="--", color=ideal_line_color
-        )
-    matplotlib.pyplot.xlim([0.0, 1.0])
-    matplotlib.pyplot.ylim([0.0, 1.0])
-    matplotlib.pyplot.xlabel("False Positive Rate (1-Specificity)")
-    matplotlib.pyplot.ylabel("True Positive Rate (Sensitivity)")
-    matplotlib.pyplot.title(title)
-    matplotlib.pyplot.legend(loc="lower right")
-    if show:
-        matplotlib.pyplot.show()
-    return auc
-
- -
- -

Plot a ROC curve of numeric prediction against boolean istrue.

- -

:param prediction: column of numeric predictions -:param istrue: column of items to predict -:param title: plot title -:param truth_target: value to consider target or true. -:param ideal_line_color: if not None, color of ideal line -:param extra_points: data frame of additional point to annotate graph, columns fpr, tpr, label -:param show: logical, if True call matplotlib.pyplot.show() -:return: calculated area under the curve, plot produced by call.

- -

Example:

- -

import pandas -import wvpy.util

- -

d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] -})

- -

wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - ideal_line_color='lightgrey' -)

- -

wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - ideal_line_color='lightgrey', - extra_points=pandas.DataFrame({ - 'tpr': [0, 1], - 'fpr': [0, 1], - 'label': ['AAA', 'BBB'] - }) -)

-
- - -
-
-
#   - - - def - dual_density_plot( - probs, - istrue, - title='Double density plot', - *, - truth_target=True, - positive_label='positive examples', - negative_label='negative examples', - ylabel='density of examples', - xlabel='model score', - show=True -): -
- -
- View Source -
def dual_density_plot(
-    probs,
-    istrue,
-    title="Double density plot",
-    *,
-    truth_target=True,
-    positive_label="positive examples",
-    negative_label="negative examples",
-    ylabel="density of examples",
-    xlabel="model score",
-    show=True,
-):
-    """
-    Plot a dual density plot of numeric prediction probs against boolean istrue.
-
-    :param probs: vector of numeric predictions.
-    :param istrue: truth vector
-    :param title: title of plot
-    :param truth_target: value considerd true
-    :param positive_label=label for positive class
-    :param negative_label=label for negative class
-    :param ylabel=y axis label
-    :param xlabel=x axis label
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [1, 2, 3, 4, 5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.dual_density_plot(
-        probs=d['x'],
-        istrue=d['y'],
-    )
-    """
-    probs = numpy.asarray(probs)
-    istrue = numpy.asarray(istrue) == truth_target
-    matplotlib.pyplot.gcf().clear()
-    preds_on_positive = [
-        probs[i] for i in range(len(probs)) if istrue[i] == truth_target
-    ]
-    preds_on_negative = [
-        probs[i] for i in range(len(probs)) if not istrue[i] == truth_target
-    ]
-    seaborn.kdeplot(preds_on_positive, label=positive_label, shade=True)
-    seaborn.kdeplot(preds_on_negative, label=negative_label, shade=True)
-    matplotlib.pyplot.ylabel(ylabel)
-    matplotlib.pyplot.xlabel(xlabel)
-    matplotlib.pyplot.title(title)
-    matplotlib.pyplot.legend()
-    if show:
-        matplotlib.pyplot.show()
-
- -
- -

Plot a dual density plot of numeric prediction probs against boolean istrue.

- -

:param probs: vector of numeric predictions. -:param istrue: truth vector -:param title: title of plot -:param truth_target: value considerd true -:param positive_label=label for positive class -:param negative_label=label for negative class -:param ylabel=y axis label -:param xlabel=x axis label -:param show: logical, if True call matplotlib.pyplot.show() -:return: None

- -

Example:

- -

import pandas -import wvpy.util

- -

d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] -})

- -

wvpy.util.dual_density_plot( - probs=d['x'], - istrue=d['y'], -)

-
- - -
-
-
#   - - - def - dual_hist_plot( - probs, - istrue, - title='Dual Histogram Plot', - *, - truth_target=True, - show=True -): -
- -
- View Source -
def dual_hist_plot(probs, istrue, title="Dual Histogram Plot", *, truth_target=True, show=True):
-    """
-    plot a dual histogram plot of numeric prediction probs against boolean istrue
-
-    :param probs: vector of numeric predictions.
-    :param istrue: truth vector
-    :param title: title of plot
-    :param truth_target: value to consider in class
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.dual_hist_plot(
-        probs=d['x'],
-        istrue=d['y'],
-    )
-    """
-    probs = numpy.asarray(probs)
-    istrue = numpy.asarray(istrue) == truth_target
-    matplotlib.pyplot.gcf().clear()
-    pf = pandas.DataFrame({"prob": probs, "istrue": istrue})
-    g = seaborn.FacetGrid(pf, row="istrue", height=4, aspect=3)
-    bins = numpy.arange(0, 1.1, 0.1)
-    g.map(matplotlib.pyplot.hist, "prob", bins=bins)
-    matplotlib.pyplot.title(title)
-    if show:
-        matplotlib.pyplot.show()
-
- -
- -

plot a dual histogram plot of numeric prediction probs against boolean istrue

- -

:param probs: vector of numeric predictions. -:param istrue: truth vector -:param title: title of plot -:param truth_target: value to consider in class -:param show: logical, if True call matplotlib.pyplot.show() -:return: None

- -

Example:

- -

import pandas -import wvpy.util

- -

d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] -})

- -

wvpy.util.dual_hist_plot( - probs=d['x'], - istrue=d['y'], -)

-
- - -
-
-
#   - - - def - dual_density_plot_proba1( - probs, - istrue, - title='Double density plot', - *, - truth_target=True, - positive_label='positive examples', - negative_label='negative examples', - ylabel='density of examples', - xlabel='model score', - show=True -): -
- -
- View Source -
def dual_density_plot_proba1(
-    probs,
-    istrue,
-    title="Double density plot",
-    *,
-    truth_target=True,
-    positive_label="positive examples",
-    negative_label="negative examples",
-    ylabel="density of examples",
-    xlabel="model score",
-    show=True,
-):
-    """
-    Plot a dual density plot of numeric prediction probs[:,1] against boolean istrue.
-
-    :param probs: matrix of numeric predictions (as returned from predict_proba())
-    :param istrue: truth target
-    :param title: title of plot
-    :param truth_target: value considered true
-    :param positive_label=label for positive class
-    :param negative_label=label for negative class
-    :param ylabel=y axis label
-    :param xlabel=x axis label
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [False, False, True, True, False]
-    })
-    d['x0'] = 1 - d['x']
-    pmat = numpy.asarray(d.loc[:, ['x0', 'x']])
-
-    wvpy.util.dual_density_plot_proba1(
-        probs=pmat,
-        istrue=d['y'],
-    )
-    """
-    istrue = numpy.asarray(istrue)
-    probs = numpy.asarray(probs)
-    matplotlib.pyplot.gcf().clear()
-    preds_on_positive = [
-        probs[i, 1] for i in range(len(probs)) if istrue[i] == truth_target
-    ]
-    preds_on_negative = [
-        probs[i, 1] for i in range(len(probs)) if not istrue[i] == truth_target
-    ]
-    seaborn.kdeplot(preds_on_positive, label=positive_label, shade=True)
-    seaborn.kdeplot(preds_on_negative, label=negative_label, shade=True)
-    matplotlib.pyplot.ylabel(ylabel)
-    matplotlib.pyplot.xlabel(xlabel)
-    matplotlib.pyplot.title(title)
-    matplotlib.pyplot.legend()
-    if show:
-        matplotlib.pyplot.show()
-
- -
- -

Plot a dual density plot of numeric prediction probs[:,1] against boolean istrue.

- -

:param probs: matrix of numeric predictions (as returned from predict_proba()) -:param istrue: truth target -:param title: title of plot -:param truth_target: value considered true -:param positive_label=label for positive class -:param negative_label=label for negative class -:param ylabel=y axis label -:param xlabel=x axis label -:param show: logical, if True call matplotlib.pyplot.show() -:return: None

- -

Example:

- -

d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] -}) -d['x0'] = 1 - d['x'] -pmat = numpy.asarray(d.loc[:, ['x0', 'x']])

- -

wvpy.util.dual_density_plot_proba1( - probs=pmat, - istrue=d['y'], -)

-
- - -
-
-
#   - - - def - dual_hist_plot_proba1(probs, istrue, *, show=True): -
- -
- View Source -
def dual_hist_plot_proba1(probs, istrue, *, show=True):
-    """
-    plot a dual histogram plot of numeric prediction probs[:,1] against boolean istrue
-
-    :param probs: vector of probability predictions
-    :param istrue: vector of ground truth to condition on
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [False, False, True, True, False]
-    })
-    d['x0'] = 1 - d['x']
-    pmat = numpy.asarray(d.loc[:, ['x0', 'x']])
-
-    wvpy.util.dual_hist_plot_proba1(
-        probs=pmat,
-        istrue=d['y'],
-    )
-    """
-    istrue = numpy.asarray(istrue)
-    probs = numpy.asarray(probs)
-    matplotlib.pyplot.gcf().clear()
-    pf = pandas.DataFrame(
-        {"prob": [probs[i, 1] for i in range(probs.shape[0])], "istrue": istrue}
-    )
-    g = seaborn.FacetGrid(pf, row="istrue", height=4, aspect=3)
-    bins = numpy.arange(0, 1.1, 0.1)
-    g.map(matplotlib.pyplot.hist, "prob", bins=bins)
-    if show:
-        matplotlib.pyplot.show()
-
- -
- -

plot a dual histogram plot of numeric prediction probs[:,1] against boolean istrue

- -

:param probs: vector of probability predictions -:param istrue: vector of ground truth to condition on -:param show: logical, if True call matplotlib.pyplot.show() -:return: None

- -

Example:

- -

d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] -}) -d['x0'] = 1 - d['x'] -pmat = numpy.asarray(d.loc[:, ['x0', 'x']])

- -

wvpy.util.dual_hist_plot_proba1( - probs=pmat, - istrue=d['y'], -)

-
- - -
-
-
#   - - - def - gain_curve_plot(prediction, outcome, title='Gain curve plot', *, show=True): -
- -
- View Source -
def gain_curve_plot(prediction, outcome, title="Gain curve plot", *, show=True):
-    """
-    plot cumulative outcome as a function of prediction order (descending)
-
-    :param prediction: vector of numeric predictions
-    :param outcome: vector of actual values
-    :param title: plot title
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [0, 0, 1, 1, 0]
-    })
-
-    wvpy.util.gain_curve_plot(
-        prediction=d['x'],
-        outcome=d['y'],
-    )
-    """
-
-    df = pandas.DataFrame(
-        {
-            "prediction": numpy.array(prediction).copy(),
-            "outcome": numpy.array(outcome).copy(),
-        }
-    )
-
-    # compute the gain curve
-    df.sort_values(["prediction"], ascending=[False], inplace=True)
-    df["fraction_of_observations_by_prediction"] = (
-        numpy.arange(df.shape[0]) + 1.0
-    ) / df.shape[0]
-    df["cumulative_outcome"] = df["outcome"].cumsum()
-    df["cumulative_outcome_fraction"] = df["cumulative_outcome"] / numpy.max(
-        df["cumulative_outcome"]
-    )
-
-    # compute the wizard curve
-    df.sort_values(["outcome"], ascending=[False], inplace=True)
-    df["fraction_of_observations_by_wizard"] = (
-        numpy.arange(df.shape[0]) + 1.0
-    ) / df.shape[0]
-
-    df["cumulative_outcome_by_wizard"] = df["outcome"].cumsum()
-    df["cumulative_outcome_fraction_wizard"] = df[
-        "cumulative_outcome_by_wizard"
-    ] / numpy.max(df["cumulative_outcome_by_wizard"])
-
-    seaborn.lineplot(
-        x="fraction_of_observations_by_wizard",
-        y="cumulative_outcome_fraction_wizard",
-        color="gray",
-        linestyle="--",
-        data=df,
-    )
-
-    seaborn.lineplot(
-        x="fraction_of_observations_by_prediction",
-        y="cumulative_outcome_fraction",
-        data=df,
-    )
-
-    seaborn.lineplot(x=[0, 1], y=[0, 1], color="red")
-    matplotlib.pyplot.xlabel("fraction of observations by sort criterion")
-    matplotlib.pyplot.ylabel("cumulative outcome fraction")
-    matplotlib.pyplot.title(title)
-    if show:
-        matplotlib.pyplot.show()
-
- -
- -

plot cumulative outcome as a function of prediction order (descending)

- -

:param prediction: vector of numeric predictions -:param outcome: vector of actual values -:param title: plot title -:param show: logical, if True call matplotlib.pyplot.show() -:return: None

- -

Example:

- -

d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [0, 0, 1, 1, 0] -})

- -

wvpy.util.gain_curve_plot( - prediction=d['x'], - outcome=d['y'], -)

-
- - -
-
-
#   - - - def - lift_curve_plot(prediction, outcome, title='Lift curve plot', *, show=True): -
- -
- View Source -
def lift_curve_plot(prediction, outcome, title="Lift curve plot", *, show=True):
-    """
-    plot lift as a function of prediction order (descending)
-
-    :param prediction: vector of numeric predictions
-    :param outcome: vector of actual values
-    :param title: plot title
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None
-
-    Example:
-
-    d = pandas.DataFrame({
-        'x': [.1, .2, .3, .4, .5],
-        'y': [0, 0, 1, 1, 0]
-    })
-
-    wvpy.util.lift_curve_plot(
-        prediction=d['x'],
-        outcome=d['y'],
-    )
-    """
-
-    df = pandas.DataFrame(
-        {
-            "prediction": numpy.array(prediction).copy(),
-            "outcome": numpy.array(outcome).copy(),
-        }
-    )
-
-    # compute the gain curve
-    df.sort_values(["prediction"], ascending=[False], inplace=True)
-    df["fraction_of_observations_by_prediction"] = (
-        numpy.arange(df.shape[0]) + 1.0
-    ) / df.shape[0]
-    df["cumulative_outcome"] = df["outcome"].cumsum()
-    df["cumulative_outcome_fraction"] = df["cumulative_outcome"] / numpy.max(
-        df["cumulative_outcome"]
-    )
-
-    # move to lift
-    df["lift"] = (
-        df["cumulative_outcome_fraction"] / df["fraction_of_observations_by_prediction"]
-    )
-    seaborn.lineplot(x="fraction_of_observations_by_prediction", y="lift", data=df)
-    matplotlib.pyplot.axhline(y=1, color="red")
-    matplotlib.pyplot.title(title)
-    if show:
-        matplotlib.pyplot.show()
-
- -
- -

plot lift as a function of prediction order (descending)

- -

:param prediction: vector of numeric predictions -:param outcome: vector of actual values -:param title: plot title -:param show: logical, if True call matplotlib.pyplot.show() -:return: None

- -

Example:

- -

d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [0, 0, 1, 1, 0] -})

- -

wvpy.util.lift_curve_plot( - prediction=d['x'], - outcome=d['y'], -)

-
- - -
-
-
#   - - - def - search_grid(inp: dict) -> List: -
- -
- View Source -
def search_grid(inp: dict) -> List:
-    """
-    build a cross product of all named dictionary entries
-
-    :param inp: dictionary of value lists
-    :return: list of value dictionaries
-    """
-
-    gen = (dict(zip(inp.keys(), values)) for values in itertools.product(*inp.values()))
-    return [ci for ci in gen]
-
- -
- -

build a cross product of all named dictionary entries

- -

:param inp: dictionary of value lists -:return: list of value dictionaries

-
- - -
-
-
#   - - - def - grid_to_df(grid: List) -> pandas.core.frame.DataFrame: -
- -
- View Source -
def grid_to_df(grid: List) -> pandas.DataFrame:
-    """
-    convert a search_grid list of maps to a pandas data frame
-
-    :param grid: list of combos
-    :return: data frame with one row per combo
-    """
-
-    n = len(grid)
-    keys = [ki for ki in grid[1].keys()]
-    return pandas.DataFrame({ki: [grid[i][ki] for i in range(n)] for ki in keys})
-
- -
- -

convert a search_grid list of maps to a pandas data frame

- -

:param grid: list of combos -:return: data frame with one row per combo

-
- - -
-
-
#   - - - def - eval_fn_per_row(f, x2, df: pandas.core.frame.DataFrame) -> List: -
- -
- View Source -
def eval_fn_per_row(f, x2, df: pandas.DataFrame) -> List:
-    """
-    evaluate f(row-as-map, x2) for rows in df
-
-    :param f: function to evaluate
-    :param x2: extra argument
-    :param df: data frame to take rows from
-    :return: list of evaluations
-    """
-
-    assert isinstance(df, pandas.DataFrame)
-    return [f({k: df.loc[i, k] for k in df.columns}, x2) for i in range(df.shape[0])]
-
- -
- -

evaluate f(row-as-map, x2) for rows in df

- -

:param f: function to evaluate -:param x2: extra argument -:param df: data frame to take rows from -:return: list of evaluations

-
- - -
-
-
#   - - - def - perm_score_vars( - d: pandas.core.frame.DataFrame, - istrue, - model, - modelvars: List[str], - k=5 -): -
- -
- View Source -
def perm_score_vars(d: pandas.DataFrame, istrue, model, modelvars: List[str], k=5):
-    """
-    evaluate model~istrue on d permuting each of the modelvars and return variable importances
-
-    :param d: data source (copied)
-    :param istrue: y-target
-    :param model: model to evaluate
-    :param modelvars: names of variables to permute
-    :param k: number of permutations
-    :return: score data frame
-    """
-
-    d2 = d[modelvars].copy()
-    d2.reset_index(inplace=True, drop=True)
-    istrue = numpy.asarray(istrue)
-    preds = model.predict_proba(d2[modelvars])
-    basedev = mean_deviance(preds[:, 1], istrue)
-
-    def perm_score_var(victim):
-        """Permutation score column named victim"""
-        dorig = numpy.array(d2[victim].copy())
-        dnew = numpy.array(d2[victim].copy())
-
-        def perm_score_var_once():
-            """apply fn once, used for list comprehension"""
-            numpy.random.shuffle(dnew)
-            d2[victim] = dnew
-            predsp = model.predict_proba(d2[modelvars])
-            permdev = mean_deviance(predsp[:, 1], istrue)
-            return permdev
-
-        # noinspection PyUnusedLocal
-        devs = [perm_score_var_once() for rep in range(k)]
-        d2[victim] = dorig
-        return numpy.mean(devs), statistics.stdev(devs)
-
-    stats = [perm_score_var(victim) for victim in modelvars]
-    vf = pandas.DataFrame({"var": modelvars})
-    vf["importance"] = [di[0] - basedev for di in stats]
-    vf["importance_dev"] = [di[1] for di in stats]
-    vf.sort_values(by=["importance"], ascending=False, inplace=True)
-    vf = vf.reset_index(inplace=False, drop=True)
-    return vf
-
- -
- -

evaluate model~istrue on d permuting each of the modelvars and return variable importances

- -

:param d: data source (copied) -:param istrue: y-target -:param model: model to evaluate -:param modelvars: names of variables to permute -:param k: number of permutations -:return: score data frame

-
- - -
-
-
#   - - - def - threshold_statistics( - d: pandas.core.frame.DataFrame, - *, - model_predictions: str, - yvalues: str, - y_target=True -) -> pandas.core.frame.DataFrame: -
- -
- View Source -
def threshold_statistics(
-    d: pandas.DataFrame, *, model_predictions: str, yvalues: str, y_target=True
-) -> pandas.DataFrame:
-    """
-    Compute a number of threshold statistics of how well model predictions match a truth target.
-
-    :param d: pandas.DataFrame to take values from
-    :param model_predictions: name of predictions column
-    :param yvalues: name of truth values column
-    :param y_target: value considered to be true
-    :return: summary statistic frame, include before and after pseudo-observations
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [1, 2, 3, 4, 5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.threshold_statistics(
-        d,
-        model_predictions='x',
-        yvalues='y',
-    )
-    """
-    # make a thin frame to re-sort for cumulative statistics
-    sorted_frame = pandas.DataFrame(
-        {"threshold": d[model_predictions].copy(), "truth": d[yvalues] == y_target}
-    )
-    sorted_frame["orig_index"] = sorted_frame.index + 0
-    sorted_frame.sort_values(
-        ["threshold", "orig_index"], ascending=[False, True], inplace=True
-    )
-    sorted_frame.reset_index(inplace=True, drop=True)
-    sorted_frame["notY"] = 1 - sorted_frame["truth"]  # falses
-    sorted_frame["one"] = 1
-    del sorted_frame["orig_index"]
-
-    # pseudo-observation to get end-case (accept nothing case)
-    eps = 1.0e-6
-    sorted_frame = pandas.concat(
-        [
-            pandas.DataFrame(
-                {
-                    "threshold": [sorted_frame["threshold"].max() + eps],
-                    "truth": [False],
-                    "notY": [0],
-                    "one": [0],
-                }
-            ),
-            sorted_frame,
-            pandas.DataFrame(
-                {
-                    "threshold": [sorted_frame["threshold"].min() - eps],
-                    "truth": [False],
-                    "notY": [0],
-                    "one": [0],
-                }
-            ),
-        ]
-    )
-    sorted_frame.reset_index(inplace=True, drop=True)
-
-    # basic cumulative facts
-    sorted_frame["count"] = sorted_frame["one"].cumsum()  # predicted true so far
-    sorted_frame["fraction"] = sorted_frame["count"] / max(1, sorted_frame["one"].sum())
-    sorted_frame["precision"] = sorted_frame["truth"].cumsum() / sorted_frame[
-        "count"
-    ].clip(lower=1)
-    sorted_frame["true_positive_rate"] = sorted_frame["truth"].cumsum() / max(
-        1, sorted_frame["truth"].sum()
-    )
-    sorted_frame["false_positive_rate"] = sorted_frame["notY"].cumsum() / max(
-        1, sorted_frame["notY"].sum()
-    )
-    sorted_frame["true_negative_rate"] = (
-        sorted_frame["notY"].sum() - sorted_frame["notY"].cumsum()
-    ) / max(1, sorted_frame["notY"].sum())
-    sorted_frame["false_negative_rate"] = (
-        sorted_frame["truth"].sum() - sorted_frame["truth"].cumsum()
-    ) / max(1, sorted_frame["truth"].sum())
-    sorted_frame["accuracy"] = (
-        sorted_frame["truth"].cumsum()  # true positive count
-        + sorted_frame["notY"].sum()
-        - sorted_frame["notY"].cumsum()  # true negative count
-    ) / sorted_frame["one"].sum()
-
-    # approximate cdf work
-    sorted_frame["cdf"] = 1 - sorted_frame["fraction"]
-
-    # derived facts and synonyms
-    sorted_frame["recall"] = sorted_frame["true_positive_rate"]
-    sorted_frame["sensitivity"] = sorted_frame["recall"]
-    sorted_frame["specificity"] = 1 - sorted_frame["false_positive_rate"]
-
-    # re-order for neatness
-    sorted_frame["new_index"] = sorted_frame.index.copy()
-    sorted_frame.sort_values(["new_index"], ascending=[False], inplace=True)
-    sorted_frame.reset_index(inplace=True, drop=True)
-
-    # clean up
-    del sorted_frame["notY"]
-    del sorted_frame["one"]
-    del sorted_frame["new_index"]
-    del sorted_frame["truth"]
-    return sorted_frame
-
- -
- -

Compute a number of threshold statistics of how well model predictions match a truth target.

- -

:param d: pandas.DataFrame to take values from -:param model_predictions: name of predictions column -:param yvalues: name of truth values column -:param y_target: value considered to be true -:return: summary statistic frame, include before and after pseudo-observations

- -

Example:

- -

import pandas -import wvpy.util

- -

d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] -})

- -

wvpy.util.threshold_statistics( - d, - model_predictions='x', - yvalues='y', -)

-
- - -
-
-
#   - - - def - threshold_plot( - d: pandas.core.frame.DataFrame, - pred_var: str, - truth_var: str, - truth_target: bool = True, - threshold_range: Iterable[float] = (-inf, inf), - plotvars: Iterable[str] = ('precision', 'recall'), - title: str = 'Measures as a function of threshold', - *, - show: bool = True -) -> None: -
- -
- View Source -
def threshold_plot(
-    d: pandas.DataFrame,
-    pred_var: str,
-    truth_var: str,
-    truth_target: bool = True,
-    threshold_range: Iterable[float] = (-math.inf, math.inf),
-    plotvars: Iterable[str] = ("precision", "recall"),
-    title: str = "Measures as a function of threshold",
-    *,
-    show: bool = True,
-) -> None:
-    """
-    Produce multiple facet plot relating the performance of using a threshold greater than or equal to
-    different values at predicting a truth target.
-
-    :param d: pandas.DataFrame to plot
-    :param pred_var: name of column of numeric predictions
-    :param truth_var: name of column with reference truth
-    :param truth_target: value considered true
-    :param threshold_range: x-axis range to plot
-    :param plotvars: list of metrics to plot, must come from ['threshold', 'count', 'fraction',
-        'true_positive_rate', 'false_positive_rate', 'true_negative_rate', 'false_negative_rate',
-        'precision', 'recall', 'sensitivity', 'specificity', 'accuracy']
-    :param title: title for plot
-    :param show: logical, if True call matplotlib.pyplot.show()
-    :return: None, plot produced as a side effect
-
-    Example:
-
-    import pandas
-    import wvpy.util
-
-    d = pandas.DataFrame({
-        'x': [1, 2, 3, 4, 5],
-        'y': [False, False, True, True, False]
-    })
-
-    wvpy.util.threshold_plot(
-        d,
-        pred_var='x',
-        truth_var='y',
-        plotvars=("sensitivity", "specificity"),
-    )
-    """
-    if isinstance(plotvars, str):
-        plotvars = [plotvars]
-    else:
-        plotvars = list(plotvars)
-    assert isinstance(plotvars, list)
-    assert len(plotvars) > 0
-    assert all([isinstance(v, str) for v in plotvars])
-    threshold_range = list(threshold_range)
-    assert len(threshold_range) == 2
-    frame = d[[pred_var, truth_var]].copy()
-    frame.reset_index(inplace=True, drop=True)
-    frame["outcol"] = frame[truth_var] == truth_target
-
-    prt_frame = threshold_statistics(
-        frame, model_predictions=pred_var, yvalues="outcol",
-    )
-    bad_plot_vars = set(plotvars) - set(prt_frame.columns)
-    if len(bad_plot_vars) > 0:
-        raise ValueError(
-            "allowed plotting variables are: "
-            + str(prt_frame.columns)
-            + ", "
-            + str(bad_plot_vars)
-            + " unexpected."
-        )
-
-    selector = (threshold_range[0] <= prt_frame.threshold) & (
-        prt_frame.threshold <= threshold_range[1]
-    )
-    to_plot = prt_frame.loc[selector, :]
-
-    if len(plotvars) > 1:
-        reshaper = RecordMap(
-            blocks_out=RecordSpecification(
-                pandas.DataFrame({"measure": plotvars, "value": plotvars}),
-                control_table_keys=["measure"],
-                record_keys=["threshold"],
-            )
-        )
-        prtlong = reshaper.transform(to_plot)
-        grid = seaborn.FacetGrid(
-            prtlong, row="measure", row_order=plotvars, aspect=2, sharey=False
-        )
-        grid = grid.map(matplotlib.pyplot.plot, "threshold", "value")
-        grid.set(ylabel=None)
-        matplotlib.pyplot.subplots_adjust(top=0.9)
-        grid.fig.suptitle(title)
-    else:
-        # can plot off primary frame
-        seaborn.lineplot(
-            data=to_plot, x="threshold", y=plotvars[0],
-        )
-        matplotlib.pyplot.suptitle(title)
-        matplotlib.pyplot.title(f"measure = {plotvars[0]}")
-
-    if show:
-        matplotlib.pyplot.show()
-
- -
- -

Produce multiple facet plot relating the performance of using a threshold greater than or equal to -different values at predicting a truth target.

- -

:param d: pandas.DataFrame to plot -:param pred_var: name of column of numeric predictions -:param truth_var: name of column with reference truth -:param truth_target: value considered true -:param threshold_range: x-axis range to plot -:param plotvars: list of metrics to plot, must come from ['threshold', 'count', 'fraction', - 'true_positive_rate', 'false_positive_rate', 'true_negative_rate', 'false_negative_rate', - 'precision', 'recall', 'sensitivity', 'specificity', 'accuracy'] -:param title: title for plot -:param show: logical, if True call matplotlib.pyplot.show() -:return: None, plot produced as a side effect

- -

Example:

- -

import pandas -import wvpy.util

- -

d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] -})

- -

wvpy.util.threshold_plot( - d, - pred_var='x', - truth_var='y', - plotvars=("sensitivity", "specificity"), -)

-
- - -
-
-
#   - - - def - fit_onehot_enc( - d: pandas.core.frame.DataFrame, - *, - categorical_var_names: Iterable[str] -) -> dict: -
- -
- View Source -
def fit_onehot_enc(
-    d: pandas.DataFrame, *, categorical_var_names: Iterable[str]
-) -> dict:
-    """
-    Fit a sklearn OneHot Encoder to categorical_var_names columns.
-    Note: we suggest preferring vtreat ( https://github.com/WinVector/pyvtreat ) over this example code.
-
-    :param d: training data
-    :param categorical_var_names: list of column names to learn transform from
-    :return: encoding bundle dictionary, see apply_onehot_enc() for use.
-    """
-    assert isinstance(d, pandas.DataFrame)
-    assert not isinstance(
-        categorical_var_names, str
-    )  # single name, should be in a list
-    categorical_var_names = list(categorical_var_names)  # clean copy
-    assert numpy.all([isinstance(v, str) for v in categorical_var_names])
-    assert len(categorical_var_names) > 0
-    enc = sklearn.preprocessing.OneHotEncoder(
-        categories="auto", drop=None, sparse=False, handle_unknown="ignore"  # default
-    )
-    enc.fit(d[categorical_var_names])
-    produced_column_names = list(enc.get_feature_names_out())
-    # return the structure
-    encoder_bundle = {
-        "categorical_var_names": categorical_var_names,
-        "enc": enc,
-        "produced_column_names": produced_column_names,
-    }
-    return encoder_bundle
-
- -
- -

Fit a sklearn OneHot Encoder to categorical_var_names columns. -Note: we suggest preferring vtreat ( https://github.com/WinVector/pyvtreat ) over this example code.

- -

:param d: training data -:param categorical_var_names: list of column names to learn transform from -:return: encoding bundle dictionary, see apply_onehot_enc() for use.

-
- - -
-
-
#   - - - def - apply_onehot_enc( - d: pandas.core.frame.DataFrame, - *, - encoder_bundle: dict -) -> pandas.core.frame.DataFrame: -
- -
- View Source -
def apply_onehot_enc(d: pandas.DataFrame, *, encoder_bundle: dict) -> pandas.DataFrame:
-    """
-    Apply a one hot encoding bundle to a data frame.
-
-    :param d: input data frame
-    :param encoder_bundle: transform specification, built by fit_onehot_enc()
-    :return: transformed data frame
-    """
-    assert isinstance(d, pandas.DataFrame)
-    assert isinstance(encoder_bundle, dict)
-    # one hot re-code columns, preserving column names info
-    one_hotted = pandas.DataFrame(
-        encoder_bundle["enc"].transform(d[encoder_bundle["categorical_var_names"]])
-    )
-    one_hotted.columns = encoder_bundle["produced_column_names"]
-    # copy over non-invovled columns
-    cat_set = set(encoder_bundle["categorical_var_names"])
-    complementary_columns = [c for c in d.columns if c not in cat_set]
-    res = pandas.concat([d[complementary_columns], one_hotted], axis=1)
-    return res
-
- -
- -

Apply a one hot encoding bundle to a data frame.

- -

:param d: input data frame -:param encoder_bundle: transform specification, built by fit_onehot_enc() -:return: transformed data frame

-
- - -
-
-
- #   - - - class - suppress_stdout_stderr: -
- -
- View Source -
class suppress_stdout_stderr(object):
-    '''
-    A context manager for doing a "deep suppression" of stdout and stderr in
-    Python, i.e. will suppress all print, even if the print originates in a
-    compiled C/Fortran sub-function.
-       This will not suppress raised exceptions, since exceptions are printed
-    to stderr just before a script exits, and after the context manager has
-    exited (at least, I think that is why it lets exceptions through).
-
-    '''
-    def __init__(self):
-        # Open a pair of null files
-        self.null_fds = [os.open(os.devnull, os.O_RDWR) for x in range(2)]
-        # Save the actual stdout (1) and stderr (2) file descriptors.
-        self.save_fds = (os.dup(1), os.dup(2))
-
-    def __enter__(self):
-        # Assign the null pointers to stdout and stderr.
-        os.dup2(self.null_fds[0], 1)
-        os.dup2(self.null_fds[1], 2)
-
-    def __exit__(self, *_):
-        # Re-assign the real stdout/stderr back to (1) and (2)
-        os.dup2(self.save_fds[0], 1)
-        os.dup2(self.save_fds[1], 2)
-        # Close the null files
-        os.close(self.null_fds[0])
-        os.close(self.null_fds[1])
-
- -
- -

A context manager for doing a "deep suppression" of stdout and stderr in -Python, i.e. will suppress all print, even if the print originates in a -compiled C/Fortran sub-function. - This will not suppress raised exceptions, since exceptions are printed -to stderr just before a script exits, and after the context manager has -exited (at least, I think that is why it lets exceptions through).

-
- - -
-
#   - - - suppress_stdout_stderr() -
- -
- View Source -
    def __init__(self):
-        # Open a pair of null files
-        self.null_fds = [os.open(os.devnull, os.O_RDWR) for x in range(2)]
-        # Save the actual stdout (1) and stderr (2) file descriptors.
-        self.save_fds = (os.dup(1), os.dup(2))
-
- -
- - - -
-
-
- - \ No newline at end of file diff --git a/pkg/setup.py b/pkg/setup.py index 7d2b7f6..8ef141b 100644 --- a/pkg/setup.py +++ b/pkg/setup.py @@ -1,9 +1,9 @@ # setup.py import setuptools -DESCRIPTION = "Simple utilities for teaching Pandas and scikit learn." +DESCRIPTION = "Convert Jupyter notebooks to and from Python files." LONG_DESCRIPTION = """ -Simple utilities for teaching Pandas, Jupyter, seaborn, and sklearn. +Convert Jupyter notebooks to and from Python files. """ setuptools.setup( @@ -14,13 +14,6 @@ url="https://github.com/WinVector/wvpy", packages=setuptools.find_packages(exclude=['tests', 'Examples']), install_requires=[ - "numpy", - "pandas", - "sklearn", - "seaborn", - "matplotlib", - "vtreat>=1.1.1", - "data_algebra>=1.2.0", "IPython", "nbformat", "nbconvert" diff --git a/pkg/tests/test_cross_plan1.py b/pkg/tests/test_cross_plan1.py deleted file mode 100644 index 845f70d..0000000 --- a/pkg/tests/test_cross_plan1.py +++ /dev/null @@ -1,23 +0,0 @@ -import wvpy.util - - -def test_cross_plan1(): - n = 10 - k = 3 - plan = wvpy.util.mk_cross_plan(n, k) - - assert len(plan) == k - universe = set(range(n)) - saw = set() - for split in plan: - train = split["train"] - test = split["test"] - assert len(train) > 0 - assert len(test) > 0 - assert len(set(train) - universe) == 0 - assert len(set(test) - universe) == 0 - assert len(set(train).intersection(test)) == 0 - assert len(saw.intersection(test)) == 0 - saw.update(test) - - assert universe == saw diff --git a/pkg/tests/test_cross_predict.py b/pkg/tests/test_cross_predict.py deleted file mode 100644 index bb420af..0000000 --- a/pkg/tests/test_cross_predict.py +++ /dev/null @@ -1,36 +0,0 @@ -import numpy -import pandas -import sklearn.linear_model -import wvpy.util - - -def test_cross_predict_1(): - numpy.random.seed(2022) - d = pandas.DataFrame({"x": range(10),}) - d["y"] = 2 * d["x"] + 1 - plan = wvpy.util.mk_cross_plan(n=d.shape[0], k=3) - fitter = sklearn.linear_model.LinearRegression() - preds_cross = wvpy.util.cross_predict_model( - fitter=fitter, X=d.loc[:, ["x"]], y=d["y"], plan=plan - ) - assert numpy.max(numpy.abs(preds_cross - d["y"])) < 1e-5 - fitter.fit(X=d.loc[:, ["x"]], y=d["y"]) - preds_regular = fitter.predict(d.loc[:, ["x"]]) - assert numpy.max(numpy.abs(preds_regular - d["y"])) < 1e-5 - - -def test_cross_predict_proba_1(): - numpy.random.seed(2022) - d = pandas.DataFrame({"x": numpy.random.normal(size=100),}) - d["y"] = numpy.where( - (d["x"] + numpy.random.normal(size=d.shape[0])) > 0.0, "b", "a" - ) - plan = wvpy.util.mk_cross_plan(n=d.shape[0], k=3) - fitter = sklearn.linear_model.LogisticRegression() - preds_cross = wvpy.util.cross_predict_model_proba( - fitter=fitter, X=d.loc[:, ["x"]], y=d["y"], plan=plan - ) - fitter.fit(X=d.loc[:, ["x"]], y=d["y"]) - preds_regular = fitter.predict_proba(d.loc[:, ["x"]]) - assert numpy.abs(preds_regular - preds_cross).max(axis=0).max() < 0.1 - diff --git a/pkg/tests/test_deviance_calc.py b/pkg/tests/test_deviance_calc.py deleted file mode 100644 index e382708..0000000 --- a/pkg/tests/test_deviance_calc.py +++ /dev/null @@ -1,13 +0,0 @@ -import numpy -import wvpy.util - - -def test_dev_calc_1(): - x = [True, True, False, False] - p = [0.7, 0.8, 0.2, 0.1] - dev = wvpy.util.mean_deviance(istrue=x, predictions=p) - assert dev > 0 - assert abs(0.4541612811124891 - dev) < 1e-3 - null_dev = wvpy.util.mean_null_deviance(x) - assert dev < null_dev - assert abs(-2 * numpy.log(0.5) - null_dev) < 1e-3 diff --git a/pkg/tests/test_eval_fn_pre_row.py b/pkg/tests/test_eval_fn_pre_row.py deleted file mode 100644 index abd6bec..0000000 --- a/pkg/tests/test_eval_fn_pre_row.py +++ /dev/null @@ -1,13 +0,0 @@ -import pandas -import wvpy.util - - -def test_eval_fn_per_row(): - d = pandas.DataFrame({"a": [1, 2], "b": [3, 4],}) - - def f(mp, x): - return mp["a"] + mp["b"] + x - - res = wvpy.util.eval_fn_per_row(f, 7, d) - expect = [11, 13] - assert res == expect diff --git a/pkg/tests/test_match_auc.py b/pkg/tests/test_match_auc.py deleted file mode 100644 index 1aebe86..0000000 --- a/pkg/tests/test_match_auc.py +++ /dev/null @@ -1,7 +0,0 @@ -import wvpy.util - - -def test_match_auc_1(): - for auc in [0, 0.1, 0.5, 0.9, 1]: - fit = wvpy.util.matching_roc_area_curve(auc) - assert abs(fit["auc"] - auc) < 1.0e-3 diff --git a/pkg/tests/test_onehot.py b/pkg/tests/test_onehot.py deleted file mode 100644 index fc002a3..0000000 --- a/pkg/tests/test_onehot.py +++ /dev/null @@ -1,59 +0,0 @@ -import data_algebra.test_util -import pandas as pd -import wvpy.util -import vtreat - - -def test_onehot(): - d = pd.DataFrame( - { - "xc": ["a", "b", "b"], - "xd": [1, 1, 2], # force re-encoding - "xn": [1.0, 2.0, 3.0], - } - ) - - enc_bundle = wvpy.util.fit_onehot_enc(d, categorical_var_names=["xc", "xd"]) - res = wvpy.util.apply_onehot_enc(d, encoder_bundle=enc_bundle) - - expect = pd.DataFrame( - { - "xn": [1.0, 2.0, 3.0], - "xc_a": [1.0, 0.0, 0.0], - "xc_b": [0.0, 1.0, 1.0], - "xd_1": [1.0, 1.0, 0.0], - "xd_2": [0.0, 0.0, 1.0], - } - ) - - assert data_algebra.test_util.equivalent_frames(res, expect, check_row_order=True) - - -def test_vtreat_onehot(): - d = pd.DataFrame( - { - "xc": ["a", "b", "b"], - "xd": ["1", "1", "2"], # vtreat picks columns to convert by type - "xn": [1.0, 2.0, 3.0], - } - ) - - treatment = vtreat.UnsupervisedTreatment( - params=vtreat.unsupervised_parameters( - {"coders": {"clean_copy", "indicator_code"}} - ) - ) - treatment.fit(d) - res = treatment.transform(d) - - expect = pd.DataFrame( - { - "xn": [1.0, 2.0, 3.0], - "xd_lev_1": [1.0, 1.0, 0.0], - "xd_lev_2": [0.0, 0.0, 1.0], - "xc_lev_b": [0.0, 1.0, 1.0], - "xc_lev_a": [1.0, 0.0, 0.0], - } - ) - - assert data_algebra.test_util.equivalent_frames(res, expect, check_row_order=True) diff --git a/pkg/tests/test_perm_score_vars.py b/pkg/tests/test_perm_score_vars.py deleted file mode 100644 index 3eb5858..0000000 --- a/pkg/tests/test_perm_score_vars.py +++ /dev/null @@ -1,30 +0,0 @@ -import pandas -import numpy.random -import sklearn.linear_model -import wvpy.util -import data_algebra.test_util - - -def test_perm_score_vars(): - numpy.random.seed(2022) - d = pandas.DataFrame({"y": numpy.random.normal(size=100),}) - for i in range(5): - vname = f"x_{i}" - d[vname] = numpy.random.normal(size=d.shape[0]) - d["y"] = d["y"] + d[vname] - for i in range(5): - vname = f"n_{i}" - d[vname] = numpy.random.normal(size=d.shape[0]) - d["y"] = d["y"] > 0.1 - vars = [c for c in d.columns if c != "y"] - model = sklearn.linear_model.LogisticRegression() - model.fit(d.loc[:, vars], d["y"]) - scores = wvpy.util.perm_score_vars( - d=d, model=model, istrue=d["y"], modelvars=vars, k=100, - ) - scores["signal_variable"] = [v.startswith("x_") for v in scores["var"]] - worst_good = numpy.min(scores.loc[scores["signal_variable"], "importance"]) - best_bad = numpy.max( - scores.loc[numpy.logical_not(scores["signal_variable"]), "importance"] - ) - assert worst_good > best_bad diff --git a/pkg/tests/test_plots.py b/pkg/tests/test_plots.py deleted file mode 100644 index d5a0a01..0000000 --- a/pkg/tests/test_plots.py +++ /dev/null @@ -1,132 +0,0 @@ - -import numpy -import pandas -import matplotlib.pyplot -import wvpy.util - - -# from: -# https://github.com/WinVector/wvpy/blob/main/examples/example_graphs.ipynb -def test_graphs(monkeypatch): - # https://stackoverflow.com/a/60127271/6901725 - monkeypatch.setattr(matplotlib.pyplot, 'show', lambda: None) - - # %% - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - extra_points=pandas.DataFrame({ - 'tpr': [0, 1], - 'fpr': [0, 1], - 'label': ['AAA', 'BBB'] - }) - ) - - # %% - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.dual_density_plot( - probs=d['x'], - istrue=d['y'], - ) - - # %% - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.dual_hist_plot( - probs=d['x'], - istrue=d['y'], - ) - - # %% - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [0, 0, 1, 1, 0] - }) - - wvpy.util.gain_curve_plot( - prediction=d['x'], - outcome=d['y'], - ) - - # %% - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [0, 0, 1, 1, 0] - }) - - wvpy.util.lift_curve_plot( - prediction=d['x'], - outcome=d['y'], - ) - - # %% - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.threshold_plot( - d, - pred_var='x', - truth_var='y', - plotvars=("sensitivity", "specificity"), - ) - - # %% - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.threshold_plot( - d, - pred_var='x', - truth_var='y', - plotvars=("precision", "recall", "accuracy"), - ) - - # %% - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - d['x0'] = 1 - d['x'] - pmat = numpy.asarray(d.loc[:, ['x0', 'x']]) - - wvpy.util.dual_density_plot_proba1( - probs=pmat, - istrue=d['y'], - ) - - # %% - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - d['x0'] = 1 - d['x'] - pmat = numpy.asarray(d.loc[:, ['x0', 'x']]) - - wvpy.util.dual_hist_plot_proba1( - probs=pmat, - istrue=d['y'], - ) diff --git a/pkg/tests/test_se.py b/pkg/tests/test_se.py deleted file mode 100644 index 6c57050..0000000 --- a/pkg/tests/test_se.py +++ /dev/null @@ -1,12 +0,0 @@ - -import os -import pytest - -from wvpy.util import suppress_stdout_stderr - - -def test_suppress_stdout_stderr(): - with suppress_stdout_stderr(): - x = 1 + 1 # not much of a test - assert x == 2 - diff --git a/pkg/tests/test_search_grid.py b/pkg/tests/test_search_grid.py deleted file mode 100644 index e2fea76..0000000 --- a/pkg/tests/test_search_grid.py +++ /dev/null @@ -1,17 +0,0 @@ -import pandas - -import wvpy.util -import data_algebra.test_util - - -def test_search_grid(): - res = wvpy.util.search_grid({"a": [1, 2], "b": [3, 4]}) - expect = [{"a": 1, "b": 3}, {"a": 1, "b": 4}, {"a": 2, "b": 3}, {"a": 2, "b": 4}] - assert res == expect - - -def test_search_grid_to_df(): - res = wvpy.util.search_grid({"a": [1, 2], "b": [3, 4]}) - res = wvpy.util.grid_to_df(res) - expect = pandas.DataFrame({"a": [1, 1, 2, 2], "b": [3, 4, 3, 4],}) - assert data_algebra.test_util.equivalent_frames(res, expect) diff --git a/pkg/tests/test_stats1.py b/pkg/tests/test_stats1.py deleted file mode 100644 index 7ac4a3e..0000000 --- a/pkg/tests/test_stats1.py +++ /dev/null @@ -1,55 +0,0 @@ -import pandas -import wvpy.util -import data_algebra.test_util -import data_algebra.util - - -def test_stats1(): - d = pandas.DataFrame({"x": [1, 2, 3, 4, 5], "y": [False, False, True, True, False]}) - - stats = wvpy.util.threshold_statistics(d, model_predictions="x", yvalues="y",) - # print(data_algebra.util.pandas_to_example_str(stats)) - - expect = pandas.DataFrame( - { - "threshold": [0.999999, 1.0, 2.0, 3.0, 4.0, 5.0, 5.000001], - "count": [5, 5, 4, 3, 2, 1, 0], - "fraction": [1.0, 1.0, 0.8, 0.6, 0.4, 0.2, 0.0], - "precision": [0.4, 0.4, 0.5, 0.6666666666666666, 0.5, 0.0, 0.0], - "true_positive_rate": [1.0, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0], - "false_positive_rate": [ - 1.0, - 1.0, - 0.6666666666666666, - 0.3333333333333333, - 0.3333333333333333, - 0.3333333333333333, - 0.0, - ], - "true_negative_rate": [ - 0.0, - 0.0, - 0.3333333333333333, - 0.6666666666666666, - 0.6666666666666666, - 0.6666666666666666, - 1.0, - ], - "false_negative_rate": [0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0], - "accuracy": [0.4, 0.4, 0.6, 0.8, 0.6, 0.4, 0.6], - "cdf": [0.0, 0.0, 0.19999999999999996, 0.4, 0.6, 0.8, 1.0], - "recall": [1.0, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0], - "sensitivity": [1.0, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0], - "specificity": [ - 0.0, - 0.0, - 0.33333333333333337, - 0.6666666666666667, - 0.6666666666666667, - 0.6666666666666667, - 1.0, - ], - } - ) - - assert data_algebra.test_util.equivalent_frames(stats, expect) diff --git a/pkg/tests/test_threshold_stats.py b/pkg/tests/test_threshold_stats.py deleted file mode 100644 index ad27308..0000000 --- a/pkg/tests/test_threshold_stats.py +++ /dev/null @@ -1,50 +0,0 @@ -import pandas -import data_algebra.test_util -import wvpy.util - - -def test_threshold_stats_(): - d = pandas.DataFrame({"x": [1, 2, 3, 4, 5], "y": [False, False, True, True, False]}) - stats = wvpy.util.threshold_statistics(d, model_predictions="x", yvalues="y",) - expect = pandas.DataFrame( - { - "threshold": [0.999999, 1.0, 2.0, 3.0, 4.0, 5.0, 5.000001], - "count": [5, 5, 4, 3, 2, 1, 0], - "fraction": [1.0, 1.0, 0.8, 0.6, 0.4, 0.2, 0.0], - "precision": [0.4, 0.4, 0.5, 0.6666666666666666, 0.5, 0.0, 0.0], - "true_positive_rate": [1.0, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0], - "false_positive_rate": [ - 1.0, - 1.0, - 0.6666666666666666, - 0.3333333333333333, - 0.3333333333333333, - 0.3333333333333333, - 0.0, - ], - "true_negative_rate": [ - 0.0, - 0.0, - 0.3333333333333333, - 0.6666666666666666, - 0.6666666666666666, - 0.6666666666666666, - 1.0, - ], - "false_negative_rate": [0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0], - "accuracy": [0.4, 0.4, 0.6, 0.8, 0.6, 0.4, 0.6], - "cdf": [0.0, 0.0, 0.19999999999999996, 0.4, 0.6, 0.8, 1.0], - "recall": [1.0, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0], - "sensitivity": [1.0, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0], - "specificity": [ - 0.0, - 0.0, - 0.33333333333333337, - 0.6666666666666667, - 0.6666666666666667, - 0.6666666666666667, - 1.0, - ], - } - ) - assert data_algebra.test_util.equivalent_frames(stats, expect) diff --git a/pkg/tests/test_typs_in_frame.py b/pkg/tests/test_typs_in_frame.py deleted file mode 100644 index f7f23aa..0000000 --- a/pkg/tests/test_typs_in_frame.py +++ /dev/null @@ -1,19 +0,0 @@ - -import pandas as pd -from wvpy.util import types_in_frame - - -def test_types_in_frame(): - d = pd.DataFrame({ - 'x': [1, 2], - 'y': ['a', 'b'], - 'z': ['a', 1], - }) - found = types_in_frame(d) - expect = { - 'x': [int], - 'y': [str], - 'z': [int, str], - } - assert found == expect - diff --git a/pkg/wvpy.egg-info/PKG-INFO b/pkg/wvpy.egg-info/PKG-INFO index 2b133fc..57c506a 100644 --- a/pkg/wvpy.egg-info/PKG-INFO +++ b/pkg/wvpy.egg-info/PKG-INFO @@ -1,7 +1,7 @@ Metadata-Version: 2.1 Name: wvpy Version: 0.3.6 -Summary: Simple utilities for teaching Pandas and scikit learn. +Summary: Convert Jupyter notebooks to and from Python files. Home-page: https://github.com/WinVector/wvpy Author: John Mount Author-email: jmount@win-vector.com @@ -22,6 +22,6 @@ Provides-Extra: code_format License-File: LICENSE -Simple utilities for teaching Pandas, Jupyter, seaborn, and sklearn. +Convert Jupyter notebooks to and from Python files. diff --git a/pkg/wvpy.egg-info/SOURCES.txt b/pkg/wvpy.egg-info/SOURCES.txt index 6c5510f..86b476f 100644 --- a/pkg/wvpy.egg-info/SOURCES.txt +++ b/pkg/wvpy.egg-info/SOURCES.txt @@ -1,13 +1,24 @@ LICENSE +MANIFEST MANIFEST.in README.txt setup.py Doc/documentation.txt +docs/index.html +docs/search.js +docs/wvpy.html +docs/wvpy/jtools.html +docs/wvpy/pysheet.html +docs/wvpy/render_workbook.html +tests/__init__.py +tests/example_bad_notebook.ipynb +tests/example_good_notebook.ipynb +tests/example_parameterized_notebook.ipynb +tests/test_nb_fns.py wvpy/__init__.py wvpy/jtools.py wvpy/pysheet.py wvpy/render_workbook.py -wvpy/util.py wvpy.egg-info/PKG-INFO wvpy.egg-info/SOURCES.txt wvpy.egg-info/dependency_links.txt diff --git a/pkg/wvpy.egg-info/requires.txt b/pkg/wvpy.egg-info/requires.txt index f4f6489..4e15b65 100644 --- a/pkg/wvpy.egg-info/requires.txt +++ b/pkg/wvpy.egg-info/requires.txt @@ -1,10 +1,3 @@ -numpy -pandas -sklearn -seaborn -matplotlib -vtreat>=1.1.1 -data_algebra>=1.2.0 IPython nbformat nbconvert diff --git a/pkg/wvpy/util.py b/pkg/wvpy/util.py deleted file mode 100644 index 3b62821..0000000 --- a/pkg/wvpy/util.py +++ /dev/null @@ -1,982 +0,0 @@ -""" -Utility functions for teaching data science. -""" - -from typing import Dict, Iterable, List, Tuple - -import re -import os -import numpy -import statistics -import matplotlib -import matplotlib.pyplot -import seaborn -import sklearn -import sklearn.metrics -import sklearn.preprocessing -import itertools -import pandas -import math -from data_algebra.cdata import RecordMap, RecordSpecification - - -def types_in_frame(d: pandas.DataFrame) -> Dict[str, List[type]]: - """ - Report what type as seen as values in a Pandas data frame. - - :param d: Pandas data frame to inspect, not altered. - :return: dictionary mapping column names to order lists of types found in column. - """ - assert isinstance(d, pandas.DataFrame) - type_dict_map = { - col_name: {str(type(v)): type(v) for v in d[col_name]} - for col_name in d.columns - } - type_dict = { - col_name: [type_set[k] for k in sorted(list(type_set.keys()))] - for col_name, type_set in type_dict_map.items() - } - return type_dict - - -# noinspection PyPep8Naming -def cross_predict_model( - fitter, X: pandas.DataFrame, y: pandas.Series, plan: List -) -> numpy.ndarray: - """ - train a model y~X using the cross validation plan and return predictions - - :param fitter: sklearn model we can call .fit() on - :param X: explanatory variables, pandas DataFrame - :param y: dependent variable, pandas Series - :param plan: cross validation plan from mk_cross_plan() - :return: vector of simulated out of sample predictions - """ - - assert isinstance(X, pandas.DataFrame) - assert isinstance(y, pandas.Series) - assert isinstance(plan, List) - preds = None - for pi in plan: - model = fitter.fit(X.iloc[pi["train"], :], y.iloc[pi["train"]]) - predg = model.predict(X.iloc[pi["test"], :]) - # patch results in - if preds is None: - preds = numpy.asarray([None] * X.shape[0], dtype=numpy.asarray(predg).dtype) - preds[pi["test"]] = predg - return preds - - -# noinspection PyPep8Naming -def cross_predict_model_proba( - fitter, X: pandas.DataFrame, y: pandas.Series, plan: List -) -> pandas.DataFrame: - """ - train a model y~X using the cross validation plan and return probability matrix - - :param fitter: sklearn model we can call .fit() on - :param X: explanatory variables, pandas DataFrame - :param y: dependent variable, pandas Series - :param plan: cross validation plan from mk_cross_plan() - :return: matrix of simulated out of sample predictions - """ - - assert isinstance(X, pandas.DataFrame) - assert isinstance(y, pandas.Series) - assert isinstance(plan, List) - preds = None - for pi in plan: - model = fitter.fit(X.iloc[pi["train"], :], y.iloc[pi["train"]]) - predg = model.predict_proba(X.iloc[pi["test"], :]) - # patch results in - if preds is None: - preds = numpy.zeros((X.shape[0], predg.shape[1])) - for j in range(preds.shape[1]): - preds[pi["test"], j] = predg[:, j] - preds = pandas.DataFrame(preds) - preds.columns = list(fitter.classes_) - return preds - - -def mean_deviance(predictions, istrue, *, eps=1.0e-6): - """ - compute per-row deviance of predictions versus istrue - - :param predictions: vector of probability preditions - :param istrue: vector of True/False outcomes to be predicted - :param eps: how close to zero or one we clip predictions - :return: vector of per-row deviances - """ - - istrue = numpy.asarray(istrue) - predictions = numpy.asarray(predictions) - mass_on_correct = numpy.where(istrue, predictions, 1 - predictions) - mass_on_correct = numpy.maximum(mass_on_correct, eps) - return -2 * sum(numpy.log(mass_on_correct)) / len(istrue) - - -def mean_null_deviance(istrue, *, eps=1.0e-6): - """ - compute per-row nulll deviance of predictions versus istrue - - :param istrue: vector of True/False outcomes to be predicted - :param eps: how close to zero or one we clip predictions - :return: mean null deviance of using prevalence as the prediction. - """ - - istrue = numpy.asarray(istrue) - p = numpy.zeros(len(istrue)) + numpy.mean(istrue) - return mean_deviance(predictions=p, istrue=istrue, eps=eps) - - -def mk_cross_plan(n: int, k: int) -> List: - """ - Randomly split range(n) into k train/test groups such that test groups partition range(n). - - :param n: integer > 1 - :param k: integer > 1 - :return: list of train/test dictionaries - - Example: - - import wvpy.util - - wvpy.util.mk_cross_plan(10, 3) - """ - grp = [i % k for i in range(n)] - numpy.random.shuffle(grp) - plan = [ - { - "train": [i for i in range(n) if grp[i] != j], - "test": [i for i in range(n) if grp[i] == j], - } - for j in range(k) - ] - return plan - - -# https://win-vector.com/2020/09/13/why-working-with-auc-is-more-powerful-than-one-might-think/ -def matching_roc_area_curve(auc: float) -> dict: - """ - Find an ROC curve with a given area with form of y = 1 - (1 - (1 - x) ** q) ** (1 / q). - - :param auc: area to match - :return: dictionary of ideal x, y series matching area - """ - step = 0.01 - eval_pts = numpy.arange(0, 1 + step, step) - q_eps = 1e-6 - q_low = 0.0 - q_high = 1.0 - while q_low + q_eps < q_high: - q_mid = (q_low + q_high) / 2.0 - q_mid_area = numpy.mean(1 - (1 - (1 - eval_pts) ** q_mid) ** (1 / q_mid)) - if q_mid_area <= auc: - q_high = q_mid - else: - q_low = q_mid - q = (q_low + q_high) / 2.0 - return { - "auc": auc, - "q": q, - "x": 1 - eval_pts, - "y": 1 - (1 - (1 - eval_pts) ** q) ** (1 / q), - } - - -# https://scikit-learn.org/stable/auto_examples/model_selection/plot_roc.html -def plot_roc( - prediction, - istrue, - title="Receiver operating characteristic plot", - *, - truth_target=True, - ideal_line_color=None, - extra_points=None, - show=True, -): - """ - Plot a ROC curve of numeric prediction against boolean istrue. - - :param prediction: column of numeric predictions - :param istrue: column of items to predict - :param title: plot title - :param truth_target: value to consider target or true. - :param ideal_line_color: if not None, color of ideal line - :param extra_points: data frame of additional point to annotate graph, columns fpr, tpr, label - :param show: logical, if True call matplotlib.pyplot.show() - :return: calculated area under the curve, plot produced by call. - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - ideal_line_color='lightgrey' - ) - - wvpy.util.plot_roc( - prediction=d['x'], - istrue=d['y'], - ideal_line_color='lightgrey', - extra_points=pandas.DataFrame({ - 'tpr': [0, 1], - 'fpr': [0, 1], - 'label': ['AAA', 'BBB'] - }) - ) - """ - prediction = numpy.asarray(prediction) - istrue = numpy.asarray(istrue) == truth_target - fpr, tpr, _ = sklearn.metrics.roc_curve(istrue, prediction) - auc = sklearn.metrics.auc(fpr, tpr) - ideal_curve = None - if ideal_line_color is not None: - ideal_curve = matching_roc_area_curve(auc) - matplotlib.pyplot.figure() - lw = 2 - matplotlib.pyplot.gcf().clear() - fig1, ax1 = matplotlib.pyplot.subplots() - ax1.set_aspect("equal") - matplotlib.pyplot.plot( - fpr, - tpr, - color="darkorange", - lw=lw, - label="ROC curve (area = {0:0.2f})" "".format(auc), - ) - matplotlib.pyplot.fill_between(fpr, tpr, color="orange", alpha=0.3) - matplotlib.pyplot.plot([0, 1], [0, 1], color="navy", lw=lw, linestyle="--") - if extra_points is not None: - matplotlib.pyplot.scatter(extra_points.fpr, extra_points.tpr, color="red") - if "label" in extra_points.columns: - tpr = extra_points.tpr.to_list() - fpr = extra_points.fpr.to_list() - label = extra_points.label.to_list() - for i in range(extra_points.shape[0]): - txt = label[i] - if txt is not None: - ax1.annotate(txt, (fpr[i], tpr[i])) - if ideal_curve is not None: - matplotlib.pyplot.plot( - ideal_curve["x"], ideal_curve["y"], linestyle="--", color=ideal_line_color - ) - matplotlib.pyplot.xlim([0.0, 1.0]) - matplotlib.pyplot.ylim([0.0, 1.0]) - matplotlib.pyplot.xlabel("False Positive Rate (1-Specificity)") - matplotlib.pyplot.ylabel("True Positive Rate (Sensitivity)") - matplotlib.pyplot.title(title) - matplotlib.pyplot.legend(loc="lower right") - if show: - matplotlib.pyplot.show() - return auc - - -def dual_density_plot( - probs, - istrue, - title="Double density plot", - *, - truth_target=True, - positive_label="positive examples", - negative_label="negative examples", - ylabel="density of examples", - xlabel="model score", - show=True, -): - """ - Plot a dual density plot of numeric prediction probs against boolean istrue. - - :param probs: vector of numeric predictions. - :param istrue: truth vector - :param title: title of plot - :param truth_target: value considerd true - :param positive_label=label for positive class - :param negative_label=label for negative class - :param ylabel=y axis label - :param xlabel=x axis label - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.dual_density_plot( - probs=d['x'], - istrue=d['y'], - ) - """ - probs = numpy.asarray(probs) - istrue = numpy.asarray(istrue) == truth_target - matplotlib.pyplot.gcf().clear() - preds_on_positive = [ - probs[i] for i in range(len(probs)) if istrue[i] == truth_target - ] - preds_on_negative = [ - probs[i] for i in range(len(probs)) if not istrue[i] == truth_target - ] - seaborn.kdeplot(preds_on_positive, label=positive_label, shade=True) - seaborn.kdeplot(preds_on_negative, label=negative_label, shade=True) - matplotlib.pyplot.ylabel(ylabel) - matplotlib.pyplot.xlabel(xlabel) - matplotlib.pyplot.title(title) - matplotlib.pyplot.legend() - if show: - matplotlib.pyplot.show() - - -def dual_hist_plot(probs, istrue, title="Dual Histogram Plot", *, truth_target=True, show=True): - """ - plot a dual histogram plot of numeric prediction probs against boolean istrue - - :param probs: vector of numeric predictions. - :param istrue: truth vector - :param title: title of plot - :param truth_target: value to consider in class - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.dual_hist_plot( - probs=d['x'], - istrue=d['y'], - ) - """ - probs = numpy.asarray(probs) - istrue = numpy.asarray(istrue) == truth_target - matplotlib.pyplot.gcf().clear() - pf = pandas.DataFrame({"prob": probs, "istrue": istrue}) - g = seaborn.FacetGrid(pf, row="istrue", height=4, aspect=3) - bins = numpy.arange(0, 1.1, 0.1) - g.map(matplotlib.pyplot.hist, "prob", bins=bins) - matplotlib.pyplot.title(title) - if show: - matplotlib.pyplot.show() - - -def dual_density_plot_proba1( - probs, - istrue, - title="Double density plot", - *, - truth_target=True, - positive_label="positive examples", - negative_label="negative examples", - ylabel="density of examples", - xlabel="model score", - show=True, -): - """ - Plot a dual density plot of numeric prediction probs[:,1] against boolean istrue. - - :param probs: matrix of numeric predictions (as returned from predict_proba()) - :param istrue: truth target - :param title: title of plot - :param truth_target: value considered true - :param positive_label=label for positive class - :param negative_label=label for negative class - :param ylabel=y axis label - :param xlabel=x axis label - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - d['x0'] = 1 - d['x'] - pmat = numpy.asarray(d.loc[:, ['x0', 'x']]) - - wvpy.util.dual_density_plot_proba1( - probs=pmat, - istrue=d['y'], - ) - """ - istrue = numpy.asarray(istrue) - probs = numpy.asarray(probs) - matplotlib.pyplot.gcf().clear() - preds_on_positive = [ - probs[i, 1] for i in range(len(probs)) if istrue[i] == truth_target - ] - preds_on_negative = [ - probs[i, 1] for i in range(len(probs)) if not istrue[i] == truth_target - ] - seaborn.kdeplot(preds_on_positive, label=positive_label, shade=True) - seaborn.kdeplot(preds_on_negative, label=negative_label, shade=True) - matplotlib.pyplot.ylabel(ylabel) - matplotlib.pyplot.xlabel(xlabel) - matplotlib.pyplot.title(title) - matplotlib.pyplot.legend() - if show: - matplotlib.pyplot.show() - - -def dual_hist_plot_proba1(probs, istrue, *, show=True): - """ - plot a dual histogram plot of numeric prediction probs[:,1] against boolean istrue - - :param probs: vector of probability predictions - :param istrue: vector of ground truth to condition on - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [False, False, True, True, False] - }) - d['x0'] = 1 - d['x'] - pmat = numpy.asarray(d.loc[:, ['x0', 'x']]) - - wvpy.util.dual_hist_plot_proba1( - probs=pmat, - istrue=d['y'], - ) - """ - istrue = numpy.asarray(istrue) - probs = numpy.asarray(probs) - matplotlib.pyplot.gcf().clear() - pf = pandas.DataFrame( - {"prob": [probs[i, 1] for i in range(probs.shape[0])], "istrue": istrue} - ) - g = seaborn.FacetGrid(pf, row="istrue", height=4, aspect=3) - bins = numpy.arange(0, 1.1, 0.1) - g.map(matplotlib.pyplot.hist, "prob", bins=bins) - if show: - matplotlib.pyplot.show() - - -def gain_curve_plot(prediction, outcome, title="Gain curve plot", *, show=True): - """ - plot cumulative outcome as a function of prediction order (descending) - - :param prediction: vector of numeric predictions - :param outcome: vector of actual values - :param title: plot title - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [0, 0, 1, 1, 0] - }) - - wvpy.util.gain_curve_plot( - prediction=d['x'], - outcome=d['y'], - ) - """ - - df = pandas.DataFrame( - { - "prediction": numpy.array(prediction).copy(), - "outcome": numpy.array(outcome).copy(), - } - ) - - # compute the gain curve - df.sort_values(["prediction"], ascending=[False], inplace=True) - df["fraction_of_observations_by_prediction"] = ( - numpy.arange(df.shape[0]) + 1.0 - ) / df.shape[0] - df["cumulative_outcome"] = df["outcome"].cumsum() - df["cumulative_outcome_fraction"] = df["cumulative_outcome"] / numpy.max( - df["cumulative_outcome"] - ) - - # compute the wizard curve - df.sort_values(["outcome"], ascending=[False], inplace=True) - df["fraction_of_observations_by_wizard"] = ( - numpy.arange(df.shape[0]) + 1.0 - ) / df.shape[0] - - df["cumulative_outcome_by_wizard"] = df["outcome"].cumsum() - df["cumulative_outcome_fraction_wizard"] = df[ - "cumulative_outcome_by_wizard" - ] / numpy.max(df["cumulative_outcome_by_wizard"]) - - seaborn.lineplot( - x="fraction_of_observations_by_wizard", - y="cumulative_outcome_fraction_wizard", - color="gray", - linestyle="--", - data=df, - ) - - seaborn.lineplot( - x="fraction_of_observations_by_prediction", - y="cumulative_outcome_fraction", - data=df, - ) - - seaborn.lineplot(x=[0, 1], y=[0, 1], color="red") - matplotlib.pyplot.xlabel("fraction of observations by sort criterion") - matplotlib.pyplot.ylabel("cumulative outcome fraction") - matplotlib.pyplot.title(title) - if show: - matplotlib.pyplot.show() - - -def lift_curve_plot(prediction, outcome, title="Lift curve plot", *, show=True): - """ - plot lift as a function of prediction order (descending) - - :param prediction: vector of numeric predictions - :param outcome: vector of actual values - :param title: plot title - :param show: logical, if True call matplotlib.pyplot.show() - :return: None - - Example: - - d = pandas.DataFrame({ - 'x': [.1, .2, .3, .4, .5], - 'y': [0, 0, 1, 1, 0] - }) - - wvpy.util.lift_curve_plot( - prediction=d['x'], - outcome=d['y'], - ) - """ - - df = pandas.DataFrame( - { - "prediction": numpy.array(prediction).copy(), - "outcome": numpy.array(outcome).copy(), - } - ) - - # compute the gain curve - df.sort_values(["prediction"], ascending=[False], inplace=True) - df["fraction_of_observations_by_prediction"] = ( - numpy.arange(df.shape[0]) + 1.0 - ) / df.shape[0] - df["cumulative_outcome"] = df["outcome"].cumsum() - df["cumulative_outcome_fraction"] = df["cumulative_outcome"] / numpy.max( - df["cumulative_outcome"] - ) - - # move to lift - df["lift"] = ( - df["cumulative_outcome_fraction"] / df["fraction_of_observations_by_prediction"] - ) - seaborn.lineplot(x="fraction_of_observations_by_prediction", y="lift", data=df) - matplotlib.pyplot.axhline(y=1, color="red") - matplotlib.pyplot.title(title) - if show: - matplotlib.pyplot.show() - - -# https://stackoverflow.com/questions/5228158/cartesian-product-of-a-dictionary-of-lists -def search_grid(inp: dict) -> List: - """ - build a cross product of all named dictionary entries - - :param inp: dictionary of value lists - :return: list of value dictionaries - """ - - gen = (dict(zip(inp.keys(), values)) for values in itertools.product(*inp.values())) - return [ci for ci in gen] - - -def grid_to_df(grid: List) -> pandas.DataFrame: - """ - convert a search_grid list of maps to a pandas data frame - - :param grid: list of combos - :return: data frame with one row per combo - """ - - n = len(grid) - keys = [ki for ki in grid[1].keys()] - return pandas.DataFrame({ki: [grid[i][ki] for i in range(n)] for ki in keys}) - - -def eval_fn_per_row(f, x2, df: pandas.DataFrame) -> List: - """ - evaluate f(row-as-map, x2) for rows in df - - :param f: function to evaluate - :param x2: extra argument - :param df: data frame to take rows from - :return: list of evaluations - """ - - assert isinstance(df, pandas.DataFrame) - return [f({k: df.loc[i, k] for k in df.columns}, x2) for i in range(df.shape[0])] - - -def perm_score_vars(d: pandas.DataFrame, istrue, model, modelvars: List[str], k=5): - """ - evaluate model~istrue on d permuting each of the modelvars and return variable importances - - :param d: data source (copied) - :param istrue: y-target - :param model: model to evaluate - :param modelvars: names of variables to permute - :param k: number of permutations - :return: score data frame - """ - - d2 = d[modelvars].copy() - d2.reset_index(inplace=True, drop=True) - istrue = numpy.asarray(istrue) - preds = model.predict_proba(d2[modelvars]) - basedev = mean_deviance(preds[:, 1], istrue) - - def perm_score_var(victim): - """Permutation score column named victim""" - dorig = numpy.array(d2[victim].copy()) - dnew = numpy.array(d2[victim].copy()) - - def perm_score_var_once(): - """apply fn once, used for list comprehension""" - numpy.random.shuffle(dnew) - d2[victim] = dnew - predsp = model.predict_proba(d2[modelvars]) - permdev = mean_deviance(predsp[:, 1], istrue) - return permdev - - # noinspection PyUnusedLocal - devs = [perm_score_var_once() for rep in range(k)] - d2[victim] = dorig - return numpy.mean(devs), statistics.stdev(devs) - - stats = [perm_score_var(victim) for victim in modelvars] - vf = pandas.DataFrame({"var": modelvars}) - vf["importance"] = [di[0] - basedev for di in stats] - vf["importance_dev"] = [di[1] for di in stats] - vf.sort_values(by=["importance"], ascending=False, inplace=True) - vf = vf.reset_index(inplace=False, drop=True) - return vf - - -def threshold_statistics( - d: pandas.DataFrame, *, model_predictions: str, yvalues: str, y_target=True -) -> pandas.DataFrame: - """ - Compute a number of threshold statistics of how well model predictions match a truth target. - - :param d: pandas.DataFrame to take values from - :param model_predictions: name of predictions column - :param yvalues: name of truth values column - :param y_target: value considered to be true - :return: summary statistic frame, include before and after pseudo-observations - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.threshold_statistics( - d, - model_predictions='x', - yvalues='y', - ) - """ - # make a thin frame to re-sort for cumulative statistics - sorted_frame = pandas.DataFrame( - {"threshold": d[model_predictions].copy(), "truth": d[yvalues] == y_target} - ) - sorted_frame["orig_index"] = sorted_frame.index + 0 - sorted_frame.sort_values( - ["threshold", "orig_index"], ascending=[False, True], inplace=True - ) - sorted_frame.reset_index(inplace=True, drop=True) - sorted_frame["notY"] = 1 - sorted_frame["truth"] # falses - sorted_frame["one"] = 1 - del sorted_frame["orig_index"] - - # pseudo-observation to get end-case (accept nothing case) - eps = 1.0e-6 - sorted_frame = pandas.concat( - [ - pandas.DataFrame( - { - "threshold": [sorted_frame["threshold"].max() + eps], - "truth": [False], - "notY": [0], - "one": [0], - } - ), - sorted_frame, - pandas.DataFrame( - { - "threshold": [sorted_frame["threshold"].min() - eps], - "truth": [False], - "notY": [0], - "one": [0], - } - ), - ] - ) - sorted_frame.reset_index(inplace=True, drop=True) - - # basic cumulative facts - sorted_frame["count"] = sorted_frame["one"].cumsum() # predicted true so far - sorted_frame["fraction"] = sorted_frame["count"] / max(1, sorted_frame["one"].sum()) - sorted_frame["precision"] = sorted_frame["truth"].cumsum() / sorted_frame[ - "count" - ].clip(lower=1) - sorted_frame["true_positive_rate"] = sorted_frame["truth"].cumsum() / max( - 1, sorted_frame["truth"].sum() - ) - sorted_frame["false_positive_rate"] = sorted_frame["notY"].cumsum() / max( - 1, sorted_frame["notY"].sum() - ) - sorted_frame["true_negative_rate"] = ( - sorted_frame["notY"].sum() - sorted_frame["notY"].cumsum() - ) / max(1, sorted_frame["notY"].sum()) - sorted_frame["false_negative_rate"] = ( - sorted_frame["truth"].sum() - sorted_frame["truth"].cumsum() - ) / max(1, sorted_frame["truth"].sum()) - sorted_frame["accuracy"] = ( - sorted_frame["truth"].cumsum() # true positive count - + sorted_frame["notY"].sum() - - sorted_frame["notY"].cumsum() # true negative count - ) / sorted_frame["one"].sum() - - # approximate cdf work - sorted_frame["cdf"] = 1 - sorted_frame["fraction"] - - # derived facts and synonyms - sorted_frame["recall"] = sorted_frame["true_positive_rate"] - sorted_frame["sensitivity"] = sorted_frame["recall"] - sorted_frame["specificity"] = 1 - sorted_frame["false_positive_rate"] - - # re-order for neatness - sorted_frame["new_index"] = sorted_frame.index.copy() - sorted_frame.sort_values(["new_index"], ascending=[False], inplace=True) - sorted_frame.reset_index(inplace=True, drop=True) - - # clean up - del sorted_frame["notY"] - del sorted_frame["one"] - del sorted_frame["new_index"] - del sorted_frame["truth"] - return sorted_frame - - -def threshold_plot( - d: pandas.DataFrame, - pred_var: str, - truth_var: str, - truth_target: bool = True, - threshold_range: Iterable[float] = (-math.inf, math.inf), - plotvars: Iterable[str] = ("precision", "recall"), - title: str = "Measures as a function of threshold", - *, - show: bool = True, -) -> None: - """ - Produce multiple facet plot relating the performance of using a threshold greater than or equal to - different values at predicting a truth target. - - :param d: pandas.DataFrame to plot - :param pred_var: name of column of numeric predictions - :param truth_var: name of column with reference truth - :param truth_target: value considered true - :param threshold_range: x-axis range to plot - :param plotvars: list of metrics to plot, must come from ['threshold', 'count', 'fraction', - 'true_positive_rate', 'false_positive_rate', 'true_negative_rate', 'false_negative_rate', - 'precision', 'recall', 'sensitivity', 'specificity', 'accuracy'] - :param title: title for plot - :param show: logical, if True call matplotlib.pyplot.show() - :return: None, plot produced as a side effect - - Example: - - import pandas - import wvpy.util - - d = pandas.DataFrame({ - 'x': [1, 2, 3, 4, 5], - 'y': [False, False, True, True, False] - }) - - wvpy.util.threshold_plot( - d, - pred_var='x', - truth_var='y', - plotvars=("sensitivity", "specificity"), - ) - """ - if isinstance(plotvars, str): - plotvars = [plotvars] - else: - plotvars = list(plotvars) - assert isinstance(plotvars, list) - assert len(plotvars) > 0 - assert all([isinstance(v, str) for v in plotvars]) - threshold_range = list(threshold_range) - assert len(threshold_range) == 2 - frame = d[[pred_var, truth_var]].copy() - frame.reset_index(inplace=True, drop=True) - frame["outcol"] = frame[truth_var] == truth_target - - prt_frame = threshold_statistics( - frame, model_predictions=pred_var, yvalues="outcol", - ) - bad_plot_vars = set(plotvars) - set(prt_frame.columns) - if len(bad_plot_vars) > 0: - raise ValueError( - "allowed plotting variables are: " - + str(prt_frame.columns) - + ", " - + str(bad_plot_vars) - + " unexpected." - ) - - selector = (threshold_range[0] <= prt_frame.threshold) & ( - prt_frame.threshold <= threshold_range[1] - ) - to_plot = prt_frame.loc[selector, :] - - if len(plotvars) > 1: - reshaper = RecordMap( - blocks_out=RecordSpecification( - pandas.DataFrame({"measure": plotvars, "value": plotvars}), - control_table_keys=["measure"], - record_keys=["threshold"], - ) - ) - prtlong = reshaper.transform(to_plot) - grid = seaborn.FacetGrid( - prtlong, row="measure", row_order=plotvars, aspect=2, sharey=False - ) - grid = grid.map(matplotlib.pyplot.plot, "threshold", "value") - grid.set(ylabel=None) - matplotlib.pyplot.subplots_adjust(top=0.9) - grid.fig.suptitle(title) - else: - # can plot off primary frame - seaborn.lineplot( - data=to_plot, x="threshold", y=plotvars[0], - ) - matplotlib.pyplot.suptitle(title) - matplotlib.pyplot.title(f"measure = {plotvars[0]}") - - if show: - matplotlib.pyplot.show() - - -def fit_onehot_enc( - d: pandas.DataFrame, *, categorical_var_names: Iterable[str] -) -> dict: - """ - Fit a sklearn OneHot Encoder to categorical_var_names columns. - Note: we suggest preferring vtreat ( https://github.com/WinVector/pyvtreat ) over this example code. - - :param d: training data - :param categorical_var_names: list of column names to learn transform from - :return: encoding bundle dictionary, see apply_onehot_enc() for use. - """ - assert isinstance(d, pandas.DataFrame) - assert not isinstance( - categorical_var_names, str - ) # single name, should be in a list - categorical_var_names = list(categorical_var_names) # clean copy - assert numpy.all([isinstance(v, str) for v in categorical_var_names]) - assert len(categorical_var_names) > 0 - enc = sklearn.preprocessing.OneHotEncoder( - categories="auto", drop=None, sparse=False, handle_unknown="ignore" # default - ) - enc.fit(d[categorical_var_names]) - produced_column_names = list(enc.get_feature_names_out()) - # return the structure - encoder_bundle = { - "categorical_var_names": categorical_var_names, - "enc": enc, - "produced_column_names": produced_column_names, - } - return encoder_bundle - - -def apply_onehot_enc(d: pandas.DataFrame, *, encoder_bundle: dict) -> pandas.DataFrame: - """ - Apply a one hot encoding bundle to a data frame. - - :param d: input data frame - :param encoder_bundle: transform specification, built by fit_onehot_enc() - :return: transformed data frame - """ - assert isinstance(d, pandas.DataFrame) - assert isinstance(encoder_bundle, dict) - # one hot re-code columns, preserving column names info - one_hotted = pandas.DataFrame( - encoder_bundle["enc"].transform(d[encoder_bundle["categorical_var_names"]]) - ) - one_hotted.columns = encoder_bundle["produced_column_names"] - # copy over non-invovled columns - cat_set = set(encoder_bundle["categorical_var_names"]) - complementary_columns = [c for c in d.columns if c not in cat_set] - res = pandas.concat([d[complementary_columns], one_hotted], axis=1) - return res - - -# https://stackoverflow.com/a/56695622/6901725 -# from https://stackoverflow.com/questions/11130156/suppress-stdout-stderr-print-from-python-functions -class suppress_stdout_stderr(object): - ''' - A context manager for doing a "deep suppression" of stdout and stderr in - Python, i.e. will suppress all print, even if the print originates in a - compiled C/Fortran sub-function. - This will not suppress raised exceptions, since exceptions are printed - to stderr just before a script exits, and after the context manager has - exited (at least, I think that is why it lets exceptions through). - - ''' - def __init__(self): - # Open a pair of null files - self.null_fds = [os.open(os.devnull, os.O_RDWR) for x in range(2)] - # Save the actual stdout (1) and stderr (2) file descriptors. - self.save_fds = (os.dup(1), os.dup(2)) - - def __enter__(self): - # Assign the null pointers to stdout and stderr. - os.dup2(self.null_fds[0], 1) - os.dup2(self.null_fds[1], 2) - - def __exit__(self, *_): - # Re-assign the real stdout/stderr back to (1) and (2) - os.dup2(self.save_fds[0], 1) - os.dup2(self.save_fds[1], 2) - # Close the null files - os.close(self.null_fds[0]) - os.close(self.null_fds[1]) diff --git a/wvpy_dev_env.yaml b/wvpy_dev_env.yaml index 405bbb4..1da3628 100644 --- a/wvpy_dev_env.yaml +++ b/wvpy_dev_env.yaml @@ -3,31 +3,18 @@ channels: - defaults - conda-forge dependencies: - - numpy - - pandas - - lark - - scipy - jupyterlab - python=3.9.* - - scikit-learn - - seaborn - - matplotlib - - PyYAML - black - twine - pytest - pytest-cov - - pyarrow - - sqlalchemy - - psycopg2 - - pymysql - - pyspark - pylint - pip - pip: - pdoc - - data_algebra - - vtreat - - google.cloud - - google-cloud-bigquery - + - IPython + - nbformat + - nbconvert + - pdfkit +