rapidsegment 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rapidsegment/__init__.py +8 -0
- rapidsegment/builder.py +625 -0
- rapidsegment/py.typed +0 -0
- rapidsegment/scorer.py +174 -0
- rapidsegment/utils/__init__.py +3 -0
- rapidsegment/utils/data_loader.py +170 -0
- rapidsegment/utils/on_gcp_feature_selection.py +271 -0
- rapidsegment/utils/undersampler.sql +14 -0
- rapidsegment-1.0.0.dist-info/METADATA +470 -0
- rapidsegment-1.0.0.dist-info/RECORD +11 -0
- rapidsegment-1.0.0.dist-info/WHEEL +4 -0
rapidsegment/scorer.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Strategic Segmentation Scorecard Engine
|
|
3
|
+
=========================================
|
|
4
|
+
Creating scorecards for multi-segmented populations using vectorized DuckDB aggregations.
|
|
5
|
+
|
|
6
|
+
Author: Bishwarup Biswas + Gemini
|
|
7
|
+
Python Version: 3.9+
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, Dict, List, Union
|
|
14
|
+
import duckdb
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
# 1. Get current date and time
|
|
18
|
+
now = datetime.now()
|
|
19
|
+
|
|
20
|
+
# Example output: "2026_07_11_22_24_30"
|
|
21
|
+
timestamp = now.strftime("%Y_%m_%d_%H_%M_%S")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Configure Production Module Logger
|
|
25
|
+
logging.basicConfig(
|
|
26
|
+
level=logging.INFO,
|
|
27
|
+
format="%(asctime)s | %(levelname)-8s | [%(filename)s:%(lineno)d] | %(message)s",
|
|
28
|
+
)
|
|
29
|
+
logger = logging.getLogger("StrategicEngine")
|
|
30
|
+
|
|
31
|
+
class StrategicSegmentScore:
|
|
32
|
+
"""High-Throughput Vectorized Scorecard Engine.
|
|
33
|
+
|
|
34
|
+
Computes segment weights via Harmonic Mean and applies deciling
|
|
35
|
+
over large datasets natively inside DuckDB's out-of-core engine.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self, target_col: str, primary_key: str, segment_cols: List[str]
|
|
40
|
+
) -> None:
|
|
41
|
+
self.target_col = target_col
|
|
42
|
+
self.primary_key = primary_key
|
|
43
|
+
self.segment_cols = segment_cols
|
|
44
|
+
self.model_artifact: Dict[str, Any] = {}
|
|
45
|
+
|
|
46
|
+
def calculate_and_export_weights(
|
|
47
|
+
self, data: Any, export_path: str = f"scored_experiment_{timestamp}.json"
|
|
48
|
+
) -> Dict[str, Any]:
|
|
49
|
+
"""Calculates harmonic weights and derives decile boundaries via vectorized execution."""
|
|
50
|
+
logger.info(f"Initializing out-of-core DuckDB scorecard engine...")
|
|
51
|
+
|
|
52
|
+
# Memory Optimization: File-backed engine for 100M+ rows
|
|
53
|
+
if os.path.exists(f"score_experiment_{timestamp}.db"):
|
|
54
|
+
os.remove(f"score_experiment_{timestamp}.db")
|
|
55
|
+
ctx = duckdb.connect(f"score_experiment_{timestamp}.db")
|
|
56
|
+
ctx.execute("CREATE OR REPLACE TABLE df AS SELECT * FROM data")
|
|
57
|
+
|
|
58
|
+
# Step 1: Baseline metrics + Vectorized multi-segment aggregation (O(1) Scan)
|
|
59
|
+
agg_expressions = [
|
|
60
|
+
f'COUNT(CASE WHEN "{col}" = 1 THEN 1 END) AS "{col}_cnt", '
|
|
61
|
+
f'SUM(CASE WHEN "{col}" = 1 THEN "{self.target_col}" ELSE 0 END) AS "{col}_ev"'
|
|
62
|
+
for col in self.segment_cols
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
master_sql = f"""
|
|
66
|
+
SELECT
|
|
67
|
+
COUNT(*) AS total_pop,
|
|
68
|
+
SUM(CAST("{self.target_col}" AS DOUBLE)) AS total_ev,
|
|
69
|
+
{', '.join(agg_expressions)}
|
|
70
|
+
FROM df
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
master_res = ctx.execute(master_sql).fetchone()
|
|
74
|
+
if not master_res:
|
|
75
|
+
raise RuntimeError("Database engine failed to return aggregations.")
|
|
76
|
+
|
|
77
|
+
total_population, total_events = master_res[0], master_res[1]
|
|
78
|
+
|
|
79
|
+
if total_population == 0 or total_events == 0:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"Invalid Dataset: Population and total events must be greater than zero."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
baseline_rate = total_events / total_population
|
|
85
|
+
zero_inflation_rate = 1.0 - baseline_rate
|
|
86
|
+
|
|
87
|
+
# Step 2: Unpack vectorized SQL aggregations into weight lookup
|
|
88
|
+
logger.info("Computing harmonic scorecard weights...")
|
|
89
|
+
weights_lookup: Dict[str, Dict[str, Union[int, float]]] = {}
|
|
90
|
+
|
|
91
|
+
for idx, seg_col in enumerate(self.segment_cols):
|
|
92
|
+
seg_count = master_res[2 + (idx * 2)] or 0
|
|
93
|
+
seg_events = master_res[2 + (idx * 2) + 1] or 0
|
|
94
|
+
|
|
95
|
+
if seg_count == 0 or seg_events == 0:
|
|
96
|
+
logger.warning(f"Segment '{seg_col}' has zero volume or events. Setting weight=0.")
|
|
97
|
+
weights_lookup[seg_col] = {
|
|
98
|
+
"weight": 0, "lift": 0.0, "response_rate": 0.0, "capture_rate": 0.0,
|
|
99
|
+
}
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
response_rate = seg_events / seg_count
|
|
103
|
+
capture_rate = seg_events / total_events
|
|
104
|
+
lift = response_rate / baseline_rate
|
|
105
|
+
|
|
106
|
+
harmonic_mean = 2 * ((response_rate * capture_rate) / (response_rate + capture_rate))
|
|
107
|
+
raw_weight = lift * harmonic_mean * 100.0
|
|
108
|
+
|
|
109
|
+
weights_lookup[seg_col] = {
|
|
110
|
+
"weight": int(round(raw_weight)),
|
|
111
|
+
"lift": round(lift, 4),
|
|
112
|
+
"response_rate": round(response_rate, 4),
|
|
113
|
+
"capture_rate": round(capture_rate, 4),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Step 3: Direct C++ SQL Matrix Math (Replaces NumPy Memory Bottleneck)
|
|
117
|
+
logger.info("Scoring 100M rows natively via C++ database engine...")
|
|
118
|
+
scored_cols = list(weights_lookup.keys())
|
|
119
|
+
|
|
120
|
+
if not scored_cols:
|
|
121
|
+
raise ValueError("Scorecard Failure: No valid segments found to score.")
|
|
122
|
+
|
|
123
|
+
score_terms = [f'(CAST("{col}" AS DOUBLE) * {weights_lookup[col]["weight"]})' for col in scored_cols]
|
|
124
|
+
score_math_expr = " + ".join(score_terms)
|
|
125
|
+
|
|
126
|
+
ctx.execute(f"""
|
|
127
|
+
CREATE OR REPLACE TABLE scored_population AS
|
|
128
|
+
SELECT "{self.primary_key}", ({score_math_expr}) AS total_score
|
|
129
|
+
FROM df
|
|
130
|
+
""")
|
|
131
|
+
|
|
132
|
+
logger.info(f"Dataset Zero-Inflation Rate: {zero_inflation_rate:.2%}")
|
|
133
|
+
|
|
134
|
+
# Step 4: Out-of-Core Decile Boundary Profiling via SQL Quantiles
|
|
135
|
+
logger.info("Calibrating deciles across active populations...")
|
|
136
|
+
|
|
137
|
+
# Handle zero-inflation masking completely on the database side
|
|
138
|
+
filter_clause = "WHERE total_score > 0" if zero_inflation_rate >= 0.80 else ""
|
|
139
|
+
|
|
140
|
+
# Calculate all 10 deciles in a single table scan using highly optimized C++ quantiles
|
|
141
|
+
quantile_query = f"""
|
|
142
|
+
SELECT QUANTILE_DISC(total_score, [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0])
|
|
143
|
+
FROM scored_population
|
|
144
|
+
{filter_clause}
|
|
145
|
+
"""
|
|
146
|
+
quantiles_res = ctx.execute(quantile_query).fetchone()
|
|
147
|
+
|
|
148
|
+
if not quantiles_res or not quantiles_res[0]:
|
|
149
|
+
raise ValueError("Scorecard Failure: 0 customers triggered any segment rules.")
|
|
150
|
+
|
|
151
|
+
quantiles = quantiles_res[0]
|
|
152
|
+
quantiles = quantiles[::-1] # Reverse list to match descending order paradigm
|
|
153
|
+
decile_thresholds = {str(i+1): int(quantiles[i]) for i in range(10)}
|
|
154
|
+
|
|
155
|
+
# Capture Active Population dynamically from the filtered subset
|
|
156
|
+
active_pop_size = ctx.execute(f"SELECT COUNT(*) FROM scored_population {filter_clause}").fetchone()[0]
|
|
157
|
+
|
|
158
|
+
# Step 5: JSON Generation
|
|
159
|
+
self.model_artifact = {
|
|
160
|
+
"model_metadata": {
|
|
161
|
+
"total_training_population": int(total_population),
|
|
162
|
+
"active_scored_population": int(active_pop_size),
|
|
163
|
+
"active_population_pct": round((active_pop_size / total_population) * 100.0, 2),
|
|
164
|
+
"baseline_event_rate": round(baseline_rate, 4),
|
|
165
|
+
},
|
|
166
|
+
"segment_weights": weights_lookup,
|
|
167
|
+
"decile_min_thresholds": decile_thresholds,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
with open(export_path, "w", encoding="utf-8") as f:
|
|
171
|
+
json.dump(self.model_artifact, f, indent=4)
|
|
172
|
+
|
|
173
|
+
ctx.close()
|
|
174
|
+
return self.model_artifact
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified Data Ingestion Layer for Strategic Analytics
|
|
3
|
+
===================================================
|
|
4
|
+
A multi-format data loader abstraction supporting Local Files (CSV, Parquet, Arrow, Excel),
|
|
5
|
+
In-Memory PyArrow Tables, and Google Cloud BigQuery Storage API streams.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Optional, Union, List
|
|
11
|
+
import pyarrow as pa
|
|
12
|
+
import pyarrow.csv as pa_csv
|
|
13
|
+
import pyarrow.parquet as pa_pq
|
|
14
|
+
import pyarrow.compute as pc # Added for vectorized schema casting
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("StrategicEngine.DataLoader")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UniversalDataLoader:
|
|
20
|
+
"""Handles multi-source data ingestion, normalizing inputs into highly optimized
|
|
21
|
+
in-memory structures compatible with vectorized down-stream compute engines.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
project_id: Optional[str] = None,
|
|
27
|
+
dataset_id: Optional[str] = None,
|
|
28
|
+
table_id: Optional[str] = None,
|
|
29
|
+
file_path: Optional[str] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.project_id = project_id
|
|
32
|
+
self.dataset_id = dataset_id
|
|
33
|
+
self.table_id = table_id
|
|
34
|
+
self.file_path = file_path
|
|
35
|
+
|
|
36
|
+
def load(self, fallback_data: Optional[Any] = None) -> Union[pa.Table, str]:
|
|
37
|
+
"""Auto-detects the source configuration parameters and loads the dataset.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
An ingestion asset (pa.Table or DuckDB macro string) ready for downstream consumption.
|
|
41
|
+
"""
|
|
42
|
+
# Scenario 1: Direct In-Memory Object Check
|
|
43
|
+
if fallback_data is not None:
|
|
44
|
+
if isinstance(fallback_data, pa.Table):
|
|
45
|
+
logger.info("Ingesting directly provided in-memory PyArrow Table.")
|
|
46
|
+
return self._cast_table_numerics_to_float(fallback_data)
|
|
47
|
+
return fallback_data
|
|
48
|
+
|
|
49
|
+
# Scenario 2: BigQuery Parameters Detected
|
|
50
|
+
if self.dataset_id and self.table_id:
|
|
51
|
+
return self._load_from_bigquery()
|
|
52
|
+
|
|
53
|
+
# Scenario 3: Local File Path Detection
|
|
54
|
+
if self.file_path:
|
|
55
|
+
return self._load_from_file()
|
|
56
|
+
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"Invalid Configuration: You must provide either a valid `file_path`, "
|
|
59
|
+
"BigQuery identifiers, or pass an explicit `fallback_data` object."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _cast_table_numerics_to_float(self, table: pa.Table) -> pa.Table:
|
|
63
|
+
"""Inspects a PyArrow Table schema and casts all numeric columns to float64."""
|
|
64
|
+
if not isinstance(table, pa.Table):
|
|
65
|
+
return table
|
|
66
|
+
|
|
67
|
+
new_columns = []
|
|
68
|
+
new_fields = []
|
|
69
|
+
|
|
70
|
+
for i, field in enumerate(table.schema):
|
|
71
|
+
# Evaluate if the type is integer, floating, or decimal
|
|
72
|
+
if (pa.types.is_integer(field.type) or
|
|
73
|
+
pa.types.is_floating(field.type) or
|
|
74
|
+
pa.types.is_decimal(field.type)):
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Cast the underlying data array to float64
|
|
78
|
+
casted_col = pc.cast(table.column(i), pa.float64(), safe=False)
|
|
79
|
+
new_columns.append(casted_col)
|
|
80
|
+
new_fields.append(pa.field(field.name, pa.float64(), nullable=field.nullable))
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.warning(f"Failed to cast column {field.name} to float64. Reason: {e}")
|
|
83
|
+
new_columns.append(table.column(i))
|
|
84
|
+
new_fields.append(field)
|
|
85
|
+
else:
|
|
86
|
+
new_columns.append(table.column(i))
|
|
87
|
+
new_fields.append(field)
|
|
88
|
+
|
|
89
|
+
return pa.Table.from_arrays(new_columns, schema=pa.schema(new_fields))
|
|
90
|
+
|
|
91
|
+
def _load_from_file(self) -> pa.Table:
|
|
92
|
+
"""Parses local files using high-performance C++ Arrow readers."""
|
|
93
|
+
if not os.path.exists(self.file_path):
|
|
94
|
+
raise FileNotFoundError(f"Data file not found at: {self.file_path}")
|
|
95
|
+
|
|
96
|
+
ext = os.path.splitext(self.file_path)[-1].lower()
|
|
97
|
+
table = None
|
|
98
|
+
|
|
99
|
+
if ext == ".parquet":
|
|
100
|
+
table = pa_pq.read_table(self.file_path)
|
|
101
|
+
elif ext == ".csv":
|
|
102
|
+
table = pa_csv.read_csv(self.file_path)
|
|
103
|
+
elif ext in [".arrow", ".feather"]:
|
|
104
|
+
with pa.memory_map(self.file_path, "r") as source:
|
|
105
|
+
table = pa.ipc.open_file(source).read_all()
|
|
106
|
+
elif ext in [".xlsx", ".xls"]:
|
|
107
|
+
table = self._load_excel_to_arrow()
|
|
108
|
+
else:
|
|
109
|
+
raise ValueError(f"Unsupported format: '{ext}'.")
|
|
110
|
+
|
|
111
|
+
# Apply standard numerical conversion to all successfully loaded local tables
|
|
112
|
+
return self._cast_table_numerics_to_float(table)
|
|
113
|
+
|
|
114
|
+
def _load_excel_to_arrow(self) -> pa.Table:
|
|
115
|
+
"""Parses Excel using positional tracking to prevent column misalignment."""
|
|
116
|
+
logger.info("Parsing Excel spreadsheet via positional column tracking...")
|
|
117
|
+
try:
|
|
118
|
+
import openpyxl
|
|
119
|
+
wb = openpyxl.load_workbook(self.file_path, data_only=True, read_only=True)
|
|
120
|
+
sheet = wb.active
|
|
121
|
+
rows = sheet.iter_rows(values_only=True)
|
|
122
|
+
|
|
123
|
+
headers = next(rows)
|
|
124
|
+
if not headers:
|
|
125
|
+
raise ValueError("The Excel file appears to be empty.")
|
|
126
|
+
|
|
127
|
+
# Fix: Track column names by index to prevent header-shift corruption
|
|
128
|
+
column_names = [f"{h}" if h else f"_col_{i}" for i, h in enumerate(headers)]
|
|
129
|
+
data_columns = {name: [] for name in column_names}
|
|
130
|
+
|
|
131
|
+
for row in rows:
|
|
132
|
+
for i, name in enumerate(column_names):
|
|
133
|
+
val = row[i] if i < len(row) else None
|
|
134
|
+
data_columns[name].append(val)
|
|
135
|
+
|
|
136
|
+
wb.close()
|
|
137
|
+
return pa.Table.from_pydict(data_columns)
|
|
138
|
+
except ImportError:
|
|
139
|
+
raise ImportError("Dependency missing: `pip install openpyxl` required.")
|
|
140
|
+
|
|
141
|
+
def _load_from_bigquery(self) -> Union[pa.Table, str]:
|
|
142
|
+
"""Resolves BigQuery ingestion using cost-optimized metadata inspection."""
|
|
143
|
+
try:
|
|
144
|
+
from google.cloud import bigquery
|
|
145
|
+
bq_client = bigquery.Client(project=self.project_id)
|
|
146
|
+
full_table_ref = f"{self.project_id or bq_client.project}.{self.dataset_id}.{self.table_id}"
|
|
147
|
+
|
|
148
|
+
# Optimization: Use get_table() instead of INFORMATION_SCHEMA to avoid slot costs
|
|
149
|
+
table = bq_client.get_table(full_table_ref)
|
|
150
|
+
|
|
151
|
+
select_clauses = []
|
|
152
|
+
for field in table.schema:
|
|
153
|
+
# Type normalization: Extended to intercept all kinds of integers, decimals, and floats
|
|
154
|
+
if field.field_type in ("NUMERIC", "BIGNUMERIC", "DECIMAL", "INTEGER", "INT64", "FLOAT", "FLOAT64"):
|
|
155
|
+
select_clauses.append(f"SAFE_CAST(`{field.name}` AS FLOAT64) AS `{field.name}`")
|
|
156
|
+
else:
|
|
157
|
+
select_clauses.append(f"`{field.name}`")
|
|
158
|
+
|
|
159
|
+
query = f"SELECT {', '.join(select_clauses)} FROM `{full_table_ref}`"
|
|
160
|
+
|
|
161
|
+
# No need to run _cast_table_numerics_to_float here as BQ natively hands back FLOAT64 based on the query.
|
|
162
|
+
return bq_client.query(query).to_arrow()
|
|
163
|
+
|
|
164
|
+
except ImportError:
|
|
165
|
+
# Fallback for environments without the GCP library installed
|
|
166
|
+
if not self.dataset_id or not self.table_id:
|
|
167
|
+
raise ValueError("BigQuery coordinates (dataset_id, table_id) required for scan.")
|
|
168
|
+
|
|
169
|
+
logger.warning("google-cloud-bigquery not found. Returning native DuckDB scan macro.")
|
|
170
|
+
return f"bigquery_scan('{self.project_id or 'default'}', '{self.dataset_id}', '{self.table_id}')"
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List, Tuple, Optional
|
|
3
|
+
import duckdb
|
|
4
|
+
from google.cloud import bigquery
|
|
5
|
+
|
|
6
|
+
# Configure enterprise-grade logging
|
|
7
|
+
logging.basicConfig(
|
|
8
|
+
level=logging.INFO,
|
|
9
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
10
|
+
)
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class BigQueryFeatureSelector:
|
|
14
|
+
"""
|
|
15
|
+
WARNING: This utility is designed for large-scale datasets and may incur costs on Google BigQuery.
|
|
16
|
+
A highly scalable feature selection utility designed for enterprise analytics pipelines.
|
|
17
|
+
Computes Information Value (IV) and standard deviation natively within Google BigQuery.
|
|
18
|
+
|
|
19
|
+
Optimizations:
|
|
20
|
+
- Single-hit schema extraction to reduce API latency.
|
|
21
|
+
- Dynamic Binary Profiling: Automatically detects numeric 0/1 flags and treats them
|
|
22
|
+
categorically.
|
|
23
|
+
- Batched SQL execution to safeguard BigQuery slot availability.
|
|
24
|
+
- DuckDB integration: Streams data via PyArrow to bypass Pandas completely.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
project_id (str): The Google Cloud project ID.
|
|
28
|
+
dataset_id (str): The BigQuery dataset ID.
|
|
29
|
+
table_id (str): The BigQuery table ID.
|
|
30
|
+
target_column (str): The name of the binary target variable (must be 0 or 1).
|
|
31
|
+
iv_threshold (float): Minimum Information Value required to retain a feature.
|
|
32
|
+
stddev_threshold (float): Minimum standard deviation required to retain a feature.
|
|
33
|
+
bins (int): The number of quantiles (ntiles) to use for the naive IV calculation.
|
|
34
|
+
batch_size (int): Number of columns to process per BigQuery job to prevent timeout.
|
|
35
|
+
binary_columns (Optional[List[str]]): Explicit list of binary columns. If None, auto-detected.
|
|
36
|
+
client (google.cloud.bigquery.Client): The BigQuery client instance.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
project_id: str,
|
|
42
|
+
dataset_id: str,
|
|
43
|
+
table_id: str,
|
|
44
|
+
target_column: str,
|
|
45
|
+
iv_threshold: float = 0.02,
|
|
46
|
+
stddev_threshold: float = 1e-5,
|
|
47
|
+
min_bin_n_event: int = 1,
|
|
48
|
+
min_bin_n_nonevent: int = 1,
|
|
49
|
+
bins: int = 10,
|
|
50
|
+
batch_size: int = 15,
|
|
51
|
+
binary_columns: Optional[List[str]] = None,
|
|
52
|
+
bq_client: Optional[bigquery.Client] = None
|
|
53
|
+
):
|
|
54
|
+
self.project_id = project_id
|
|
55
|
+
self.dataset_id = dataset_id
|
|
56
|
+
self.table_id = table_id
|
|
57
|
+
self.target_column = target_column
|
|
58
|
+
self.iv_threshold = iv_threshold
|
|
59
|
+
self.stddev_threshold = stddev_threshold
|
|
60
|
+
self.min_bin_n_event = min_bin_n_event
|
|
61
|
+
self.min_bin_n_nonevent = min_bin_n_nonevent
|
|
62
|
+
self.bins = bins
|
|
63
|
+
self.batch_size = batch_size
|
|
64
|
+
self.binary_columns = binary_columns
|
|
65
|
+
|
|
66
|
+
self.client = bq_client if bq_client else bigquery.Client(project=self.project_id)
|
|
67
|
+
self.full_table_path = f"`{self.project_id}.{self.dataset_id}.{self.table_id}`"
|
|
68
|
+
|
|
69
|
+
def _detect_binary_numerical_columns(self, numerical_columns: List[str]) -> List[str]:
|
|
70
|
+
"""Executes a single row query to count distinct values in numerical columns."""
|
|
71
|
+
if not numerical_columns:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
logger.info("Performing dynamic profiling on numerical columns to detect binary flags...")
|
|
75
|
+
|
|
76
|
+
select_expressions = [
|
|
77
|
+
f"COUNT(DISTINCT {col}) AS `{col}`" for col in numerical_columns
|
|
78
|
+
]
|
|
79
|
+
query = f"SELECT {', '.join(select_expressions)} FROM {self.full_table_path}"
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
job = self.client.query(query)
|
|
83
|
+
# Natively fetch the single resulting row iterator
|
|
84
|
+
row = next(job.result())
|
|
85
|
+
|
|
86
|
+
# Extract column names where the distinct count is <= 2
|
|
87
|
+
detected_binary = [
|
|
88
|
+
col for col in numerical_columns if row[col] <= 2
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
if detected_binary:
|
|
92
|
+
logger.info(f"Dynamically detected {len(detected_binary)} binary numeric columns: {detected_binary}")
|
|
93
|
+
else:
|
|
94
|
+
logger.info("No implicit binary numeric columns discovered.")
|
|
95
|
+
|
|
96
|
+
return detected_binary
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"Failed to dynamically profile numeric columns: {e}. Proceeding without auto-detection.")
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
def _get_table_schema(self) -> Tuple[List[str], List[str]]:
|
|
103
|
+
"""Retrieves the table schema and categorizes columns."""
|
|
104
|
+
logger.info(f"Fetching metadata schema for table: {self.full_table_path}")
|
|
105
|
+
table_ref = self.client.get_table(f"{self.project_id}.{self.dataset_id}.{self.table_id}")
|
|
106
|
+
|
|
107
|
+
num_types = {"INTEGER", "FLOAT", "NUMERIC", "BIGNUMERIC"}
|
|
108
|
+
cat_types = {"STRING", "BOOLEAN"}
|
|
109
|
+
|
|
110
|
+
raw_numerical = []
|
|
111
|
+
categorical_columns = []
|
|
112
|
+
|
|
113
|
+
for field in table_ref.schema:
|
|
114
|
+
if field.name == self.target_column:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
if field.field_type in num_types:
|
|
118
|
+
raw_numerical.append(field.name)
|
|
119
|
+
elif field.field_type in cat_types:
|
|
120
|
+
categorical_columns.append(field.name)
|
|
121
|
+
|
|
122
|
+
# Handle Binary Conversions
|
|
123
|
+
if self.binary_columns is not None:
|
|
124
|
+
logger.info("Applying user-defined binary column overrides.")
|
|
125
|
+
actual_binary = [col for col in self.binary_columns if col in raw_numerical]
|
|
126
|
+
else:
|
|
127
|
+
actual_binary = self._detect_binary_numerical_columns(raw_numerical)
|
|
128
|
+
|
|
129
|
+
numerical_columns = [col for col in raw_numerical if col not in actual_binary]
|
|
130
|
+
categorical_columns.extend(actual_binary)
|
|
131
|
+
|
|
132
|
+
logger.info(f"Final categorization: {len(numerical_columns)} continuous features, "
|
|
133
|
+
f"{len(categorical_columns)} categorical/binary features.")
|
|
134
|
+
return numerical_columns, categorical_columns
|
|
135
|
+
|
|
136
|
+
def _build_batch_query(self, numerical_chunk: List[str], categorical_chunk: List[str]) -> str:
|
|
137
|
+
"""Constructs the optimized SQL query for a specific batch of columns."""
|
|
138
|
+
sql_parts = [f"""
|
|
139
|
+
WITH global_stats AS (
|
|
140
|
+
SELECT
|
|
141
|
+
COUNTIF({self.target_column} = 0) AS total_goods,
|
|
142
|
+
COUNTIF({self.target_column} = 1) AS total_bads
|
|
143
|
+
FROM {self.full_table_path}
|
|
144
|
+
)
|
|
145
|
+
"""]
|
|
146
|
+
|
|
147
|
+
union_queries = []
|
|
148
|
+
|
|
149
|
+
# Defensive constraint clause shared across numerical and categorical blocks to ensure minimum bin counts for IV calculation and prevent any target leakage into feature space. This is crucial for enterprise-grade feature selection where data integrity is paramount.
|
|
150
|
+
iv_calculation_template = f"""
|
|
151
|
+
SUM(
|
|
152
|
+
CASE
|
|
153
|
+
WHEN
|
|
154
|
+
goods_in_bin < {self.min_bin_n_nonevent}
|
|
155
|
+
OR bads_in_bin < {self.min_bin_n_event}
|
|
156
|
+
THEN 0
|
|
157
|
+
ELSE
|
|
158
|
+
(
|
|
159
|
+
(goods_in_bin / NULLIF(total_goods, 0))
|
|
160
|
+
- (bads_in_bin / NULLIF(total_bads, 0)))
|
|
161
|
+
* LN(
|
|
162
|
+
((goods_in_bin / NULLIF(total_goods, 0)) + 0.0001)
|
|
163
|
+
/ ((bads_in_bin / NULLIF(total_bads, 0)) + 0.0001))
|
|
164
|
+
END) AS naive_iv
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
for col in numerical_chunk:
|
|
168
|
+
part = f"""
|
|
169
|
+
SELECT
|
|
170
|
+
'{col}' AS feature_name,
|
|
171
|
+
(SELECT STDDEV({col}) FROM {self.full_table_path}) AS feature_stddev,
|
|
172
|
+
{iv_calculation_template}
|
|
173
|
+
FROM
|
|
174
|
+
(
|
|
175
|
+
SELECT
|
|
176
|
+
bin,
|
|
177
|
+
COUNTIF({self.target_column} = 0) AS goods_in_bin,
|
|
178
|
+
COUNTIF({self.target_column} = 1) AS bads_in_bin
|
|
179
|
+
FROM
|
|
180
|
+
(
|
|
181
|
+
SELECT
|
|
182
|
+
NTILE({self.bins}) OVER (ORDER BY {col}) AS bin,
|
|
183
|
+
{self.target_column}
|
|
184
|
+
FROM {self.full_table_path}
|
|
185
|
+
WHERE {col} IS NOT NULL
|
|
186
|
+
)
|
|
187
|
+
GROUP BY bin
|
|
188
|
+
)
|
|
189
|
+
CROSS JOIN global_stats
|
|
190
|
+
"""
|
|
191
|
+
union_queries.append(part)
|
|
192
|
+
|
|
193
|
+
for col in categorical_chunk:
|
|
194
|
+
part = f"""
|
|
195
|
+
SELECT
|
|
196
|
+
'{col}' AS feature_name,
|
|
197
|
+
9999.0 AS feature_stddev,
|
|
198
|
+
{iv_calculation_template}
|
|
199
|
+
FROM
|
|
200
|
+
(
|
|
201
|
+
SELECT
|
|
202
|
+
CAST({col} AS STRING) AS bin,
|
|
203
|
+
COUNTIF({self.target_column} = 0) AS goods_in_bin,
|
|
204
|
+
COUNTIF({self.target_column} = 1) AS bads_in_bin
|
|
205
|
+
FROM {self.full_table_path}
|
|
206
|
+
WHERE {col} IS NOT NULL
|
|
207
|
+
GROUP BY 1
|
|
208
|
+
)
|
|
209
|
+
CROSS JOIN global_stats
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
union_queries.append(part)
|
|
213
|
+
|
|
214
|
+
sql_parts.append("\nUNION ALL\n".join(union_queries))
|
|
215
|
+
return "\n".join(sql_parts)
|
|
216
|
+
|
|
217
|
+
def screen_features(self) -> duckdb.DuckDBPyRelation:
|
|
218
|
+
"""Executes the pipeline and filters/sorts results via local DuckDB."""
|
|
219
|
+
numerical_columns, categorical_columns = self._get_table_schema()
|
|
220
|
+
|
|
221
|
+
con = duckdb.connect()
|
|
222
|
+
|
|
223
|
+
if not numerical_columns and not categorical_columns:
|
|
224
|
+
logger.warning("No valid numerical or categorical columns found to screen.")
|
|
225
|
+
return con.sql("SELECT NULL AS feature_name, NULL AS feature_stddev, NULL AS naive_iv WHERE 1=0")
|
|
226
|
+
|
|
227
|
+
con.execute("""
|
|
228
|
+
CREATE TABLE all_results (
|
|
229
|
+
feature_name VARCHAR,
|
|
230
|
+
feature_stddev DOUBLE,
|
|
231
|
+
naive_iv DOUBLE
|
|
232
|
+
)
|
|
233
|
+
""")
|
|
234
|
+
|
|
235
|
+
# Process numerical columns
|
|
236
|
+
for i in range(0, len(numerical_columns), self.batch_size):
|
|
237
|
+
chunk = numerical_columns[i:i + self.batch_size]
|
|
238
|
+
logger.info(f"Processing numerical batch {i // self.batch_size + 1}...")
|
|
239
|
+
query = self._build_batch_query(numerical_chunk=chunk, categorical_chunk=[])
|
|
240
|
+
|
|
241
|
+
arrow_table = self.client.query(query).to_arrow()
|
|
242
|
+
con.execute("INSERT INTO all_results SELECT * FROM arrow_table")
|
|
243
|
+
|
|
244
|
+
# Process categorical/binary columns
|
|
245
|
+
for i in range(0, len(categorical_columns), self.batch_size):
|
|
246
|
+
chunk = categorical_columns[i:i + self.batch_size]
|
|
247
|
+
logger.info(f"Processing categorical/binary batch {i // self.batch_size + 1}...")
|
|
248
|
+
query = self._build_batch_query(numerical_chunk=[], categorical_chunk=chunk)
|
|
249
|
+
|
|
250
|
+
arrow_table = self.client.query(query).to_arrow()
|
|
251
|
+
con.execute("INSERT INTO all_results SELECT * FROM arrow_table")
|
|
252
|
+
|
|
253
|
+
# Apply thresholds and sort inside DuckDB
|
|
254
|
+
retained_features_rel = con.sql(f"""
|
|
255
|
+
SELECT
|
|
256
|
+
feature_name,
|
|
257
|
+
feature_stddev,
|
|
258
|
+
naive_iv
|
|
259
|
+
FROM all_results
|
|
260
|
+
WHERE feature_stddev > {self.stddev_threshold}
|
|
261
|
+
AND naive_iv >= {self.iv_threshold}
|
|
262
|
+
ORDER BY naive_iv DESC
|
|
263
|
+
""")
|
|
264
|
+
|
|
265
|
+
retained_count = con.sql("SELECT COUNT(*) FROM retained_features_rel").fetchone()[0]
|
|
266
|
+
total_screened = len(numerical_columns) + len(categorical_columns)
|
|
267
|
+
dropped_count = total_screened - retained_count
|
|
268
|
+
|
|
269
|
+
logger.info(f"Screening complete. Retained: {retained_count} | Dropped: {dropped_count}")
|
|
270
|
+
|
|
271
|
+
return retained_features_rel
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- sample SQL script to downsample a dataset in BigQuery
|
|
2
|
+
|
|
3
|
+
CREATE OR REPLACE TABLE `your_dataset.downsampled_training_data` AS
|
|
4
|
+
SELECT *
|
|
5
|
+
FROM `your_project.your_dataset.your_table`
|
|
6
|
+
WHERE target_y = 1 -- Keep 100% of minority class
|
|
7
|
+
|
|
8
|
+
UNION ALL
|
|
9
|
+
|
|
10
|
+
SELECT *
|
|
11
|
+
FROM `your_project.your_dataset.your_table`
|
|
12
|
+
WHERE target_y = 0
|
|
13
|
+
-- Generates a deterministic pseudo-random float between 0 and 1 per row
|
|
14
|
+
AND ABS(MOD(FARM_FINGERPRINT(CAST(row_id AS STRING)), 100)) < 10; -- Keeps exactly 10% of Class 0
|