Задача

Задачу я встретил, решая упражнения из книги Структура и Интерпретация Компьютерных Программ). Обычно её называют SICP (читается сик-пи) — это аббревиатура названия на английском языке.

Раздел 2.3 посвящён цитированию в LISP и символическим вычислениям.

Обычные — несимволические — вычисления сводятся к расчётам с помощью арифметических операций. Например, если я попрошу вас вычислить производную функции x^2в точке x=17, вы можете сделать это по формуле при каком-нибудь не очень большом значении dx.

(x^2)' = \frac{(x+dx)^2-x^2}{dx}

Подгоняя dx, мы можем вычислить производную с хорошей точностью.

\frac{(17+0.0001)^2-17^2}{0.0001}=34.0001000001

Символические же вычисления позволяют нам применить правила выведения производных и получить значение абсолютно точно.

(x^2)'=2x

При x=17значение производной будет абсолютно точно равно 34.

Реализация на Scheme

SICP предлагает вычислять производную с помощью цитирования. По-английски этот механизм называется quotation.

Если мы вводим в интерпретатор Scheme любое выражение, он вычисляет его сразу.

(+ (/ 1 1) (/ 1 1) (/ 1 2) (/ 1 6) (/ 1 24) (/ 1 120) (/ 1 720) (/ 1 5040))
; => 2.7182539682539684

Но если мы предваряем его кавычкой (quote), Scheme сохраняет выражение в виде списка, не вычисляя.

'(+ (/ 1 1) (/ 1 1) (/ 1 2) (/ 1 6) (/ 1 24) (/ 1 120) (/ 1 720) (/ 1 5040))
; => (+ (/ 1 1) (/ 1 1) (/ 1 2) (/ 1 6) (/ 1 24) (/ 1 120) (/ 1 720) (/ 1 5040))

Таким образом, мы получаем корректное выражение на LISP и можем обработать его, как любой другой список, в частности, преобразовать по правилам вычисления производной.

Вот простая функция, которая строит производную сумм и произведений.

(define (variable? x) (symbol? x))
(define (same-variable? v1 v2)
  (and (variable? v1) (variable? v2) (eq? v1 v2)))
(define (make-sum a1 a2) (list '+ a1 a2))
(define (make-product m1 m2) (list '* m1 m2))
(define (sum? x)
  (and (pair? x) (eq? (car x) '+)))
(define (addend s) (cadr s))
(define (augend s) (caddr s))
(define (product? x)
  (and (pair? x) (eq? (car x) '*)))
(define (multiplier p) (cadr p))
(define (multiplicand p) (caddr p))

(define (deriv exp var)
  (cond ((number? exp) 0)
        ((variable? exp)
         (if (same-variable? exp var) 1 0))
        ((sum? exp)
         (make-sum (deriv (addend exp) var)
                   (deriv (augend exp) var)))
        ((product? exp)
         (make-sum
          (make-product (multiplier exp)
                        (deriv (multiplicand exp) var))
          (make-product (deriv (multiplier exp) var)
                        (multiplicand exp))))
        (else
         (error "Unknown expression type"))))

Очевидным недостатком функции является сложность получаемых выражений.

(deriv '(+ x 3) 'x)
; => (+ 1 0), это означает 1 + 0

(deriv '(* x y) 'x)
; => (+ (* x 0) (* 1 y)), это озачает 0 * x + 1 * y

(deriv '(* (* x y) (+ x 3)) 'x)
; => (+ (* (* x y) (+ 1 0)) (* (+ (* x 0) (* 1 y)) (+ x 3))), а это вообще сложно

Их надо упрощать, для чего может быть написана отдельная функция. Упрощение выражений также рассматривается в SICP.

Реализация на F#

Цитирование на F# всё ещё похоже на цитирование.

let expSquare = <@ fun x -> x * x @>
// => val expSquare : Quotations.Expr<(int -> int)> = Lambda (x, Call (None, op_Multiply, [x, x]))

Чтобы получить вместо кода его представление в виде сложного объекта, заключим код в своеобразные кавычки — <@ и @>.

Результатом будет значение типа Expr, с которым можно работать также, как с деревьями выражений в C#.

Вот простая функция, которая строит производную сумм и произведений.

open Microsoft.FSharp.Quotations
open Microsoft.FSharp.Quotations.Patterns
open Microsoft.FSharp.Quotations.DerivedPatterns

let  make_sum left right =
    let left = Expr.Cast<float> left
    let right = Expr.Cast<float> right 
    <@ %left + %right @> :> Expr
    
let make_prod left right =
    let left = Expr.Cast<float> left
    let right = Expr.Cast<float> right 
    <@ %left * %right @> :> Expr

let deriv (exp: Expr) =
    match exp with
    | Lambda(arg, body) ->
        let rec d exp =
            match exp with
            | Int32(_) ->
                Expr.Value 0.0
            | Var(var) ->
                if var.Name = arg.Name
                then Expr.Value 1.0
                else Expr.Value 0.0
            | Double(_) ->
                Expr.Value 0.0
            | SpecificCall <@ (+) @> (None, _, [left; right]) ->
                make_sum (d left) (d right)
            | SpecificCall <@ (*) @> (_, _, [left; right]) ->
                let left = Expr.Cast<float> left
                let right = Expr.Cast<float> left
                make_sum (make_prod left (d right)) (make_prod (d left) right)
            | _ -> failwith "Unknown expression type"

        d body
    | _ -> failwith "Expr.Lambda expected"

<@ fun (x: double) -> x * x @>
// => Lambda (x, Call (None, op_Multiply, [x, x]))

deriv <@ fun (x: double) -> x * x @>
// => Call (None op_Addition,
//          [Call (None, op_Multiply, [x, Value (1.0)]),
//           Call (None, op_Multiply, [Value (1.0), x])])

Реализация на C#

В C# существует аналог цитирования — деревья выражений. В отличие от F#, здесь нет специальных кавычек для выделения кода. Вместо этого мы указываем тип выражения Expression, а всё остальное делает механизм вывода типов.

Обычные выражения вычисляются сразу.

Func<double, double> square = x => x * x;
sqaure(2) // 4

Выражения, которые приводятся к типу Expression, складываются в древовидную структуру, которую мы сможем потом обработать.

Expression<Func<double, double>> expSquare = x => x * x;
expSquare.Compile()(2) // 4

Деревья выражений хорошо знакомы многим программистам на C#, поскольку они применяются в библиотеке Entity Framework. Однако, с их помощью можно делать и более сложную обработку.

Вот функция, которая получает на вход лямбда-функцию и применяет её к самой себе.

static Expression<Func<double, double>> DoubleFunc(Expression<Func<double, double>> f)
{
    var parameter = Expression.Parameter(typeof(double));
    var inner = Expression.Invoke(f, parameter);
    var outer = Expression.Invoke(f, inner);
    return Expression.Lambda<Func<double, double>>(outer, parameter);
}

var expFourth = DoubleFunc(expSquare);

Если два раза применить функцию возведения в квадрат к какому-то числу, мы получим возведение в четвёртую степень.

expFourth.Compile()(2) // 16

Я разработал пакет SySharp, который умеет генерировать производные функции по деревьям выражений. Исходный код пакета открыт.

Symbolic.Derivative(x => x * x).ToString()
// => x => ((x * 1) + (1 * x))

Там же реализован код для упрощения выражений.

Symbolic.Derivative(x => x * x).Simplify().ToString()
// => x => (2 * x)

В отличие от F#, в C# очень просто из дерева выражения получить работающий код.

var d = (Func<double, double>)Symbolic.Derivative(x => x * x).Compile();
d(17)
// => 34

Комментарии (21)


  1. belch84
    06.06.2022 10:19
    +2

    Их надо упрощать, для чего может быть написана отдельная функция. Упрощение выражений также рассматривается в SICP.

    Упрощение выражений, конечно же, следует производить прямо в процессе вычисления производной, обрабатывая соответствующие варианты типа: X+0 = X, X*0 = 0, X*1 = X,
    это существенно ускорит дальнейшую обработку при дифференцировании сложных выражений с большой вложенностью


    1. markshevchenko Автор
      06.06.2022 10:49
      +1

      В SICP это предлагают делать именно так, но здесь я предпочёл писать (относительно) простые методы, каждый из которых делают свою работу. Своеобразное переложение принципа единственной ответственности для методов.

      В таком подходе есть свои плюсы. Рядом можно написать независимую функцию интегрирования и упрощать её результаты с помощью готового метода. Или написать функцию сложения или умножения выражений, и тоже упрощать результат, просто вызывая Symplify.

      Кент Бек советовал следовать трём правилам при написании кода: сначала надо сделать так, чтобы код работал правильно; потом переписать его так, чтобы он был понятен; и лишь в конце его следует оптимизировать, если это, конечно, надо.

      Возможно, и есть задачи, где требуется быстро дифференцировать десятки тысяч сложных выражений с большой вложенностью. Пожелаем удачи программистам, которые это пишут. Она им пригодится.


      1. belch84
        06.06.2022 11:18
        +3

        В таком подходе есть свои плюсы. Рядом можно написать независимую функцию интегрирования и упрощать её результаты с помощью готового метода. Или написать функцию сложения или умножения выражений, и тоже упрощать результат, просто вызывая Symplify
        Один подход не противоречит другому, просто функцию упрощения следует применять непосредственно при обработке каждой из бинарных операций при дифференцировании
        image
        Есть и еще один аспект — выражения, упрощенные в процессе отыскания выражения для производной, при отладке намного удобнее того, что может получиться при дифференцировании даже относительно простых выражений без упрощений


        1. markshevchenko Автор
          06.06.2022 13:19
          +1

          Я ещё раз перечитал, прикинул и, думаю, вы правы.


  1. NeoCode
    06.06.2022 10:31

    Я знаю C# весьма поверхностно, и не знал что лямбда-функции могут компилироваться в деревья выражений. Как это происходит? Вывод типов настолько мощный, что для выражения

    Expression<Func<double, double>> expSquare = x => x * x;

    он понимает, что не нужно генерировать код для лямбды, а нужно превратить ее в синтаксическое дерево? А для каких объектов это еще работает, кроме лямбд?


    1. markshevchenko Автор
      06.06.2022 10:59
      +3

      Да, в C# 3.5 завезли именно такой мощный вывод типов. Завозили, чтобы там работал LINQ и им было удобно пользоваться. Работает эта штука и для лямбд, и для других выражений, где используются деревья. Скажем, если LINQ запрос приводить к IQueryable<T>, он тоже не будет выполняться сразу, его нужно будет во что-то скомпилировать. В Entity Framework такие запросы компилируют в SQL, но можно обращаться и к обычным коллекциям дотнета.


    1. qw1
      06.06.2022 19:48

      imho, тут не нужен вывод типов. Компилятор всегда заранее знает (из контекста), интерпретировать выражение как AST (Expression) или как вычисляемое непосредственно.


      1. NeoCode
        06.06.2022 23:09

        Если я напишу что-то вроде

        var v = x => x * x;

        то что такое v - лямбда или дерево?


        1. qw1
          07.06.2022 01:15

          Это недопустимое выражения для C#
          А вот если написать

          var v = (int x) => x * x;
          или так
          Func<int,int> v = x => x * x;
          будет лямбда, тип указан снаружи, в контексте использования выражения. А так
          Expression<Func<int,int>> t = x => x * x;
          
          Уже будет Expression. Компилятору нужны подсказки из контекста.


  1. Refridgerator
    06.06.2022 11:04

    Ещё производные можно считать при помощи дуальных чисел. Это будет попроще манипуляций с деревом выражений и поточнее конечной разности — за исключением особых случаев типа sin(x)/x при x=0.


  1. belch84
    06.06.2022 11:31

    Ещё производные можно считать при помощи дуальных чисел. Это будет попроще манипуляций с деревом выражений и поточнее конечной разности — за исключением особых случаев типа sin(x)/x при x=0.
    Аналитическое и численное дифференцирование — это совершенно разные области вычислений. При численном дифференцировании часто требуется находить значения первой и высших производных для функции, заданной не формулой, а поточечно. При этом могут использоваться несколько соседних точек, они позволяют вычислять производную в заданной точке намного точнее.
    Кстати, формула   image, которую автор приводит в начале публикации, имеет точность порядка h, а формула для вычисления по предыдущей и последующей точкам   image — точность порядка h^2


    1. Refridgerator
      06.06.2022 12:00

      Википедия предлагает ещё вариант с 4-мя и более точками. А если значения функции заданы поточечно, то имеет смысл использовать полиномиальную интерполяцию или МНК.


      1. belch84
        06.06.2022 12:38

        У многоточечных формул для численного дифференцирования есть существенный недостаток — для их применения требуется, чтобы шаг изменения аргумента был постоянным. В общем же случае, например, при применении МНК приходится для каждой точки решать системку линейных уравнений, зависящую от соседних точек — для линейной аппроксимации второго порядка, для квадратичной — третьего и т.д.


        1. Refridgerator
          06.06.2022 13:09

          требуется, чтобы шаг изменения аргумента был постоянным
          Не требуется, это просто в готовых формулах используют для удобства — на вики даже ссылка есть для произвольного количества произвольных. Они же получаются тоже из полиномиальной интерполяции — строим аналитический полином по n точкам, а затем он очень просто дифференцируется до нужного порядка. Другой вопрос, что вычислительную точность таким образом увеличить не получится, и это имеет смысл только для производных высшего порядка. А для обычных имеет смысл использовать расширенную точность в промежуточных вычислениях.


          1. belch84
            06.06.2022 13:38

            Не требуется, это просто в готовых формулах используют для удобства — на вики даже ссылка есть для произвольного количества произвольных
            Сходил по ссылке, не увидел, как там можно пользоваться не равноотстоящими точками. Т.е. пользоваться то можно, конечно, но при изменении относительного положения точек формула изменяется, для получения этой формулы нужно решить систему линейных уравнений, различную для различных значений шага
            Последняя точка - половинный шаг
            image

            Когда-то писАл программу, в которой использовалось численное дифференцирование, там формируется и решается маленькая системка на каждом шаге

            Процедура для численного дифференцирования
            procedure DiffTsNumLS(N:integer; var X,F,DF:LongVector; var RC:SmallInt);
            { получение производной временного ряда линейной МНК-аппроксимацией }
            { по соседним точкам }
              var
                DiffPnts : integer;
                i, j, k, jlo, jhi : integer;
                R, A11, A12, A21, A22, B1, B2, D : real;
              begin
                { кол-во точек, по которым стррится линейная аппроксимация }
                DiffPnts:=2*RcnSmoo+1;
            
                for i:=1 to TsCnt do
                 begin
                   { в крайних точках вычисляем по формулам дифференцирования }
                   { назад (в начале) и вперед (в конце) второго порядка      }
                   if i <= round((DiffPnts)/2) then
                    begin
                      D:=X[i+1]-X[i];
                      DF[i]:=(-3*F[i]+4*F[i+1]-F[i+2])/(2*D);
                    end
                   else if i >= TsCnt-round((DiffPnts-1)/2) then
                    begin
                      D:=X[i]-X[i-1];
                      DF[i]:=(F[i-2]-4*F[i-1]+3*F[i])/(2*D);
                    end
                   { иначе используем линейную аппроксимацию }
                   else
                    begin
                      jlo:=i-round((DiffPnts-1)/2);
                      jhi:=i+round((DiffPnts-1)/2);
            
                      { формируем лин.систему второго порядка для коэфф.лин.аппрокс.}
                      R:=0.0;
                      for j:=jlo to jhi do
                       R:=R+sqr(X[j]-X[i]);
                      A11:=R;
            
                      R:=0.0;
                      for j:=jlo to jhi do
                       R:=R+(X[j]-X[i]);
                      A12:=R;
            
                      A21:=A12;
            
                      A22:=DiffPnts;
            
                      R:=0.0;
                      for j:=jlo to jhi do
                       R:=R+F[j]*(X[j]-X[i]);
                      B1:=R;
            
                      R:=0.0;
                      for j:=jlo to jhi do
                       R:=R+F[j];
                      B2:=R;
            
                      { значение производной-коэфф. A в выражении Y=A(X-X0)+B }
                      DF[i]:=(B1*A22-B2*A12)/(A11*A22-A12*A21);
                    end;
                 end;
            
              end;
            


            1. Refridgerator
              06.06.2022 14:25

              Для различных значений шага систему линейных уравнений решать не надо, потому что решение в общем виде достаточно легко выводится через произведение корней и сложение (лень подробно расписывать). Ну типа x*(x-x1)*(x-x2)*(x-x3)… очевидно что в точках x=x1,x=x2,x=x3 полином будет равен нулю.


              1. belch84
                06.06.2022 14:31

                Для различных значений шага систему линейных уравнений решать не надо, потому что решение в общем виде достаточно легко выводится через произведение корней и сложение (лень подробно расписывать)
                Возможно, когда вам будет не лень, вы таки выпишете выражения для коэффициентов в общем виде, можно будет сравнить, а пока мне кажется, что приведенная выше работающая процедура является приемлемым простым решением


                1. Refridgerator
                  07.06.2022 07:09

                  Всё уже придумано до нас и решение в общем виде известно как "Интерполяционный многочлен Лагранжа". Для равноотстоящих точек значения в явном виде выражаются через биномиальные коэффициенты, для произвольных чуть сложнее, но по схожей процедуре, через свёртку — программно несложно реализовать. В сети наверняка есть посвящённые этому работы, написанные чуть более авторитетными людьми.

                  приведенная выше работающая процедура является приемлемым простым решением
                  Если вы о процедуре (f(x+h) - f(x-h)) / (2*h) — то она тоже верна только для равноотстоящих точек. Немного погоняв её по тестам подбирая h, я не смог получить точность результата выше половины исходной. Если двукратная потеря точности допустима — то да, вполне приемлемое решение. Но как я уже писал вам когда-то давно, в обработке сигналов такой фокус не всегда прокатит из-за наложения спектра, а коэффициенты (и их необходимое количество) считаются явным образом из произведения производной sinc-функции на оконную функцию. Да и в прочих случаях нет оснований полагать, что исходная функция наилучшим образом аппроксимируется именно полиномом.


                  1. belch84
                    07.06.2022 07:42

                    Если вы о процедуре (f(x+h) — f(x-h)) / (2*h) — то она тоже верна только для равноотстоящих точек.
                    Нет, я о процедуре численного дифференцирования в этом комментарии. Она предназначена для дифференцирования при помощи линейной аппроксимации, работает для переменного шага и позволяет поэкспериментировать с количеством соседних точек. Её можно бы и обобщить, добавив туда параметром степень полинома для аппроксимации, но это уже намного сложнее. И кстати, в результате получится примерно то же самое, что предлагаете вы.
                    Вообще, как уже неоднократно бывало в дискуссиях с вами, в случае, когда я предлагаю демонстрирую готовое работающее решение, вы обычно оперируете понятиями типа «программно несложно реализовать». В данном конкретном случае я не хочу сказать ничего кроме того, что явную формулу для численного дифференцирования по соседним точкам имеет смысл выписывать только для постоянного шага, иначе нужно писАть программу (для постоянного шага тоже нужна программа, но простая, состоящая из примененной множество раз одной формулы)


                    1. Refridgerator
                      07.06.2022 09:21

                      я предлагаю демонстрирую готовое работающее решение, вы обычно оперируете понятиями типа «программно несложно реализовать»
                      Ну да. Я могу сделать намного больше, чем уже сделал. И «несложно» не значит написать за 5 минут, а результат уместить в комментарий. «Несложно» значит, что можно написать за несколько часов/дней, а все проблемы в процессе решаются первой строчкой гугла.

                      Ну вот накидал я класс для работы с полиномами в C#:
                      код
                      using System;
                      using System.Text;
                      
                      namespace Polynomial
                      {
                          public class Polynomial
                          {
                              public double[] data;
                              int order = 0;
                              public int Order { get { return order; } }
                      
                              private Polynomial(int order)
                              {
                                  this.order = order;
                                  data = new double[order + 1];
                              }
                      
                              public Polynomial(params double[] args)
                              {
                                  order = args.Length - 1;
                                  data = new double[order+1];
                                  Array.Copy(args, 0, data, 0, args.Length);
                              }
                      
                              public double this[int index]
                              {
                                  get
                                  {
                                      if (index >= data.Length)
                                          return 0;
                                      return data[index];
                                  }
                                  set
                                  {
                                      if (index >= data.Length)
                                      {
                                          order = index;
                                          double[] data2 = new double[index + 1];
                                          Array.Copy(data, 0, data2, 0, data.Length);
                                          data = data2;
                                      }
                                      data[index] = value;
                                  }
                              }
                      
                              public void Scale(double v)
                              {
                                  for (int i = 0; i <= order; i++)
                                      data[i] *= v;
                              }
                      
                              public double Calc(double x)
                              {
                                  double y = 0;
                                  for (int i = order; i > 0; i--)
                                  {
                                      y += data[i];
                                      y *= x;
                                  }
                                  y += data[0];
                                  return y;
                              }
                      
                              public static Polynomial Add(Polynomial p1, Polynomial p2)
                              {
                                  Polynomial p3 = new Polynomial(Math.Max(p1.order, p2.order));
                                  for (int i = 0; i <= p3.order; i++)
                                      p3.data[i] = p1[i] + p2[i];
                                  return p3;
                              }
                      
                              public static Polynomial Sub(Polynomial p1, Polynomial p2)
                              {
                                  Polynomial p3 = new Polynomial(Math.Max(p1.order, p2.order));
                                  for (int i = 0; i <= p3.order; i++)
                                      p3.data[i] = p1[i] - p2[i];
                                  return p3;
                              }
                      
                              public static Polynomial Multiply(Polynomial p1, Polynomial p2)
                              {
                                  Polynomial p3 = new Polynomial(p1.order + p2.order + 1);
                                  for (int i = 0; i <= p1.order; i++)
                                      for (int j = 0; j <= p2.order; j++)
                                          p3.data[i + j] += p1.data[i] * p2.data[j];
                                  return p3;
                              }
                      
                              public override string ToString()
                              {
                                  string format = "0.0";
                                  StringBuilder sb = new StringBuilder();
                                  sb.Append(data[0].ToString(format));
                                  for (int i = 1; i <= order; i++)
                                  {
                                      var v = data[i];
                                      if (v >= 0.0)
                                      {
                                          sb.Append(" + ");
                                          sb.Append(v.ToString(format));
                                          if (i == 1)
                                          {
                                              sb.Append("*x");
                                          }
                                          else
                                          {
                                              sb.Append("*x^");
                                              sb.Append(i.ToString());
                                          }
                                      }
                                      else
                                      {
                                          sb.Append(" - ");
                                          sb.Append((-v).ToString(format));
                                          if (i == 1)
                                          {
                                              sb.Append("*x");
                                          }
                                          else
                                          {
                                              sb.Append("*x^");
                                              sb.Append(i.ToString());
                                          }
                                      }
                                  }
                                  return sb.ToString();
                              }
                          }
                      }


                      И что? Мне он сейчас не нужен, вам он не нужен тем более, на практике потребуется оптимизация для частных случаев, чтобы не терять в производительности. Впустую потраченное время.


  1. belch84
    06.06.2022 13:38

    del