Fix DeMemWM memory dropout and inference stabilization
Browse files
algorithms/dememwm/df_video.py
CHANGED
|
@@ -645,11 +645,22 @@ def _select_online_event_dynamic_from_cache(
|
|
| 645 |
return _select_dynamic_from_stream(eligible_stream, reference_frames, count)
|
| 646 |
|
| 647 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
def _memory_noise_levels_for_streams(cfg, diffusion_model, query_noise_levels, stream_lengths, mode: str):
|
| 649 |
is_training = str(mode) == "training"
|
| 650 |
noise_cfg = _cfg_get(cfg, "memory_noise")
|
| 651 |
if noise_cfg is None:
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
return {
|
| 654 |
key: torch.full((int(stream_lengths[key]), query_noise_levels.shape[-1]), value, device=query_noise_levels.device, dtype=torch.long)
|
| 655 |
for key in _DEMEMWM_STREAM_KEYS
|
|
@@ -663,6 +674,11 @@ def _memory_noise_levels_for_streams(cfg, diffusion_model, query_noise_levels, s
|
|
| 663 |
)
|
| 664 |
query = query_noise_levels[:, None] if query_noise_levels.ndim == 1 else query_noise_levels
|
| 665 |
query_bounds = query.to(dtype=torch.long).clamp_min(0).min(dim=0).values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
if noise_mode == "independent":
|
| 667 |
if is_training:
|
| 668 |
max_level = int(getattr(diffusion_model, "timesteps", 0) or 0) - 1
|
|
@@ -2077,7 +2093,7 @@ class DeMemWMMinecraft(DiffusionForcingBase):
|
|
| 2077 |
for m in range(scheduling_matrix.shape[0] - 1):
|
| 2078 |
from_query = torch.as_tensor(scheduling_matrix[m], device=xs.device, dtype=torch.long)[:, None].repeat(1, batch_size)
|
| 2079 |
to_query = torch.as_tensor(scheduling_matrix[m + 1], device=xs.device, dtype=torch.long)[:, None].repeat(1, batch_size)
|
| 2080 |
-
context_levels =
|
| 2081 |
from_target = torch.cat([context_levels, from_query], dim=0)
|
| 2082 |
to_target = torch.cat([context_levels, to_query], dim=0)
|
| 2083 |
memory_noise_levels_by_key = _memory_noise_levels_for_streams(
|
|
|
|
| 645 |
return _select_dynamic_from_stream(eligible_stream, reference_frames, count)
|
| 646 |
|
| 647 |
|
| 648 |
+
def _stabilized_sampling_levels(reference_levels, count: int):
|
| 649 |
+
"""Schedule level 0 enters sample_step's fixed stabilization path."""
|
| 650 |
+
levels = reference_levels[:, None] if reference_levels.ndim == 1 else reference_levels
|
| 651 |
+
return levels.new_zeros((int(count), levels.shape[-1]), dtype=torch.long)
|
| 652 |
+
|
| 653 |
+
|
| 654 |
def _memory_noise_levels_for_streams(cfg, diffusion_model, query_noise_levels, stream_lengths, mode: str):
|
| 655 |
is_training = str(mode) == "training"
|
| 656 |
noise_cfg = _cfg_get(cfg, "memory_noise")
|
| 657 |
if noise_cfg is None:
|
| 658 |
+
if not is_training:
|
| 659 |
+
return {
|
| 660 |
+
key: _stabilized_sampling_levels(query_noise_levels, int(stream_lengths[key]))
|
| 661 |
+
for key in _DEMEMWM_STREAM_KEYS
|
| 662 |
+
}
|
| 663 |
+
value = int(getattr(diffusion_model, "stabilization_level", 0))
|
| 664 |
return {
|
| 665 |
key: torch.full((int(stream_lengths[key]), query_noise_levels.shape[-1]), value, device=query_noise_levels.device, dtype=torch.long)
|
| 666 |
for key in _DEMEMWM_STREAM_KEYS
|
|
|
|
| 674 |
)
|
| 675 |
query = query_noise_levels[:, None] if query_noise_levels.ndim == 1 else query_noise_levels
|
| 676 |
query_bounds = query.to(dtype=torch.long).clamp_min(0).min(dim=0).values
|
| 677 |
+
if not noisy_memory:
|
| 678 |
+
return {
|
| 679 |
+
key: _stabilized_sampling_levels(query, max(0, int(stream_lengths[key])))
|
| 680 |
+
for key in _DEMEMWM_STREAM_KEYS
|
| 681 |
+
}
|
| 682 |
if noise_mode == "independent":
|
| 683 |
if is_training:
|
| 684 |
max_level = int(getattr(diffusion_model, "timesteps", 0) or 0) - 1
|
|
|
|
| 2093 |
for m in range(scheduling_matrix.shape[0] - 1):
|
| 2094 |
from_query = torch.as_tensor(scheduling_matrix[m], device=xs.device, dtype=torch.long)[:, None].repeat(1, batch_size)
|
| 2095 |
to_query = torch.as_tensor(scheduling_matrix[m + 1], device=xs.device, dtype=torch.long)[:, None].repeat(1, batch_size)
|
| 2096 |
+
context_levels = _stabilized_sampling_levels(from_query, query_offset)
|
| 2097 |
from_target = torch.cat([context_levels, from_query], dim=0)
|
| 2098 |
to_target = torch.cat([context_levels, to_query], dim=0)
|
| 2099 |
memory_noise_levels_by_key = _memory_noise_levels_for_streams(
|
configurations/algorithm/dememwm_base.yaml
CHANGED
|
@@ -13,6 +13,8 @@ memory_noise:
|
|
| 13 |
anchor_max_fraction: 0.25
|
| 14 |
dynamic_max_fraction: 0.50
|
| 15 |
revisit_max_fraction: 0.25
|
|
|
|
|
|
|
| 16 |
# Per stream: all = always active, high = active for high query noise,
|
| 17 |
# low = active for low query noise.
|
| 18 |
noise_route: {anchor: all, dynamic: all, revisit: all}
|
|
|
|
| 13 |
anchor_max_fraction: 0.25
|
| 14 |
dynamic_max_fraction: 0.50
|
| 15 |
revisit_max_fraction: 0.25
|
| 16 |
+
# Inference memory follows the WorldMem stabilization path by default.
|
| 17 |
+
validation_noisy_memory: false
|
| 18 |
# Per stream: all = always active, high = active for high query noise,
|
| 19 |
# low = active for low query noise.
|
| 20 |
noise_route: {anchor: all, dynamic: all, revisit: all}
|
configurations/dataset/video_minecraft_dememwm_latent.yaml
CHANGED
|
@@ -26,6 +26,7 @@ memory_selection:
|
|
| 26 |
pose_similarity_threshold: 0.6
|
| 27 |
training_use_plucker: true
|
| 28 |
training_plucker_weight: 1.0
|
|
|
|
| 29 |
fov_overlap_threshold: 0.6
|
| 30 |
min_total_selected_coverage: 0.1
|
| 31 |
local_context_exclusion_frames: 8
|
|
|
|
| 26 |
pose_similarity_threshold: 0.6
|
| 27 |
training_use_plucker: true
|
| 28 |
training_plucker_weight: 1.0
|
| 29 |
+
training_dropout: 0.1
|
| 30 |
fov_overlap_threshold: 0.6
|
| 31 |
min_total_selected_coverage: 0.1
|
| 32 |
local_context_exclusion_frames: 8
|
datasets/video/memory_selection.py
CHANGED
|
@@ -421,6 +421,27 @@ def _write_segment(indices: MutableMapping[str, np.ndarray], masks: MutableMappi
|
|
| 421 |
masks[key][: len(selected)] = True
|
| 422 |
|
| 423 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
def _best_row(rows: torch.Tensor, gains: torch.Tensor, fov_values: torch.Tensor, plucker: torch.Tensor, gaps: torch.Tensor, candidates_t: torch.Tensor) -> int:
|
| 425 |
active = rows
|
| 426 |
for values in (gains, fov_values, plucker, -gaps.to(dtype=torch.float32), -candidates_t.to(dtype=torch.float32)):
|
|
@@ -1319,6 +1340,37 @@ def _select_dynamic_by_policy(
|
|
| 1319 |
raise AssertionError(f"unhandled dynamic selection policy: {policy!r}")
|
| 1320 |
|
| 1321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1322 |
def _select_revisit(
|
| 1323 |
poses: np.ndarray,
|
| 1324 |
target_positions: np.ndarray,
|
|
@@ -1433,7 +1485,6 @@ def select_memory_indices(
|
|
| 1433 |
min_candidate_frame=min_candidate_frame,
|
| 1434 |
fov_pool=fov_pool,
|
| 1435 |
)
|
| 1436 |
-
|
| 1437 |
if policy == "recent":
|
| 1438 |
dynamic = _select_dynamic_by_policy(
|
| 1439 |
target_start,
|
|
@@ -1486,6 +1537,30 @@ def select_memory_indices(
|
|
| 1486 |
else:
|
| 1487 |
raise AssertionError(f"unhandled dynamic selection policy: {policy!r}")
|
| 1488 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1489 |
_write_segment(indices, masks, "anchor", anchor)
|
| 1490 |
_write_segment(indices, masks, "dynamic", dynamic)
|
| 1491 |
_write_segment(indices, masks, "revisit", revisit)
|
|
|
|
| 421 |
masks[key][: len(selected)] = True
|
| 422 |
|
| 423 |
|
| 424 |
+
def _rng_choice(rng, values: np.ndarray, size: int, *, replace: bool) -> np.ndarray:
|
| 425 |
+
chooser = np.random if rng is None else rng
|
| 426 |
+
return np.asarray(chooser.choice(values, size=int(size), replace=replace), dtype=np.int64)
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _rng_random(rng) -> float:
|
| 430 |
+
return float(np.random.random() if rng is None else rng.random())
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _sample_random_selection(candidates: np.ndarray, count: int, rng=None) -> np.ndarray:
|
| 434 |
+
count = int(count)
|
| 435 |
+
if count <= 0:
|
| 436 |
+
return np.empty((0,), dtype=np.int64)
|
| 437 |
+
|
| 438 |
+
candidates = np.asarray(candidates, dtype=np.int64)
|
| 439 |
+
candidates = candidates[candidates >= 0]
|
| 440 |
+
if len(candidates) == 0:
|
| 441 |
+
return np.empty((0,), dtype=np.int64)
|
| 442 |
+
return _rng_choice(rng, candidates, count, replace=True)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
def _best_row(rows: torch.Tensor, gains: torch.Tensor, fov_values: torch.Tensor, plucker: torch.Tensor, gaps: torch.Tensor, candidates_t: torch.Tensor) -> int:
|
| 446 |
active = rows
|
| 447 |
for values in (gains, fov_values, plucker, -gaps.to(dtype=torch.float32), -candidates_t.to(dtype=torch.float32)):
|
|
|
|
| 1340 |
raise AssertionError(f"unhandled dynamic selection policy: {policy!r}")
|
| 1341 |
|
| 1342 |
|
| 1343 |
+
def _dynamic_random_candidates(
|
| 1344 |
+
num_frames: int,
|
| 1345 |
+
target_start: int,
|
| 1346 |
+
target_positions: np.ndarray,
|
| 1347 |
+
cfg,
|
| 1348 |
+
split: str,
|
| 1349 |
+
min_candidate_frame: int,
|
| 1350 |
+
policy: str,
|
| 1351 |
+
*,
|
| 1352 |
+
excluded: np.ndarray | None = None,
|
| 1353 |
+
) -> np.ndarray:
|
| 1354 |
+
if policy == "recent":
|
| 1355 |
+
stop = int(target_start)
|
| 1356 |
+
exclusion = max(0, int(cfg_get(cfg, "local_context_exclusion_frames", 8)))
|
| 1357 |
+
if exclusion > 0:
|
| 1358 |
+
stop = max(int(min_candidate_frame), stop - exclusion)
|
| 1359 |
+
return np.arange(int(min_candidate_frame), stop, dtype=np.int64)
|
| 1360 |
+
|
| 1361 |
+
candidates = _memory_candidate_frames(
|
| 1362 |
+
int(num_frames),
|
| 1363 |
+
target_positions,
|
| 1364 |
+
cfg,
|
| 1365 |
+
split,
|
| 1366 |
+
min_candidate_frame=int(min_candidate_frame),
|
| 1367 |
+
)
|
| 1368 |
+
candidates = _exclude_local_context(candidates, target_positions, cfg)
|
| 1369 |
+
if policy == "multiview" and excluded is not None and len(excluded) > 0 and len(candidates) > 0:
|
| 1370 |
+
candidates = candidates[~np.isin(candidates, np.asarray(excluded, dtype=np.int64))]
|
| 1371 |
+
return candidates.astype(np.int64, copy=False)
|
| 1372 |
+
|
| 1373 |
+
|
| 1374 |
def _select_revisit(
|
| 1375 |
poses: np.ndarray,
|
| 1376 |
target_positions: np.ndarray,
|
|
|
|
| 1485 |
min_candidate_frame=min_candidate_frame,
|
| 1486 |
fov_pool=fov_pool,
|
| 1487 |
)
|
|
|
|
| 1488 |
if policy == "recent":
|
| 1489 |
dynamic = _select_dynamic_by_policy(
|
| 1490 |
target_start,
|
|
|
|
| 1537 |
else:
|
| 1538 |
raise AssertionError(f"unhandled dynamic selection policy: {policy!r}")
|
| 1539 |
|
| 1540 |
+
training_dropout = float(cfg_get(cfg, "training_dropout", 0.0)) if split == "training" else 0.0
|
| 1541 |
+
if training_dropout > 0.0 and _rng_random(rng) < training_dropout:
|
| 1542 |
+
revisit_candidates = _memory_candidate_frames(
|
| 1543 |
+
len(poses),
|
| 1544 |
+
target_positions,
|
| 1545 |
+
cfg,
|
| 1546 |
+
split,
|
| 1547 |
+
min_candidate_frame=min_candidate_frame,
|
| 1548 |
+
)
|
| 1549 |
+
revisit_candidates = _exclude_revisit_local_context(revisit_candidates, target_positions, cfg)
|
| 1550 |
+
dynamic_candidates = _dynamic_random_candidates(
|
| 1551 |
+
len(poses),
|
| 1552 |
+
target_start,
|
| 1553 |
+
target_positions,
|
| 1554 |
+
cfg,
|
| 1555 |
+
split,
|
| 1556 |
+
min_candidate_frame,
|
| 1557 |
+
policy,
|
| 1558 |
+
excluded=revisit if policy == "multiview" else None,
|
| 1559 |
+
)
|
| 1560 |
+
anchor = _sample_random_selection(anchor_candidates, counts["anchor"], rng=rng)
|
| 1561 |
+
dynamic = _sample_random_selection(dynamic_candidates, counts["dynamic"], rng=rng)
|
| 1562 |
+
revisit = _sample_random_selection(revisit_candidates, counts["revisit"], rng=rng)
|
| 1563 |
+
|
| 1564 |
_write_segment(indices, masks, "anchor", anchor)
|
| 1565 |
_write_segment(indices, masks, "dynamic", dynamic)
|
| 1566 |
_write_segment(indices, masks, "revisit", revisit)
|
tests/test_dememwm_latent_dataset.py
CHANGED
|
@@ -48,6 +48,7 @@ def _selection_cfg(**overrides):
|
|
| 48 |
"pose_similarity_threshold": 0.6,
|
| 49 |
"training_use_plucker": False,
|
| 50 |
"training_plucker_weight": 0.1,
|
|
|
|
| 51 |
"fov_overlap_threshold": 0.0,
|
| 52 |
"min_total_selected_coverage": 0.0,
|
| 53 |
"local_context_exclusion_frames": 2,
|
|
@@ -219,6 +220,7 @@ class MemorySelectionTests(unittest.TestCase):
|
|
| 219 |
self.assertEqual(cfg.memory_selection.dynamic.multiview_selector, "fov_greedy")
|
| 220 |
self.assertEqual(cfg.memory_selection.pose_preselect_topk, 32)
|
| 221 |
self.assertEqual(cfg.memory_selection.candidate_chunk_size, 0)
|
|
|
|
| 222 |
|
| 223 |
def test_revisit_uses_sampled_fov_selection(self):
|
| 224 |
poses = np.array(
|
|
@@ -833,6 +835,32 @@ class MemorySelectionTests(unittest.TestCase):
|
|
| 833 |
self.assertEqual(indices["anchor"].tolist(), [2, 5])
|
| 834 |
self.assertEqual(masks["anchor"].tolist(), [True, True])
|
| 835 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
def test_event_triggered_dynamic_causal_stream_stays_before_target(self):
|
| 837 |
poses = _poses(12)
|
| 838 |
cfg = _selection_cfg(
|
|
@@ -1771,7 +1799,10 @@ class DeMemWMLatentDatasetTests(unittest.TestCase):
|
|
| 1771 |
self.memory_condition_length, self.clip_noise = 3, 0.0
|
| 1772 |
self.context_frames, self.frame_stack, self.chunk_size, self.n_tokens = 2, 1, 2, 2
|
| 1773 |
self.cfg = OmegaConf.create(
|
| 1774 |
-
{
|
|
|
|
|
|
|
|
|
|
| 1775 |
)
|
| 1776 |
self.diffusion_model = FakeDiffusion()
|
| 1777 |
self.logged = []
|
|
@@ -1858,6 +1889,8 @@ class DeMemWMLatentDatasetTests(unittest.TestCase):
|
|
| 1858 |
self.assertEqual(calls[0]["action_cond"][:, 0, 0].tolist(), [102.0, 103.0, 0.0, 0.0])
|
| 1859 |
self.assertEqual(calls[0]["kwargs"]["frame_idx"][:, 0].tolist(), [12, 13, 10, 11])
|
| 1860 |
self.assertEqual(calls[2]["kwargs"]["frame_idx"][:, 0].tolist(), [13, 14, 10, 12, 12])
|
|
|
|
|
|
|
| 1861 |
self.assertEqual(calls[0]["kwargs"]["frame_memory_masks"]["revisit"].tolist(), [[]])
|
| 1862 |
self.assertEqual(calls[2]["kwargs"]["frame_memory_masks"]["revisit"].tolist(), [[True]])
|
| 1863 |
self.assertAlmostEqual(float(loss), 1.0 / 3.0)
|
|
|
|
| 48 |
"pose_similarity_threshold": 0.6,
|
| 49 |
"training_use_plucker": False,
|
| 50 |
"training_plucker_weight": 0.1,
|
| 51 |
+
"training_dropout": 0.0,
|
| 52 |
"fov_overlap_threshold": 0.0,
|
| 53 |
"min_total_selected_coverage": 0.0,
|
| 54 |
"local_context_exclusion_frames": 2,
|
|
|
|
| 220 |
self.assertEqual(cfg.memory_selection.dynamic.multiview_selector, "fov_greedy")
|
| 221 |
self.assertEqual(cfg.memory_selection.pose_preselect_topk, 32)
|
| 222 |
self.assertEqual(cfg.memory_selection.candidate_chunk_size, 0)
|
| 223 |
+
self.assertEqual(cfg.memory_selection.training_dropout, 0.1)
|
| 224 |
|
| 225 |
def test_revisit_uses_sampled_fov_selection(self):
|
| 226 |
poses = np.array(
|
|
|
|
| 835 |
self.assertEqual(indices["anchor"].tolist(), [2, 5])
|
| 836 |
self.assertEqual(masks["anchor"].tolist(), [True, True])
|
| 837 |
|
| 838 |
+
def test_training_dropout_randomizes_memory_streams_with_replacement(self):
|
| 839 |
+
poses = _poses(12)
|
| 840 |
+
cfg = _selection_cfg(
|
| 841 |
+
max_anchor_frames=2,
|
| 842 |
+
max_dynamic_frames=3,
|
| 843 |
+
max_revisit_frames=2,
|
| 844 |
+
training_dropout=1.0,
|
| 845 |
+
dynamic=_dynamic_cfg("recent"),
|
| 846 |
+
)
|
| 847 |
+
|
| 848 |
+
indices, masks = select_memory_indices(
|
| 849 |
+
poses,
|
| 850 |
+
np.array([8]),
|
| 851 |
+
cfg,
|
| 852 |
+
split="training",
|
| 853 |
+
rng=np.random.default_rng(0),
|
| 854 |
+
anchor_candidate_start=2,
|
| 855 |
+
anchor_candidate_stop=6,
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
self.assertEqual(indices["anchor"].tolist(), [4, 3])
|
| 859 |
+
self.assertEqual(indices["dynamic"].tolist(), [1, 0, 0])
|
| 860 |
+
self.assertEqual(indices["revisit"].tolist(), [0, 1])
|
| 861 |
+
for key in ("anchor", "dynamic", "revisit"):
|
| 862 |
+
self.assertTrue(masks[key].all())
|
| 863 |
+
|
| 864 |
def test_event_triggered_dynamic_causal_stream_stays_before_target(self):
|
| 865 |
poses = _poses(12)
|
| 866 |
cfg = _selection_cfg(
|
|
|
|
| 1799 |
self.memory_condition_length, self.clip_noise = 3, 0.0
|
| 1800 |
self.context_frames, self.frame_stack, self.chunk_size, self.n_tokens = 2, 1, 2, 2
|
| 1801 |
self.cfg = OmegaConf.create(
|
| 1802 |
+
{
|
| 1803 |
+
"memory_selection": {"enabled": True, "max_anchor_frames": 1, "max_dynamic_frames": 1, "max_revisit_frames": 1, "fov_overlap_threshold": 0.75},
|
| 1804 |
+
"memory_noise": {"enabled": True, "validation_noisy_memory": False},
|
| 1805 |
+
}
|
| 1806 |
)
|
| 1807 |
self.diffusion_model = FakeDiffusion()
|
| 1808 |
self.logged = []
|
|
|
|
| 1889 |
self.assertEqual(calls[0]["action_cond"][:, 0, 0].tolist(), [102.0, 103.0, 0.0, 0.0])
|
| 1890 |
self.assertEqual(calls[0]["kwargs"]["frame_idx"][:, 0].tolist(), [12, 13, 10, 11])
|
| 1891 |
self.assertEqual(calls[2]["kwargs"]["frame_idx"][:, 0].tolist(), [13, 14, 10, 12, 12])
|
| 1892 |
+
self.assertEqual(calls[0]["curr_noise_level"][:, 0].tolist(), [2, 2, 0, 0])
|
| 1893 |
+
self.assertEqual(calls[2]["curr_noise_level"][:, 0].tolist(), [0, 2, 0, 0, 0])
|
| 1894 |
self.assertEqual(calls[0]["kwargs"]["frame_memory_masks"]["revisit"].tolist(), [[]])
|
| 1895 |
self.assertEqual(calls[2]["kwargs"]["frame_memory_masks"]["revisit"].tolist(), [[True]])
|
| 1896 |
self.assertAlmostEqual(float(loss), 1.0 / 3.0)
|