mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-28 16:07:46 +00:00
fuse all change into one commit (#298)
This commit is contained in:
@@ -7,7 +7,6 @@ import pandas as pd
|
||||
from fea_share_preprocess import clean_and_impute_data, preprocess_script
|
||||
from scipy import stats
|
||||
from sklearn.metrics import accuracy_score, matthews_corrcoef
|
||||
from sklearn.preprocessing import LabelEncoder
|
||||
|
||||
# Set random seed for reproducibility
|
||||
SEED = 42
|
||||
@@ -37,7 +36,6 @@ def import_module_from_path(module_name, module_path):
|
||||
|
||||
|
||||
# 1) Preprocess the data
|
||||
# TODO 如果已经做过数据预处理了,不需要再做了
|
||||
X_train, X_valid, y_train, y_valid, X_test, ids = preprocess_script()
|
||||
|
||||
# 2) Auto feature engineering
|
||||
|
||||
+18
-20
@@ -9,53 +9,52 @@ from tqdm import tqdm
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
|
||||
# Modified model for multi-class classification
|
||||
class HybridFeatureInteractionModel(nn.Module):
|
||||
def __init__(self, num_features, num_classes):
|
||||
super(HybridFeatureInteractionModel, self).__init__()
|
||||
# Restored three-layer model structure
|
||||
class FeatureInteractionModel(nn.Module):
|
||||
def __init__(self, num_features):
|
||||
super(FeatureInteractionModel, self).__init__()
|
||||
self.fc1 = nn.Linear(num_features, 128)
|
||||
self.bn1 = nn.BatchNorm1d(128)
|
||||
self.fc2 = nn.Linear(128, 64)
|
||||
self.bn2 = nn.BatchNorm1d(64)
|
||||
self.fc3 = nn.Linear(64, num_classes) # Output nodes equal to num_classes
|
||||
self.fc3 = nn.Linear(64, 1)
|
||||
self.dropout = nn.Dropout(0.3)
|
||||
|
||||
def forward(self, x):
|
||||
x = F.relu(self.bn1(self.fc1(x)))
|
||||
x = F.relu(self.bn2(self.fc2(x)))
|
||||
x = self.dropout(x)
|
||||
x = self.fc3(x) # No activation here, use CrossEntropyLoss
|
||||
x = torch.sigmoid(self.fc3(x))
|
||||
return x
|
||||
|
||||
|
||||
# Training function
|
||||
def fit(X_train, y_train, X_valid, y_valid):
|
||||
num_features = X_train.shape[1]
|
||||
num_classes = len(np.unique(y_train)) # Determine number of classes
|
||||
model = HybridFeatureInteractionModel(num_features, num_classes).to(device)
|
||||
criterion = nn.CrossEntropyLoss() # Use CrossEntropyLoss for multi-class
|
||||
model = FeatureInteractionModel(num_features).to(device)
|
||||
criterion = nn.BCELoss() # Binary classification problem
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
|
||||
# Convert to TensorDataset and create DataLoader
|
||||
train_dataset = TensorDataset(
|
||||
torch.tensor(X_train.to_numpy(), dtype=torch.float32), torch.tensor(y_train.to_numpy(), dtype=torch.long)
|
||||
torch.tensor(X_train.to_numpy(), dtype=torch.float32), torch.tensor(y_train.reshape(-1), dtype=torch.float32)
|
||||
)
|
||||
valid_dataset = TensorDataset(
|
||||
torch.tensor(X_valid.to_numpy(), dtype=torch.float32), torch.tensor(y_valid.to_numpy(), dtype=torch.long)
|
||||
torch.tensor(X_valid.to_numpy(), dtype=torch.float32), torch.tensor(y_valid.reshape(-1), dtype=torch.float32)
|
||||
)
|
||||
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
|
||||
valid_loader = DataLoader(valid_dataset, batch_size=32, shuffle=False)
|
||||
|
||||
# Train the model
|
||||
model.train()
|
||||
for epoch in range(5): # just for quick run
|
||||
for epoch in range(5):
|
||||
print(f"Epoch {epoch + 1}/5")
|
||||
epoch_loss = 0
|
||||
for X_batch, y_batch in tqdm(train_loader, desc="Training", leave=False):
|
||||
X_batch, y_batch = X_batch.to(device), y_batch.to(device)
|
||||
X_batch, y_batch = X_batch.to(device), y_batch.to(device) # Move data to the device
|
||||
optimizer.zero_grad()
|
||||
outputs = model(X_batch)
|
||||
loss = criterion(outputs, y_batch)
|
||||
outputs = model(X_batch).squeeze(1) # Reshape outputs to [32]
|
||||
loss = criterion(outputs, y_batch) # Adjust target shape
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
epoch_loss += loss.item()
|
||||
@@ -69,10 +68,9 @@ def predict(model, X):
|
||||
model.eval()
|
||||
predictions = []
|
||||
with torch.no_grad():
|
||||
X_tensor = torch.tensor(X.values, dtype=torch.float32).to(device)
|
||||
X_tensor = torch.tensor(X.values, dtype=torch.float32).to(device) # Move data to the device
|
||||
for i in tqdm(range(0, len(X_tensor), 32), desc="Predicting", leave=False):
|
||||
batch = X_tensor[i : i + 32]
|
||||
pred = model(batch)
|
||||
pred = torch.argmax(pred, dim=1).cpu().numpy() # Use argmax to get class
|
||||
batch = X_tensor[i : i + 32] # Predict in batches
|
||||
pred = model(batch).squeeze().cpu().numpy() # Move results back to CPU
|
||||
predictions.extend(pred)
|
||||
return np.array(predictions)
|
||||
return np.array(predictions) # Return boolean predictions
|
||||
+3
-3
@@ -10,9 +10,9 @@ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
|
||||
# Restored three-layer model structure
|
||||
class HybridFeatureInteractionModel(nn.Module):
|
||||
class FeatureInteractionModel(nn.Module):
|
||||
def __init__(self, num_features):
|
||||
super(HybridFeatureInteractionModel, self).__init__()
|
||||
super(FeatureInteractionModel, self).__init__()
|
||||
self.fc1 = nn.Linear(num_features, 128)
|
||||
self.bn1 = nn.BatchNorm1d(128)
|
||||
self.fc2 = nn.Linear(128, 64)
|
||||
@@ -31,7 +31,7 @@ class HybridFeatureInteractionModel(nn.Module):
|
||||
# Training function
|
||||
def fit(X_train, y_train, X_valid, y_valid):
|
||||
num_features = X_train.shape[1]
|
||||
model = HybridFeatureInteractionModel(num_features).to(device)
|
||||
model = FeatureInteractionModel(num_features).to(device)
|
||||
criterion = nn.BCELoss() # Binary classification problem
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
|
||||
|
||||
+3
-3
@@ -10,9 +10,9 @@ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
|
||||
# Modified model for regression
|
||||
class HybridFeatureInteractionModel(nn.Module):
|
||||
class FeatureInteractionModel(nn.Module):
|
||||
def __init__(self, num_features):
|
||||
super(HybridFeatureInteractionModel, self).__init__()
|
||||
super(FeatureInteractionModel, self).__init__()
|
||||
self.fc1 = nn.Linear(num_features, 128)
|
||||
self.bn1 = nn.BatchNorm1d(128)
|
||||
self.fc2 = nn.Linear(128, 64)
|
||||
@@ -31,7 +31,7 @@ class HybridFeatureInteractionModel(nn.Module):
|
||||
# Training function
|
||||
def fit(X_train, y_train, X_valid, y_valid):
|
||||
num_features = X_train.shape[1]
|
||||
model = HybridFeatureInteractionModel(num_features).to(device)
|
||||
model = FeatureInteractionModel(num_features).to(device)
|
||||
criterion = nn.MSELoss() # Use MSELoss for regression
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class KGScenario(Scenario):
|
||||
self.target_description = None
|
||||
self.competition_features = None
|
||||
self._analysis_competition_description()
|
||||
self.if_action_choosing_based_on_UCB = False
|
||||
|
||||
self._background = self.background
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch.utils.data import DataLoader, TensorDataset
|
||||
from tqdm import tqdm
|
||||
|
||||
# Check if a GPU is available
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
|
||||
# Restored three-layer model structure
|
||||
class FeatureInteractionModel(nn.Module):
|
||||
def __init__(self, num_features):
|
||||
super(FeatureInteractionModel, self).__init__()
|
||||
self.fc1 = nn.Linear(num_features, 128)
|
||||
self.bn1 = nn.BatchNorm1d(128)
|
||||
self.fc2 = nn.Linear(128, 64)
|
||||
self.bn2 = nn.BatchNorm1d(64)
|
||||
self.fc3 = nn.Linear(64, 1)
|
||||
self.dropout = nn.Dropout(0.3)
|
||||
|
||||
def forward(self, x):
|
||||
x = F.relu(self.bn1(self.fc1(x)))
|
||||
x = F.relu(self.bn2(self.fc2(x)))
|
||||
x = self.dropout(x)
|
||||
x = torch.sigmoid(self.fc3(x))
|
||||
return x
|
||||
|
||||
|
||||
# Training function
|
||||
def fit(X_train, y_train, X_valid, y_valid):
|
||||
num_features = X_train.shape[1]
|
||||
model = FeatureInteractionModel(num_features).to(device)
|
||||
criterion = nn.BCELoss() # Binary classification problem
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
|
||||
# Convert to TensorDataset and create DataLoader
|
||||
train_dataset = TensorDataset(
|
||||
torch.tensor(X_train.to_numpy(), dtype=torch.float32), torch.tensor(y_train.reshape(-1), dtype=torch.float32)
|
||||
)
|
||||
valid_dataset = TensorDataset(
|
||||
torch.tensor(X_valid.to_numpy(), dtype=torch.float32), torch.tensor(y_valid.reshape(-1), dtype=torch.float32)
|
||||
)
|
||||
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
|
||||
valid_loader = DataLoader(valid_dataset, batch_size=32, shuffle=False)
|
||||
|
||||
# Train the model
|
||||
model.train()
|
||||
for epoch in range(5):
|
||||
print(f"Epoch {epoch + 1}/5")
|
||||
epoch_loss = 0
|
||||
for X_batch, y_batch in tqdm(train_loader, desc="Training", leave=False):
|
||||
X_batch, y_batch = X_batch.to(device), y_batch.to(device) # Move data to the device
|
||||
optimizer.zero_grad()
|
||||
outputs = model(X_batch).squeeze(1) # Reshape outputs to [32]
|
||||
loss = criterion(outputs, y_batch) # Adjust target shape
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
epoch_loss += loss.item()
|
||||
print(f"End of epoch {epoch + 1}, Avg Loss: {epoch_loss / len(train_loader):.4f}")
|
||||
|
||||
return model
|
||||
|
||||
|
||||
# Prediction function
|
||||
def predict(model, X):
|
||||
model.eval()
|
||||
predictions = []
|
||||
with torch.no_grad():
|
||||
X_tensor = torch.tensor(X.values, dtype=torch.float32).to(device) # Move data to the device
|
||||
for i in tqdm(range(0, len(X_tensor), 32), desc="Predicting", leave=False):
|
||||
batch = X_tensor[i : i + 32] # Predict in batches
|
||||
pred = model(batch).squeeze().cpu().numpy() # Move results back to CPU
|
||||
predictions.extend(pred)
|
||||
return np.array(predictions) # Return boolean predictions
|
||||
+18
-24
@@ -5,7 +5,7 @@ from sklearn.compose import ColumnTransformer
|
||||
from sklearn.impute import SimpleImputer
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.pipeline import Pipeline
|
||||
from sklearn.preprocessing import LabelEncoder, OneHotEncoder
|
||||
from sklearn.preprocessing import LabelEncoder
|
||||
|
||||
|
||||
def prepreprocess():
|
||||
@@ -17,7 +17,7 @@ def prepreprocess():
|
||||
data_df = data_df.drop(["PassengerId"], axis=1)
|
||||
|
||||
X = data_df.drop(["Transported"], axis=1)
|
||||
y = data_df[["Transported"]]
|
||||
y = data_df["Transported"]
|
||||
|
||||
label_encoder = LabelEncoder()
|
||||
y = label_encoder.fit_transform(y) # Convert class labels to numeric
|
||||
@@ -37,45 +37,39 @@ def preprocess_fit(X_train: pd.DataFrame):
|
||||
categorical_cols = [cname for cname in X_train.columns if X_train[cname].dtype == "object"]
|
||||
|
||||
# Define preprocessors for numerical and categorical features
|
||||
categorical_transformer = Pipeline(
|
||||
steps=[
|
||||
("imputer", SimpleImputer(strategy="most_frequent")),
|
||||
("onehot", OneHotEncoder(handle_unknown="ignore")),
|
||||
]
|
||||
)
|
||||
label_encoders = {col: LabelEncoder().fit(X_train[col]) for col in categorical_cols}
|
||||
|
||||
numerical_transformer = Pipeline(steps=[("imputer", SimpleImputer(strategy="mean"))])
|
||||
|
||||
# Combine preprocessing steps
|
||||
preprocessor = ColumnTransformer(
|
||||
transformers=[
|
||||
("cat", categorical_transformer, categorical_cols),
|
||||
("num", numerical_transformer, numerical_cols),
|
||||
]
|
||||
],
|
||||
remainder="passthrough",
|
||||
)
|
||||
|
||||
# Fit the preprocessor on the training data
|
||||
preprocessor.fit(X_train)
|
||||
|
||||
return preprocessor
|
||||
return preprocessor, label_encoders
|
||||
|
||||
|
||||
def preprocess_transform(X: pd.DataFrame, preprocessor):
|
||||
def preprocess_transform(X: pd.DataFrame, preprocessor, label_encoders):
|
||||
"""
|
||||
Transforms the given DataFrame using the fitted preprocessor.
|
||||
Ensures the processed data has consistent features across train, validation, and test sets.
|
||||
"""
|
||||
# Transform the data using the fitted preprocessor
|
||||
X_array = preprocessor.transform(X).toarray()
|
||||
# Encode categorical features
|
||||
for col, le in label_encoders.items():
|
||||
# Handle unseen labels by setting them to a default value (e.g., -1)
|
||||
X[col] = X[col].apply(lambda x: le.transform([x])[0] if x in le.classes_ else -1)
|
||||
|
||||
# Get feature names for the columns in the transformed data
|
||||
categorical_cols = [cname for cname in X.columns if X[cname].dtype == "object"]
|
||||
feature_names = preprocessor.named_transformers_["cat"]["onehot"].get_feature_names_out(
|
||||
categorical_cols
|
||||
).tolist() + [cname for cname in X.columns if X[cname].dtype in ["int64", "float64"]]
|
||||
# Transform the data using the fitted preprocessor
|
||||
X_array = preprocessor.transform(X)
|
||||
|
||||
# Convert arrays back to DataFrames
|
||||
X_transformed = pd.DataFrame(X_array, columns=feature_names, index=X.index)
|
||||
X_transformed = pd.DataFrame(X_array, columns=X.columns, index=X.index)
|
||||
|
||||
return X_transformed
|
||||
|
||||
@@ -96,16 +90,16 @@ def preprocess_script():
|
||||
X_train, X_valid, y_train, y_valid = prepreprocess()
|
||||
|
||||
# Fit the preprocessor on the training data
|
||||
preprocessor = preprocess_fit(X_train)
|
||||
preprocessor, label_encoders = preprocess_fit(X_train)
|
||||
|
||||
# Preprocess the train, validation, and test data
|
||||
X_train = preprocess_transform(X_train, preprocessor)
|
||||
X_valid = preprocess_transform(X_valid, preprocessor)
|
||||
X_train = preprocess_transform(X_train, preprocessor, label_encoders)
|
||||
X_valid = preprocess_transform(X_valid, preprocessor, label_encoders)
|
||||
|
||||
# Load and preprocess the test data
|
||||
submission_df = pd.read_csv("/kaggle/input/test.csv")
|
||||
passenger_ids = submission_df["PassengerId"]
|
||||
submission_df = submission_df.drop(["PassengerId"], axis=1)
|
||||
X_test = preprocess_transform(submission_df, preprocessor)
|
||||
X_test = preprocess_transform(submission_df, preprocessor, label_encoders)
|
||||
|
||||
return X_train, X_valid, y_train, y_valid, X_test, passenger_ids
|
||||
|
||||
@@ -10,9 +10,9 @@ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
|
||||
# Restored three-layer model structure
|
||||
class HybridFeatureInteractionModel(nn.Module):
|
||||
class FeatureInteractionModel(nn.Module):
|
||||
def __init__(self, num_features):
|
||||
super(HybridFeatureInteractionModel, self).__init__()
|
||||
super(FeatureInteractionModel, self).__init__()
|
||||
self.fc1 = nn.Linear(num_features, 128)
|
||||
self.bn1 = nn.BatchNorm1d(128)
|
||||
self.fc2 = nn.Linear(128, 64)
|
||||
@@ -31,7 +31,7 @@ class HybridFeatureInteractionModel(nn.Module):
|
||||
# Training function
|
||||
def fit(X_train, y_train, X_valid, y_valid):
|
||||
num_features = X_train.shape[1]
|
||||
model = HybridFeatureInteractionModel(num_features).to(device)
|
||||
model = FeatureInteractionModel(num_features).to(device)
|
||||
criterion = nn.BCELoss() # Binary classification problem
|
||||
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ def fit(X_train: pd.DataFrame, y_train: pd.DataFrame, X_valid: pd.DataFrame, y_v
|
||||
params = {
|
||||
"nthred": -1,
|
||||
}
|
||||
num_round = 180
|
||||
num_round = 100
|
||||
|
||||
evallist = [(dtrain, "train"), (dvalid, "eval")]
|
||||
bst = xgb.train(params, dtrain, num_round, evallist)
|
||||
|
||||
@@ -39,7 +39,7 @@ hypothesis_and_feedback: |-
|
||||
hypothesis_output_format: |-
|
||||
The output should follow JSON format. The schema is as follows:
|
||||
{
|
||||
"action": "The action that the user wants to take based on the information provided. should be one of ["Feature engineering", "Feature processing", "Model feature selection", "Model tuning"]"
|
||||
"action": "If "hypothesis_specification" provides the action you need to take, please follow "hypothesis_specification" to choose the action. Otherwise, based on previous experimental results, suggest the action you believe is most appropriate at the moment. It should be one of ["Feature engineering", "Feature processing", "Model feature selection", "Model tuning"]"
|
||||
"hypothesis": "The new hypothesis generated based on the information provided.",
|
||||
"reason": "The reason why you generate this hypothesis. It should be comprehensive and logical. It should cover the other keys below and extend them.",
|
||||
"concise_reason": "Two-line summary. First line focuses on a concise justification for the change. Second line generalizes a knowledge statement.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
@@ -81,6 +82,20 @@ class KGHypothesisGen(ModelHypothesisGen):
|
||||
|
||||
def __init__(self, scen: Scenario) -> Tuple[dict, bool]:
|
||||
super().__init__(scen)
|
||||
self.action_counts = {
|
||||
"Feature engineering": 0,
|
||||
"Feature processing": 0,
|
||||
"Model feature selection": 0,
|
||||
"Model tuning": 0,
|
||||
}
|
||||
self.reward_estimates = {
|
||||
"Feature engineering": 0.0,
|
||||
"Feature processing": 0.0,
|
||||
"Model feature selection": 0.0,
|
||||
"Model tuning": 0.0,
|
||||
}
|
||||
self.confidence_parameter = 1.0
|
||||
self.initial_performance = 0.0
|
||||
|
||||
def generate_RAG_content(self, trace: Trace) -> str:
|
||||
if trace.knowledge_base is None:
|
||||
@@ -162,6 +177,52 @@ class KGHypothesisGen(ModelHypothesisGen):
|
||||
)
|
||||
return RAG_content
|
||||
|
||||
def update_reward_estimates(self, trace: Trace) -> None:
|
||||
if len(trace.hist) > 0:
|
||||
last_entry = trace.hist[-1]
|
||||
last_action = last_entry[0].action
|
||||
last_result = last_entry[1].result
|
||||
# Extract performance_t
|
||||
performance_t = last_result.get("performance", 0.0)
|
||||
# Get performance_{t-1}
|
||||
if len(trace.hist) > 1:
|
||||
prev_entry = trace.hist[-2]
|
||||
prev_result = prev_entry[1].result
|
||||
performance_t_minus_1 = prev_result.get("performance", 0.0)
|
||||
else:
|
||||
performance_t_minus_1 = self.initial_performance
|
||||
|
||||
reward = performance_t_minus_1 - performance_t
|
||||
n_o = self.action_counts[last_action]
|
||||
mu_o = self.reward_estimates[last_action]
|
||||
self.reward_estimates[last_action] += (reward - mu_o) / n_o
|
||||
else:
|
||||
# First iteration, nothing to update
|
||||
pass
|
||||
|
||||
def execute_next_action(self, trace: Trace) -> str:
|
||||
actions = list(self.action_counts.keys())
|
||||
t = sum(self.action_counts.values()) + 1
|
||||
|
||||
# If any action has not been tried yet, select it
|
||||
for action in actions:
|
||||
if self.action_counts[action] == 0:
|
||||
selected_action = action
|
||||
self.action_counts[selected_action] += 1
|
||||
return selected_action
|
||||
|
||||
c = self.confidence_parameter
|
||||
ucb_values = {}
|
||||
for action in actions:
|
||||
mu_o = self.reward_estimates[action]
|
||||
n_o = self.action_counts[action]
|
||||
ucb = mu_o + c * math.sqrt(math.log(t) / n_o)
|
||||
ucb_values[action] = ucb
|
||||
# Select action with highest UCB
|
||||
selected_action = max(ucb_values, key=ucb_values.get)
|
||||
self.action_counts[selected_action] += 1
|
||||
return selected_action
|
||||
|
||||
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
|
||||
hypothesis_and_feedback = (
|
||||
(
|
||||
@@ -173,16 +234,22 @@ class KGHypothesisGen(ModelHypothesisGen):
|
||||
else "No previous hypothesis and feedback available since it's the first round."
|
||||
)
|
||||
|
||||
if self.scen.if_action_choosing_based_on_UCB:
|
||||
action = self.execute_next_action(trace)
|
||||
|
||||
context_dict = {
|
||||
"hypothesis_and_feedback": hypothesis_and_feedback,
|
||||
"RAG": self.generate_RAG_content(trace),
|
||||
"hypothesis_output_format": prompt_dict["hypothesis_output_format"],
|
||||
"hypothesis_specification": None,
|
||||
"hypothesis_specification": f"next experiment action is {action}"
|
||||
if self.scen.if_action_choosing_based_on_UCB
|
||||
else None,
|
||||
}
|
||||
return context_dict, True
|
||||
|
||||
def convert_response(self, response: str) -> ModelHypothesis:
|
||||
response_dict = json.loads(response)
|
||||
|
||||
hypothesis = KGHypothesis(
|
||||
hypothesis=response_dict["hypothesis"],
|
||||
reason=response_dict["reason"],
|
||||
|
||||
Reference in New Issue
Block a user