Adaptive Tracks

Some experiments require the use of an adaptive adjustment of a stimulus based on participant responses. There are several basic adaptive tracking algorithms built into Weber, and you can also implement your own as well.

Using an Adaptive Track

To use an adaptive track in your experiment, you need to make use of some of the advanced features of Weber. In this section we'll walk through the necessary steps, using a simple frequency discrimination experiment.

In this experiment, on each trial, listeners hear a low and a high tone, separated in frequency by an adaptively adjusted delta. Their task is to indicate which tone is lower, and the delta is adjusted to determine the difference in frequency at which listeners respond with 79% accuracy. The entire example code is provided below.

using Weber

version = v"0.0.3"
sid,trial_skip,adapt = @read_args("Frequency Discrimination ($version).",
                                  adapt=[:levitt,:bayes])

const atten_dB = 30
const n_trials = 60
const feedback_delay = 750ms

isresponse(e) = iskeydown(e,key"p") || iskeydown(e,key"q")

const standard_freq = 1kHz
const standard = @> tone(standard_freq,100ms) ramp attenuate(atten_dB)

function one_trial(adapter)
  first_lower = rand(Bool)
  resp = response(adapter,key"q" => "first_lower",key"p" => "second_lower",
                  correct=(first_lower ? "first_lower" : "second_lower"))

  signal() = @> tone((1-delta(adapter))*standard_freq,100ms) begin
    ramp
    attenuate(atten_dB)
  end
  stimuli = first_lower? [signal,standard] : [standard,signal]

  [moment(feedback_delay,play,stimuli[1]),
   show_cross(),
   moment(900ms,play,stimuli[2]),
   moment(100ms + 300ms,display,
          "Was the first [Q] or second sound [P] lower in pitch?"),
   resp,await_response(isresponse)]
end

experiment = Experiment(
  skip=trial_skip,
  columns = [
    :sid => sid,
    :condition => "example",
    :version => version,
    :standard => standard_freq
  ]
)

setup(experiment) do
  addbreak(moment(record,"start"))

  addbreak(instruct("""

    On each trial, you will hear two beeps. Indicate which of the two beeps you
heard was lower in pitch. Hit 'Q' if the first beep was lower, and 'P' if the
second beep was lower.
"""))

  if adapt == :levitt
    adapter = levitt_adapter(down=3,up=1,min_delta=0,max_delta=1,
                             big=2,little=sqrt(2),mult=true)
  else
    adapter = bayesian_adapter(min_delta = 0,max_delta = 0.95)
  end

  @addtrials let a = adapter
    for trial in 1:n_trials
      addtrial(one_trial(a))
    end

    function threshold_report()
      mean,sd = estimate(adapter)
      thresh = round(mean,3)*standard_freq
      thresh_sd = round(sd,3)*standard_freq

      # define this string during run time when we know
      # what the threshold estimate is.
      "Threshold $(thresh)Hz (SD: $thresh_sd)\n"*
      "Hit spacebar to continue..."
    end

    addbreak(moment(display,threshold_report,clean_whitespace=false),
             await_response(iskeydown(key":space:")))
  end

end

run(experiment)

In what follows we'll walk through the parts of this code unique to creating an adaptive track. For more details on the basics of creating an experiment see Getting Started.

Creating the Adapter

if adapt == :levitt
  adapter = levitt_adapter(down=3,up=1,min_delta=0,max_delta=1,
                           big=2,little=sqrt(2),mult=true)
else
  adapter = bayesian_adapter(min_delta = 0,max_delta = 0.95)
end

The present experiment can be run using either of two built-in adapters: levitt_adapter and bayesian_adapter. An adapter is the object you create to run an adaptive track, and defines the particular algorithm that will be used to select a new delta on each trial, based on the responses to previous deltas.

Generating Stimuli

const standard = attenuate(ramp(tone(standard_freq,0.1s)),atten_dB)
...
signal() = attenuate(ramp(tone(standard_freq*(1-delta(adapter)),0.1s)),atten_dB)
stimuli = first_lower? [signal,standard] : [standard,signal]

The two stimuli presented to the listener are the standard (always at 1kHz) and the signal (1kHz - delta). The standard is always the same, and so can be generated in advance before the experiment begins. The signal must be generated during the experiment, on each trial. The next delta is queried from the adapter using delta. The signal is defined as a function that takes no arguments. When passed a function, play generates the stimulus defined by that function at runtime, rather than setup time, which is precisely what we want in this case.

Collecting Responses

resp = response(adapter,key"q" => "first_lower",key"p" => "second_lower",
                  correct=(first_lower ? "first_lower" : "second_lower"))

To update the adapter after each response, a special method of the response function is used, which takes the adapter as its first argument. We also must indicate which response is correct by setting correct appropriately.

Generating Trials

@addtrials let a = adapter
  for trial in 1:n_trials
    addtrial(one_trial(a))
  end
  addbreak(moment(display,() -> "Estimated threshold: $(estimate(adapter)[1])\n",
                                "Hit spacebar to exit."),
           await_response(iskeydown(key":space:")))
end

To generate the trials, which depend on the run-time state of the adapter, we use the @addtrials macro. Any time the behavior of listeners in one trial influences subsequent trials, this macro will be necessary. In this case it is used to signal to Weber that the trials added inside the loop depend on the run-time state of the adapter.

After all trials have been run, we report the threshold estimated by the adapter using the estimate function, which returns both the mean and measurement error.

Reporting the Threshold

function threshold_report()
  mean,sd = estimate(adapter)
  thresh = round(mean,3)*standard_freq
  thresh_sd = round(sd,3)*standard_freq

  # define this string during run time when we know
  # what the threshold estimate is.
  "Threshold $(thresh)Hz (SD: $thresh_sd)\n"*
  "Hit spacebar to continue..."
end

addbreak(moment(display,threshold_report,clean_whitespace=false),
         await_response(iskeydown(key":space:")))

You can report the threshold at the end of an experiment using estimate, as above, but this isn't strictly necessary. The tricky part is to make sure you find the estimate after trials have been run (during run time).

Custom Adaptive Tracking Algorithms

You can define your own adaptive tracking algorithms by defining a new type that is a child of Adapter. You must define an appropriate function to generate the adapter, and methods of Weber.update!, estimate and delta for this type. Strictly speaking estimate need not be implemented, if you choose not to make use of this method in your experiment.