科技遐想阁

欢迎您来到“科技遐想阁”,一个汇聚技术与非技术文章的丰富平台。

Q1 无缓冲的 channel 和 有缓冲的 channel 的区别?

答案

对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。

对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。

例如:

1
2
3
4
5
6
7
8
9
10
11
func main() {
st := time.Now()
ch := make(chan bool)
go func () {
time.Sleep(time.Second * 2)
<-ch
}()
ch <- true // 无缓冲,发送方阻塞直到接收方接收到数据。
fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds())
time.Sleep(time.Second * 5)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
st := time.Now()
ch := make(chan bool, 2)
go func () {
time.Sleep(time.Second * 2)
<-ch
}()
ch <- true
ch <- true // 缓冲区为 2,发送方不阻塞,继续往下执行
fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 0.0 s
ch <- true // 缓冲区使用完,发送方阻塞,2s 后接收方接收到数据,释放一个插槽,继续往下执行
fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 2.0 s
time.Sleep(time.Second * 5)
}

Q2 什么是协程泄露(Goroutine Leak)?

答案

协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:

  • 缺少接收器,导致发送阻塞

这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func query() int {
ch := make(chan int)
for i := 0; i < 1000; i++ {
go func() { ch <- 0 }()
}
return <-ch
}

func main() {
for i := 0; i < 4; i++ {
query()
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
}
}
// goroutines: 1001
// goroutines: 2000
// goroutines: 2999
// goroutines: 3998
  • 缺少发送器,导致接收阻塞

那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。

  • 死锁(dead lock)

两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。

  • 无限循环(infinite loops)

这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func request(url string, wg *sync.WaitGroup) {
i := 0
for {
if _, err := http.Get(url); err == nil {
// write to db
break
}
i++
if i >= 3 {
break
}
time.Sleep(time.Second)
}
wg.Done()
}

func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go request(fmt.Sprintf("https://127.0.0.1:8080/%d", i), &wg)
}
wg.Wait()
}

Q3 Go 可以限制运行时操作系统线程的数量吗?

答案

The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit.

可以使用环境变量 GOMAXPROCSruntime.GOMAXPROCS(num int) 设置,例如:

1
runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1

从官方文档的解释可以看到,GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。

Q1 init() 函数是什么时候执行的?

答案

init() 函数是 Go 程序初始化的一部分。Go 程序初始化先于 main 函数,由 runtime 初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。

每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 init() 函数。同一个包,甚至是同一个源文件可以有多个 init() 函数。init() 函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init() 函数的执行顺序不作保证。

一句话总结: import –> const –> var –> init() –> main()

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

func init() {
fmt.Println("init1:", a)
}

func init() {
fmt.Println("init2:", a)
}

var a = 10
const b = 100

func main() {
fmt.Println("main:", a)
}
// 执行结果
// init1: 10
// init2: 10
// main: 10

Q2 Go 语言的局部变量分配在栈上还是堆上?

答案

由编译器决定。Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。

1
2
3
4
5
6
7
8
9
func foo() *int {
v := 11
return &v
}

func main() {
m := foo()
println(*m) // 11
}

foo() 函数中,如果 v 分配在栈上,foo 函数返回时,&v 就不存在了,但是这段函数是能够正常运行的。Go 编译器发现 v 的引用脱离了 foo 的作用域,会将其分配在堆上。因此,main 函数中仍能够正常访问该值。

Q3 2 个 interface 可以比较吗?

答案

Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 ==!= 比较。2 个 interface 相等有以下 2 种情况

  1. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
  2. 类型 T 相同,且对应的值 V 相等。

看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
type Stu struct {
Name string
}

type StuInt interface{}

func main() {
var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
fmt.Println(stu1 == stu2) // false
fmt.Println(stu3 == stu4) // true
}

stu1stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。
stu3stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。

Q4 两个 nil 可能不相等吗?

答案

可能。

接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。

  • 两个接口值比较时,会先比较 T,再比较 V。
  • 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
1
2
3
4
5
6
7
func main() {
var p *int = nil
var i interface{} = p
fmt.Println(i == p) // true
fmt.Println(p == nil) // true
fmt.Println(i == nil) // false
}

上面这个例子中,将一个 nil 非接口值 p 赋值给接口 i,此时,i 的内部字段为(T=*int, V=nil),i 与 p 作比较时,将 p 转换为接口后再比较,因此 i == p,p 与 nil 比较,直接比较值,所以 p == nil

但是当 i 与 nil 比较时,会将 nil 转换为接口 (T=nil, V=nil),与i (T=*int, V=nil) 不相等,因此 i != nil。因此 V 为 nil ,但 T 不为 nil 的接口不等于 nil。

Q5 简述 Go 语言GC(垃圾回收)的工作原理

答案

最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。

标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  • 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
  • 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。

标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。为了能够异步执行,减少 STW 的时间,Go 语言采用了三色标记法。

三色标记算法将程序中的对象分成白色、黑色和灰色三类。

  • 白色:不确定对象。
  • 灰色:存活对象,子对象待处理。
  • 黑色:存活对象。

标记开始时,所有对象加入白色集合(这一步需 STW )。首先将根对象标记为灰色,加入灰色集合,垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。重复这个过程,直到灰色集合为空为止,标记阶段结束。那么白色对象即可需要清理的对象,而黑色对象均为根可达的对象,不能被清理。

三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。

三色标记法并发执行仍存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子:

1
A (黑) -> B (灰) -> C (白) -> D (白)

正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记和用户程序并发执行过程中,用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。

1
2
3
A (黑) -> B (灰) -> C (白) 

D (白)

为了解决这个问题,Go 使用了内存屏障技术,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,类似于一个钩子。垃圾收集器使用了写屏障(Write Barrier)技术,当对象新增或更新时,会将其着色为灰色。这样即使与用户程序并发执行,对象的引用发生改变时,垃圾收集器也能正确处理了。

一次完整的 GC 分为四个阶段:

  • 1)标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)
  • 2)使用三色标记法标记(Marking, 并发)
  • 3)标记结束(Mark Termination,需 STW),关闭写屏障。
  • 4)清理(Sweeping, 并发)

Q6 函数返回局部变量的指针是否安全?

答案

这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上。

Q7 非接口非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?

答案
  • 一个T类型的值可以调用为*T类型声明的方法,但是仅当此T的值是可寻址(addressable) 的情况下。编译器在调用指针属主方法前,会自动取此T值的地址。因为不是任何T值都是可寻址的,所以并非任何T值都能够调用为类型*T声明的方法。
  • 反过来,一个*T类型的值可以调用为类型T声明的方法,这是因为解引用指针总是合法的。事实上,你可以认为对于每一个为类型 T 声明的方法,编译器都会为类型*T自动隐式声明一个同名和同签名的方法。

哪些值是不可寻址的呢?

  • 字符串中的字节;
  • map 对象中的元素(slice 对象中的元素是可寻址的,slice的底层是数组);
  • 常量;
  • 包级别的函数等。

举一个例子,定义类型 T,并为类型 *T 声明一个方法 hello(),变量 t1 可以调用该方法,但是常量 t2 调用该方法时,会产生编译错误。

1
2
3
4
5
6
7
8
9
10
11
12
type T string

func (t *T) hello() {
fmt.Println("hello")
}

func main() {
var t1 T = "ABC"
t1.hello() // hello
const t2 T = "ABC"
t2.hello() // error: cannot call pointer method on t
}

Q1 =:= 的区别?

答案

:= 声明+赋值

= 仅赋值

1
2
3
4
var foo int
foo = 10
// 等价于
foo := 10

Q2 指针的作用?

答案

指针用来保存变量的地址。

例如

1
2
3
var x =  5
var p *int = &x
fmt.Printf("x = %d", *p) // x 可以用 *p 访问
  • * 运算符,也称为解引用运算符,用于访问地址中的值。
  • 运算符,也称为地址运算符,用于返回变量的地址。

Q3 Go 允许多个返回值吗?

答案

允许

1
2
3
4
5
6
7
8
func swap(x, y string) (string, string) {
return y, x
}

func main() {
a, b := swap("A", "B")
fmt.Println(a, b) // B A
}

Q4 Go 有异常类型吗?

答案

Go 没有异常类型,只有错误类型(Error),通常使用返回值来表示异常状态。

1
2
3
4
f, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}

Q5 什么是协程(Goroutine)

答案

Goroutine 是与其他函数或方法同时运行的函数或方法。 Goroutines 可以被认为是轻量级的线程。 与线程相比,创建 Goroutine 的开销很小。 Go应用程序同时运行数千个 Goroutine 是非常常见的做法。

Q6 如何高效地拼接字符串

答案

Go 语言中,字符串是只读的,也就意味着每次修改操作都会创建一个新的字符串。如果需要拼接多次,应使用 strings.Builder,最小化内存拷贝次数。

1
2
3
4
5
var str strings.Builder
for i := 0; i < 1000; i++ {
str.WriteString("a")
}
fmt.Println(str.String())

Q7 什么是 rune 类型

答案

ASCII 码只需要 7 bit 就可以完整地表示,但只能表示英文字母在内的128个字符,为了表示世界上大部分的文字系统,发明了 Unicode, 它是ASCII的超集,包含世界上书写系统中存在的所有字符,并为每个代码分配一个标准编号(称为Unicode CodePoint),在 Go 语言中称之为 rune,是 int32 类型的别名。

Go 语言中,字符串的底层表示是 byte (8 bit) 序列,而非 rune (32 bit) 序列。例如下面的例子中 使用 UTF-8 编码后各占 3 个 byte,因此 len("Go语言") 等于 8,当然我们也可以将字符串转换为 rune 序列。

1
2
fmt.Println(len("Go语言")) // 8
fmt.Println(len([]rune("Go语言"))) // 4

Q8 如何判断 map 中是否包含某个 key ?

答案
1
2
3
if val, ok := dict["foo"]; ok {
//do something here
}

dict["foo"] 有 2 个返回值,val 和 ok,如果 ok 等于 true,则说明 dict 包含 key "foo",val 将被赋予 "foo" 对应的值。

Q9 Go 支持默认参数或可选参数吗?

答案

Go 语言不支持可选参数(python 支持),也不支持方法重载(java支持)。

Q10 defer 的执行顺序

答案
  • 多个 defer 语句,遵从后进先出(Last In First Out,LIFO)的原则,最后声明的 defer 语句,最先得到执行。
  • defer 在 return 语句之后执行,但在函数退出之前,defer 可以修改返回值。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func test() int {
i := 0
defer func() {
fmt.Println("defer1")
}()
defer func() {
i += 1
fmt.Println("defer2")
}()
return i
}

func main() {
fmt.Println("return", test())
}
// defer2
// defer1
// return 0

这个例子中,可以看到 defer 的执行顺序:后进先出。但是返回值并没有被修改,这是由于 Go 的返回机制决定的,执行 return 语句后,Go 会创建一个临时变量保存返回值,因此,defer 语句修改了局部变量 i,并没有修改返回值。那如果是有名的返回值呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func test() (i int) {
i = 0
defer func() {
i += 1
fmt.Println("defer2")
}()
return i
}

func main() {
fmt.Println("return", test())
}
// defer2
// return 1

这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。

Q11 如何交换 2 个变量的值?

答案
1
2
3
a, b := "A", "B"
a, b = b, a
fmt.Println(a, b) // B A

Q12 Go 语言 tag 的用处?

答案

tag 可以理解为 struct 字段的注解,可以用来定义字段的一个或多个属性。框架/工具可以通过反射获取到某个字段定义的属性,采取相应的处理方式。tag 丰富了代码的语义,增强了灵活性。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"
import "encoding/json"

type Stu struct {
Name string `json:"stu_name"`
ID string `json:"stu_id"`
Age int `json:"-"`
}

func main() {
buf, _ := json.Marshal(Stu{"Tom", "t001", 18})
fmt.Printf("%s\n", buf)
}

这个例子使用 tag 定义了结构体字段与 json 字段的转换关系,Name -> stu_name, ID -> stu_id,忽略 Age 字段。很方便地实现了 Go 结构体与不同规范的 json 文本之间的转换。

Q13 如何判断 2 个字符串切片(slice) 是相等的?

答案

go 语言中可以使用反射 reflect.DeepEqual(a, b) 判断 a、b 两个切片是否相等,但是通常不推荐这么做,使用反射非常影响性能。

通常采用的方式如下,遍历比较切片中的每一个元素(注意处理越界的情况)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func StringSliceEqualBCE(a, b []string) bool {
if len(a) != len(b) {
return false
}

if (a == nil) != (b == nil) {
return false
}

b = b[:len(a)]
for i, v := range a {
if v != b[i] {
return false
}
}

return true
}

Q14 字符串打印时,%v%+v 的区别

答案

%v%+v 都可以用来打印 struct 的值,区别在于 %v 仅打印各个字段的值,%+v 还会打印各个字段的名称。

1
2
3
4
5
6
7
8
type Stu struct {
Name string
}

func main() {
fmt.Printf("%v\n", Stu{"Tom"}) // {Tom}
fmt.Printf("%+v\n", Stu{"Tom"}) // {Name:Tom}
}

但如果结构体定义了 String() 方法,%v%+v 都会调用 String() 覆盖默认值。

Q15 Go 语言中如何表示枚举值(enums)

答案

通常使用常量(const) 来表示枚举值。

1
2
3
4
5
6
7
8
9
10
11
12
type StuType int32

const (
Type1 StuType = iota
Type2
Type3
Type4
)

func main() {
fmt.Println(Type1, Type2, Type3, Type4) // 0, 1, 2, 3
}

参考 What is an idiomatic way of representing enums in Go? - StackOverflow

Q16 空 struct{} 的用途

答案

使用空结构体 struct{} 可以节省内存,一般作为占位符使用,表明这里并不需要一个值。

1
fmt.Println(unsafe.Sizeof(struct{}{})) // 0

比如使用 map 表示集合时,只关注 key,value 可以使用 struct{} 作为占位符。如果使用其他类型作为占位符,例如 int,bool,不仅浪费了内存,而且容易引起歧义。

1
2
3
4
5
6
7
8
9
10
11
12
13
type Set map[string]struct{}

func main() {
set := make(Set)

for _, item := range []string{"A", "A", "B", "C"} {
set[item] = struct{}{}
}
fmt.Println(len(set)) // 3
if _, ok := set["A"]; ok {
fmt.Println("A exists") // A exists
}
}

再比如,使用信道(channel)控制并发时,我们只是需要一个信号,但并不需要传递值,这个时候,也可以使用 struct{} 代替。

1
2
3
4
5
6
7
8
9
func main() {
ch := make(chan struct{}, 1)
go func() {
<-ch
// do something
}()
ch <- struct{}{}
// ...
}

再比如,声明只包含方法的结构体。

1
2
3
4
5
6
7
8
9
type Lamp struct{}

func (l Lamp) On() {
println("On")

}
func (l Lamp) Off() {
println("Off")
}

Optuna是一个自动化超参数优化框架,主要用于机器学习项目。它的目标是通过试验的方式找到一个最优的超参数组合,以提高模型的性能。以下是Optuna的基本原理和特性:

  1. 搜索空间定义:在Optuna中,用户需要定义一个超参数的搜索空间。这个空间通常是一个高维的空间,每一个维度对应一个超参数。搜索空间可以是离散的也可以是连续的,取决于超参数的性质。

  2. 目标函数:用户需要定义一个目标函数,这个函数通常代表模型的某种性能指标,比如准确度或者损失函数。Optuna的目标就是找到一个超参数组合,使得这个目标函数的值最大(或者最小)。

  3. 采样策略:Optuna使用了一种特殊的采样策略,叫做Tree-structured Parzen Estimator (TPE)。TPE是一种基于贝叶斯优化的采样方法,它可以有效地搜索高维度和非凸的超参数空间。

  4. 剪枝策略:为了加速搜索过程,Optuna还采用了一种剪枝策略。如果在搜索过程中,一个试验的中间结果已经很差,Optuna可以提前停止这个试验,把资源用在更有希望的地方。

  5. 并行优化:Optuna还支持分布式的并行优化。如果有多个计算资源可用,Optuna可以在不同的资源上并行进行多个试验,这样可以大大加速搜索过程。

Optuna的优化过程是迭代的。在每次迭代中,它先使用采样策略从搜索空间中选择一个超参数组合,然后使用这个组合运行目标函数,并记录函数的返回值。然后,根据这个返回值和剪枝策略来决定是否继续进行这个试验,或者是否需要调整采样策略。这个过程会一直进行,直到找到一个最优的超参数组合,或者达到预设的试验次数。

下面是一个使用Optuna来优化Scikit-Learn随机森林分类器超参数的例子。这个例子使用的是Iris数据集,我们的目标是找到最优的超参数组合,使得分类器的准确度最高。

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
import optuna
from sklearn.datasets import load_iris
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

# 加载数据
iris = load_iris()
X, y = iris.data, iris.target
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=0)

# 定义目标函数
def objective(trial):
# 提出要调整的超参数
n_estimators = trial.suggest_int('n_estimators', 2, 150)
max_depth = int(trial.suggest_loguniform('max_depth', 1, 32))

# 定义模型
clf = RandomForestClassifier(n_estimators=n_estimators, max_depth=max_depth, random_state=0)

# 使用交叉验证评估模型
score = cross_val_score(clf, X_train, y_train, cv=5, n_jobs=-1).mean()

# 目标是让这个分数尽可能高
return score

# 创建一个study对象,设置优化方向为最大化
study = optuna.create_study(direction='maximize')

# 通过调用study的optimize方法进行优化,n_trials为优化的次数
study.optimize(objective, n_trials=100)

# 打印最优参数
print('最优参数: ', study.best_trial.params)

# 打印最优得分
print('最优得分: ', study.best_value)

在这个例子中:

  • 我们首先加载了Iris数据集,并将其分为训练集和验证集。
  • 然后我们定义了一个目标函数objective。这个函数有一个参数trial,这是Optuna在每次优化试验中传递的对象。我们可以使用trial.suggest_系列的方法从超参数空间中抽样。
  • 在这个函数中,我们定义了一个随机森林分类器,并使用抽样得到的超参数来配置它。然后我们使用交叉验证的方式来评估模型的性能,并返回这个性能值。
  • 最后我们创建了一个study对象,并调用它的optimize方法来进行优化。在优化过程中,Optuna会根据目标函数的返回值和剪枝策略来选择最优的超参数组合。
  • 最后,我们打印出了最优的超参数组合和对应的性能值。

  1. CartPole-v0 & v1:平衡一个直立的杆子,防止其倒下。
  2. MountainCar-v0 & MountainCarContinuous-v0:让一辆车爬上陡峭的山坡。
  3. Pendulum-v1:使倒立摆保持上下倒立的状态。
  4. Acrobot-v1:通过控制连接两个连杆的关节,使得机器人的末端达到目标高度。
  5. LunarLander-v2 & LunarLanderContinuous-v2:在月球表面降落太空船,需要平稳且尽可能在目标点降落。
  6. BipedalWalker-v3 & BipedalWalkerHardcore-v3:控制一个两足行走的机器人过障碍。
  7. CarRacing-v0:控制一辆车在赛道上快速、准确地行驶。
  8. Blackjack-v1:在赌场游戏黑杰克中取得优势,尽可能赢得游戏。
  9. FrozenLake-v1 & FrozenLake8x8-v1:在冰冻的湖面上找到安全的路径从起点走到终点。
  10. CliffWalking-v0:找到从起点到终点的最优路径,同时避免掉入悬崖。
  11. Taxi-v3:控制出租车去接客并把客人送到目的地。
  12. Reacher-v2:控制一个二维臂将其末端移动到目标位置。
  13. Pusher-v2:控制一个二维臂推动一个物体到目标位置。
  14. Thrower-v2:控制一个二维臂抛出一个物体到目标位置。
  15. Striker-v2:控制一个二维臂击打一个物体使其移动到目标位置。
  16. InvertedPendulum-v2 & InvertedDoublePendulum-v2:控制车上的摆来保持平衡,防止倒下。
  17. HalfCheetah-v2 & v3:控制一个仿生机器人像猎豹一样快速前进。
  18. Hopper-v2 & v3:控制一个单足跳跃的机器人快速前进。
  19. Swimmer-v2 & v3:控制一个仿生游泳机器人在水下快速前进。
  20. Walker2d-v2 & v3:控制一个二维行走的机器人快速前进。
  21. Ant-v2 & v3:控制一个像蚂蚁一样的四足机器人快速前进。
  22. Humanoid-v2 & v3:控制一个人形机器人快速前进。
  23. HumanoidStandup-v2:让一个人形机器人从倒下的状态起立起来。
  24. FetchSlide-v1, FetchPickAndPlace-v1, FetchReach-v1, FetchPush-v1以及相应的Dense版本:控制一个机械臂完成特定任务,如滑动、抓取放置、触达和推动物体。
  25. HandReach-v0, HandManipulateBlockRotateZ-v0, HandManipulateBlockRotateParallel-v0等以及相应的TouchSensors和Dense版本:控制一个模拟的手完成特定的任务,例如抓取、旋转和操纵物体。
  26. CubeCrash-v0, CubeCrashSparse-v0, CubeCrashScreenBecomesBlack-v0:控制一个立方体避免碰撞,游戏的难度和视觉反馈有所不同。
  27. MemorizeDigits-v0:记住显示的数字序列。

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
import gym
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv
from stable_baselines3.common.evaluation import evaluate_policy
from stable_baselines3.common.monitor import Monitor

class CustomMountainCarEnv(gym.Env):
def __init__(self):
super(CustomMountainCarEnv, self).__init__()

self.env = gym.make('MountainCar-v0')

self.action_space = self.env.action_space
self.observation_space = self.env.observation_space

def step(self, action):
state, reward, done, info = self.env.step(action)

reward = -abs(state[0] - 0.5)

return state, reward, done, info

def reset(self):
return self.env.reset()

def render(self, mode='rgb_array'):
return self.env.render(mode=mode)

# 接下来你就可以像之前一样使用这个自定义环境了
env = CustomMountainCarEnv()

env = Monitor(env)
env = DummyVecEnv([lambda: env]) # 想让环境与stable_baselines兼容,必须要对环境进行向量化

# 开关,如果是True则为测试模式,直接加载模型,如果为False则训练模型
test_mode = False

# 根据开关状态选择模型加载或训练
if test_mode:
# 加载模型
model = PPO.load("ppo_cartpole")
else:
# 初始化并训练模型
model = PPO('MlpPolicy', env, verbose=1, learning_rate=0.01, ent_coef=0.1)
model.learn(total_timesteps=100000)
# 保存模型
model.save("ppo_cartpole")

# 评估模型
mean_reward, std_reward = evaluate_policy(model, env, n_eval_episodes=10)
print(f"Mean reward: {mean_reward} +/- {std_reward}")

successes = 0
# 测试代理
obs = env.reset()
for i in range(10000):
done = False
step = 0
while not done:
action, _ = model.predict(obs, deterministic=True)
obs, _, done, _ = env.step(action)
env.render()
step += 1
if done:
obs = env.reset()
if step != 200:
successes += 1
print(successes, step)

env.close()

这段代码主要是用于强化学习(Reinforcement Learning)模型训练和测试的。在这段代码中,首先创建了一个自定义的环境(CustomMountainCarEnv),然后在这个环境中训练一个使用PPO(Proximal Policy Optimization)算法的模型,最后对模型进行了测试。

让我们逐步地详细分析这段代码:

  1. 导入依赖库:这段代码中使用了gym、stable_baselines3等库。Gym是一个用于开发和比较强化学习算法的工具库,它包含了许多预定义的环境。stable_baselines3则是一个提供实现了各种强化学习算法的模型的库。

  2. 定义自定义环境(CustomMountainCarEnv):这个环境基于Gym库中的’MountainCar-v0’环境,但是修改了其奖励(reward)的计算方式。在新的奖励机制中,奖励与车辆的位置与目标位置(0.5)的距离成反比。

  3. 环境向量化和监控:使用DummyVecEnv进行环境向量化,这是与stable_baselines兼容所必需的,它允许模型在训练时采取批量(batch)的动作。而Monitor则是用来追踪训练过程中的一些指标,如奖励等。

  4. 模型的训练与加载:如果测试模式(test_mode)为False,就会训练一个新的模型;否则,就会加载一个已有的模型。训练模型时,使用的是PPO算法,这是一种策略优化算法,它可以在策略改变过程中保持一定的稳定性。

  5. 评估模型:使用stable_baselines3中的evaluate_policy方法,通过在环境中运行模型来评估其性能。这个过程会运行多次(n_eval_episodes=10),计算得出平均奖励(mean_reward)和奖励的标准差(std_reward)。

  6. 测试模型:通过让模型在环境中运行,查看模型的性能。每次环境结束后(done = True),如果步骤数不为200(这是’MountainCar-v0’环境的默认最大步数),就认为这是一个成功的尝试。

最后,关闭环境以释放资源。

总的来说,这段代码实现了在自定义环境中使用PPO模型进行强化学习的全过程,包括环境的定义、模型的训练、评估以及测试。

stable_baselines3 库主要提供了 NormalActionNoiseOrnsteinUhlenbeckActionNoise 这两种噪声类,这两种噪声已经覆盖了强化学习中最常见的需求。通常,NormalActionNoise 用于添加独立并且相同分布(i.i.d)的高斯噪声,而 OrnsteinUhlenbeckActionNoise 则是为动作序列提供时间相关性的噪声。

然而,如果这两种类型的噪声无法满足您的需求,您可以自定义噪声生成类。自定义的噪声生成类应该实现 __call__ 方法,当每次调用该对象时,它应返回一组新的噪声值。

例如,如果您想使用均匀分布的噪声,可以创建如下的噪声类:

1
2
3
4
5
6
7
8
9
10
class UniformActionNoise:
def __init__(self, low, high):
self.low = low
self.high = high

def __call__(self):
return np.random.uniform(self.low, self.high)

def reset(self):
pass

在这个例子中,每当这个对象被调用,它都会从一个均匀分布中抽样一个新的噪声值。注意到 reset 方法在这个例子中并没有实际的功能,但它是必须存在的,因为在模型中可能会在某些时间调用它。如果在你的自定义噪声中,reset 方法有其特定的功能,你可以在里面添加相应的实现。

让我们考虑这样一组数据:[1, 2, 3, 3, 3]。

首先,我们计算平均值 (a):

a = (1+2+3+3+3) / 5 = 2.4

接着,我们计算每个数据点与平均值的差,以及这些差值的平方:

(1-2.4)^2 = 1.96
(2-2.4)^2 = 0.16
(3-2.4)^2 = 0.36
(3-2.4)^2 = 0.36
(3-2.4)^2 = 0.36

然后,我们求这些平方的平均值(即方差):

(1.96 + 0.16 + 0.36 + 0.36 + 0.36) / 4 = 0.8

最后,我们求方差的平方根,得到标准差 (b):

b = sqrt(0.8) ≈ 0.89

所以,这组数据的标准差为0.89,平均值为2.4。平均值与标准差之和为 2.4 + 0.89 = 3.29。

然而,这组数据的最大值为3,显然小于平均值与标准差之和3.29。

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
import time
import numpy as np
import gym

env = gym.make("FrozenLake-v1")

# 初始化Q表
qtable = np.zeros((env.observation_space.n, env.action_space.n))

train_mode = True # 控制是否进行训练

if train_mode:
start_time = time.time()

# 参数设定
total_episodes = 10000 # 训练的总回合数
learning_rate = 0.1 # 学习率
gamma = 0.98 # 折扣因子

for episode in range(total_episodes):
state = env.reset()
done = False

step = 0

while not done:
action = np.argmax(qtable[state, :] + np.random.randn(1, env.action_space.n)*(1./(step+1)))

# 执行动作并得到反馈
new_state, reward, done, info = env.step(action)

# 更新 Q-table 为 Q(s,a)
qtable[state, action] = qtable[state, action] + learning_rate * (reward + gamma * np.max(qtable[new_state, :]) - qtable[state, action])

state = new_state

step += 1

np.save('qtable.npy', qtable)

print("--- 训练耗时: %s 秒 ---" % (time.time() - start_time))

else:
qtable = np.load('qtable.npy')


start_time = time.time()

successes = 0

for episode in range(10000):
state = env.reset()
done = False

while not done:
action = np.argmax(qtable[state, :])
state, reward, done, info = env.step(action)

# 如果reward为1,代表成功到达目标
if reward == 1:
successes += 1

env.close()

print("成功完成目标次数: " + str(successes))

print("--- 测试耗时: %s 秒 ---" % (time.time() - start_time))

这段代码使用Q-Learning的强化学习算法在OpenAI Gym的FrozenLake环境中进行训练和测试。下面详细解释这段代码的每一部分:

首先,我们导入需要的库,包括时间库time,科学计算库numpy,以及gym库。

接着,我们创建一个FrozenLake环境。FrozenLake是一个网格世界,目标是从起点移动到目标点,避免落入洞中。

然后,我们初始化一个Q表。Q表是一个二维数组,大小为state的数量xaction的数量。这个表用于存储每个state-action对应的Q值。

train_mode变量决定我们是否进行训练。

如果train_mode为真,我们将进行训练。我们首先记录训练开始的时间,然后设定一些训练参数,包括训练的总回合数、学习率和折扣因子。

在训练的每一回合中,我们首先重置环境,然后在每一步中,我们选择一个动作,这个动作是当前state对应的Q表中Q值最大的动作,然后加上一个噪声,这个噪声随着时间逐渐减小,这样既保证了一定的探索性,又能让agent随着时间的推移越来越依赖已学习的经验。

在执行这个动作后,我们会获得一个新的状态、奖励、是否结束的标志以及一些其他的信息。然后我们更新Q表。

Q表的更新公式为:

Q(s,a) = Q(s,a) + lr * [R(s,a) + γ * maxQ(s',a') - Q(s,a)]

其中,

  • s是当前状态
  • a是当前动作
  • R(s,a)是执行动作a在状态s后得到的奖励
  • s'是新的状态
  • a'是在新的状态s'下Q值最大的动作
  • γ是折扣因子,决定了未来奖励的影响力
  • lr是学习率,决定了我们在更新Q值时,新的信息占据的比例。

如果train_mode为假,我们将加载已经训练好的Q表进行测试。

在测试中,我们将选择Q值最大的动作进行执行,不再加入噪声。测试完成后,我们将统计成功达到目标的次数。

至于代码中的两个time.time(),主要是用于计算训练和测试所花费的时间。

总的来说,这是一个基于Q-Learning的强化学习例子,通过不断的试错和学习,agent能够学会在FrozenLake环境中如何行动以达到目标。

yield是TypeScript(以及JavaScript)中的一个关键字,主要在一个生成器函数中使用。生成器函数是ES6引入的一种特殊类型的函数,它可以在执行过程中被暂停和恢复。

使用yield的生成器函数的特点是:函数体内可以使用yield表达式,返回一个迭代器,每次调用迭代器的next方法,函数会运行到下一个yield表达式处,yield后面的表达式的值,就是返回对象的value属性值。

基础用法

下面是一个基本的例子,用来生成一个简单的数字序列:

1
2
3
4
5
6
7
8
9
10
11
12
function* numberGenerator() {
let i = 0;
while (true) {
yield i++;
}
}

const gen = numberGenerator();
console.log(gen.next().value); // 0
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
// 这可以一直执行下去

在上面的例子中,每次调用gen.next()时,生成器函数就会从上次暂停的地方开始运行,直到遇到下一个yield表达式。然后,它将返回一个对象,该对象的value属性值等于yield后面的表达式的值。

产出值和接收值

生成器函数还有一个很有用的特性:它们不仅可以产出(yield)值,还可以接收值。当你调用next()方法并传入一个参数时,该参数会成为上一个yield表达式的值。看下面的例子:

1
2
3
4
5
6
7
8
9
10
function* dialog() {
const name = yield "What is your name?";
const job = yield `Hello, ${name}! What is your job?`;
return `${job}, that sounds interesting!`;
}

const gen = dialog();
console.log(gen.next().value); // What is your name?
console.log(gen.next("Alice").value); // Hello, Alice! What is your job?
console.log(gen.next("Engineer").value); // Engineer, that sounds interesting!

在这个例子中,当我们调用gen.next("Alice")时,"Alice"这个值被发送给生成器,赋值给name变量。同样地,当我们调用gen.next("Engineer")时,"Engineer"被发送给生成器,赋值给job变量。这就是生成器函数的接收值功能。

使用场景

生成器在处理流式数据或大型集合等情况下很有用。它们可以用于创建只在需要时计算其元素的数据结构(懒计算),处理异步操作等。

请注意,这只是关于yield和生成器函数的基本介绍。这是一个复杂且强大的特性,有很多更高级的用法和技巧,需要进一步学习和实践。