ImportanceScore 1.1.2__tar.gz

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.
Files changed (29) hide show
  1. importancescore-1.1.2/ImportanceScore/ImportanceScore/__init__.py +1 -0
  2. importancescore-1.1.2/ImportanceScore/ImportanceScore/csv_table.py +114 -0
  3. importancescore-1.1.2/ImportanceScore/ImportanceScore/custom_editor.py +50 -0
  4. importancescore-1.1.2/ImportanceScore/ImportanceScore/data_loader.py +211 -0
  5. importancescore-1.1.2/ImportanceScore/ImportanceScore/features.py +155 -0
  6. importancescore-1.1.2/ImportanceScore/ImportanceScore/importance_rank.py +522 -0
  7. importancescore-1.1.2/ImportanceScore/ImportanceScore/importance_schema.py +161 -0
  8. importancescore-1.1.2/ImportanceScore/ImportanceScore/manifest.py +77 -0
  9. importancescore-1.1.2/ImportanceScore/ImportanceScore/model.py +264 -0
  10. importancescore-1.1.2/ImportanceScore/ImportanceScore/predict_model.py +438 -0
  11. importancescore-1.1.2/ImportanceScore/ImportanceScore/project_paths.py +96 -0
  12. importancescore-1.1.2/ImportanceScore/ImportanceScore/resources/__init__.py +1 -0
  13. importancescore-1.1.2/ImportanceScore/ImportanceScore/text_weight_scoring.py +206 -0
  14. importancescore-1.1.2/ImportanceScore/ImportanceScore/train_model.py +249 -0
  15. importancescore-1.1.2/ImportanceScore/ImportanceScore/transformations.py +240 -0
  16. importancescore-1.1.2/ImportanceScore/ImportanceScore/tune_model.py +124 -0
  17. importancescore-1.1.2/ImportanceScore/ImportanceScore/weighted_linear_model.py +161 -0
  18. importancescore-1.1.2/ImportanceScore/tests/text_benchmark.py +202 -0
  19. importancescore-1.1.2/ImportanceScore.egg-info/PKG-INFO +136 -0
  20. importancescore-1.1.2/ImportanceScore.egg-info/SOURCES.txt +27 -0
  21. importancescore-1.1.2/ImportanceScore.egg-info/dependency_links.txt +1 -0
  22. importancescore-1.1.2/ImportanceScore.egg-info/entry_points.txt +5 -0
  23. importancescore-1.1.2/ImportanceScore.egg-info/requires.txt +18 -0
  24. importancescore-1.1.2/ImportanceScore.egg-info/top_level.txt +1 -0
  25. importancescore-1.1.2/LICENSE +7 -0
  26. importancescore-1.1.2/PKG-INFO +136 -0
  27. importancescore-1.1.2/docs/readme.md +92 -0
  28. importancescore-1.1.2/pyproject.toml +56 -0
  29. importancescore-1.1.2/setup.cfg +4 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -0,0 +1,114 @@
1
+ from pathlib import Path
2
+ import sys
3
+ from typing import List, Optional
4
+
5
+ import pandas as pd
6
+ from PySide6.QtCore import Qt
7
+ from PySide6.QtWidgets import (QApplication, QHBoxLayout, QLabel, QLineEdit, QMainWindow,
8
+ QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, )
9
+
10
+
11
+ class CsvTable(QWidget):
12
+ """
13
+ QWidget for viewing a CSV data in a table - supports find and sort
14
+ """
15
+ def __init__(self, parent: Optional[QWidget] = None) -> None:
16
+ super().__init__(parent)
17
+ main_layout = QVBoxLayout(self)
18
+ self.table_widget = QTableWidget()
19
+ self.table_widget.setSortingEnabled(True)
20
+ self.table_widget.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
21
+ main_layout.addWidget(self.table_widget)
22
+
23
+ find_layout = QHBoxLayout()
24
+ self.find_input = QLineEdit()
25
+ self.find_input.textChanged.connect(self._reset_search)
26
+
27
+ self.find_button = QPushButton("Find")
28
+ self.find_button.clicked.connect(self._on_find_clicked)
29
+
30
+ find_layout.addWidget(QLabel("Find:"))
31
+ find_layout.addWidget(self.find_input)
32
+ find_layout.addWidget(self.find_button)
33
+ find_layout.addStretch()
34
+ main_layout.addLayout(find_layout)
35
+
36
+ self._search_results = []
37
+ self._current_search_index = -1
38
+
39
+ def clear(self):
40
+ """ Clear table contents """
41
+ self.table_widget.clear()
42
+
43
+ def load(self, path: Path, remove_columns: Optional[List[str]] = None):
44
+ """Load and displays a CSV file. Remove columns if specified """
45
+ try:
46
+ print(f"Loading {path}")
47
+ self.table_widget.clear()
48
+
49
+ df = pd.read_csv(path)
50
+ if remove_columns:
51
+ df = df.drop(columns=remove_columns, errors='ignore')
52
+
53
+ self.table_widget.setRowCount(df.shape[0])
54
+ self.table_widget.setColumnCount(df.shape[1])
55
+ self.table_widget.setHorizontalHeaderLabels(df.columns)
56
+
57
+ for row_idx, row_data in enumerate(df.values):
58
+ for col_idx, value in enumerate(row_data):
59
+ if isinstance(value, (int, float)):
60
+ if value > 2147483647: # Max C++ int
61
+ item = QTableWidgetItem(str(value))
62
+ else:
63
+ item = QTableWidgetItem(value)
64
+ item.setData(Qt.ItemDataRole.EditRole, value)
65
+ else:
66
+ txt = "" if pd.isna(value) else str(value)
67
+ item = QTableWidgetItem(txt)
68
+
69
+ self.table_widget.setItem(row_idx, col_idx, item)
70
+
71
+ self.table_widget.resizeColumnsToContents()
72
+ print(" Load complete")
73
+
74
+ except Exception as e:
75
+ print(f"err {e}")
76
+ self.table_widget.setItem(0, 0, QTableWidgetItem(f"Error loading file"))
77
+
78
+
79
+ def _reset_search(self):
80
+ """
81
+ Resets the search state. Called whenever the find text is changed.
82
+ """
83
+ self.table_widget.clearSelection()
84
+ self._search_results = []
85
+ self._current_search_index = -1
86
+
87
+ def _on_find_clicked(self):
88
+ """
89
+ Handles a click on the "Find" button.
90
+ - If it's a new search (or text has changed), it finds all occurrences.
91
+ - If it's a subsequent search, it finds the next occurrence.
92
+ """
93
+ search_text = self.find_input.text()
94
+
95
+ # If the index is -1, it means this is a new search.
96
+ if self._current_search_index == -1:
97
+ self._search_results = self.table_widget.findItems(
98
+ search_text, Qt.MatchFlag.MatchContains
99
+ )
100
+ if not self._search_results:
101
+ return
102
+
103
+ # If we have results, move to the next one.
104
+ if self._search_results:
105
+ self._current_search_index = (self._current_search_index + 1) % len(self._search_results)
106
+ self._highlight_current_search_result()
107
+
108
+ def _highlight_current_search_result(self):
109
+ """Scrolls to and selects the current search item."""
110
+ if not self._search_results:
111
+ return
112
+ item = self._search_results[self._current_search_index]
113
+ self.table_widget.setCurrentItem(item)
114
+
@@ -0,0 +1,50 @@
1
+ # custom_editor.py
2
+ from PySide6.QtGui import QColor, QSyntaxHighlighter, QTextCharFormat, QFont
3
+ from pygments import highlight
4
+ from pygments.lexers import YamlLexer
5
+ from pygments.token import Token
6
+ from pygments.styles import get_style_by_name
7
+
8
+ class PygmentsHighlighter(QSyntaxHighlighter):
9
+ """A QSyntaxHighlighter that uses the Pygments library for styling."""
10
+
11
+ def __init__(self, parent):
12
+ super().__init__(parent)
13
+
14
+ # The lexer is responsible for breaking text into tokens
15
+ self.lexer = YamlLexer()
16
+
17
+ # 1. Get a Pygments style.
18
+ # 2. Create a dictionary mapping token types to QTextCharFormat objects.
19
+ self.styles = {}
20
+ style = get_style_by_name('dracula')
21
+ for token, s in style:
22
+ q_format = QTextCharFormat()
23
+ if s['color']:
24
+ q_format.setForeground(QColor(f"#{s['color']}"))
25
+
26
+ self.styles[token] = q_format
27
+
28
+ def highlightBlock(self, text: str):
29
+ """
30
+ This method is called by Qt for each block of text to be highlighted.
31
+ """
32
+ # Get the tokens from Pygments
33
+ tokens = self.lexer.get_tokens_unprocessed(text)
34
+
35
+ start_index = 0
36
+ for index, token_type, value in tokens:
37
+ # Find the correct style for this token
38
+ # We traverse up the token hierarchy (e.g., from Number.Integer to Number to Token)
39
+ # until we find a style defined.
40
+ current_format = None
41
+ temp_token_type = token_type
42
+ while current_format is None:
43
+ current_format = self.styles.get(temp_token_type)
44
+ if temp_token_type is Token:
45
+ break
46
+ temp_token_type = temp_token_type.parent
47
+
48
+ # Apply the format if one was found
49
+ if current_format:
50
+ self.setFormat(index, len(value), current_format)
@@ -0,0 +1,211 @@
1
+ # data_loader.py
2
+
3
+ import sys
4
+
5
+ import pandas as pd
6
+
7
+ from ImportanceScore.model import get_target_column
8
+
9
+
10
+ class DataLoader:
11
+ """
12
+ Handles loading, validating, and merging of feature and target datasets.
13
+
14
+ This class encapsulates the data loading logic, using a configuration
15
+ dictionary to dynamically handle different data schemas and requirements.
16
+ """
17
+
18
+ def __init__(self, model_config: dict):
19
+ """
20
+ Initializes the DataLoader with a model configuration.
21
+
22
+ Args:
23
+ model_config (dict): A dictionary containing model settings, including
24
+ the `id_column` and `regressor_target`.
25
+ """
26
+ if not isinstance(model_config, dict):
27
+ raise TypeError("model_config must be a dictionary.")
28
+
29
+ self.config = model_config
30
+ self.id_column = self.config['id_column']
31
+ self.name_key = self.config['name_column']
32
+ self.model_name = self.config['model']
33
+ self.target_column = get_target_column(self.config, self.model_name)
34
+
35
+ @staticmethod
36
+ def _safe_read_csv(path, dtype=None, required_cols=None, label=None):
37
+ """Internal method to safely read a CSV with validation."""
38
+ try:
39
+ df = pd.read_csv(path, dtype=dtype)
40
+ except Exception as e:
41
+ print(f" ❌ Error loading {label}: {path}. {e}")
42
+ sys.exit(1)
43
+
44
+ if required_cols:
45
+ missing = [col for col in required_cols if col not in df.columns]
46
+ if missing:
47
+ print(f" ❌ {label or path} is missing required columns: {missing}")
48
+ sys.exit(1)
49
+ return df
50
+
51
+ def _validate_input_files(self, df_features, df_targets):
52
+ """Internal method to ensure required columns exist in dataframes."""
53
+ required_target_cols = [self.id_column, self.target_column]
54
+ for col in required_target_cols:
55
+ if col not in df_targets.columns:
56
+ raise ValueError(f"Missing '{col}' in targets file.")
57
+ if self.id_column not in df_features.columns:
58
+ raise ValueError(f"Missing '{self.id_column}' in features file.")
59
+
60
+ def load_and_merge_data(self, features_path: str, targets_path: str) -> pd.DataFrame:
61
+ """
62
+ Loads, validates, and merges feature and target data.
63
+
64
+ Args:
65
+ features_path (str): Path to the features CSV.
66
+ targets_path (str): Path to the targets CSV.
67
+
68
+ Returns:
69
+ pd.DataFrame: A merged DataFrame ready for preprocessing.
70
+ """
71
+ required_final_cols = [self.id_column, self.name_key, self.target_column]
72
+
73
+ df_features = self._safe_read_csv(
74
+ features_path, dtype={self.id_column: str}, required_cols=[self.id_column],
75
+ label="Features CSV"
76
+ )
77
+ df_targets = self._safe_read_csv(
78
+ targets_path, dtype={self.id_column: str},
79
+ required_cols=[self.id_column, self.target_column], label="Targets CSV"
80
+ )
81
+
82
+ try:
83
+ if self.name_key in df_targets.columns:
84
+ df_targets = df_targets.drop(columns=[self.name_key])
85
+
86
+ self._validate_input_files(df_features, df_targets)
87
+ df = pd.merge(df_features, df_targets, on=self.id_column, how="left")
88
+
89
+ missing = [col for col in required_final_cols if col not in df.columns]
90
+ if missing:
91
+ raise ValueError(f"Missing required columns after merge: {', '.join(missing)}")
92
+
93
+ return df
94
+ except Exception as e:
95
+ print(f"Error loading data: {e}")
96
+ sys.exit(1)
97
+
98
+
99
+ # Schema for Model config
100
+ MODEL_CONFIG_SCHEMA = {
101
+ 'config_type': {'type': 'string','required': True,'allowed': ["ModelConfig"] },
102
+ 'anomaly_group_key': {'type': 'string', 'required': False},
103
+ 'model': {
104
+ 'type': 'string', 'required': True,
105
+ 'allowed': ['WLM', 'RFR', 'GBT', 'LOGR', 'SVM', 'SVR', 'LR']
106
+ },
107
+ 'id_column': {'type': 'string', 'required': True},
108
+ 'name_column': {'type': 'string', 'required': True},
109
+ 'regressor_target': {'type': 'string', 'required': True},
110
+ 'base_score_column': {'type': 'string', 'required': False},
111
+
112
+ 'scaling': {
113
+ 'type': 'dict', 'required': False, 'schema': {
114
+ 'min': {'type': 'number', 'required': True},
115
+ 'max': {'type': 'number', 'required': True},
116
+ }
117
+ },
118
+
119
+ 'text_weight_columns': {
120
+ 'type': 'list', 'schema': {'type': 'string'}, 'required': False, 'default': []
121
+ }, 'one_hot_columns': {
122
+ 'type': 'list', 'schema': {'type': 'string'}, 'required': False, 'default': []
123
+ }, 'fillna': {
124
+ 'type': 'dict', 'required': False, 'valuesrules': {'type': ['number', 'string', 'boolean']}
125
+ }, 'conditional_adjustment': {
126
+ 'type': 'dict', 'required': False, 'default': {}
127
+ }, 'ignore_columns': {
128
+ 'type': 'list', 'schema': {'type': 'string'}, 'required': False, 'default': []
129
+ }, 'output_columns': {
130
+ 'type': 'list', 'schema': {'type': 'string'}, 'required': True
131
+ },
132
+
133
+ 'feature_interactions': {
134
+ 'type': 'list', 'required': False, 'schema': {
135
+ 'type': 'dict', 'schema': {
136
+ 'output_column': {'type': 'string', 'required': True},
137
+ 'input_columns': {'type': 'list', 'schema': {'type': 'string'}, 'required': True},
138
+ 'method': {'type': 'string', 'allowed': ['logical_or'], 'required': True}
139
+ }
140
+ }
141
+ },
142
+
143
+ # clip_outliers
144
+ 'clip_outliers': {
145
+ 'type': 'dict', 'required': False, 'valuesrules': {
146
+ 'type': 'dict', 'schema': {
147
+ 'method': {'type': 'string', 'allowed': ['IQR', 'threshold'], 'required': True},
148
+
149
+ # Parameters for the 'IQR' method
150
+ 'factor': {'type': 'number', 'required': False, 'default': 1.5},
151
+
152
+ # Parameters for the new 'threshold' method
153
+ 'min': {'type': 'number', 'required': False},
154
+ 'max': {'type': 'number', 'required': False},
155
+ }
156
+ }
157
+ },
158
+
159
+ # scaler section
160
+ 'scaler': {
161
+ 'type': 'dict', 'required': False, 'valuesrules': {
162
+ 'type': 'string',
163
+ 'allowed': ['standard', 'minmax', 'robust', 'power', 'sublinear', 'quantile', 'none']
164
+ }
165
+ },
166
+
167
+ 'smoothing': {
168
+ 'type': 'dict', 'required': False, 'schema': {
169
+ 'method': {'type': 'string', 'allowed': ['sigmoid', 'none'], 'required': True},
170
+ 'midpoint': {'type': 'number', 'required': False},
171
+ 'steepness': {'type': 'number', 'required': False}
172
+ }
173
+ }
174
+ }
175
+
176
+
177
+
178
+ CLASSIFICATION_SCHEMA = {
179
+ 'config_type': {'type': 'string','required': True,'allowed': ["Classification"] },
180
+ 'keys': {'type': 'dict', 'required': True},
181
+ 'output_tag': {'type': 'string', 'required': True},
182
+ 'score_key': {'type': 'string', 'required': True},
183
+ 'style': {'type': 'string', 'required': False},
184
+ 'features': {'type': 'list', 'required': True},
185
+ 'require_name': {
186
+ 'type': 'boolean',
187
+ 'required': False, # It's an optional setting
188
+ 'default': True # A sensible default: usually we want named features
189
+ },
190
+
191
+ 'debug_ids': {
192
+ 'type': 'list', 'schema': {'type': 'integer'}, # assuming node_row IDs are integers
193
+ 'required': False,
194
+ },
195
+
196
+ 'enrichment': {
197
+ 'type': 'list',
198
+ 'required': False,
199
+ 'schema': {
200
+ 'type': 'dict',
201
+ 'schema': {
202
+ 'file_suffix': {'type': 'string', 'required': True},
203
+ 'columns': {
204
+ 'type': 'list',
205
+ 'required': True,
206
+ 'schema': {'type': 'string'}
207
+ }
208
+ }
209
+ }
210
+ },
211
+ }
@@ -0,0 +1,155 @@
1
+ # features.py
2
+ """
3
+ This module provides a robust FeaturePreprocessor class that acts as a
4
+ data-driven orchestrator for the feature preprocessing pipeline.
5
+ """
6
+ import logging
7
+ import sys
8
+ from typing import Tuple, Dict, Any, List
9
+
10
+ import pandas as pd
11
+
12
+ from ImportanceScore import transformations
13
+ from ImportanceScore.model import get_target_column
14
+
15
+
16
+ # --- Module-Level Helper for Logging ---
17
+ def _setup_logger(level) -> logging.Logger:
18
+ """Initializes and configures the logger for the feature pipeline."""
19
+ logger = logging.getLogger("__file__")
20
+ logger.setLevel(level)
21
+ if not logger.handlers:
22
+ handler = logging.StreamHandler(sys.stdout)
23
+ formatter = logging.Formatter("%(message)s")
24
+ handler.setFormatter(formatter)
25
+ logger.addHandler(handler)
26
+ return logger
27
+
28
+
29
+ class FeaturePreprocessor:
30
+ """
31
+ Orchestrates the feature preprocessing pipeline by executing a configurable
32
+ sequence of independent transformation strategies.
33
+ """
34
+ def __init__(self, model_config: Dict[str, Any], category: str, model_name: str):
35
+ """Initializes the FeaturePreprocessor."""
36
+ self.config = model_config
37
+ self.category = category
38
+ self.model_name = model_name
39
+ self.target_col_name = get_target_column(self.config, self.model_name)
40
+ self.logger = _setup_logger(4)
41
+
42
+ # This property will store the names of columns created during one-hot encoding.
43
+ self.one_hot_column_names: List[str] = []
44
+
45
+ self.pipeline_steps = [
46
+ ("feature_interactions", transformations.apply_feature_interactions),
47
+ ("fillna", lambda df, cfg, log: transformations.apply_fillna(
48
+ df, cfg, [self.target_col_name], log, self.category
49
+ )),
50
+ ("text_weight_columns", lambda df, cfg, log: transformations.apply_text_weight_scoring(
51
+ df, {"columns": cfg}, self.category, log
52
+ )),
53
+ ("conditional_adjustment", transformations.apply_conditional_adjustment),
54
+ ]
55
+
56
+ def transform(
57
+ self, raw_dataframe: pd.DataFrame, training: bool = True
58
+ ) -> Tuple[pd.DataFrame, pd.Series, pd.Series]:
59
+ """Executes the full preprocessing pipeline in the correct order."""
60
+ self.logger.info("\n🔷Preprocessing Features:\n")
61
+ self.logger.info(f" {len(raw_dataframe)} records.")
62
+
63
+ try:
64
+ processed_df = raw_dataframe.copy()
65
+
66
+ # --- Step 1: Handle the special case of one-hot encoding first ---
67
+ one_hot_config = {"columns": self.config.get("one_hot_columns", [])}
68
+ processed_df, self.one_hot_column_names = transformations.apply_one_hot_encoding(
69
+ processed_df, one_hot_config, self.logger
70
+ )
71
+
72
+ # --- Step 2: Run the rest of the data-driven pipeline ---
73
+ for config_key, transform_func in self.pipeline_steps:
74
+ step_config = self.config.get(config_key, {})
75
+ processed_df = transform_func(processed_df, step_config, self.logger)
76
+
77
+ # --- Step 3: Post-Transformation Orchestration ---
78
+ y, labeled_mask = self._extract_labels(processed_df, training)
79
+ feature_matrix_X = self._prune_columns(processed_df)
80
+ feature_matrix_X = transformations.clip_outliers(
81
+ feature_matrix_X, self.config.get("clip_outliers", {}), self.logger
82
+ )
83
+ feature_matrix_X = transformations.scale_features(
84
+ feature_matrix_X, self.config.get("scaler", {}), self.logger
85
+ )
86
+ self._validate_feature_coverage(feature_matrix_X)
87
+
88
+ return feature_matrix_X, y, labeled_mask
89
+
90
+ except (ValueError, KeyError) as e:
91
+ self.logger.error(f"\n ❌ Preprocessing Error: {e}")
92
+ self.logger.error(" Processing stopped. Please correct the error above and rerun.")
93
+ sys.exit(1)
94
+
95
+ # --- Orchestration-Level Helper Methods ---
96
+
97
+ def _extract_labels(self, df: pd.DataFrame, training: bool) -> Tuple[pd.Series, pd.Series]:
98
+ """Extracts the target variable (y) and a mask of labeled rows."""
99
+ if not training:
100
+ return pd.Series([pd.NA] * len(df), dtype="Int64"), pd.Series([False] * len(df))
101
+
102
+ if self.target_col_name not in df.columns:
103
+ raise KeyError(
104
+ f"Label column '{self.target_col_name}' is required for training but is missing."
105
+ )
106
+
107
+ dtype = "Int64" if self.config.get("task_type") == "classification" else "float64"
108
+ y = df[self.target_col_name].astype(dtype)
109
+ return y, y.notna()
110
+
111
+ def _prune_columns(self, df: pd.DataFrame) -> pd.DataFrame:
112
+ """Removes ignored and non-numeric columns to create the feature matrix."""
113
+ self.logger.info("➡️ Pruning columns to create feature matrix...")
114
+
115
+ ignore_cols = self.config.get("ignore_columns", [])
116
+ df.drop(columns=ignore_cols, errors="ignore", inplace=True)
117
+
118
+ numeric_dtypes = ["int64", "float64", "bool"]
119
+ non_numeric_cols = df.select_dtypes(exclude=numeric_dtypes).columns.tolist()
120
+ df.drop(columns=non_numeric_cols, errors="ignore", inplace=True)
121
+
122
+ self.logger.info(f" • Ignored columns dropped: {ignore_cols}")
123
+ self.logger.info(f" • Non-numeric columns dropped: {non_numeric_cols or 'none'}")
124
+
125
+ return df
126
+
127
+ def _validate_feature_coverage(self, X: pd.DataFrame) -> None:
128
+ """Validates that each feature column has some non-zero coverage."""
129
+ self.logger.info("\n✳️ Feature column coverage:\n")
130
+ for col in X.columns:
131
+ _validate_tag(X, col, threshold=1, logger=self.logger)
132
+
133
+ self.logger.info("\n")
134
+
135
+ # --- Utility Functions ---
136
+
137
+ def _validate_tag(df: pd.DataFrame, column: str, threshold: float = None, logger=None) -> bool:
138
+ """Ensures a given column has sufficient non-zero coverage."""
139
+ if column not in df.columns:
140
+ logger.info(f"❌ Column '{column}' not found for coverage validation.")
141
+ return False
142
+
143
+ pct_filled = df[column].fillna(0).ne(0).mean()
144
+
145
+ if threshold is None:
146
+ logger.info(f"↪ Column '{column}': {pct_filled:.0%} filled")
147
+ return True
148
+
149
+ is_valid = pct_filled >= (threshold / 100)
150
+ status_emoji = "🆗" if is_valid else "⚠️"
151
+ column_name = f"{column}:"
152
+ logger.info(
153
+ f" {status_emoji} {column_name:<20} {pct_filled:>7.2%} filled"
154
+ )
155
+ return is_valid