File size: 8,830 Bytes
ee7f83c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import torch
import torch.nn as nn
import torch.nn.functional as F


class BahdanauAttention(nn.Module):
    """
    Bahdanau (additive) attention from Bahdanau et al. (2015).

    At each decoder step, the attention module compares the current decoder hidden
    state against every encoder output position and produces a context vector β€”
    a weighted sum of encoder outputs β€” that tells the decoder where to look in the
    source sentence. This is especially useful for translation because word order
    between Filipino and the regional languages isn't always 1-to-1.

    The energy score for each source position i is computed as:
        score(decoder_hidden, encoder_output_i)
            = score_vector( tanh( W_enc * encoder_output_i
                                + W_dec * decoder_hidden ) )

    Scores are then passed through softmax to get normalized attention weights.
    """

    def __init__(self, encoder_hidden_size: int, decoder_hidden_size: int) -> None:
        super().__init__()

        # Projects encoder outputs (bidirectional, so * 2) into the attention space
        self.encoder_projection = nn.Linear(
            encoder_hidden_size * 2, decoder_hidden_size
        )

        # Projects the decoder hidden state into the same attention space
        self.decoder_projection = nn.Linear(decoder_hidden_size, decoder_hidden_size)

        # Collapses the attention space down to a single scalar score per source position
        self.score_vector = nn.Linear(decoder_hidden_size, 1, bias=False)

    def forward(
        self,
        decoder_hidden: torch.Tensor,
        encoder_outputs: torch.Tensor,
        source_padding_mask: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        # decoder_hidden:  (batch_size, decoder_hidden_size)
        # encoder_outputs: (batch_size, source_len, encoder_hidden_size * 2)

        # Expand the decoder hidden state so it can be broadcast across all source positions
        decoder_hidden_expanded = decoder_hidden.unsqueeze(1)
        # decoder_hidden_expanded: (batch_size, 1, decoder_hidden_size)

        # Add the two projections and apply tanh β€” this is the "energy" function
        energy = torch.tanh(
            self.encoder_projection(encoder_outputs)
            + self.decoder_projection(decoder_hidden_expanded)
        )
        # energy: (batch_size, source_len, decoder_hidden_size)

        # Reduce to one score per source position
        scores = self.score_vector(energy).squeeze(2)
        # scores: (batch_size, source_len)

        # Padding positions should never get attention, so we set their scores to -inf
        # before softmax so they come out as zero weight
        if source_padding_mask is not None:
            scores = scores.masked_fill(source_padding_mask == 0, float("-inf"))

        # Softmax turns the raw scores into a proper probability distribution
        attention_weights = F.softmax(scores, dim=1)
        # attention_weights: (batch_size, source_len)

        # Build the context vector as a weighted sum of the encoder outputs
        # bmm performs batch matrix multiplication: (batch, 1, source_len) x (batch, source_len, hidden*2)
        context_vector = torch.bmm(attention_weights.unsqueeze(1), encoder_outputs)
        # context_vector: (batch_size, 1, encoder_hidden_size * 2)

        return context_vector.squeeze(1), attention_weights


class Decoder(nn.Module):
    """
    Autoregressive GRU/LSTM decoder with Bahdanau attention.

    At each decoding step the decoder:
      1. Embeds the previous output token (or <sos> at the first step)
      2. Attends over all encoder outputs to build a context vector
      3. Concatenates the embedding and context vector, then runs one RNN step
      4. Projects the RNN output to a distribution over the target vocabulary

    The final prediction combines the RNN output, context vector, and embedding
    (a technique called "input feeding" / deep output) so the model has direct
    access to what it just generated and what it attended to.
    """

    def __init__(
        self,
        target_vocab_size: int,
        embedding_dim: int,
        encoder_hidden_size: int,
        decoder_hidden_size: int,
        dropout: float,
        rnn_type: str = "gru",
    ) -> None:
        super().__init__()

        self.rnn_type = rnn_type.lower()
        self.encoder_hidden_size = encoder_hidden_size

        # Embedding for the target language (Waray / Kapampangan / Hiligaynon)
        self.embedding = nn.Embedding(target_vocab_size, embedding_dim, padding_idx=0)
        self.dropout = nn.Dropout(dropout)

        self.attention = BahdanauAttention(encoder_hidden_size, decoder_hidden_size)

        # The RNN input at each step is the concatenation of:
        #   - the embedded previous token  (embedding_dim)
        #   - the attention context vector (encoder_hidden_size * 2)
        rnn_input_dim = embedding_dim + encoder_hidden_size * 2

        rnn_options = {"rnn": nn.RNN, "gru": nn.GRU, "lstm": nn.LSTM}
        if self.rnn_type not in rnn_options:
            raise ValueError(
                f"rnn_type must be one of {list(rnn_options.keys())}, got '{rnn_type}'"
            )

        self.rnn = rnn_options[self.rnn_type](
            input_size=rnn_input_dim,
            hidden_size=decoder_hidden_size,
            num_layers=1,
            batch_first=True,
        )

        # The output projection combines three sources of information for a richer prediction:
        #   - the new RNN hidden state       (decoder_hidden_size)
        #   - the attention context vector   (encoder_hidden_size * 2)
        #   - the embedded input token       (embedding_dim)
        self.output_projection = nn.Linear(
            decoder_hidden_size + encoder_hidden_size * 2 + embedding_dim,
            target_vocab_size,
        )

    def forward_step(
        self,
        input_token: torch.Tensor,
        decoder_hidden: torch.Tensor,
        decoder_cell: torch.Tensor | None,
        encoder_outputs: torch.Tensor,
        source_padding_mask: torch.Tensor | None,
    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None, torch.Tensor]:
        """
        Performs a single decoder step and returns the vocabulary logits for that step.

        Args:
            input_token:        (batch_size,) β€” token id fed as input at this step
            decoder_hidden:     (1, batch_size, decoder_hidden_size) β€” current hidden state
            decoder_cell:       (1, batch_size, decoder_hidden_size) β€” current cell (LSTM only)
            encoder_outputs:    (batch_size, source_len, encoder_hidden_size * 2)
            source_padding_mask:(batch_size, source_len) β€” 1 for real tokens, 0 for padding

        Returns:
            vocab_logits:       (batch_size, target_vocab_size) β€” unnormalized scores
            decoder_hidden:     (1, batch_size, decoder_hidden_size) β€” updated hidden state
            decoder_cell:       (1, batch_size, decoder_hidden_size) or None
            attention_weights:  (batch_size, source_len) β€” where the model looked
        """

        embedded = self.dropout(self.embedding(input_token.unsqueeze(1)))
        # embedded: (batch_size, 1, embedding_dim)

        # Squeeze out the leading num_layers dimension (always 1 here) for the attention call
        context_vector, attention_weights = self.attention(
            decoder_hidden.squeeze(0), encoder_outputs, source_padding_mask
        )
        # context_vector:    (batch_size, encoder_hidden_size * 2)
        # attention_weights: (batch_size, source_len)

        # Concatenate the embedding and context vector, then step the RNN
        rnn_input = torch.cat([embedded, context_vector.unsqueeze(1)], dim=2)
        # rnn_input: (batch_size, 1, embedding_dim + encoder_hidden_size * 2)

        if self.rnn_type == "lstm":
            rnn_output, (decoder_hidden, decoder_cell) = self.rnn(
                rnn_input, (decoder_hidden, decoder_cell)
            )
        else:
            rnn_output, decoder_hidden = self.rnn(rnn_input, decoder_hidden)
            decoder_cell = None
        # rnn_output: (batch_size, 1, decoder_hidden_size)

        # Combine rnn output, context, and embedding to form the final prediction input
        # This gives the model a direct view of what it attended to and its last token
        prediction_input = torch.cat(
            [rnn_output.squeeze(1), context_vector, embedded.squeeze(1)], dim=1
        )
        # prediction_input: (batch_size, decoder_hidden_size + encoder_hidden_size * 2 + embedding_dim)

        vocab_logits = self.output_projection(prediction_input)
        # vocab_logits: (batch_size, target_vocab_size)

        return vocab_logits, decoder_hidden, decoder_cell, attention_weights