Ingest Reference
philanthropy.ingest
On-ramps from an upstream donor system to a PhilanthroPy donor-level feature table.
UniSchema: read_constituent_events loads UniSchema's JSON / NDJSON egress
files; constituent_events_to_features aggregates them into the
one-row-per-donor feature frame the estimators consume.
CiviCRM: read_civicrm_contributions loads a contribution export CSV;
civicrm_contributions_to_features aggregates it the same way, dropping
test-mode and non-Completed rows first.
Raiser's Edge: read_raisers_edge_gifts loads a Blackbaud gift export CSV;
raisers_edge_gifts_to_features aggregates it, dropping the commitment rows
(pledges, matching gift pledges, recurring gift templates) first so a pledged
dollar is not counted both as the promise and as the payments against it.
Nonprofit Cloud: read_nonprofit_cloud_gifts loads a Salesforce Nonprofit Cloud
GiftTransaction export CSV; nonprofit_cloud_gifts_to_features aggregates it,
keeping only paid statuses (Paid), so all other documented statuses (Unpaid,
Failed, Pending, Written Off, Fully Refunded, Cancelled) are
not counted as received gifts.
NPSP: read_npsp_opportunities loads a Salesforce Nonprofit Success Pack
Opportunity export CSV; npsp_opportunities_to_features aggregates it,
keeping only closed/won stages (Closed Won, Awarded, Posted) so a
Pledged instalment, an open pipeline row, or Closed Lost is not
counted as a gift.
Bloomerang: read_bloomerang_transactions loads a Bloomerang transaction
export CSV; bloomerang_transactions_to_features aggregates it, dropping
the commitment rows (Pledge, Recurring Donation) first so a pledged
dollar is not counted both as the promise and as the payments against it.
DonorPerfect: read_donorperfect_gifts loads a DonorPerfect gift export
CSV; donorperfect_gifts_to_features aggregates it, dropping Pledge
records and split-gift Main totals (record_type P / M) first so
a pledged or split dollar is not counted both as the promise/total and as
the payments/splits against it.
map_columns renames a user-supplied export's headers to the canonical
names a bridge above expects, raising one error listing every column still
missing after the rename.
activities_to_features aggregates a long, multi-source activity log
(event attendance, volunteer shifts, email clicks, ...) into per-donor,
per-type engagement features, generalising the pattern above to an
open-ended set of activity types discovered from the data itself.
read_gifts(path_or_df, source=...) looks up the CiviCRM, Raiser's Edge,
Nonprofit Cloud or NPSP reader-and-aggregator pair by name, for a caller
working with more than one CRM export format.
build_leadership_snapshots builds a per-donor, per-fiscal-year training
table for an upgrade model: one row per (donor, fiscal year T) for every
donor whose FY T giving falls in a mid-level band, features computed only
from data through the end of T, and a target reading whether FY T+1 crossed
a leadership threshold. build_snapshots is the same idea for every
question (upgrade, lapse, response next year, next gift amount) on one shared
column set, with an optional minimum number of giving years.
activities_to_features(activities, *, as_of, donors=None, include_momentum=False)
Aggregate a long activity log into per-donor, per-type features.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
activities
|
iterable of mapping, or DataFrame
|
Long-format rows, one per activity: |
required |
as_of
|
str or datetime - like
|
The cutoff. Rows with |
required |
donors
|
DataFrame, Series, or iterable
|
The donor population this activity log is being matched against (its
index if a DataFrame, its values otherwise). Used only to report the
match rate: the share of distinct |
None
|
include_momentum
|
bool
|
Also emit, per activity type, |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per donor with at least one activity at or before |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
If |
Examples:
>>> rows = [
... {"contact_id": "1", "activity_date": "2024-03-01",
... "activity_type": "volunteer", "hours": 2},
... {"contact_id": "1", "activity_date": "2023-01-01",
... "activity_type": "volunteer", "hours": 3},
... {"contact_id": "2", "activity_date": "2024-06-01",
... "activity_type": "event"},
... ]
>>> feats = activities_to_features(rows, as_of="2024-12-31")
>>> int(feats.loc["1", "volunteer_count_12m"])
1
>>> int(feats.loc["1", "volunteer_distinct"])
2
>>> float(feats.loc["1", "volunteer_hours_12m"])
2.0
>>> "event_count_12m" in feats.columns
True
>>> int(feats.loc["2", "event_count_12m"])
1
Source code in philanthropy/ingest/_activities.py
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | |
bloomerang_transactions_to_features(transactions, *, reference_date=None, exclude_entry_types=DEFAULT_EXCLUDED_ENTRY_TYPES)
Aggregate a Bloomerang transaction export into donor-level features.
Commitment rows (pledges and recurring-donation schedules) are dropped
first, then the surviving payment rows are handed to
:func:civicrm_contributions_to_features, which produces the donor
frame. Bloomerang has no contribution-status column analogous to
CiviCRM's, so no status filter is applied.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
transactions
|
iterable of mapping, or DataFrame
|
Bloomerang transaction rows, under a report export label
( |
required |
reference_date
|
str or datetime - like
|
Anchor for the recency features. If |
None
|
exclude_entry_types
|
sequence of str or None
|
Entry types to drop before aggregating, matched against |
:data:`DEFAULT_EXCLUDED_ENTRY_TYPES`
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per donor, indexed by |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
If |
Notes
Nothing here deduplicates on a transaction id, so concatenating two exports that overlap in time will double-count the overlap; export disjoint date ranges.
Examples:
>>> rows = [
... {"Account Number": "88", "Date": "2025-01-10",
... "Amount": "1200.00", "Transaction Type": "Pledge"},
... {"Account Number": "88", "Date": "2025-02-10",
... "Amount": "100.00", "Transaction Type": "PledgePayment"},
... {"Account Number": "88", "Date": "2025-03-10",
... "Amount": "100.00", "Transaction Type": "PledgePayment"},
... ]
>>> feats = bloomerang_transactions_to_features(rows)
>>> float(feats.loc["88", "total_gift_amount"]) # not 1400.0
200.0
>>> int(feats.loc["88", "gift_count"])
2
Source code in philanthropy/ingest/_bloomerang.py
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | |
read_bloomerang_transactions(path)
Read Bloomerang transaction export CSV(s) into one frame.
Accepts a single .csv or a directory of them, walked recursively and
concatenated in sorted relative-path order; symlinks are not followed.
Every column is read as text and nothing is filtered: commitment rows
are still present, and it is
:func:bloomerang_transactions_to_features that drops them.
Reading a gift export is CRM-agnostic once the headers are normalised, so
this delegates to :func:read_civicrm_contributions rather than
repeating its BOM handling and path hardening. Bloomerang headers are
normalised on the way into
:func:bloomerang_transactions_to_features, not here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or Path
|
CSV file, or a directory of them. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
transactions |
DataFrame
|
The export as written, with text values. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
Source code in philanthropy/ingest/_bloomerang.py
civicrm_contributions_to_features(contributions, *, reference_date=None, statuses=('Completed',))
Aggregate a CiviCRM contribution log into donor-level features.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
contributions
|
iterable of mapping, or DataFrame
|
CiviCRM contribution rows. Accepts the output of
:func: |
required |
reference_date
|
str or datetime - like
|
Anchor for the recency features ( |
None
|
statuses
|
sequence of str or None
|
Contribution statuses to count, matched case-insensitively against
|
``("Completed",)``
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per donor, indexed by |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
If |
UserWarning
|
If the batch mixes currencies. |
Examples:
>>> rows = [
... {"Contact ID": "101", "Contribution Date": "2025-01-15",
... "Total Amount": "250.00", "Contribution Status": "Completed"},
... {"Contact ID": "101", "Contribution Date": "2025-06-01",
... "Total Amount": "1,000.00", "Contribution Status": "Completed"},
... {"Contact ID": "101", "Contribution Date": "2025-06-02",
... "Total Amount": "99.00", "Contribution Status": "Failed"},
... ]
>>> feats = civicrm_contributions_to_features(rows)
>>> float(feats.loc["101", "total_gift_amount"])
1250.0
>>> int(feats.loc["101", "gift_count"])
2
Source code in philanthropy/ingest/_civicrm.py
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | |
read_civicrm_contributions(path)
Read CiviCRM contribution export CSV(s) into one normalised frame.
Accepts either a single .csv file or a directory, which is walked
recursively and whose *.csv files are concatenated in sorted
relative-path order, the shape you get from keeping a folder of monthly
exports. Symlinks are not followed.
Every column is read as text and the headers are normalised to the APIv4
spelling, so "Total Amount", "Contact ID" and "Contribution Date"
arrive as total_amount, contact_id and receive_date. Nothing else
is done to the rows: test-mode and non-Completed contributions are
still present, and it is
:func:civicrm_contributions_to_features that drops them and types the
values. Reading is deliberately lossless so the raw export stays inspectable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or Path
|
CSV file, or a directory of them. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
contributions |
DataFrame
|
The export as written, with normalised column names and text values. A directory holding no CSV returns an empty frame. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
Source code in philanthropy/ingest/_civicrm.py
constituent_events_to_features(events, *, reference_date=None, deduplicate=True)
Aggregate a UniSchema ConstituentEvent stream into donor features.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
events
|
iterable of mapping, or DataFrame
|
Records following UniSchema's |
required |
reference_date
|
str or datetime - like
|
Anchor for the recency features ( |
None
|
deduplicate
|
bool
|
Drop repeated |
True
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per constituent, indexed by |
Warns:
| Type | Description |
|---|---|
UserWarning
|
If the batch mixes currencies (more than one distinct |
Examples:
>>> events = [
... {"constituentEmail": "a@x.edu", "eventType": "DONATION",
... "sourceSystem": "GIVECAMPUS", "amount": 250.0,
... "createdAt": "2025-03-01T12:00:00Z"},
... {"constituentEmail": "a@x.edu", "eventType": "EVENT_REGISTRATION",
... "sourceSystem": "CVENT", "createdAt": "2025-06-01T09:00:00Z"},
... ]
>>> feats = constituent_events_to_features(events)
>>> float(feats.loc["a@x.edu", "total_gift_amount"])
250.0
>>> int(feats.loc["a@x.edu", "event_attendance_count"])
1
Source code in philanthropy/ingest/_constituent_events.py
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | |
read_constituent_events(path)
Read UniSchema egress files into a list of ConstituentEvent dicts.
Handles the shapes UniSchema's egress writes:
- a single
.jsonfile holding one event (object) or many (array); - a
.ndjson/.jsonlbatch, one event per line; - a directory, which is walked recursively: every
*.json,*.ndjson, and*.jsonlfile at any depth is read and concatenated, sorted by relative path. This handles UniSchema's date-partitioned egress ({prefix}/{vendor}/{yyyy}/{mm}/{dd}/{eventId}.json); a flat directory still works too.*.manifest.jsonbatch sidecars are skipped.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or Path
|
File or directory to read. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
events |
list of dict
|
Parsed events, ready to pass to :func: |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
Source code in philanthropy/ingest/_constituent_events.py
donorperfect_gifts_to_features(gifts, *, reference_date=None, exclude_record_types=DEFAULT_EXCLUDED_RECORD_TYPES)
Aggregate a DonorPerfect gift export into donor-level features.
Pledge records (record_type='P') and split-gift Main totals
(record_type='M') are dropped first, then the surviving gift rows
(regular gifts, pledge payments, and split gift entries, all
record_type='G') are handed to :func:civicrm_contributions_to_features,
which produces the donor frame. DonorPerfect has no contribution-status
column analogous to CiviCRM's, so no status filter is applied.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gifts
|
iterable of mapping, or DataFrame
|
DonorPerfect gift rows, under a "Gift/Pledge Transactions" export
label ( |
required |
reference_date
|
str or datetime - like
|
Anchor for the recency features. If |
None
|
exclude_record_types
|
sequence of str or None
|
Record types to drop before aggregating, matched against
|
:data:`DEFAULT_EXCLUDED_RECORD_TYPES`
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per donor, indexed by |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
If |
Notes
Nothing here deduplicates on a gift id, so concatenating two exports
that overlap in time will double-count the overlap; export disjoint
date ranges. Soft credits (record_type='S') are out of scope for
this filter and are summed if present; DonorPerfect creates a soft
credit under a different donor than the one who actually gave, so
including them attributes the same dollar to two donors by design of
that feature, not a bug this bridge is meant to catch.
Examples:
>>> rows = [
... {"Donor ID": "88", "Gift Date": "2025-01-10",
... "Gift Amount": "1200.00", "Record Type": "P"},
... {"Donor ID": "88", "Gift Date": "2025-02-10",
... "Gift Amount": "100.00", "Record Type": "G"},
... {"Donor ID": "88", "Gift Date": "2025-03-10",
... "Gift Amount": "100.00", "Record Type": "G"},
... ]
>>> feats = donorperfect_gifts_to_features(rows)
>>> float(feats.loc["88", "total_gift_amount"]) # not 1400.0
200.0
>>> int(feats.loc["88", "gift_count"])
2
Source code in philanthropy/ingest/_donorperfect.py
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | |
read_donorperfect_gifts(path)
Read DonorPerfect gift export CSV(s) into one frame.
Accepts a single .csv or a directory of them, walked recursively and
concatenated in sorted relative-path order; symlinks are not followed.
Every column is read as text and nothing is filtered: pledge and
split-Main rows are still present, and it is
:func:donorperfect_gifts_to_features that drops them.
Reading a gift export is CRM-agnostic once the headers are normalised, so
this delegates to :func:read_civicrm_contributions rather than
repeating its BOM handling and path hardening. DonorPerfect headers are
normalised on the way into :func:donorperfect_gifts_to_features, not
here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or Path
|
CSV file, or a directory of them. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
gifts |
DataFrame
|
The export as written, with text values. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
Source code in philanthropy/ingest/_donorperfect.py
map_columns(df, mapping, *, required=())
Rename a DataFrame's columns per a user-supplied mapping.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
df
|
DataFrame
|
The uploaded export, with whatever headers the source system wrote. |
required |
mapping
|
mapping of str to str
|
User header -> canonical name, e.g. |
required |
required
|
sequence of str
|
Canonical column names that must be present after renaming. |
()
|
Returns:
| Name | Type | Description |
|---|---|---|
mapped |
DataFrame
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If, after renaming, any name in |
Examples:
>>> import pandas as pd
>>> df = pd.DataFrame({"CnID": [1], "Gift Date": ["2025-01-01"]})
>>> map_columns(
... df,
... {"CnID": "contact_id", "Gift Date": "activity_date"},
... required=["contact_id", "activity_date"],
... ).columns.tolist()
['contact_id', 'activity_date']
>>> map_columns(df, {"CnID": "contact_id"}, required=["contact_id", "amount"])
Traceback (most recent call last):
...
ValueError: missing required column(s) after mapping: amount
Source code in philanthropy/ingest/_map_columns.py
nonprofit_cloud_gifts_to_features(gifts, *, reference_date=None, include_statuses=DEFAULT_INCLUDED_GIFT_TRANSACTION_STATUSES)
Aggregate a Nonprofit Cloud GiftTransaction export into donor features.
Transactions whose status is not a documented received status (Paid) are
dropped first, then the surviving receipts are handed to
:func:civicrm_contributions_to_features, which produces the donor frame.
Nonprofit Cloud's GiftTransaction carries no separate pledge flag beyond
Status, so no other filter is applied.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gifts
|
iterable of mapping, or DataFrame
|
Nonprofit Cloud |
required |
reference_date
|
str or datetime - like
|
Anchor for the recency features. If |
None
|
include_statuses
|
sequence of str or None
|
Statuses to keep before aggregating, matched against |
:data:`DEFAULT_INCLUDED_GIFT_TRANSACTION_STATUSES`
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per donor, indexed by |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
If |
Notes
Nothing here deduplicates on a GiftTransaction id, so concatenating two exports that overlap in time will double-count the overlap; export disjoint date ranges.
Examples:
>>> rows = [
... {"Donor ID": "88", "Transaction Date": "2025-01-10",
... "Current Amount": "100.00", "Status": "Paid"},
... {"Donor ID": "88", "Transaction Date": "2025-02-10",
... "Current Amount": "100.00", "Status": "Unpaid"},
... {"Donor ID": "88", "Transaction Date": "2025-03-10",
... "Current Amount": "100.00", "Status": "Paid"},
... ]
>>> feats = nonprofit_cloud_gifts_to_features(rows)
>>> float(feats.loc["88", "total_gift_amount"]) # not 300.0
200.0
>>> int(feats.loc["88", "gift_count"])
2
Source code in philanthropy/ingest/_nonprofit_cloud.py
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | |
read_nonprofit_cloud_gifts(path)
Read Nonprofit Cloud GiftTransaction export CSV(s) into one frame.
Accepts a single .csv or a directory of them, walked recursively and
concatenated in sorted relative-path order; symlinks are not followed.
Every column is read as text and nothing is filtered: unpaid and other
non-paid transactions are still present, and it is
:func:nonprofit_cloud_gifts_to_features that drops them.
Reading a gift export is CRM-agnostic once the headers are normalised,
so this delegates to :func:read_civicrm_contributions rather than
repeating its BOM handling and path hardening. Nonprofit Cloud headers
are normalised on the way into
:func:nonprofit_cloud_gifts_to_features, not here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or Path
|
CSV file, or a directory of them. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
gifts |
DataFrame
|
The export as written, with text values. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
Source code in philanthropy/ingest/_nonprofit_cloud.py
npsp_opportunities_to_features(opportunities, *, reference_date=None, include_stages=DEFAULT_INCLUDED_STAGES)
Aggregate an NPSP Opportunity export into donor-level features.
Only rows whose stage is a closed/won equivalent are kept, then handed to
:func:civicrm_contributions_to_features, which produces the donor
frame. NPSP's stage vocabulary carries no test-mode flag, so none is
applied.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
opportunities
|
iterable of mapping, or DataFrame
|
NPSP Opportunity export rows, under a report's column labels
( |
required |
reference_date
|
str or datetime - like
|
Anchor for the recency features. If |
None
|
include_stages
|
sequence of str or None
|
Allowlist of closed/won-equivalent stages to keep before aggregating,
matched against |
:data:`DEFAULT_INCLUDED_STAGES`
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per donor, indexed by |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
If |
Notes
Nothing here deduplicates on an Opportunity id, so concatenating two exports that overlap in time will double-count the overlap; export disjoint date ranges.
Examples:
>>> rows = [
... {"Account ID": "88", "Close Date": "2025-01-10",
... "Amount": "100.00", "Stage": "Pledged"},
... {"Account ID": "88", "Close Date": "2025-02-10",
... "Amount": "100.00", "Stage": "Closed Won"},
... {"Account ID": "88", "Close Date": "2025-03-10",
... "Amount": "100.00", "Stage": "Closed Won"},
... ]
>>> feats = npsp_opportunities_to_features(rows)
>>> float(feats.loc["88", "total_gift_amount"]) # not 300.0
200.0
>>> int(feats.loc["88", "gift_count"])
2
Source code in philanthropy/ingest/_npsp.py
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | |
read_npsp_opportunities(path)
Read NPSP Opportunity export CSV(s) into one frame.
Accepts a single .csv or a directory of them, walked recursively and
concatenated in sorted relative-path order; symlinks are not followed.
Every column is read as text and nothing is filtered: Pledged rows are
still present, and it is :func:npsp_opportunities_to_features that
drops them.
Reading a gift export is CRM-agnostic once the headers are normalised, so
this delegates to :func:read_civicrm_contributions rather than repeating
its BOM handling and path hardening. NPSP headers are normalised on the
way into :func:npsp_opportunities_to_features, not here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or Path
|
CSV file, or a directory of them. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
opportunities |
DataFrame
|
The export as written, with text values. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
Source code in philanthropy/ingest/_npsp.py
raisers_edge_gifts_to_features(gifts, *, reference_date=None, exclude_gift_types=DEFAULT_EXCLUDED_GIFT_TYPES)
Aggregate a Raiser's Edge gift export into donor-level features.
Commitment rows (pledges, matching gift pledges, recurring gift templates)
and ledger corrections are dropped first, then the surviving payment rows
are handed to :func:civicrm_contributions_to_features, which produces the
donor frame. Raiser's Edge has no contribution-status column, so no status
filter is applied.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gifts
|
iterable of mapping, or DataFrame
|
Raiser's Edge gift rows, under the desktop export labels
( |
required |
reference_date
|
str or datetime - like
|
Anchor for the recency features. If |
None
|
exclude_gift_types
|
sequence of str or None
|
Gift types to drop before aggregating, matched against |
:data:`DEFAULT_EXCLUDED_GIFT_TYPES`
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per donor, indexed by |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
If |
Notes
A Raiser's Edge export of split gifts writes one row per split, all sharing the gift's system record id, and their amounts are meant to be summed. Nothing here deduplicates on a gift id for that reason, so concatenating two exports that overlap in time will double-count the overlap; export disjoint date ranges.
Examples:
>>> rows = [
... {"Constituent ID": "88", "Gift Date": "2025-01-10",
... "Gift Amount": "1200.00", "Gift Type": "Pledge"},
... {"Constituent ID": "88", "Gift Date": "2025-02-10",
... "Gift Amount": "100.00", "Gift Type": "Pay-Cash"},
... {"Constituent ID": "88", "Gift Date": "2025-03-10",
... "Gift Amount": "100.00", "Gift Type": "Pay-Cash"},
... ]
>>> feats = raisers_edge_gifts_to_features(rows)
>>> float(feats.loc["88", "total_gift_amount"]) # not 1400.0
200.0
>>> int(feats.loc["88", "gift_count"])
2
Source code in philanthropy/ingest/_raisers_edge.py
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | |
read_raisers_edge_gifts(path)
Read Raiser's Edge gift export CSV(s) into one frame.
Accepts a single .csv or a directory of them, walked recursively and
concatenated in sorted relative-path order; symlinks are not followed. Every
column is read as text and nothing is filtered: commitment rows are still
present, and it is :func:raisers_edge_gifts_to_features that drops them.
Reading a gift export is CRM-agnostic once the headers are normalised, so
this delegates to :func:read_civicrm_contributions rather than repeating
its BOM handling and path hardening. Raiser's Edge headers are normalised on
the way into :func:raisers_edge_gifts_to_features, not here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or Path
|
CSV file, or a directory of them. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
gifts |
DataFrame
|
The export as written, with text values. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
Source code in philanthropy/ingest/_raisers_edge.py
read_gifts(path_or_df, *, source, **kwargs)
Read (if given a path) and aggregate a CRM gift export in one call.
Looks source up in a small preset registry mapping each of
"civicrm", "raisers_edge", "npsp", and "nonprofit_cloud"
to that CRM's read_<source>_... loader and <source>_..._to_features
aggregator, then runs the pair. read_gifts("gifts.csv", source="npsp")
is equivalent to
npsp_opportunities_to_features(read_npsp_opportunities("gifts.csv")).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path_or_df
|
str, pathlib.Path, DataFrame, or iterable of mapping
|
A gift export CSV file, a directory of them, or gift rows already in memory (a DataFrame or an iterable of mappings, e.g. an API result). A path is only meaningful for a single source's own export format; already-in-memory rows are handed straight to the aggregator. |
required |
source
|
str
|
Which CRM the export came from. One of :data: |
required |
**kwargs
|
Any
|
Passed through to the source's aggregator, e.g. |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
features |
DataFrame
|
One row per donor, indexed by |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> rows = [
... {"Constituent ID": "88", "Gift Date": "2025-01-10",
... "Gift Amount": "1200.00", "Gift Type": "Pledge"},
... {"Constituent ID": "88", "Gift Date": "2025-02-10",
... "Gift Amount": "100.00", "Gift Type": "Pay-Cash"},
... ]
>>> feats = read_gifts(rows, source="raisers_edge")
>>> float(feats.loc["88", "total_gift_amount"]) # the pledge is excluded
100.0
Source code in philanthropy/ingest/_read_gifts.py
build_leadership_snapshots(gifts, *, fiscal_years, threshold=1000, band=(100, 999), fiscal_year_start=7, activities=None, donors=None, include_momentum=False)
Build upgrade-candidate snapshots, one row per (donor, fiscal year).
For each fiscal year T in fiscal_years, the population is every
donor whose FY T total giving falls inside band (inclusive) and below
threshold: donors already at or above threshold in T are not
"upgrade candidates" and get no row for that T. Every feature is computed
from gift (and, if given, activity) rows dated at or before the end of FY
T; target is 1 if the donor's FY T+1 total reaches threshold, else
0, including when the donor gave nothing at all in T+1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gifts
|
iterable of mapping, or DataFrame
|
Gift-level rows, already normalised (not a raw CRM export): each row
needs |
required |
fiscal_years
|
iterable of int
|
The snapshot years |
required |
threshold
|
float or str
|
The leadership-giving level an upgrade crosses into. Also the upper
exclusive bound of the candidate population: a donor at or above this
in FY T already gave at that level and is not a candidate. A string
|
1000
|
band
|
(float, float)
|
Inclusive |
(100, 999)
|
fiscal_year_start
|
int
|
Month (1-12) the organisation's fiscal year begins, the same
parameter :class: |
7
|
activities
|
iterable of mapping, or DataFrame
|
A long activity log in the shape
:func: |
None
|
donors
|
DataFrame
|
Static donor attributes (constituency, wealth rating, ...), indexed
by donor id. Joined in as-is; also passed through to
|
None
|
include_momentum
|
bool
|
Also emit, for each of |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
snapshots |
DataFrame
|
One row per qualifying |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
ValueError
|
If |
Examples:
>>> import pandas as pd
>>> from philanthropy.ingest import build_leadership_snapshots
>>> gifts = pd.DataFrame({
... "donor_id": ["1", "1", "1", "1", "2"],
... "gift_date": ["2017-08-01", "2018-08-01", "2019-08-01",
... "2020-08-01", "2019-08-01"],
... "gift_amount": [200, 300, 500, 1500, 5000],
... })
>>> snaps = build_leadership_snapshots(gifts, fiscal_years=[2020])
>>> list(snaps.index)
['1']
>>> int(snaps.loc["1", "fiscal_year"])
2020
>>> float(snaps.loc["1", "fy_total"])
500.0
>>> int(snaps.loc["1", "consecutive_years_given"])
3
>>> int(snaps.loc["1", "target"])
1
Donor "2" gave 5000 in FY2020, at or above threshold, so is excluded
from the band even though it never appears in snaps:
Source code in philanthropy/ingest/_upgrade_snapshots.py
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | |
build_snapshots(gifts, *, kind, fiscal_years=None, threshold=1000.0, band=(100.0, 999.0), min_years_given=1, fiscal_year_start=7, scale_free=False)
Build labelled donor x fiscal-year snapshots for one question.
Every feature is computed from gifts dated at or before the end of
fiscal year T; the label alone is read from T + 1. A fiscal year
with no following year in the file contributes no rows, so "gave
nothing next year" and "next year not in the file" are never confused.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gifts
|
iterable of mapping, or DataFrame
|
Gift-level rows with |
required |
kind
|
('upgrade', 'lapse', 'response_next_year', 'next_amount')
|
Which population and label (see :func: |
"upgrade"
|
fiscal_years
|
iterable of int
|
Snapshot years |
None
|
threshold
|
(float, (float, float))
|
The upgrade question's leadership level and candidate band, as in
:func: |
1000.0
|
band
|
(float, (float, float))
|
The upgrade question's leadership level and candidate band, as in
:func: |
1000.0
|
min_years_given
|
int
|
Keep only donors who gave in at least this many fiscal years up to
and including |
1
|
fiscal_year_start
|
int
|
Month (1-12) the fiscal year begins. |
7
|
scale_free
|
bool
|
Also emit :data: |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
snapshots |
DataFrame
|
Indexed by |
Examples:
>>> import pandas as pd
>>> from philanthropy.ingest import build_snapshots
>>> gifts = pd.DataFrame({
... "donor_id": ["1", "1", "2", "2", "2"],
... "gift_date": ["2018-08-01", "2019-08-01", "2018-08-01",
... "2019-08-01", "2020-08-01"],
... "gift_amount": [50, 60, 20, 30, 40],
... })
>>> snaps = build_snapshots(gifts, kind="lapse", min_years_given=2)
>>> snaps["period"].tolist(), snaps["target"].tolist()
([2020, 2020], [1, 0])
Donor "1" gave in FY2019 and FY2020 and nothing in FY2021, so it is a lapse; donor "2" gave again. FY2019 has no rows: neither donor had two giving years by then.
Source code in philanthropy/ingest/_snapshots.py
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | |