├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yaml ├── .gitignore ├── LICENSE.txt ├── Makefile ├── README.md ├── auditai ├── __init__.py ├── is_newest_version.py ├── misc.py ├── simulations.py ├── stats.py ├── tests │ ├── __init__.py │ ├── test_crosstabs.py │ ├── test_misc.py │ ├── test_nothing.py │ ├── test_simulations.py │ ├── test_stats.py │ ├── test_utils.py │ └── test_viz.py ├── utils │ ├── __init__.py │ ├── cmh.py │ ├── crosstabs.py │ ├── functions.py │ ├── general.py │ └── validate.py ├── version.py └── viz.py ├── data ├── GermanCreditData.csv ├── auditAI_gender_plot.png └── student-mat.csv ├── examples ├── CMH_test_example.ipynb ├── GermanCreditData_BiasCheck.ipynb ├── StudentPerformanceData_BiasCheck.ipynb └── implementation_suggestions.md ├── setup.cfg ├── setup.py └── tox.ini /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python Package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [2.7, 3.7, 3.8] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | make install-dev 27 | - name: Lint with flake8 28 | run: | 29 | pip install flake8 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 auditai --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 auditai --count --exit-zero --max-complexity=11 --max-line-length=127 --statistics 34 | - name: Test with pytest 35 | run: | 36 | make tests 37 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: '3.7' 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel twine 22 | - name: Build and publish 23 | env: 24 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 26 | run: | 27 | python setup.py sdist bdist_wheel 28 | twine upload dist/* 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .DS_Store 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | Untitled.ipynb 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 pymetrics, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | build: 4 | pip install -e . 5 | 6 | install-dev: clean 7 | pip install -e ".[dev]" 8 | 9 | tests: 10 | pytest --cov-config=setup.cfg --cov=auditai --cov-fail-under=65 11 | 12 | lint: 13 | flake8 ./auditai 14 | 15 | covhtml: 16 | coverage html 17 | open ./htmlcov/index.html 18 | 19 | clean: 20 | @rm -Rf *.egg-info .eggs .tox build dist htmlcov .coverage 21 | @find ./ -depth -type d -name __pycache__ -exec rm -Rf {} \; 22 | @find ./ -type f \( -iname \*.pyc -o -iname \*.pyo -o -iname \*~ \) -delete 23 | 24 | test-all: tests lint 25 | 26 | pubdev: is_newest_version clean 27 | python setup.py bdist_wheel 28 | twine upload --repository testpypi dist/* 29 | 30 | is_newest_version: 31 | python auditai/is_newest_version.py 32 | 33 | publish: is_newest_version clean 34 | python setup.py bdist_wheel 35 | twine upload --repository pypi dist/* 36 | 37 | tox_tests: 38 | tox 39 | 40 | .PHONY: build install-dev tests lint coverage covhtml clean test-all pubdev publish -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Python package](https://github.com/pymetrics/audit-ai/workflows/Python%20package/badge.svg) 2 | # audit-AI 3 | 4 | 5 | 6 | Open Sourced Bias Testing for Generalized Machine Learning Applications 7 | 8 | `audit-AI` is a Python library built on top of `pandas` and `sklearn` that 9 | implements fairness-aware machine learning algorithms. `audit-AI` was developed 10 | by the Data Science team at [pymetrics](https://www.pymetrics.com/) 11 | 12 | # Bias Testing for Generalized Machine Learning Applications 13 | 14 | `audit-AI` is a tool to measure and mitigate the effects of discriminatory 15 | patterns in training data and the predictions made by machine learning 16 | algorithms trained for the purposes of socially sensitive decision processes. 17 | 18 | The overall goal of this research is to come up with a reasonable way to think 19 | about how to make machine learning algorithms more fair. While identifying 20 | potential bias in training datasets and by consequence the machine learning 21 | algorithms trained on them is not sufficient to solve the problem of 22 | discrimination, in a world where more and more decisions are being automated 23 | by Artificial Intelligence, our ability to understand and identify the degree 24 | to which an algorithm is fair or biased is a step in the right direction. 25 | 26 | # Regulatory Compliance and Checks for Practical and Statistical Bias 27 | 28 | According to the Uniform Guidelines on Employee Selection Procedures (UGESP; 29 | EEOC et al., 1978), all assessment tools should comply to fair standard of 30 | treatment for all protected groups. Audit-ai extends this to machine learning 31 | methods. Let's say we build a model that makes some prediction about people. 32 | This model could theoretically be anything -- a prediction of credit scores, 33 | the likelihood of prison recidivism, the cost of a home loan, etc. Audit-ai 34 | takes data from a known population (e.g., credit information from people of 35 | multiple genders and ethnicities), and runs them through the model in question. 36 | The proportional pass rates of the highest-passing demographic group are compared 37 | to the lowest-passing group for each demographic category (gender and 38 | ethnicity). This proportion is known as the bias ratio. 39 | 40 | Audit-ai determines whether groups are different according to a standard of 41 | statistical significance (within a statistically different margin of error) or 42 | practical significance (whether a difference is large enough to matter on a 43 | practical level). The exact threshold of statistical and practical significance 44 | depends on the field and use-case. Within the hiring space, the EEOC often 45 | uses a statistical significance of p < .05 to determine bias, and a bias ratio 46 | below the 4/5ths rule to demonstrate practical significance. 47 | 48 | The 4/5ths rule effectively states that the lowest-passing group has to be 49 | within 4/5ths of the pass rate of the highest-passing group. Consider an example 50 | with 4,000 users, 1,000 of each of the following groups: Asian, Black, 51 | Hispanic/Latino, and White, who pass at a frequency of 250, 270, 240 and 260 52 | users, respectively. The highest and lowest passing groups are Black (27%) and 53 | Hispanic/Latino (24%), respectively. The bias ratio is therefore 24/27 or .889. 54 | As this ratio is greater than .80 (4/5ths), the legal requirement enforced by 55 | the EEOC, the model would pass the check for practical significance. Likewise, 56 | a chi-squared test (a common statistical test for count data) would report that 57 | these groups are above the p = .05 threshold, and therefore pass the check for 58 | statistical significance. 59 | 60 | Audit-ai also offers tools to check for differences over time or across 61 | different regions, using the Cochran-Mantel-Hanzel test, a common test in 62 | regulatory circles. To our knowledge this is the first implementation of this 63 | measure in an open-source python format. 64 | 65 | # Features 66 | 67 | Here are a few of the bias testing and algorithm auditing techniques 68 | that this library implements. 69 | 70 | ### Classification tasks 71 | 72 | - 4/5th, fisher, z-test, bayes factor, chi squared 73 | - sim_beta_ratio, classifier_posterior_probabilities 74 | 75 | ### Regression tasks 76 | 77 | - anova 78 | - 4/5th, fisher, z-test, bayes factor, chi squared 79 | - group proportions at different thresholds 80 | 81 | # Installation 82 | 83 | The source code is currently hosted on GitHub: https://github.com/pymetrics/audit-ai 84 | 85 | You can install the latest released version with `pip`. 86 | 87 | ``` 88 | # pip 89 | pip install audit-AI 90 | ``` 91 | 92 | If you install with pip, you'll need to install scikit-learn, numpy, and pandas 93 | with either pip or conda. Version requirements: 94 | 95 | - numpy 96 | - scipy 97 | - pandas 98 | 99 | For vizualization: 100 | - matplotlib 101 | - seaborn 102 | 103 | # How to use this package: 104 | 105 | See our implementation paper here: https://github.com/pymetrics/audit-ai/blob/master/examples/implementation_suggestions.md 106 | 107 | ```python 108 | 109 | from auditai.misc import bias_test_check 110 | 111 | X = df.loc[:,features] 112 | y_pred = clf.predict_proba(X) 113 | 114 | # test for bias 115 | bias_test_check(labels=df['gender'], results=y_pred, category='Gender') 116 | 117 | >>> *Gender passes 4/5 test, Fisher p-value, Chi-Squared p-value, z-test p-value and Bayes Factor at 50.00* 118 | 119 | ``` 120 | To get a plot of the different tests at different thresholds: 121 | 122 | ```python 123 | 124 | from auditai.viz import plot_threshold_tests 125 | 126 | X = df.loc[:,features] 127 | y_pred = clf.predict_proba(X) 128 | 129 | # test for bias 130 | plot_threshold_tests(labels=df['gender'], results=y_pred, category='Gender') 131 | 132 | ``` 133 | Sample audit-AI Plot 134 | 135 | # Example Datasets 136 | 137 | - [german-credit](https://archive.ics.uci.edu/ml/datasets/statlog+(german+credit+data)) 138 | - [student-performance](https://archive.ics.uci.edu/ml/datasets/student+performance) 139 | -------------------------------------------------------------------------------- /auditai/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymetrics/audit-ai/4891b1d3c813a0e6ce657eaee3f1b2ab50e8f429/auditai/__init__.py -------------------------------------------------------------------------------- /auditai/is_newest_version.py: -------------------------------------------------------------------------------- 1 | from distutils.version import StrictVersion 2 | import requests 3 | 4 | 5 | exec(open('auditai/version.py').read()) 6 | PACKAGE_NAME = 'audit-AI' 7 | VERSION = StrictVersion(__version__) # noqa 8 | 9 | 10 | def versions(package_name): 11 | url = "https://pypi.python.org/pypi/{}/json".format(package_name) 12 | response = requests.get(url) 13 | 14 | data = response.json() 15 | versions = [StrictVersion(v) for v in data["releases"].keys()] 16 | versions.sort() 17 | return versions 18 | 19 | 20 | print('Version: ', VERSION) 21 | if not VERSION > versions(PACKAGE_NAME)[-1]: 22 | print("Version would not be the newest version.") 23 | exit(1) 24 | 25 | print("Version would be the newest version.") 26 | -------------------------------------------------------------------------------- /auditai/misc.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from scipy.stats import chi2_contingency, fisher_exact, f_oneway 6 | from sklearn.metrics import mutual_info_score 7 | 8 | from .simulations import classifier_posterior_probabilities 9 | from .utils.crosstabs import (crosstab_bayes_factor, 10 | crosstab_ztest, 11 | top_bottom_crosstab) 12 | from .utils.validate import boolean_array, check_consistent_length 13 | 14 | 15 | def default_thresh_value(results): 16 | if len(set(results)) == 2: 17 | return np.mean(list(set(results))) 18 | return np.median(results) 19 | 20 | 21 | def anova(labels, results, subset_labels=None): 22 | """ 23 | Returns one-way ANOVA f-statistic and p-value from 24 | input vectors of categorical labels and numeric results 25 | 26 | Parameters 27 | ------------ 28 | labels : array_like 29 | containing categorical values like ['M', 'F'] 30 | results : array_like 31 | containing real numbers 32 | subset_labels : list of strings, optional 33 | if only specific labels should be included 34 | 35 | Returns 36 | ---------- 37 | F_onewayResult : scipy.stats object (essentially a 2-tuple) 38 | contains one-way f-statistic and p-value, indicating whether 39 | scores have same sample mean 40 | 41 | """ 42 | check_consistent_length(labels, results) 43 | 44 | df = pd.DataFrame(list(zip(labels, results)), columns=['label', 'result']) 45 | if subset_labels is not None: 46 | df = df.loc[df['label'].isin(subset_labels)] 47 | 48 | unique_labels = df['label'].dropna().unique() 49 | score_vectors = [df.loc[df['label'] == lab, 'result'] 50 | for lab in unique_labels] 51 | return f_oneway(*score_vectors) 52 | 53 | 54 | def bias_test_check(labels, results, category=None, test_thresh=None, 55 | **kwargs): 56 | """ 57 | Utility function for checking if statistical tests are passed 58 | at a reference threshold 59 | 60 | Parameters 61 | -------- 62 | labels : array_like 63 | containing categorical values like ['M', 'F'] 64 | results : array_like 65 | containing real numbers 66 | category : string, optional 67 | the name of the category labels are in, e.g. 'Gender' 68 | test_thresh : numeric 69 | threshold value to test 70 | **kwargs : optional additional arguments for compare_groups 71 | 72 | Returns 73 | -------- 74 | print statement indicating whether specific statistical tests pass or fail 75 | """ 76 | if test_thresh is None: 77 | test_thresh = default_thresh_value(results) 78 | 79 | min_props, z_ps, fisher_ps, chi_ps, bfs = compare_groups( 80 | labels, results, low=test_thresh, num=1, **kwargs) 81 | 82 | # if no category is specified, concatenate strings 83 | if category is None: 84 | category = '_vs_'.join([str(i) for i in set(labels)])[:20] 85 | # test if passes at test_thresh 86 | bias_tests = {'4/5': {'results': min_props, 87 | 'check': lambda x: x < 0.8}, 88 | 'Fisher exact': {'results': fisher_ps, 89 | 'check': lambda x: x < 0.05}, 90 | 'Chi squared': {'results': chi_ps, 91 | 'check': lambda x: x < 0.05}, 92 | 'z': {'results': z_ps, 93 | 'check': lambda x: x < 0.05}, 94 | 'Bayes Factor': {'results': bfs, 95 | 'check': lambda x: x > 3.} 96 | } 97 | 98 | for name, test in bias_tests.items(): 99 | stat_value = test['results'].get(test_thresh) 100 | if stat_value and not test['check'](stat_value): 101 | print('*{} passes {} test at {:.2f}*'.format( 102 | category, name, test_thresh)) 103 | elif stat_value is not None: 104 | print('*{} fails {} test at {:.2f}*'.format( 105 | category, name, test_thresh)) 106 | print(" - {} minimum proportion at {:.2f}: {:.3f}".format( 107 | category, test_thresh, stat_value)) 108 | else: 109 | print("Unable to run {} test".format(name)) 110 | 111 | 112 | def make_bias_report(clf, df, feature_names, categories, **kwargs): 113 | """ 114 | Utility function for report dictionary from 115 | `classifier_posterior_probabilities`. Used for plotting 116 | bar plots in `bias_bar_plot` 117 | 118 | Parameters 119 | ----------- 120 | clf : sklearn clf 121 | fitted clf with predict object 122 | df : pandas DataFrame 123 | reference dataframe containing labeled features to test for bias 124 | feature_names : list of strings 125 | names of features used in fitting clf 126 | categories : list of strings 127 | names of categories to test for bias, e.g. ['gender'] 128 | ref_threshold : float (optional) 129 | cutoff value at which to generate metrics 130 | **kwargs : optional additional arguments for 131 | classifier_posterior_probabilities, specifically low, high, num 132 | 133 | Returns 134 | -------- 135 | out_dict : dictionary 136 | contains category names, average probabilities and errors by category 137 | of form {'gender': {'categories':['F', 'M'], 138 | 'averages': [.5, .5], 139 | 'errors': [.1, .1]} 140 | } 141 | """ 142 | ref_threshold = kwargs.pop("ref_threshold") 143 | threshes, probs = classifier_posterior_probabilities( 144 | df, clf, feature_names, categories, **kwargs) 145 | 146 | # if not specified, set ref_threshold at 80% of max(threshes) 147 | if ref_threshold is None: 148 | idx_80 = int(len(threshes)*.8) 149 | ref_threshold = sorted(threshes)[idx_80] 150 | 151 | ref_idx = list(threshes).index(ref_threshold) 152 | 153 | out_dict = {} 154 | for category in categories: 155 | cat_vals = [k.split('__')[1] 156 | for k in probs.keys() if k.split('__')[0] == category] 157 | cat_avgs = [probs[val][ref_idx][0] for val in cat_vals] 158 | cat_errors = [probs[val][ref_idx][1:] for val in cat_vals] 159 | out_dict[category] = { 160 | 'categories': cat_vals, 161 | 'averages': cat_avgs, 162 | 'errors': cat_errors} 163 | 164 | return out_dict 165 | 166 | 167 | def get_group_proportions(labels, results, **kwargs): 168 | """ 169 | Returns pass proportions for each group present in labels, according to 170 | their results 171 | 172 | Parameters 173 | ------------ 174 | labels : array_like 175 | contains categorical labels 176 | results : array_like 177 | contains numeric or boolean values 178 | **kwargs : optional 179 | additional values for thresholds to test: 180 | low : float 181 | if None, will default to min(results) 182 | high : float 183 | if None, will default to max(results) 184 | num : int, default 100 185 | number of thresholds to check 186 | 187 | Returns 188 | -------- 189 | prop_dict: dictionary 190 | contains {group_name : [[thresholds, pass_proportions]]} 191 | 192 | """ 193 | low = kwargs.get("low", min(results)) 194 | high = kwargs.get("high", max(results)) 195 | num = kwargs.get("num", 100) 196 | thresholds = np.linspace(low, high, num).tolist() 197 | groups = set(labels) 198 | prop_dict = defaultdict(list) 199 | 200 | for group in groups: 201 | pass_props = [] 202 | for thresh in thresholds: 203 | decs = [i <= thresh for i in results] 204 | crosstab = pd.crosstab(pd.Series(labels), pd.Series(decs)) 205 | row = crosstab.loc[group] 206 | pass_prop = row[True] / float(row.sum()) 207 | pass_props.append(pass_prop) 208 | prop_dict[group].append(thresholds) 209 | prop_dict[group].append(pass_props) 210 | return prop_dict 211 | 212 | 213 | def compare_groups(labels, results, 214 | low=None, high=None, num=100, 215 | comp_groups=None, print_skips=False): 216 | """ 217 | Function to plot proportion of largest and smallest bias groups and 218 | get relative z scores 219 | 220 | Parameters 221 | -------- 222 | labels : array_like 223 | contains categorical values like ['M', 'F'] 224 | results : array_like 225 | contains real numbers, e.g. threshold scores or floats in (0,1) 226 | low : float 227 | lower threshold value 228 | high : float 229 | upper threshold value 230 | num : int 231 | number of thresholds to check 232 | comp_groups : list of strings, optional 233 | subset of labels to compare, e.g. ['white', 'black'] 234 | print_skips : bool 235 | whether to display thresholds skipped 236 | 237 | Returns 238 | --------- 239 | min_props : dict 240 | contains (key, value) of (threshold : max group/min group proportions) 241 | z_ps : dict 242 | contains (key, value) of (threshold : p-value of two tailed z test) 243 | fisher_ps : dict 244 | contains (key, value) of (threshold : p-value of fisher exact test) 245 | chi_ps : dict 246 | contains (key, value) of (threshold : p-value of chi squared test) 247 | bayes_facts : dict 248 | contains (key, value) of (threshold : bayes factor) 249 | """ 250 | 251 | # cast labels and scores to pandas Series 252 | df = pd.DataFrame(list(zip(labels, results)), columns=['label', 'result']) 253 | 254 | min_props = {} 255 | fisher_ps = {} 256 | chi_ps = {} 257 | z_ps = {} 258 | bayes_facts = {} 259 | 260 | if comp_groups is not None: 261 | df = df[df['label'].isin(comp_groups)] 262 | 263 | # define range of values to test over if not inputted 264 | if low is None: 265 | low = min(results) 266 | if high is None: 267 | high = max(results) 268 | 269 | thresholds = np.linspace(low, high, num) 270 | 271 | skip_thresholds = [] 272 | for thresh in thresholds: 273 | 274 | df['dec'] = [i >= thresh for i in results] 275 | 276 | # compare rates of passing across groups 277 | ctabs = pd.crosstab(df['label'], df['dec']) 278 | 279 | # skip any thresholds for which the crosstabs are one-dimensional 280 | if 1 in ctabs.shape: 281 | skip_thresholds.append(thresh) 282 | continue 283 | 284 | normed_ctabs = ctabs.div(ctabs.sum(axis=1), axis=0) 285 | true_val = max(set(df['dec'])) 286 | max_group = normed_ctabs[true_val].max() 287 | normed_proportions = normed_ctabs[true_val] / max_group 288 | min_proportion = normed_proportions.min() 289 | 290 | # run statistical tests 291 | if ctabs.shape == (2, 2): 292 | test_results = test_multiple(df['label'].values, df['dec'].values) 293 | z_pval = test_results.get('z_score')[1] 294 | fisher_pval = test_results.get('fisher_p')[1] 295 | chi2_pval = test_results.get('chi2_p')[1] 296 | bayes_fact = test_results.get('BF') 297 | 298 | else: 299 | top_bottom_ctabs = top_bottom_crosstab(df['label'], df['dec']) 300 | z_pval = crosstab_ztest(top_bottom_ctabs)[1] 301 | fisher_pval = fisher_exact(top_bottom_ctabs)[1] 302 | chi2_pval = chi2_contingency(ctabs)[1] 303 | bayes_fact = crosstab_bayes_factor(ctabs) 304 | 305 | min_props[thresh] = min_proportion 306 | z_ps[thresh] = z_pval 307 | fisher_ps[thresh] = fisher_pval 308 | chi_ps[thresh] = chi2_pval 309 | bayes_facts[thresh] = bayes_fact 310 | 311 | if len(skip_thresholds) > 0 and print_skips: 312 | print('One-dimensional thresholds were skipped: {}'.format( 313 | skip_thresholds)) 314 | return min_props, z_ps, fisher_ps, chi_ps, bayes_facts 315 | 316 | 317 | def proportion_test(labels, decisions): 318 | """ 319 | Compare rates of passing across groups, 320 | relative to the highest passing group 321 | 322 | Parameters 323 | ---------- 324 | labels : array_like 325 | categorical labels for each corresponding value of `decision` ie. M/F 326 | 327 | decisions : array_like 328 | binary decision values, ie. True/False, 0/1 or 'pass'/'fail' 329 | NB: the 'passing' value must evaluate to greater than the failing value 330 | 331 | Returns 332 | ------- 333 | normed_proportions : pd.Series 334 | displays pass rates by `label` group 335 | relative to the highest passing group (which itself is always 1.0) 336 | """ 337 | check_consistent_length(labels, decisions) 338 | decisions = boolean_array(decisions) 339 | crosstab = pd.crosstab(pd.Series(labels), pd.Series(decisions)) 340 | 341 | # require crosstab not to be one-dimensional (e.g. one kind of label) 342 | if 1 in crosstab.shape: 343 | raise ValueError('One-dimensional data has no proportions') 344 | 345 | normed_ctabs = crosstab.div(crosstab.sum(axis=1), axis=0) 346 | true_val = max(set(decisions)) 347 | max_group = normed_ctabs[true_val].max() 348 | normed_proportions = normed_ctabs[true_val] / max_group 349 | return normed_proportions 350 | 351 | 352 | def test_multiple(labels, decisions, 353 | tests=('ztest', 'fisher', 'chi2', 'BF', 'prop'), 354 | display=False): 355 | """ 356 | Function that returns p_values for z-score, fisher exact, and chi2 test 357 | of 2x2 crosstab of passing rate by labels and decisions 358 | 359 | See docs for z_test_ctabs, fisher_exact, chi2_contingency and 360 | bf_ctabs for details of specific tests 361 | 362 | Parameters 363 | ---------- 364 | labels : array_like 365 | categorical labels for each corresponding value of `decision` ie. M/F 366 | 367 | decisions : array_like 368 | binary decision values, ie. True/False or 0/1 369 | 370 | tests : list 371 | a list of strings specifying the tests to run, valid options 372 | are 'ztest', 'fisher', 'chi2' and 'bayes'. Defaults to all four. 373 | -ztest: p-value for two-sided z-score for proportions 374 | -fisher: p-value for Fisher's exact test for proportions 375 | -chi2: p-value for chi-squared test of independence for proportions 376 | -bayes: bayes factor for independence assuming uniform prior 377 | -prop: proportion of lowest to highest passing rates by group 378 | 379 | display : bool 380 | print the results of each test in addition to returning them 381 | 382 | Returns 383 | ------- 384 | results : dict 385 | dictionary of values, one for each test. 386 | Valid keys are: 'z_score', 'fisher_p', 'chi2_p', 'BF', and 'prop' 387 | 388 | Examples 389 | -------- 390 | >>> # no real difference between groups 391 | >>> labels = ['group1']*100 + ['group2']*100 + ['group3']*100 392 | >>> decisions = [1,0,0]*100 393 | >>> all_test_ctabs(dependent_ctabs) 394 | (0.0, 1.0, 1.0, 0.26162148804907587) 395 | 396 | >>> # massively biased ratio of hits/misses by group 397 | >>> ind_ctabs = np.array([[75,50],[25,50]]) 398 | >>> all_test_ctabs(ind_ctabs) 399 | (-3.651483716701106, 400 | 0.0004203304586999487, 401 | 0.0004558800052056139, 402 | 202.95548692414306) 403 | 404 | >>> # correcting with a biased prior 405 | >>> biased_prior = np.array([[5,10],[70,10]]) 406 | >>> all_test_ctabs(ind_ctabs, biased_prior) 407 | (-3.651483716701106, 408 | 0.0004203304586999487, 409 | 0.0004558800052056139, 410 | 0.00012159518854984268) 411 | """ 412 | 413 | decisions = boolean_array(decisions) 414 | crosstab = pd.crosstab(pd.Series(labels), pd.Series(decisions)) 415 | crosstab = crosstab.values 416 | 417 | # can only perform 2-group z-tests & fisher tests 418 | # getting crosstabs for groups with highest and lowest pass rates 419 | # as any difference between groups is considered biased 420 | tb_crosstab = top_bottom_crosstab(labels, decisions) 421 | 422 | results = {} 423 | if 'ztest' in tests: 424 | results['z_score'] = crosstab_ztest(tb_crosstab) 425 | if 'fisher' in tests: 426 | # although fisher's exact can be generalized to multiple groups 427 | # scipy is limited to shape (2, 2) 428 | # TODO make generalized fisher's exact test 429 | # returns oddsratio and p-value 430 | results['fisher_p'] = fisher_exact(tb_crosstab)[:2] 431 | if 'chi2' in tests: 432 | # returns chi2 test statistic and p-value 433 | results['chi2_p'] = chi2_contingency(crosstab)[:2] 434 | if 'BF' in tests: 435 | results['BF'] = crosstab_bayes_factor(crosstab) 436 | if 'prop' in tests: 437 | results['prop'] = min(proportion_test(labels, decisions)) 438 | 439 | if display: 440 | for key in results: 441 | print("{}: {}".format(key, results[key])) 442 | 443 | return results 444 | 445 | 446 | def quick_bias_check(clf, df, feature_names, categories, thresh_pct=80, 447 | pass_ratio=.8): 448 | """ 449 | Useful for generating a bias_report more quickly than make_bias_report 450 | simply uses np.percentile for checks 451 | 452 | Parameters 453 | ----------- 454 | clf : sklearn clf 455 | fitted clf with predict object 456 | df : pandas DataFrame 457 | reference dataframe containing labeled features to test for bias 458 | feature_names : list of strings 459 | names of features used in fitting clf 460 | categories : list of strings 461 | names of categories to test for bias, e.g. ['gender', 'ethnicity'] 462 | thresh_pct : float, default 80 463 | percentile in [0, 100] at which to check for pass rates 464 | pass_ratio : float, default .8 465 | cutoff specifying whether ratio of min/max pass rates is acceptable 466 | 467 | Returns 468 | -------- 469 | passed: bool 470 | indicates whether all groups have min/max pass rates >= `pass_ratio` 471 | bias_report : dict 472 | of form {'gender': {'categories':['F', 'M'], 473 | 'averages': [.2, .22], 474 | 'errors': [[.2, .2], [.22, .22]]} 475 | } 476 | min_bias_ratio : float 477 | min of min_max_ratios across all categories 478 | if this value is less than `pass_ratio`, passed == False 479 | """ 480 | 481 | bdf = df.copy() 482 | X = bdf.loc[:, feature_names].values 483 | decs = clf.decision_function(X) 484 | bdf['score'] = decs 485 | 486 | min_max_ratios = [] 487 | bias_report = {} 488 | for category in categories: 489 | cat_df = bdf[bdf[category].notnull()] 490 | cat_df['pass'] = cat_df.score > np.percentile(cat_df.score, thresh_pct) 491 | cat_group = bdf.groupby(category).mean()['pass'] 492 | cat_dict = cat_group.to_dict() 493 | min_max_ratios.append(cat_group.min()/float(cat_group.max())) 494 | bias_report[category] = {'averages': cat_dict.values(), 495 | 'categories': cat_dict.keys(), 496 | 'errors': [[i, i] for i in cat_dict.values()] 497 | } 498 | 499 | passed = all(np.array(min_max_ratios) >= pass_ratio) 500 | min_bias_ratio = min(min_max_ratios) 501 | return passed, bias_report, min_bias_ratio 502 | 503 | 504 | def one_way_mi(df, feature_list, group_column, y_var, bins): 505 | 506 | """ 507 | Calculates one-way mutual information group variable and a 508 | target variable (y) given a feature list regarding. 509 | 510 | Parameters 511 | ---------- 512 | df : pandas DataFrame 513 | df with features used to train model, plus a target variable 514 | and a group column. 515 | feature_list : list DataFrame 516 | List of strings, feature names. 517 | group_column : string 518 | name of column for testing bias, should contain numeric categories 519 | y_var : string 520 | name of target variable column 521 | bins : tuple 522 | number of bins for each dimension 523 | 524 | Returns 525 | ------- 526 | mi_table : pandas DataFrame 527 | data frame with mutual information values, with one row per feature 528 | in the feature_list, columns for group and y. 529 | """ 530 | 531 | group_cats = df[group_column].values 532 | y_cats = df[y_var].values 533 | 534 | c_g = [ 535 | np.histogramdd([np.array(df[feature]), group_cats], bins=bins)[0] 536 | for feature in feature_list 537 | ] 538 | c_y = [ 539 | np.histogramdd([np.array(df[feature]), y_cats], bins=bins)[0] 540 | for feature in feature_list 541 | ] 542 | 543 | # compute mutual information (MI) between trait and gender/eth/y 544 | mi_g = [mutual_info_score(None, None, contingency=i) for i in c_g] 545 | mi_y = [mutual_info_score(None, None, contingency=i) for i in c_y] 546 | mi_table = pd.DataFrame({'feature': feature_list, 547 | group_column: mi_g, 548 | y_var: mi_y}) 549 | 550 | # NOTE: Scale group and y where the highest MI is scaled to 1 to 551 | # facilitate interpreting relative importance to bias and performance 552 | mi_table["{}_scaled".format(group_column)] = ( 553 | mi_table[group_column] / mi_table[group_column].max() 554 | ) 555 | mi_table["{}_scaled".format(y_var)] = ( 556 | mi_table[y_var] / mi_table[y_var].max() 557 | ) 558 | 559 | return mi_table 560 | -------------------------------------------------------------------------------- /auditai/simulations.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, OrderedDict 2 | from itertools import combinations 3 | 4 | import numpy as np 5 | import pandas as pd 6 | from scipy import stats 7 | from scipy.stats import chi2_contingency 8 | 9 | from .utils.general import get_unique_name 10 | from .utils.validate import ClassifierWrapper 11 | 12 | 13 | def classifier_posterior_probabilities(clf, df, feature_names, categories, 14 | low=None, high=None, num=100): 15 | """ 16 | Function to compute posterior probabilities and 90% credible intervals 17 | for demographic categories 18 | 19 | Parameters 20 | ---------- 21 | clf : sklearn clf 22 | fitted clf with predict object 23 | df : dataframe 24 | containing labeled columns to test for bias 25 | feature_names : list of strings 26 | names of features used in fitting clf 27 | categories : list of strings 28 | demographic columns in df to test for bias, e.g. ['gender', ...] 29 | low : float 30 | lower bound threshold 31 | high : float 32 | upper bound threshold 33 | num : int 34 | number of values in threshold range 35 | 36 | Returns 37 | ------- 38 | thresholds_to_check : np.array 39 | np.linspace(low, high, num) 40 | range of float thresholds checked for posterior prob. 41 | post_probs : defaultdict(list) 42 | containing posterior probability at each threshold 43 | 44 | Example 45 | --------- 46 | given 47 | 1) a classifier, clf, trained on: 48 | three features ('feat1', 'feat2', 'feat3') 49 | and a target 'y', containing values ranging from 0 to 1, and 50 | 2) a df containing columns: ['feat1', 'feat2', 'feat3', 'gender'] 51 | 52 | classifier_posterior_probabilities(clf, 53 | df, 54 | ['feat1', 'feat2', 'feat3'], 55 | ['gender']) 56 | >>> np.array([0.6, ..., 0.9]), defaultdict(list, {'gender__F':[.1,...]}) 57 | """ 58 | 59 | # get decision score for each user and sort by the score 60 | 61 | # this sort makes finding who matches at a threshold easy 62 | X = df[feature_names].values 63 | 64 | # copy dataframe and add decision column: 65 | df = df.copy() 66 | # require clf to have decision_function or predict_proba 67 | clf = ClassifierWrapper(clf) 68 | df['decision'] = clf.decision_function(X) 69 | # allow for older and newer pandas sorting schemes 70 | if hasattr(df, 'sort_values'): 71 | sorted_df = df.reindex( 72 | df.sort_values( 73 | 'decision', 74 | ascending=False).index) 75 | else: 76 | sorted_df = df.reindex(df.sort('decision', ascending=False).index) 77 | 78 | n_samples = sorted_df.shape[0] 79 | 80 | # define range of values to test over if not inputted 81 | if low is None: 82 | low = df.decision.min() 83 | if high is None: 84 | high = df.decision.max() 85 | 86 | thresholds_to_check = np.linspace(low, high, num) 87 | post_probs = defaultdict(list) 88 | 89 | for thresh in thresholds_to_check: 90 | # set the top 1-thresh proportion of sample to 1 (match) and the 91 | # rest to 0 (not match) 92 | num_matches = int(n_samples * (1 - thresh)) 93 | num_not_matches = (n_samples - int(n_samples * (1 - thresh))) 94 | matched_col = get_unique_name('matched', df.columns) 95 | sorted_df[matched_col] = ([1] * num_matches) + ([0] * num_not_matches) 96 | 97 | # bias category probabilities 98 | for category in categories: 99 | cat_vals = set(sorted_df[category].dropna()) 100 | for val in cat_vals: 101 | n = sum(sorted_df[category] == val) 102 | x = sum(np.logical_and(sorted_df[category] == val, 103 | sorted_df[matched_col])) 104 | a = x + 0.5 105 | b = n - x + 0.5 106 | l, u = stats.beta.interval(0.95, a, b, loc=0, scale=1) 107 | feat_sim = [ 108 | round(stats.beta.mean(a, b, loc=0, scale=1), 3), 109 | round(l, 3), 110 | round(u, 3) 111 | ] 112 | post_probs[str(category) + '__' + str(val)].append(feat_sim) 113 | 114 | return thresholds_to_check, post_probs 115 | 116 | 117 | def get_bias_chi2_pvals(clf, df, feature_names, categories, 118 | low=None, high=None, num=100, **kwargs): 119 | """ 120 | Get p-values across a range of decision thresholds 121 | 122 | Parameters 123 | ------------ 124 | clf : sklearn clf object 125 | model classifier, must have a `decision_function` or 126 | `predict_proba` method 127 | df : pandas DataFrame 128 | contains untransformed data 129 | feature_names : list of strings 130 | features included in the classifier 131 | categories : list of strings 132 | names of demographic columns to check, e.g. ['gender', 'ethnicity'] 133 | low : float 134 | lower threshold value 135 | high : float 136 | upper threshold value 137 | num : int 138 | number of thresholds to consider 139 | 140 | Returns 141 | --------- 142 | thresholds_to_check : range of floats 143 | decision thresholds obtained by np.linspace(low, high,num) 144 | post_chi2stat_pvals : defaultdict(list) 145 | containing categories' chi2 statistics and p_vals at a range 146 | of thresholds 147 | 148 | """ 149 | 150 | # get decision score for each user and sort by the score 151 | # this sort makes finding who matches at a threshold easy 152 | X = df[feature_names].values 153 | 154 | # subsequent modifications on copy of the input dataframe 155 | df = df.copy() 156 | clf = ClassifierWrapper(clf) 157 | df['decision'] = clf.decision_function(X) 158 | # allow for older and newer pandas sorting schemes 159 | if hasattr(df, 'sort_values'): 160 | sorted_df = df.reindex( 161 | df.sort_values( 162 | 'decision', 163 | ascending=False).index) 164 | else: 165 | sorted_df = df.reindex(df.sort('decision', ascending=False).index) 166 | 167 | matched_col = get_unique_name('matched', df.columns) 168 | 169 | # define range of values to test over if not inputted 170 | if low is None: 171 | low = df.decision.min() 172 | if high is None: 173 | high = df.decision.max() 174 | 175 | n_samples = sorted_df.shape[0] 176 | thresholds_to_check = np.linspace(low, high, num) 177 | post_chi2stat_pvals = defaultdict(list) 178 | 179 | for threshold in thresholds_to_check: 180 | # set the top 1-threshold proportion of sample to 1 (match) and the 181 | # rest to 0 (not match) 182 | num_matches = int(n_samples * (1 - threshold)) 183 | num_not_matches = (n_samples - int(n_samples * (1 - threshold))) 184 | sorted_df[matched_col] = ([1] * num_matches) + ([0] * num_not_matches) 185 | 186 | for category in categories: 187 | # get p-values for non-nan values 188 | category_vals = set(sorted_df[category].dropna()) 189 | cat_df = sorted_df[sorted_df[category].isin(category_vals)] 190 | cat_ctabs = pd.crosstab(cat_df[matched_col], cat_df[category]) 191 | chi2_stat, chi2_pval = chi2_contingency(cat_ctabs)[:2] 192 | post_chi2stat_pvals[category].append((chi2_stat, chi2_pval)) 193 | 194 | return thresholds_to_check, post_chi2stat_pvals 195 | 196 | 197 | def generate_bayesfactors(clf, df, feature_names, categories, 198 | prior_strength='', threshold=None, 199 | hyperparam=(1, 1, 1, 1), 200 | N=1000, low=.01, high=.99, num=99, **kwargs): 201 | """ 202 | Function to check demographic bias of clf with reference to 203 | dataframe containing labeled features. Decision functions for test 204 | data are sorted, and users in top thresholds, in terms of decision 205 | functions, are considered 'matched'. Proportion of matched users across 206 | demographic categories are used to estimate posterior densities of 207 | probability of being matched within demographic categories. The mean and 208 | 90% credible intervals of ratios of probabilities between categories are 209 | calculated and can be plotted using `viz.get_bias_plots`. 210 | The intervals are compared to the null hypothetical bounds of 211 | 0.8-1.2, with reference to the "4/5ths Rule" for adverse impact, 212 | across the range of thresholds. If the interval overlaps the 213 | bounds across thresholds, then the clf can be considered to be 'unbiased'. 214 | 215 | Parameters 216 | ---------- 217 | 218 | clf : sklearn clf 219 | fitted clf with predict object 220 | df : pandas DataFrame 221 | reference dataframe containing labeled features to test for bias 222 | feature_names : list of strings 223 | names of features used in fitting clf 224 | categories : list of strings 225 | names of categories to test for bias, e.g. ['gender', ...] 226 | prior_strength : string from {'weak', 'strong', 'uniform'} 227 | prior distribution to be 'informative'/'noninformative'/'uniform' 228 | threshold : float 229 | single decision threshold at which to evaluate bias 230 | hyperparam : tuple (alpha1, beta1, alpha2, beta2) 231 | optional manual input of hyperparameters 232 | N : int 233 | number of posterior samples to draw for each simulation 234 | low, high, num : float, float, int 235 | range of values for thresholds 236 | 237 | Returns 238 | ------- 239 | thresholds_to_check : array of floats 240 | decision thresholds obtained by np.linspace(low, high, num) 241 | ratio_probs : OrderedDict() 242 | contains ratios of matched probabilities within demographic categories 243 | N : int 244 | number of posterior samples drawn for each simulation 245 | """ 246 | 247 | X = df[feature_names].values 248 | 249 | df = df.copy() 250 | # get decision score for each user and sort by the score 251 | clf = ClassifierWrapper(clf) 252 | df['decision'] = clf.decision_function(X) 253 | # this sort makes finding who matches at a threshold easy 254 | # allow for older and newer pandas sorting schemes 255 | if hasattr(df, 'sort_values'): 256 | sorted_df = df.reindex( 257 | df.sort_values( 258 | 'decision', 259 | ascending=False).index) 260 | else: 261 | sorted_df = df.reindex(df.sort('decision', ascending=False).index) 262 | 263 | # allow for testing at optional single threshold instead of range: 264 | if threshold is not None: 265 | low = high = threshold 266 | num = 1 267 | 268 | n_samples = sorted_df.shape[0] 269 | thresholds_to_check = np.linspace(low, high, num) 270 | 271 | # raise ValueError if there is a threshold where everyone would pass/fail 272 | if 0.0 in thresholds_to_check: 273 | raise ValueError('Only passing values at a thresh of 0, increase low') 274 | if 1.0 in thresholds_to_check: 275 | raise ValueError('Only failing values at thresh of 1, decrease high') 276 | 277 | ratio_probs = defaultdict(list) 278 | 279 | for thresh in thresholds_to_check: 280 | # set the top (1-thresh) of samples to 1 (match), rest to 0 (not match) 281 | num_matches = int(n_samples * (1 - thresh)) 282 | num_not_matches = (n_samples - int(n_samples * (1 - thresh))) 283 | matched_col = get_unique_name('matched', df.columns) 284 | sorted_df[matched_col] = ([1] * num_matches) + ([0] * num_not_matches) 285 | 286 | # for all categories, generate contigency tables 287 | # and simulate posterior sample for ratio of matched probabilities 288 | for category in categories: 289 | feature_vals = set(df[category].dropna()) 290 | for v1, v2 in combinations(feature_vals, 2): 291 | 292 | mask = np.logical_or(df[category] == v1, 293 | df[category] == v2) 294 | 295 | ct_table = pd.crosstab( 296 | sorted_df.loc[mask, category], 297 | sorted_df.loc[mask, matched_col] 298 | ) 299 | 300 | ct_table = ct_table.reindex( 301 | index=[v1, v2], 302 | columns=[0, 1], 303 | fill_value=0 304 | ).values 305 | 306 | feat_sim = sim_beta_ratio(ct_table, thresh, prior_strength, 307 | hyperparam, N, return_bayes=True) 308 | 309 | # alphabetize out_string 310 | sorted_combo = sorted([str(v1), str(v2)]) 311 | out_string = "{}: {} over {}".format( 312 | str(category), sorted_combo[0], sorted_combo[1]) 313 | # first occurence gets an OrderedDict but others call the 314 | # existing ratio_probs 315 | ratio_probs[out_string] = ratio_probs.get(out_string, 316 | OrderedDict()) 317 | ratio_probs[out_string][thresh] = feat_sim 318 | 319 | return thresholds_to_check, ratio_probs, N 320 | 321 | 322 | def sim_beta_ratio(table, threshold, prior_strength, hyperparam, N, 323 | return_bayes=False): 324 | """ 325 | Calculates simulated ratios of match probabilites using a beta 326 | distribution and returns corresponding means and 95% credible 327 | intervals, posterior parameters, Bayes factor 328 | 329 | Parameters 330 | ------------ 331 | table : 2x2 numpy array 332 | corresponds to contingency table, 333 | for example, 334 | False True 335 | GroupA 5 4 336 | GroupB 3 4 337 | contains frequency counts: [[5, 4], [3, 4]] 338 | threshold : float 339 | value to split continuous variable on 340 | prior_strength : string from {'weak', 'strong', 'uniform'} 341 | prior distribution to be 'informative'/'noninformative'/'uniform' 342 | N : int 343 | number of posterior samples to draw for each simulation 344 | 345 | Returns 346 | ------------ 347 | list : means and 95% credible intervals, posterior parameters, Bayes factor 348 | """ 349 | 350 | n_sim = N 351 | # store array of total counts in table by category 352 | category_counts = table.sum(axis=1, dtype=float) 353 | # store array of number of matches by categories 354 | match_counts = table[:, 1] 355 | # set hyperparameters according to threshold and sample size 356 | if prior_strength == 'weak': 357 | # weakly informative prior, has standard deviation 358 | # of 0.1 at alpha / (alpha + beta) = 0.5 359 | # coefficient 24 is empirically derived for best smoothing at small N 360 | alpha1, beta1 = (1 - threshold) * 24., threshold * 24. 361 | alpha2, beta2 = (1 - threshold) * 24., threshold * 24. 362 | elif prior_strength == 'strong': 363 | # observing 'idealized' dataset of size n 364 | alpha1 = round((1 - threshold) * category_counts[0]) 365 | beta1 = round(threshold * category_counts[0]) 366 | alpha2 = round((1 - threshold) * category_counts[1]) 367 | beta2 = round(threshold * category_counts[1]) 368 | elif prior_strength == 'uniform': 369 | # uniform prior 370 | alpha1, beta1 = 1, 1 371 | alpha2, beta2 = 1, 1 372 | else: 373 | # user specified, defaults to uniform 374 | alpha1, beta1, alpha2, beta2 = hyperparam 375 | 376 | # draw posterior sample of matching probabilities 377 | post_alpha1 = alpha1 + match_counts[0] 378 | post_beta1 = beta1 + category_counts[0] - match_counts[0] 379 | 380 | post_alpha2 = alpha2 + match_counts[1] 381 | post_beta2 = beta2 + category_counts[1] - match_counts[1] 382 | 383 | p1 = np.random.beta(post_alpha1, post_beta1, n_sim) 384 | p2 = np.random.beta(post_alpha2, post_beta2, n_sim) 385 | 386 | # posterior draw of ratios 387 | p1p2 = p1 / p2 388 | p2p1 = p2 / p1 389 | 390 | sim_beta_ratio_metrics = [np.mean(p1p2), np.mean(p2p1), 391 | np.std(p1p2), np.std(p2p1), 392 | np.percentile(p1p2, 2.5), 393 | np.percentile(p2p1, 2.5), 394 | np.percentile(p1p2, 97.5), 395 | np.percentile(p2p1, 97.5), 396 | (post_alpha1, post_beta1), 397 | (post_alpha2, post_beta2)] 398 | 399 | if return_bayes: 400 | # Return bayes factor for % of posterior ratios in range [.8, 1.25] 401 | post_prob_null = np.sum((p1p2 >= 0.8) & (p1p2 <= 1.25)) / float(n_sim) 402 | bayes_factor = post_prob_null / (1 - post_prob_null) 403 | sim_beta_ratio_metrics.append(bayes_factor) 404 | 405 | return sim_beta_ratio_metrics 406 | -------------------------------------------------------------------------------- /auditai/stats.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pandas import DataFrame 3 | from scipy.stats import chi2, fisher_exact, chi2_contingency 4 | from functools import partial 5 | 6 | from .utils.crosstabs import (crosstab, 7 | crosstab_bayes_factor, 8 | crosstab_df, 9 | crosstab_odds_ratio, 10 | crosstab_ztest, 11 | get_top_bottom_indices, 12 | top_bottom_crosstab) 13 | from .utils.validate import (check_consistent_length, 14 | boolean_array) 15 | from .utils.cmh import (parse_matrix, 16 | extract_data) 17 | 18 | 19 | def ztest(labels, results, threshold=None): 20 | """ 21 | Two-tailed z score for proportions 22 | 23 | Parameters 24 | ---------- 25 | labels : array_like 26 | categorical labels for each corresponding value of `result` ie. M/F 27 | 28 | results : array_like 29 | binary decision values, if continuous values are supplied then 30 | the `threshold` must also be supplied to generate binary decisions 31 | 32 | threshold : numeric 33 | value dividing scores into True/False 34 | 35 | Returns 36 | ------- 37 | z-score: float 38 | test statistic of two-tailed z-test 39 | z > 1.960 is signficant at p = .05 40 | z > 2.575 is significant at p = .01 41 | z > 3.100 is significant at p = .001 42 | """ 43 | 44 | check_consistent_length(labels, results) 45 | results = np.array(results) 46 | 47 | # convert the results to True/False 48 | results = boolean_array(results, threshold=threshold) 49 | # get crosstab for two groups 50 | ctab = top_bottom_crosstab(labels, results) 51 | 52 | zstat, pvalue = crosstab_ztest(ctab) 53 | return zstat, pvalue 54 | 55 | 56 | def fisher_exact_test(labels, results, threshold=None): 57 | """ 58 | Returns odds ratio and p-value of Fisher's exact test 59 | Uses scipy.stats.fisher_exact, which only works for 2x2 contingency tables 60 | https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.fisher_exact.html 61 | 62 | Parameters 63 | ---------- 64 | labels : array_like 65 | categorical labels for each corresponding value of `result` ie. M/F 66 | 67 | results : array_like 68 | binary decision values, if continuous values are supplied then 69 | the `threshold` must also be supplied to generate binary decisions 70 | 71 | threshold : numeric 72 | value dividing scores into True/False, where result>=threshold == True 73 | 74 | Returns 75 | ------- 76 | oddsratio : float 77 | This is prior odds ratio and not a posterior estimate. 78 | pvalue : float 79 | P-value, the probability of obtaining a distribution at least 80 | as extreme as the one that was actually observed, assuming that 81 | the null hypothesis is true. 82 | """ 83 | 84 | check_consistent_length(labels, results) 85 | results = np.array(results) 86 | 87 | # convert the results to True/False 88 | results = boolean_array(results, threshold=threshold) 89 | # get crosstab for two groups 90 | ctab = top_bottom_crosstab(labels, results) 91 | 92 | oddsratio, pvalue = fisher_exact(ctab) 93 | return oddsratio, pvalue 94 | 95 | 96 | def chi2_test(labels, results, threshold=None): 97 | """ 98 | Takes list of labels and results and returns odds ratio and p-value of 99 | Chi-square test of independence. Uses scipy.stats.chi2_contingency, 100 | using an Rx2 contingency table 101 | https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.chi2_contingency.html 102 | 103 | Parameters 104 | ---------- 105 | labels : array_like 106 | categorical labels for each corresponding value of `result` ie. M/F 107 | 108 | results : array_like 109 | binary decision values, if continuous values are supplied then 110 | the `threshold` must also be supplied to generate binary decisions 111 | 112 | threshold : numeric 113 | value dividing scores into True/False, where result>=threshold == True 114 | 115 | Returns 116 | ------- 117 | chi2_stat : float 118 | The test statistic. 119 | pvalue : float 120 | P-value, the probability of obtaining a distribution at least 121 | as extreme as the one that was actually observed, assuming that 122 | the null hypothesis is true. 123 | """ 124 | 125 | check_consistent_length(labels, results) 126 | results = np.array(results) 127 | 128 | # convert the results to True/False 129 | results = boolean_array(results, threshold=threshold) 130 | ctab = crosstab(labels, results) 131 | 132 | chi2_stat, pvalue = chi2_contingency(ctab)[:2] 133 | return chi2_stat, pvalue 134 | 135 | 136 | def bayes_factor(labels, results, threshold=None, 137 | priors=None, top_bottom=True): 138 | """ 139 | Computes the analytical Bayes factor for an nx2 contingency table. 140 | If matrix is bigger than 2x2, calculates the bayes factor for the entire 141 | dataset unless top_bottom is set to True. Will always calculate odds ratio 142 | between largest and smallest diverging groups 143 | 144 | 145 | Adapted from Albert (2007) Bayesian Computation with R, 146 | 1st ed., pg 178:184 147 | 148 | Parameters 149 | ---------- 150 | labels : array_like 151 | categorical labels for each corresponding value of `result` ie. M/F 152 | 153 | results : array_like 154 | binary decision values, if continuous values are supplied then 155 | the `threshold` must also be supplied to generate binary decisions 156 | 157 | threshold : numeric, optional 158 | value dividing scores into True/False, where result>=threshold == True 159 | 160 | priors : ndarray, optional, shape (n,2) 161 | e.g. uniform_prior = np.array([[1,1],[1,1]]) 162 | 163 | 164 | Returns 165 | ------- 166 | 167 | BF : float, 168 | bayes factor for the hypothesis of independence 169 | BF < 1 : support for a hypothesis of dependence 170 | 1 < BF < 3 : marginal support for independence 171 | 3 < BF < 20 : moderate support for independence 172 | 20 < BF < 150 : strong support for independence 173 | BF > 150 : very strong support for independence 174 | odds_ratio : float, 175 | odds ratio for the highest and lowest groups. 176 | 177 | Examples 178 | -------- 179 | TODO : update examples with odds ratios 180 | 181 | >>> from biastesting.categorical import bf_ctabs 182 | >>> ctabs = np.array([[50,50],[50,50]]) # 1:1 hit/miss ratio 183 | >>> # assuming uniformity 184 | >>> uniform_prior = np.array([[1,1],[1,1]]) 185 | >>> bf_ctabs(ctabs, uniform_prior) #all same leads to low BF (dependence) 186 | 0.26162148804907587 187 | 188 | >>> biased_prior = np.array([[5,10],[70,10]]) 189 | >>> # all same given strong priors lowers BF (stronger proof of dependence) 190 | >>> bf_ctabs(ctabs, biased_prior) 191 | 0.016048077654948357 192 | 193 | >>> biased_ctab = np.array([[75,50],[25,50]]) 194 | >>> # biased crosstabs against prior assumption of uniformity 195 | >>> # large BF (strong proof of independence) 196 | >>> bf_ctabs(biased_ctab, biased_prior) 197 | 202.95548692414306 198 | 199 | >>> # assuming a large prior bias in one direction 200 | >>> # conteracts a large observed dataset in the opposite direction 201 | >>> bf_ctabs(biased_ctab, biased_prior) 202 | 0.00012159518854984268 203 | """ 204 | check_consistent_length(labels, results) 205 | results = np.array(results) 206 | 207 | # convert the results to True/False 208 | results = boolean_array(results, threshold=threshold) 209 | ctab = crosstab_df(labels, results) 210 | 211 | if top_bottom: 212 | used_indices = list(get_top_bottom_indices(ctab)) 213 | ctab = ctab.iloc[used_indices] 214 | 215 | else: 216 | used_indices = list(ctab.index) 217 | 218 | if priors: 219 | prior = DataFrame(priors) 220 | prior = prior.iloc[used_indices] 221 | prior = prior.values 222 | else: 223 | prior = np.ones(shape=ctab.shape) 224 | 225 | ctab = ctab.values 226 | 227 | BF = crosstab_bayes_factor(ctab, prior) 228 | oddsratio = crosstab_odds_ratio(ctab) 229 | 230 | return oddsratio, BF 231 | 232 | 233 | def cmh_test(dfs, pass_col=False): 234 | """ 235 | The pairwise special case of the Cochran-Mantel-Haenszel test. The CMH test 236 | is a generalization of the McNemar Chi-Squared test of Homogenaity. Whereas 237 | the McNemar test examines differences over two intervals (usually before 238 | and after), the CMH test examines differences over any number of 239 | K instances. 240 | 241 | The Yates correction for continuity is not used; while some experts 242 | recommend its use even for moderately large samples (hundreds), the 243 | effect is to reduce the test statistic and increase the p-value. This 244 | is a conservative approach in experimental settings, but NOT in adverse 245 | impact monitoring; such an approach would systematically allow marginal 246 | cases of bias to go undetected. 247 | 248 | Parameters 249 | ------------ 250 | dfs : pd.DataFrame, shape = (2,2) OR list of pd.DataFrame objects 251 | a K-deep list of 2x2 contingency tables 252 | representing K locations or time frames 253 | rows indexed by group (0,1) 254 | columns labelled 'pass' and 'fail' 255 | pass_col : Boolean or string, optional 256 | if true, column names assumed to be 'pass' and 'fail' 257 | if string, enter column name of passing counts 258 | if false, column index 0 interpreted as passing 259 | column name of failing counts interpreted automatically 260 | 261 | Returns 262 | ---------- 263 | cmh : cmh chi-squared statistic 264 | pcmh : p-value of the cmh chi-squared statistic with 1 degree of freedom 265 | 266 | References 267 | ------- 268 | 269 | McDonald, J.H. 2014. Handbook of Biological Statistics (3rd ed.). Sparky 270 | House Publishing, Baltimore, Maryland. 271 | 272 | Forumla example from Boston University School of Public Health 273 | https://tinyurl.com/k3du64x 274 | """ 275 | partial_ed = partial(extract_data, pass_col=pass_col) 276 | data = list(map(partial_ed, dfs)) 277 | c_nums = [val[2] for val in data] 278 | c_dens = [val[3] for val in data] 279 | c_num = sum(c_nums)**2 280 | c_den = sum(c_dens) 281 | cmh = float(c_num)/float(c_den) 282 | 283 | pcmh = 1 - chi2.cdf(cmh, 1) 284 | return cmh, pcmh 285 | 286 | 287 | def multi_odds_ratio(dfs, pass_col=False): 288 | """ 289 | Common odds ratio. Designed for multiple interval odds ratio for use with 290 | the cmh test and breslow-day test, but is generalized for the 2x2 case. 291 | 292 | Parameters 293 | ------------ 294 | dfs : pd.DataFrame, shape = (2,2) OR list of pd.DataFrame objects 295 | a K-deep list of 2x2 contingency tables 296 | representing K locations or time frames 297 | rows indexed by group (0,1) 298 | columns labelled 'pass' and 'fail' 299 | pass_col : Boolean or string, optional 300 | if true, column names assumed to be 'pass' and 'fail' 301 | if string, enter column name of passing counts 302 | if false, column index 0 interpreted as passing 303 | column name of failing counts interpreted automatically 304 | 305 | Returns 306 | ---------- 307 | r : pooled odds ratio 308 | 309 | Example 310 | ------- 311 | from https://en.wikipedia.org/wiki/Odds_ratio#Example 312 | 313 | Of a sample of 100 men and 100 women, 90 men drank wine over the past week, 314 | while only 10 women did the same. 315 | 316 | df = pd.DataFrame({'pass':[90,20], 'fail':[10,80]}) 317 | multi_odds_ratio(df) 318 | > 36.0 319 | 320 | For odds ratios over multiple intervals, the use-case of the CMH test, 321 | let's presume that the next week 70 of 100 men drank wine, but now 322 | 70 of 100 women also drank. 323 | 324 | df2 = pd.DataFrame({'pass':[70,70], 'fail':[30,30]}) 325 | dfs = [df,df2] 326 | multi_odds_ratio(df) 327 | > 36.0 328 | 329 | multi_odds_ratio(df2) 330 | > 1.0 331 | 332 | multi_odds_ratio(dfs) 333 | >4.043478260869565 334 | 335 | References 336 | ---------- 337 | Boston University School of Public Health 338 | https://tinyurl.com/k3du64x 339 | """ 340 | 341 | if isinstance(dfs, list): 342 | # if we have a list of multiple dfs 343 | partial_ed = partial(extract_data, pass_col=pass_col) 344 | data = list(map(partial_ed, dfs)) 345 | r_nums = [val[0] for val in data] 346 | r_dens = [val[1] for val in data] 347 | r_num = sum(r_nums) 348 | r_den = sum(r_dens) 349 | elif np.shape(dfs) == (2, 2): 350 | data = extract_data(dfs, pass_col=pass_col) 351 | r_num = data[0] 352 | r_den = data[1] 353 | else: 354 | return('Input error. Requires 2x2 dataframe or list of dataframes') 355 | r = float(r_num)/float(r_den) 356 | return r 357 | 358 | 359 | def bres_day(df, r, pass_col=False): 360 | """ 361 | Calculates the Breslow-Day test of homogeneous association for a 362 | 2 x 2 x k table. E.g., given three factors, A, B, and C, the Breslow-Day 363 | test would measure wheher pairwise effects (AB, AC, BC) have identical 364 | odds ratios. 365 | 366 | Parameters 367 | ------------ 368 | df : pd.DataFrame, shape = (2,2) 369 | a 2x2 contingency table 370 | rows indexed by group (0,1) 371 | columns labelled 'pass' and 'fail' 372 | r : odds ratio; auditai.stats.multi_odds_ratio 373 | pass_col : Boolean or string, optional 374 | if true, column names assumed to be 'pass' and 'fail' 375 | if string, enter column name of passing counts 376 | if false, column index 0 interpreted as passing 377 | column name of failing counts interpreted automatically 378 | 379 | Returns 380 | ---------- 381 | bd : Breslow-Day chi-squared statistic 382 | pcmh : p-value of the cmh chi-squared statistic with 1 degree of freedom 383 | 384 | References 385 | ------- 386 | 387 | """ 388 | 389 | pass0, fail0, pass1, fail1, total = parse_matrix(df, pass_col) 390 | 391 | coef = [] 392 | coef.append(1.0-r) 393 | # coef.append(r*((a+c)+(a+b)) + (d-a)) 394 | # coef.append(r*(-1*(a+c)*(a+b))) 395 | coef.append(r*((pass0+pass1)+(pass0+fail0)) + (fail1-pass0)) 396 | coef.append(r*(-1*(pass0+pass1)*(pass0+fail0))) 397 | 398 | sols = np.roots(coef) 399 | if min(sols) > 0: 400 | t_a = min(sols) 401 | else: 402 | t_a = max(sols) 403 | 404 | t_b = (pass0+fail0) - t_a 405 | t_c = (pass0+pass1) - t_a 406 | t_d = (fail0+fail1) - t_b 407 | 408 | var = 1/((1/t_a) + (1/t_b) + (1/t_c) + (1/t_d)) 409 | bd = (pass0-t_a)**2/var 410 | pbd = 1 - chi2.cdf(bd, len(df)-1) 411 | 412 | return bd, pbd 413 | 414 | 415 | def test_cmh_bd(dfs, pass_col=False): 416 | """ 417 | Master function for Cochran-Mantel-Haenszel and associated tests 418 | Overview: Compare multiple 2x2 pass-fail contingency tables by gender 419 | or ethnicity (pairwise) to determine if there is a consistent 420 | pattern across regions, time periods, or similar groupings. 421 | 422 | Parameters 423 | ---------- 424 | dfs : pd.DataFrame, 2x2xK stack of contingency tables 425 | Cell values are counts of individuals. 426 | Columns are 'pass' and 'fail'. 427 | Rows are integer 1 for focal, 0 for reference group. 428 | K regions, time periods, etc. 429 | 430 | Returns 431 | ------- 432 | r : common odds ratio 433 | cmh : Cochran-Mantel-Haenszel statistic (pattern of impact) 434 | pcmh : p-value of Cochran-Mantel-Haenszel test 435 | bd : Breslow-Day statistic (sufficiency of common odds ratio) 436 | pbd : p-value of Breslow-Day test 437 | 438 | Example 439 | ------- 440 | Handbook of Biological Statistics by John H. McDonald 441 | Signicant CMH test with non-significant Breslow-Day test 442 | http://www.biostathandbook.com/cmh.html 443 | 444 | "McDonald and Siebenaller (1989) surveyed allele frequencies at the Lap 445 | locus in the mussel Mytilus trossulus on the Oregon coast. At four 446 | estuaries, we collected mussels from inside the estuary and from a marine 447 | habitat outside the estuary. There were three common alleles and a couple 448 | of rare alleles; based on previous results, the biologically interesting 449 | question was whether the Lap94 allele was less common inside estuaries, 450 | so we pooled all the other alleles into a "non-94" class." 451 | 452 | "There are three nominal variables: allele (94 or non-94), habitat 453 | (marine or estuarine), and area (Tillamook, Yaquina, Alsea, or Umpqua). 454 | The null hypothesis is that at each area, there is no difference in the 455 | proportion of Lap94 alleles between the marine and estuarine habitats." 456 | 457 | tillamook = pd.DataFrame({'Marine':[56,40],'Estuarine':[69,77]}) 458 | yaquina = pd.DataFrame({'Marine':[61,57],'Estuarine':[257,301]}) 459 | alsea = pd.DataFrame({'Marine':[73,71],'Estuarine':[65,79]}) 460 | umpqua = pd.DataFrame({'Marine':[71,55],'Estuarine':[48,48]}) 461 | dfs = [tillamook,yaquina,alsea,umpqua] 462 | 463 | test_cmh_bd(dfs) 464 | > (1.3174848702393571, 465 | 5.320927767938446, 466 | 0.021070789938349432, 467 | 0.5294859090315444, 468 | 0.9123673420971026) 469 | 470 | """ 471 | 472 | r = multi_odds_ratio(dfs, pass_col) 473 | cmh, pcmh = cmh_test(dfs, pass_col) 474 | part_bd = partial(bres_day, r=r, pass_col=pass_col) 475 | 476 | # sum of Breslow-Day chi-square statistics 477 | bd = DataFrame(list(map(part_bd, dfs)))[0].sum() 478 | pbd = 1 - chi2.cdf(bd, len(dfs)-1) 479 | 480 | return r, cmh, pcmh, bd, pbd 481 | -------------------------------------------------------------------------------- /auditai/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymetrics/audit-ai/4891b1d3c813a0e6ce657eaee3f1b2ab50e8f429/auditai/tests/__init__.py -------------------------------------------------------------------------------- /auditai/tests/test_crosstabs.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from numpy.testing import assert_almost_equal 4 | 5 | from auditai.utils.crosstabs import top_bottom_crosstab, crosstab_ztest 6 | 7 | 8 | class TestTBCrosstab(unittest.TestCase): 9 | 10 | def test_tbcross_shape(self): 11 | T10 = [True] * 10 12 | F10 = [False] * 10 13 | labels = ['F'] * 20 + ['M'] * 20 14 | decisions = T10 + F10 + T10 + T10 15 | ctab = top_bottom_crosstab(labels, decisions) 16 | self.assertEqual(len(ctab), 2) 17 | self.assertEqual(len(ctab[0]), 2) 18 | self.assertEqual(ctab[0][1], 10) 19 | self.assertEqual(ctab[1][1], 20) 20 | 21 | 22 | class TestCtabZtest(unittest.TestCase): 23 | 24 | def test_crosstab_ztest(self): 25 | z, p = crosstab_ztest([[10, 10], [10, 10]]) 26 | self.assertEqual(z, 0.0) 27 | z, p = crosstab_ztest([[10, 10], [0, 20]]) 28 | assert_almost_equal(z, -3.6514837167011072) 29 | z, p = crosstab_ztest([[78, 5], [87, 12]]) 30 | assert_almost_equal(z, -1.4078304151258787) 31 | -------------------------------------------------------------------------------- /auditai/tests/test_misc.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from six import StringIO 3 | import sys 4 | import re 5 | import pandas as pd 6 | 7 | from auditai import misc 8 | 9 | 10 | def pass_fail_count(output): 11 | pass_cnt = len(re.findall('pass', output)) 12 | fail_cnt = len(re.findall('fail', output)) 13 | return pass_cnt, fail_cnt 14 | 15 | 16 | class TestMisc(unittest.TestCase): 17 | def setUp(self): 18 | self.labels = [0, 0, 0, 0, 19 | 1, 1, 1, 1, 1, 1] 20 | self.results = [0.25, 0.25, 0.75, 0.75, 21 | 0.25, 0.25, 0.25, 0.75, 0.75, 0.75] 22 | 23 | def test_bias_test_check_all_pass(self): 24 | """ 25 | Testing unbias results. 26 | Default test_thresh = 0.50 and all tests pass. 27 | """ 28 | capturedOutput = StringIO() 29 | sys.stdout = capturedOutput 30 | 31 | misc.bias_test_check(self.labels, self.results, category='test_group') 32 | 33 | sys.stdout = sys.__stdout__ 34 | 35 | pass_cnt, fail_cnt = pass_fail_count(capturedOutput.getvalue()) 36 | self.assertEqual(pass_cnt, 5) 37 | self.assertEqual(fail_cnt, 0) 38 | 39 | def test_bias_test_check_below_min_thresh(self): 40 | """ 41 | Testing unbias results at a test_threshold below min(results). 42 | Unable to run all tests all labels are classified into one group. 43 | """ 44 | capturedOutput = StringIO() 45 | sys.stdout = capturedOutput 46 | 47 | misc.bias_test_check(self.labels, self.results, category='test_group', 48 | test_thresh=0.20) 49 | 50 | sys.stdout = sys.__stdout__ 51 | 52 | pass_cnt, fail_cnt = pass_fail_count(capturedOutput.getvalue()) 53 | self.assertEqual(pass_cnt, 0) 54 | self.assertEqual(fail_cnt, 0) 55 | 56 | def test_bias_test_completely_bias(self): 57 | """ 58 | Testing bias results at a test_threshold of 0.50. All tests will fail. 59 | """ 60 | labels = [0, 0, 0, 0, 1, 1, 1, 1, 1, 1] 61 | results = [0, 0, 0, 0, 1, 1, 1, 1, 1, 1] 62 | 63 | capturedOutput = StringIO() 64 | sys.stdout = capturedOutput 65 | 66 | misc.bias_test_check(labels, results, category='test_group') 67 | 68 | sys.stdout = sys.__stdout__ 69 | 70 | pass_cnt, fail_cnt = pass_fail_count(capturedOutput.getvalue()) 71 | self.assertEqual(pass_cnt, 0) 72 | self.assertEqual(fail_cnt, 5) 73 | 74 | def test_one_way_mi(self): 75 | df = pd.DataFrame({'feat1': [1, 2, 3, 2, 1], 76 | 'feat2': [3, 2, 1, 2, 3], 77 | 'feat3': [1, 2, 3, 2, 1], 78 | 'feat4': [1, 2, 3, 2, 1], 79 | 'group': [1, 1, 3, 4, 5], 80 | 'y': [1, 2, 1, 1, 2]}) 81 | expected_output = { 82 | 'feature': {0: 'feat1', 1: 'feat2', 2: 'feat3', 3: 'feat4'}, 83 | 'group': {0: 0.12, 1: 0.12, 2: 0.12, 3: 0.12}, 84 | 'group_scaled': {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0}, 85 | 'y': {0: 0.12, 1: 0.12, 2: 0.12, 3: 0.12}, 86 | 'y_scaled': {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0}} 87 | 88 | features = ['feat1', 'feat2', 'feat3', 'feat4'] 89 | group_column = 'group' 90 | y_var = 'y' 91 | 92 | output = misc.one_way_mi(df, features, group_column, y_var, (4, 2)) 93 | for col in [group_column, y_var, 94 | group_column+'_scaled', y_var+'_scaled']: 95 | output[col] = output[col].round(2) 96 | self.assertEqual(output.to_dict(), expected_output) 97 | -------------------------------------------------------------------------------- /auditai/tests/test_nothing.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class TestSimple(unittest.TestCase): 5 | 6 | def test_true(self): 7 | self.assertTrue(True) 8 | 9 | def test_print(self): 10 | print("Hello python") 11 | -------------------------------------------------------------------------------- /auditai/tests/test_simulations.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | 4 | from sklearn.linear_model import LogisticRegression 5 | import pandas as pd 6 | import numpy as np 7 | 8 | import auditai.simulations as sim 9 | 10 | 11 | class TestGenerateBayesFactors(unittest.TestCase): 12 | def setUp(self): 13 | data_path = os.path.join(os.path.dirname(__file__), '..', '..', 14 | 'data', 'GermanCreditData.csv') 15 | self.data = pd.read_csv(data_path) 16 | self.features = ['age', 17 | 'duration', 18 | 'amount', 19 | 'dependents', 20 | 'inst_rate', 21 | 'num_credits' 22 | ] 23 | X = self.data[self.features] 24 | y = self.data['status'] 25 | 26 | self.clf = LogisticRegression() 27 | self.clf.fit(X, y) 28 | 29 | def test_use_default_values(self): 30 | thresholds, ratios, N = sim.generate_bayesfactors(self.clf, self.data, 31 | self.features, 32 | ['under_30', 33 | 'is_female']) 34 | # make sure all thresholds have ratio values 35 | for key in ratios.keys(): 36 | self.assertEqual(len(ratios[key]), len(thresholds)) 37 | 38 | def test_add_low_high(self): 39 | thresholds, ratios, N = sim.generate_bayesfactors(self.clf, self.data, 40 | self.features, 41 | ['under_30', 42 | 'is_female'], 43 | low=.01, 44 | high=.99, 45 | num=99) 46 | # make sure all thresholds have ratio values 47 | for key in ratios.keys(): 48 | self.assertEqual(len(ratios[key]), len(thresholds)) 49 | 50 | def test_bad_case_all_users_pass(self): 51 | with self.assertRaises(ValueError): 52 | thresholds, ratios, N = sim.generate_bayesfactors(self.clf, 53 | self.data, 54 | self.features, 55 | ['under_30', 56 | 'is_female'], 57 | low=.00, 58 | high=.99, 59 | num=100) 60 | 61 | def test_bad_case_all_users_fail(self): 62 | with self.assertRaises(ValueError): 63 | thresholds, ratios, N = sim.generate_bayesfactors(self.clf, 64 | self.data, 65 | self.features, 66 | ['under_30', 67 | 'is_female'], 68 | low=.01, 69 | high=1., 70 | num=100) 71 | 72 | def test_single_threshold(self): 73 | thresholds, ratios, N = sim.generate_bayesfactors(self.clf, self.data, 74 | self.features, 75 | ['under_30', 76 | 'is_female'], 77 | threshold=.7) 78 | # make sure all thresholds have ratio values 79 | for key in ratios.keys(): 80 | self.assertEqual(len(ratios[key]), len(thresholds)) 81 | self.assertEqual(len(ratios[key][0.7]), 11) 82 | 83 | def test_expected_values_0_01(self): 84 | np.random.seed(42) 85 | expected_is_female = [0.9964569398234155, 86 | 1.0035990084294402, 87 | 0.006555291338954991, 88 | 0.006589967705087398, 89 | 0.9844427430302514, 90 | 0.9895322416249943, 91 | 1.0105784931410273, 92 | 1.0158031109908876, 93 | (683, 9.0), 94 | (309, 3.0), 95 | np.inf] 96 | 97 | expected_under_30 = [1.0156517754102912, 98 | 0.9846637559926464, 99 | 0.008848752073678487, 100 | 0.008532082550902103, 101 | 1.0017184895413358, 102 | 0.9656369218482502, 103 | 1.0355859215014185, 104 | 0.9982844586036186, 105 | (627, 4.0), 106 | (365, 8.0), 107 | np.inf] 108 | 109 | _, ratios, _ = sim.generate_bayesfactors(self.clf, self.data, 110 | self.features, 111 | ['under_30', 112 | 'is_female'], 113 | threshold=.01) 114 | self.assertTrue(np.all(np.isclose( 115 | ratios['is_female: 0 over 1'][0.01][:8], 116 | expected_is_female[:8]))) 117 | self.assertTrue(np.all(np.isclose( 118 | ratios['under_30: 0 over 1'][0.01][:8], 119 | expected_under_30[:8]))) 120 | -------------------------------------------------------------------------------- /auditai/tests/test_stats.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from numpy.testing import assert_almost_equal 4 | 5 | from auditai.stats import ztest, fisher_exact_test, chi2_test, bayes_factor 6 | 7 | 8 | class TestZtest(unittest.TestCase): 9 | 10 | def setUp(self): 11 | T10 = [True] * 10 12 | F10 = [False] * 10 13 | self.labels = ['F'] * 20 + ['M'] * 20 14 | self.results = T10 + F10 + T10 + F10 15 | self.results_2 = T10 + F10 + T10 + T10 16 | 17 | def test_ztest(self): 18 | z, p = ztest(self.labels, self.results) 19 | self.assertEqual(z, 0.0) 20 | z, p = ztest(self.labels, self.results_2) 21 | assert_almost_equal(z, -3.6514837167011072) 22 | 23 | def test_fisher_exact_test(self): 24 | oddsratio, pvalue = fisher_exact_test(self.labels, self.results) 25 | self.assertEqual(oddsratio, 1.0) 26 | self.assertEqual(pvalue, 1.0) 27 | 28 | def test_chi2_test(self): 29 | chi2_stat, pvalue = chi2_test(self.labels, self.results) 30 | self.assertEqual(chi2_stat, 0.0) 31 | self.assertEqual(pvalue, 1.0) 32 | 33 | def test_bayes_factor(self): 34 | oddsratio, bf = bayes_factor(self.labels, self.results) 35 | self.assertEqual(oddsratio, 1.0) 36 | self.assertAlmostEqual(bf, 0.5500676358744966) 37 | with self.assertRaises(ValueError): 38 | oddsratio, bf = bayes_factor(self.labels, self.results, 39 | priors=self.results) 40 | -------------------------------------------------------------------------------- /auditai/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from auditai.utils.functions import get_unique_name, two_tailed_ztest, dirichln 4 | 5 | 6 | class TestUtils(unittest.TestCase): 7 | def test_get_unique_name(self): 8 | new_name = 'matched' 9 | name_list = ['feat1', 'feat2', 'matched'] 10 | 11 | output = get_unique_name(new_name, name_list) 12 | self.assertEqual(output, 'matched_new') 13 | 14 | name_list.append(output) 15 | output = get_unique_name(new_name, name_list) 16 | self.assertEqual(output, 'matched_new_new') 17 | 18 | def test_two_tailed_ztest(self): 19 | success1, success2, total1, total2 = 5, 10, 10, 10 20 | zstat, p_value = two_tailed_ztest(success1, success2, total1, total2) 21 | output = tuple(round(i, 2) for i in (zstat, p_value)) 22 | self.assertEqual(output, (-2.58, 0.01)) 23 | 24 | def test_dirichln(self): 25 | output = round(dirichln([1, 2]), 2) 26 | self.assertEqual(output, -0.69) 27 | 28 | output = round(dirichln([1]), 2) 29 | self.assertEqual(output, 0.0) 30 | -------------------------------------------------------------------------------- /auditai/tests/test_viz.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | import mock 4 | import pytest 5 | import os 6 | 7 | from sklearn.ensemble import RandomForestClassifier 8 | import pandas as pd 9 | 10 | import auditai.viz as viz 11 | 12 | 13 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6+") 14 | class TestBiasBarPlot(unittest.TestCase): 15 | def setUp(self): 16 | self.bias_report = {'ethnicity': {'averages': [0.291, 0.303, 0.317, 17 | 0.338, 0.301], 18 | 'categories': ['asian', 19 | 'black', 20 | 'hispanic-latino', 21 | 'two-or-more-groups', 22 | 'white'], 23 | 'errors': [[0.278, 0.304], 24 | [0.273, 0.334], 25 | [0.288, 0.347], 26 | [0.297, 0.38], 27 | [0.29, 0.313]]}, 28 | 'gender': {'averages': [0.293, 0.308], 29 | 'categories': ['F', 'M'], 30 | 'errors': [[0.282, 0.304], 31 | [0.297, 0.319]]}} 32 | 33 | @mock.patch('matplotlib.pyplot.show') 34 | @mock.patch('matplotlib.pyplot.savefig') 35 | def test_bias_report_case(self, mock_plt_savefig, mock_plt_show): 36 | viz.bias_report_plot(self.bias_report) 37 | 38 | def test_bad_input(self): 39 | with self.assertRaises(TypeError): 40 | viz.bias_bar_plot(bias_report=None, clf=None) 41 | 42 | 43 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6+") 44 | class TestGetBiasPlots(unittest.TestCase): 45 | def setUp(self): 46 | data_path = os.path.join(os.path.dirname(__file__), '..', '..', 47 | 'data', 'GermanCreditData.csv') 48 | self.data = pd.read_csv(data_path) 49 | self.features = ['age', 50 | 'duration', 51 | 'amount', 52 | 'dependents', 53 | 'inst_rate', 54 | 'num_credits' 55 | ] 56 | X = self.data[self.features] 57 | y = self.data['status'] 58 | 59 | self.clf = RandomForestClassifier() 60 | self.clf.fit(X, y) 61 | 62 | @mock.patch('matplotlib.pyplot.show') 63 | @mock.patch('matplotlib.pyplot.savefig') 64 | def test_use_default_values(self, mock_plt_savefig, mock_plt_show): 65 | viz.get_bias_plots(self.clf, self.data, 66 | self.features, ['under_30', 'is_female']) 67 | 68 | @mock.patch('matplotlib.pyplot.show') 69 | @mock.patch('matplotlib.pyplot.savefig') 70 | def test_apply_custom_formatting(self, mock_plt_savefig, mock_plt_show): 71 | viz.get_bias_plots(self.clf, self.data, 72 | self.features, ['under_30', 'is_female'], s=10) 73 | 74 | @mock.patch('matplotlib.pyplot.show') 75 | @mock.patch('matplotlib.pyplot.savefig') 76 | def test_ignore_bad_plt_kwargs(self, mock_plt_savefig, mock_plt_show): 77 | viz.get_bias_plots(self.clf, self.data, 78 | self.features, ['under_30', 'is_female'], s=10, 79 | bad_kwarg=10) 80 | -------------------------------------------------------------------------------- /auditai/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymetrics/audit-ai/4891b1d3c813a0e6ce657eaee3f1b2ab50e8f429/auditai/utils/__init__.py -------------------------------------------------------------------------------- /auditai/utils/cmh.py: -------------------------------------------------------------------------------- 1 | # helper functions specifically for cmh tests 2 | 3 | 4 | def parse_matrix(df, pass_col=False): 5 | """ 6 | Utility function for parsing matrix into cell counts. Used for 7 | parsing strata for the Cochran-Manzel-Haenszel and Breslow-Day tests in 8 | stats.cmh_test and stats.multi_odds_ratio 9 | 10 | Parameters 11 | ------------ 12 | df : pd.DataFrame, shape (2,2) 13 | rows indexed by group (0,1) 14 | columns labelled 'pass' and 'fail' 15 | pass_col : Boolean or string, optional 16 | if true, column names assumed to be 'pass' and 'fail' 17 | if string, enter column name of passing counts 18 | if false, column index 0 interpreted as passing 19 | column name of failing counts interpreted automatically 20 | 21 | Returns 22 | ---------- 23 | pass0 : counts of passing at row index 0 24 | fail0 : counts of failing at row index 1 25 | pass1 : counts of passing at row index 1 26 | fail1 : counts of failing at row index 1 27 | total : total n for matrix 28 | """ 29 | 30 | # if True or string 31 | if pass_col: 32 | # pass a string for pass column 33 | if isinstance(pass_col, str): 34 | fail_col = df.columns[df.columns != pass_col][0] 35 | else: 36 | # default mapping of column headers 37 | pass_col = 'pass' 38 | fail_col = 'fail' 39 | else: 40 | pass_col = 1 41 | fail_col = 0 42 | pass0 = float(df.iloc[0][pass_col]) 43 | fail0 = float(df.iloc[0][fail_col]) 44 | pass1 = float(df.iloc[1][pass_col]) 45 | fail1 = float(df.iloc[1][fail_col]) 46 | 47 | total = pass0 + fail0 + pass1 + fail1 48 | 49 | return pass0, fail0, pass1, fail1, total 50 | 51 | 52 | def extract_data(df, pass_col=False): 53 | """ 54 | Utility function for preprocessing data for stats.cmh_test and 55 | stats.multi_odds_ratio. Used for parsing strata for the 56 | Cochran-Manzel-Haenszel and Breslow-Day tests in 57 | stats.cmh_test and stats.multi_odds_ratio Returns component 58 | values for relevant tests. 59 | 60 | Parameters 61 | ------------ 62 | df : pd.DataFrame, shape (2,2) 63 | rows indexed by group (0,1) 64 | columns labelled 'pass' and 'fail' 65 | pass_col : Boolean or string, optional 66 | if true, column names assumed to be 'pass' and 'fail' 67 | if string, enter column name of passing counts 68 | if false, column index 0 interpreted as passing 69 | column name of failing counts interpreted automatically 70 | 71 | Returns 72 | ---------- 73 | r_num : odds ratio numerator; n group 0 pass * n group 1 fail over total 74 | r_den : odds ratio denominator; n group 0 fail * n group 1 pass over total 75 | c_num : CMH numerator 76 | c_den : CMH denominator 77 | """ 78 | 79 | pass0, fail0, pass1, fail1, total = parse_matrix(df, pass_col) 80 | 81 | r_num = (pass0*fail1)/(total) 82 | r_den = (fail0*pass1)/(total) 83 | 84 | c_num = pass0 - ((pass0 + fail0)*(pass0 + pass1))/(total) 85 | c_den = (((pass0 + fail0)*(pass1 + fail1)*(pass0 + pass1)*(fail0 + fail1)) 86 | / (total*total*(total - 1))) 87 | 88 | return r_num, r_den, c_num, c_den 89 | -------------------------------------------------------------------------------- /auditai/utils/crosstabs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of functions to create and manipulate cross tabulations 3 | """ 4 | import numpy as np 5 | import pandas as pd 6 | 7 | from .general import two_tailed_ztest, dirichln 8 | 9 | 10 | def crosstab_df(labels, decisions): 11 | """ 12 | Parameters 13 | ------------ 14 | labels : array_like 15 | containing categorical values like ['M', 'F'] 16 | decisions : array_like 17 | containing boolean / binary values 18 | 19 | Returns 20 | -------- 21 | crosstab : 2x2 array 22 | in the form, 23 | False True 24 | TopGroup 5 4 25 | BottomGroup 3 4 26 | so, crosstab = array([[5, 4], [3, 4]]) 27 | """ 28 | labels, decisions = pd.Series(labels), pd.Series(decisions) 29 | # rows are label values (e.g. ['F', 'M']) 30 | # columns are decision values (e.g. [False, True]) 31 | ctab = pd.crosstab(labels, decisions) 32 | return ctab 33 | 34 | 35 | def crosstab(labels, decisions): 36 | """ 37 | Returns value of crosstab_df as numpy array 38 | """ 39 | ctab = crosstab_df(labels, decisions) 40 | return ctab.values 41 | 42 | 43 | def top_bottom_crosstab(labels, decisions): 44 | """ 45 | Utility function for grabbing crosstabs for groups with highest 46 | and lowest pass rates, as defined by: 47 | - a categorical comparision column 48 | - a binary decision column 49 | 50 | Parameters 51 | ------- 52 | labels : array_like 53 | categorical labels for each corresponding value of `decision` ie. M/F 54 | 55 | decision : array_like 56 | binary decision values, ie. True/False or 0/1 57 | 58 | Returns 59 | --------- 60 | top_bottom_ctabs : 2x2 array 61 | contains True/False frequency counts of 2 groups with highest 62 | and lowest pass rates 63 | """ 64 | 65 | ctab = crosstab_df(labels, decisions) 66 | 67 | # if already 2x2, return table 68 | if ctab.shape == (2, 2): 69 | return ctab.values 70 | 71 | top, bottom = get_top_bottom_indices(ctab) 72 | 73 | top_bottom_ctab = ctab.iloc[[top, bottom]].values 74 | return top_bottom_ctab 75 | 76 | 77 | def get_top_bottom_indices(ctab_df): 78 | """ 79 | Utility function for returning row indices of groups 80 | with highest and lowest pass rates, according to input crosstab dataframe 81 | 82 | Parameters 83 | ----------- 84 | ctab_df - pandas DataFrame, shape (n,2) 85 | the rows should correspond to groups, columns to binary labels 86 | 87 | Returns 88 | --------- 89 | top_group_idx - int 90 | index of group in ctab_df with highest pass rate (ratio of True/total) 91 | bottom_group_idx - int 92 | index of group in ctab_df with lowest pass rate (ratio of True/total) 93 | """ 94 | 95 | if ctab_df.shape[1] != 2: 96 | ctab_df = ctab_df.T 97 | 98 | normed_ctabs = ctab_df.div(ctab_df.sum(axis=1), axis=0) 99 | 100 | # assume larger decision value corresponds to passing 101 | # e.g. max({1,0}) or max({True, False}) 102 | true_val = ctab_df.columns.max() 103 | 104 | # grab top and bottom group indices 105 | top_group_idx = normed_ctabs[true_val].values.argmax() 106 | bottom_group_idx = normed_ctabs[true_val].values.argmin() 107 | return top_group_idx, bottom_group_idx 108 | 109 | 110 | def crosstab_bayes_factor(ctab, prior=None): 111 | """ 112 | Computes the analytical Bayes factor for an nx2 contingency table. 113 | 114 | Adapted from Albert (2007) Bayesian Computation with R, 115 | 1st ed., pg 178:184 116 | 117 | Parameters 118 | ---------- 119 | ctab : ndarray, shape (n,2) 120 | contingency table containing label x result frequency counts 121 | 122 | prior : ndarray, optional, shape (n,2) 123 | e.g. uniform_prior = np.array([[1,1],[1,1]]) 124 | 125 | 126 | Returns 127 | ------- 128 | 129 | BF : float, 130 | bayes factor for the hypothesis of independence 131 | BF < 1 : support for a hypothesis of dependence 132 | 1 < BF < 3 : marginal support for independence 133 | 3 < BF < 20 : moderate support for independence 134 | 20 < BF < 150 : strong support for independence 135 | BF > 150 : very strong support for independence 136 | 137 | 138 | Examples 139 | -------- 140 | >>> ctab = np.array([[50,50],[50,50]]) # 1:1 hit/miss ratio 141 | >>> # assuming uniformity 142 | >>> uniform_prior = np.ones(ctab.shape) 143 | >>> bf_ctabs(ctab, uniform_prior) #all same leads to low BF (dependence) 144 | 0.26162148804907587 145 | 146 | """ 147 | if isinstance(ctab, pd.DataFrame): 148 | ctab = ctab.values 149 | 150 | if prior is None: 151 | prior = np.ones(shape=ctab.shape) 152 | 153 | ac = prior.sum(axis=0) # column sums 154 | ar = prior.sum(axis=1) # row sums 155 | yc = ctab.sum(axis=0) # column sums 156 | yr = ctab.sum(axis=1) # row sums 157 | 158 | I, J = ctab.shape 159 | OR = np.ones(I) # vector of ones for rows 160 | OC = np.ones(J) # vector of ones for columns 161 | 162 | if ctab.size != prior.size: 163 | raise ValueError("ctab and priors must be of same size") 164 | 165 | lbf = dirichln(ctab + prior) + dirichln(ar - (J - 1) * OR) \ 166 | + dirichln(ac - (I - 1) * OC) - dirichln(prior) \ 167 | - dirichln(yr + ar - (J - 1) * OR) - dirichln(yc + ac - (I - 1) * OC) 168 | 169 | # calculating bayes factor for all groups 170 | BF = np.exp(lbf) 171 | return BF 172 | 173 | 174 | def crosstab_odds_ratio(ctab): 175 | """ 176 | Calculate exact odds ratio between two groups. 177 | Designed for 2x2 matrices. When greater than 2x2, 178 | calculates OR for highest and lowest groups. 179 | 180 | Parameters 181 | ---------- 182 | ctab : matrix of values, shape(2,2) 183 | 184 | Returns 185 | ------- 186 | odds_ratio : exact odds ratio 187 | 188 | Examples 189 | -------- 190 | 191 | TODO: add examples 192 | """ 193 | ctab = np.array(ctab, dtype=np.float) 194 | 195 | if ctab.shape[1] != 2: 196 | raise ValueError("Must be of shape (n,2)") 197 | 198 | if ctab.shape[0] != 2: 199 | tab_ratios = ctab[:, 0] / ctab[:, 1] 200 | max_idx = np.argmax(tab_ratios) 201 | min_idx = np.argmin(tab_ratios) 202 | ctab = ctab[:, [min_idx, max_idx]] 203 | 204 | a_ratio = float(ctab[0, 0]) / ctab[0, 1] 205 | b_ratio = float(ctab[1, 0]) / ctab[1, 1] 206 | 207 | return a_ratio / b_ratio 208 | 209 | 210 | def crosstab_ztest(ctab): 211 | """ 212 | z-scores from 2x2 cross tabs of passing rate across groups 213 | 214 | Parameters 215 | ---------- 216 | ctab: array, shape=(2,2); 217 | crosstab of passing rate across groups, where each row is a group 218 | and the first and second columns count the number of unsuccessful 219 | and successful trials respectively 220 | 221 | Returns 222 | ------- 223 | zstat: float 224 | test statistic for two tailed z-test 225 | """ 226 | ctab = np.asarray(ctab) 227 | 228 | n1 = ctab[0].sum() 229 | n2 = ctab[1].sum() 230 | pos1 = ctab[0, 1] 231 | pos2 = ctab[1, 1] 232 | zstat, p_value = two_tailed_ztest(pos1, pos2, n1, n2) 233 | return zstat, p_value 234 | -------------------------------------------------------------------------------- /auditai/utils/functions.py: -------------------------------------------------------------------------------- 1 | # Maintain existing API functionality 2 | from .general import * # noqa 3 | -------------------------------------------------------------------------------- /auditai/utils/general.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.stats import norm 3 | from scipy.special import gammaln 4 | 5 | 6 | def two_tailed_ztest(success1, success2, total1, total2): 7 | """ 8 | Two-tailed z score for proportions 9 | 10 | Parameters 11 | ------- 12 | success1 : int 13 | the number of success in `total1` trials/observations 14 | 15 | success2 : int 16 | the number of success in `total2` trials/observations 17 | 18 | total1 : int 19 | the number of trials or observations of class 1 20 | 21 | total2 : int 22 | the number of trials or observations of class 2 23 | 24 | Returns 25 | ------- 26 | zstat : float 27 | z score for two tailed z-test 28 | p_value : float 29 | p value for two tailed z-test 30 | """ 31 | p1 = success1 / float(total1) 32 | p2 = success2 / float(total2) 33 | p_pooled = (success1 + success2) / float(total1 + total2) 34 | 35 | obs_ratio = (1. / total1 + 1. / total2) 36 | var = p_pooled * (1 - p_pooled) * obs_ratio 37 | 38 | # calculate z-score using foregoing values 39 | zstat = (p1 - p2) / np.sqrt(var) 40 | 41 | # calculate associated p-value for 2-tailed normal distribution 42 | p_value = norm.sf(abs(zstat)) * 2 43 | 44 | return zstat, p_value 45 | 46 | 47 | def dirichln(arr): 48 | """ 49 | Dirichlet gamma function 50 | Albert (2007) Bayesian Computation with R, 1st ed., pg 178 51 | 52 | Parameters 53 | ---------- 54 | arr : array or matrix of float values 55 | 56 | Returns 57 | ------- 58 | val : float or array, 59 | logged Dirichlet transformed value if array or matrix 60 | """ 61 | val = np.sum(gammaln(arr)) - gammaln(np.sum(arr)) 62 | return val 63 | 64 | 65 | def get_unique_name(new_name, name_list, addendum='_new'): 66 | """ 67 | Utility function to return a new unique name if name is in list. 68 | 69 | Parameters 70 | ---------- 71 | new_name : string 72 | name to be updated 73 | name_list: list 74 | list of existing names 75 | addendum: string 76 | addendum appended to new_name if new_name is in name_list 77 | 78 | Returns 79 | ------- 80 | new_name : string, 81 | updated name 82 | 83 | Example 84 | ------- 85 | new_name = 'feat1' 86 | name_list = ['feat1', 'feat2'] 87 | 88 | first iteration: new_name returned = 'feat1_new' 89 | now with name_list being updated to include new feature: 90 | name_list = ['feat1', 'feat2', 'feat1_new'] 91 | 92 | second iteration: new_name returned = 'feat1_new_new' 93 | """ 94 | # keep appending "new" until new_name is not in list 95 | while new_name in name_list: 96 | new_name += addendum 97 | return new_name 98 | -------------------------------------------------------------------------------- /auditai/utils/validate.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | 5 | def _num_samples(x): 6 | """ 7 | Return number of samples in array_like x. 8 | """ 9 | if not hasattr(x, '__len__') and not hasattr(x, 'shape'): 10 | if hasattr(x, '__array__'): 11 | x = np.asarray(x) 12 | else: 13 | raise TypeError("Expected sequence or array_like, got {}".format( 14 | type(x))) 15 | if hasattr(x, 'shape'): 16 | if len(x.shape) == 0: 17 | raise TypeError("Singleton array {} cannot be considered" 18 | " a valid collection.".format(x)) 19 | return x.shape[0] 20 | else: 21 | return len(x) 22 | 23 | 24 | def check_consistent_length(*arrays): 25 | """ 26 | Check that all arrays have consistent first dimensions. 27 | Checks whether all objects in arrays have the same shape or length. 28 | 29 | Parameters 30 | ---------- 31 | *arrays : list or tuple of input objects. 32 | Objects that will be checked for consistent length. 33 | """ 34 | 35 | lengths = [_num_samples(X) for X in arrays if X is not None] 36 | uniques = np.unique(lengths) 37 | if len(uniques) > 1: 38 | raise ValueError("Found input variables with inconsistent numbers of" 39 | " samples: {}".format([int(le) for le in lengths])) 40 | 41 | 42 | class ClassifierWrapper(object): 43 | """Simple sklearn wrapper for classifiers""" 44 | 45 | def __init__(self, clf): 46 | 47 | if not hasattr(clf, "decision_function"): 48 | if not hasattr(clf, "predict_proba"): 49 | raise ValueError("Classifier object has no decision_function" 50 | "or predict_proba attribute") 51 | self.clf = clf 52 | 53 | def decision_function(self, X): 54 | if hasattr(self.clf, 'decision_function'): 55 | return 1 / (1 + np.exp(-self.clf.decision_function(X))) 56 | elif hasattr(self.clf, 'predict_proba'): 57 | # assume positive case is maximum value 58 | positive_col = self.clf.classes_.argmax() 59 | return self.clf.predict_proba(X)[:, positive_col] 60 | else: 61 | raise ValueError( 62 | 'Classifier has no decision_function or predict_proba') 63 | 64 | 65 | def boolean_array(array, threshold=None): 66 | """ 67 | Converts a numpy array of integers into True/False values. 68 | If there are more than 2 unique values, a threshold must be supplied 69 | 70 | Parameters 71 | ----------- 72 | array : numpy array 73 | an array of integers 74 | threshold : integer 75 | a threshold to apply to an array of values 76 | 77 | Returns 78 | --------- 79 | array : list of boolean values 80 | """ 81 | if not isinstance(array, np.ndarray): 82 | raise TypeError("Expected numpy array, got {}".format(type(array))) 83 | if threshold: 84 | return array >= threshold 85 | else: 86 | vals = np.unique(array) 87 | if vals.shape[0] != 2: 88 | raise ValueError("Expected 2 unique values when " 89 | "threshold=None, got {}".format(vals.shape[0])) 90 | max_val = np.max(vals) 91 | return array == max_val 92 | 93 | 94 | def arrays_check(labels, results, null_vals=None): 95 | """ 96 | Given two input arrays of same length, 97 | returns same arrays with missing values (from either) removed from both 98 | 99 | Parameters 100 | ----------- 101 | labels : array_like 102 | results : array_like 103 | null_vals : list-like, optional 104 | user-specified unwanted values, e.g. ('', 'missing') 105 | 106 | Returns 107 | --------- 108 | labels, results : two-tuple of input arrays, without missings 109 | 110 | """ 111 | # require they be the same lengths 112 | assert len(labels) == len(results), 'input arrays not the same lengths!' 113 | 114 | # create dataframe and use pandas to remove rows containing nulls 115 | df = pd.DataFrame(list(zip(labels, results)), columns=['label', 'result']) 116 | 117 | # if user passes unwanted values to check for, replace these with NaNs 118 | if null_vals is not None: 119 | df.replace(null_vals, np.nan, inplace=True) 120 | 121 | # if no missing values, return original arrays 122 | if df.isnull().sum().sum() == 0: 123 | return labels, results 124 | 125 | # otherwise, get rid of rows with missing values and return remaining 126 | # arrays 127 | else: 128 | df.dropna(axis=0, how='any', inplace=True) 129 | return df.label.values, df.result.values 130 | -------------------------------------------------------------------------------- /auditai/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.1' 2 | -------------------------------------------------------------------------------- /auditai/viz.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import matplotlib.pyplot as plt 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from .misc import (compare_groups, 7 | get_group_proportions, 8 | make_bias_report) 9 | from .simulations import (get_bias_chi2_pvals, 10 | generate_bayesfactors) 11 | 12 | plt.style.use('fivethirtyeight') 13 | 14 | 15 | def plot_bias_pvals(thresholds, pvals, category, sig_thresh=.05, outpath=None): 16 | """ 17 | Plots Chi-Squared p-value test of match rate differences 18 | within demographic groups. Uses outputs of `get_bias_chi2_pvals` 19 | 20 | Parameters 21 | ---------- 22 | thresholds : array_like, range of floats 23 | decision thresholds obtained by np.linspace(low,high,num) 24 | pvals : array_like, range of floats 25 | p-values from Chi-Squared test 26 | category : string 27 | demographic category to test for bias, e.g. 'Gender' 28 | sig_thresh : float 29 | y-axis value to draw threshold line 30 | outpath : string, optional 31 | location in memory to save plot 32 | 33 | Returns 34 | ------- 35 | None 36 | 37 | Plots 38 | ------ 39 | plot with Chi-Squared p-values 40 | """ 41 | 42 | low = np.floor(min(thresholds)) 43 | high = np.ceil(max(thresholds)) 44 | 45 | plot_title = 'Bias Chi-Squared p-val tests for {}'.format(category) 46 | 47 | plt.figure() 48 | plt.scatter(thresholds, pvals) 49 | plt.axis([low, high, -.1, 1.1]) 50 | plt.title(plot_title) 51 | plt.ylabel('p-val') 52 | plt.xlabel('Threshold') 53 | plt.axhline(y=sig_thresh, ls='dashed', color='r') 54 | if outpath is not None: 55 | plt.savefig(outpath) 56 | plt.show() 57 | 58 | 59 | def plot_bias_test(thresholds, ratios, category, outpath=None, **kwargs): 60 | """ 61 | Plots prediction ratio for different groups across thresholds 62 | Checks 4/5ths test -- i.e. whether any group passes 20+% more than another 63 | 64 | Parameters 65 | ---------- 66 | thresholds : array_like 67 | range of floats - thresholds obtained by np.linspace(low,high,num) 68 | ratios : dict 69 | output of `generate_bayesfactors`, contains means, lowers, uppers 70 | category : string 71 | demographic category to test for bias, e.g. 'Gender' 72 | outpath : string, optional 73 | location in memory to save plot 74 | 75 | Returns 76 | ------- 77 | None 78 | 79 | Plots 80 | ------ 81 | plot with prediction ratios for different groups 82 | """ 83 | 84 | means = [i[0] for i in ratios.values()] 85 | lowers = [i[4] for i in ratios.values()] 86 | uppers = [i[6] for i in ratios.values()] 87 | 88 | low = np.floor(min(thresholds)) 89 | high = np.ceil(max(thresholds)) 90 | 91 | plot_title = 'Bias Tests for {}'.format(category) 92 | scatter_kwargs = { 93 | k: v for k, v in kwargs.items() 94 | if k in inspect.getfullargspec(plt.scatter)[0] 95 | } 96 | 97 | plt.figure() 98 | plt.scatter(thresholds, means, **scatter_kwargs) 99 | plt.plot(thresholds, lowers, '-', color='g') 100 | plt.plot(thresholds, uppers, '-', color='g') 101 | plt.axis([low, high, 0, max(2, max(uppers) + 0.1)]) 102 | plt.title(plot_title) 103 | plt.ylabel('Ratio') 104 | plt.xlabel('Threshold') 105 | plt.axhline(y=0.8, ls='dashed', color='r') 106 | plt.axhline(y=1.25, ls='dashed', color='r') 107 | if outpath is not None: 108 | plt.savefig(outpath) 109 | plt.show() 110 | 111 | 112 | def get_bias_plots(clf, df, feature_names, categories, **kwargs): 113 | """ 114 | Generate bias plots from a classifier 115 | 116 | Parameters 117 | ------------ 118 | clf : sklearn fitted clf 119 | with predict object 120 | df : pandas DataFrame 121 | untransformed df 122 | feature_names : list of strings 123 | names of features used in fitting clf 124 | categories : list of strings 125 | demographic column names used to test for bias, 126 | e.g. ['gender', 'ethnicity'] 127 | **kwargs : 128 | additional arguments for `generate_bayes_factors`, 129 | `get_bias_chi2_pvals`, and `plt.scatter`, 130 | such as `low`, `high`, `num`, `prior_strength`, `s`, `marker`, `cmap` 131 | 132 | Returns 133 | ------------ 134 | None 135 | 136 | Plots 137 | ------- 138 | Linear plots of simulated pass ratios and chi squared p-values 139 | at assorted thresholds 140 | """ 141 | 142 | thresholds, ratios, N = generate_bayesfactors( 143 | clf, df, feature_names, categories, **kwargs 144 | ) 145 | keys = sorted(ratios.keys()) 146 | for key in keys: 147 | plot_bias_test(thresholds, ratios[key], key, **kwargs) 148 | 149 | chi2_thresholds, chi2stat_pvals = get_bias_chi2_pvals( 150 | clf, df, feature_names, categories, **kwargs 151 | ) 152 | for category in categories: 153 | chi2_pvals = [i[1] for i in chi2stat_pvals[category]] 154 | plot_bias_pvals(chi2_thresholds, chi2_pvals, category) 155 | 156 | 157 | def bias_report_plot(bias_report): 158 | """ 159 | Plot bar plots for overall recommendation by bias group 160 | 161 | Parameters 162 | ------------ 163 | bias_report: dict 164 | output of make_bias_report 165 | 166 | Returns 167 | ------------ 168 | ax : matplotlib subplot axes 169 | 170 | Plots 171 | -------- 172 | Bar plot containing recommendations across each bias group 173 | """ 174 | for key in bias_report: 175 | data = bias_report[key] 176 | errs = list(map(lambda i: (i[1] - i[0]) / 2., data['errors'])) 177 | labels = data['categories'] 178 | avgs = data['averages'] 179 | ind = np.arange(len(labels)) 180 | 181 | fig, ax = plt.subplots() 182 | ax.bar(ind, avgs, 0.8, yerr=errs) 183 | ax.set_title(key) 184 | ax.set_xticks(ind + 0.8 // 2) 185 | ax.set_xticklabels(labels) 186 | plt.show() 187 | 188 | return ax 189 | 190 | 191 | def bias_bar_plot(clf, df, feature_names, categories, **kwargs): 192 | """ 193 | Plot bar plots for overall recommendation by bias group 194 | 195 | Parameters 196 | ------------ 197 | clf : sklearn clf 198 | fitted clf with predict object 199 | df : pandas DataFrame 200 | reference dataframe containing labeled features to test for bias 201 | feature_names : list of strings 202 | names of features used in fitting clf 203 | categories : list of strings 204 | names of categories to test for bias, e.g. ['gender'] 205 | **kwargs : additional arguments for misc.make_bias_report, 206 | e.g. low, high, num for defining threshold values 207 | 208 | Returns 209 | ------------ 210 | ax : matplotlib subplot axes 211 | 212 | Plots 213 | -------- 214 | Bar plot containing recommendations across each bias group 215 | """ 216 | bias_report = make_bias_report(clf, df, feature_names, categories, 217 | **kwargs) 218 | 219 | ax = bias_report_plot(bias_report) 220 | 221 | return ax 222 | 223 | 224 | def plot_threshold_tests(labels, results, category=None, comp_groups=None, 225 | include_metrics=('Proportions', 226 | 'z-test p-vals', 227 | 'Fisher Exact p-vals', 228 | 'Chi Squared p-vals', 229 | 'Bayes factors'), 230 | ref_prop=0.8, ref_z=0.05, ref_fisher_p=0.05, 231 | ref_chi2_p=0.05, ref_bayes=3., **kwargs): 232 | """ 233 | Plot results of following tests across thresholds: 234 | - max-vs-min pass proportions 235 | - two-tailed z-test for max-vs-min groups 236 | - Fisher exact test for max-vs-min groups 237 | - Chi Squared test of independence for all groups 238 | - Bayes factor 239 | 240 | Parameters 241 | -------- 242 | labels : array_like 243 | containing categorical values like ['M', 'F'] 244 | results : array_like 245 | containing real numbers 246 | category : string 247 | corresponding to category of labels, e.g. 'Gender' 248 | comp_groups : list of strings 249 | optional -- subset of 2 labels to include, e.g. ['M', 'F'] 250 | include_metrics : list/tuple of strings 251 | from `{'Proportions', 'z-scores', 'Fisher Exact p-vals', 252 | 'Chi Squared p-vals', 'Bayes factors'}` 253 | NB : if only one metric plot is wanted, use a trailing comma, 254 | e.g. `include_metrics = ('Proportions', )` 255 | ref_prop, ref_z, ref_fisher_p, ref_chi2_p, ref_bayes : floats 256 | designated values to plot horizontal reference line for 257 | corresponding metric 258 | **kwargs : additional arguments for continuous.compare_groups, 259 | e.g. low, high, num for defining threshold values 260 | 261 | Returns 262 | -------- 263 | axarr : numpy.ndarray 264 | contains matplotlib subplot axes 265 | 266 | Plots 267 | -------- 268 | Values for desired statistical tests across threshold values 269 | """ 270 | 271 | reference_vals = (ref_prop, ref_z, ref_fisher_p, ref_chi2_p, ref_bayes) 272 | 273 | # supported metrics 274 | metric_names = ( 275 | 'Proportions', 276 | 'z-test p-vals', 277 | 'Fisher Exact p-vals', 278 | 'Chi Squared p-vals', 279 | 'Bayes factors') 280 | 281 | # check to make sure only allowed metrics are included: 282 | if not set(include_metrics).issubset(set(metric_names)): 283 | raise KeyError( 284 | "include_metrics must be within " 285 | "{'Proportions', 'z-test p-vals', 'Fisher Exact p-vals', " 286 | "'Chi Squared p-vals', 'Bayes factors'}") 287 | # tuple of dictionaries containing {threshold: metric_value} pairs 288 | metric_dicts = compare_groups(labels, results, **kwargs) 289 | # map those dictionaries to their names 290 | names_mets_refvals_dict = dict( 291 | zip(metric_names, zip(metric_dicts, reference_vals))) 292 | 293 | # create subplots 294 | num_subplots = len(include_metrics) 295 | labels = list(map(str, labels)) 296 | 297 | fig, axarr = plt.subplots( 298 | num_subplots, sharex=True, figsize=( 299 | 12, 3 + 2 * num_subplots)) 300 | 301 | if category is None: 302 | category = ' vs '.join(sorted(set(labels))) 303 | 304 | fig.suptitle('Model Statistical Bias Tests {}'.format(category), 305 | fontsize=16) 306 | 307 | plt.rc('xtick', labelsize=8) 308 | plt.rc('ytick', labelsize=8) 309 | 310 | group_name = 'Min-Max Group' 311 | 312 | if comp_groups is not None: 313 | group_name = '{} vs {}'.format(*comp_groups[:2]) 314 | 315 | def _make_metric_plot(ax, input_dict, metric_name, group_name, 316 | metric_bound=.05): 317 | """internal function for making a standalone metrics plot""" 318 | x_vals, y_vals = zip(*sorted(input_dict.items())) 319 | ax.plot(x_vals, y_vals, 'o-', c='blue') 320 | ax.set_title('{} {}'.format(group_name, metric_name), fontsize=14) 321 | ax.set_ylabel(metric_name, fontsize=12) 322 | ax.axhline(metric_bound, c='r', linestyle='--') 323 | ax.tick_params(labelsize=10) 324 | ax.set_xlabel('Threshold Value', fontsize=12) 325 | 326 | # plot relative pass rates 327 | def _add_metric_plot(axarr, input_dict, metric_name, group_name, 328 | subplot_no=0, metric_bound=.05, last_plot=False): 329 | """internal function for adding a metrics subplot""" 330 | x_vals, y_vals = zip(*sorted(input_dict.items())) 331 | axarr[subplot_no].plot(x_vals, y_vals, 'o-', c='blue') 332 | axarr[subplot_no].set_title('{} {}'.format( 333 | group_name, metric_name), fontsize=14) 334 | axarr[subplot_no].set_ylabel(metric_name, fontsize=12) 335 | axarr[subplot_no].axhline(metric_bound, c='r', linestyle='--') 336 | if subplot_no == 0: 337 | axarr[subplot_no].tick_params(labelsize=10) 338 | if last_plot is True: 339 | axarr[subplot_no].set_xlabel('Threshold Value', fontsize=12) 340 | 341 | for idx, met_name in enumerate(include_metrics): 342 | if met_name in ('Chi Squared p-vals', 343 | 'Bayes factors') and comp_groups is None: 344 | group_name = 'All Groups' 345 | last_plot = idx == num_subplots - 1 346 | met_dict, ref_val = names_mets_refvals_dict[met_name] 347 | if num_subplots == 1: 348 | _make_metric_plot(axarr, met_dict, met_name, group_name, 349 | metric_bound=ref_val) 350 | else: 351 | _add_metric_plot(axarr, met_dict, met_name, group_name, 352 | idx, ref_val, last_plot) 353 | 354 | fig.tight_layout() 355 | fig.subplots_adjust(top=.90) 356 | plt.show() 357 | 358 | return axarr 359 | 360 | 361 | def plot_group_proportions(labels, results, category=None, **kwargs): 362 | """ 363 | Function for plotting group pass proportions at or below various thresholds 364 | NB: A group whose curve lies on top of another passes less frequently 365 | at or below that threshold 366 | 367 | Parameters 368 | ------------ 369 | labels : array_like 370 | contains categorical labels 371 | results : array_like 372 | contains numeric or boolean values 373 | category : string 374 | describes label values, e.g. 'Gender' 375 | **kwargs : optional 376 | additional values for `misc.get_group_proportions` fn 377 | specifically - low, high, num values for thresholds to test 378 | 379 | Returns 380 | -------- 381 | ax : matplotlib lines object 382 | 383 | Plots 384 | ------- 385 | single plot: 386 | Overlays linear plots of pass rates below range of thresholds 387 | results for the n groups found in labels 388 | 389 | """ 390 | prop_dict = get_group_proportions(labels, results, **kwargs) 391 | groups = prop_dict.keys() 392 | for group in groups: 393 | x_vals, y_vals = prop_dict[group] 394 | ax = plt.plot(x_vals, y_vals, label=group) 395 | plt.legend(loc='best') 396 | plt.xlabel('Threshold') 397 | plt.ylabel('Fraction of Group Below') 398 | if not category: 399 | category = '_vs_'.join(map(str, groups)) 400 | plt.title("{} Cumulative Pass Rate Below Threshold".format(category)) 401 | plt.show() 402 | return ax[0] 403 | 404 | 405 | def plot_kdes(labels=None, 406 | results=None, 407 | category=None, 408 | df=None, 409 | label_col=None, 410 | result_col=None, 411 | colors=None, 412 | **kwargs): 413 | """ 414 | Plots KDEs and Cumulative KDEs 415 | Requires seaborn for plotting 416 | 417 | Can either pass in arrays of labels/results or else df 418 | 419 | Parameters 420 | ----------- 421 | labels : array_like 422 | categorical values 423 | results : array_like 424 | numerical values 425 | category : string, optional 426 | name of label category for plotting, e.g. 'Gender' 427 | df : pandas DataFrame, optional 428 | label_col : string, optional 429 | name of labels column in df 430 | result_col : string, optional 431 | name of results column in df 432 | colors : list of strings, optional 433 | takes xkcd hue labels, e.g. ['red', 'blue', 'mustard yellow'] 434 | more here: https://xkcd.com/color/rgb/ 435 | 436 | Returns 437 | -------- 438 | ax : numpy array of matplotlib axes 439 | 440 | Plots 441 | ------- 442 | (1,2) subplots: KDE and cumulative KDE by group in `labels` 443 | """ 444 | import seaborn as sns 445 | if df is None: 446 | df = pd.DataFrame(list(zip(labels, results)), 447 | columns=['label', 'result']) 448 | else: 449 | df = df.rename(columns={label_col: 'label', result_col: 'result'}) 450 | unique_labels = df.label.dropna().unique() 451 | nlabels = len(unique_labels) 452 | 453 | # Check if there is a distribution to plot in each group 454 | stds = df.groupby('label')[['result']].std() 455 | if 0 in stds.values: 456 | groups = stds.index[stds['result'] == 0].values 457 | print('No distribution of results in groups: {}'.format( 458 | ', '.join([str(i) for i in groups]))) 459 | return 460 | 461 | if not colors: 462 | base_colors = ['red', 'blue'] 463 | others = list(set(sns.xkcd_rgb.keys()) - set(base_colors)) 464 | extra_colors = list(np.random.choice(others, nlabels, replace=False)) 465 | colors = list(base_colors + extra_colors)[:nlabels] 466 | sns.set_palette(sns.xkcd_palette(colors)) 467 | fig, ax = plt.subplots(1, 2, figsize=(16, 6)) 468 | if not category: 469 | category = '_vs_'.join(map(str, unique_labels)) 470 | ax[0].set_title("{} KDEs".format(category)) 471 | ax[1].set_title("{} Cumulative KDEs".format(category)) 472 | ax[0].set_ylabel('Frequency') 473 | ax[1].set_ylabel('Group Fraction Below') 474 | ax[0].set_xlabel('Threshold') 475 | ax[1].set_xlabel('Threshold') 476 | for lab in unique_labels: 477 | 478 | sns.kdeplot(df.loc[df.label == lab].result, 479 | shade=True, label=lab, ax=ax[0], **kwargs) 480 | sns.kdeplot(df.loc[df.label == lab].result, 481 | shade=False, label=lab, ax=ax[1], 482 | cumulative=True, **kwargs) 483 | 484 | ax0_max_y = max([max(i.get_data()[1]) for i in ax[0].get_lines()]) 485 | ax[0].set_ylim(0, ax0_max_y*1.1) 486 | plt.show() 487 | 488 | return ax 489 | -------------------------------------------------------------------------------- /data/auditAI_gender_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymetrics/audit-ai/4891b1d3c813a0e6ce657eaee3f1b2ab50e8f429/data/auditAI_gender_plot.png -------------------------------------------------------------------------------- /data/student-mat.csv: -------------------------------------------------------------------------------- 1 | school;sex;age;address;famsize;Pstatus;Medu;Fedu;Mjob;Fjob;reason;guardian;traveltime;studytime;failures;schoolsup;famsup;paid;activities;nursery;higher;internet;romantic;famrel;freetime;goout;Dalc;Walc;health;absences;G1;G2;G3 2 | "GP";"F";18;"U";"GT3";"A";4;4;"at_home";"teacher";"course";"mother";2;2;0;"yes";"no";"no";"no";"yes";"yes";"no";"no";4;3;4;1;1;3;6;"5";"6";6 3 | "GP";"F";17;"U";"GT3";"T";1;1;"at_home";"other";"course";"father";1;2;0;"no";"yes";"no";"no";"no";"yes";"yes";"no";5;3;3;1;1;3;4;"5";"5";6 4 | "GP";"F";15;"U";"LE3";"T";1;1;"at_home";"other";"other";"mother";1;2;3;"yes";"no";"yes";"no";"yes";"yes";"yes";"no";4;3;2;2;3;3;10;"7";"8";10 5 | "GP";"F";15;"U";"GT3";"T";4;2;"health";"services";"home";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";3;2;2;1;1;5;2;"15";"14";15 6 | "GP";"F";16;"U";"GT3";"T";3;3;"other";"other";"home";"father";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"no";"no";4;3;2;1;2;5;4;"6";"10";10 7 | "GP";"M";16;"U";"LE3";"T";4;3;"services";"other";"reputation";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;4;2;1;2;5;10;"15";"15";15 8 | "GP";"M";16;"U";"LE3";"T";2;2;"other";"other";"home";"mother";1;2;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";4;4;4;1;1;3;0;"12";"12";11 9 | "GP";"F";17;"U";"GT3";"A";4;4;"other";"teacher";"home";"mother";2;2;0;"yes";"yes";"no";"no";"yes";"yes";"no";"no";4;1;4;1;1;1;6;"6";"5";6 10 | "GP";"M";15;"U";"LE3";"A";3;2;"services";"other";"home";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;2;2;1;1;1;0;"16";"18";19 11 | "GP";"M";15;"U";"GT3";"T";3;4;"other";"other";"home";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;5;1;1;1;5;0;"14";"15";15 12 | "GP";"F";15;"U";"GT3";"T";4;4;"teacher";"health";"reputation";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";3;3;3;1;2;2;0;"10";"8";9 13 | "GP";"F";15;"U";"GT3";"T";2;1;"services";"other";"reputation";"father";3;3;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;2;2;1;1;4;4;"10";"12";12 14 | "GP";"M";15;"U";"LE3";"T";4;4;"health";"services";"course";"father";1;1;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;3;1;3;5;2;"14";"14";14 15 | "GP";"M";15;"U";"GT3";"T";4;3;"teacher";"other";"course";"mother";2;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";5;4;3;1;2;3;2;"10";"10";11 16 | "GP";"M";15;"U";"GT3";"A";2;2;"other";"other";"home";"other";1;3;0;"no";"yes";"no";"no";"yes";"yes";"yes";"yes";4;5;2;1;1;3;0;"14";"16";16 17 | "GP";"F";16;"U";"GT3";"T";4;4;"health";"other";"home";"mother";1;1;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";4;4;4;1;2;2;4;"14";"14";14 18 | "GP";"F";16;"U";"GT3";"T";4;4;"services";"services";"reputation";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";3;2;3;1;2;2;6;"13";"14";14 19 | "GP";"F";16;"U";"GT3";"T";3;3;"other";"other";"reputation";"mother";3;2;0;"yes";"yes";"no";"yes";"yes";"yes";"no";"no";5;3;2;1;1;4;4;"8";"10";10 20 | "GP";"M";17;"U";"GT3";"T";3;2;"services";"services";"course";"mother";1;1;3;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;5;5;2;4;5;16;"6";"5";5 21 | "GP";"M";16;"U";"LE3";"T";4;3;"health";"other";"home";"father";1;1;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";3;1;3;1;3;5;4;"8";"10";10 22 | "GP";"M";15;"U";"GT3";"T";4;3;"teacher";"other";"reputation";"mother";1;2;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";4;4;1;1;1;1;0;"13";"14";15 23 | "GP";"M";15;"U";"GT3";"T";4;4;"health";"health";"other";"father";1;1;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";5;4;2;1;1;5;0;"12";"15";15 24 | "GP";"M";16;"U";"LE3";"T";4;2;"teacher";"other";"course";"mother";1;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";4;5;1;1;3;5;2;"15";"15";16 25 | "GP";"M";16;"U";"LE3";"T";2;2;"other";"other";"reputation";"mother";2;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;4;4;2;4;5;0;"13";"13";12 26 | "GP";"F";15;"R";"GT3";"T";2;4;"services";"health";"course";"mother";1;3;0;"yes";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;2;1;1;5;2;"10";"9";8 27 | "GP";"F";16;"U";"GT3";"T";2;2;"services";"services";"home";"mother";1;1;2;"no";"yes";"yes";"no";"no";"yes";"yes";"no";1;2;2;1;3;5;14;"6";"9";8 28 | "GP";"M";15;"U";"GT3";"T";2;2;"other";"other";"home";"mother";1;1;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;2;2;1;2;5;2;"12";"12";11 29 | "GP";"M";15;"U";"GT3";"T";4;2;"health";"services";"other";"mother";1;1;0;"no";"no";"yes";"no";"yes";"yes";"yes";"no";2;2;4;2;4;1;4;"15";"16";15 30 | "GP";"M";16;"U";"LE3";"A";3;4;"services";"other";"home";"mother";1;2;0;"yes";"yes";"no";"yes";"yes";"yes";"yes";"no";5;3;3;1;1;5;4;"11";"11";11 31 | "GP";"M";16;"U";"GT3";"T";4;4;"teacher";"teacher";"home";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;4;5;5;5;5;16;"10";"12";11 32 | "GP";"M";15;"U";"GT3";"T";4;4;"health";"services";"home";"mother";1;2;0;"no";"yes";"yes";"no";"no";"yes";"yes";"no";5;4;2;3;4;5;0;"9";"11";12 33 | "GP";"M";15;"U";"GT3";"T";4;4;"services";"services";"reputation";"mother";2;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;3;1;1;1;5;0;"17";"16";17 34 | "GP";"M";15;"R";"GT3";"T";4;3;"teacher";"at_home";"course";"mother";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";4;5;2;1;1;5;0;"17";"16";16 35 | "GP";"M";15;"U";"LE3";"T";3;3;"other";"other";"course";"mother";1;2;0;"no";"no";"no";"yes";"no";"yes";"yes";"no";5;3;2;1;1;2;0;"8";"10";12 36 | "GP";"M";16;"U";"GT3";"T";3;2;"other";"other";"home";"mother";1;1;0;"no";"yes";"yes";"no";"no";"yes";"yes";"no";5;4;3;1;1;5;0;"12";"14";15 37 | "GP";"F";15;"U";"GT3";"T";2;3;"other";"other";"other";"father";2;1;0;"no";"yes";"no";"yes";"yes";"yes";"no";"no";3;5;1;1;1;5;0;"8";"7";6 38 | "GP";"M";15;"U";"LE3";"T";4;3;"teacher";"services";"home";"mother";1;3;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;4;3;1;1;4;2;"15";"16";18 39 | "GP";"M";16;"R";"GT3";"A";4;4;"other";"teacher";"reputation";"mother";2;3;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";2;4;3;1;1;5;7;"15";"16";15 40 | "GP";"F";15;"R";"GT3";"T";3;4;"services";"health";"course";"mother";1;3;0;"yes";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;2;1;1;5;2;"12";"12";11 41 | "GP";"F";15;"R";"GT3";"T";2;2;"at_home";"other";"reputation";"mother";1;1;0;"yes";"yes";"yes";"yes";"yes";"yes";"no";"no";4;3;1;1;1;2;8;"14";"13";13 42 | "GP";"F";16;"U";"LE3";"T";2;2;"other";"other";"home";"mother";2;2;1;"no";"yes";"no";"yes";"no";"yes";"yes";"yes";3;3;3;1;2;3;25;"7";"10";11 43 | "GP";"M";15;"U";"LE3";"T";4;4;"teacher";"other";"home";"other";1;1;0;"no";"yes";"no";"no";"no";"yes";"yes";"yes";5;4;3;2;4;5;8;"12";"12";12 44 | "GP";"M";15;"U";"GT3";"T";4;4;"services";"teacher";"course";"father";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;3;3;1;1;5;2;"19";"18";18 45 | "GP";"M";15;"U";"GT3";"T";2;2;"services";"services";"course";"father";1;1;0;"yes";"yes";"no";"no";"yes";"yes";"yes";"no";5;4;1;1;1;1;0;"8";"8";11 46 | "GP";"F";16;"U";"LE3";"T";2;2;"other";"at_home";"course";"father";2;2;1;"yes";"no";"no";"yes";"yes";"yes";"yes";"no";4;3;3;2;2;5;14;"10";"10";9 47 | "GP";"F";15;"U";"LE3";"A";4;3;"other";"other";"course";"mother";1;2;0;"yes";"yes";"yes";"yes";"yes";"yes";"yes";"yes";5;2;2;1;1;5;8;"8";"8";6 48 | "GP";"F";16;"U";"LE3";"A";3;3;"other";"services";"home";"mother";1;2;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";2;3;5;1;4;3;12;"11";"12";11 49 | "GP";"M";16;"U";"GT3";"T";4;3;"health";"services";"reputation";"mother";1;4;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";4;2;2;1;1;2;4;"19";"19";20 50 | "GP";"M";15;"U";"GT3";"T";4;2;"teacher";"other";"home";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"no";"no";4;3;3;2;2;5;2;"15";"15";14 51 | "GP";"F";15;"U";"GT3";"T";4;4;"services";"teacher";"other";"father";1;2;1;"yes";"yes";"no";"yes";"no";"yes";"yes";"no";4;4;4;1;1;3;2;"7";"7";7 52 | "GP";"F";16;"U";"LE3";"T";2;2;"services";"services";"course";"mother";3;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;3;2;3;4;2;"12";"13";13 53 | "GP";"F";15;"U";"LE3";"T";4;2;"health";"other";"other";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;3;1;1;5;2;"11";"13";13 54 | "GP";"M";15;"U";"LE3";"A";4;2;"health";"health";"other";"father";2;1;1;"no";"no";"no";"no";"yes";"yes";"no";"no";5;5;5;3;4;5;6;"11";"11";10 55 | "GP";"F";15;"U";"GT3";"T";4;4;"services";"services";"course";"mother";1;1;0;"yes";"yes";"yes";"no";"yes";"yes";"yes";"no";3;3;4;2;3;5;0;"8";"10";11 56 | "GP";"F";15;"U";"LE3";"A";3;3;"other";"other";"other";"mother";1;1;0;"no";"no";"yes";"no";"yes";"yes";"yes";"no";5;3;4;4;4;1;6;"10";"13";13 57 | "GP";"F";16;"U";"GT3";"A";2;1;"other";"other";"other";"mother";1;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"yes";5;3;4;1;1;2;8;"8";"9";10 58 | "GP";"F";15;"U";"GT3";"A";4;3;"services";"services";"reputation";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;2;1;1;1;0;"14";"15";15 59 | "GP";"M";15;"U";"GT3";"T";4;4;"teacher";"health";"reputation";"mother";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"no";"no";3;2;2;1;1;5;4;"14";"15";15 60 | "GP";"M";15;"U";"LE3";"T";1;2;"other";"at_home";"home";"father";1;2;0;"yes";"yes";"no";"yes";"yes";"yes";"yes";"no";4;3;2;1;1;5;2;"9";"10";9 61 | "GP";"F";16;"U";"GT3";"T";4;2;"services";"other";"course";"mother";1;2;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";4;2;3;1;1;5;2;"15";"16";16 62 | "GP";"F";16;"R";"GT3";"T";4;4;"health";"teacher";"other";"mother";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"no";"no";2;4;4;2;3;4;6;"10";"11";11 63 | "GP";"F";16;"U";"GT3";"T";1;1;"services";"services";"course";"father";4;1;0;"yes";"yes";"no";"yes";"no";"yes";"yes";"yes";5;5;5;5;5;5;6;"10";"8";11 64 | "GP";"F";16;"U";"LE3";"T";1;2;"other";"services";"reputation";"father";1;2;0;"yes";"no";"no";"yes";"yes";"yes";"yes";"no";4;4;3;1;1;1;4;"8";"10";9 65 | "GP";"F";16;"U";"GT3";"T";4;3;"teacher";"health";"home";"mother";1;3;0;"yes";"yes";"yes";"yes";"yes";"yes";"yes";"no";3;4;4;2;4;4;2;"10";"9";9 66 | "GP";"F";15;"U";"LE3";"T";4;3;"services";"services";"reputation";"father";1;2;0;"yes";"no";"no";"yes";"yes";"yes";"yes";"yes";4;4;4;2;4;2;0;"10";"10";10 67 | "GP";"F";16;"U";"LE3";"T";4;3;"teacher";"services";"course";"mother";3;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;4;3;1;2;1;2;"16";"15";15 68 | "GP";"M";15;"U";"GT3";"A";4;4;"other";"services";"reputation";"mother";1;4;0;"no";"yes";"no";"yes";"no";"yes";"yes";"yes";1;3;3;5;5;3;4;"13";"13";12 69 | "GP";"F";16;"U";"GT3";"T";3;1;"services";"other";"course";"mother";1;4;0;"yes";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;3;1;2;5;4;"7";"7";6 70 | "GP";"F";15;"R";"LE3";"T";2;2;"health";"services";"reputation";"mother";2;2;0;"yes";"yes";"yes";"no";"yes";"yes";"yes";"no";4;1;3;1;3;4;2;"8";"9";8 71 | "GP";"F";15;"R";"LE3";"T";3;1;"other";"other";"reputation";"father";2;4;0;"no";"yes";"no";"no";"no";"yes";"yes";"no";4;4;2;2;3;3;12;"16";"16";16 72 | "GP";"M";16;"U";"GT3";"T";3;1;"other";"other";"reputation";"father";2;4;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;2;1;1;5;0;"13";"15";15 73 | "GP";"M";15;"U";"GT3";"T";4;2;"other";"other";"course";"mother";1;4;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";3;3;3;1;1;3;0;"10";"10";10 74 | "GP";"F";15;"R";"GT3";"T";1;1;"other";"other";"reputation";"mother";1;2;2;"yes";"yes";"no";"no";"no";"yes";"yes";"yes";3;3;4;2;4;5;2;"8";"6";5 75 | "GP";"M";16;"U";"GT3";"T";3;1;"other";"other";"reputation";"mother";1;1;0;"no";"no";"no";"yes";"yes";"yes";"no";"no";5;3;2;2;2;5;2;"12";"12";14 76 | "GP";"F";16;"U";"GT3";"T";3;3;"other";"services";"home";"mother";1;2;0;"yes";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;3;2;4;5;54;"11";"12";11 77 | "GP";"M";15;"U";"GT3";"T";4;3;"teacher";"other";"home";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;3;2;3;5;6;"9";"9";10 78 | "GP";"M";15;"U";"GT3";"T";4;0;"teacher";"other";"course";"mother";2;4;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";3;4;3;1;1;1;8;"11";"11";10 79 | "GP";"F";16;"U";"GT3";"T";2;2;"other";"other";"reputation";"mother";1;4;0;"no";"no";"yes";"no";"yes";"yes";"yes";"yes";5;2;3;1;3;3;0;"11";"11";11 80 | "GP";"M";17;"U";"GT3";"T";2;1;"other";"other";"home";"mother";2;1;3;"yes";"yes";"no";"yes";"yes";"no";"yes";"no";4;5;1;1;1;3;2;"8";"8";10 81 | "GP";"F";16;"U";"GT3";"T";3;4;"at_home";"other";"course";"mother";1;2;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";2;4;3;1;2;3;12;"5";"5";5 82 | "GP";"M";15;"U";"GT3";"T";2;3;"other";"services";"course";"father";1;1;0;"yes";"yes";"yes";"yes";"no";"yes";"yes";"yes";3;2;2;1;3;3;2;"10";"12";12 83 | "GP";"M";15;"U";"GT3";"T";2;3;"other";"other";"home";"mother";1;3;0;"yes";"no";"yes";"no";"no";"yes";"yes";"no";5;3;2;1;2;5;4;"11";"10";11 84 | "GP";"F";15;"U";"LE3";"T";3;2;"services";"other";"reputation";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;4;4;1;1;5;10;"7";"6";6 85 | "GP";"M";15;"U";"LE3";"T";2;2;"services";"services";"home";"mother";2;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";5;3;3;1;3;4;4;"15";"15";15 86 | "GP";"F";15;"U";"GT3";"T";1;1;"other";"other";"home";"father";1;2;0;"no";"yes";"no";"yes";"no";"yes";"yes";"no";4;3;2;2;3;4;2;"9";"10";10 87 | "GP";"F";15;"U";"GT3";"T";4;4;"services";"services";"reputation";"father";2;2;2;"no";"no";"yes";"no";"yes";"yes";"yes";"yes";4;4;4;2;3;5;6;"7";"9";8 88 | "GP";"F";16;"U";"LE3";"T";2;2;"at_home";"other";"course";"mother";1;2;0;"no";"yes";"no";"no";"yes";"yes";"no";"no";4;3;4;1;2;2;4;"8";"7";6 89 | "GP";"F";15;"U";"GT3";"T";4;2;"other";"other";"reputation";"mother";1;3;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;3;3;1;3;1;4;"13";"14";14 90 | "GP";"M";16;"U";"GT3";"T";2;2;"services";"other";"reputation";"father";2;2;1;"no";"no";"yes";"yes";"no";"yes";"yes";"no";4;4;2;1;1;3;12;"11";"10";10 91 | "GP";"M";16;"U";"LE3";"A";4;4;"teacher";"health";"reputation";"mother";1;2;0;"no";"yes";"no";"no";"yes";"yes";"no";"no";4;1;3;3;5;5;18;"8";"6";7 92 | "GP";"F";16;"U";"GT3";"T";3;3;"other";"other";"home";"mother";1;3;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;3;3;1;3;4;0;"7";"7";8 93 | "GP";"F";15;"U";"GT3";"T";4;3;"services";"other";"reputation";"mother";1;1;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";4;5;5;1;3;1;4;"16";"17";18 94 | "GP";"F";16;"U";"LE3";"T";3;1;"other";"other";"home";"father";1;2;0;"yes";"yes";"no";"no";"yes";"yes";"no";"no";3;3;3;2;3;2;4;"7";"6";6 95 | "GP";"F";16;"U";"GT3";"T";4;2;"teacher";"services";"home";"mother";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;3;3;1;1;1;0;"11";"10";10 96 | "GP";"M";15;"U";"LE3";"T";2;2;"services";"health";"reputation";"mother";1;4;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;3;4;1;1;4;6;"11";"13";14 97 | "GP";"F";15;"R";"GT3";"T";1;1;"at_home";"other";"home";"mother";2;4;1;"yes";"yes";"yes";"yes";"yes";"yes";"yes";"no";3;1;2;1;1;1;2;"7";"10";10 98 | "GP";"M";16;"R";"GT3";"T";4;3;"services";"other";"reputation";"mother";2;1;0;"yes";"yes";"no";"yes";"no";"yes";"yes";"no";3;3;3;1;1;4;2;"11";"15";15 99 | "GP";"F";16;"U";"GT3";"T";2;1;"other";"other";"course";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"no";"yes";4;3;5;1;1;5;2;"8";"9";10 100 | "GP";"F";16;"U";"GT3";"T";4;4;"other";"other";"reputation";"mother";1;1;0;"no";"no";"no";"yes";"no";"yes";"yes";"no";5;3;4;1;2;1;6;"11";"14";14 101 | "GP";"F";16;"U";"GT3";"T";4;3;"other";"at_home";"course";"mother";1;3;0;"yes";"yes";"yes";"no";"yes";"yes";"yes";"no";5;3;5;1;1;3;0;"7";"9";8 102 | "GP";"M";16;"U";"GT3";"T";4;4;"services";"services";"other";"mother";1;1;0;"yes";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;5;5;5;5;4;14;"7";"7";5 103 | "GP";"M";16;"U";"GT3";"T";4;4;"services";"teacher";"other";"father";1;3;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";4;4;3;1;1;4;0;"16";"17";17 104 | "GP";"M";15;"U";"GT3";"T";4;4;"services";"other";"course";"mother";1;1;0;"no";"yes";"no";"yes";"no";"yes";"yes";"no";5;3;3;1;1;5;4;"10";"13";14 105 | "GP";"F";15;"U";"GT3";"T";3;2;"services";"other";"home";"mother";2;2;0;"yes";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;5;1;1;2;26;"7";"6";6 106 | "GP";"M";15;"U";"GT3";"A";3;4;"services";"other";"course";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;4;4;1;1;1;0;"16";"18";18 107 | "GP";"F";15;"U";"GT3";"A";3;3;"other";"health";"reputation";"father";1;4;0;"yes";"no";"no";"no";"yes";"yes";"no";"no";4;3;3;1;1;4;10;"10";"11";11 108 | "GP";"F";15;"U";"GT3";"T";2;2;"other";"other";"course";"mother";1;4;0;"yes";"yes";"yes";"no";"yes";"yes";"yes";"no";5;1;2;1;1;3;8;"7";"8";8 109 | "GP";"M";16;"U";"GT3";"T";3;3;"services";"other";"home";"father";1;3;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;3;3;1;1;5;2;"16";"18";18 110 | "GP";"M";15;"R";"GT3";"T";4;4;"other";"other";"home";"father";4;4;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";1;3;5;3;5;1;6;"10";"13";13 111 | "GP";"F";16;"U";"LE3";"T";4;4;"health";"health";"other";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";5;4;5;1;1;4;4;"14";"15";16 112 | "GP";"M";15;"U";"LE3";"A";4;4;"teacher";"teacher";"course";"mother";1;1;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";5;5;3;1;1;4;6;"18";"19";19 113 | "GP";"F";16;"R";"GT3";"T";3;3;"services";"other";"reputation";"father";1;3;1;"yes";"yes";"no";"yes";"yes";"yes";"yes";"no";4;1;2;1;1;2;0;"7";"10";10 114 | "GP";"F";16;"U";"GT3";"T";2;2;"at_home";"other";"home";"mother";1;2;1;"yes";"no";"no";"yes";"yes";"yes";"yes";"no";3;1;2;1;1;5;6;"10";"13";13 115 | "GP";"M";15;"U";"LE3";"T";4;2;"teacher";"other";"course";"mother";1;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";3;5;2;1;1;3;10;"18";"19";19 116 | "GP";"M";15;"R";"GT3";"T";2;1;"health";"services";"reputation";"mother";1;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"yes";5;4;2;1;1;5;8;"9";"9";9 117 | "GP";"M";16;"U";"GT3";"T";4;4;"teacher";"teacher";"course";"father";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;4;4;1;2;5;2;"15";"15";16 118 | "GP";"M";15;"U";"GT3";"T";4;4;"other";"teacher";"reputation";"father";2;2;0;"no";"yes";"no";"yes";"yes";"yes";"no";"no";4;4;3;1;1;2;2;"11";"13";14 119 | "GP";"M";16;"U";"GT3";"T";3;3;"other";"services";"home";"father";2;1;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";5;4;2;1;1;5;0;"13";"14";13 120 | "GP";"M";17;"R";"GT3";"T";1;3;"other";"other";"course";"father";3;2;1;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";5;2;4;1;4;5;20;"9";"7";8 121 | "GP";"M";15;"U";"GT3";"T";3;4;"other";"other";"reputation";"father";1;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";3;4;3;1;2;4;6;"14";"13";13 122 | "GP";"F";15;"U";"GT3";"T";1;2;"at_home";"services";"course";"mother";1;2;0;"no";"no";"no";"no";"no";"yes";"yes";"no";3;2;3;1;2;1;2;"16";"15";15 123 | "GP";"M";15;"U";"GT3";"T";2;2;"services";"services";"home";"father";1;4;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;5;4;1;2;5;6;"16";"14";15 124 | "GP";"F";16;"U";"LE3";"T";2;4;"other";"health";"course";"father";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;2;2;1;2;5;2;"13";"13";13 125 | "GP";"M";16;"U";"GT3";"T";4;4;"health";"other";"course";"mother";1;1;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";3;4;4;1;4;5;18;"14";"11";13 126 | "GP";"F";16;"U";"GT3";"T";2;2;"other";"other";"home";"mother";1;2;0;"no";"no";"yes";"no";"yes";"yes";"yes";"yes";5;4;4;1;1;5;0;"8";"7";8 127 | "GP";"M";15;"U";"GT3";"T";3;4;"services";"services";"home";"father";1;1;0;"yes";"no";"no";"no";"yes";"yes";"yes";"no";5;5;5;3;2;5;0;"13";"13";12 128 | "GP";"F";15;"U";"LE3";"A";3;4;"other";"other";"home";"mother";1;2;0;"yes";"no";"no";"yes";"yes";"yes";"yes";"yes";5;3;2;1;1;1;0;"7";"10";11 129 | "GP";"F";19;"U";"GT3";"T";0;1;"at_home";"other";"course";"other";1;2;3;"no";"yes";"no";"no";"no";"no";"no";"no";3;4;2;1;1;5;2;"7";"8";9 130 | "GP";"M";18;"R";"GT3";"T";2;2;"services";"other";"reputation";"mother";1;1;2;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";3;3;3;1;2;4;0;"7";"4";0 131 | "GP";"M";16;"R";"GT3";"T";4;4;"teacher";"teacher";"course";"mother";1;1;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";3;5;5;2;5;4;8;"18";"18";18 132 | "GP";"F";15;"R";"GT3";"T";3;4;"services";"teacher";"course";"father";2;3;2;"no";"yes";"no";"no";"yes";"yes";"yes";"yes";4;2;2;2;2;5;0;"12";"0";0 133 | "GP";"F";15;"U";"GT3";"T";1;1;"at_home";"other";"course";"mother";3;1;0;"no";"yes";"no";"yes";"no";"yes";"yes";"yes";4;3;3;1;2;4;0;"8";"0";0 134 | "GP";"F";17;"U";"LE3";"T";2;2;"other";"other";"course";"father";1;1;0;"no";"yes";"no";"no";"yes";"yes";"yes";"yes";3;4;4;1;3;5;12;"10";"13";12 135 | "GP";"F";16;"U";"GT3";"A";3;4;"services";"other";"course";"father";1;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";3;2;1;1;4;5;16;"12";"11";11 136 | "GP";"M";15;"R";"GT3";"T";3;4;"at_home";"teacher";"course";"mother";4;2;0;"no";"yes";"no";"no";"yes";"yes";"no";"yes";5;3;3;1;1;5;0;"9";"0";0 137 | "GP";"F";15;"U";"GT3";"T";4;4;"services";"at_home";"course";"mother";1;3;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";4;3;3;1;1;5;0;"11";"0";0 138 | "GP";"M";17;"R";"GT3";"T";3;4;"at_home";"other";"course";"mother";3;2;0;"no";"no";"no";"no";"yes";"yes";"no";"no";5;4;5;2;4;5;0;"10";"0";0 139 | "GP";"F";16;"U";"GT3";"A";3;3;"other";"other";"course";"other";2;1;2;"no";"yes";"no";"yes";"no";"yes";"yes";"yes";4;3;2;1;1;5;0;"4";"0";0 140 | "GP";"M";16;"U";"LE3";"T";1;1;"services";"other";"course";"mother";1;2;1;"no";"no";"no";"no";"yes";"yes";"no";"yes";4;4;4;1;3;5;0;"14";"12";12 141 | "GP";"F";15;"U";"GT3";"T";4;4;"teacher";"teacher";"course";"mother";2;1;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";4;3;2;1;1;5;0;"16";"16";15 142 | "GP";"M";15;"U";"GT3";"T";4;3;"teacher";"services";"course";"father";2;4;0;"yes";"yes";"no";"no";"yes";"yes";"yes";"no";2;2;2;1;1;3;0;"7";"9";0 143 | "GP";"M";16;"U";"LE3";"T";2;2;"services";"services";"reputation";"father";2;1;2;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";2;3;3;2;2;2;8;"9";"9";9 144 | "GP";"F";15;"U";"GT3";"T";4;4;"teacher";"services";"course";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;2;2;1;1;5;2;"9";"11";11 145 | "GP";"F";16;"U";"LE3";"T";1;1;"at_home";"at_home";"course";"mother";1;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";3;4;4;3;3;1;2;"14";"14";13 146 | "GP";"M";17;"U";"GT3";"T";2;1;"other";"other";"home";"mother";1;1;3;"no";"yes";"no";"no";"yes";"yes";"yes";"no";5;4;5;1;2;5;0;"5";"0";0 147 | "GP";"F";15;"U";"GT3";"T";1;1;"other";"services";"course";"father";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;4;2;1;2;5;0;"8";"11";11 148 | "GP";"F";15;"U";"GT3";"T";3;2;"health";"services";"home";"father";1;2;3;"no";"yes";"no";"no";"yes";"yes";"yes";"no";3;3;2;1;1;3;0;"6";"7";0 149 | "GP";"F";15;"U";"GT3";"T";1;2;"at_home";"other";"course";"mother";1;2;0;"no";"yes";"yes";"no";"no";"yes";"yes";"no";4;3;2;1;1;5;2;"10";"11";11 150 | "GP";"M";16;"U";"GT3";"T";4;4;"teacher";"teacher";"course";"mother";1;1;0;"no";"yes";"no";"no";"yes";"no";"yes";"yes";3;3;2;2;1;5;0;"7";"6";0 151 | "GP";"M";15;"U";"LE3";"A";2;1;"services";"other";"course";"mother";4;1;3;"no";"no";"no";"no";"yes";"yes";"yes";"no";4;5;5;2;5;5;0;"8";"9";10 152 | "GP";"M";18;"U";"LE3";"T";1;1;"other";"other";"course";"mother";1;1;3;"no";"no";"no";"no";"yes";"no";"yes";"yes";2;3;5;2;5;4;0;"6";"5";0 153 | "GP";"M";16;"U";"LE3";"T";2;1;"at_home";"other";"course";"mother";1;1;1;"no";"no";"no";"yes";"yes";"yes";"no";"yes";4;4;4;3;5;5;6;"12";"13";14 154 | "GP";"F";15;"R";"GT3";"T";3;3;"services";"services";"reputation";"other";2;3;2;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;2;1;2;3;3;8;"10";"10";10 155 | "GP";"M";19;"U";"GT3";"T";3;2;"services";"at_home";"home";"mother";1;1;3;"no";"yes";"no";"no";"yes";"no";"yes";"yes";4;5;4;1;1;4;0;"5";"0";0 156 | "GP";"F";17;"U";"GT3";"T";4;4;"other";"teacher";"course";"mother";1;1;0;"yes";"yes";"no";"no";"yes";"yes";"no";"yes";4;2;1;1;1;4;0;"11";"11";12 157 | "GP";"M";15;"R";"GT3";"T";2;3;"at_home";"services";"course";"mother";1;2;0;"yes";"no";"yes";"yes";"yes";"yes";"no";"no";4;4;4;1;1;1;2;"11";"8";8 158 | "GP";"M";17;"R";"LE3";"T";1;2;"other";"other";"reputation";"mother";1;1;0;"no";"no";"no";"no";"yes";"yes";"no";"no";2;2;2;3;3;5;8;"16";"12";13 159 | "GP";"F";18;"R";"GT3";"T";1;1;"at_home";"other";"course";"mother";3;1;3;"no";"yes";"no";"yes";"no";"yes";"no";"no";5;2;5;1;5;4;6;"9";"8";10 160 | "GP";"M";16;"R";"GT3";"T";2;2;"at_home";"other";"course";"mother";3;1;0;"no";"no";"no";"no";"no";"yes";"no";"no";4;2;2;1;2;3;2;"17";"15";15 161 | "GP";"M";16;"U";"GT3";"T";3;3;"other";"services";"course";"father";1;2;1;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;5;5;4;4;5;4;"10";"12";12 162 | "GP";"M";17;"R";"LE3";"T";2;1;"at_home";"other";"course";"mother";2;1;2;"no";"no";"no";"yes";"yes";"no";"yes";"yes";3;3;2;2;2;5;0;"7";"6";0 163 | "GP";"M";15;"R";"GT3";"T";3;2;"other";"other";"course";"mother";2;2;2;"yes";"yes";"no";"no";"yes";"yes";"yes";"yes";4;4;4;1;4;3;6;"5";"9";7 164 | "GP";"M";16;"U";"LE3";"T";1;2;"other";"other";"course";"mother";2;1;1;"no";"no";"no";"yes";"yes";"yes";"no";"no";4;4;4;2;4;5;0;"7";"0";0 165 | "GP";"M";17;"U";"GT3";"T";1;3;"at_home";"services";"course";"father";1;1;0;"no";"no";"no";"no";"yes";"no";"yes";"no";5;3;3;1;4;2;2;"10";"10";10 166 | "GP";"M";17;"R";"LE3";"T";1;1;"other";"services";"course";"mother";4;2;3;"no";"no";"no";"yes";"yes";"no";"no";"yes";5;3;5;1;5;5;0;"5";"8";7 167 | "GP";"M";16;"U";"GT3";"T";3;2;"services";"services";"course";"mother";2;1;1;"no";"yes";"no";"yes";"no";"no";"no";"no";4;5;2;1;1;2;16;"12";"11";12 168 | "GP";"M";16;"U";"GT3";"T";2;2;"other";"other";"course";"father";1;2;0;"no";"no";"no";"no";"yes";"no";"yes";"no";4;3;5;2;4;4;4;"10";"10";10 169 | "GP";"F";16;"U";"GT3";"T";4;2;"health";"services";"home";"father";1;2;0;"no";"no";"yes";"no";"yes";"yes";"yes";"yes";4;2;3;1;1;3;0;"14";"15";16 170 | "GP";"F";16;"U";"GT3";"T";2;2;"other";"other";"home";"mother";1;2;0;"no";"yes";"yes";"no";"no";"yes";"yes";"no";5;1;5;1;1;4;0;"6";"7";0 171 | "GP";"F";16;"U";"GT3";"T";4;4;"health";"health";"reputation";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;4;2;1;1;3;0;"14";"14";14 172 | "GP";"M";16;"U";"GT3";"T";3;4;"other";"other";"course";"father";3;1;2;"no";"yes";"no";"yes";"no";"yes";"yes";"no";3;4;5;2;4;2;0;"6";"5";0 173 | "GP";"M";16;"U";"GT3";"T";1;0;"other";"other";"reputation";"mother";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;3;2;1;1;3;2;"13";"15";16 174 | "GP";"M";17;"U";"LE3";"T";4;4;"teacher";"other";"reputation";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;4;4;1;3;5;0;"13";"11";10 175 | "GP";"F";16;"U";"GT3";"T";1;3;"at_home";"services";"home";"mother";1;2;3;"no";"no";"no";"yes";"no";"yes";"yes";"yes";4;3;5;1;1;3;0;"8";"7";0 176 | "GP";"F";16;"U";"LE3";"T";3;3;"other";"other";"reputation";"mother";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;4;5;1;1;4;4;"10";"11";9 177 | "GP";"M";17;"U";"LE3";"T";4;3;"teacher";"other";"course";"mother";2;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";4;4;4;4;4;4;4;"10";"9";9 178 | "GP";"F";16;"U";"GT3";"T";2;2;"services";"other";"reputation";"mother";2;2;0;"no";"no";"yes";"yes";"no";"yes";"yes";"no";3;4;4;1;4;5;2;"13";"13";11 179 | "GP";"M";17;"U";"GT3";"T";3;3;"other";"other";"reputation";"father";1;2;0;"no";"no";"no";"yes";"no";"yes";"yes";"no";4;3;4;1;4;4;4;"6";"5";6 180 | "GP";"M";16;"R";"GT3";"T";4;2;"teacher";"services";"other";"mother";1;1;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";4;3;3;3;4;3;10;"10";"8";9 181 | "GP";"M";17;"U";"GT3";"T";4;3;"other";"other";"course";"mother";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";5;2;3;1;1;2;4;"10";"10";11 182 | "GP";"M";16;"U";"GT3";"T";4;3;"teacher";"other";"home";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";3;4;3;2;3;3;10;"9";"8";8 183 | "GP";"M";16;"U";"GT3";"T";3;3;"services";"other";"home";"mother";1;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"yes";4;2;3;1;2;3;2;"12";"13";12 184 | "GP";"F";17;"U";"GT3";"T";2;4;"services";"services";"reputation";"father";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"no";"no";5;4;2;2;3;5;0;"16";"17";17 185 | "GP";"F";17;"U";"LE3";"T";3;3;"other";"other";"reputation";"mother";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";5;3;3;2;3;1;56;"9";"9";8 186 | "GP";"F";16;"U";"GT3";"T";3;2;"other";"other";"reputation";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";1;2;2;1;2;1;14;"12";"13";12 187 | "GP";"M";17;"U";"GT3";"T";3;3;"services";"services";"other";"mother";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";4;3;4;2;3;4;12;"12";"12";11 188 | "GP";"M";16;"U";"GT3";"T";1;2;"services";"services";"other";"mother";1;1;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";3;3;3;1;2;3;2;"11";"12";11 189 | "GP";"M";16;"U";"LE3";"T";2;1;"other";"other";"course";"mother";1;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"yes";4;2;3;1;2;5;0;"15";"15";15 190 | "GP";"F";17;"U";"GT3";"A";3;3;"health";"other";"reputation";"mother";1;2;0;"no";"yes";"no";"no";"no";"yes";"yes";"yes";3;3;3;1;3;3;6;"8";"7";9 191 | "GP";"M";17;"R";"GT3";"T";1;2;"at_home";"other";"home";"mother";1;2;0;"no";"no";"no";"no";"yes";"yes";"no";"no";3;1;3;1;5;3;4;"8";"9";10 192 | "GP";"F";16;"U";"GT3";"T";2;3;"services";"services";"course";"mother";1;2;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";4;3;3;1;1;2;10;"11";"12";13 193 | "GP";"F";17;"U";"GT3";"T";1;1;"at_home";"services";"course";"mother";1;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";5;3;3;1;1;3;0;"8";"8";9 194 | "GP";"M";17;"U";"GT3";"T";1;2;"at_home";"services";"other";"other";2;2;0;"no";"no";"yes";"yes";"no";"yes";"yes";"no";4;4;4;4;5;5;12;"7";"8";8 195 | "GP";"M";16;"R";"GT3";"T";3;3;"services";"services";"reputation";"mother";1;1;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;3;2;3;4;5;8;"8";"9";10 196 | "GP";"M";16;"U";"GT3";"T";2;3;"other";"other";"home";"father";2;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";5;3;3;1;1;3;0;"13";"14";14 197 | "GP";"F";17;"U";"LE3";"T";2;4;"services";"services";"course";"father";1;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"yes";4;3;2;1;1;5;0;"14";"15";15 198 | "GP";"M";17;"U";"GT3";"T";4;4;"services";"teacher";"home";"mother";1;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";5;2;3;1;2;5;4;"17";"15";16 199 | "GP";"M";16;"R";"LE3";"T";3;3;"teacher";"other";"home";"father";3;1;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";3;3;4;3;5;3;8;"9";"9";10 200 | "GP";"F";17;"U";"GT3";"T";4;4;"services";"teacher";"home";"mother";2;1;1;"no";"yes";"no";"no";"yes";"yes";"yes";"no";4;2;4;2;3;2;24;"18";"18";18 201 | "GP";"F";16;"U";"LE3";"T";4;4;"teacher";"teacher";"reputation";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;5;2;1;2;3;0;"9";"9";10 202 | "GP";"F";16;"U";"GT3";"T";4;3;"health";"other";"home";"mother";1;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;3;5;1;5;2;2;"16";"16";16 203 | "GP";"F";16;"U";"GT3";"T";2;3;"other";"other";"reputation";"mother";1;2;0;"yes";"yes";"yes";"yes";"yes";"yes";"no";"no";4;4;3;1;3;4;6;"8";"10";10 204 | "GP";"F";17;"U";"GT3";"T";1;1;"other";"other";"course";"mother";1;2;0;"no";"yes";"yes";"no";"no";"yes";"no";"no";4;4;4;1;3;1;4;"9";"9";10 205 | "GP";"F";17;"R";"GT3";"T";2;2;"other";"other";"reputation";"mother";1;1;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";5;3;2;1;2;3;18;"7";"6";6 206 | "GP";"F";16;"R";"GT3";"T";2;2;"services";"services";"reputation";"mother";2;4;0;"no";"yes";"yes";"yes";"no";"yes";"yes";"no";5;3;5;1;1;5;6;"10";"10";11 207 | "GP";"F";17;"U";"GT3";"T";3;4;"at_home";"services";"home";"mother";1;3;1;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;4;3;3;4;5;28;"10";"9";9 208 | "GP";"F";16;"U";"GT3";"A";3;1;"services";"other";"course";"mother";1;2;3;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";2;3;3;2;2;4;5;"7";"7";7 209 | "GP";"F";16;"U";"GT3";"T";4;3;"teacher";"other";"other";"mother";1;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"yes";1;3;2;1;1;1;10;"11";"12";13 210 | "GP";"F";16;"U";"GT3";"T";1;1;"at_home";"other";"home";"mother";2;1;0;"no";"yes";"yes";"no";"yes";"yes";"no";"no";4;3;2;1;4;5;6;"9";"9";10 211 | "GP";"F";17;"R";"GT3";"T";4;3;"teacher";"other";"reputation";"mother";2;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;4;2;1;1;4;6;"7";"7";7 212 | "GP";"F";19;"U";"GT3";"T";3;3;"other";"other";"reputation";"other";1;4;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;3;1;2;3;10;"8";"8";8 213 | "GP";"M";17;"U";"LE3";"T";4;4;"services";"other";"home";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";5;3;5;4;5;3;13;"12";"12";13 214 | "GP";"F";16;"U";"GT3";"A";2;2;"other";"other";"reputation";"mother";1;2;0;"yes";"yes";"yes";"no";"yes";"yes";"yes";"no";3;3;4;1;1;4;0;"12";"13";14 215 | "GP";"M";18;"U";"GT3";"T";2;2;"services";"other";"home";"mother";1;2;1;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;4;4;2;4;5;15;"6";"7";8 216 | "GP";"F";17;"R";"LE3";"T";4;4;"services";"other";"other";"mother";1;1;0;"no";"yes";"yes";"no";"yes";"yes";"no";"no";5;2;1;1;2;3;12;"8";"10";10 217 | "GP";"F";17;"U";"LE3";"T";3;2;"other";"other";"reputation";"mother";2;2;0;"no";"no";"yes";"no";"yes";"yes";"yes";"no";4;4;4;1;3;1;2;"14";"15";15 218 | "GP";"F";17;"U";"GT3";"T";4;3;"other";"other";"reputation";"mother";1;2;2;"no";"no";"yes";"no";"yes";"yes";"yes";"yes";3;4;5;2;4;1;22;"6";"6";4 219 | "GP";"M";18;"U";"LE3";"T";3;3;"services";"health";"home";"father";1;2;1;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";3;2;4;2;4;4;13;"6";"6";8 220 | "GP";"F";17;"U";"GT3";"T";2;3;"at_home";"other";"home";"father";2;1;0;"no";"yes";"yes";"no";"yes";"yes";"no";"no";3;3;3;1;4;3;3;"7";"7";8 221 | "GP";"F";17;"U";"GT3";"T";2;2;"at_home";"at_home";"course";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;3;1;1;4;4;"9";"10";10 222 | "GP";"F";17;"R";"GT3";"T";2;1;"at_home";"services";"reputation";"mother";2;2;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;2;5;1;2;5;2;"6";"6";6 223 | "GP";"F";17;"U";"GT3";"T";1;1;"at_home";"other";"reputation";"mother";1;3;1;"no";"yes";"no";"yes";"yes";"yes";"no";"yes";4;3;4;1;1;5;0;"6";"5";0 224 | "GP";"F";16;"U";"GT3";"T";2;3;"services";"teacher";"other";"mother";1;2;0;"yes";"no";"no";"no";"yes";"yes";"yes";"no";2;3;1;1;1;3;2;"16";"16";17 225 | "GP";"M";18;"U";"GT3";"T";2;2;"other";"other";"home";"mother";2;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";3;3;3;5;5;4;0;"12";"13";13 226 | "GP";"F";16;"U";"GT3";"T";4;4;"teacher";"services";"home";"mother";1;3;0;"no";"yes";"no";"yes";"no";"yes";"yes";"no";5;3;2;1;1;5;0;"13";"13";14 227 | "GP";"F";18;"R";"GT3";"T";3;1;"other";"other";"reputation";"mother";1;2;1;"no";"no";"no";"yes";"yes";"yes";"yes";"yes";5;3;3;1;1;4;16;"9";"8";7 228 | "GP";"F";17;"U";"GT3";"T";3;2;"other";"other";"course";"mother";1;2;0;"no";"no";"no";"yes";"no";"yes";"yes";"no";5;3;4;1;3;3;10;"16";"15";15 229 | "GP";"M";17;"U";"LE3";"T";2;3;"services";"services";"reputation";"father";1;2;0;"no";"yes";"yes";"no";"no";"yes";"yes";"no";5;3;3;1;3;3;2;"12";"11";12 230 | "GP";"M";18;"U";"LE3";"T";2;1;"at_home";"other";"course";"mother";4;2;0;"yes";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;3;2;4;5;3;14;"10";"8";9 231 | "GP";"F";17;"U";"GT3";"A";2;1;"other";"other";"course";"mother";2;3;0;"no";"no";"no";"yes";"yes";"yes";"yes";"yes";3;2;3;1;2;3;10;"12";"10";12 232 | "GP";"F";17;"U";"LE3";"T";4;3;"health";"other";"reputation";"father";1;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"yes";3;2;3;1;2;3;14;"13";"13";14 233 | "GP";"M";17;"R";"GT3";"T";2;2;"other";"other";"course";"father";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;5;2;1;1;1;4;"11";"11";11 234 | "GP";"M";17;"U";"GT3";"T";4;4;"teacher";"teacher";"reputation";"mother";1;2;0;"yes";"yes";"no";"yes";"yes";"yes";"yes";"yes";4;5;5;1;3;2;14;"11";"9";9 235 | "GP";"M";16;"U";"GT3";"T";4;4;"health";"other";"reputation";"father";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;2;4;2;4;1;2;"14";"13";13 236 | "GP";"M";16;"U";"LE3";"T";1;1;"other";"other";"home";"mother";2;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";3;4;2;1;1;5;18;"9";"7";6 237 | "GP";"M";16;"U";"GT3";"T";3;2;"at_home";"other";"reputation";"mother";2;3;0;"no";"no";"no";"yes";"yes";"yes";"yes";"yes";5;3;3;1;3;2;10;"11";"9";10 238 | "GP";"M";17;"U";"LE3";"T";2;2;"other";"other";"home";"father";1;2;0;"no";"no";"yes";"yes";"no";"yes";"yes";"yes";4;4;2;5;5;4;4;"14";"13";13 239 | "GP";"F";16;"U";"GT3";"T";2;1;"other";"other";"home";"mother";1;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"yes";4;5;2;1;1;5;20;"13";"12";12 240 | "GP";"F";17;"R";"GT3";"T";2;1;"at_home";"services";"course";"mother";3;2;0;"no";"no";"no";"yes";"yes";"yes";"no";"no";2;1;1;1;1;3;2;"13";"11";11 241 | "GP";"M";18;"U";"GT3";"T";2;2;"other";"services";"reputation";"father";1;2;1;"no";"no";"no";"no";"yes";"no";"yes";"no";5;5;4;3;5;2;0;"7";"7";0 242 | "GP";"M";17;"U";"LE3";"T";4;3;"health";"other";"course";"mother";2;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"yes";2;5;5;1;4;5;14;"12";"12";12 243 | "GP";"M";17;"R";"LE3";"A";4;4;"teacher";"other";"course";"mother";2;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";3;3;3;2;3;4;2;"10";"11";12 244 | "GP";"M";16;"U";"LE3";"T";4;3;"teacher";"other";"course";"mother";1;1;0;"no";"no";"no";"yes";"no";"yes";"yes";"no";5;4;5;1;1;3;0;"6";"0";0 245 | "GP";"M";16;"U";"GT3";"T";4;4;"services";"services";"course";"mother";1;1;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";5;3;2;1;2;5;0;"13";"12";12 246 | "GP";"F";18;"U";"GT3";"T";2;1;"other";"other";"course";"other";2;3;0;"no";"yes";"yes";"no";"no";"yes";"yes";"yes";4;4;4;1;1;3;0;"7";"0";0 247 | "GP";"M";16;"U";"GT3";"T";2;1;"other";"other";"course";"mother";3;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";4;3;3;1;1;4;6;"18";"18";18 248 | "GP";"M";17;"U";"GT3";"T";2;3;"other";"other";"course";"father";2;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";5;2;2;1;1;2;4;"12";"12";13 249 | "GP";"M";22;"U";"GT3";"T";3;1;"services";"services";"other";"mother";1;1;3;"no";"no";"no";"no";"no";"no";"yes";"yes";5;4;5;5;5;1;16;"6";"8";8 250 | "GP";"M";18;"R";"LE3";"T";3;3;"other";"services";"course";"mother";1;2;1;"no";"yes";"no";"no";"yes";"yes";"yes";"yes";4;3;3;1;3;5;8;"3";"5";5 251 | "GP";"M";16;"U";"GT3";"T";0;2;"other";"other";"other";"mother";1;1;0;"no";"no";"yes";"no";"no";"yes";"yes";"no";4;3;2;2;4;5;0;"13";"15";15 252 | "GP";"M";18;"U";"GT3";"T";3;2;"services";"other";"course";"mother";2;1;1;"no";"no";"no";"no";"yes";"no";"yes";"no";4;4;5;2;4;5;0;"6";"8";8 253 | "GP";"M";16;"U";"GT3";"T";3;3;"at_home";"other";"reputation";"other";3;2;0;"yes";"yes";"no";"no";"no";"yes";"yes";"no";5;3;3;1;3;2;6;"7";"10";10 254 | "GP";"M";18;"U";"GT3";"T";2;1;"services";"services";"other";"mother";1;1;1;"no";"no";"no";"no";"no";"no";"yes";"no";3;2;5;2;5;5;4;"6";"9";8 255 | "GP";"M";16;"R";"GT3";"T";2;1;"other";"other";"course";"mother";2;1;0;"no";"no";"no";"yes";"no";"yes";"no";"no";3;3;2;1;3;3;0;"8";"9";8 256 | "GP";"M";17;"R";"GT3";"T";2;1;"other";"other";"course";"mother";1;1;0;"no";"no";"no";"no";"no";"yes";"yes";"no";4;4;2;2;4;5;0;"8";"12";12 257 | "GP";"M";17;"U";"LE3";"T";1;1;"health";"other";"course";"mother";2;1;1;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;4;4;1;2;5;2;"7";"9";8 258 | "GP";"F";17;"U";"LE3";"T";4;2;"teacher";"services";"reputation";"mother";1;4;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;2;3;1;1;4;6;"14";"12";13 259 | "GP";"M";19;"U";"LE3";"A";4;3;"services";"at_home";"reputation";"mother";1;2;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";4;3;1;1;1;1;12;"11";"11";11 260 | "GP";"M";18;"U";"GT3";"T";2;1;"other";"other";"home";"mother";1;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";5;2;4;1;2;4;8;"15";"14";14 261 | "GP";"F";17;"U";"LE3";"T";2;2;"services";"services";"course";"father";1;4;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"yes";3;4;1;1;1;2;0;"10";"9";0 262 | "GP";"F";18;"U";"GT3";"T";4;3;"services";"other";"home";"father";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";3;1;2;1;3;2;21;"17";"18";18 263 | "GP";"M";18;"U";"GT3";"T";4;3;"teacher";"other";"course";"mother";1;2;0;"no";"yes";"yes";"no";"no";"yes";"yes";"no";4;3;2;1;1;3;2;"8";"8";8 264 | "GP";"M";18;"R";"GT3";"T";3;2;"other";"other";"course";"mother";1;3;0;"no";"no";"no";"yes";"no";"yes";"no";"no";5;3;2;1;1;3;1;"13";"12";12 265 | "GP";"F";17;"U";"GT3";"T";3;3;"other";"other";"home";"mother";1;3;0;"no";"no";"no";"yes";"no";"yes";"no";"no";3;2;3;1;1;4;4;"10";"9";9 266 | "GP";"F";18;"U";"GT3";"T";2;2;"at_home";"services";"home";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;3;3;1;1;3;0;"9";"10";0 267 | "GP";"M";18;"R";"LE3";"A";3;4;"other";"other";"reputation";"mother";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;2;5;3;4;1;13;"17";"17";17 268 | "GP";"M";17;"U";"GT3";"T";3;1;"services";"other";"other";"mother";1;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"yes";5;4;4;3;4;5;2;"9";"9";10 269 | "GP";"F";18;"R";"GT3";"T";4;4;"teacher";"other";"reputation";"mother";2;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";4;3;4;2;2;4;8;"12";"10";11 270 | "GP";"M";18;"U";"GT3";"T";4;2;"health";"other";"reputation";"father";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";5;4;5;1;3;5;10;"10";"9";10 271 | "GP";"F";18;"R";"GT3";"T";2;1;"other";"other";"reputation";"mother";2;2;0;"no";"yes";"no";"no";"yes";"no";"yes";"yes";4;3;5;1;2;3;0;"6";"0";0 272 | "GP";"F";19;"U";"GT3";"T";3;3;"other";"services";"home";"other";1;2;2;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;5;3;3;5;15;"9";"9";9 273 | "GP";"F";18;"U";"GT3";"T";2;3;"other";"services";"reputation";"father";1;4;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;5;5;1;3;2;4;"15";"14";14 274 | "GP";"F";18;"U";"LE3";"T";1;1;"other";"other";"home";"mother";2;2;0;"no";"yes";"yes";"no";"no";"yes";"no";"no";4;4;3;1;1;3;2;"11";"11";11 275 | "GP";"M";17;"R";"GT3";"T";1;2;"at_home";"at_home";"home";"mother";1;2;0;"no";"yes";"yes";"yes";"no";"yes";"no";"yes";3;5;2;2;2;1;2;"15";"14";14 276 | "GP";"F";17;"U";"GT3";"T";2;4;"at_home";"health";"reputation";"mother";2;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;3;3;1;1;1;2;"10";"10";10 277 | "GP";"F";17;"U";"LE3";"T";2;2;"services";"other";"course";"mother";2;2;0;"yes";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;4;4;2;3;5;6;"12";"12";12 278 | "GP";"F";18;"R";"GT3";"A";3;2;"other";"services";"home";"mother";2;2;0;"no";"no";"no";"no";"no";"no";"yes";"yes";4;1;1;1;1;5;75;"10";"9";9 279 | "GP";"M";18;"U";"GT3";"T";4;4;"teacher";"services";"home";"mother";2;1;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";3;2;4;1;4;3;22;"9";"9";9 280 | "GP";"F";18;"U";"GT3";"T";4;4;"health";"health";"reputation";"father";1;2;1;"yes";"yes";"no";"yes";"yes";"yes";"yes";"yes";2;4;4;1;1;4;15;"9";"8";8 281 | "GP";"M";18;"U";"LE3";"T";4;3;"teacher";"services";"course";"mother";2;1;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";4;2;3;1;2;1;8;"10";"11";10 282 | "GP";"M";17;"U";"LE3";"A";4;1;"services";"other";"home";"mother";2;1;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"yes";4;5;4;2;4;5;30;"8";"8";8 283 | "GP";"M";17;"U";"LE3";"A";3;2;"teacher";"services";"home";"mother";1;1;1;"no";"no";"no";"no";"yes";"yes";"yes";"no";4;4;4;3;4;3;19;"11";"9";10 284 | "GP";"F";18;"R";"LE3";"T";1;1;"at_home";"other";"reputation";"mother";2;4;0;"no";"yes";"yes";"yes";"yes";"yes";"no";"no";5;2;2;1;1;3;1;"12";"12";12 285 | "GP";"F";18;"U";"GT3";"T";1;1;"other";"other";"home";"mother";2;2;0;"yes";"no";"no";"yes";"yes";"yes";"yes";"no";5;4;4;1;1;4;4;"8";"9";10 286 | "GP";"F";17;"U";"GT3";"T";2;2;"other";"other";"course";"mother";1;2;0;"no";"yes";"no";"no";"no";"yes";"yes";"no";5;4;5;1;2;5;4;"10";"9";11 287 | "GP";"M";17;"U";"GT3";"T";1;1;"other";"other";"reputation";"father";1;2;0;"no";"no";"yes";"no";"no";"yes";"yes";"no";4;3;3;1;2;4;2;"12";"10";11 288 | "GP";"F";18;"U";"GT3";"T";2;2;"at_home";"at_home";"other";"mother";1;3;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;3;1;2;2;5;"18";"18";19 289 | "GP";"F";17;"U";"GT3";"T";1;1;"services";"teacher";"reputation";"mother";1;3;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;3;1;1;3;6;"13";"12";12 290 | "GP";"M";18;"U";"GT3";"T";2;1;"services";"services";"reputation";"mother";1;3;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";4;2;4;1;3;2;6;"15";"14";14 291 | "GP";"M";18;"U";"LE3";"A";4;4;"teacher";"teacher";"reputation";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;4;3;1;1;2;9;"15";"13";15 292 | "GP";"M";18;"U";"GT3";"T";4;2;"teacher";"other";"home";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;3;2;1;4;5;11;"12";"11";11 293 | "GP";"F";17;"U";"GT3";"T";4;3;"health";"services";"reputation";"mother";1;3;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;2;2;1;2;3;0;"15";"15";15 294 | "GP";"F";18;"U";"LE3";"T";2;1;"services";"at_home";"reputation";"mother";1;2;1;"no";"no";"no";"no";"yes";"yes";"yes";"yes";5;4;3;1;1;5;12;"12";"12";13 295 | "GP";"F";17;"R";"LE3";"T";3;1;"services";"other";"reputation";"mother";2;4;0;"no";"yes";"yes";"no";"yes";"yes";"no";"no";3;1;2;1;1;3;6;"18";"18";18 296 | "GP";"M";18;"R";"LE3";"T";3;2;"services";"other";"reputation";"mother";2;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;4;2;1;1;4;8;"14";"13";14 297 | "GP";"M";17;"U";"GT3";"T";3;3;"health";"other";"home";"mother";1;1;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;4;3;1;3;5;4;"14";"12";11 298 | "GP";"F";19;"U";"GT3";"T";4;4;"health";"other";"reputation";"other";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";2;3;4;2;3;2;0;"10";"9";0 299 | "GP";"F";18;"U";"LE3";"T";4;3;"other";"other";"home";"other";2;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;4;5;1;2;2;10;"10";"8";8 300 | "GP";"F";18;"U";"GT3";"T";4;3;"other";"other";"reputation";"father";1;4;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;3;1;1;3;0;"14";"13";14 301 | "GP";"M";18;"U";"LE3";"T";4;4;"teacher";"teacher";"home";"mother";1;1;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";1;4;2;2;2;1;5;"16";"15";16 302 | "GP";"F";18;"U";"LE3";"A";4;4;"health";"other";"home";"mother";1;2;0;"no";"yes";"no";"no";"yes";"yes";"yes";"yes";4;2;4;1;1;4;14;"12";"10";11 303 | "GP";"M";17;"U";"LE3";"T";4;4;"other";"teacher";"home";"father";2;1;0;"no";"no";"yes";"no";"yes";"yes";"yes";"no";4;1;1;2;2;5;0;"11";"11";10 304 | "GP";"F";17;"U";"GT3";"T";4;2;"other";"other";"reputation";"mother";2;3;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;3;1;1;3;0;"15";"12";14 305 | "GP";"F";17;"U";"GT3";"T";3;2;"health";"health";"reputation";"father";1;4;0;"no";"yes";"yes";"yes";"no";"yes";"yes";"no";5;2;2;1;2;5;0;"17";"17";18 306 | "GP";"M";19;"U";"GT3";"T";3;3;"other";"other";"home";"other";1;2;1;"no";"yes";"no";"yes";"yes";"yes";"yes";"yes";4;4;4;1;1;3;20;"15";"14";13 307 | "GP";"F";18;"U";"GT3";"T";2;4;"services";"at_home";"reputation";"other";1;2;1;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;4;3;1;1;3;8;"14";"12";12 308 | "GP";"M";20;"U";"GT3";"A";3;2;"services";"other";"course";"other";1;1;0;"no";"no";"no";"yes";"yes";"yes";"no";"no";5;5;3;1;1;5;0;"17";"18";18 309 | "GP";"M";19;"U";"GT3";"T";4;4;"teacher";"services";"reputation";"other";2;1;1;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;3;4;1;1;4;38;"8";"9";8 310 | "GP";"M";19;"R";"GT3";"T";3;3;"other";"services";"reputation";"father";1;2;1;"no";"no";"no";"yes";"yes";"yes";"no";"yes";4;5;3;1;2;5;0;"15";"12";12 311 | "GP";"F";19;"U";"LE3";"T";1;1;"at_home";"other";"reputation";"other";1;2;1;"yes";"yes";"no";"yes";"no";"yes";"yes";"no";4;4;3;1;3;3;18;"12";"10";10 312 | "GP";"F";19;"U";"LE3";"T";1;2;"services";"services";"home";"other";1;2;1;"no";"no";"no";"yes";"no";"yes";"no";"yes";4;2;4;2;2;3;0;"9";"9";0 313 | "GP";"F";19;"U";"GT3";"T";2;1;"at_home";"other";"other";"other";3;2;0;"no";"yes";"no";"no";"yes";"no";"yes";"yes";3;4;1;1;1;2;20;"14";"12";13 314 | "GP";"M";19;"U";"GT3";"T";1;2;"other";"services";"course";"other";1;2;1;"no";"no";"no";"no";"no";"yes";"yes";"no";4;5;2;2;2;4;3;"13";"11";11 315 | "GP";"F";19;"U";"LE3";"T";3;2;"services";"other";"reputation";"other";2;2;1;"no";"yes";"yes";"no";"no";"yes";"yes";"yes";4;2;2;1;2;1;22;"13";"10";11 316 | "GP";"F";19;"U";"GT3";"T";1;1;"at_home";"health";"home";"other";1;3;2;"no";"no";"no";"no";"no";"yes";"yes";"yes";4;1;2;1;1;3;14;"15";"13";13 317 | "GP";"F";19;"R";"GT3";"T";2;3;"other";"other";"reputation";"other";1;3;1;"no";"no";"no";"no";"yes";"yes";"yes";"yes";4;1;2;1;1;3;40;"13";"11";11 318 | "GP";"F";18;"U";"GT3";"T";2;1;"services";"other";"course";"mother";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;3;3;1;2;1;0;"8";"8";0 319 | "GP";"F";18;"U";"GT3";"T";4;3;"other";"other";"course";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;3;4;1;1;5;9;"9";"10";9 320 | "GP";"F";17;"R";"GT3";"T";3;4;"at_home";"services";"course";"father";1;3;0;"no";"yes";"yes";"yes";"no";"yes";"yes";"no";4;3;4;2;5;5;0;"11";"11";10 321 | "GP";"F";18;"U";"GT3";"T";4;4;"teacher";"other";"course";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;4;4;3;3;5;2;"11";"11";11 322 | "GP";"F";17;"U";"GT3";"A";4;3;"services";"services";"course";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";5;2;2;1;2;5;23;"13";"13";13 323 | "GP";"F";17;"U";"GT3";"T";2;2;"other";"other";"course";"mother";1;2;0;"no";"yes";"no";"no";"yes";"yes";"no";"yes";4;2;2;1;1;3;12;"11";"9";9 324 | "GP";"F";17;"R";"LE3";"T";2;2;"services";"services";"course";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";3;3;2;2;2;3;3;"11";"11";11 325 | "GP";"F";17;"U";"GT3";"T";3;1;"services";"services";"course";"father";1;3;0;"no";"yes";"no";"no";"no";"yes";"yes";"no";3;4;3;2;3;5;1;"12";"14";15 326 | "GP";"F";17;"U";"LE3";"T";0;2;"at_home";"at_home";"home";"father";2;3;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";3;3;3;2;3;2;0;"16";"15";15 327 | "GP";"M";18;"U";"GT3";"T";4;4;"other";"other";"course";"mother";1;3;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";4;3;3;2;2;3;3;"9";"12";11 328 | "GP";"M";17;"U";"GT3";"T";3;3;"other";"services";"reputation";"mother";1;1;0;"no";"no";"no";"yes";"no";"yes";"yes";"no";4;3;5;3;5;5;3;"14";"15";16 329 | "GP";"M";17;"R";"GT3";"T";2;2;"services";"other";"course";"mother";4;1;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";4;4;5;5;5;4;8;"11";"10";10 330 | "GP";"F";17;"U";"GT3";"T";4;4;"teacher";"services";"course";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";5;4;4;1;3;4;7;"10";"9";9 331 | "GP";"F";17;"U";"GT3";"T";4;4;"teacher";"teacher";"course";"mother";2;3;0;"no";"yes";"yes";"no";"no";"yes";"yes";"yes";4;3;3;1;2;4;4;"14";"14";14 332 | "GP";"M";18;"U";"LE3";"T";2;2;"other";"other";"course";"mother";1;4;0;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;5;5;2;4;5;2;"9";"8";8 333 | "GP";"F";17;"R";"GT3";"T";2;4;"at_home";"other";"course";"father";1;3;0;"no";"yes";"no";"no";"yes";"yes";"yes";"yes";4;4;3;1;1;5;7;"12";"14";14 334 | "GP";"F";18;"U";"GT3";"T";3;3;"services";"services";"home";"mother";1;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";5;3;4;1;1;4;0;"7";"0";0 335 | "GP";"F";18;"U";"LE3";"T";2;2;"other";"other";"home";"other";1;2;0;"no";"no";"no";"yes";"no";"yes";"yes";"yes";4;3;3;1;1;2;0;"8";"8";0 336 | "GP";"F";18;"R";"GT3";"T";2;2;"at_home";"other";"course";"mother";2;4;0;"no";"no";"no";"yes";"yes";"yes";"no";"no";4;4;4;1;1;4;0;"10";"9";0 337 | "GP";"F";17;"U";"GT3";"T";3;4;"services";"other";"course";"mother";1;3;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";4;4;5;1;3;5;16;"16";"15";15 338 | "GP";"F";19;"R";"GT3";"A";3;1;"services";"at_home";"home";"other";1;3;1;"no";"no";"yes";"no";"yes";"yes";"no";"no";5;4;3;1;2;5;12;"14";"13";13 339 | "GP";"F";17;"U";"GT3";"T";3;2;"other";"other";"home";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";4;3;2;2;3;2;0;"7";"8";0 340 | "GP";"F";18;"U";"LE3";"T";3;3;"services";"services";"home";"mother";1;4;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";5;3;3;1;1;1;7;"16";"15";17 341 | "GP";"F";17;"R";"GT3";"A";3;2;"other";"other";"home";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;3;2;3;2;4;"9";"10";10 342 | "GP";"F";19;"U";"GT3";"T";2;1;"services";"services";"home";"other";1;3;1;"no";"no";"yes";"yes";"yes";"yes";"yes";"yes";4;3;4;1;3;3;4;"11";"12";11 343 | "GP";"M";18;"U";"GT3";"T";4;4;"teacher";"services";"home";"father";1;2;1;"no";"yes";"no";"yes";"yes";"yes";"yes";"no";4;3;3;2;2;2;0;"10";"10";0 344 | "GP";"M";18;"U";"LE3";"T";3;4;"services";"other";"home";"mother";1;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"yes";4;3;3;1;3;5;11;"16";"15";15 345 | "GP";"F";17;"U";"GT3";"A";2;2;"at_home";"at_home";"home";"father";1;2;1;"no";"yes";"no";"no";"yes";"yes";"yes";"yes";3;3;1;1;2;4;0;"9";"8";0 346 | "GP";"F";18;"U";"GT3";"T";2;3;"at_home";"other";"course";"mother";1;3;0;"no";"yes";"no";"no";"yes";"yes";"yes";"no";4;3;3;1;2;3;4;"11";"10";10 347 | "GP";"F";18;"U";"GT3";"T";3;2;"other";"services";"other";"mother";1;3;0;"no";"no";"no";"no";"yes";"yes";"yes";"yes";5;4;3;2;3;1;7;"13";"13";14 348 | "GP";"M";18;"R";"GT3";"T";4;3;"teacher";"services";"course";"mother";1;3;0;"no";"no";"no";"no";"yes";"yes";"yes";"yes";5;3;2;1;2;4;9;"16";"15";16 349 | "GP";"M";18;"U";"GT3";"T";4;3;"teacher";"other";"course";"mother";1;3;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";5;4;5;2;3;5;0;"10";"10";9 350 | "GP";"F";17;"U";"GT3";"T";4;3;"health";"other";"reputation";"mother";1;3;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;4;3;1;3;4;0;"13";"15";15 351 | "MS";"M";18;"R";"GT3";"T";3;2;"other";"other";"course";"mother";2;1;1;"no";"yes";"no";"no";"no";"yes";"yes";"no";2;5;5;5;5;5;10;"11";"13";13 352 | "MS";"M";19;"R";"GT3";"T";1;1;"other";"services";"home";"other";3;2;3;"no";"no";"no";"no";"yes";"yes";"yes";"no";5;4;4;3;3;2;8;"8";"7";8 353 | "MS";"M";17;"U";"GT3";"T";3;3;"health";"other";"course";"mother";2;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;5;4;2;3;3;2;"13";"13";13 354 | "MS";"M";18;"U";"LE3";"T";1;3;"at_home";"services";"course";"mother";1;1;1;"no";"no";"no";"no";"yes";"no";"yes";"yes";4;3;3;2;3;3;7;"8";"7";8 355 | "MS";"M";19;"R";"GT3";"T";1;1;"other";"other";"home";"other";3;1;1;"no";"yes";"no";"no";"yes";"yes";"yes";"no";4;4;4;3;3;5;4;"8";"8";8 356 | "MS";"M";17;"R";"GT3";"T";4;3;"services";"other";"home";"mother";2;2;0;"no";"yes";"yes";"yes";"no";"yes";"yes";"yes";4;5;5;1;3;2;4;"13";"11";11 357 | "MS";"F";18;"U";"GT3";"T";3;3;"services";"services";"course";"father";1;2;0;"no";"yes";"no";"no";"yes";"yes";"no";"yes";5;3;4;1;1;5;0;"10";"9";9 358 | "MS";"F";17;"R";"GT3";"T";4;4;"teacher";"services";"other";"father";2;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"no";4;3;3;1;2;5;4;"12";"13";13 359 | "MS";"F";17;"U";"LE3";"A";3;2;"services";"other";"reputation";"mother";2;2;0;"no";"no";"no";"no";"yes";"yes";"no";"yes";1;2;3;1;2;5;2;"12";"12";11 360 | "MS";"M";18;"U";"LE3";"T";1;1;"other";"services";"home";"father";2;1;0;"no";"no";"no";"no";"no";"yes";"yes";"yes";3;3;2;1;2;3;4;"10";"10";10 361 | "MS";"F";18;"U";"LE3";"T";1;1;"at_home";"services";"course";"father";2;3;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";5;3;2;1;1;4;0;"18";"16";16 362 | "MS";"F";18;"R";"LE3";"A";1;4;"at_home";"other";"course";"mother";3;2;0;"no";"no";"no";"no";"yes";"yes";"no";"yes";4;3;4;1;4;5;0;"13";"13";13 363 | "MS";"M";18;"R";"LE3";"T";1;1;"at_home";"other";"other";"mother";2;2;1;"no";"no";"no";"yes";"no";"no";"no";"no";4;4;3;2;3;5;2;"13";"12";12 364 | "MS";"F";18;"U";"GT3";"T";3;3;"services";"services";"other";"mother";2;2;0;"no";"yes";"no";"no";"yes";"yes";"yes";"yes";4;3;2;1;3;3;0;"11";"11";10 365 | "MS";"F";17;"U";"LE3";"T";4;4;"at_home";"at_home";"course";"mother";1;2;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";2;3;4;1;1;1;0;"16";"15";15 366 | "MS";"F";17;"R";"GT3";"T";1;2;"other";"services";"course";"father";2;2;0;"no";"no";"no";"no";"no";"yes";"no";"no";3;2;2;1;2;3;0;"12";"11";12 367 | "MS";"M";18;"R";"GT3";"T";1;3;"at_home";"other";"course";"mother";2;2;0;"no";"yes";"yes";"no";"yes";"yes";"no";"no";3;3;4;2;4;3;4;"10";"10";10 368 | "MS";"M";18;"U";"LE3";"T";4;4;"teacher";"services";"other";"mother";2;3;0;"no";"no";"yes";"no";"yes";"yes";"yes";"yes";4;2;2;2;2;5;0;"13";"13";13 369 | "MS";"F";17;"R";"GT3";"T";1;1;"other";"services";"reputation";"mother";3;1;1;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";5;2;1;1;2;1;0;"7";"6";0 370 | "MS";"F";18;"U";"GT3";"T";2;3;"at_home";"services";"course";"father";2;1;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"yes";5;2;3;1;2;4;0;"11";"10";10 371 | "MS";"F";18;"R";"GT3";"T";4;4;"other";"teacher";"other";"father";3;2;0;"no";"yes";"yes";"no";"no";"yes";"yes";"yes";3;2;2;4;2;5;10;"14";"12";11 372 | "MS";"F";19;"U";"LE3";"T";3;2;"services";"services";"home";"other";2;2;2;"no";"no";"no";"yes";"yes";"yes";"no";"yes";3;2;2;1;1;3;4;"7";"7";9 373 | "MS";"M";18;"R";"LE3";"T";1;2;"at_home";"services";"other";"father";3;1;0;"no";"yes";"yes";"yes";"yes";"no";"yes";"yes";4;3;3;2;3;3;3;"14";"12";12 374 | "MS";"F";17;"U";"GT3";"T";2;2;"other";"at_home";"home";"mother";1;3;0;"no";"no";"no";"yes";"yes";"yes";"no";"yes";3;4;3;1;1;3;8;"13";"11";11 375 | "MS";"F";17;"R";"GT3";"T";1;2;"other";"other";"course";"mother";1;1;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";3;5;5;1;3;1;14;"6";"5";5 376 | "MS";"F";18;"R";"LE3";"T";4;4;"other";"other";"reputation";"mother";2;3;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";5;4;4;1;1;1;0;"19";"18";19 377 | "MS";"F";18;"R";"GT3";"T";1;1;"other";"other";"home";"mother";4;3;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";4;3;2;1;2;4;2;"8";"8";10 378 | "MS";"F";20;"U";"GT3";"T";4;2;"health";"other";"course";"other";2;3;2;"no";"yes";"yes";"no";"no";"yes";"yes";"yes";5;4;3;1;1;3;4;"15";"14";15 379 | "MS";"F";18;"R";"LE3";"T";4;4;"teacher";"services";"course";"mother";1;2;0;"no";"no";"yes";"yes";"yes";"yes";"yes";"no";5;4;3;3;4;2;4;"8";"9";10 380 | "MS";"F";18;"U";"GT3";"T";3;3;"other";"other";"home";"mother";1;2;0;"no";"no";"yes";"no";"yes";"yes";"yes";"yes";4;1;3;1;2;1;0;"15";"15";15 381 | "MS";"F";17;"R";"GT3";"T";3;1;"at_home";"other";"reputation";"mother";1;2;0;"no";"yes";"yes";"yes";"no";"yes";"yes";"no";4;5;4;2;3;1;17;"10";"10";10 382 | "MS";"M";18;"U";"GT3";"T";4;4;"teacher";"teacher";"home";"father";1;2;0;"no";"no";"yes";"yes";"no";"yes";"yes";"no";3;2;4;1;4;2;4;"15";"14";14 383 | "MS";"M";18;"R";"GT3";"T";2;1;"other";"other";"other";"mother";2;1;0;"no";"no";"no";"yes";"no";"yes";"yes";"yes";4;4;3;1;3;5;5;"7";"6";7 384 | "MS";"M";17;"U";"GT3";"T";2;3;"other";"services";"home";"father";2;2;0;"no";"no";"no";"yes";"yes";"yes";"yes";"no";4;4;3;1;1;3;2;"11";"11";10 385 | "MS";"M";19;"R";"GT3";"T";1;1;"other";"services";"other";"mother";2;1;1;"no";"no";"no";"no";"yes";"yes";"no";"no";4;3;2;1;3;5;0;"6";"5";0 386 | "MS";"M";18;"R";"GT3";"T";4;2;"other";"other";"home";"father";2;1;1;"no";"no";"yes";"no";"yes";"yes";"no";"no";5;4;3;4;3;3;14;"6";"5";5 387 | "MS";"F";18;"R";"GT3";"T";2;2;"at_home";"other";"other";"mother";2;3;0;"no";"no";"yes";"no";"yes";"yes";"no";"no";5;3;3;1;3;4;2;"10";"9";10 388 | "MS";"F";18;"R";"GT3";"T";4;4;"teacher";"at_home";"reputation";"mother";3;1;0;"no";"yes";"yes";"yes";"yes";"yes";"yes";"yes";4;4;3;2;2;5;7;"6";"5";6 389 | "MS";"F";19;"R";"GT3";"T";2;3;"services";"other";"course";"mother";1;3;1;"no";"no";"no";"yes";"no";"yes";"yes";"no";5;4;2;1;2;5;0;"7";"5";0 390 | "MS";"F";18;"U";"LE3";"T";3;1;"teacher";"services";"course";"mother";1;2;0;"no";"yes";"yes";"no";"yes";"yes";"yes";"no";4;3;4;1;1;1;0;"7";"9";8 391 | "MS";"F";18;"U";"GT3";"T";1;1;"other";"other";"course";"mother";2;2;1;"no";"no";"no";"yes";"yes";"yes";"no";"no";1;1;1;1;1;5;0;"6";"5";0 392 | "MS";"M";20;"U";"LE3";"A";2;2;"services";"services";"course";"other";1;2;2;"no";"yes";"yes";"no";"yes";"yes";"no";"no";5;5;4;4;5;4;11;"9";"9";9 393 | "MS";"M";17;"U";"LE3";"T";3;1;"services";"services";"course";"mother";2;1;0;"no";"no";"no";"no";"no";"yes";"yes";"no";2;4;5;3;4;2;3;"14";"16";16 394 | "MS";"M";21;"R";"GT3";"T";1;1;"other";"other";"course";"other";1;1;3;"no";"no";"no";"no";"no";"yes";"no";"no";5;5;3;3;3;3;3;"10";"8";7 395 | "MS";"M";18;"R";"LE3";"T";3;2;"services";"other";"course";"mother";3;1;0;"no";"no";"no";"no";"no";"yes";"yes";"no";4;4;1;3;4;5;0;"11";"12";10 396 | "MS";"M";19;"U";"LE3";"T";1;1;"other";"at_home";"course";"father";1;1;0;"no";"no";"no";"no";"yes";"yes";"yes";"no";3;2;3;3;3;5;5;"8";"9";9 397 | -------------------------------------------------------------------------------- /examples/CMH_test_example.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Using Audit-AI for Testing Adverse Impact over Time: An Introduction to Cochran-Mantel-Haenzel and Breslow-Day Statistics\n", 8 | "### Contributing data scientists: Anne Thissen-Roe & Lewis Baker\n", 9 | "\n", 10 | "The Audit-AI package provides developers with simple functions that can be used to test for adverse impact in an algorithm. Many of the original functions in this repo were for testing single instances of an algorithm. In application, however, it is often necessary to test for historic trends in a decision-making pipeline. This pipeline could be any number of decisions: the college admissions rate of men vs women over the past decade, the engine failure rate of six major car manufacturers for the past 18 months, or even the hourly number of ad clicks over time in two website formats during AB testing.\n", 11 | "\n", 12 | "Here we illustrate the pairwise special case of the Cochran-Mantel-Haenzel test. The CMH test is a generalization of the McNemar Chi-Squared test of Homogenaity. Whereas the McNemar test examines differences over two intervals (usually before and after), the CMH test examines differences over any number of _k_ instances.\n", 13 | "\n", 14 | "\n", 15 | "### A Note on Significance Testing for Adverse Impact\n", 16 | "\n", 17 | "Basic researchers often use the CMH test to identify significant differences between groups caused by an experimental condition. In this case, statisticians recommend the application of Yates' correction for continuity. This acts to reduce the test statistics and increase the p-value of tests to correct for false discovery rate at moderately large samples (recommended by some experts to include N's of tens to hundreds of datapoints). Yate's correction is a conservative approach in experimental settings, but NOT in adverse impact monitoring; such an approach would systematically allow marginal cases of bias to go undetected.\n", 18 | "\n", 19 | "Users are also advised to take practical significance into account. Statistical significance may be achieved at sufficiently large sample sizes despite trivially small effect sizes. Users should consult the industry, academic and regulatory norms for practical significance in their use-case.\n", 20 | "\n", 21 | "### Example: McDonald and Siebenaller (1989) \n", 22 | "\n", 23 | "Example taken from the Handbook of Biological Statistics by John H. McDonald (http://www.biostathandbook.com/cmh.html). From the text:\n", 24 | "\n", 25 | " \"McDonald and Siebenaller (1989) surveyed allele frequencies at the Lap\n", 26 | " locus in the mussel Mytilus trossulus on the Oregon coast. At four\n", 27 | " estuaries, we collected mussels from inside the estuary and from a marine\n", 28 | " habitat outside the estuary. There were three common alleles and a couple\n", 29 | " of rare alleles; based on previous results, the biologically interesting\n", 30 | " question was whether the Lap94 allele was less common inside estuaries,\n", 31 | " so we pooled all the other alleles into a \"non-94\" class.\"\n", 32 | "\n", 33 | " \"There are three nominal variables: allele (94 or non-94), habitat\n", 34 | " (marine or estuarine), and area (Tillamook, Yaquina, Alsea, or Umpqua).\n", 35 | " The null hypothesis is that at each area, there is no difference in the\n", 36 | " proportion of Lap94 alleles between the marine and estuarine habitats.\"" 37 | ] 38 | }, 39 | { 40 | "cell_type": "code", 41 | "execution_count": 1, 42 | "metadata": {}, 43 | "outputs": [], 44 | "source": [ 45 | "import numpy as np\n", 46 | "import pandas as pd\n", 47 | "import auditai" 48 | ] 49 | }, 50 | { 51 | "cell_type": "code", 52 | "execution_count": 2, 53 | "metadata": {}, 54 | "outputs": [ 55 | { 56 | "data": { 57 | "text/html": [ 58 | "
\n", 59 | "\n", 72 | "\n", 73 | " \n", 74 | " \n", 75 | " \n", 76 | " \n", 77 | " \n", 78 | " \n", 79 | " \n", 80 | " \n", 81 | " \n", 82 | " \n", 83 | " \n", 84 | " \n", 85 | " \n", 86 | " \n", 87 | " \n", 88 | " \n", 89 | " \n", 90 | " \n", 91 | " \n", 92 | "
MarineEstuarine
945669
non-944077
\n", 93 | "
" 94 | ], 95 | "text/plain": [ 96 | " Marine Estuarine\n", 97 | "94 56 69\n", 98 | "non-94 40 77" 99 | ] 100 | }, 101 | "execution_count": 2, 102 | "metadata": {}, 103 | "output_type": "execute_result" 104 | } 105 | ], 106 | "source": [ 107 | "tillamook = pd.DataFrame({'Marine':[56,40],'Estuarine':[69,77]}, index=['94','non-94'])\n", 108 | "tillamook" 109 | ] 110 | }, 111 | { 112 | "cell_type": "code", 113 | "execution_count": 3, 114 | "metadata": {}, 115 | "outputs": [ 116 | { 117 | "data": { 118 | "text/plain": [ 119 | "[ Marine Estuarine\n", 120 | " 94 56 69\n", 121 | " non-94 40 77, Marine Estuarine\n", 122 | " 94 61 257\n", 123 | " non-94 57 301, Marine Estuarine\n", 124 | " 94 73 65\n", 125 | " non-94 71 79, Marine Estuarine\n", 126 | " 94 71 48\n", 127 | " non-94 55 48]" 128 | ] 129 | }, 130 | "execution_count": 3, 131 | "metadata": {}, 132 | "output_type": "execute_result" 133 | } 134 | ], 135 | "source": [ 136 | "yaquina = pd.DataFrame({'Marine':[61,57],'Estuarine':[257,301]}, index=['94','non-94'])\n", 137 | "alsea = pd.DataFrame({'Marine':[73,71],'Estuarine':[65,79]}, index=['94','non-94'])\n", 138 | "umpqua = pd.DataFrame({'Marine':[71,55],'Estuarine':[48,48]}, index=['94','non-94'])\n", 139 | "dfs = [tillamook,yaquina,alsea,umpqua]\n", 140 | "\n", 141 | "dfs" 142 | ] 143 | }, 144 | { 145 | "cell_type": "code", 146 | "execution_count": 4, 147 | "metadata": {}, 148 | "outputs": [], 149 | "source": [ 150 | "# import the CMH test from auditai\n", 151 | "from auditai.stats import test_cmh_bd" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": 5, 157 | "metadata": {}, 158 | "outputs": [ 159 | { 160 | "name": "stdout", 161 | "output_type": "stream", 162 | "text": [ 163 | "common odds ratio: 0.7590219991052517\n", 164 | "CMH Chi-Squared Statistic: 5.320927767938459\n", 165 | "CMH p-value: 0.02107078993834932\n", 166 | "Breslow-Day Chi-Squared Statistic: 0.5294859090315414\n", 167 | "Breslow-Day p-value: 0.9123673420971034\n" 168 | ] 169 | } 170 | ], 171 | "source": [ 172 | "# pass_col is reduntant here, as either case could be considered \"passing\" a priori\n", 173 | "# setting `pass_col` to False compares collumns in order (Estuarine compared to Marine)\n", 174 | "# Note that all statistics are identical, except `r`, the common odds ratio, \n", 175 | "# which will be inverted\n", 176 | "r, cmh, pcmh, bd, pbd = test_cmh_bd(dfs=dfs, pass_col=False) \n", 177 | "print (\"common odds ratio: {}\".format(r))\n", 178 | "print (\"CMH Chi-Squared Statistic: {}\".format(cmh))\n", 179 | "print (\"CMH p-value: {}\".format(pcmh))\n", 180 | "print (\"Breslow-Day Chi-Squared Statistic: {}\".format(bd))\n", 181 | "print (\"Breslow-Day p-value: {}\".format(pbd))\n" 182 | ] 183 | }, 184 | { 185 | "cell_type": "code", 186 | "execution_count": 6, 187 | "metadata": {}, 188 | "outputs": [ 189 | { 190 | "data": { 191 | "text/plain": [ 192 | "(0.7590219991052517,\n", 193 | " 5.320927767938459,\n", 194 | " 0.02107078993834932,\n", 195 | " 0.5294859090315414,\n", 196 | " 0.9123673420971034)" 197 | ] 198 | }, 199 | "execution_count": 6, 200 | "metadata": {}, 201 | "output_type": "execute_result" 202 | } 203 | ], 204 | "source": [ 205 | "test_cmh_bd(dfs=dfs, pass_col='Estuarine')" 206 | ] 207 | }, 208 | { 209 | "cell_type": "markdown", 210 | "metadata": {}, 211 | "source": [ 212 | "# Explaining the Functions\n", 213 | "\n", 214 | "The `test_cmh_bd` function is a wrapper for three statistical analyses.\n", 215 | "\n", 216 | "\n", 217 | "### Odds ratio\n", 218 | "The `multi_odds_ratio` function computes the common odds ratio from average of classifications across multiple samples. The odds ratio of the total _will_ diverge from the odds ratio at each sampling interval." 219 | ] 220 | }, 221 | { 222 | "cell_type": "code", 223 | "execution_count": 7, 224 | "metadata": {}, 225 | "outputs": [ 226 | { 227 | "name": "stdout", 228 | "output_type": "stream", 229 | "text": [ 230 | "odds ratio at each sampling interval\n", 231 | "0.640074211502783\n", 232 | "0.7978323620717825\n", 233 | "0.8002427605340732\n", 234 | "0.7746478873239436\n", 235 | "\n", 236 | "odds ratio for the total sample\n", 237 | "0.7590219991052517\n" 238 | ] 239 | } 240 | ], 241 | "source": [ 242 | "from auditai.stats import multi_odds_ratio\n", 243 | "\n", 244 | "print ('odds ratio at each sampling interval')\n", 245 | "for df in dfs:\n", 246 | " print (multi_odds_ratio(df))\n", 247 | "\n", 248 | "print ('\\nodds ratio for the total sample')\n", 249 | "print (multi_odds_ratio(dfs))" 250 | ] 251 | }, 252 | { 253 | "cell_type": "markdown", 254 | "metadata": {}, 255 | "source": [ 256 | "Interested parties will note that the common odds ratio of the total is not just the harmonic mean of the sample odds ratios." 257 | ] 258 | }, 259 | { 260 | "cell_type": "code", 261 | "execution_count": 8, 262 | "metadata": {}, 263 | "outputs": [ 264 | { 265 | "data": { 266 | "text/plain": [ 267 | "1.3390612173699998" 268 | ] 269 | }, 270 | "execution_count": 8, 271 | "metadata": {}, 272 | "output_type": "execute_result" 273 | } 274 | ], 275 | "source": [ 276 | "np.mean([1.56231884058,1.25339613626,1.24962080173,1.29090909091])" 277 | ] 278 | }, 279 | { 280 | "cell_type": "markdown", 281 | "metadata": {}, 282 | "source": [ 283 | "Nor is the total common odds ratio just the odds ratio of the sum of all sample measurements." 284 | ] 285 | }, 286 | { 287 | "cell_type": "code", 288 | "execution_count": 9, 289 | "metadata": {}, 290 | "outputs": [ 291 | { 292 | "name": "stdout", 293 | "output_type": "stream", 294 | "text": [ 295 | "0.742741170668791\n" 296 | ] 297 | } 298 | ], 299 | "source": [ 300 | "dfTOT = dfs[0] + dfs[1] + dfs[2] + dfs[3]\n", 301 | "print (multi_odds_ratio(dfTOT))" 302 | ] 303 | }, 304 | { 305 | "cell_type": "markdown", 306 | "metadata": {}, 307 | "source": [ 308 | "Rather, the total odds ratio is an approximation of the true, unknown odds ratio, taken from the ratio of \"pass\" and \"fail\" observations of both categories. Note that this method works even in the current case, where there is not a true pass or fail category. " 309 | ] 310 | }, 311 | { 312 | "cell_type": "code", 313 | "execution_count": 10, 314 | "metadata": {}, 315 | "outputs": [], 316 | "source": [ 317 | "from auditai.utils.cmh import parse_matrix, extract_data\n", 318 | "from functools import partial" 319 | ] 320 | }, 321 | { 322 | "cell_type": "code", 323 | "execution_count": 11, 324 | "metadata": {}, 325 | "outputs": [ 326 | { 327 | "name": "stdout", 328 | "output_type": "stream", 329 | "text": [ 330 | "60.99127446832867 80.35508132863902\n", 331 | "0.7590219991052517\n" 332 | ] 333 | } 334 | ], 335 | "source": [ 336 | "r_num = []\n", 337 | "r_den = []\n", 338 | "for df in dfs:\n", 339 | " pass0, fail0, pass1, fail1, total = parse_matrix(df)\n", 340 | " r_num.append((pass0*fail1)/(total))\n", 341 | " r_den.append((fail0*pass1)/(total))\n", 342 | "print (sum(r_num), sum(r_den))\n", 343 | "print (float(sum(r_num))/float(sum(r_den)))" 344 | ] 345 | }, 346 | { 347 | "cell_type": "markdown", 348 | "metadata": {}, 349 | "source": [ 350 | "### CMH Test\n", 351 | "The `cmh_test` function computes the Cochran-Mantel-Haenszel chi-squared statistic and corresponding p-value. The CMH test statistic grows as the observed values from one group deviates from the pooled expected value of both groups.\n", 352 | "\n", 353 | "From McDonald http://www.biostathandbook.com/cmh.html :\n", 354 | "\n", 355 | " \"The numerator contains the absolute value of the difference between the observed value in one cell (a) and the expected value under the null hypothesis, (a+b)(a+c)/n, so the numerator is the squared sum of deviations between the observed and expected values. It doesn't matter how you arrange the 2×2 tables, any of the four values can be used as a. You subtract the 0.5 as a continuity correction. The denominator contains an estimate of the variance of the squared differences.\"\n", 356 | "\n", 357 | " \"The test statistic, χ2MH, gets bigger as the differences between the observed and expected values get larger, or as the variance gets smaller (primarily due to the sample size getting bigger). It is chi-square distributed with one degree of freedom.\"" 358 | ] 359 | }, 360 | { 361 | "cell_type": "code", 362 | "execution_count": 12, 363 | "metadata": {}, 364 | "outputs": [ 365 | { 366 | "data": { 367 | "text/plain": [ 368 | "(5.320927767938459, 0.02107078993834932)" 369 | ] 370 | }, 371 | "execution_count": 12, 372 | "metadata": {}, 373 | "output_type": "execute_result" 374 | } 375 | ], 376 | "source": [ 377 | "from auditai.stats import cmh_test\n", 378 | "cmh_test(dfs)" 379 | ] 380 | }, 381 | { 382 | "cell_type": "markdown", 383 | "metadata": {}, 384 | "source": [ 385 | "The CMH test statistic is sensitive to sample size at each interval" 386 | ] 387 | }, 388 | { 389 | "cell_type": "code", 390 | "execution_count": 13, 391 | "metadata": {}, 392 | "outputs": [], 393 | "source": [ 394 | "from copy import deepcopy" 395 | ] 396 | }, 397 | { 398 | "cell_type": "code", 399 | "execution_count": 14, 400 | "metadata": {}, 401 | "outputs": [], 402 | "source": [ 403 | "dfs2 = deepcopy(dfs)\n", 404 | "dfs2[0] = dfs[0] * 10" 405 | ] 406 | }, 407 | { 408 | "cell_type": "code", 409 | "execution_count": 15, 410 | "metadata": {}, 411 | "outputs": [ 412 | { 413 | "data": { 414 | "text/plain": [ 415 | "(5.320927767938459, 0.02107078993834932)" 416 | ] 417 | }, 418 | "execution_count": 15, 419 | "metadata": {}, 420 | "output_type": "execute_result" 421 | } 422 | ], 423 | "source": [ 424 | "cmh_test(dfs)" 425 | ] 426 | }, 427 | { 428 | "cell_type": "code", 429 | "execution_count": 16, 430 | "metadata": {}, 431 | "outputs": [ 432 | { 433 | "data": { 434 | "text/plain": [ 435 | "(29.61407107326871, 5.2720829701868865e-08)" 436 | ] 437 | }, 438 | "execution_count": 16, 439 | "metadata": {}, 440 | "output_type": "execute_result" 441 | } 442 | ], 443 | "source": [ 444 | "cmh_test(dfs2)" 445 | ] 446 | }, 447 | { 448 | "cell_type": "markdown", 449 | "metadata": {}, 450 | "source": [ 451 | "...and is primarially suited to identify trends over time, which are magnified at large sample sizes." 452 | ] 453 | }, 454 | { 455 | "cell_type": "code", 456 | "execution_count": 17, 457 | "metadata": {}, 458 | "outputs": [ 459 | { 460 | "data": { 461 | "text/plain": [ 462 | "[ Marine Estuarine\n", 463 | " 94 560 690\n", 464 | " non-94 400 770, Marine Estuarine\n", 465 | " 94 610 2570\n", 466 | " non-94 570 3010, Marine Estuarine\n", 467 | " 94 730 650\n", 468 | " non-94 710 790, Marine Estuarine\n", 469 | " 94 710 480\n", 470 | " non-94 550 480]" 471 | ] 472 | }, 473 | "execution_count": 17, 474 | "metadata": {}, 475 | "output_type": "execute_result" 476 | } 477 | ], 478 | "source": [ 479 | "dfs2 = deepcopy(dfs)\n", 480 | "for i,d in enumerate(dfs2):\n", 481 | " dfs2[i] = d * 10\n", 482 | "dfs2" 483 | ] 484 | }, 485 | { 486 | "cell_type": "code", 487 | "execution_count": 18, 488 | "metadata": {}, 489 | "outputs": [ 490 | { 491 | "data": { 492 | "text/plain": [ 493 | "(53.359182839685964, 2.7777780076121417e-13)" 494 | ] 495 | }, 496 | "execution_count": 18, 497 | "metadata": {}, 498 | "output_type": "execute_result" 499 | } 500 | ], 501 | "source": [ 502 | "cmh_test(dfs2)" 503 | ] 504 | }, 505 | { 506 | "cell_type": "markdown", 507 | "metadata": {}, 508 | "source": [ 509 | "### Breslow-Day Test\n", 510 | "The `bres_day` function computes the Breslow-Day test of homogeneous association for a 2 x 2 x k table. For example, given three factors, A, B, and C, the Breslow-Day test would measure wheher pairwise effects (AB, AC, BC) have identical odds ratios.\n", 511 | "\n", 512 | "Here, we see that the original set of sample data, `dfs`, have relatively consistent directionality of odds ratios of differences between regions and alelle groups. A modified set of data, where the odds ratio of one of the groups is greater and in the opposite direction than odds ratios of other samples." 513 | ] 514 | }, 515 | { 516 | "cell_type": "code", 517 | "execution_count": 19, 518 | "metadata": {}, 519 | "outputs": [], 520 | "source": [ 521 | "dfs2 = deepcopy(dfs)\n", 522 | "dfs2[0].iloc[0,0] = dfs2[0].iloc[0,0] * 10" 523 | ] 524 | }, 525 | { 526 | "cell_type": "code", 527 | "execution_count": 20, 528 | "metadata": {}, 529 | "outputs": [ 530 | { 531 | "name": "stdout", 532 | "output_type": "stream", 533 | "text": [ 534 | "0.5294859090315414 142.76157303853344\n" 535 | ] 536 | } 537 | ], 538 | "source": [ 539 | "from auditai.stats import bres_day\n", 540 | "r = multi_odds_ratio(dfs)\n", 541 | "part_bd = partial(bres_day, r=r)\n", 542 | "# sum of Breslow-Day chi-square statistics\n", 543 | "bd = pd.DataFrame(map(part_bd, dfs))[0].sum()\n", 544 | "\n", 545 | "r2 = multi_odds_ratio(dfs2)\n", 546 | "part_bd2 = partial(bres_day, r=r2)\n", 547 | "bd2 = pd.DataFrame(map(part_bd, dfs2))[0].sum()\n", 548 | "\n", 549 | "print(bd,bd2)" 550 | ] 551 | }, 552 | { 553 | "cell_type": "code", 554 | "execution_count": 21, 555 | "metadata": {}, 556 | "outputs": [ 557 | { 558 | "name": "stdout", 559 | "output_type": "stream", 560 | "text": [ 561 | "0.06400742115027828 78.79661720061715 0.0\n", 562 | "0.7978323620717825 8.565769483918876 0.003425421688411645\n", 563 | "0.8002427605340732 6.319607038889988 0.01194100883813387\n", 564 | "0.7746478873239436 4.2598761264502505 0.039022766893019756\n" 565 | ] 566 | } 567 | ], 568 | "source": [ 569 | "r2 = multi_odds_ratio(dfs2)\n", 570 | "for df in dfs2:\n", 571 | " r = multi_odds_ratio(df)\n", 572 | " bd, pbd = bres_day(df,r2)\n", 573 | " print (r,bd,pbd)\n", 574 | " " 575 | ] 576 | } 577 | ], 578 | "metadata": { 579 | "kernelspec": { 580 | "display_name": "auditai", 581 | "language": "python", 582 | "name": "auditai" 583 | }, 584 | "language_info": { 585 | "codemirror_mode": { 586 | "name": "ipython", 587 | "version": 3 588 | }, 589 | "file_extension": ".py", 590 | "mimetype": "text/x-python", 591 | "name": "python", 592 | "nbconvert_exporter": "python", 593 | "pygments_lexer": "ipython3", 594 | "version": "3.7.3" 595 | } 596 | }, 597 | "nbformat": 4, 598 | "nbformat_minor": 2 599 | } 600 | -------------------------------------------------------------------------------- /examples/implementation_suggestions.md: -------------------------------------------------------------------------------- 1 | # audit-AI: How we use it and what it does 2 | ### pymetrics inc. 3 | ### 16 January 2020 4 | 5 |

The goal of the below document is to explain one way in which audit-AI can be used to de-bias a machine learning model. While this is far from the only way in which the code can be used, we hope it gives you a sense of this tool’s potential.

6 | 7 | #### Background: pymetrics models 8 |

Pymetrics models are built for specific roles within specific companies. To achieve this customization, we collect data from top-performing incumbents in the target role. We then compare incumbents to a baseline sample of the over 1 million candidates who have applied to jobs through pymetrics. We also establish a special data set, which we call the debias set, which is sampled from a pool of 150,000 individuals who have voluntarily provided basic demographic information such as sex, ethnicity or age. From there, a wide variety of algorithms might be tested to create an initial machine learning model from the training data. The process itself is model agnostic. Multiple algorithms are fit in this process, and we are continuously testing new methods that might improve performance. The goal of the algorithm is to find the features that will most accurately and reliably separate the incumbent set from the baseline set. We create hundreds of possible models with slightly different model parameters to test and compare their performance before selecting the final model.

9 | 10 | #### Pre-deployment auditing 11 |

Audit-ai first comes into play when we check the initial model for bias, primarily focusing on disparities across racial and gender groups. Definitions of “fairness” vary across contexts, but in the realm of employment, a tool used to evaluate job candidates must consistently recommend individuals across legally-protected groups, known as pass rates. Specifically, the Equal Employment Opportunity Commission’s (EEOC) Uniform Guidelines on Employee Selection Procedures mandates that pass rates for any one group must be within 80% of the highest-passing group, 80% (also known as “the 4/5th rule”). For example: if 200 people apply for a job,100 men and 100 women, an assessment tool is used that deems 50 of the men qualified for the role (a 50% pass rate) must also deem at least 40 women qualified (an 80% pass rate). The goal of this standard is to ensure that employment selection practices that appear neutral on the surface are not discreetly resulting in adverse impact.

12 | 13 | #### Pre-deployment de-biasing 14 |

In short, by using audit-AI at this point in the process, we are able to streamline the process of testing models for compliance with the EEOC’s 4/5th rule. More importantly, audit-AI provides visibility into how we can improve the fairness of our models, without sacrificing predictive power. The package allows us to identify and adjust any traits that exhibit score discrepancies across demographic groups, perhaps due to Simpson’s Paradox or a sampling anomaly. From there, we can employ a feature selection process, such as recursive feature elimination or feature regularization on a criterion of fairness, to reduce the weighting of problematic features in the local population. This continues until we can estimate no significant differences between legally-protected groups. Prior to deployment, the overall efficacy of the newly de-biased model is approximated using five-fold stratified cross-validation, with 80% of the training data being used to train the model and 20% being held out for testing, repeated and averaged over five trials so that all data both contributes to prediction and serves as a test set.

15 | 16 | #### Other standards for fairness 17 |

Depending on the precise context, it is worth noting that models may be tested for compliance with standards for fairness beyond the EEOC’s 4/5ths rule. For example, with the advent of employment selection tools that rely on automated data analysis, some U.S. courts have begun evaluating hiring assessments through the lens of statistical significance. In other words, if there are disparities in the pass rates of demographic groups, what is the likelihood that this is merely due to chance (and therefore not due to embedded systematic discrimination)? Audit-AIi is also able to streamline the process of iteratively testing models for such probabilities, reporting statistical significance calculated from z-tests, analysis of variance (ANOVA) tests, Chi-squared tests, and Fisher’s exact tests. The selection of the appropriate method to evaluate the presence of bias is typically a function of the available samples. A novel contribution of the package is the first-ever implementation of the Cochran-Mantel-Haenszel test, which the EEOC uses when testing for statistical adverse impact over time.

18 | 19 | #### Post-deployment validation 20 |

Once we deploy a model to predict the hireability of a group of new job applicants, audit-AI can again be used to validate whether the fairness of our approach holds up in the “real world.” Like in the pre-deployment phase, the goal in the post-deployment phase is to test for consistency of pass rates across legally-protected demographic groups, whether defined by measures of practical or statistical significance.

21 | 22 | #### Some key takeaways 23 |

The above process of iteratively testing for bias is informed by a few important principles about AI in hiring:

24 | 25 | 1. Bias checks should happen both in the initial process of building a model and after it has been deployed. 26 | 2. Bias can exist against any sub-group within a population, but the specific groups that must be tested will depend on how the algorithm is being used. 27 | 3. It is extremely important to have a large and diverse sample to robustly test for bias. 28 | 4. The law provides standards for selection procedures. If an algorithm leads to a disparity between protected groups (gender, race, age 40+), then it may not be used as part of the selection procedure. 29 | 5. Employers *should not use* an algorithm that is not rigorously tested for bias across these groups. -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # supports Python 2 and Python 3 3 | universal=1 4 | 5 | [metadata] 6 | # This includes the license file in the wheel. 7 | license_file = LICENSE.txt 8 | 9 | [run] 10 | omit = *viz* -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | from os import path 3 | from setuptools import setup, find_packages 4 | 5 | 6 | # Get the long description from the README file 7 | here = path.abspath(path.dirname(__file__)) 8 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 9 | long_description = f.read() 10 | 11 | # load the version 12 | exec(open('auditai/version.py').read()) 13 | 14 | setup(name='audit-AI', 15 | version=__version__, 16 | description='audit-AI detects demographic differences in the output of machine learning models or other assessments', 17 | long_description=long_description, 18 | long_description_content_type='text/markdown', 19 | keywords=[ 20 | "audit", 21 | "adverse impact", 22 | "artificial intelligence", 23 | "machine learning", 24 | "fairness", 25 | "bias", 26 | "accountability", 27 | "transparency", 28 | "discrimination", 29 | ], 30 | url='https://github.com/pymetrics/audit-ai', 31 | author='pymetrics Data Team', 32 | author_email='data@pymetrics.com', 33 | project_urls={ 34 | 'Company': 'https://www.pymetrics.com/science/', 35 | }, 36 | license='MIT', 37 | packages=find_packages(exclude=['*.tests', '*.tests.*']), 38 | install_requires=[ 39 | 'numpy', 40 | 'scipy', 41 | 'pandas', 42 | 'matplotlib', 43 | 'statsmodels', 44 | 'sklearn' 45 | ], 46 | tests_require=['pytest-cov'], 47 | extras_require={ 48 | 'dev': ['pytest-cov', 'flake8', 'detox', 'mock', 'six'] 49 | }, 50 | classifiers=[ 51 | 'Development Status :: 3 - Alpha', 52 | 'License :: OSI Approved :: MIT License', 53 | 'Programming Language :: Python :: 2', 54 | 'Programming Language :: Python :: 3', 55 | 'Intended Audience :: Developers', 56 | 'Intended Audience :: Education', 57 | 'Intended Audience :: Financial and Insurance Industry', 58 | 'Intended Audience :: Healthcare Industry', 59 | 'Intended Audience :: Legal Industry', 60 | 'Intended Audience :: Other Audience', 61 | 'Intended Audience :: Science/Research', 62 | 'Natural Language :: English', 63 | 'Topic :: Scientific/Engineering', 64 | 'Topic :: Scientific/Engineering :: Artificial Intelligence', 65 | 'Topic :: Scientific/Engineering :: Visualization', 66 | ]) 67 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py37 3 | skipsdist = true 4 | 5 | [testenv] 6 | whitelist_externals = 7 | make 8 | commands = 9 | make install-dev 10 | make tests --------------------------------------------------------------------------------