Activations for audio LSTM

this concerns “my own” lstm implementation in c/c++, i think i’ve had three (3) separate periods of identifying i/j transposition errors since i posted here, me no get sleepy,

but not try to bug fix my code today, asking about activation options.

i’ve been told “for audio, you can use NO activation function, just pass the output layer prediction” and tried it with and without clipping in the training loop. very slow progress in output if any. let’s ask a people. :slight_smile:

i do try to keep track of what others try to express but so far i can’t tell if humanity has determined that audio in an lstm requires an ‘‘0.5 * (y * y)’’ shaping for backprop without tanh or not. i’m getting faster learning but a ways to go before my little lappy churns out anything click/burp i’m guessing. the prediction in training sound pretty good .. immediately similar, highs improve.. but as said, a ways to go for generative output (error is nll on abs(predict - target).

i’m wondering if i still have errors. i take my fixes back to my text lstm, train there since text is easy to observe, and find gradient clipping produces recognisable words, even during initial training. gradient normalisation and ADAM can train down to 0.0n error but just be “english shaped nonsense” (hot one chars, not tokenised text). i haven’t added regularisation because i’m guessing that will mess it up also, being another ‘generaliser’.

tried y = y * y going into backprop and managed to train at (1.0/65536) learning rate down to about 0.5~0.6 nll error before it started to crunch up and i halved the learning rate… aand watched the error climb back above 2.0..

..maybe it’s due to my activation choice, i’ve had to keep my learning rates real slow or they go down a bit and then don’t. try again before it’s 90 degrees in here later.

11.25k samplerate, 2x 64 size hidden layers, one [-1,1] float in/out, 256 step context window.. ~32k samples of data. real pleased to see anything happen at this scale especially such an encouraging figure, looks like it will train all the way down.. didn’t save before i switched the rate, can’t hear it.. try again..

Hi. Hmm… for now, I have a feeling that the output, loss, and derivative might be getting mixed together:


There is no single activation that audio “requires.” For a continuous sample in [-1, 1], I would use a linear output first, because it gives the most transparent path for checking the arithmetic.

Assuming:

z = output_affine(hidden);
p = z;
e = p - target;

then one ordinary squared-error version is:

loss += 0.5f * e * e;
dL_dz = e;

Here, 0.5 * e * e is the loss contribution, not an output activation. The value entering backpropagation is its derivative, e.

So if y = y * y going into backprop is meant literally, and y is the signed prediction error, that would remove the sign of the error. The network could then lose the information that says whether an output needs to move upward or downward. You may be using y to mean something else in your C shorthand, though, so I would separate the four values explicitly before deciding that this is the bug:

z       = raw_output(...);
p       = output_transform(z);
loss    = loss_function(p, target);
dL_dz   = derivative_used_to_start_backprop(...);

Also make the reduction explicit. For a 256-sample window:

loss = sum_of_sample_losses;

and

loss = sum_of_sample_losses / 256.0f;

are both valid conventions, but the gradients differ by a factor of 256. Divide once, in one known place. The MSELoss documentation is a convenient statement of the sum versus mean distinction; I am only using PyTorch here as documentation for the numerical contract, not suggesting that you replace the C implementation.

If you want a bounded output, tanh is also coherent:

z = output_affine(hidden);
p = tanhf(z);
e = p - target;

loss += 0.5f * e * e;
dL_dz = e * (1.0f - p * p);

That last factor is required. tanh also saturates when z becomes large, so I would not introduce it until the linear-output derivative has passed a small check.

A hard audio clamp is a third, separate operation:

playback_sample = clamp(p, -1.0f, 1.0f);

I would initially use that only to protect playback or WAV conversion, not as a hidden part of the training graph. A hard clamp can produce no useful gradient once the raw prediction is beyond the boundary.

My default route would therefore be:

  1. linear output;
  2. 0.5 * error²;
  3. derivative error;
  4. one clearly stated sum or mean;
  5. no output clamp inside training;
  6. verify one output bias numerically;
  7. only then compare tanh, L1, Adam, or a probabilistic output.

The cheapest useful gradient test is probably one output bias. Temporarily disable clipping, regularisation, and the optimizer update. Compute its analytic gradient, then evaluate the same fixed window once with the bias slightly increased and once slightly decreased. The centred finite-difference result should agree with the analytic value over some useful epsilon range. This is the same basic mechanism described in gradcheck mechanics, but it is easy to do directly in C.

The immediate decision tree I would use is:

Teacher-forced loss itself becomes unstable
    -> check loss derivative and reduction
    -> check non-square parameter traversal
    -> check gradient clipping / SGD / Adam update

Teacher-forced loss is stable, but free-running audio fails
    -> compare rollout lengths
    -> check output feedback scaling and clamping
    -> later compare deterministic and probabilistic output heads

Short sound texture works, but longer structure does not
    -> check state carry across windows
    -> distinguish TBPTT length from state lifetime
    -> consider data/model scale and longer-timescale structure
Why I would separate these cases, and what I would test next

1. “NLL,” absolute error, and squared error are different contracts

You wrote that the displayed error is “nll on abs(predict - target).”

It would help to pin down the exact scalar that is accumulated, without worrying too much about its name.

For example:

e = prediction - target;
loss += fabsf(e);

is L1 or absolute error. Away from exactly zero, its derivative with respect to a linear prediction is approximately:

dL_dz = (e > 0.0f) ? 1.0f : -1.0f;

possibly divided by the window length if you use a mean.

By contrast:

loss += 0.5f * e * e;
dL_dz = e;

is half squared error.

A categorical negative log-likelihood is something else: it normally consumes log-probabilities over classes, as in the NLLLoss contract.

It is possible to interpret L1 as a negative log-likelihood under a fixed-scale Laplace distribution, or squared error under a fixed-variance Gaussian distribution, but then the assumed distribution, scale, constants and reduction are part of the definition. Therefore a reported value such as 0.5 cannot really be compared with a SampleRNN or WaveRNN likelihood unless the equations and denominators match.

For debugging, I would probably print all three separately if useful:

mean absolute error
mean half-squared error
the actual scalar used for backpropagation

That avoids changing the audible progress display while changing the training objective.

2. Why I would not blame the activation yet

The sequence you observed—

loss falls to roughly 0.5-0.6
learning rate is changed
loss later rises above 2

—is compatible with several different mechanisms:

  • an incorrect derivative;
  • a missing or duplicated reduction factor;
  • a large or non-finite gradient;
  • a correct gradient applied to the wrong parameter element;
  • an optimizer state problem;
  • a change of data or recurrent-state boundary;
  • or an ordinary unstable update.

The timing alone does not show that the activation caused it. The bad update may have happened before the learning-rate change, and Adam or momentum-like state can continue to affect later updates.

Before changing another condition, a compact one-update trace would be more informative than a long training run:

loss sum
number of contributing samples
loss mean
global gradient norm before clipping
maximum absolute gradient
first NaN or Inf, if any
parameter norm before update
update norm
parameter norm after update
raw output minimum and maximum
played/clamped output minimum and maximum
hidden-state and cell-state norms
whether clipping was activated

You do not need to print every tensor every iteration. One failing window and one ordinary window would already separate many branches.

3. Gradient clipping, value clipping, and normalization

These terms can describe different programs:

Operation What it changes
Audio/output clamp Limits generated sample values
Gradient value clipping Limits each gradient component separately
Global gradient-norm clipping Scales the whole gradient vector only when its norm exceeds a threshold
Gradient normalization Often forces every update direction to a chosen norm, even when the original gradient is small
Optimizer update scaling Changes gradients through momentum, moments or adaptive denominators

A conventional global-norm clip behaves approximately like:

norm = sqrt(sum_of_all_parameter_gradient_squares);

if (norm > max_norm)
{
    scale = max_norm / norm;
    multiply_every_gradient_by(scale);
}

It should not enlarge a small gradient. The official clip_grad_norm_ description describes the parameter gradients as one combined vector.

That distinction may be relevant to your observation that clipping gives recognisable text while “normalisation” can reach a displayed zero error but produce English-shaped nonsense. If normalization forces nearly every update to a similar magnitude, it can keep moving even when the true gradient becomes tiny. Another possibility is that the clipping, normalization and Adam paths do not traverse exactly the same parameter elements.

A useful comparison would therefore be one update from the same saved state:

plain SGD
SGD plus global-norm clipping
Adam

with the same gradients frozen before any of the three update paths. Then compare the first parameter element where the resulting updates differ unexpectedly.

4. The non-square paths remain especially useful probes

Your public repository README already notes remaining i/j ordering trouble, while also noting that the audio build produces recognisable signal. I would not assume that the current audio program contains the same error, but it gives a very useful audit strategy.

The recurrent matrices are square, so a transpose can sometimes remain in bounds and look plausible. In the audio build, the informative shapes should be approximately:

input -> hidden:  [64][1] or [destination][source]
hidden -> output: [1][64]

Those cannot silently masquerade as their transpose as easily as [64][64].

I would follow each non-square parameter through the entire lifecycle:

declaration
initialisation
forward
backward
gradient accumulation
gradient norm calculation
gradient clipping or normalization
SGD update
Adam first moment
Adam second moment
save
load

The text header already contains distinct non-square weight, gradient and Adam-state arrays in lstm.h. The important question is not merely whether each declaration has the intended shape, but whether every loop uses the same [destination][source] convention.

A very small diagnostic value pattern can make this easier than random values. For example, initialise each test weight from both indices so that a transpose is immediately visible in a dump.

5. Save two kinds of checkpoint

There are two useful meanings of “checkpoint” here.

A weights-only sound checkpoint

Enough to hear or generate from a particular set of parameters.

A resumable training checkpoint

Enough to reproduce the next update. For Adam this normally includes:

weights and biases
first moments
second moments
Adam step counter
learning rate and other optimizer settings
recurrent/data position if the run depends on them

The comment in the current header says that Adam state is not yet stored with the model. That is fine for a weights-only listening checkpoint, but it means reloading and continuing Adam is not the same training trajectory. The general distinction is also described in the PyTorch checkpoint guide: resuming training requires optimizer state in addition to model parameters.

For experiments like this, I would save immediately before changing any one of:

activation
loss
learning rate
optimizer
gradient clipping
window/state policy

Then one change can be compared without losing the earlier branch.

6. Teacher-forced prediction and free-running generation are separate tests

The training preview can sound similar very quickly even when free-running generation is poor.

If training uses the true previous sample:

input[t] = target[t-1]

but generation uses:

input[t] = prediction[t-1]

then a small output error becomes part of the next input. The recurrent state can gradually move into a region that was rarely visited during training.

This is a known distinction between teacher-forced and free-running recurrent models. Professor Forcing studies the difference directly and includes raw-waveform vocal synthesis, although its adversarial training method would be much more machinery than I would add here.

I would only borrow the diagnostic idea. From one checkpoint and one starting sample, compare:

teacher-forced one-step error
free-running 16 samples
free-running 64 samples
free-running 256 samples
free-running 1024 samples

Record where the waveform first diverges badly, rather than judging only the final sound.

Interpretation:

Immediate failure
    -> output scale, feedback index, target offset, clamp or sign

Gradual drift
    -> accumulated prediction error / teacher-forcing gap

Stable local waveform but no larger structure
    -> context, state, model scale, data scale or output distribution

This branch is probably separate from the rising training loss. Teacher-forcing mismatch can explain weak generation even when the one-step loss is correct and stable; it does not by itself explain a training objective suddenly rising.

7. Linear output is valid, but it answers a particular question

A linear scalar output with L1 or L2 loss is perfectly reasonable when the next value is nearly determined by the inputs and state. Neural models of deterministic audio effects often use direct continuous sample regression.

Unconditional raw-audio generation is a somewhat different problem. Given the same short waveform history, several next samples may be plausible. A deterministic squared-error model is encouraged toward a conditional average, which can be a poor closed-loop sample even when its one-step error is small.

This is why several established raw-audio generators model a distribution:

  • SampleRNN is a hierarchical recurrent model that generates one audio sample at a time.
  • WaveRNN uses recurrent computation with a categorical coarse/fine output.
  • WaveNet is a fully probabilistic autoregressive waveform model.

These are precedents, not evidence that your linear output is wrong. They use much larger architectures and datasets, and they address different production goals.

A modest later comparison, after the derivative is verified, could be:

linear output + half squared error
tanh output + half squared error + tanh derivative
linear output + L1
8-bit or 9-bit mu-law categories + softmax + cross-entropy + sampling

Use the same audio, initial weights where possible, reduction, checkpoint points and evaluation horizons. I would not start with a Gaussian mixture or another complex density head; it would add several more derivatives and numerical boundaries before the basic path is established.

8. The 256-step window is not necessarily a 256-sample memory limit

At 11.25 kHz, 256 samples are roughly 22.8 ms. That is short for musical structure, but the truncated backpropagation length and the forward recurrent-state lifetime are not necessarily the same.

If hidden and cell state carry from one consecutive window to the next, the forward state can retain influence from earlier samples even though gradients are truncated at the boundary. Truncation changes what earlier events receive credit or blame for; it does not automatically reset the numerical state.

A very cheap pure-forward test is:

run 512 samples continuously, without updating weights

versus:

run 256 samples
carry h and c
run the next 256 samples
do not update weights between the halves

The outputs and final states should agree under the intended state contract. If they do not, the first place to inspect is window indexing, copying, reset timing or circular-buffer position—not the activation.

Also distinguish these boundaries:

consecutive windows from the same recording
new unrelated training segment
start of a file
start of an epoch
start of free-running generation

Each may need a different reset or warm-up policy. Work on truncated BPTT bias is useful background, but a boundary-equivalence test in your C program will probably tell you more immediately.

9. A few DSP-style controls may be more revealing than more training

For one fixed checkpoint, try several deliberately simple signals:

all zeros
constant small DC value
small sine wave
single impulse followed by zeros
short real audio segment

These can expose different failures:

Input Useful observation
Silence Bias, drift, unstable autonomous state
DC Sign and steady-state behaviour
Sine Phase, frequency and amplitude drift
Impulse State response and decay
Real audio Actual target behaviour

For prediction baselines, I would also compare:

next sample = previous sample
small linear AR predictor
LSTM scalar predictor

Audio is strongly locally correlated, so a training prediction that sounds immediately similar may partly reflect persistence. Beating a one-sample-copy baseline tells you that the LSTM has learned something beyond the easiest local rule.

A later DSP-oriented route would be to let a linear predictor handle the easily predictable part and ask the recurrent model to predict a residual or excitation. LPCNet is a much more developed example of that general division of labour. I would treat it as a direction on the map, not as the next rewrite.

10. A compact order of operations

If this were my test branch, I would use this order:

1. Define z, prediction, error, scalar loss and dL/dz separately.
2. Choose sum or mean over the 256 samples.
3. Use a linear output and half squared error.
4. Finite-difference one output bias.
5. Finite-difference one output weight.
6. Save a checkpoint and one-update telemetry.
7. Audit [64][1] and [1][64] paths through every optimizer stage.
8. Compare continuous 512 forward with carried 256 + 256.
9. Compare teacher-forced and free-running horizons.
10. Compare the persistence and small AR baselines.
11. Only then compare tanh, L1 or a probabilistic output.

That route should distinguish an activation choice from a derivative error, an update error, a window/state error and an autoregressive-generation limitation without requiring a framework rewrite.

If a small fragment is easier to post than the whole audio branch, the most informative part would be only:

output affine calculation
output transform or clamp
loss accumulation and reduction
initial output delta
output-weight and output-bias gradients
gradient clipping / normalization
one SGD or Adam update

That should be enough to decide whether 0.5 * y * y belongs where it currently sits, while leaving the rest of the LSTM alone.

well John sixes,
i’m unable to intuit much about your nature as my cosmology is tasked by such rarity. i wouldn’t wish it upon sensate tissue but if your dsp portion is minded to chew through it, the audio build is at git hub.com /atomictraveller/alstm = equivalence to begging

we note that i’ve reported very low error rates overfitting the text/char lstm to nll < 0.01 on simple material, but then next week, i discerned there were several i/j transpositions. the same way i managed to hit an 0.5-0.6 error for audio using the incorrectly squared version for back propogation. since i fixed these things, it doesn’t act like it trains or improves or does anything. i’ve tried “overfit training” on sines. there is no “improvement” in it’s current form, any training pass traces roughly the same contour.

training does gradually increase the highs of the “in training audition” until they feed back at window length into a scream when the loss starts to go out of control and the hidden layers flatten into the same value and jump around. generative auditioning was added, but not doing much since “fixed”.

i believe the nonsquare arrays are fit (what an accomplishment..) the square ones are pretty much beyond estimation until someone stops nuking me. for now i can pretty much copy the symbols and keep them in order, but my next step is trying to graphically observe the flow of the net and essentially develop a new technology autonomously since i can’t seem to learn it from humans. the notion of having to further analyse my build graphically is pretty dismal, it’s not elaborate.

it’s not anyone else’s problem, but right now someone is keeping me too awake/asleep to really parse much myself. i could think well on the seventh. fond memories.

rofl

Hi. Please don’t add weird settings to me.:sweat_smile:
Um, does this just look like it’s simply bugged?:


I took a static pass through the current rnn-lstm.h. I have not built or run the native program, so this is source inspection rather than a runtime verdict. But I found several concrete mismatches that look strong enough that I would stop tuning the activation or learning rate until they are separated.

The first one is the forget and output gates.

In forward, those arrays appear to store the sigmoid denominator:

gate_storage = 1.0f + expf(-a);
value *= 1.0f / gate_storage;

That forward calculation can represent a sigmoid. But backward later treats the stored denominator as though it were the sigmoid output itself:

gate_storage * (1.0f - gate_storage)

Since the stored denominator is normally at least 1, this is not the sigmoid derivative. It also appears in the cell-state carry:

dc_next = dc_total * gate_storage;

where the multiplier should be the actual forget-gate value, not its denominator.

The simplest repair would probably be to store the gate value directly:

gate = 1.0f / (1.0f + expf(-a));

and consistently use:

gate * (1.0f - gate)

in backward. This affects the forget and output gates in both LSTM layers. The relevant forward and backward sections are visible in the current rnn-lstm.h.

The second issue is the sample used by forward versus backward.

The training loop does this, approximately:

st = current_audio_sample;
samplein[twin] = sb;

first_lstm_forward(st);
target = st;

Then, at the end of the step:

sb = st;

But backward uses the saved samplein[twin], which is the previous sb, when calculating the first-layer input-weight gradients.

So the current paths appear to describe two different programs:

forward input:          current sample
training target:        current sample
input-weight backward:  previous sample

Unless I am missing an intended indexing trick, backward is therefore not differentiating the forward calculation that actually ran.

If this is intended to be a one-sample-ahead predictor, I would make that contract explicit everywhere:

input[t]  = audio[t - 1];
target[t] = audio[t];

The exact input[t] used in forward should then be saved and reused for its input-weight gradients. If the intended task is current-sample reconstruction instead, forward and backward should both use the current sample—but that is a different and much easier task than next-sample prediction.

The third issue looks like a direct array-bounds problem in the Adam path.

The output bias has idim elements—apparently one in this audio model—but its Adam update currently loops to hidd:

for (unsigned int i = 0; i < hidd; i++)
{
    mobias[i] += ...;
    vobias[i] += ...;
    obias[i]  -= ...;
}

That loop looks as though it should be:

for (unsigned int i = 0; i < idim; i++)

With idim = 1 and hidd = 64 or 128, the current loop can write beyond the output-bias and Adam-state arrays and corrupt neighbouring memory. It is around the output-bias Adam update.

There is also a measurement problem.

After subtracting the target, the value entering output-layer backward is the signed prediction error:

error = prediction - target;

But the displayed quantity is approximately:

display += -logf(max(epsilon, fabsf(error)));

That displayed “NLL” is not the scalar objective whose derivative the program is following. It also has a counterintuitive direction for errors below 1: making the absolute error smaller makes -log(abs(error)) larger.

So the old contours may still be useful as a visual trace, but I would not interpret “down to 0.5” or “back above 2” as ordinary loss improvement or deterioration. For debugging, I would display something directly connected to the derivative, such as:

mean_abs_error += fabsf(error);
mean_half_squared_error += 0.5f * error * error;

while separately naming which one is actually used for backpropagation.

I reconstructed the gate-storage and input-sample parts in a small independent double-precision model. Equations shaped like the current code failed centred finite differences across many parameters. Storing actual sigmoid values and using the same sample in forward and backward brought the checked gradients down to roughly numerical-noise scale.

That is not a build or execution of your complete program. The native build, AddressSanitizer, complete retraining, Adam replay and generated-audio comparison remain untested on my side.

My present repair order would be:

1. Store actual forget/output gate values, or derive them consistently.
2. Choose current->current or previous->current and use it everywhere.
3. Change the output-bias Adam loop from hidd to idim.
4. Temporarily use plain SGD with clipping and normalization disabled.
5. Check one output bias with centred finite differences.
6. Check one output weight.
7. Check one first-layer input weight.
8. Run the Adam branch under AddressSanitizer.
9. Add immediate NaN/Inf checks for gradients, states and parameters.
10. Only after those pass, compare linear, tanh or probabilistic audio outputs.

So my rough answer to the original activation question is still: keep the final output linear for now—not because linear output must be the final audio design, but because it introduces the least additional arithmetic while the forward/backward contract is being repaired.

At the moment this looks less like a mysterious audio-generation limitation and more like several local, fixable disagreements between the forward program, backward program and optimizer.

no wonder it never learned and the training preview sounded so accurate.. dear dear evisceration ‘r’ us
:face_with_peeking_eye: :distorted_face: :sleeping_face:
apparently i’m awake enough to successfully parse and apply all three corrections. what an amazing specimen of the species i have presented you with today. let’s keep it past tense :slight_smile:

real glad i posted then you posted.