File size: 36,033 Bytes
ba7b9c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
import numpy as np

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import random
import math

from datetime import datetime
import os
from collections import namedtuple, deque
import csv

from utils import save_distance_data
from Const import *

# Use GPU if available, otherwise CPU
device = DEVICE

# Define the structure for experiences stored in replay memory
Transition = namedtuple('Transition',
                        ('state', 'action', 'next_state', 'reward', 'done'))

Att_Transition = namedtuple('Att_Transition',
                            ('state', 'state_mask', 'action', 'next_state', 'next_state_mask', 'reward', 'done'))
class ReplayMemory(object):
    """Stores transitions for experience replay."""

    def __init__(self, capacity):
        """Initialize a ReplayMemory instance.

        Args:
            capacity (int): Maximum size of the replay buffer.
        """
        # Use deque for efficient adding/popping from both ends
        self.memory = deque([], maxlen=capacity)

    def push(self, *args):
        """Save a transition tuple."""
        self.memory.append(Transition(*args))

    def sample(self, batch_size):
        """Randomly sample a batch of transitions from memory.

        Args:
            batch_size (int): The number of transitions to sample.

        Returns:
            list: A list of Transition tuples.
        """
        return random.sample(self.memory, batch_size)

    def __len__(self):
        """Return the current size of the memory."""
        return len(self.memory)


class DQNNetwork(nn.Module):
    """Neural Network for approximating Q-values."""

    def __init__(self, n_observations, n_actions):
        """Initialize the DQN Network.

        Args:
            n_observations (int): The size of the state space (input dimensions).
            n_actions (int): The size of the action space (output dimensions).
        """
        super(DQNNetwork, self).__init__()
        # Simple feedforward network
        self.layer1 = nn.Linear(n_observations, 128) # Input layer -> Hidden layer 1
        self.layer2 = nn.Linear(128, 128)             # Hidden layer 1 -> Hidden layer 2
        self.layer3 = nn.Linear(128, n_actions)       # Hidden layer 2 -> Output layer

    def forward(self, x):
        """Defines the forward pass of the network.

        Args:
            x (torch.Tensor): The input state tensor.

        Returns:
            torch.Tensor: The output Q-values for each action.
        """
        x = F.relu(self.layer1(x)) # Apply ReLU activation function
        x = F.relu(self.layer2(x))
        return self.layer3(x)      # Output raw Q-values



class DuelingDQNNetwork(nn.Module):
    """Dueling Network Architecture for Q-values.
       Q(s,a) = V(s) + (A(s,a) - mean(A(s,a')))
    """
    def __init__(self, n_observations, n_actions):
        """Initialize the Dueling DQN Network.

        Args:
            n_observations (int): The size of the state space.
            n_actions (int): The size of the action space.
        """
        super(DuelingDQNNetwork, self).__init__()
        self.n_actions = n_actions

        # Shared feature learning layers (example: similar size to your original)
        self.feature_layer1 = nn.Linear(n_observations, 128)
        self.feature_layer2 = nn.Linear(128, 128)

        # State Value stream
        self.value_stream_layer1 = nn.Linear(128, 128)
        self.value_stream_output = nn.Linear(128, 1) # Outputs single value V(s)

        # Action Advantage stream
        self.advantage_stream_layer1 = nn.Linear(128, 128)
        self.advantage_stream_output = nn.Linear(128, n_actions) # Outputs advantage A(s,a) for each action

    def forward(self, x):
        """Defines the forward pass."""
        # Pass through shared feature layers
        features = F.relu(self.feature_layer1(x))
        features = F.relu(self.feature_layer2(features))

        # Calculate state value
        value_hidden = F.relu(self.value_stream_layer1(features))
        value = self.value_stream_output(value_hidden) # V(s)

        # Calculate action advantages
        advantage_hidden = F.relu(self.advantage_stream_layer1(features))
        advantages = self.advantage_stream_output(advantage_hidden) # A(s,a)

        # Combine value and advantage streams to get Q-values
        # Q(s,a) = V(s) + (A(s,a) - mean(A(s,a')))
        # The subtraction of the mean advantage ensures identifiability and improves stability
        q_values = value + (advantages - advantages.mean(dim=1, keepdim=True))

        return q_values

class Agent:
    """Interacts with and learns from the environment using DQN."""

    def __init__(self, state_size, action_size, replay_memory_capacity, batch_size,
                 gamma, eps_start, eps_end, eps_decay, tau, lr, target_update_freq):
        """Initialize the DQN Agent.

        Args:
            state_size (int): Dimension of each state.
            action_size (int): Dimension of each action.
            replay_memory_capacity (int): Size of the replay buffer.
            batch_size (int): Minibatch size for training.
            gamma (float): Discount factor.
            eps_start (float): Starting value of epsilon for epsilon-greedy.
            eps_end (float): Minimum value of epsilon.
            eps_decay (float): Decay rate for epsilon.
            tau (float): Soft update parameter for target network.
            lr (float): Learning rate for the optimizer.
            target_update_freq (int): Frequency (in steps or episodes) for hard target updates (if tau=0).
        """
        self.state_size = state_size
        self.action_size = action_size
        self.memory = ReplayMemory(replay_memory_capacity)
        self.batch_size = batch_size
        self.gamma = gamma
        self.eps_start = eps_start
        self.eps_end = eps_end
        self.eps_decay = eps_decay
        self.tau = tau
        self.lr = lr
        self.target_update_freq = target_update_freq # Note: Used only if tau is 0 (hard updates)

        # --- Networks ---
        # Policy Network: Learns and selects actions
        self.policy_net = DuelingDQNNetwork(state_size, action_size).to(device)
        # Target Network: Provides stable targets for learning
        self.target_net = DuelingDQNNetwork(state_size, action_size).to(device)
        self.target_net.load_state_dict(self.policy_net.state_dict()) # Initialize target with policy weights
        self.target_net.eval()  # Set target network to evaluation mode (no dropout, batchnorm updates)

        # --- Optimizer ---
        self.optimizer = optim.AdamW(self.policy_net.parameters(), lr=self.lr, amsgrad=True)

        self.steps_done = 0  # Counter for epsilon decay

    def select_action(self, state):
        """Selects an action using an epsilon-greedy policy.

        Args:
            state (torch.Tensor): The current state.

        Returns:
            torch.Tensor: The selected action tensor.
        """
        sample = random.random()
        # Calculate current epsilon value based on exponential decay
        eps_threshold = self.eps_end + (self.eps_start - self.eps_end) * \
            math.exp(-1. * self.steps_done / self.eps_decay)
        self.steps_done += 1

        if sample > eps_threshold:
            # Exploitation: Choose the best action from the policy network
            with torch.no_grad():  # Disable gradient calculation during inference
                # policy_net(state) -> Q-values for all actions
                # .max(1)[1] -> index of the max Q-value (the best action)
                # .view(1, 1) -> reshape for compatibility
                action = self.policy_net(state).max(1)[1].view(1, 1)
        else:
            # Exploration: Choose a random action
            action = torch.tensor([[random.randrange(self.action_size)]], device=device, dtype=torch.long)

        return action

    def store_experience(self, state, action, next_state, reward, done):
        """Stores a single experience tuple in the replay memory.

        Args:
            state (torch.Tensor): The starting state.
            action (torch.Tensor): The action taken.
            next_state (torch.Tensor or None): The resulting state (None if terminal).
            reward (torch.Tensor): The reward received.
            done (bool): True if the episode terminated, False otherwise.
        """
        # Convert done flag to tensor before storing
        done_tensor = torch.tensor([done], device=device, dtype=torch.bool)
        self.memory.push(state, action, next_state, reward, done_tensor)

    def optimize_model(self):
        """Performs one step of optimization on the policy network."""
        if len(self.memory) < self.batch_size:
            return  # Not enough samples in memory to form a batch

        # Sample a batch of transitions
        transitions = self.memory.sample(self.batch_size)
        # Transpose the batch: Converts batch-array of Transitions
        # to Transition of batch-arrays.
        batch = Transition(*zip(*transitions))

        # --- Prepare Batch Tensors ---
        # Create a mask for non-final next states (handles terminal states where next_state is None)
        non_final_mask = torch.tensor(tuple(map(lambda s: s is not None, batch.next_state)),
                                      device=device, dtype=torch.bool)
        # Concatenate non-final next states into a tensor
        non_final_next_states = torch.cat([s for s in batch.next_state if s is not None])

        # Concatenate batch elements into tensors
        state_batch = torch.cat(batch.state)
        action_batch = torch.cat(batch.action)
        reward_batch = torch.cat(batch.reward)
        # done_batch = torch.cat(batch.done) # Not explicitly used in standard DQN loss below

        # --- Calculate Q(s_t, a) ---
        # Get Q-values from the policy network for the actions that were actually taken
        state_action_values = self.policy_net(state_batch).gather(1, action_batch)

        # --- Calculate V(s_{t+1}) = max_a Q_target(s_{t+1}, a) ---
        # Initialize next state values to zero (for terminal states)
        next_state_values = torch.zeros(self.batch_size, device=device)
        # Compute max Q-value for non-final next states using the target network
        with torch.no_grad(): # No gradient needed for target calculations
            next_state_values[non_final_mask] = self.target_net(non_final_next_states).max(1)[0]

        # --- Compute Expected Q Values (Bellman Target) ---
        # Target = reward + gamma * V(s_{t+1})
        # For terminal states (where next_state_values is 0), target is just the reward
        expected_state_action_values = (next_state_values * self.gamma) + reward_batch

        # --- Compute Loss ---
        # Huber loss (Smooth L1 loss) is often more stable than MSELoss
        criterion = nn.SmoothL1Loss()
        loss = criterion(state_action_values, expected_state_action_values.unsqueeze(1)) # Target needs same shape

        # --- Optimize the Policy Network ---
        self.optimizer.zero_grad()  # Clear previous gradients
        loss.backward()             # Calculate gradients
        # In-place gradient clipping (helps prevent exploding gradients)
        torch.nn.utils.clip_grad_value_(self.policy_net.parameters(), 100)
        self.optimizer.step()       # Update network weights

    def update_target_net(self, soft_update=True):
        """Updates the target network weights.

        Args:
            soft_update (bool): If True, uses soft updates (Polyak averaging).
                                If False, performs a hard copy (less common with tau > 0).
        """
        if soft_update and self.tau > 0:
            # Soft update: target_weights = tau * policy_weights + (1 - tau) * target_weights
            target_net_state_dict = self.target_net.state_dict()
            policy_net_state_dict = self.policy_net.state_dict()
            for key in policy_net_state_dict:
                target_net_state_dict[key] = policy_net_state_dict[key] * self.tau + \
                                             target_net_state_dict[key] * (1 - self.tau)
            self.target_net.load_state_dict(target_net_state_dict)
        elif not soft_update:
            # Hard update: Copy weights directly
            # Typically done less frequently (e.g., every target_update_freq steps/episodes)
            self.target_net.load_state_dict(self.policy_net.state_dict())

    def save_weights_and_distances(self, all_distances_acquisitions,model_name = 'DQN', filename="agent_policy_weights.pth"):
        """Saves the weights of the policy network."""
        
        # You can specify a different weights file path if needed
        datetime_str = datetime.now().strftime("%Y%m%d-%H%M%S")
        models_dir = f"Metrics/{model_name}/{datetime_str}/"
        #Create models directory if it doesn't exist

        if not os.path.exists(models_dir):
            os.makedirs(models_dir)

        # Save first the distance data to a CSV file
        save_distance_data(all_distances_acquisitions, save_folder=models_dir)

        filename = os.path.join(models_dir, "agent_policy_weights.pth")
        try:
            # Ensure directory exists if filename includes path
            save_dir = os.path.dirname(filename)
            if save_dir and not os.path.exists(save_dir):
                 os.makedirs(save_dir, exist_ok=True)
                 
            torch.save(self.policy_net.state_dict(), filename)
            print(f"Agent policy weights saved to {filename}")
        except Exception as e:
            print(f"Error saving agent weights: {e}")

    def load_weights(self, model_name = 'DQN/'):
        """Loads weights into the policy and target networks."""
        all_subdirs = [d for d in os.listdir(f'Metrics/{model_name}/')]
        latest_subdir = max(all_subdirs)
        path = os.path.join('Metrics/', model_name)
        path = os.path.join(path, latest_subdir)
        filename = os.path.join(path, "agent_policy_weights.pth")

        # Check if the file exists before attempting to load
        if not os.path.exists(filename):
            print(f"Warning: Weight file not found at {filename}. Starting with initial weights.")
            return False
        try:
            # Load state dict, mapping to the correct device (CPU or GPU)
            state_dict = torch.load(filename, map_location=device) # Use map_location!
            self.policy_net.load_state_dict(state_dict)
            # It's crucial to also update the target network after loading weights
            self.target_net.load_state_dict(self.policy_net.state_dict())
            self.policy_net.eval() # Set to eval mode if needed after loading
            self.target_net.eval()
            print(f"Agent policy weights loaded from {filename}")
            return True # Indicate success
        except Exception as e:
            print(f"Error loading agent weights from {filename}: {e}")
            return False # Indicate failure
        


class Att_ReplayMemory(object):
    """Stores transitions (including masks) for experience replay."""

    def __init__(self, capacity):
        """Initialize a ReplayMemory instance."""
        self.memory = deque([], maxlen=capacity)

    def push(self, *args):
        """Save a transition tuple (state, state_mask, action, next_state, next_state_mask, reward, done)."""
        # Ensure the correct number of arguments are passed
        assert len(args) == 7, "Incorrect number of arguments for Transition tuple"
        self.memory.append(Att_Transition(*args))

    def sample(self, batch_size):
        """Randomly sample a batch of transitions from memory."""
        return random.sample(self.memory, batch_size)

    def __len__(self):
        """Return the current size of the memory."""
        return len(self.memory)


# --- Attention-Based Network ---
class AttentionDQNNetwork(nn.Module):
    """
    DQN Network using Attention to process sequence states.
    Uses learned positional embeddings instead of initial Conv1D layers.
    Input shape: (batch, num_features, seq_length)
    Mask shape: (batch, seq_length)
    Output shape: (batch, n_actions)
    """
    def __init__(self, n_features, seq_len, n_actions, embed_dim=64, num_heads=4):
        """
        Args:
            n_features (int): Number of features in the input sequence (e.g., 5).
            seq_len (int): Length of the input sequence.
            n_actions (int): Number of possible actions.
            embed_dim (int): Dimension for embedding and attention mechanism.
            num_heads (int): Number of attention heads.
        """
        super(AttentionDQNNetwork, self).__init__()
        self.n_features = n_features
        self.seq_len = seq_len
        self.n_actions = n_actions
        self.embed_dim = embed_dim

        # --- Replace Conv1D layers with Input Projection ---
        # Use a Conv1D with kernel_size=1 to project features to embed_dim at each time step
        # Equivalent to applying a Linear layer independently at each time step
        self.input_proj = nn.Conv1d(n_features, embed_dim, kernel_size=1)

        # --- Learned Positional Embedding ---
        # Creates learnable embedding vectors for each position (0 to seq_len-1)
        self.positional_embedding = nn.Embedding(seq_len, embed_dim)

        # --- Attention Layer ---
        # MultiheadAttention expects (seq_len, batch, embed_dim) or (batch, seq_len, embed_dim) if batch_first=True
        self.attention = nn.MultiheadAttention(embed_dim, num_heads, batch_first=True) # Use batch_first=True

        # --- Layer Normalization ---
        self.norm1 = nn.LayerNorm(embed_dim)
        self.norm2 = nn.LayerNorm(embed_dim)

        # --- Feed-forward layers after attention ---
        self.linear1 = nn.Linear(embed_dim, embed_dim * 2)
        self.linear2 = nn.Linear(embed_dim * 2, n_actions)

    def forward(self, x, attention_mask):
        """
        Forward pass through the network with positional embeddings.

        Args:
            x (torch.Tensor): Input state sequence tensor (batch, n_features, seq_len).
            attention_mask (torch.Tensor): Boolean mask (batch, seq_len). True indicates valid data.

        Returns:
            torch.Tensor: Q-values for each action (batch, n_actions).
        """
        # 1. Project Input Features to Embedding Dimension
        # Input x: (batch, n_features, seq_len)
        x = self.input_proj(x) # Output: (batch, embed_dim, seq_len)
        # Apply activation after projection
        x = F.relu(x)

        # 2. Generate Positional Embeddings
        # Create position indices (0, 1, ..., seq_len-1)
        positions = torch.arange(0, self.seq_len, device=x.device).unsqueeze(0) # Shape: (1, seq_len)
        # Get embeddings for these positions
        pos_emb = self.positional_embedding(positions) # Shape: (1, seq_len, embed_dim)

        # 3. Add Positional Embeddings
        # Permute x to match positional embedding shape for addition
        # x shape becomes (batch, seq_len, embed_dim)
        x = x.permute(0, 2, 1)
        # Add positional embeddings (broadcasts along batch dimension)
        x = x + pos_emb

        # --- The rest of the network remains the same ---

        # 4. Apply Attention
        key_padding_mask = ~attention_mask # Invert mask: True means ignore

        # Apply LayerNorm before attention
        x_norm = self.norm1(x)

        # Self-attention
        attn_output, _ = self.attention(x_norm, x_norm, x_norm, key_padding_mask=key_padding_mask)

        # Add & Norm (Residual connection)
        x = x + attn_output
        x = self.norm2(x)

        # 5. Aggregate Sequence Information (e.g., Masked Average Pooling)
        mask_expanded = attention_mask.unsqueeze(-1).float()
        masked_sum = (x * mask_expanded).sum(dim=1)
        valid_counts = mask_expanded.sum(dim=1)
        valid_counts = torch.clamp(valid_counts, min=1.0)
        aggregated_output = masked_sum / valid_counts # Shape: (batch, embed_dim)

        # 6. Final Feed-Forward Layers for Q-Values
        q_values = F.relu(self.linear1(aggregated_output))
        q_values = self.linear2(q_values) # Shape: (batch, n_actions)

        return q_values


class Att_Agent:
    """Interacts with and learns from the environment using Attention DQN."""

    def __init__(self, n_features, seq_len, action_size, replay_memory_capacity, batch_size,
                 gamma, eps_start, eps_end, eps_decay, tau, lr, target_update_freq, embed_dim=64, num_heads=4):
        """Initialize the Attention DQN Agent.

        Args:
            n_features (int): Number of features per time step in the state sequence.
            seq_len (int): The length of the state sequence.
            action_size (int): Dimension of each action.
            replay_memory_capacity (int): Size of the replay buffer.
            batch_size (int): Minibatch size for training.
            gamma (float): Discount factor.
            eps_start (float): Starting value of epsilon for epsilon-greedy.
            eps_end (float): Minimum value of epsilon.
            eps_decay (float): Decay rate for epsilon.
            tau (float): Soft update parameter for target network.
            lr (float): Learning rate for the optimizer.
            target_update_freq (int): Frequency for hard target updates (if tau=0).
            embed_dim (int): Embedding dimension for the Attention network.
            num_heads (int): Number of attention heads for the Attention network.
        """
        self.n_features = n_features
        self.seq_len = seq_len
        self.action_size = action_size
        self.memory = Att_ReplayMemory(replay_memory_capacity)
        self.batch_size = batch_size
        self.gamma = gamma
        self.eps_start = eps_start
        self.eps_end = eps_end
        self.eps_decay = eps_decay
        self.tau = tau
        self.lr = lr
        self.target_update_freq = target_update_freq # Used only if tau is 0 (hard updates)
        

        # --- Networks ---
        self.policy_net = AttentionDQNNetwork(n_features, seq_len, action_size, embed_dim, num_heads).to(device)
        self.target_net = AttentionDQNNetwork(n_features, seq_len, action_size, embed_dim, num_heads).to(device)
        self.target_net.load_state_dict(self.policy_net.state_dict())
        self.target_net.eval()

        # --- Optimizer ---
        self.optimizer = optim.AdamW(self.policy_net.parameters(), lr=self.lr, amsgrad=True)

        self.steps_done = 0  # Counter for epsilon decay

        # --- Logging Attributes ---
        self.step_losses = []       # To store loss values between episode logs
        self.log_writer = None      # CSV writer object
        self.log_file = None        # File handle for the log
        self.log_file_path = None   # Full path to the log file
        self.log_header_written = False

    def select_action(self, state_tuple):
        """Selects an action using an epsilon-greedy policy based on state sequence and mask.

        Args:
            state_tuple (tuple): A tuple containing (state_sequence, state_mask).
                                 state_sequence shape: (1, n_features, seq_len)
                                 state_mask shape: (1, seq_len)

        Returns:
            torch.Tensor: The selected action tensor (shape [1, 1]).
        """
        state_sequence, state_mask = state_tuple # Unpack the tuple
        sample = random.random()
        eps_threshold = self.eps_end + (self.eps_start - self.eps_end) * \
            math.exp(-1. * self.steps_done / self.eps_decay)
        self.steps_done += 1

        if sample > eps_threshold:
            # Exploitation: Choose the best action from the policy network
            with torch.no_grad():
                # Pass both sequence and mask to the network
                q_values = self.policy_net(state_sequence.to(device), state_mask.to(device))
                # .max(1)[1] -> index of the max Q-value (the best action)
                # .view(1, 1) -> reshape for compatibility
                action = q_values.max(1)[1].view(1, 1)
            # print(f"Action selected: {action.item()} with epsilon: {eps_threshold:.4f}, and sample: {sample:.4f}")
        else:
            # Exploration: Choose a random action
            action = torch.tensor([[random.randrange(self.action_size)]], device=device, dtype=torch.long)
            # print(f"Random action selected: {action.item()} with epsilon: {eps_threshold:.4f}, and sample: {sample:.4f}")

        return action

    def store_experience(self, state_tuple, action, next_state_tuple, reward, done):
        """Stores a single experience tuple (including masks) in the replay memory.

        Args:
            state_tuple (tuple): (state_sequence, state_mask).
            action (torch.Tensor): The action taken (shape [1, 1]).
            next_state_tuple (tuple or None): (next_state_sequence, next_state_mask) or None if terminal.
            reward (torch.Tensor): The reward received (shape [1]).
            done (bool): True if the episode terminated.
        """
        # Unpack tuples or handle None case
        state_sequence, state_mask = state_tuple
        if next_state_tuple is not None:
            next_state_sequence, next_state_mask = next_state_tuple
        else:
            # Use None placeholders if next state is terminal
            # Note: The memory needs to handle these None values during sampling/batching
            next_state_sequence, next_state_mask = None, None

        done_tensor = torch.tensor([done], device=device, dtype=torch.bool)
        # Push all components including masks
        self.memory.push(state_sequence, state_mask, action, next_state_sequence, next_state_mask, reward, done_tensor)


    def optimize_model(self):
        """Performs one step of optimization and stores the loss."""
        if len(self.memory) < self.batch_size:
            return  # Not enough samples

        # ... (sampling and batch preparation logic remains the same) ...
        transitions = self.memory.sample(self.batch_size)
        batch = Att_Transition(*zip(*transitions))
        # ... prepare state_batch, mask_batch, action_batch, reward_batch, non_final masks etc. ...
        non_final_mask_indices = [i for i, ns in enumerate(batch.next_state) if ns is not None]
        state_batch = torch.cat([s for s in batch.state if s is not None])
        state_mask_batch = torch.cat([m for m in batch.state_mask if m is not None])
        if non_final_mask_indices:
            non_final_next_states = torch.cat([batch.next_state[i] for i in non_final_mask_indices])
            non_final_next_state_masks = torch.cat([batch.next_state_mask[i] for i in non_final_mask_indices])
        else:
            non_final_next_states = None
            non_final_next_state_masks = None
        action_batch = torch.cat(batch.action)
        reward_batch = torch.cat(batch.reward)
        non_final_mask = torch.tensor(tuple(map(lambda ns: ns is not None, batch.next_state)), device=device, dtype=torch.bool)

        # --- Q-Value Calculation ---
        state_action_values = self.policy_net(state_batch, state_mask_batch).gather(1, action_batch)

        # --- Target Q-Value Calculation ---
        next_state_values = torch.zeros(self.batch_size, device=device)
        if non_final_next_states is not None and non_final_next_state_masks is not None:
             with torch.no_grad():
                 max_target_q = self.target_net(non_final_next_states, non_final_next_state_masks).max(1)[0]
                 next_state_values[non_final_mask] = max_target_q
        expected_state_action_values = (next_state_values * self.gamma) + reward_batch

        # --- Compute Loss ---
        criterion = nn.SmoothL1Loss()
        loss = criterion(state_action_values, expected_state_action_values.unsqueeze(1))

        # --- Store Loss ---
        self.step_losses.append(loss.item()) # Append scalar loss value

        # --- Optimize ---
        self.optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_value_(self.policy_net.parameters(), 100)
        self.optimizer.step()


    def setup_logging(self, model_name='AttentionDQN', base_dir="Metrics"):
        """Creates logging directory and initializes the CSV log file."""
        try:
            datetime_str = datetime.now().strftime("%Y%m%d-%H%M%S")
            self.log_dir = os.path.join(base_dir, model_name, datetime_str) # Store log dir path
            if not os.path.exists(self.log_dir):
                os.makedirs(self.log_dir)

            self.log_file_path = os.path.join(self.log_dir, "training_log.csv")
            # Open file in append mode ('a') to allow continuation if needed, use newline='' for csv
            self.log_file = open(self.log_file_path, mode='a', newline='', encoding='utf-8')
            self.log_writer = csv.writer(self.log_file)

            # Write header only if the file is newly created or empty
            self.log_file.seek(0, os.SEEK_END) # Go to end of file
            if self.log_file.tell() == 0: # Check if file is empty
                 header = ["Episode", "Steps", "TotalReward", "AverageLoss", "Epsilon", "GoalAchieved"]
                 self.log_writer.writerow(header)
                 self.log_header_written = True
                 print(f"Logging initialized at: {self.log_file_path}")

        except Exception as e:
            print(f"Error setting up logging: {e}")
            if self.log_file:
                self.log_file.close() # Ensure file is closed on error
            self.log_writer = None
            self.log_file = None

    # --- New Method: Log Episode Data ---
    def log_episode_data(self, episode, steps, avg_reward, goal_achieved):
        """Calculates metrics and writes a row to the log file for the completed episode."""
        if self.log_writer is None:
            print("Warning: Log writer not initialized. Cannot log episode data.")
            return

        # Calculate average loss for the steps in this episode
        avg_loss = np.mean(self.step_losses) if self.step_losses else 0.0
        self.step_losses = [] # Reset losses for the next episode

        # Calculate current epsilon
        current_epsilon = self.eps_end + (self.eps_start - self.eps_end) * \
            math.exp(-1. * self.steps_done / self.eps_decay)

        try:
            row = [episode, steps, f"{avg_reward:.4f}", f"{avg_loss:.6f}", f"{current_epsilon:.4f}", goal_achieved]
            self.log_writer.writerow(row)
            # Optionally flush data to disk periodically or on each write
            # self.log_file.flush()
        except Exception as e:
            print(f"Error writing log data: {e}")

    # --- New Method: Close Log File ---
    def close_log(self):
        """Closes the log file handle."""
        if self.log_file:
            try:
                self.log_file.close()
                print(f"Log file closed: {self.log_file_path}")
                self.log_file = None
                self.log_writer = None
            except Exception as e:
                print(f"Error closing log file: {e}")

    def update_target_net(self, soft_update=True):
        """Updates the target network weights (same logic as before)."""
        if soft_update and self.tau > 0:
            target_net_state_dict = self.target_net.state_dict()
            policy_net_state_dict = self.policy_net.state_dict()
            for key in policy_net_state_dict:
                target_net_state_dict[key] = policy_net_state_dict[key] * self.tau + \
                                             target_net_state_dict[key] * (1 - self.tau)
            self.target_net.load_state_dict(target_net_state_dict)
        elif not soft_update:
            # Hard update (less common if tau > 0)
            self.target_net.load_state_dict(self.policy_net.state_dict())

    # --- save_weights_and_distances and load_weights ---
    # These methods should generally work as they operate on the model's state_dict.
    # You might want to change the default model_name argument or the save directory structure.

    def save_weights_and_distances(self, all_distances_acquisitions, filename="agent_policy_weights.pth"):
        """Saves the weights of the policy network and distance data."""
        base_dir = f'Metrics/{MODEL_NAME}/'
        if not os.path.exists(base_dir):
                print(f"Warning: Base directory {base_dir} not found. Cannot load weights.")
                return False

        all_subdirs = [d for d in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, d))]
        if not all_subdirs:
            print(f"Warning: No subdirectories found in {base_dir}. Cannot load weights.")
            return False

        latest_subdir = max(all_subdirs) # Assumes subdirs are sortable timestamps
        models_dir = os.path.join(base_dir, latest_subdir)
        if not os.path.exists(models_dir):
            os.makedirs(models_dir)

        # Save distance data (assuming save_distance_data is available)
        # You might need to import or pass this function if it's in utils
        try:
            # Example: Assuming save_distance_data is imported or defined globally
            from utils import save_distance_data
            save_distance_data(all_distances_acquisitions, save_folder=models_dir)
        except ImportError:
            print("Warning: 'save_distance_data' function not found. Skipping distance saving.")
        except Exception as e:
            print(f"Error saving distance data: {e}")


        # Save weights
        weights_filename = os.path.join(models_dir, filename)
        try:
            torch.save(self.policy_net.state_dict(), weights_filename)
            print(f"Agent policy weights saved to {weights_filename}")
        except Exception as e:
            print(f"Error saving agent weights: {e}")

    def load_weights(self):
        # ... (load_weights implementation - check paths match Metrics/ModelName/Timestamp/) ...
        # Make sure it finds the correct directory structure
        try:
            base_dir = f'Metrics/{MODEL_NAME}/'
            if not os.path.exists(base_dir):
                 print(f"Warning: Base directory {base_dir} not found. Cannot load weights.")
                 return False

            all_subdirs = [d for d in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, d))]
            if not all_subdirs:
                print(f"Warning: No subdirectories found in {base_dir}. Cannot load weights.")
                return False

            latest_subdir = max(all_subdirs) # Assumes subdirs are sortable timestamps
            path = os.path.join(base_dir, latest_subdir)
            filename = os.path.join(path, "agent_policy_weights.pth") # Standard weight file name

            if not os.path.exists(filename):
                print(f"Warning: Weight file not found at {filename}. Starting with initial weights.")
                return False

            state_dict = torch.load(filename, map_location=device)
            self.policy_net.load_state_dict(state_dict)
            self.target_net.load_state_dict(self.policy_net.state_dict()) # Sync target net
            self.policy_net.eval()
            self.target_net.eval()
            print(f"Agent policy weights loaded from {filename}")
            # Optionally setup logging here if loading a pre-trained model to continue logging
            # self.setup_logging(model_name=model_name) # Needs careful handling if continuing runs
            return True
        except FileNotFoundError:
             print(f"Warning: Directory or file not found during weight loading for {MODEL_NAME}. Starting with initial weights.")
             return False
        except Exception as e:
            print(f"Error loading agent weights for {MODEL_NAME}: {e}")
            return False