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/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,3 @@
1
+ from .data_loader import UniversalDataLoader
2
+
3
+ __all__ = ["UniversalDataLoader"]
@@ -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