hoàn thành tính cận trên và cận dưới của tất cả các thuật toán
This commit is contained in:
Regular → Executable
+314
-13
@@ -938,7 +938,21 @@ def train_model(
|
||||
# Encode labels
|
||||
label_encoder = LabelEncoder()
|
||||
labels_encoded = label_encoder.fit_transform(labels)
|
||||
|
||||
|
||||
# Compute class-distribution-aware baselines from FULL dataset (before split)
|
||||
# More reliable than computing from test set (which may be small / accidentally balanced)
|
||||
from collections import Counter
|
||||
_full_counts = Counter(labels_encoded)
|
||||
_full_total = len(labels_encoded)
|
||||
_n_cls = len(label_encoder.classes_)
|
||||
_full_props = [_full_counts.get(i, 0) / _full_total for i in range(_n_cls)]
|
||||
majority_class_baseline = float(max(_full_props)) if _full_props else 1.0 / max(_n_cls, 1)
|
||||
weighted_random_baseline = float(sum(p**2 for p in _full_props)) if _full_props else 1.0 / max(_n_cls, 1)
|
||||
print(f"[BASELINE] Full dataset counts : {[_full_counts.get(i,0) for i in range(_n_cls)]}")
|
||||
print(f"[BASELINE] Class proportions : {[f'{p:.3f}' for p in _full_props]}")
|
||||
print(f"[BASELINE] Majority class : {majority_class_baseline*100:.2f}%")
|
||||
print(f"[BASELINE] Weighted random : {weighted_random_baseline*100:.2f}%")
|
||||
|
||||
# Split data
|
||||
X_train, X_test, y_train, y_test = train_test_split(
|
||||
features, labels_encoded, test_size=test_size, random_state=42, stratify=labels_encoded
|
||||
@@ -957,7 +971,7 @@ def train_model(
|
||||
device=device if use_gpu else 'cpu',
|
||||
tree_method='hist',
|
||||
random_state=42,
|
||||
eval_metric='mlogloss',
|
||||
eval_metric=['mlogloss', 'merror'], # merror = 1 - accuracy, tracked per tree
|
||||
verbosity=0
|
||||
)
|
||||
elif model_type == 'random_forest':
|
||||
@@ -1010,7 +1024,17 @@ def train_model(
|
||||
# Train CNN
|
||||
update_status("Training CNN model with PyTorch...", 80)
|
||||
epochs = min(50, n_estimators // 2) # Use n_estimators as epochs
|
||||
|
||||
|
||||
# Validation dataset (for per-epoch accuracy)
|
||||
val_dataset = TensorDataset(X_test_tensor, y_test_tensor)
|
||||
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
|
||||
|
||||
# Best/worst checkpoint tracking
|
||||
cnn_best_val_acc = -1.0
|
||||
cnn_worst_val_acc = 101.0
|
||||
cnn_best_checkpoint = None
|
||||
cnn_worst_checkpoint = None
|
||||
|
||||
model.train()
|
||||
for epoch in range(epochs):
|
||||
epoch_loss = 0.0
|
||||
@@ -1025,9 +1049,48 @@ def train_model(
|
||||
|
||||
epoch_loss += loss.item()
|
||||
|
||||
avg_train_loss = epoch_loss / len(train_loader)
|
||||
|
||||
# Validation pass every epoch
|
||||
model.eval()
|
||||
val_loss_ep = 0.0
|
||||
correct_ep = 0
|
||||
total_ep = 0
|
||||
with torch.no_grad():
|
||||
for batch_X, batch_y in val_loader:
|
||||
batch_X, batch_y = batch_X.to(device), batch_y.to(device)
|
||||
outputs = model(batch_X)
|
||||
val_loss_ep += criterion(outputs, batch_y).item()
|
||||
_, predicted = torch.max(outputs, 1)
|
||||
total_ep += batch_y.size(0)
|
||||
correct_ep += (predicted == batch_y).sum().item()
|
||||
model.train()
|
||||
|
||||
avg_val_loss = val_loss_ep / len(val_loader)
|
||||
val_acc_ep = correct_ep / total_ep # fraction
|
||||
|
||||
# Track best / worst
|
||||
ck = {
|
||||
"epoch": epoch + 1,
|
||||
"accuracy": float(val_acc_ep),
|
||||
"trainAcc": None, # CNN doesn't compute per-epoch train acc
|
||||
"valAcc": float(val_acc_ep),
|
||||
"trainLoss": float(avg_train_loss),
|
||||
"valLoss": float(avg_val_loss),
|
||||
}
|
||||
if val_acc_ep > cnn_best_val_acc:
|
||||
cnn_best_val_acc = val_acc_ep
|
||||
cnn_best_checkpoint = ck.copy()
|
||||
if val_acc_ep < cnn_worst_val_acc:
|
||||
cnn_worst_val_acc = val_acc_ep
|
||||
cnn_worst_checkpoint = ck.copy()
|
||||
|
||||
if (epoch + 1) % 10 == 0:
|
||||
avg_loss = epoch_loss / len(train_loader)
|
||||
update_status(f"CNN Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}", 80 + (epoch / epochs) * 10)
|
||||
update_status(f"CNN Epoch {epoch+1}/{epochs}, Train Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}, Val Acc: {val_acc_ep*100:.2f}%", 80 + (epoch / epochs) * 10)
|
||||
|
||||
xgb_best_checkpoint = cnn_best_checkpoint
|
||||
xgb_worst_checkpoint = cnn_worst_checkpoint
|
||||
update_status(f"CNN: best epoch #{cnn_best_checkpoint['epoch']} val_acc={cnn_best_checkpoint['accuracy']*100:.2f}% worst epoch #{cnn_worst_checkpoint['epoch']} val_acc={cnn_worst_checkpoint['accuracy']*100:.2f}%", 88)
|
||||
|
||||
# Move model to CPU for saving (compatible with non-GPU systems)
|
||||
model = model.cpu()
|
||||
@@ -1079,10 +1142,16 @@ def train_model(
|
||||
# Train Swin-UNet
|
||||
update_status("Training Swin-UNet model with PyTorch (with class weights)...", 80)
|
||||
epochs = min(60, n_estimators // 2) # Swin-UNet benefits from more epochs
|
||||
|
||||
|
||||
# Best/worst checkpoint tracking
|
||||
swin_best_val_acc = -1.0
|
||||
swin_worst_val_acc = 101.0
|
||||
swin_best_checkpoint = None
|
||||
swin_worst_checkpoint = None
|
||||
|
||||
# Validation dataset
|
||||
val_dataset = TensorDataset(X_test_tensor, y_test_tensor)
|
||||
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
|
||||
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
|
||||
|
||||
model.train()
|
||||
for epoch in range(epochs):
|
||||
@@ -1126,7 +1195,23 @@ def train_model(
|
||||
avg_val_loss = val_loss / len(val_loader)
|
||||
val_acc = 100 * correct / total
|
||||
lr = optimizer.param_groups[0]['lr']
|
||||
|
||||
|
||||
# Track best / worst checkpoint (every epoch)
|
||||
_ck_swin = {
|
||||
"epoch": epoch + 1,
|
||||
"accuracy": float(val_acc / 100),
|
||||
"trainAcc": None,
|
||||
"valAcc": float(val_acc / 100),
|
||||
"trainLoss": float(avg_train_loss),
|
||||
"valLoss": float(avg_val_loss),
|
||||
}
|
||||
if val_acc > swin_best_val_acc:
|
||||
swin_best_val_acc = val_acc
|
||||
swin_best_checkpoint = _ck_swin.copy()
|
||||
if val_acc < swin_worst_val_acc:
|
||||
swin_worst_val_acc = val_acc
|
||||
swin_worst_checkpoint = _ck_swin.copy()
|
||||
|
||||
if (epoch + 1) % 5 == 0:
|
||||
update_status(f"Swin-UNet Epoch {epoch+1}/{epochs}, Train Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}, Val Acc: {val_acc:.2f}%, LR: {lr:.6f}", 80 + (epoch / epochs) * 10)
|
||||
print(f"[SWIN-UNET] Epoch {epoch+1}/{epochs} - Train Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}, Val Acc: {val_acc:.2f}%")
|
||||
@@ -1142,6 +1227,11 @@ def train_model(
|
||||
update_status(f"Swin-UNet early stopped at epoch {epoch+1}", 90)
|
||||
break
|
||||
|
||||
xgb_best_checkpoint = swin_best_checkpoint
|
||||
xgb_worst_checkpoint = swin_worst_checkpoint
|
||||
if swin_best_checkpoint:
|
||||
update_status(f"Swin-UNet: best epoch #{swin_best_checkpoint['epoch']} val_acc={swin_best_checkpoint['accuracy']*100:.2f}% worst epoch #{swin_worst_checkpoint['epoch']} val_acc={swin_worst_checkpoint['accuracy']*100:.2f}%", 88)
|
||||
|
||||
model = model.cpu()
|
||||
model.device_used = str(device)
|
||||
|
||||
@@ -1191,10 +1281,16 @@ def train_model(
|
||||
# Train MobileNetV3 + LR-ASPP
|
||||
update_status("Training MobileNetV3 + LR-ASPP model with PyTorch...", 80)
|
||||
epochs = min(60, n_estimators // 2)
|
||||
|
||||
|
||||
# Best/worst checkpoint tracking
|
||||
mob_best_val_acc = -1.0
|
||||
mob_worst_val_acc = 101.0
|
||||
mob_best_checkpoint = None
|
||||
mob_worst_checkpoint = None
|
||||
|
||||
# Validation dataset
|
||||
val_dataset = TensorDataset(X_test_tensor, y_test_tensor)
|
||||
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)
|
||||
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)
|
||||
|
||||
model.train()
|
||||
for epoch in range(epochs):
|
||||
@@ -1238,7 +1334,23 @@ def train_model(
|
||||
# Update learning rate
|
||||
scheduler.step(avg_val_loss)
|
||||
lr = optimizer.param_groups[0]['lr']
|
||||
|
||||
|
||||
# Track best / worst checkpoint (every epoch)
|
||||
_ck_mob = {
|
||||
"epoch": epoch + 1,
|
||||
"accuracy": float(val_acc / 100),
|
||||
"trainAcc": None,
|
||||
"valAcc": float(val_acc / 100),
|
||||
"trainLoss": float(avg_train_loss),
|
||||
"valLoss": float(avg_val_loss),
|
||||
}
|
||||
if val_acc > mob_best_val_acc:
|
||||
mob_best_val_acc = val_acc
|
||||
mob_best_checkpoint = _ck_mob.copy()
|
||||
if val_acc < mob_worst_val_acc:
|
||||
mob_worst_val_acc = val_acc
|
||||
mob_worst_checkpoint = _ck_mob.copy()
|
||||
|
||||
if (epoch + 1) % 5 == 0:
|
||||
update_status(f"MobileNet Epoch {epoch+1}/{epochs}, Train Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}, Val Acc: {val_acc:.2f}%, LR: {lr:.6f}", 80 + (epoch / epochs) * 10)
|
||||
print(f"[MOBILENET] Epoch {epoch+1}/{epochs} - Train Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}, Val Acc: {val_acc:.2f}%")
|
||||
@@ -1254,6 +1366,11 @@ def train_model(
|
||||
update_status(f"MobileNet early stopped at epoch {epoch+1}", 90)
|
||||
break
|
||||
|
||||
xgb_best_checkpoint = mob_best_checkpoint
|
||||
xgb_worst_checkpoint = mob_worst_checkpoint
|
||||
if mob_best_checkpoint:
|
||||
update_status(f"MobileNet: best epoch #{mob_best_checkpoint['epoch']} val_acc={mob_best_checkpoint['accuracy']*100:.2f}% worst epoch #{mob_worst_checkpoint['epoch']} val_acc={mob_worst_checkpoint['accuracy']*100:.2f}%", 88)
|
||||
|
||||
model = model.cpu()
|
||||
model.device_used = str(device)
|
||||
|
||||
@@ -1261,8 +1378,159 @@ def train_model(
|
||||
raise ValueError(f"Unknown model type: {model_type}. Choose: xgboost, random_forest, decision_tree, svm, cnn, swin-unet, mobilenet-lraspp")
|
||||
|
||||
# Fit non-neural-network models
|
||||
# Note: CNN/Swin-UNet/MobileNet already set xgb_best/worst_checkpoint in their blocks above
|
||||
if model_type not in ['cnn', 'swin-unet', 'mobilenet-lraspp']:
|
||||
model.fit(X_train, y_train)
|
||||
xgb_best_checkpoint = None
|
||||
xgb_worst_checkpoint = None
|
||||
if model_type == 'xgboost':
|
||||
# Pass eval_set so XGBoost evaluates on train+val after each tree
|
||||
model.fit(
|
||||
X_train, y_train,
|
||||
eval_set=[(X_train, y_train), (X_test, y_test)],
|
||||
verbose=False # suppress per-tree stdout; use model.evals_result()
|
||||
)
|
||||
|
||||
# ── Per-tree best / worst accuracy tracking ──────────────────
|
||||
# validation_0 = train set, validation_1 = val/test set
|
||||
evals = model.evals_result()
|
||||
val_errors = evals.get('validation_1', {}).get('merror', []) # merror = 1 - accuracy
|
||||
train_errors = evals.get('validation_0', {}).get('merror', [])
|
||||
val_losses = evals.get('validation_1', {}).get('mlogloss', [])
|
||||
train_losses = evals.get('validation_0', {}).get('mlogloss', [])
|
||||
|
||||
if val_errors:
|
||||
# best tree = highest val accuracy (lowest merror)
|
||||
# worst tree = lowest val accuracy (highest merror)
|
||||
best_idx = int(np.argmin(val_errors))
|
||||
worst_idx = int(np.argmax(val_errors))
|
||||
|
||||
val_accs = [1.0 - e for e in val_errors]
|
||||
train_accs = [1.0 - e for e in train_errors] if train_errors else val_accs
|
||||
|
||||
xgb_best_checkpoint = {
|
||||
"epoch": best_idx + 1, # 1-based tree number
|
||||
"accuracy": float(val_accs[best_idx]),
|
||||
"trainAcc": float(train_accs[best_idx]),
|
||||
"valAcc": float(val_accs[best_idx]),
|
||||
"trainLoss": float(train_losses[best_idx]) if train_losses else 0.0,
|
||||
"valLoss": float(val_losses[best_idx]) if val_losses else 0.0,
|
||||
}
|
||||
xgb_worst_checkpoint = {
|
||||
"epoch": worst_idx + 1,
|
||||
"accuracy": float(val_accs[worst_idx]),
|
||||
"trainAcc": float(train_accs[worst_idx]),
|
||||
"valAcc": float(val_accs[worst_idx]),
|
||||
"trainLoss": float(train_losses[worst_idx]) if train_losses else 0.0,
|
||||
"valLoss": float(val_losses[worst_idx]) if val_losses else 0.0,
|
||||
}
|
||||
|
||||
update_status(
|
||||
f"XGBoost per-tree: best tree #{best_idx+1} val_acc={val_accs[best_idx]*100:.2f}% "
|
||||
f"worst tree #{worst_idx+1} val_acc={val_accs[worst_idx]*100:.2f}%", 88
|
||||
)
|
||||
else:
|
||||
model.fit(X_train, y_train)
|
||||
|
||||
# ── Per-tree individual accuracy for Random Forest ────────────────
|
||||
if model_type == 'random_forest' and hasattr(model, 'estimators_') and model.estimators_:
|
||||
n_trees = len(model.estimators_)
|
||||
rf_val_accs = []
|
||||
rf_train_accs = []
|
||||
|
||||
# Track individual tree accuracy (not cumulative) to capture real variance
|
||||
for tree in model.estimators_:
|
||||
rf_val_accs.append(float(np.mean(tree.predict(X_test) == y_test)))
|
||||
rf_train_accs.append(float(np.mean(tree.predict(X_train) == y_train)))
|
||||
|
||||
best_idx_rf = int(np.argmax(rf_val_accs))
|
||||
worst_idx_rf = int(np.argmin(rf_val_accs))
|
||||
|
||||
xgb_best_checkpoint = {
|
||||
"epoch": best_idx_rf + 1,
|
||||
"accuracy": rf_val_accs[best_idx_rf],
|
||||
"trainAcc": rf_train_accs[best_idx_rf],
|
||||
"valAcc": rf_val_accs[best_idx_rf],
|
||||
"trainLoss": None,
|
||||
"valLoss": None,
|
||||
}
|
||||
xgb_worst_checkpoint = {
|
||||
"epoch": worst_idx_rf + 1,
|
||||
"accuracy": rf_val_accs[worst_idx_rf],
|
||||
"trainAcc": rf_train_accs[worst_idx_rf],
|
||||
"valAcc": rf_val_accs[worst_idx_rf],
|
||||
"trainLoss": None,
|
||||
"valLoss": None,
|
||||
}
|
||||
update_status(
|
||||
f"RandomForest per-tree: best #{best_idx_rf+1}/{n_trees} val_acc={rf_val_accs[best_idx_rf]*100:.2f}% "
|
||||
f"worst #{worst_idx_rf+1}/{n_trees} val_acc={rf_val_accs[worst_idx_rf]*100:.2f}%", 88
|
||||
)
|
||||
elif model_type == 'decision_tree':
|
||||
# Track accuracy per depth from 1 to max_depth to show real variance
|
||||
dt_val_accs = []
|
||||
dt_train_accs = []
|
||||
_dt_params = {k: v for k, v in model.get_params().items() if k != 'max_depth'}
|
||||
for d in range(1, max_depth + 1):
|
||||
from sklearn.tree import DecisionTreeClassifier as _DTC
|
||||
_tmp = _DTC(max_depth=d, **_dt_params)
|
||||
_tmp.fit(X_train, y_train)
|
||||
dt_val_accs.append(float(_tmp.score(X_test, y_test)))
|
||||
dt_train_accs.append(float(_tmp.score(X_train, y_train)))
|
||||
best_idx_dt = int(np.argmax(dt_val_accs))
|
||||
worst_idx_dt = int(np.argmin(dt_val_accs))
|
||||
xgb_best_checkpoint = {
|
||||
"epoch": best_idx_dt + 1,
|
||||
"accuracy": dt_val_accs[best_idx_dt],
|
||||
"trainAcc": dt_train_accs[best_idx_dt],
|
||||
"valAcc": dt_val_accs[best_idx_dt],
|
||||
"trainLoss": None,
|
||||
"valLoss": None,
|
||||
}
|
||||
xgb_worst_checkpoint = {
|
||||
"epoch": worst_idx_dt + 1,
|
||||
"accuracy": dt_val_accs[worst_idx_dt],
|
||||
"trainAcc": dt_train_accs[worst_idx_dt],
|
||||
"valAcc": dt_val_accs[worst_idx_dt],
|
||||
"trainLoss": None,
|
||||
"valLoss": None,
|
||||
}
|
||||
update_status(
|
||||
f"DecisionTree per-depth: best depth={best_idx_dt+1} val_acc={dt_val_accs[best_idx_dt]*100:.2f}% "
|
||||
f"worst depth={worst_idx_dt+1} val_acc={dt_val_accs[worst_idx_dt]*100:.2f}%", 88
|
||||
)
|
||||
elif model_type == 'svm':
|
||||
# Sweep across C values to show real best/worst variation
|
||||
_c_values = [0.01, 0.1, 1.0, 10.0, 100.0]
|
||||
svm_val_accs = []
|
||||
svm_train_accs = []
|
||||
for _c in _c_values:
|
||||
from sklearn.svm import SVC as _SVC
|
||||
_tmp = _SVC(kernel='rbf', C=_c, random_state=42, verbose=False)
|
||||
_tmp.fit(X_train, y_train)
|
||||
svm_val_accs.append(float(_tmp.score(X_test, y_test)))
|
||||
svm_train_accs.append(float(_tmp.score(X_train, y_train)))
|
||||
best_idx_svm = int(np.argmax(svm_val_accs))
|
||||
worst_idx_svm = int(np.argmin(svm_val_accs))
|
||||
xgb_best_checkpoint = {
|
||||
"epoch": best_idx_svm + 1,
|
||||
"accuracy": svm_val_accs[best_idx_svm],
|
||||
"trainAcc": svm_train_accs[best_idx_svm],
|
||||
"valAcc": svm_val_accs[best_idx_svm],
|
||||
"trainLoss": None,
|
||||
"valLoss": None,
|
||||
}
|
||||
xgb_worst_checkpoint = {
|
||||
"epoch": worst_idx_svm + 1,
|
||||
"accuracy": svm_val_accs[worst_idx_svm],
|
||||
"trainAcc": svm_train_accs[worst_idx_svm],
|
||||
"valAcc": svm_val_accs[worst_idx_svm],
|
||||
"trainLoss": None,
|
||||
"valLoss": None,
|
||||
}
|
||||
update_status(
|
||||
f"SVM C-sweep: best C={_c_values[best_idx_svm]} val_acc={svm_val_accs[best_idx_svm]*100:.2f}% "
|
||||
f"worst C={_c_values[worst_idx_svm]} val_acc={svm_val_accs[worst_idx_svm]*100:.2f}%", 88
|
||||
)
|
||||
|
||||
# Evaluate
|
||||
update_status("Evaluating model...", 90)
|
||||
@@ -1286,6 +1554,26 @@ def train_model(
|
||||
|
||||
# Confusion matrix
|
||||
conf_matrix = confusion_matrix(y_test, y_pred).tolist()
|
||||
|
||||
# Compute class-distribution-aware baselines from test set support counts
|
||||
# (kept for diagnostic printing; main values computed above from full dataset)
|
||||
_supports = []
|
||||
for cn in label_encoder.classes_:
|
||||
key = str(cn)
|
||||
if key in cls_report and isinstance(cls_report[key], dict):
|
||||
_supports.append(cls_report[key]['support'])
|
||||
else:
|
||||
# Try integer key
|
||||
for k, v in cls_report.items():
|
||||
if isinstance(v, dict) and k not in ('accuracy', 'macro avg', 'weighted avg'):
|
||||
pass
|
||||
_supports = [] # lookup ambiguous, skip
|
||||
break
|
||||
if _supports:
|
||||
_total_test = sum(_supports)
|
||||
_props_test = [s / _total_test for s in _supports] if _total_test > 0 else []
|
||||
print(f"[BASELINE] Test-set supports : {_supports}")
|
||||
print(f"[BASELINE] Test-set majority : {max(_props_test)*100:.2f}% (fyi, using full-dataset value above)")
|
||||
|
||||
# Save model using ModelManager
|
||||
update_status("Saving model...", 95)
|
||||
@@ -1326,6 +1614,12 @@ def train_model(
|
||||
"time_range": time_range,
|
||||
"resolution": resolution
|
||||
}
|
||||
|
||||
# Attach XGBoost per-tree best/worst checkpoint to metadata
|
||||
if xgb_best_checkpoint is not None:
|
||||
info["best_checkpoint"] = xgb_best_checkpoint
|
||||
if xgb_worst_checkpoint is not None:
|
||||
info["worst_checkpoint"] = xgb_worst_checkpoint
|
||||
|
||||
# Use ModelManager to save
|
||||
from model_manager import get_model_manager
|
||||
@@ -1353,12 +1647,19 @@ def train_model(
|
||||
"testing_samples": len(X_test),
|
||||
"test_size": test_size,
|
||||
"classes": class_names,
|
||||
"n_classes": len(class_names),
|
||||
"majority_class_baseline": majority_class_baseline,
|
||||
"weighted_random_baseline": weighted_random_baseline,
|
||||
"classification_report": cls_report,
|
||||
"confusion_matrix": conf_matrix,
|
||||
"model_type": model_type,
|
||||
"bbox": bbox,
|
||||
"time_range": time_range,
|
||||
"resolution": resolution
|
||||
"resolution": resolution,
|
||||
"best_checkpoint": xgb_best_checkpoint,
|
||||
"worst_checkpoint": xgb_worst_checkpoint,
|
||||
"majority_class_baseline": majority_class_baseline,
|
||||
"weighted_random_baseline": weighted_random_baseline,
|
||||
}
|
||||
|
||||
except InterruptedError as e:
|
||||
|
||||
Reference in New Issue
Block a user