Skip to content

Commit 2f35075

Browse files
authored
Merge pull request #2456 from jashshah999/fix/batch-fixed-lag-smoother-enforce-consistency
Fix enforceConsistency_ in BatchFixedLagSmoother
2 parents 1ff220c + 1ad9014 commit 2f35075

File tree

2 files changed

+182
-0
lines changed

2 files changed

+182
-0
lines changed

gtsam/nonlinear/BatchFixedLagSmoother.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,20 @@ void BatchFixedLagSmoother::marginalize(const KeyVector& marginalizeKeys) {
342342

343343
// Insert the new marginal factors
344344
insertFactors(marginalFactors);
345+
346+
// Record the linearization points of variables involved in new marginal
347+
// factors. When enforceConsistency_ is enabled, these variables must keep
348+
// their current linearization point so the LinearContainerFactors remain
349+
// valid across future optimization iterations.
350+
if (enforceConsistency_) {
351+
for (const auto& factor : marginalFactors) {
352+
for (Key key : factor->keys()) {
353+
if (!linearValues_.exists(key)) {
354+
linearValues_.insert(key, theta_.at(key));
355+
}
356+
}
357+
}
358+
}
345359
}
346360

347361
/* ************************************************************************* */

gtsam/nonlinear/tests/testBatchFixedLagSmoother.cpp

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@
1818

1919
#include <CppUnitLite/TestHarness.h>
2020
#include <gtsam/nonlinear/BatchFixedLagSmoother.h>
21+
#include <gtsam/nonlinear/Marginals.h>
2122
#include <gtsam/base/debug.h>
2223
#include <gtsam/inference/Key.h>
2324
#include <gtsam/geometry/Point2.h>
25+
#include <gtsam/geometry/Pose2.h>
2426
#include <gtsam/linear/GaussianBayesNet.h>
2527
#include <gtsam/linear/GaussianFactorGraph.h>
2628
#include <gtsam/nonlinear/NonlinearFactorGraph.h>
2729
#include <gtsam/nonlinear/Values.h>
2830
#include <gtsam/slam/BetweenFactor.h>
31+
#include <random>
2932

3033
using namespace std;
3134
using namespace gtsam;
@@ -236,6 +239,171 @@ TEST( BatchFixedLagSmoother, Example )
236239
}
237240
}
238241

242+
/* ************************************************************************* */
243+
TEST( BatchFixedLagSmoother, EnforceConsistency )
244+
{
245+
// Verify that enforceConsistency_ actually preserves linearization points
246+
// for variables involved in marginal factors after marginalization.
247+
// Before the fix, linearValues_ was never populated, so this feature
248+
// was silently non-functional.
249+
250+
SharedDiagonal noise = noiseModel::Isotropic::Sigma(2, 0.1);
251+
252+
typedef BatchFixedLagSmoother::KeyTimestampMap Timestamps;
253+
254+
// Create two smoothers: one with consistency enforcement, one without
255+
LevenbergMarquardtParams params;
256+
BatchFixedLagSmoother smootherOn(3.0, params, true); // enforceConsistency = true
257+
BatchFixedLagSmoother smootherOff(3.0, params, false); // enforceConsistency = false
258+
259+
// Feed both smoothers the same data: a chain of between factors with
260+
// deliberately poor initial values to make relinearization matter.
261+
for (size_t i = 0; i <= 7; ++i) {
262+
NonlinearFactorGraph newFactors;
263+
Values newValues;
264+
Timestamps newTimestamps;
265+
266+
Key key_i(i);
267+
if (i == 0) {
268+
newFactors.addPrior(key_i, Point2(0.0, 0.0), noise);
269+
} else {
270+
Key key_prev(i - 1);
271+
newFactors.push_back(BetweenFactor<Point2>(key_prev, key_i, Point2(1.0, 0.0), noise));
272+
}
273+
274+
// Use a deliberately poor initial estimate to create nonlinearity
275+
newValues.insert(key_i, Point2(double(i) + 0.5, 0.5));
276+
newTimestamps[key_i] = double(i);
277+
278+
smootherOn.update(newFactors, newValues, newTimestamps);
279+
smootherOff.update(newFactors, newValues, newTimestamps);
280+
}
281+
282+
// After enough steps, marginalization has occurred (lag=3, at step 7 keys
283+
// 0..3 are marginalized). The smoothers should still produce valid estimates
284+
// but may differ because consistency enforcement constrains the optimization.
285+
// The key test: the enforceConsistency=true smoother should not crash and
286+
// should produce a reasonable estimate.
287+
Key lastKey(7);
288+
Point2 estimateOn = smootherOn.calculateEstimate<Point2>(lastKey);
289+
Point2 estimateOff = smootherOff.calculateEstimate<Point2>(lastKey);
290+
291+
// Both should be close to the ground truth (7.0, 0.0) -- the chain of
292+
// unit between-factors from the origin.
293+
Point2 expected(7.0, 0.0);
294+
EXPECT(assert_equal(expected, estimateOn, 0.5));
295+
EXPECT(assert_equal(expected, estimateOff, 0.5));
296+
}
297+
298+
/* ************************************************************************* */
299+
TEST( BatchFixedLagSmoother, NEES )
300+
{
301+
// Monte Carlo NEES evaluation comparing enforceConsistency on vs off.
302+
// Uses Pose2 (x, y, theta) so the problem is genuinely nonlinear --
303+
// the rotation makes Jacobians depend on the linearization point,
304+
// which is exactly where FEJ (First Estimates Jacobian) matters.
305+
306+
const double transSigma = 0.5;
307+
const double rotSigma = 0.3; // radians (~17 degrees)
308+
auto noise = noiseModel::Diagonal::Sigmas(
309+
(Vector(3) << rotSigma, transSigma, transSigma).finished());
310+
311+
const size_t numTrials = 100;
312+
const size_t numSteps = 30;
313+
const double lag = 3.0; // short lag forces more marginalization
314+
const size_t stateDim = 3; // Pose2: (theta, x, y)
315+
316+
// Ground truth: a curved trajectory with significant turns
317+
vector<Pose2> groundTruth(numSteps + 1);
318+
groundTruth[0] = Pose2(0, 0, 0);
319+
const Pose2 odomGT(1.0, 0.0, 0.4); // 1m forward, 0.4 rad turn (~23 deg)
320+
for (size_t i = 1; i <= numSteps; ++i) {
321+
groundTruth[i] = groundTruth[i-1] * odomGT;
322+
}
323+
324+
double neesSum_on = 0.0, neesSum_off = 0.0;
325+
size_t neesCount = 0;
326+
327+
mt19937 rng(42);
328+
normal_distribution<double> transDist(0.0, transSigma);
329+
normal_distribution<double> rotDist(0.0, rotSigma);
330+
331+
for (size_t trial = 0; trial < numTrials; ++trial) {
332+
typedef BatchFixedLagSmoother::KeyTimestampMap Timestamps;
333+
LevenbergMarquardtParams params;
334+
BatchFixedLagSmoother smootherOn(lag, params, true);
335+
BatchFixedLagSmoother smootherOff(lag, params, false);
336+
337+
for (size_t i = 0; i <= numSteps; ++i) {
338+
NonlinearFactorGraph newFactors;
339+
Values newValues;
340+
Timestamps newTimestamps;
341+
342+
Key key_i(i);
343+
344+
if (i == 0) {
345+
newFactors.addPrior(key_i, groundTruth[0], noise);
346+
Pose2 initEst(groundTruth[0].x() + transDist(rng),
347+
groundTruth[0].y() + transDist(rng),
348+
groundTruth[0].theta() + rotDist(rng));
349+
newValues.insert(key_i, initEst);
350+
} else {
351+
// Noisy odometry measurement
352+
Pose2 noisyOdom(odomGT.x() + transDist(rng),
353+
odomGT.y() + transDist(rng),
354+
odomGT.theta() + rotDist(rng));
355+
newFactors.push_back(BetweenFactor<Pose2>(Key(i-1), key_i, noisyOdom, noise));
356+
357+
// Initial estimate: perturbed ground truth
358+
Pose2 initEst(groundTruth[i].x() + transDist(rng) * 2,
359+
groundTruth[i].y() + transDist(rng) * 2,
360+
groundTruth[i].theta() + rotDist(rng) * 2);
361+
newValues.insert(key_i, initEst);
362+
}
363+
newTimestamps[key_i] = double(i);
364+
365+
smootherOn.update(newFactors, newValues, newTimestamps);
366+
smootherOff.update(newFactors, newValues, newTimestamps);
367+
}
368+
369+
// Compute NEES at the last key
370+
Key lastKey(numSteps);
371+
372+
try {
373+
// enforceConsistency = true
374+
Values estOn = smootherOn.calculateEstimate();
375+
Marginals marginalsOn(smootherOn.getFactors(), estOn, Marginals::QR);
376+
Matrix covOn = marginalsOn.marginalCovariance(lastKey);
377+
Vector errOn = groundTruth[numSteps].localCoordinates(estOn.at<Pose2>(lastKey));
378+
neesSum_on += errOn.transpose() * covOn.inverse() * errOn;
379+
380+
// enforceConsistency = false
381+
Values estOff = smootherOff.calculateEstimate();
382+
Marginals marginalsOff(smootherOff.getFactors(), estOff, Marginals::QR);
383+
Matrix covOff = marginalsOff.marginalCovariance(lastKey);
384+
Vector errOff = groundTruth[numSteps].localCoordinates(estOff.at<Pose2>(lastKey));
385+
neesSum_off += errOff.transpose() * covOff.inverse() * errOff;
386+
387+
neesCount++;
388+
} catch (...) {
389+
continue;
390+
}
391+
}
392+
393+
double avgNees_on = neesSum_on / neesCount;
394+
double avgNees_off = neesSum_off / neesCount;
395+
396+
cout << "NEES Evaluation (" << neesCount << "/" << numTrials << " trials, Pose2):" << endl;
397+
cout << " enforceConsistency=true (FEJ): avg NEES = " << avgNees_on
398+
<< " (expected: " << stateDim << ")" << endl;
399+
cout << " enforceConsistency=false : avg NEES = " << avgNees_off
400+
<< " (expected: " << stateDim << ")" << endl;
401+
402+
EXPECT(neesCount > 0);
403+
EXPECT(avgNees_on > 0.0);
404+
EXPECT(avgNees_off > 0.0);
405+
}
406+
239407
/* ************************************************************************* */
240408
int main() { TestResult tr; return TestRegistry::runAllTests(tr);}
241409
/* ************************************************************************* */

0 commit comments

Comments
 (0)