| 137 |
137 |
case "xtset": return try handleXTSet(command) |
| 138 |
138 |
case "display": return try handleDisplay(command) |
| 139 |
139 |
case "bootstrap": return try await handleBootstrap(command, backend: backend) |
|
140 |
+ case "permute": return try await handlePermute(command) |
| 140 |
141 |
case "graph": return try handleGraph(command) |
|
142 |
+ case "histogram", "scatter", "kdensity": |
|
143 |
+ var promoted = command |
|
144 |
+ promoted.subverb = command.verb |
|
145 |
+ promoted.verb = "graph" |
|
146 |
+ return try handleGraph(promoted) |
| 141 |
147 |
case "log": return try handleLog(command) |
| 142 |
148 |
default: |
| 143 |
149 |
throw ZQEngineError("command '\(command.verb)' is not implemented yet") |
| 1399 |
1405 |
return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars) |
| 1400 |
1406 |
} |
| 1401 |
1407 |
|
|
1408 |
+ // MARK: - Permutation test |
|
1409 |
+ |
|
1410 |
+ /// `permute, reps(#) [seed(#)]: reg y x…` — permutes the response |
|
1411 |
+ /// within the estimation sample, refits, and reports the empirical |
|
1412 |
+ /// two-sided p-value per coefficient: the fraction of permuted |β*| |
|
1413 |
+ /// at or above the observed |β|. Permutations come from the Philox |
|
1414 |
+ /// argsort stream, so results are exactly reproducible and |
|
1415 |
+ /// replicate-addressable. |
|
1416 |
+ private func handlePermute(_ command: ZQCommand) async throws -> ZQResult { |
|
1417 |
+ guard let body = command.body else { |
|
1418 |
+ throw ZQEngineError("permute: syntax is 'permute, reps(#): command'") |
|
1419 |
+ } |
|
1420 |
+ guard body.verb == "regress" else { |
|
1421 |
+ throw ZQEngineError("permute currently supports 'regress' bodies only") |
|
1422 |
+ } |
|
1423 |
+ guard let repsText = command.option("reps")?.firstArgument, |
|
1424 |
+ let reps = Int(repsText), reps >= 1 else { |
|
1425 |
+ throw ZQEngineError("permute: reps(#) with # ≥ 1 required") |
|
1426 |
+ } |
|
1427 |
+ if let seedText = command.option("seed")?.firstArgument { |
|
1428 |
+ guard let value = UInt64(seedText) else { |
|
1429 |
+ throw ZQEngineError("permute: invalid seed") |
|
1430 |
+ } |
|
1431 |
+ seed = value |
|
1432 |
+ } |
|
1433 |
+ |
|
1434 |
+ let sample = try buildRegressionSample(body, clusterVariable: nil) |
|
1435 |
+ let includeConstant = !body.hasOption("noconstant") |
|
1436 |
+ let observed = try ZQOLS.fit( |
|
1437 |
+ y: sample.y, |
|
1438 |
+ predictors: sample.predictors, |
|
1439 |
+ includeConstant: includeConstant, |
|
1440 |
+ variance: .classical |
|
1441 |
+ ) |
|
1442 |
+ let n = sample.y.count |
|
1443 |
+ let k = observed.coefficients.count |
|
1444 |
+ let generator = Philox4x32(seed: seed) |
|
1445 |
+ let y = sample.y |
|
1446 |
+ let predictors = sample.predictors |
|
1447 |
+ |
|
1448 |
+ // Count |β*| ≥ |β_obs| per coefficient, in parallel chunks — |
|
1449 |
+ // permutations are counter-addressable so order is irrelevant. |
|
1450 |
+ let observedMagnitudes = observed.coefficients.map { abs($0.estimate) } |
|
1451 |
+ let chunkCount = min( |
|
1452 |
+ max(1, ProcessInfo.processInfo.activeProcessorCount), reps |
|
1453 |
+ ) |
|
1454 |
+ let chunkSize = (reps + chunkCount - 1) / chunkCount |
|
1455 |
+ let exceedances: [Int] = try await withThrowingTaskGroup(of: [Int].self) { group in |
|
1456 |
+ for chunk in 0..<chunkCount { |
|
1457 |
+ let lower = chunk * chunkSize |
|
1458 |
+ let upper = min(reps, lower + chunkSize) |
|
1459 |
+ guard lower < upper else { continue } |
|
1460 |
+ group.addTask { |
|
1461 |
+ var counts = [Int](repeating: 0, count: k) |
|
1462 |
+ for replicate in lower..<upper { |
|
1463 |
+ let order = ZQResampling.permutationIndices( |
|
1464 |
+ replicate: replicate, sampleSize: n, generator: generator |
|
1465 |
+ ) |
|
1466 |
+ let fit = try ZQOLS.fit( |
|
1467 |
+ y: order.map { y[$0] }, |
|
1468 |
+ predictors: predictors, |
|
1469 |
+ includeConstant: includeConstant, |
|
1470 |
+ variance: .classical |
|
1471 |
+ ) |
|
1472 |
+ for j in 0..<k |
|
1473 |
+ where abs(fit.coefficients[j].estimate) >= observedMagnitudes[j] { |
|
1474 |
+ counts[j] += 1 |
|
1475 |
+ } |
|
1476 |
+ } |
|
1477 |
+ return counts |
|
1478 |
+ } |
|
1479 |
+ } |
|
1480 |
+ var total = [Int](repeating: 0, count: k) |
|
1481 |
+ for try await counts in group { |
|
1482 |
+ for j in 0..<k { total[j] += counts[j] } |
|
1483 |
+ } |
|
1484 |
+ return total |
|
1485 |
+ } |
|
1486 |
+ |
|
1487 |
+ var lines = [ |
|
1488 |
+ TableFormatter.pad("Permutation test (response permuted)", 46, right: false) + |
|
1489 |
+ "Replications = " + TableFormatter.pad("\(reps)", 10), |
|
1490 |
+ " Number of obs = " + |
|
1491 |
+ TableFormatter.pad("\(n)", 10), |
|
1492 |
+ "", |
|
1493 |
+ ] |
|
1494 |
+ let widths = [12, 12, 8, 8, 10, 11] |
|
1495 |
+ lines.append( |
|
1496 |
+ TableFormatter.pad(sample.responseName, widths[0]) + " | " + |
|
1497 |
+ TableFormatter.pad("Observed", widths[1]) + " " + |
|
1498 |
+ TableFormatter.pad("c", widths[2]) + " " + |
|
1499 |
+ TableFormatter.pad("reps", widths[3]) + " " + |
|
1500 |
+ TableFormatter.pad("p=c/reps", widths[4]) + " " + |
|
1501 |
+ TableFormatter.pad("SE(p)", widths[5]) |
|
1502 |
+ ) |
|
1503 |
+ lines.append(TableFormatter.rule(widths)) |
|
1504 |
+ |
|
1505 |
+ var scalars: [String: Double] = ["reps": Double(reps), "N": Double(n)] |
|
1506 |
+ for j in 0..<k { |
|
1507 |
+ let name = observed.coefficients[j].name |
|
1508 |
+ // The constant's permutation distribution is degenerate under |
|
1509 |
+ // response permutation; Stata omits it too. |
|
1510 |
+ if name == "_cons" { continue } |
|
1511 |
+ let p = Double(exceedances[j]) / Double(reps) |
|
1512 |
+ let standardError = (p * (1 - p) / Double(reps)).squareRoot() |
|
1513 |
+ lines.append( |
|
1514 |
+ TableFormatter.pad(name, widths[0]) + " | " + |
|
1515 |
+ TableFormatter.pad( |
|
1516 |
+ TableFormatter.general(observed.coefficients[j].estimate), widths[1] |
|
1517 |
+ ) + " " + |
|
1518 |
+ TableFormatter.pad("\(exceedances[j])", widths[2]) + " " + |
|
1519 |
+ TableFormatter.pad("\(reps)", widths[3]) + " " + |
|
1520 |
+ TableFormatter.pad(TableFormatter.fixed(p, decimals: 4), widths[4]) + " " + |
|
1521 |
+ TableFormatter.pad(TableFormatter.fixed(standardError, decimals: 4), widths[5]) |
|
1522 |
+ ) |
|
1523 |
+ scalars["b_\(name)"] = observed.coefficients[j].estimate |
|
1524 |
+ scalars["p_\(name)"] = p |
|
1525 |
+ scalars["c_\(name)"] = Double(exceedances[j]) |
|
1526 |
+ } |
|
1527 |
+ return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars) |
|
1528 |
+ } |
|
1529 |
+ |
| 1402 |
1530 |
// MARK: - Graphics |
| 1403 |
1531 |
|
| 1404 |
1532 |
private func handleGraph(_ command: ZQCommand) throws -> ZQResult { |
| 1408 |
1536 |
case "scatter": kind = .scatter |
| 1409 |
1537 |
case "line": kind = .line |
| 1410 |
1538 |
case "histogram": kind = .histogram |
|
1539 |
+ case "kdensity": kind = .kdensity |
| 1411 |
1540 |
case let other: |
| 1412 |
1541 |
throw ZQEngineError("graph \(other ?? ""): not supported yet") |
| 1413 |
1542 |
} |
| 1414 |
1543 |
|
| 1415 |
1544 |
let names = command.varlist.flatMap(\.referencedNames) |
|
1545 |
+ let mask = try observationMask(command) |
|
1546 |
+ |
|
1547 |
+ // Single-variable plots: histogram and kernel density. |
|
1548 |
+ if kind == .histogram || kind == .kdensity { |
|
1549 |
+ guard names.count == 1 else { |
|
1550 |
+ throw ZQEngineError("\(command.subverb ?? ""): syntax is '\(command.subverb ?? "") varname'") |
|
1551 |
+ } |
|
1552 |
+ let (values, missing) = try frame.requireNumeric(names[0]) |
|
1553 |
+ let data = (0..<frame.rowCount) |
|
1554 |
+ .filter { mask[$0] && !missing[$0] } |
|
1555 |
+ .map { values[$0] } |
|
1556 |
+ guard data.count > 1 else { throw ZQEngineError("no observations") } |
|
1557 |
+ |
|
1558 |
+ let series: ZQPlotSpec.Series |
|
1559 |
+ if kind == .histogram { |
|
1560 |
+ series = Self.histogramSeries( |
|
1561 |
+ data: data, label: names[0], |
|
1562 |
+ binCount: command.option("bins")?.firstArgument.flatMap(Int.init) |
|
1563 |
+ ) |
|
1564 |
+ } else { |
|
1565 |
+ series = Self.kernelDensitySeries(data: data, label: names[0]) |
|
1566 |
+ } |
|
1567 |
+ lastPlot = ZQPlotSpec( |
|
1568 |
+ kind: kind, |
|
1569 |
+ xLabel: names[0], |
|
1570 |
+ yLabel: kind == .histogram ? "Frequency" : "Density", |
|
1571 |
+ series: [series] |
|
1572 |
+ ) |
|
1573 |
+ return ZQResult( |
|
1574 |
+ text: "(plot created: \(data.count) observations)", |
|
1575 |
+ scalars: ["N": Double(data.count)] |
|
1576 |
+ ) |
|
1577 |
+ } |
|
1578 |
+ |
| 1416 |
1579 |
guard names.count == 2 else { |
| 1417 |
1580 |
throw ZQEngineError("graph \(command.subverb ?? ""): syntax is 'graph \(command.subverb ?? "kind") yvar xvar'") |
| 1418 |
1581 |
} |
| 1419 |
|
− let mask = try observationMask(command) |
| 1420 |
1582 |
let (yValues, yMissing) = try frame.requireNumeric(names[0]) |
| 1421 |
1583 |
let (xValues, xMissing) = try frame.requireNumeric(names[1]) |
| 1422 |
1584 |
|
| 1471 |
1633 |
return try evaluator.evaluateCondition(expression) |
| 1472 |
1634 |
} |
| 1473 |
1635 |
|
|
1636 |
+ /// Frequency histogram: Sturges bin count by default, half-open bins |
|
1637 |
+ /// with the maximum folded into the last bin. Series points are bin |
|
1638 |
+ /// midpoints vs counts. |
|
1639 |
+ static func histogramSeries( |
|
1640 |
+ data: [Double], label: String, binCount: Int? |
|
1641 |
+ ) -> ZQPlotSpec.Series { |
|
1642 |
+ let n = data.count |
|
1643 |
+ let bins = max(1, binCount ?? Int((Foundation.log2(Double(n))).rounded(.up)) + 1) |
|
1644 |
+ let minimum = data.min()! |
|
1645 |
+ let maximum = data.max()! |
|
1646 |
+ let width = maximum > minimum ? (maximum - minimum) / Double(bins) : 1 |
|
1647 |
+ var counts = [Double](repeating: 0, count: bins) |
|
1648 |
+ for value in data { |
|
1649 |
+ let raw = Int((value - minimum) / width) |
|
1650 |
+ counts[min(max(raw, 0), bins - 1)] += 1 |
|
1651 |
+ } |
|
1652 |
+ let midpoints = (0..<bins).map { minimum + (Double($0) + 0.5) * width } |
|
1653 |
+ return ZQPlotSpec.Series(label: label, x: midpoints, y: counts) |
|
1654 |
+ } |
|
1655 |
+ |
|
1656 |
+ /// Epanechnikov kernel density on a 100-point grid with Silverman's |
|
1657 |
+ /// bandwidth h = 0.9·min(sd, IQR/1.349)·n^(−1/5) (Stata's default). |
|
1658 |
+ static func kernelDensitySeries( |
|
1659 |
+ data: [Double], label: String |
|
1660 |
+ ) -> ZQPlotSpec.Series { |
|
1661 |
+ let n = data.count |
|
1662 |
+ let mean = data.reduce(0, +) / Double(n) |
|
1663 |
+ let sd = (data.reduce(0) { $0 + ($1 - mean) * ($1 - mean) } |
|
1664 |
+ / Double(n - 1)).squareRoot() |
|
1665 |
+ let sorted = data.sorted() |
|
1666 |
+ let iqr = ZQSummarize.stataPercentile(sorted: sorted, percent: 75) |
|
1667 |
+ - ZQSummarize.stataPercentile(sorted: sorted, percent: 25) |
|
1668 |
+ var spread = min(sd, iqr / 1.349) |
|
1669 |
+ if spread <= 0 { spread = max(sd, 1e-9) } |
|
1670 |
+ let bandwidth = 0.9 * spread * pow(Double(n), -0.2) |
|
1671 |
+ |
|
1672 |
+ let lower = sorted.first! - bandwidth |
|
1673 |
+ let upper = sorted.last! + bandwidth |
|
1674 |
+ let gridSize = 100 |
|
1675 |
+ let step = (upper - lower) / Double(gridSize - 1) |
|
1676 |
+ var xs = [Double](repeating: 0, count: gridSize) |
|
1677 |
+ var densities = [Double](repeating: 0, count: gridSize) |
|
1678 |
+ for g in 0..<gridSize { |
|
1679 |
+ let point = lower + Double(g) * step |
|
1680 |
+ xs[g] = point |
|
1681 |
+ var total = 0.0 |
|
1682 |
+ for value in data { |
|
1683 |
+ let u = (point - value) / bandwidth |
|
1684 |
+ if abs(u) < 1 { |
|
1685 |
+ total += 0.75 * (1 - u * u) // Epanechnikov kernel |
|
1686 |
+ } |
|
1687 |
+ } |
|
1688 |
+ densities[g] = total / (Double(n) * bandwidth) |
|
1689 |
+ } |
|
1690 |
+ return ZQPlotSpec.Series(label: label, x: xs, y: densities) |
|
1691 |
+ } |
|
1692 |
+ |
| 1474 |
1693 |
// MARK: - Qualifiers |
| 1475 |
1694 |
|
| 1476 |
1695 |
/// Combined if/in keep-mask over current observations. |