Primary research question
How can historical banking transactions and customer characteristics be used to identify meaningful customer segments and understand differences in financial behavior?
Project: Transaction Intelligence: Customer Segmentation and Behavioral Analytics
Dataset: Berka / PKDD’99 Financial Dataset
Course alignment:
This notebook establishes the analytical contract for the project and builds its reusable data foundation. It frames the problem, audits the validated relational snapshot, documents preparation decisions, and produces four Silver-layer Parquet tables. It does not perform customer segmentation or make fraud claims.
How can historical banking transactions and customer characteristics be used to identify meaningful customer segments and understand differences in financial behavior?
The project is an exploratory customer-intelligence study. A statistical outlier is not evidence of fraud, and a discovered association is not evidence that a recommendation will benefit a customer.
| Stakeholder | Potential decision or need | Appropriate project output |
|---|---|---|
| Customer analytics team | Understand heterogeneous account behavior | Interpretable behavioral segments |
| Product and service team | Investigate relevant service combinations | Association patterns and recommendation hypotheses |
| Branch or portfolio managers | Monitor changes in aggregate banking activity | Descriptive summaries and bounded forecasts |
| Data and AI team | Reproduce, validate, and extend the analysis | Versioned data pipeline, quality checks, and documented features |
| Customers and regulators | Expect fair and responsible use of financial data | Privacy protection, limitations, and non-discriminatory interpretation |
No automated lending, fraud, or customer-treatment decision should be made from this course project.
| Analytics type | Project question | Planned method |
|---|---|---|
| Descriptive | What behaviors are present? | EDA, summaries, temporal profiles |
| Diagnostic | What characteristics distinguish customer groups? | Feature comparisons, clustering profiles |
| Predictive | What aggregate banking activity or bounded loan outcome can be estimated? | Time-series forecasting; limited supervised case study |
| Prescriptive | What service recommendations could be investigated? | Association rules interpreted as hypotheses |
The descriptive and diagnostic components are central. Predictive and prescriptive components remain bounded demonstrations because the project goal is not to build another fraud or lending-decision tool.
| Unit | Grain | Intended use |
|---|---|---|
| Transaction | One row per transaction | Cash-flow, temporal, and behavioral analysis |
| Disposition | One client–account relationship | Ownership and authorized-user analysis |
| Account | One row per bank account | Primary unit for segmentation |
| Loan | One row per loan | Bounded supervised-learning case study |
| District | One row per district | Contextual demographic attributes |
| Month | One row per calendar month | Aggregate time-series analysis |
An account is the primary segmentation unit because transactions and most services are linked to accounts. Every account has exactly one OWNER disposition; 869 accounts also have a DISPONENT. Treating every disposition as a separate account would duplicate shared financial behavior.
Analytical success
Lifecycle
Problem framing → acquisition → validation → preparation → exploration → feature engineering → modelling → evaluation → interpretation → reporting.
Each notebook consumes versioned outputs from the previous stage. Raw files are immutable; derived tables can be rebuilt.
The acquisition process creates an immutable Bronze snapshot: eight CSV tables, the original MariaDB DDL, and a checksum manifest. The local DuckDB database reconstructs the keys and relationships and is the only database queried by this notebook.
from pathlib import Path
import json
import platform
import duckdb
import matplotlib.pyplot as plt
import pandas as pd
from matplotlib.patches import FancyBboxPatch
from IPython.display import display
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
def find_project_root(start: Path | None = None) -> Path:
current = (start or Path.cwd()).resolve()
for candidate in (current, *current.parents):
if (candidate / "pyproject.toml").is_file():
return candidate
raise FileNotFoundError("Could not locate the project root containing pyproject.toml")
PROJECT_ROOT = find_project_root()
RAW_DIR = PROJECT_ROOT / "data" / "raw"
INTERIM_DIR = PROJECT_ROOT / "data" / "interim"
PROCESSED_DIR = PROJECT_ROOT / "data" / "processed"
MANIFEST_PATH = RAW_DIR / "download_manifest.json"
RELATIONSHIP_REPORT_PATH = INTERIM_DIR / "relationship_validation.json"
DATABASE_PATH = INTERIM_DIR / "berka.duckdb"
INTERIM_DIR.mkdir(parents=True, exist_ok=True)
environment = pd.Series(
{
"Python": platform.python_version(),
"DuckDB": duckdb.__version__,
"Project root": ".",
"Database": str(DATABASE_PATH.relative_to(PROJECT_ROOT)),
},
name="value",
)
display(environment.to_frame())| value | |
|---|---|
| Python | 3.14.6 |
| DuckDB | 1.5.5 |
| Project root | . |
| Database | data/interim/berka.duckdb |
required_artifacts = {
"Download manifest": MANIFEST_PATH,
"Source MariaDB schema": RAW_DIR / "schema_mariadb.sql",
"Local DuckDB database": DATABASE_PATH,
"Relationship validation": RELATIONSHIP_REPORT_PATH,
}
artifact_status = pd.DataFrame(
[
{
"artifact": name,
"path": str(path.relative_to(PROJECT_ROOT)),
"exists": path.is_file(),
"size_mb": round(path.stat().st_size / 1_048_576, 3) if path.is_file() else None,
}
for name, path in required_artifacts.items()
]
)
display(artifact_status)
missing_artifacts = artifact_status.loc[~artifact_status["exists"], "artifact"].tolist()
assert not missing_artifacts, (
"Missing required artifacts: "
+ ", ".join(missing_artifacts)
+ ". Run scripts/download_berka.py and scripts/build_local_database.py first."
)
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
relationship_report = json.loads(RELATIONSHIP_REPORT_PATH.read_text(encoding="utf-8"))
provenance = pd.Series(
{
"Source": manifest["source"],
"Dataset": manifest["dataset"],
"URL": manifest["url"],
"Retrieved (UTC)": manifest["retrieved_at_utc"],
"Source database": manifest["database"],
"Source host": manifest["host"],
"Schema checksum": manifest["schema_sha256"],
"Relationship validation": relationship_report["valid"],
},
name="value",
)
display(provenance.to_frame())| artifact | path | exists | size_mb | |
|---|---|---|---|---|
| 0 | Download manifest | data/raw/download_manifest.json | True | 0.012 |
| 1 | Source MariaDB schema | data/raw/schema_mariadb.sql | True | 0.004 |
| 2 | Local DuckDB database | data/interim/berka.duckdb | True | 46.262 |
| 3 | Relationship validation | data/interim/relationship_validation.json | True | 0.002 |
| value | |
|---|---|
| Source | CTU Prague Relational Dataset Repository |
| Dataset | Financial — PKDD’99 Financial Dataset |
| URL | https://relational.fel.cvut.cz/dataset/Financial |
| Retrieved (UTC) | 2026-07-17T05:45:37.075352+00:00 |
| Source database | financial |
| Source host | relational.fel.cvut.cz |
| Schema checksum | ba5ea18fe037f77ccb97ef98a0cb91d5ca50838c44375d... |
| Relationship validation | True |
The database contains eight normalized tables. Primary and foreign keys are retained in DuckDB, while the download manifest preserves the original MariaDB column definitions and checksums.
district ──< account ──< disp >── client >── district
│ │
│ └──< card
├──< loan
├──< order
└──< trans
The disposition table resolves the many-to-many relationship between clients and accounts. Purpose-specific joins are used instead of creating one universal denormalized table with mixed grain.
connection = duckdb.connect(str(DATABASE_PATH), read_only=True)
inventory_rows = []
for table_name, metadata in manifest["tables"].items():
local_rows = connection.execute(
"SELECT COUNT(*) FROM " + '"' + table_name.replace('"', '""') + '"'
).fetchone()[0]
inventory_rows.append(
{
"table": table_name,
"grain/key": ", ".join(metadata["primary_key"]),
"columns": len(metadata["columns"]),
"source_rows": metadata["source_row_count"],
"exported_rows": metadata["exported_row_count"],
"local_rows": local_rows,
"foreign_keys": len(metadata["foreign_keys"]),
"row_counts_match": (
metadata["source_row_count"] == metadata["exported_row_count"] == local_rows
),
}
)
table_inventory = pd.DataFrame(inventory_rows).sort_values("table").reset_index(drop=True)
display(table_inventory)
assert table_inventory["row_counts_match"].all()
relationship_summary = pd.DataFrame(relationship_report["relationships"])
display(
relationship_summary[
["table", "columns", "referenced_table", "referenced_columns", "orphan_count", "valid"]
]
)
assert relationship_report["valid"]
assert relationship_summary["valid"].all()| table | grain/key | columns | source_rows | exported_rows | local_rows | foreign_keys | row_counts_match | |
|---|---|---|---|---|---|---|---|---|
| 0 | account | account_id | 4 | 4500 | 4500 | 4500 | 1 | True |
| 1 | card | card_id | 4 | 892 | 892 | 892 | 1 | True |
| 2 | client | client_id | 4 | 5369 | 5369 | 5369 | 1 | True |
| 3 | disp | disp_id | 4 | 5369 | 5369 | 5369 | 2 | True |
| 4 | district | district_id | 16 | 77 | 77 | 77 | 0 | True |
| 5 | loan | loan_id | 7 | 682 | 682 | 682 | 1 | True |
| 6 | order | order_id | 6 | 6471 | 6471 | 6471 | 1 | True |
| 7 | trans | trans_id | 10 | 1056320 | 1056320 | 1056320 | 1 | True |
| table | columns | referenced_table | referenced_columns | orphan_count | valid | |
|---|---|---|---|---|---|---|
| 0 | account | [district_id] | district | [district_id] | 0 | True |
| 1 | card | [disp_id] | disp | [disp_id] | 0 | True |
| 2 | client | [district_id] | district | [district_id] | 0 | True |
| 3 | disp | [account_id] | account | [account_id] | 0 | True |
| 4 | disp | [client_id] | client | [client_id] | 0 | True |
| 5 | loan | [account_id] | account | [account_id] | 0 | True |
| 6 | order | [account_id] | account | [account_id] | 0 | True |
| 7 | trans | [account_id] | account | [account_id] | 0 | True |
The raw snapshot is never modified. Every Silver output below is derived from the validated DuckDB database and can be reproduced by rerunning this notebook.
Data quality is assessed before changing values. Missingness is interpreted in context: a null counterparty bank may be structurally correct for a cash transaction, while a missing district statistic is an incomplete observation.
Primary-key duplication and referential integrity are hard failures. Context-dependent missing values and valid extremes are documented rather than removed automatically.
quality_rows = []
for table_name, metadata in manifest["tables"].items():
quoted_table = '"' + table_name.replace('"', '""') + '"'
primary_key = metadata["primary_key"]
pk_columns = ", ".join('"' + column.replace('"', '""') + '"' for column in primary_key)
duplicate_pk_count = connection.execute(
"SELECT COUNT(*) FROM ("
+ "SELECT "
+ pk_columns
+ ", COUNT(*) AS n FROM "
+ quoted_table
+ " GROUP BY "
+ pk_columns
+ " HAVING COUNT(*) > 1)"
).fetchone()[0]
null_pk_condition = " OR ".join(
'"' + column.replace('"', '""') + '" IS NULL' for column in primary_key
)
null_pk_count = connection.execute(
"SELECT COUNT(*) FROM " + quoted_table + " WHERE " + null_pk_condition
).fetchone()[0]
for column in metadata["columns"]:
quoted_column = '"' + column["name"].replace('"', '""') + '"'
missing_count = connection.execute(
"SELECT COUNT(*) FILTER (WHERE "
+ quoted_column
+ " IS NULL) FROM "
+ quoted_table
).fetchone()[0]
if missing_count:
quality_rows.append(
{
"table": table_name,
"column": column["name"],
"missing_count": missing_count,
"missing_pct": 100 * missing_count / metadata["source_row_count"],
}
)
assert duplicate_pk_count == 0, table_name + " contains duplicate primary keys"
assert null_pk_count == 0, table_name + " contains null primary keys"
missingness = pd.DataFrame(quality_rows).sort_values(
["missing_pct", "table"], ascending=[False, True]
)
display(missingness.style.format({"missing_pct": "{:.2f}%"}))| table | column | missing_count | missing_pct | |
|---|---|---|---|---|
| 4 | trans | bank | 782812 | 74.11% |
| 5 | trans | account | 760931 | 72.04% |
| 3 | trans | k_symbol | 481881 | 45.62% |
| 2 | trans | operation | 183114 | 17.34% |
| 0 | district | A12 | 1 | 1.30% |
| 1 | district | A15 | 1 | 1.30% |
categorical_queries = {
"account.frequency": "SELECT frequency AS value, COUNT(*) AS rows FROM account GROUP BY 1 ORDER BY 2 DESC",
"disp.type": "SELECT type AS value, COUNT(*) AS rows FROM disp GROUP BY 1 ORDER BY 2 DESC",
"card.type": "SELECT type AS value, COUNT(*) AS rows FROM card GROUP BY 1 ORDER BY 2 DESC",
"loan.status": "SELECT status AS value, COUNT(*) AS rows FROM loan GROUP BY 1 ORDER BY 1",
"trans.type": "SELECT type AS value, COUNT(*) AS rows FROM trans GROUP BY 1 ORDER BY 2 DESC",
"trans.operation": "SELECT COALESCE(operation, '<NULL>') AS value, COUNT(*) AS rows FROM trans GROUP BY 1 ORDER BY 2 DESC",
"trans.k_symbol": "SELECT COALESCE(NULLIF(TRIM(k_symbol), ''), '<UNSPECIFIED>') AS value, COUNT(*) AS rows FROM trans GROUP BY 1 ORDER BY 2 DESC",
}
categorical_profiles = []
for field, query in categorical_queries.items():
profile = connection.execute(query).fetchdf()
profile.insert(0, "field", field)
categorical_profiles.append(profile)
display(pd.concat(categorical_profiles, ignore_index=True))
date_coverage = connection.execute(
"""
SELECT 'account' AS table_name, MIN(date) AS first_date, MAX(date) AS last_date
FROM account
UNION ALL
SELECT 'card', MIN(issued), MAX(issued) FROM card
UNION ALL
SELECT 'loan', MIN(date), MAX(date) FROM loan
UNION ALL
SELECT 'trans', MIN(date), MAX(date) FROM trans
"""
).fetchdf()
display(date_coverage)| field | value | rows | |
|---|---|---|---|
| 0 | account.frequency | POPLATEK MESICNE | 4167 |
| 1 | account.frequency | POPLATEK TYDNE | 240 |
| 2 | account.frequency | POPLATEK PO OBRATU | 93 |
| 3 | disp.type | OWNER | 4500 |
| 4 | disp.type | DISPONENT | 869 |
| 5 | card.type | classic | 659 |
| 6 | card.type | junior | 145 |
| 7 | card.type | gold | 88 |
| 8 | loan.status | A | 203 |
| 9 | loan.status | B | 31 |
| 10 | loan.status | C | 403 |
| 11 | loan.status | D | 45 |
| 12 | trans.type | VYDAJ | 634571 |
| 13 | trans.type | PRIJEM | 405083 |
| 14 | trans.type | VYBER | 16666 |
| 15 | trans.operation | VYBER | 434918 |
| 16 | trans.operation | PREVOD NA UCET | 208283 |
| 17 | trans.operation | <NULL> | 183114 |
| 18 | trans.operation | VKLAD | 156743 |
| 19 | trans.operation | PREVOD Z UCTU | 65226 |
| 20 | trans.operation | VYBER KARTOU | 8036 |
| 21 | trans.k_symbol | <UNSPECIFIED> | 535314 |
| 22 | trans.k_symbol | UROK | 183114 |
| 23 | trans.k_symbol | SLUZBY | 155832 |
| 24 | trans.k_symbol | SIPO | 118065 |
| 25 | trans.k_symbol | DUCHOD | 30338 |
| 26 | trans.k_symbol | POJISTNE | 18500 |
| 27 | trans.k_symbol | UVER | 13580 |
| 28 | trans.k_symbol | SANKC. UROK | 1577 |
| table_name | first_date | last_date | |
|---|---|---|---|
| 0 | account | 1993-01-01 | 1997-12-29 |
| 1 | card | 1993-11-07 | 1998-12-29 |
| 2 | loan | 1993-07-05 | 1998-12-08 |
| 3 | trans | 1993-01-01 | 1998-12-31 |
Missing values
Dates
Dates are already typed as DATE by the local builder. Clean tables rename them explicitly and derive calendar fields only when those fields have analytical meaning.
Categorical standardization
Original Czech codes remain available in fields ending in _raw. English analytical labels are added with explicit mappings. Unrecognized codes fail validation rather than silently becoming missing.
Numerical transformations
Amounts and balances are not scaled in Silver data. Amount distributions may later use log1p or robust scaling, but negative balances require separate treatment because log1p is not defined for sufficiently negative values.
Scaling and encoding
Scaling and one-hot encoding belong inside model-specific scikit-learn pipelines fitted on training data. Persisted Silver tables contain interpretable values.
Feature interactions
Signed transaction amount, service-count indicators, cash-flow ratios, activity rates, and balance volatility are candidate interactions. Only signed amount and static service indicators are introduced here; behavioral features are developed in Notebook 2.
Leakage prevention
Any loan feature derived from transactions must use transactions dated on or before the loan date. Forecasts use chronological splits. Clustering preprocessing is fitted without using future evaluation data.
ACCOUNT_FREQUENCY_MAP = {
"POPLATEK MESICNE": "monthly",
"POPLATEK TYDNE": "weekly",
"POPLATEK PO OBRATU": "after_transaction",
}
TRANSACTION_DIRECTION_MAP = {
"PRIJEM": "inflow",
"VYDAJ": "outflow",
"VYBER": "outflow",
}
TRANSACTION_OPERATION_MAP = {
"VYBER KARTOU": "card_withdrawal",
"VKLAD": "cash_deposit",
"PREVOD Z UCTU": "incoming_transfer",
"VYBER": "cash_withdrawal",
"PREVOD NA UCET": "outgoing_transfer",
}
TRANSACTION_PURPOSE_MAP = {
"POJISTNE": "insurance",
"SLUZBY": "statement_fee",
"UROK": "interest_credit",
"SANKC. UROK": "penalty_interest",
"SIPO": "household_payment",
"DUCHOD": "pension",
"UVER": "loan_payment",
}
LOAN_STATUS_MAP = {
"A": "completed_no_problems",
"B": "completed_with_problems",
"C": "active_no_problems",
"D": "active_with_problems",
}
mapping_checks = {
"account.frequency": (
set(connection.execute("SELECT DISTINCT frequency FROM account").fetchnumpy()["frequency"])
- set(ACCOUNT_FREQUENCY_MAP)
),
"trans.type": (
set(connection.execute("SELECT DISTINCT type FROM trans").fetchnumpy()["type"])
- set(TRANSACTION_DIRECTION_MAP)
),
"trans.operation": (
set(
connection.execute(
"SELECT DISTINCT operation FROM trans WHERE operation IS NOT NULL"
).fetchnumpy()["operation"]
)
- set(TRANSACTION_OPERATION_MAP)
),
"trans.k_symbol": (
set(
connection.execute(
"SELECT DISTINCT TRIM(k_symbol) AS k_symbol FROM trans "
"WHERE NULLIF(TRIM(k_symbol), '') IS NOT NULL"
).fetchnumpy()["k_symbol"]
)
- set(TRANSACTION_PURPOSE_MAP)
),
"loan.status": (
set(connection.execute("SELECT DISTINCT status FROM loan").fetchnumpy()["status"])
- set(LOAN_STATUS_MAP)
),
}
mapping_validation = pd.DataFrame(
[
{"field": field, "unmapped_values": sorted(values), "valid": not values}
for field, values in mapping_checks.items()
]
)
display(mapping_validation)
assert mapping_validation["valid"].all()| field | unmapped_values | valid | |
|---|---|---|---|
| 0 | account.frequency | [] | True |
| 1 | trans.type | [] | True |
| 2 | trans.operation | [] | True |
| 3 | trans.k_symbol | [] | True |
| 4 | loan.status | [] | True |
Invalid values violate schema or domain rules—for example, duplicate transaction IDs, negative transaction amounts, impossible dates, or orphaned foreign keys.
Valid extremes are unusual but permissible observations—for example, a large transfer or a negative balance. They are retained and may motivate robust transformations.
Behavioral outliers are observations that deviate from a contextual baseline. They can only be identified after defining comparable accounts or segments.
Therefore:
Invalid value ≠ valid extreme value ≠ behavioral outlier
extreme_value_audit = connection.execute(
"""
SELECT
COUNT(*) FILTER (WHERE amount < 0) AS invalid_negative_amount_rows,
COUNT(*) FILTER (WHERE balance < 0) AS negative_balance_rows,
COUNT(DISTINCT account_id) FILTER (WHERE balance < 0) AS accounts_with_negative_balance,
MIN(amount) AS minimum_amount,
MAX(amount) AS maximum_amount,
MIN(balance) AS minimum_balance,
MAX(balance) AS maximum_balance
FROM trans
"""
).fetchdf()
display(extreme_value_audit)
assert extreme_value_audit.loc[0, "invalid_negative_amount_rows"] == 0| invalid_negative_amount_rows | negative_balance_rows | accounts_with_negative_balance | minimum_amount | maximum_amount | minimum_balance | maximum_balance | |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 2999 | 288 | 0 | 87400 | -41126 | 209637 |
The Silver outputs remain human-readable. When a later notebook trains a model, numeric imputation/scaling and categorical imputation/encoding will be fitted inside a pipeline. This prevents statistics from the evaluation data leaking into training transformations.
def make_preprocessor(
numeric_features: list[str],
categorical_features: list[str],
) -> ColumnTransformer:
numeric_pipeline = Pipeline(
steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
]
)
categorical_pipeline = Pipeline(
steps=[
("imputer", SimpleImputer(strategy="most_frequent")),
("encoder", OneHotEncoder(handle_unknown="ignore")),
]
)
return ColumnTransformer(
transformers=[
("numeric", numeric_pipeline, numeric_features),
("categorical", categorical_pipeline, categorical_features),
],
remainder="drop",
)
preprocessor_example = make_preprocessor(
numeric_features=["average_balance", "monthly_transaction_count"],
categorical_features=["statement_frequency"],
)
preprocessor_exampleColumnTransformer(transformers=[('numeric',
Pipeline(steps=[('imputer',
SimpleImputer(strategy='median')),
('scaler', StandardScaler())]),
['average_balance',
'monthly_transaction_count']),
('categorical',
Pipeline(steps=[('imputer',
SimpleImputer(strategy='most_frequent')),
('encoder',
OneHotEncoder(handle_unknown='ignore'))]),
['statement_frequency'])])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. ['average_balance', 'monthly_transaction_count']
['statement_frequency']
The project uses a local, batch-oriented medallion pattern:
A warehouse emphasizes curated structured data and governed reporting. A data lake stores flexible raw files but can suffer from weak governance. A lakehouse combines low-cost file storage with schema, transactions, and analytical management. This project is a small local analogue: files provide reproducibility, DuckDB provides relational querying, and Parquet provides portable analytical storage.
The source is static and modest enough for batch processing. Streaming would add complexity without analytical benefit. At larger scale, object storage could hold Bronze and Silver Parquet data, a catalog could manage schemas and lineage, and a cloud warehouse or lakehouse engine could serve governed Gold tables.
Governance, versioning, and privacy: code and non-sensitive manifests belong in version control; raw and derived banking records remain excluded. Checksums identify the exact source snapshot, schemas and validation reports preserve lineage, and regenerated outputs inherit that provenance. A deployed system would add role-based access, encryption, retention policies, audit logs, and suppression of account/client identifiers from reports.
fig, ax = plt.subplots(figsize=(13, 3.8))
ax.set_xlim(0, 13)
ax.set_ylim(0, 4)
ax.axis("off")
layers = [
(0.3, "#E8F1FB", "SOURCE", "External CTU MariaDB\nNo repository folder\n8 relational tables"),
(3.0, "#FFF1D6", "BRONZE", "data/raw/\nCSV + DDL\nmanifest + checksums"),
(6.0, "#E2F2E8", "SILVER", "data/interim/\nValidated DuckDB\nclean Parquet tables"),
(9.3, "#F1E7F8", "GOLD", "data/processed/\nFeatures + segments\nanalytical outputs"),
]
for x, color, title, detail in layers:
box = FancyBboxPatch(
(x, 1.15),
2.4,
1.7,
boxstyle="round,pad=0.08,rounding_size=0.12",
facecolor=color,
edgecolor="#34495E",
linewidth=1.2,
)
ax.add_patch(box)
ax.text(x + 1.2, 2.35, title, ha="center", va="center", fontsize=11, weight="bold")
ax.text(x + 1.2, 1.72, detail, ha="center", va="center", fontsize=9)
for start, end in [(2.7, 3.0), (5.4, 6.0), (8.4, 9.3)]:
ax.annotate("", xy=(end, 2.0), xytext=(start, 2.0), arrowprops={"arrowstyle": "->", "lw": 1.8})
ax.text(6.5, 3.55, "Local batch architecture with reproducible lineage", ha="center", fontsize=13)
plt.show()
Four outputs are produced at explicit grains:
| Output | Grain | Purpose |
|---|---|---|
| transactions_clean.parquet | Transaction | Standardized transaction analysis |
| accounts_clients.parquet | Client–account disposition | Ownership and authorized-user relationships |
| loans_clean.parquet | Loan | Interpretable loan status and future bounded modelling |
| account_analytical_base.parquet | Account | One-row-per-account base for behavioral feature engineering |
Source columns are preserved or renamed transparently. Standardized labels are added rather than overwriting raw categorical values.
TRANSACTIONS_SQL = """
SELECT
trans_id,
account_id,
date AS transaction_date,
YEAR(date) AS transaction_year,
MONTH(date) AS transaction_month_number,
STRFTIME(date, '%Y-%m') AS transaction_month,
type AS transaction_type_raw,
CASE type
WHEN 'PRIJEM' THEN 'inflow'
WHEN 'VYDAJ' THEN 'outflow'
WHEN 'VYBER' THEN 'outflow'
END AS transaction_direction,
operation AS operation_raw,
CASE operation
WHEN 'VYBER KARTOU' THEN 'card_withdrawal'
WHEN 'VKLAD' THEN 'cash_deposit'
WHEN 'PREVOD Z UCTU' THEN 'incoming_transfer'
WHEN 'VYBER' THEN 'cash_withdrawal'
WHEN 'PREVOD NA UCET' THEN 'outgoing_transfer'
ELSE NULL
END AS operation,
k_symbol AS purpose_raw,
CASE NULLIF(TRIM(k_symbol), '')
WHEN 'POJISTNE' THEN 'insurance'
WHEN 'SLUZBY' THEN 'statement_fee'
WHEN 'UROK' THEN 'interest_credit'
WHEN 'SANKC. UROK' THEN 'penalty_interest'
WHEN 'SIPO' THEN 'household_payment'
WHEN 'DUCHOD' THEN 'pension'
WHEN 'UVER' THEN 'loan_payment'
ELSE 'unspecified'
END AS transaction_purpose,
amount,
CASE WHEN type = 'PRIJEM' THEN amount ELSE -amount END AS signed_amount,
balance,
bank AS counterparty_bank,
account AS counterparty_account
FROM trans
"""
ACCOUNTS_CLIENTS_SQL = """
WITH card_by_disposition AS (
SELECT
disp_id,
COUNT(*) AS card_count,
MIN(issued) AS first_card_issued,
STRING_AGG(DISTINCT type, ', ' ORDER BY type) AS card_types
FROM card
GROUP BY disp_id
)
SELECT
a.account_id,
a.district_id AS account_district_id,
a.frequency AS statement_frequency_raw,
CASE a.frequency
WHEN 'POPLATEK MESICNE' THEN 'monthly'
WHEN 'POPLATEK TYDNE' THEN 'weekly'
WHEN 'POPLATEK PO OBRATU' THEN 'after_transaction'
END AS statement_frequency,
a.date AS account_open_date,
d.disp_id,
d.type AS relationship_type,
c.client_id,
c.gender,
c.birth_date,
c.district_id AS client_district_id,
COALESCE(cb.card_count, 0) AS card_count,
cb.first_card_issued,
cb.card_types
FROM account AS a
JOIN disp AS d USING (account_id)
JOIN client AS c USING (client_id)
LEFT JOIN card_by_disposition AS cb USING (disp_id)
"""
LOANS_SQL = """
SELECT
loan_id,
account_id,
date AS loan_date,
amount AS loan_amount,
duration AS duration_months,
payments AS monthly_payment,
status AS loan_status_raw,
CASE status
WHEN 'A' THEN 'completed_no_problems'
WHEN 'B' THEN 'completed_with_problems'
WHEN 'C' THEN 'active_no_problems'
WHEN 'D' THEN 'active_with_problems'
END AS loan_status,
status IN ('A', 'B') AS is_completed,
CASE
WHEN status = 'A' THEN FALSE
WHEN status = 'B' THEN TRUE
ELSE NULL
END AS completed_repayment_problem
FROM loan
"""
ACCOUNT_BASE_SQL = """
WITH owner AS (
SELECT
d.account_id,
c.client_id AS owner_client_id,
c.gender AS owner_gender,
c.birth_date AS owner_birth_date,
c.district_id AS owner_district_id
FROM disp AS d
JOIN client AS c USING (client_id)
WHERE d.type = 'OWNER'
),
relationship_summary AS (
SELECT
account_id,
COUNT(*) AS client_count,
COUNT(*) FILTER (WHERE type = 'DISPONENT') AS disponent_count
FROM disp
GROUP BY account_id
),
card_summary AS (
SELECT
d.account_id,
COUNT(*) AS card_count,
MIN(c.issued) AS first_card_issued,
STRING_AGG(DISTINCT c.type, ', ' ORDER BY c.type) AS card_types
FROM disp AS d
JOIN card AS c USING (disp_id)
GROUP BY d.account_id
),
loan_summary AS (
SELECT account_id, COUNT(*) AS loan_count
FROM loan
GROUP BY account_id
),
order_summary AS (
SELECT
account_id,
COUNT(*) AS standing_order_count,
SUM(amount) AS standing_order_total_amount
FROM "order"
GROUP BY account_id
),
transaction_summary AS (
SELECT
account_id,
COUNT(*) AS transaction_count,
MIN(date) AS first_transaction_date,
MAX(date) AS last_transaction_date
FROM trans
GROUP BY account_id
)
SELECT
a.account_id,
a.district_id AS account_district_id,
dist.A2 AS account_district_name,
dist.A3 AS account_region,
dist.A4 AS district_inhabitants,
dist.A10 AS district_urbanization_pct,
dist.A11 AS district_average_salary,
dist.A12 AS district_unemployment_1995,
dist.A13 AS district_unemployment_1996,
dist.A14 AS district_entrepreneurs_per_1000,
dist.A15 AS district_crimes_1995,
dist.A16 AS district_crimes_1996,
a.frequency AS statement_frequency_raw,
CASE a.frequency
WHEN 'POPLATEK MESICNE' THEN 'monthly'
WHEN 'POPLATEK TYDNE' THEN 'weekly'
WHEN 'POPLATEK PO OBRATU' THEN 'after_transaction'
END AS statement_frequency,
a.date AS account_open_date,
o.owner_client_id,
o.owner_gender,
o.owner_birth_date,
o.owner_district_id,
rs.client_count,
rs.disponent_count,
rs.disponent_count > 0 AS has_disponent,
COALESCE(cs.card_count, 0) AS card_count,
COALESCE(cs.card_count, 0) > 0 AS has_card,
cs.first_card_issued,
cs.card_types,
COALESCE(ls.loan_count, 0) AS loan_count,
COALESCE(ls.loan_count, 0) > 0 AS has_loan,
COALESCE(os.standing_order_count, 0) AS standing_order_count,
COALESCE(os.standing_order_count, 0) > 0 AS has_standing_order,
COALESCE(os.standing_order_total_amount, 0) AS standing_order_total_amount,
ts.transaction_count,
ts.first_transaction_date,
ts.last_transaction_date
FROM account AS a
JOIN district AS dist USING (district_id)
JOIN owner AS o USING (account_id)
JOIN relationship_summary AS rs USING (account_id)
LEFT JOIN card_summary AS cs USING (account_id)
LEFT JOIN loan_summary AS ls USING (account_id)
LEFT JOIN order_summary AS os USING (account_id)
JOIN transaction_summary AS ts USING (account_id)
"""
silver_outputs = {
"transactions_clean.parquet": TRANSACTIONS_SQL,
"accounts_clients.parquet": ACCOUNTS_CLIENTS_SQL,
"loans_clean.parquet": LOANS_SQL,
"account_analytical_base.parquet": ACCOUNT_BASE_SQL,
}
def sql_literal(path: Path) -> str:
return "'" + str(path).replace("'", "''") + "'"
for filename, query in silver_outputs.items():
output_path = INTERIM_DIR / filename
connection.execute(
"COPY (" + query.strip().rstrip(";") + ") TO "
+ sql_literal(output_path)
+ " (FORMAT PARQUET, COMPRESSION ZSTD)"
)
print("Wrote", output_path.relative_to(PROJECT_ROOT))Wrote data/interim/transactions_clean.parquet
Wrote data/interim/accounts_clients.parquet
Wrote data/interim/loans_clean.parquet
Wrote data/interim/account_analytical_base.parquet
expected_rows = {
"transactions_clean.parquet": manifest["tables"]["trans"]["source_row_count"],
"accounts_clients.parquet": manifest["tables"]["disp"]["source_row_count"],
"loans_clean.parquet": manifest["tables"]["loan"]["source_row_count"],
"account_analytical_base.parquet": manifest["tables"]["account"]["source_row_count"],
}
primary_keys = {
"transactions_clean.parquet": ["trans_id"],
"accounts_clients.parquet": ["disp_id"],
"loans_clean.parquet": ["loan_id"],
"account_analytical_base.parquet": ["account_id"],
}
validation_rows = []
for filename, expected_count in expected_rows.items():
path = INTERIM_DIR / filename
actual_count = connection.execute(
"SELECT COUNT(*) FROM read_parquet(" + sql_literal(path) + ")"
).fetchone()[0]
key_expression = ", ".join('"' + key + '"' for key in primary_keys[filename])
duplicate_keys = connection.execute(
"SELECT COUNT(*) FROM (SELECT "
+ key_expression
+ ", COUNT(*) AS n FROM read_parquet("
+ sql_literal(path)
+ ") GROUP BY "
+ key_expression
+ " HAVING COUNT(*) > 1)"
).fetchone()[0]
validation_rows.append(
{
"output": filename,
"expected_rows": expected_count,
"actual_rows": actual_count,
"duplicate_keys": duplicate_keys,
"size_mb": round(path.stat().st_size / 1_048_576, 3),
"valid": actual_count == expected_count and duplicate_keys == 0,
}
)
output_validation = pd.DataFrame(validation_rows)
display(output_validation)
assert output_validation["valid"].all()
transaction_mapping_validation = connection.execute(
"SELECT "
"COUNT(*) FILTER (WHERE transaction_direction IS NULL) AS unmapped_directions, "
"COUNT(*) FILTER (WHERE transaction_purpose IS NULL) AS null_standardized_purpose "
"FROM read_parquet(" + sql_literal(INTERIM_DIR / "transactions_clean.parquet") + ")"
).fetchdf()
display(transaction_mapping_validation)
assert transaction_mapping_validation.loc[0, "unmapped_directions"] == 0
assert transaction_mapping_validation.loc[0, "null_standardized_purpose"] == 0| output | expected_rows | actual_rows | duplicate_keys | size_mb | valid | |
|---|---|---|---|---|---|---|
| 0 | transactions_clean.parquet | 1056320 | 1056320 | 0 | 7.636 | True |
| 1 | accounts_clients.parquet | 5369 | 5369 | 0 | 0.065 | True |
| 2 | loans_clean.parquet | 682 | 682 | 0 | 0.010 | True |
| 3 | account_analytical_base.parquet | 4500 | 4500 | 0 | 0.121 | True |
| unmapped_directions | null_standardized_purpose | |
|---|---|---|
| 0 | 0 | 0 |
Next notebook: 02_behavioral_and_temporal_analysis.ipynb will perform EDA, construct behavioral features, analyze temporal patterns, and prepare association-rule inputs. It will read the Silver Parquet outputs created here rather than repeat ingestion or cleaning.