@@ -390,6 +390,14 @@ def solve_ols(
390390 rank-deficient matrices. Use only when you know the design matrix is
391391 full rank. If the matrix is actually rank-deficient, results may be
392392 incorrect (minimum-norm solution instead of R-style NA handling).
393+ weights : ndarray of shape (n,), optional
394+ Observation weights for Weighted Least Squares. When provided,
395+ minimizes sum(w_i * (y_i - X_i @ beta)^2). Weights should be
396+ pre-normalized (e.g., mean=1 for pweights).
397+ weight_type : str, default "pweight"
398+ Type of weights: "pweight" (inverse selection probability),
399+ "fweight" (frequency), or "aweight" (inverse variance).
400+ Affects variance estimation but not coefficient computation.
393401
394402 Returns
395403 -------
@@ -497,6 +505,11 @@ def solve_ols(
497505 X = X * sqrt_w [:, np .newaxis ]
498506 y = y * sqrt_w
499507
508+ # When weights are present, compute vcov separately on original-scale data
509+ # to avoid double-weighting. The backend only computes point estimates.
510+ _weighted_vcov_external = weights is not None
511+ _backend_return_vcov = return_vcov and not _weighted_vcov_external
512+
500513 # Fast path: skip rank check and use Rust directly when requested
501514 # This saves O(nk²) QR overhead but won't detect rank-deficient matrices
502515 result = None # Will hold the tuple from backend functions
@@ -507,23 +520,20 @@ def solve_ols(
507520 X ,
508521 y ,
509522 cluster_ids = cluster_ids ,
510- return_vcov = return_vcov ,
523+ return_vcov = _backend_return_vcov ,
511524 return_fitted = return_fitted ,
512525 )
513526 # result is None on numerical instability → fall through
514527 if result is None :
515- # Fall through to Python without rank check (user guarantees full rank)
516528 result = _solve_ols_numpy (
517529 X ,
518530 y ,
519531 cluster_ids = cluster_ids ,
520- return_vcov = return_vcov ,
532+ return_vcov = _backend_return_vcov ,
521533 return_fitted = return_fitted ,
522534 rank_deficient_action = rank_deficient_action ,
523535 column_names = column_names ,
524536 _skip_rank_check = True ,
525- weights = weights ,
526- weight_type = weight_type ,
527537 )
528538 else :
529539 # Check for rank deficiency using fast pivoted QR decomposition.
@@ -546,14 +556,13 @@ def solve_ols(
546556 X ,
547557 y ,
548558 cluster_ids = cluster_ids ,
549- return_vcov = return_vcov ,
559+ return_vcov = _backend_return_vcov ,
550560 return_fitted = return_fitted ,
551561 )
552562
553563 if result is not None :
554- # Check for NaN vcov: Rust SVD may detect rank-deficiency that QR missed
555564 vcov_check = result [- 1 ]
556- if return_vcov and vcov_check is not None and np .any (np .isnan (vcov_check )):
565+ if _backend_return_vcov and vcov_check is not None and np .any (np .isnan (vcov_check )):
557566 warnings .warn (
558567 "Rust backend detected ill-conditioned matrix (NaN in variance-covariance). "
559568 "Re-running with Python backend for proper rank detection." ,
@@ -563,35 +572,41 @@ def solve_ols(
563572 result = None # Force Python fallback below
564573
565574 if result is None :
566- # Python backend for: weighted, rank-deficient, Rust instability, no Rust
567575 result = _solve_ols_numpy (
568576 X ,
569577 y ,
570578 cluster_ids = cluster_ids ,
571- return_vcov = return_vcov ,
579+ return_vcov = _backend_return_vcov ,
572580 return_fitted = return_fitted ,
573581 rank_deficient_action = rank_deficient_action ,
574582 column_names = column_names ,
575- _precomputed_rank_info = (
576- (rank , dropped_cols , pivot )
577- if not (weights is not None and _original_X is not None )
578- else None
579- ),
580- weights = weights ,
581- weight_type = weight_type ,
583+ _precomputed_rank_info = (rank , dropped_cols , pivot ),
582584 )
583585
584- # Back-transform residuals to original scale when WLS was applied.
585- # WLS solves on transformed (X_w, y_w) but residuals should be y - X @ beta.
586+ # Back-transform residuals and compute weighted vcov on original-scale data.
587+ # The WLS transform (sqrt(w) scaling) is for point estimates only. Vcov must
588+ # be computed on original X and residuals with weights applied exactly once.
586589 if _original_X is not None and _original_y is not None :
587590 if return_fitted :
588591 coefficients , _resid_w , _fitted_w , vcov_out = result
589- fitted_orig = np .dot (_original_X , coefficients )
590- residuals_orig = _original_y - fitted_orig
591- result = (coefficients , residuals_orig , fitted_orig , vcov_out )
592592 else :
593593 coefficients , _resid_w , vcov_out = result
594- residuals_orig = _original_y - np .dot (_original_X , coefficients )
594+
595+ fitted_orig = np .dot (_original_X , coefficients )
596+ residuals_orig = _original_y - fitted_orig
597+
598+ if return_vcov :
599+ vcov_out = _compute_robust_vcov_numpy (
600+ _original_X ,
601+ residuals_orig ,
602+ cluster_ids ,
603+ weights = weights ,
604+ weight_type = weight_type ,
605+ )
606+
607+ if return_fitted :
608+ result = (coefficients , residuals_orig , fitted_orig , vcov_out )
609+ else :
595610 result = (coefficients , residuals_orig , vcov_out )
596611
597612 return result
@@ -608,8 +623,6 @@ def _solve_ols_numpy(
608623 column_names : Optional [List [str ]] = None ,
609624 _precomputed_rank_info : Optional [Tuple [int , np .ndarray , np .ndarray ]] = None ,
610625 _skip_rank_check : bool = False ,
611- weights : Optional [np .ndarray ] = None ,
612- weight_type : str = "pweight" ,
613626) -> Union [
614627 Tuple [np .ndarray , np .ndarray , Optional [np .ndarray ]],
615628 Tuple [np .ndarray , np .ndarray , np .ndarray , Optional [np .ndarray ]],
@@ -716,8 +729,6 @@ def _solve_ols_numpy(
716729 X_reduced ,
717730 residuals ,
718731 cluster_ids ,
719- weights = weights ,
720- weight_type = weight_type ,
721732 )
722733 vcov = _expand_vcov_with_nan (vcov_reduced , k , kept_cols )
723734 else :
@@ -732,13 +743,7 @@ def _solve_ols_numpy(
732743 # Compute variance-covariance matrix if requested
733744 vcov = None
734745 if return_vcov :
735- vcov = _compute_robust_vcov_numpy (
736- X ,
737- residuals ,
738- cluster_ids ,
739- weights = weights ,
740- weight_type = weight_type ,
741- )
746+ vcov = _compute_robust_vcov_numpy (X , residuals , cluster_ids )
742747
743748 if return_fitted :
744749 return coefficients , residuals , fitted , vcov
@@ -892,8 +897,8 @@ def _compute_robust_vcov_numpy(
892897 if weights is not None and weight_type == "fweight" :
893898 n_eff = int (np .sum (weights ))
894899
895- # Compute weighted scores: pweight/fweight multiply by w; aweight and
896- # unweighted use raw residuals ( aweight errors are ~homoskedastic after WLS)
900+ # Compute weighted scores for cluster-robust meat (outer product of sums).
901+ # pweight/fweight multiply by w; aweight and unweighted use raw residuals.
897902 _use_weighted_scores = weights is not None and weight_type not in ("aweight" ,)
898903 if _use_weighted_scores :
899904 scores = X * (weights * residuals )[:, np .newaxis ]
@@ -902,8 +907,12 @@ def _compute_robust_vcov_numpy(
902907
903908 if cluster_ids is None :
904909 # HC1 (heteroskedasticity-robust) standard errors
910+ # For HC1, meat = X' diag(w * u²) X (NOT scores'scores which gives w²*u²)
905911 adjustment = n_eff / (n_eff - k )
906- meat = scores .T @ scores
912+ if _use_weighted_scores :
913+ meat = np .dot (X .T , X * (weights * residuals ** 2 )[:, np .newaxis ])
914+ else :
915+ meat = np .dot (X .T , X * (residuals ** 2 )[:, np .newaxis ])
907916 else :
908917 # Cluster-robust standard errors (vectorized via groupby)
909918 cluster_ids = np .asarray (cluster_ids )
@@ -1450,22 +1459,42 @@ def fit(
14501459 # Rank-deficient: compute vcov for identified coefficients only
14511460 kept_cols = np .where (~ nan_mask )[0 ]
14521461 X_reduced = X [:, kept_cols ]
1453- mse = np .sum (residuals ** 2 ) / (n - k_effective )
1454- try :
1455- vcov_reduced = np .linalg .solve (
1456- X_reduced .T @ X_reduced , mse * np .eye (k_effective )
1457- )
1458- except np .linalg .LinAlgError :
1459- vcov_reduced = np .linalg .pinv (X_reduced .T @ X_reduced ) * mse
1462+ if self .weights is not None :
1463+ # Weighted classical vcov: use weighted RSS and X'WX
1464+ w = self .weights
1465+ mse = np .sum (w * residuals ** 2 ) / (n - k_effective )
1466+ XtWX_reduced = X_reduced .T @ (X_reduced * w [:, np .newaxis ])
1467+ try :
1468+ vcov_reduced = np .linalg .solve (XtWX_reduced , mse * np .eye (k_effective ))
1469+ except np .linalg .LinAlgError :
1470+ vcov_reduced = np .linalg .pinv (XtWX_reduced ) * mse
1471+ else :
1472+ mse = np .sum (residuals ** 2 ) / (n - k_effective )
1473+ try :
1474+ vcov_reduced = np .linalg .solve (
1475+ X_reduced .T @ X_reduced , mse * np .eye (k_effective )
1476+ )
1477+ except np .linalg .LinAlgError :
1478+ vcov_reduced = np .linalg .pinv (X_reduced .T @ X_reduced ) * mse
14601479 # Expand to full size with NaN for dropped columns
14611480 vcov = _expand_vcov_with_nan (vcov_reduced , k , kept_cols )
14621481 else :
14631482 # Full rank: standard computation
1464- mse = np .sum (residuals ** 2 ) / (n - k )
1465- try :
1466- vcov = np .linalg .solve (X .T @ X , mse * np .eye (k ))
1467- except np .linalg .LinAlgError :
1468- vcov = np .linalg .pinv (X .T @ X ) * mse
1483+ if self .weights is not None :
1484+ # Weighted classical vcov: use weighted RSS and X'WX
1485+ w = self .weights
1486+ mse = np .sum (w * residuals ** 2 ) / (n - k )
1487+ XtWX = X .T @ (X * w [:, np .newaxis ])
1488+ try :
1489+ vcov = np .linalg .solve (XtWX , mse * np .eye (k ))
1490+ except np .linalg .LinAlgError :
1491+ vcov = np .linalg .pinv (XtWX ) * mse
1492+ else :
1493+ mse = np .sum (residuals ** 2 ) / (n - k )
1494+ try :
1495+ vcov = np .linalg .solve (X .T @ X , mse * np .eye (k ))
1496+ except np .linalg .LinAlgError :
1497+ vcov = np .linalg .pinv (X .T @ X ) * mse
14691498
14701499 # Compute survey vcov if applicable
14711500 if _use_survey_vcov :
0 commit comments