dbouquin commited on
Commit
80e69dc
·
1 Parent(s): 389aea5

initial upload of modeling pipeline and summary

Browse files
README.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - scikit-learn
5
+ - tabular
6
+ - nonprofit
7
+ - planned-giving
8
+ - classification
9
+ - snowflake
10
+ ---
11
+
12
+ # Planned Giving Propensity Model
13
+
14
+ A machine learning solution to optimize planned giving donor targeting for the National Parks Conservation Association (NPCA).
15
+
16
+ > **Note**: This model is not currently deployed or downloadable due to data privacy constraints. This repository shares the modeling approach, evaluation strategy, and relevant pipeline components for reproducibility and educational use.
17
+
18
+ ## Project Overview
19
+
20
+ This project implements a Random Forest classifier to identify potential planned giving donors, with the goal of improving mailing efficiency and response rates. The model processes donor data through Snowflake’s computing infrastructure and uses SMOTE to handle class imbalance.
21
+
22
+ ## Key Results
23
+
24
+ - **PR-AUC**: 0.88 — strong performance on imbalanced data
25
+ - **F1 Score**: 0.8125
26
+ - **Precision**: 0.7558
27
+ - **Recall**: 0.8784 — high capture rate of known planned givers
28
+ - **1,019 new high-potential donor predictions** for targeted outreach
29
+
30
+ ## Technical Implementation
31
+
32
+ ### Data Pipeline
33
+ - Donor data extracted from CRM into Snowflake
34
+ - Modular Python scripts for feature engineering and cleaning
35
+ - SMOTE oversampling to address class imbalance
36
+
37
+ ### Machine Learning
38
+ - Random Forest classifier with `scikit-learn`
39
+ - Stratified cross-validation and grid search
40
+ - Multiple imputation strategies (MICE, mean, median)
41
+ - Key temporal features (e.g., time since last gift)
42
+
43
+ 📂 [Training Script](./model/split_SMOTE_crossval.py)
44
+ 📓 [Evaluation Notebook](./model/snowflake_model_evaluation.py)
45
+
46
+ ## Model Performance Insights
47
+
48
+ Post-modeling analysis validated predictions against known donor engagement indicators:
49
+
50
+ - **66.3%** of predicted donors were already flagged as prospects by fundraisers
51
+ - **37.6%** are major donor households
52
+ - **18%** are members of the Mather Legacy Society
53
+
54
+ ### Top 5 Most Important Features
55
+ 1. Highest Previous Contribution (22.8%)
56
+ 2. Most Recent Contribution (20.1%)
57
+ 3. Years Since HPC Gift (14.6%)
58
+ 4. Total Amount (14.3%)
59
+ 5. Years Since MRC Gift (11.2%)
60
+
61
+ ### Demographics of Predicted Donors
62
+ - Average age: 69
63
+ - Giving history: 16 years (on average)
64
+ - Median total giving: $10,932
65
+ - Average number of transactions: 18
66
+
67
+ ## Tools and Technologies
68
+
69
+ - `scikit-learn`, `pandas`, `numpy`
70
+ - Snowflake
71
+ - `imbalanced-learn`, `matplotlib`, `seaborn`
72
+
73
+ ## Repository Structure
74
+
75
+ ```plaintext
76
+ ├── model/
77
+ │ ├── split_SMOTE_crossval.py # ML model executed on Snowflake
78
+ │ └── snowflake_model_evaluation.py # Model evaluation and visualization
79
+ ├── predictions_analyzed/ # Post-modeling analysis
80
+ │ ├── predictions_analyzed.ipynb # Model concurrence evaluation
81
+ ├── requirements.txt
82
+ └── README.md
83
+ ```
84
+
85
+ ## Potential Future Improvements
86
+ - Schedule automated data refresh and model retraining
87
+ - Incorporate additional feature engineering
88
+ - Develop dashboard for tracking model performance
89
+
90
+ *Note: Full project repository: [GitHub – dbouquin/bequest_modeling](https://github.com/dbouquin/bequest_modeling)*
model/snowflake_model_evaluation.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
model/split_SMOTE_crossval.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import snowflake.snowpark as snowpark
2
+ from snowflake.snowpark.functions import col
3
+ from sklearn.experimental import enable_iterative_imputer
4
+ from sklearn.impute import SimpleImputer, IterativeImputer
5
+ from imblearn.over_sampling import SMOTE
6
+ from sklearn.ensemble import RandomForestClassifier
7
+ from sklearn.metrics import classification_report, accuracy_score, precision_recall_curve, auc
8
+ from sklearn.model_selection import StratifiedKFold
9
+ import pandas as pd
10
+ import numpy as np
11
+ import json
12
+
13
+ def main(session: snowpark.Session):
14
+ # Load data from table
15
+ df = session.table("PUBLIC.BEQUESTS_CLEAN").to_pandas()
16
+
17
+ # Define imputers (only mean and mice)
18
+ imputers = {
19
+ 'mean': SimpleImputer(strategy='mean'),
20
+ 'median': SimpleImputer(strategy='median'),
21
+ 'mice': IterativeImputer(random_state=42)
22
+ }
23
+
24
+ # Store results
25
+ results_dead = []
26
+ results_alive = []
27
+ results_modeling = []
28
+
29
+ # Function to evaluate imputation method
30
+ def evaluate_imputation(df, imputer_name, imputer):
31
+ # Impute BIRTH_YEAR
32
+ df['BIRTH_YEAR'] = imputer.fit_transform(df[['BIRTH_YEAR']])
33
+
34
+ # Encode categorical variables
35
+ df = pd.get_dummies(df, columns=['REGION_CODE'], drop_first=True)
36
+
37
+ # Define features after one-hot encoding
38
+ feature_columns = [
39
+ 'TOTAL_TRANSACTIONS',
40
+ 'TOTAL_AMOUNT',
41
+ 'FIRST_GIFT_AMOUNT',
42
+ 'MRC_AMOUNT',
43
+ 'HPC_AMOUNT',
44
+ 'YEARS_SINCE_FIRST_GIFT',
45
+ 'YEARS_SINCE_MRC_GIFT',
46
+ 'YEARS_SINCE_HPC_GIFT',
47
+ 'BIRTH_YEAR'
48
+ ] + [col for col in df.columns if col.startswith('REGION_CODE_')]
49
+
50
+ # Separate dead and alive individuals
51
+ df_dead = df[df['DEATH_FLAG'] == 1]
52
+ df_alive = df[df['DEATH_FLAG'] == 0]
53
+
54
+ # Train model on dead individuals
55
+ if len(df_dead) > 0:
56
+ X_dead = df_dead[feature_columns]
57
+ y_dead = df_dead['BEQUEST_RECEIVED']
58
+ ROI_FAMILY_ID_dead = df_dead['ROI_FAMILY_ID']
59
+
60
+ # Cross-validation setup
61
+ skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
62
+ smote = SMOTE(random_state=42)
63
+ model = RandomForestClassifier(random_state=42, n_jobs=-1) # Use all available cores
64
+
65
+ # Cross-validated predictions
66
+ y_pred_dead = np.zeros(len(y_dead))
67
+ y_pred_proba_dead = np.zeros(len(y_dead))
68
+ for train_index, test_index in skf.split(X_dead, y_dead):
69
+ X_train, X_test = X_dead.iloc[train_index], X_dead.iloc[test_index]
70
+ y_train, y_test = y_dead.iloc[train_index], y_dead.iloc[test_index]
71
+
72
+ X_train_res, y_train_res = smote.fit_resample(X_train, y_train)
73
+ model.fit(X_train_res, y_train_res)
74
+ y_pred_dead[test_index] = model.predict(X_test)
75
+ y_pred_proba_dead[test_index] = model.predict_proba(X_test)[:, 1] # Probability for class 1
76
+
77
+ # Evaluation for dead individuals
78
+ accuracy_dead = accuracy_score(y_dead, y_pred_dead)
79
+ precision_dead, recall_dead, _ = precision_recall_curve(y_dead, y_pred_proba_dead)
80
+ auc_pr_dead = auc(recall_dead, precision_dead)
81
+ report_dead = classification_report(y_dead, y_pred_dead, output_dict=True)
82
+ model.fit(X_dead, y_dead)
83
+ feature_importance_dead = pd.DataFrame({
84
+ 'Feature': X_dead.columns,
85
+ 'Importance': model.feature_importances_
86
+ }).sort_values(by='Importance', ascending=False)
87
+
88
+ results_dead.append({
89
+ 'imputer': imputer_name,
90
+ 'accuracy': accuracy_dead,
91
+ 'auc_pr': auc_pr_dead,
92
+ 'report': pd.DataFrame(report_dead).transpose(),
93
+ 'feature_importance': feature_importance_dead,
94
+ 'ROI_FAMILY_ID': ROI_FAMILY_ID_dead,
95
+ 'y_true': y_dead,
96
+ 'y_pred': y_pred_dead
97
+ })
98
+
99
+ results_modeling.append({
100
+ 'imputer': imputer_name,
101
+ 'accuracy': accuracy_dead,
102
+ 'auc_pr': auc_pr_dead,
103
+ 'classification_report': json.dumps(report_dead),
104
+ 'feature_importance': feature_importance_dead.to_dict(orient='list')
105
+ })
106
+
107
+ # Predict on alive individuals
108
+ if len(df_alive) > 0:
109
+ X_alive = df_alive[feature_columns]
110
+ y_pred_alive = model.predict(X_alive)
111
+ ROI_FAMILY_ID_alive = df_alive['ROI_FAMILY_ID']
112
+
113
+ results_alive.append({
114
+ 'imputer': imputer_name,
115
+ 'ROI_FAMILY_ID': ROI_FAMILY_ID_alive,
116
+ 'y_pred': y_pred_alive
117
+ })
118
+
119
+ # Evaluate each imputation method
120
+ for imputer_name, imputer in imputers.items():
121
+ evaluate_imputation(df.copy(), imputer_name, imputer)
122
+
123
+ # Print the modeling results for dead individuals
124
+ for result in results_dead:
125
+ print(f"Imputer: {result['imputer']} (Dead)")
126
+ print("Accuracy:", result['accuracy'])
127
+ print("AUC-PR:", result['auc_pr'])
128
+ print("Classification Report:")
129
+ print(result['report'])
130
+ print("Feature Importance:")
131
+ print(result['feature_importance'])
132
+ print("\n" + "-"*50 + "\n")
133
+
134
+ # Combine all dead predictions into a single DataFrame
135
+ predictions_dead_df = pd.concat([
136
+ pd.DataFrame({
137
+ 'ROI_FAMILY_ID': result['ROI_FAMILY_ID'],
138
+ 'imputer': result['imputer'],
139
+ 'y_true': result['y_true'],
140
+ 'y_pred': result['y_pred'],
141
+ 'status': 'dead'
142
+ }) for result in results_dead
143
+ ], ignore_index=True)
144
+
145
+ # Combine all alive predictions into a single DataFrame
146
+ predictions_alive_df = pd.concat([
147
+ pd.DataFrame({
148
+ 'ROI_FAMILY_ID': result['ROI_FAMILY_ID'],
149
+ 'imputer': result['imputer'],
150
+ 'y_pred': result['y_pred'],
151
+ 'status': 'alive'
152
+ }) for result in results_alive
153
+ ], ignore_index=True)
154
+
155
+ # Write the dead predictions DataFrame to a new table
156
+ session.write_pandas(predictions_dead_df, 'BEQUEST_PREDICTIONS_DEAD', auto_create_table=True)
157
+
158
+ # Write the alive predictions DataFrame to a new table
159
+ session.write_pandas(predictions_alive_df, 'BEQUEST_PREDICTIONS_ALIVE', auto_create_table=True)
160
+
161
+ # Write the modeling results to a new table
162
+ modeling_results_df = pd.DataFrame(results_modeling)
163
+ session.write_pandas(modeling_results_df, 'BEQUEST_MODELING_RESULTS', auto_create_table=True)
164
+
165
+ # Return string
166
+ return "Data processing, prediction, and table creation completed successfully."
predictions_analyzed/predictions_analyzed.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
predictions_analyzed/predictions_existing_flags.sql ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ WITH
2
+ pred_beq AS (
3
+ SELECT ROIFAMILYID
4
+ FROM UP_16425_1193735
5
+ ),
6
+
7
+ account_profile_family AS (
8
+ SELECT roi_id,
9
+ roi_family_id
10
+ FROM v_account_profile_family apf
11
+ WHERE EXISTS (
12
+ SELECT *
13
+ FROM pred_beq pb
14
+ WHERE pb.ROIFAMILYID = apf.roi_family_id
15
+ )
16
+ ),
17
+
18
+ account_profile AS (
19
+ SELECT roi_id, -- Added roi_id here to reference it later
20
+ account_classification
21
+ FROM v_account_profile ap
22
+ WHERE EXISTS (
23
+ SELECT *
24
+ FROM account_profile_family apf
25
+ WHERE apf.roi_id = ap.roi_id
26
+ )
27
+ ),
28
+
29
+ primaryAddresses AS (
30
+ SELECT roi_id,
31
+ city,
32
+ state_code as state,
33
+ zipcode
34
+ FROM v_account_primary_address
35
+ WHERE EXISTS (
36
+ SELECT *
37
+ FROM account_profile_family apf
38
+ WHERE apf.roi_id = v_account_primary_address.roi_id
39
+ )
40
+ ),
41
+
42
+ flag_universe AS (
43
+ SELECT roi_id,
44
+ MAX(
45
+ CASE
46
+ WHEN flagstd_code LIKE 'MD_GROUP%' THEN 'Y'
47
+ ELSE 'N'
48
+ END
49
+ ) AS MD_GROUP,
50
+ MAX(
51
+ CASE
52
+ WHEN flagstd_code LIKE 'MD_TFP_HIGH' THEN 'Y'
53
+ ELSE 'N'
54
+ END
55
+ ) AS DEV_TFP,
56
+ MAX(
57
+ CASE
58
+ WHEN flagstd_code like 'MLS%' THEN 'Y'
59
+ ELSE 'N'
60
+ END
61
+ ) AS MLS,
62
+ MAX(
63
+ CASE
64
+ WHEN flagstd_code like 'REGCOUNCIL%' THEN 'Y'
65
+ ELSE 'N'
66
+ END
67
+ ) AS REG_COUNCIL,
68
+ MAX(
69
+ CASE
70
+ WHEN flagstd_code like 'NPROLE_COUNCIL' THEN 'Y'
71
+ ELSE 'N'
72
+ END
73
+ ) AS Nat_Council,
74
+ MAX(
75
+ CASE
76
+ WHEN flagstd_code like 'NPROLE_BOARD%' THEN 'Y'
77
+ ELSE 'N'
78
+ END
79
+ ) AS Board_or_Emeritus,
80
+ MAX(
81
+ CASE WHEN flagstd_code like 'SUSTAINER%' THEN 'Y'
82
+ ELSE 'N'
83
+ END
84
+ ) AS SUSTAINER,
85
+ MAX(
86
+ CASE
87
+ WHEN flagstd_code like 'SF_%' THEN v_account_flag_active.flagstd_name
88
+ ELSE NULL
89
+ END
90
+ ) AS SUPERFUND,
91
+ MAX(
92
+ CASE
93
+ WHEN flagstd_code like 'SF_GROUP5_PLG_PROSP_FY24' THEN v_account_flag_active.flagstd_name
94
+ ELSE NULL
95
+ END
96
+ ) AS SUPERFUND_PlannedGift,
97
+ MAX(
98
+ CASE
99
+ WHEN flagstd_code = 'NPROLE_VETCOUNCIL' THEN 'Y'
100
+ ELSE 'N'
101
+ END
102
+ ) AS Vet_Council,
103
+ MAX(
104
+ CASE
105
+ WHEN flagstd_code like 'CF_GROUP_%' THEN 'Y'
106
+ ELSE 'N'
107
+ END
108
+ ) AS CF_GROUP
109
+ FROM v_account_flag_active
110
+ WHERE
111
+ (
112
+ (flagstd_code LIKE 'MD_GROUP%' AND end_date IS NULL)
113
+ OR (flagstd_code LIKE 'MD_TFP_HIGH' AND end_date IS NULL)
114
+ OR (flagstd_code like 'MLS%' AND end_date IS NULL)
115
+ OR (flagstd_code LIKE 'REGCOUNCIL%' AND end_date IS NULL)
116
+ OR (flagstd_code LIKE 'NPROLE_COUNCIL' AND end_date IS NULL)
117
+ OR (flagstd_code LIKE 'NPROLE_BOARD%' AND end_date IS NULL)
118
+ OR (flagstd_code like 'SF_%' AND end_date IS NULL)
119
+ OR (flagstd_code = 'NPROLE_VETCOUNCIL' AND end_date IS NULL)
120
+ OR flagstd_code LIKE 'CF_GROUP_%' /*Active or inactive CF_GROUP flags*/
121
+ )
122
+ AND EXISTS (
123
+ SELECT *
124
+ FROM account_profile_family apf
125
+ WHERE apf.roi_id = v_account_flag_active.roi_id
126
+ )
127
+ GROUP BY roi_id
128
+ ),
129
+
130
+ universe AS (
131
+ SELECT
132
+ flag_universe.roi_id,
133
+ flag_universe.MD_GROUP,
134
+ flag_universe.DEV_TFP,
135
+ flag_universe.MLS,
136
+ flag_universe.REG_COUNCIL,
137
+ flag_universe.Nat_Council,
138
+ flag_universe.Board_or_Emeritus,
139
+ flag_universe.Vet_Council,
140
+ flag_universe.CF_GROUP,
141
+ flag_universe.SUPERFUND,
142
+ flag_universe.SUPERFUND_PlannedGift
143
+ FROM flag_universe
144
+ ),
145
+
146
+ criticalFlags AS (
147
+ SELECT roi_id,
148
+ MAX(
149
+ CASE
150
+ WHEN flagstd_code like 'SF_%'
151
+ and flagstd_code <> 'SF_GROUP5_PLG_PROSP_FY24' THEN v_account_flag_active.flagstd_name
152
+ ELSE NULL
153
+ END
154
+ ) AS SUPERFUND,
155
+ MAX(
156
+ CASE
157
+ WHEN flagstd_code like 'SF_GROUP5_PLG_PROSP_FY24'
158
+ and flagstd_code NOT LIKE '%5' THEN v_account_flag_active.flagstd_name
159
+ ELSE NULL
160
+ END
161
+ ) AS SUPERFUND_PlannedGift,
162
+ MAX(
163
+ CASE
164
+ WHEN flagstd_code like 'SOLICIT_NO_MAIL' THEN 'Y'
165
+ ELSE 'N'
166
+ END
167
+ ) AS SOLICIT_NO_MAIL,
168
+ MAX(
169
+ CASE
170
+ WHEN flagstd_code like 'NO_EMAIL%' THEN 'Y'
171
+ ELSE 'N'
172
+ END
173
+ ) AS NO_EMAIL,
174
+ MAX(
175
+ CASE
176
+ WHEN flagstd_code like 'NPROLE_STAFF' THEN 'Y'
177
+ ELSE 'N'
178
+ END
179
+ ) AS np_role_staff
180
+ FROM v_account_flag_active
181
+ WHERE (
182
+ flagstd_code LIKE 'SF_%'
183
+ OR flagstd_code like 'NO_EMAIL'
184
+ OR flagstd_code like 'SOLICIT_NO_MAIL'
185
+ OR flagstd_code like 'NPROLE_STAFF'
186
+ OR flagstd_code like 'SOLICIT_NO_PHONE'
187
+ )
188
+ AND EXISTS (
189
+ SELECT *
190
+ FROM universe
191
+ WHERE universe.roi_id = v_account_flag_active.roi_id
192
+ )
193
+ GROUP BY roi_id
194
+ )
195
+
196
+ SELECT
197
+ apf.roi_family_id, -- Include roi_family_id
198
+ apf.roi_id, -- Include roi_id
199
+ primaryAddresses.city,
200
+ primaryAddresses.state,
201
+ primaryAddresses.zipcode,
202
+ COALESCE(criticalFlags.SOLICIT_NO_MAIL, 'N') AS SOLICIT_NO_MAIL,
203
+ COALESCE(criticalFlags.NO_EMAIL, 'N') AS NO_EMAIL,
204
+ COALESCE(universe.MD_GROUP, 'N') AS MD_GROUP,
205
+ COALESCE(universe.DEV_TFP, 'N') AS DEV_TFP,
206
+ COALESCE(universe.MLS, 'N') AS MLS,
207
+ COALESCE(universe.REG_COUNCIL, 'N') AS REG_COUNCIL,
208
+ COALESCE(universe.Nat_Council, 'N') AS Nat_Council,
209
+ COALESCE(universe.Vet_Council, 'N') AS Vet_Council,
210
+ COALESCE(universe.CF_GROUP, 'N') AS CF_GROUP,
211
+ COALESCE(universe.Board_or_Emeritus, 'N') AS Board_or_Emeritus,
212
+ COALESCE(universe.SUPERFUND, criticalFlags.SUPERFUND, NULL) AS SUPERFUND,
213
+ COALESCE(universe.SUPERFUND_PlannedGift, criticalFlags.SUPERFUND_PlannedGift, NULL) AS SUPERFUND_PlannedGift,
214
+ COALESCE(criticalFlags.np_role_staff, 'N') AS np_role_staff
215
+ FROM
216
+ account_profile_family apf
217
+ JOIN
218
+ universe ON apf.roi_id = universe.roi_id
219
+ JOIN
220
+ primaryAddresses ON apf.roi_id = primaryAddresses.roi_id
221
+ LEFT JOIN
222
+ criticalFlags ON apf.roi_id = criticalFlags.roi_id
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core ML libraries
2
+ scikit-learn>=1.0.0
3
+ pandas>=1.3.0
4
+ numpy>=1.20.0
5
+ imbalanced-learn>=0.8.0
6
+
7
+ # Snowflake integration
8
+ snowflake-snowpark-python>=1.0.0
9
+
10
+ # Visualization (for evaluation notebook)
11
+ seaborn>=0.11.0
12
+ matplotlib>=3.4.0
13
+
14
+ # Jupyter for running evaluation notebook
15
+ jupyter>=1.0.0
16
+ ipykernel>=6.0.0