Redis对外暴露最基本的5种结构,比如String、List、Set、ZSet和Hash,而每种结构在底层又能通过不同的数据结构来实现。在service.h中定义了底层使用的数据结构:
1 | /* Objects encoding. Some kind of objects like Strings and Hashes can be |
对于List,在Redis中的相关代码在t_list.c
,在3.0及之前的版本中,对于list的调用为如下代码:
1 | if (subject->encoding == REDIS_ENCODING_ZIPLIST) { |
即对于3.0及之前版本,对于list在底层存在两种不同的实现方式,ziplist以及linkedlist,但是在3.2版本开始,对于list的调用变成了如下形式:
1 | if (subject->encoding == OBJ_ENCODING_QUICKLIST) { |
显然,在3.2及之后的版本,Redis使用了quicklist这个新的实现方式来替换以前的ziplist以及linkedlist。
linkedlist即经典的双链表,其定义在3.0及之前版本的adlist.h
文件中:
1 | /* Node, List, and Iterator are the only data structures used currently. */ |
每个node包含了三个部分,指向前一个节点和后一个节点的指针,以及一个数据值。而一个list包含了指向首尾的指针、整个list的长度,以及三个函数指针,用来复制节点的值、释放节点的值,以及比较节点内容。
即对于每一个节点,value指向robj对象,而robj对象中的ptr指向实际的SDS对象,包含了长度,空余长度,真实字符串+'\0',对于链表中每增加一个节点,需要实际内容额外42个字节(3.0.6版本,32位)的存储空间。1 |
|
显然,linkedlist在频繁前后端插入情况下表现良好,但是查找效率比较低,并且比较耗内存。
在Redis源码中,ziplist的实现在ziplist.c
文件中,一开头就介绍了,ziplist是一种特殊编码的节省内存空间的双链表,能以O(1)的时间复杂度在两端push
和pop
数据,具有如下结构:
<zlbytes><zltail><zllen><entry><entry><zlend>
unsigned integer
,保存ziplist占用的总内存空间,在重新分配内存时,借助这个字段可以不用遍历整个ziplist;2^16-2
个entry,如果超过了,则其值为2^16-1
,需要遍历entry才能知道具体的数量;zlbytes、zltail、zllen统称为ziplist的header,其空间总占用定义如下:
1 |
新建一个空的ziplist的代码如下:
1 | /* Create a new empty ziplist. */ |
entry的定义如下:
1 | typedef struct zlentry { |
prevrawlen和len均采用变长编码的方式来存储数据。
其中prevrawlen表示前一个节点的长度,prevrawlensize用来表示prevrawlen的大小,有1字节和5字节两种。如果prevrawlen小于254字节,则只需要一字节来保存,如果大于等于254字节,则需要5字节保存,第一个字节被置为254,其余4字节用来保存实际长度;len为当前节点长度 lensize为编码len所需的字节大小;headersize为当前节点的header大小;encoding为节点的编码方式;*p为指向节点的指针。
redis通过如下的代码来获取prevrawlen和prevrawlensize。
1 | /* Encode the length of the previous entry and write it to "p". Return the |
对于lensize和len,这二者的值和entry内存储的类型有关。如果存储string,则前两个bit位用来存储string的编码方式,后面跟上实际的长度。如果存储integer,则前两个bit位置为1,随后两个bit位指定integer的类型。具体如下:
1 | * |00pppppp| - 1 byte |
从ZIP_DECODE_LENGTH
可以看出具体的解码过程和每个字段的存储位置:
1 | /* Different encoding/length possibilities */ |
对len字段进行计算的过程如下面的函数:
1 | /* Encode the length 'rawlen' writing it in 'p'. If p is NULL it just returns |
可以看到,对于integer编码,长度恒为1,否则读取实际的string的长度值。
而实际上,encoding又是保存在len字段的第一个字节,判断是否是字符串的方法如下:
1 |
|
encoding和p表示元素编码和内容,其具体的定义可参考如下函数:
1 |
|
显然如上面的描述,对于entrylen>=32不用做处理,接下来设置encoding为具体的值。
对于ziplist的push操作,在ziplistPush
中具体定义,简单描述其流程如下:
连锁更新的执行函数以及解释如下:
1 | /* When an entry is inserted, we need to set the prevlen field of the next |
如上面的描述,可以得到ziplist的简易示意图如下,每个节点是单独的entry,每个entry中一个字段表示前一个entry的长度(长度小于254时采用一个字节编码,否则采用5个字节),一个encoding字段保存当前节点的编码方式和数据长度,content保存着entry的具体数据,可以是字符数组或整数,如果是整数且在0-12之间则不再保存content。
ziplist可以很方便的拿到头节点或者尾节点,由于每个节点都保存前一个节点的长度,因此对于任意节点可以方便的前后遍历。相比linkedlist,除了链表结构节省少量空间外,每个entry可以节省大量的额外内存(最大额外空间才10字节,对于不大于12的正整数,甚至不用content空间来进行存储)。对于主要是pop或push并且每个元素长度不大的场景来说,ziplist相比于linkedlist有较大的优势。
但是如前面所说,通过ZIP_BIGLEN
即254
这个分界点来确认prevlen的长度,如果每一个节点的长度原本都是253,如果在头部插入时下一个节点的prevlen需要扩展,则会导致整个ziplist都进行更新。在删除时也可能出现类似情况。但是这种情况出现的概率不大,并且在使用ziplist时,entry总量不大,因此可以忽略不计。
ziplist的弊端也很明显了,对于较多的entry或者entry长度较大时,需要大量的连续内存,并且节省的空间比例相对不在占优势,就可以考虑使用其他结构了。
如图所示是3.0.6版本redis中的默认值,即单个entry长度官方默认要求小于64时才使用ziplist,否则使用其他底层结构;entry数量也有限制,一般要求在512个(hash和list)或者128个(zset)之内才使用。前面介绍的两种结构,一种耗内存但是能应付数据较大(数量或者单个的长度)的情况,但是插入和删除成本低,而另一个则在小规模数据情况下表现很好并且非常节省内存,数据规模大时会有问题,并且插入和删除成本高。显然这时候QuickList该上场了。这时候让我们忘记3.0及之前的版本,开始进入新的结构吧。
首先看代码定义,quicklist.h
:
1 | /* Node, quicklist, and Iterator are the only data structures used currently. */ |
乍一看貌似很复杂,但是整个结构却是非常的清晰。
首先是quicklistNode
,这是quicklist
的节点,可以看做对ziplist
的高层封装。包含指向前后节点的指针,以及指向实际ziplist
的指针zl,从定义上看,quicklist
的节点上支持了压缩能力,并且多个字段通过位域方式申明内存节省空间。
而quicklistLZF
用来存储压缩后的ziplist
,占用空间4+N字节,其中N为压缩后的实际长度。
通过quicklist
将quicklistNode
连接起来,形成了完整的quicklist
结构。由于quicklist
同时包含了ziplist
和quicklist
的结构,因此每个quicklistNode
的大小就非常重要:如果太大其就更接近ziplist,影响插入效率;如果太小就更接近quicklist
,浪费空间。其通过fill
字段来控制大小,正数表示单个节点允许的最大数量,最大为2^15,负数表示单个节点的内存空间大小,其中-1表示单个节点最多存储4kb,-2表示单个节点最多存储8kb,以此类推,-5表示单个节点最多保存64kb,在创建时默认的值为-2。这个字段的设置代码即判定是否还能继续插入数据的代码如下。compress表示压缩的深度,0表示不压缩,正数表示头尾多少个节点不压缩其余节点都压缩。
1 |
|
1 | /* Maximum size in bytes of any multi-element ziplist. |
quicklist使用lzf进行压缩,具体压缩算法略过,压缩节点的代码如下,开辟新的空间压缩ziplist数据,并且释放node->zl原有的内存,最后指向压缩后的数据并修改其他属性值。
1 |
|
同样,解压的代码如下,开辟新的空间存放解压后的数据,同时释放压缩数据的空间,node->zl指向新的解压后的数据,最后修改其他属性值。
1 | /* Uncompress the ziplist in 'node' and update encoding details. |
在头尾插入节点如下,如果单个ziplist满足上面说到的大小、数量限制,则使用ziplist的push函数直接插入,否则新建一个节点用来插入即可。
1 | /* Add new entry to head node of quicklist. |
除此之外,quicklist还提供了merge、旋转、指定节点前后插入等功能,均在quicklist.[h|c]
中,其主要在linkedlist的基础上,对于每个节点融合ziplist的特征,并且对于中间节点还提供了lzf压缩的能力,综合了linkedlist和ziplist的有点,同时具有节省内存、插入删除数据高效的特点。整个quicklist的简单示意图可如下图。
测试平台:macOS Catalina 10.15.2,Intel Core i7 2.2GHz,16GB 1600MHz DDR3
因为系统上已有通过homebrew安装64位的5.0.7版本Redis,因此先看这个版本。因为打算对比quicklist、ziplist以及linkedlist,所以选择list结构进行测试。为了测试存储空间、插入删除性能,在不同测试中均使用redis-benchmark
执行相同的测试。
对于ziplist以及linkedlist,使用本地编译的64位3.0.6版本。
首先向quicklist插入1000条定长数据:
1 | $ redis-benchmark -t lpush -n 1000 |
实际使用5131字节,相当于每个元素使用约5.1字节,空间利用率约58.5%(实际插入的是”xxx“,三个字节长)。
再向quicklist的list中插入1000000个定长数据
1 | $ redis-benchmark -t lpush -n 1000000 |
可以看出,其插入速度基本都能保持在1ms以内,并且在未压缩情况下(value空间小于MIN_COMPRESS_BYTES
即48字节,不执行压缩),共有612个quicklist节点,总共占用5006732字节内存,即每个值仅占用约5字节,而实际插入的值"xxx"
本身是3字节长,约60%的空间利用率。弹出速度也大量保持在1ms以内。
接着尝试插入更长的数据,先不开启quicklist的,再看看插入和弹出性能以及内存占用情况:
1 | $ redis-benchmark -n 1000000 lpush mylist "mylist str len: 463. Redis is not a plain key-value store, it is actually a data structures server, supporting different kinds of values. What this means is that, while in traditional key-value stores you associated string keys to string values, in Redis the value is not limited to a simple string, but can also hold more complex data structures. The following is the list of all the data structures supported by Redis, which will be covered separately in this tutorial" |
可以看出,随着字符串的变长,实际的插入、弹出时间相差不大,每个元素占用空间470411768/1000000≈470
字节,约98.5%的空间利用率。实际内存空间使用428062336
字节,约408M。
如果开启压缩,设置list-compress-depth
为1,再进行相同的测试:
1 | $ redis-benchmark -n 1000000 lpush mylist "mylist str len: 463. Redis is not a plain key-value store, it is actually a data structures server, supporting different kinds of values. What this means is that, while in traditional key-value stores you associated string keys to string values, in Redis the value is not limited to a simple string, but can also hold more complex data structures. The following is the list of all the data structures supported by Redis, which will be covered separately in this tutorial" |
可以看出,在进行lzf压缩后,插入、弹出元素的时间相差无几,但是实际的空间占用降到了74930099
,即约71M,空间节省极大。
在redis中,通过两处配置定义list底层使用的数据结构。list-max-ziplist-entries
表示ziplist元素最大值,list-max-ziplist-value表示单个节点的最大长度。
1 | # Similarly to hashes, small lists are also encoded in a special way in order |
如果元素的值的长度或者数量超过了配置值的任何一个,则ziplist会自动转变为linkedlist并且不会退化回ziplist,转换的代码如下,可以看到只允许转为REDIS_ENCODING_LINKEDLIST
的单向转换。
1 | void listTypeConvert(robj *subject, int enc) { |
因此启动redis-server
时显式的指定list-max-ziplist-entries
为0即可使用linkedlist进行测试。
插入100条数据:
1 | $ redis-benchmark -t lpush -n 100 |
通过redisinsight分析其实际使用内存44kb,即单个元素占用约45字节,空间利用率约6.7%。
同样,插入1000000个定长数据:
1 | $ redis-benchmark -t lpush -n 1000000 |
其实际使用内存43M,即单个节点使用约45字节的空间,空间利用率约6.7%。但是在插入与弹出的时间消耗上,和quicklist相差不大。
再看看插入长字符串的情况:
1 | $ redis-benchmark -n 1000000 lpush mylist "mylist str len: 463. Redis is not a plain key-value store, it is actually a data structures server, supporting different kinds of values. What this means is that, while in traditional key-value stores you associated string keys to string values, in Redis the value is not limited to a simple string, but can also hold more complex data structures. The following is the list of all the data structures supported by Redis, which will be covered separately in this tutorial" |
通过redisinsight分析其实际使用内存488M,即单个节点使用约512字节的空间,空间利用率约90.4%。但是在插入与弹出的时间消耗上,和quicklist以及短字符串插入都相差不大。
最后再看看ziplist的表现。设置list-max-ziplist-entries
与list-max-ziplist-value
为较大的值来启动redis-server,保证使用ziplist编码来实现list。我们先插入比较少的数据:
1 | $ redis-benchmark -t lpush -n 100 |
分析内存占用,100个元素总共占用约553字节空间,平均一个元素约5.5字节,空间利用率约54.5%。
因为ziplist插入数据量过大可能非常的慢,甚至每秒的请求数量能到个位数,因此来看看插入100000个元素的情况:
1 | $ redis-benchmark -t lpush -n 100000 |
显然,插入的速度相比quicklist、linkedlist以及小规模数据量的ziplist时明显慢了许多。并且能看到,随着数据插入越来越多,插入的速度越来越慢,从数万左右的每秒请求数量慢慢下降到最后的几千每秒请求数量。在100000个元素时,内存占用约488kb,即每个元素约5.0字节,空间利用率约60%,可以看到,空间的占用几乎是线性的关系,并且空间利用率反而增加了一些。
在弹出数据时可以看到,速度越来越快,从1k左右上升到最终的数万每秒请求数量。
对于长字符串的插入,先插入100条:
1 | $ redis-benchmark -n 100 lpush mylist "mylist str len: 463. Redis is not a plain key-value store, it is actually a data structures server, supporting different kinds of values. What this means is that, while in traditional key-value stores you associated string keys to string values, in Redis the value is not limited to a simple string, but can also hold more complex data structures. The following is the list of all the data structures supported by Redis, which will be covered separately in this tutorial" |
可以看出,插入的时间明显比短字符串更多。插入后总共占用了47kb空间,即每个元素约481字节空间,空间利用率约96.3%。
再来看长字符串的批量插入(日志有删减):
1 | $ redis-benchmark -n 100000 lpush mylist "mylist str len: 463. Redis is not a plain key-value store, it is actually a data structures server, supporting different kinds of values. What this means is that, while in traditional key-value stores you associated string keys to string values, in Redis the value is not limited to a simple string, but can also hold more complex data structures. The following is the list of all the data structures supported by Redis, which will be covered separately in this tutorial" |
可以看到,在单个元素比较大时,插入、弹出ziplist会更加的耗时,但是内存总共占用45M,即单个元素占用约472字节内存,内存利用率达到98%。
从上面的试验可以看到,ziplist对空间的利用率非常高,在数据规模比较小时,耗时相对可接受,但是对于元素比较多或者是单个元素比较长时,插入、弹出的耗时非常大。而linkedlist在插入、删除元素时,元素数量、单个元素的长度对耗时影响小(耗时分布比较集中),但是空间利用率比较差,特别是数据规模较小时,空间利用率非常差。而quicklist结合了二者的优点,首先时间消耗上,数据规模对其影响小,其次是空间利用率,因为底层使用了ziplist,所以在小规模数据上空间表现也良好。
]]>嗯万万没想到,拖了一年采开始写的游记,写的过程也被我拖延了半年,终于结束双十一啦,可以把这个游记彻底撸完了。
到了青海,是的我们居然没去青海湖,惊不惊喜意不意外。早晨在KFC吃过早餐后发现商场有个卖书的活动,顺手买了本仓央嘉措诗传全集,毕竟刚从西藏回来,假装文艺一把。在格尔木时候发现以及出来半个月了,然后想开车回家后面车就不开回北京了,所以如果往东去青海湖玩,可能还得废掉几天,并且再开回成都比较麻烦,因此就打住了。看了下附件有个水上雅丹的地方,看几个月前的游记还能露营然后风景很好,所以决定去水上雅丹露营,带了一路,跟着我们翻山越岭的露营装备看上去终于能用上了。
决定好了就出发了,出格尔木不久,就进入大漠的感觉,路边很荒凉,但是路超级棒,而且还没啥车。在马路上各种角度各种拍,茫茫大漠就我们自己的感觉真的超级棒(嗯一会儿就知道多吓人了)。
继续开了不久,路边的大漠慢慢变了样,呈现出雅丹地貌的特征,大自然的鬼斧神工,很是漂亮。开到下午四点多,穿过了茫茫大漠中的一个锂电池工厂小镇,终于到了水上雅丹,结果居然围起来了在修建了,而且现在啥都没开放,只有个非常low的旅馆,并且工作人员态度非常不好(=@__@=)。当然不能就这么挨宰,怀着一丝希望,想着绕远点应该能找到没有围起来的地方,然而想多了。结果绕着绕着就绕了太远,前面是一片(咸水)湖,景色还不错的,路把湖水分成了两块,两个颜色还不一样,湖面不时飞过几只水鸟。这时候已经下午六点多了,决定不住在水上雅丹就得往回撤了,准备在前面路过的随便哪个地方露营一宿,感觉也挺棒。往回在水上雅丹外面觉得景还是很棒,把车上的小蜘蛛侠拿出来拍了几张继续往回开,不一会儿晚上八点半了,天开始黑了,路边停下来吃了个自热火锅(对的,从北京一路带来的自热火锅),边吃边和@xqq聊,今晚就这里搭帐篷了,感觉还不错。吃完饭天就黑了,不知道哪里传来声狼嚎,卧槽不行啊,两人开始打退堂鼓了。想想还是命要紧,感觉收拾收拾上车跑路吧。收拾完撤的时候才觉得这决定真的很明智,随着天黑下来,四处非常安静,我一个晚上都敢自己在乱坟堆乱窜的人居然怕黑了,感觉黑暗在往体内渗,瘆得慌。车上摇滚开到最大声,车窗紧闭,都不敢停下来上厕所,不敢换开,一路飙到德令哈。以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
过路费 | 19 | |
午饭 | 75.5 | |
过路费 | 48 | 锡铁山 |
加油 | 200 | |
加油 | 200 | |
过路费 | 29 | 饮水梁 |
过路费 | 29 | |
共计 | 600.5 |
接下来主要是赶路了,要开始虎头蛇尾流水账模式了。
德令哈住了一宿,和@xqq商量是时候撤啦。准备直接开回成都。刚好有个德马高速刚修通不久,准备走这条高速回去。
新高速的确新,没啥车,没啥人,服务区空的,路边居然还有野骆驼,超级原生态。
服务区没人也没吃的,所以大中午的,终于用上了带了一路的煮面套装神器了(其实还带着米啥的,然而煮粥得等太久了,所以煮面了)。
吃过泡面沿着这条高速跑了很久很久很久,也没看到加油站,异常心慌。随着油表上的读数越来越小,越来越慌,一路尽可能保持匀速,下坡也不太敢踩刹车。
以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
过路费 | 176 | 大武 |
加油 | 320 | |
过路费 | 84 | 久治 |
住宿 | 160 | |
共计 | 740 |
出发不久就进入大四川地界啦,从阿坝进川,在四川界附近就遇到一场雪,景色很美,南国的雪原来也能很美。
慢慢的,海拔越来越低,景色也越来越熟悉了。告别最后一片雪山,不久终于到都江堰了。晚上在都江堰吃了个鱼,真的超级棒,还在地图上留了个收藏,下次决定再去吃。以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
早餐 | 14 | |
加油 | 150 | |
水果 | 26 | |
午餐 | 52 | |
过路费 | 17 | |
晚餐 | 215.65 | |
都江堰门票 | 177.6 | |
共计 | 655.1 |
在都江堰玩了大半天,嗯就不写都江堰游记啦,放几张图片吧。
都江堰还是一如既往的气势磅礴,前段时间刷《大秦帝国》顺便了解了下李冰父子和都江堰,这次来又是不一样的感觉。是的又没去成青城山,上次和发小去都江堰,准备第二天去青城山,然而睡到了下午。这次真的是时间不够了,下午回乐山还得保养车,然后各种吃。车子在北京保养了一遍开出来的,开了这么远该保养了。
4s店依然很黑,宰了我一把。出了4s点我半小时就回去了,修车小哥一句咋又来了呢。嗯是的,出去刚上高速就被撞了,所以又回来了。不过还好,也就是反光镜掉了,漆磨掉了一些,其他啥事没有。
终于到拉萨了,昨天说了因为没有定到布达拉宫的票,所以最终也没有进布达拉宫,算是一大遗憾吧,当然了,这趟旅途遗憾可多了,比如也没能去珠穆朗玛,没有去羊湖,没有去阿里,以及前面说过的从雅鲁藏布大峡谷旁边经过也没有去看看等等~嗯有遗憾才更有兴趣下次再去(虽然并不太可能)。
Anyway,一早从酒店出发,玩到下午我们就又双叒叕启程了。@腿哥 他们预计今晚到拉萨,看上去是又要错过了,他们明天预计晚上到纳木错然而我们应该下午就从纳木错撤退,嗯友谊的小船不知道吹飞到哪里去了。
上午先去逛小昭寺,到了八廊街外面,以及震撼到不行。一大早广场上已经非常多的人(其实是中午了,这边天亮得太晚了,并且车停得非常远,走了半天),天气也很热,然而信徒们已经在聚集起来了,路上很多转着经筒默念经文的人,也有开始祭拜的人。
八廊街是一个繁华的商业街,小昭寺外的转经道上信徒们一圈一圈的转着,旁边是个广场,有很多执勤的武警。周围很多酒馆、手工作坊,也许还有当年仓央嘉措最爱的密宫玛吉阿米酒馆吧,是的我们今天也没去┓( ´∀` )┏在外面绕了一会儿就进入小昭寺了。我只知道这是当年松赞干布给文成公主修建的,里面非常的圣洁,居然还有释迦摩尼8岁等身像,以及保存很好的数千年来的一些物品,在里面听讲解了藏传佛教的历史以及藏传佛教中的一些故事,嗯又长见识了。在寺内殿中不允许拍照,所以就在外面室外拍了几张,嗯天气很好居然看到日晕了。小昭寺离布达拉宫不是太远,虽然没有买到门票,但是门口还是需要去打个卡的。一路走过去,顺便买了杯咖啡,气温不高但是非常热,停下来,一阵风过来又冷得慌。来了当然要拍游客照了,@xqq 已经早早的换好了50元人民币,到了布达拉宫广场上就迫不及待的开始拍了。
在广场旁边有个观景台,上面人蛮多,上去了一看才发现,这里才是人民币上布达拉宫的拍摄地,赶紧的排了几张照片。从观景台上下来快4点了,已经累成狗了,完全走不动,打了个滴滴去取车,然后,对的你没有看错,我们就返程啦!辛辛苦苦十天的路程,结果就玩了半天🤣。既然来的时候走的川藏,所以出藏准备走青藏,也算圆满吧(其实是想去青海湖玩玩,结果。。gg了)。出拉萨后路还不错,一路蛮平坦的,但是云层低到可怕。
晚上到当雄县,感觉跟一个镇没啥两样,找了个酒店,也和村里的差不多,非常破旧,。以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
大昭寺 | 186 | |
午饭 | 140 | |
咖啡 | 70 | |
住宿 | 125 | |
晚饭 | 80 | |
水 | 15 | |
共计 | 616 |
又是一早就起床了(真一早,七点多就起来了,相当于北京五点多就起床了),出酒店不远就转出大路往纳木错去了。一路上车不多,路也有的段正在修,但是景色真的贼美。
海拔比较高(4500m左右),所以一路上路边都是冰。翻过一个山头,就能远远的看见纳木错了,结果下了山头切身体会到望山跑死马这个词,开了好久还没看到湖。下山后是一块巨大的平地,满是牛羊,别有一番风味。翻过山后的路特别棒,拍了蛮多路的照片。再往前走了不远,到了纳木错旁边的类似湿地的一片小湖边,有很多放养的牦牛,景色很好,天也很蓝,水不深但是能倒影上旁边的雪山。纳木错太大了,本想沿着湖绕一圈,但是不太现实,所以准备沿湖走一段就撤回。沿湖不远发现有个类似旅游集散点的地方,原来这里才是真正的游客观景入口,我们却误打误撞跑去人烟稀少的边上和野牦牛拍了一堆美照。发现湖边空气很清新,看了下空气质量,PM2.4值居然是1,这估计是测量的下界了吧。纳木错不愧为圣洁雪域的圣湖,美得让人心醉。“那一年, 磕长头匍匐在山路, 不为觐见, 只为贴着你的温暖; 那一世, 转山转水转佛塔, 不为修来世, 只为途中与你相见”,仓央嘉措的两句诗,让这里变成人间圣地。然而因为时间关系,没能去看那念青唐拉神山过道的圣象天门,也没能去废弃的古寺,看那断壁残垣。很遗憾,但是留有念想。在回当雄上主路之前,终于趁着吃饭把车给洗了,这十多天来,车上糊满了的尘土。吃饭时候还遇到老板打电话训儿子,骂他不好好学习就知道贪玩,对不起自己和孩子他爸。下午开了一路平淡的公路,终于到达安多,感觉比当雄大一些,但是依然像个小镇。到安多的时候@腿哥 他们终于到当雄了。
以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
早餐 | 20 | |
纳木错门票 | 120 | |
停车费 | 5 | |
洗车 | 30 | |
午饭 | 63 | |
加油 | 210 | |
加油 | 180 | |
住宿 | 200 | |
共计 | 828 |
在高原呆了这么多天,今天终于下高原啦。然而因为两晚都在4000多米的地方睡(安多4800m,当雄4200m),和@xqq 两人都有点不舒服,原本以为是感冒了,结果翻过昆仑雪山后居然啥事也没有了,嗯居然高反了。然后我觉得是因为高反了所以我居然错过了唐古拉山口,开过去了才在地图上瞅到。
一早出发,结果走了不久就开始堵车。青藏路上大卡车比较多,一长串的大卡车,夹在大卡车中间感觉特别压抑,卡车司机们居然有的开始下车煮泡面闲聊了。我们堵了半个小时,发现小汽车再开始往回走,下车跑到路边一看,发现外面有一条小路可以通行,赶紧也跟着绕下来了。
绕下来发现好家伙,居然堵了好几公里,幸好没老老实实在上面守着。接下来一段路就比较平淡了,海拔挺高,天没有那么蓝了,路边看到青藏线了,然而一直没拍到好的照片了。慢慢的周围山也变少了,路边桥边开始出现一根根铁杆子,后来网上搜才发现,居然是因为冻土的原因,早知道拍几张照了。后来路上的卡车也变少了,跑很久才能看到一辆车。最关键的是,在出西藏北大门之前,到老沙家门之后,我居然还看到野鹿群了。不过通天河比想象中差太远了。本想跑过去多看看野鹿的,结果瞅见远处有个貌似是野牛的骨架,还听到狼嚎,狗命要紧感觉上车继续赶路。中午快到沱沱河了,这时候可能是全程离@腿哥 最近的时候,因为腿哥貌似回去的火车也离我们不远了。然后我终于拍到一张不错的火车照片了。在沱沱河吃完饭加油的时候,看到中国石油准备去,结果走进一看发现是中围石油,庆幸没上当,转头又碰到个中圆石油,跑不远还有家中因石油。。。在沱沱河之前是长江源头,过了沱沱河就是可可西里无人区,我居然还能离藏羚羊这么近,在无人区里看到了一群群的藏羚羊以及更多的野驴群🤣
过了可可西里就是最后一个山头啦,翻过昆仑山口就一路下高原了。昆仑山这个名字从小在各种电视剧中出现过无数次,当然路过要跑出去拍照啦,结果穿着短袖冲出去发现,外面居然在飘雪???刚从在可可西里不是短袖正好么???
下昆仑山的路一会儿飘雪一会儿下雨,云雾缭绕的,跟神仙道场一般,难怪这座山这么多故事。下昆仑的路海拔陡降,很快就从4700m降到3000m以下了,不舒服的感觉也一下子就消失了。然而在进入格尔木之前居然堵了估计能有俩小时,非常不开心。如果说这是一篇西藏游记,那么下高原了,这里算是告一段落了吧。
以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
加油 | 130 | |
午饭 | 82 | |
加油 | 180 | |
住宿 | 95 | |
晚饭 | 73 | |
共计 | 560 |
前面说了,从巴塘出发后,晚上的歇脚点非常不好找,沿途一天能到的城镇:芒康,海拔3875m,距离上看距离巴塘就100公里左右,太近了,排除;左贡,海拔3877m,也比较高,距离250公里左右,距离上看比较合适;邦达,海拔4120m,太高了,住宿一晚估计会高反,并且距离左贡还有100公里,比较远。综合考虑了下决定今晚在左贡住宿了,今天路途虽然不远但是一路都是翻山越岭,所以可能相对更累了。对了今天比较大的进展就是,终于进入西藏地界啦,但是有一个巨大的遗憾,就是经过金沙江大桥到达界牌的时候,我忘了拍照!!都怪@xqq 开太快没给我思考时间就过去了。赶紧拍了张车窗外的金沙江,嗯这个照片离四川-西藏的界牌大概有几米吧。
进入西藏地界后就开始烂路了,一长段的石子路,主要是金沙江开始涨水,冲毁了一部分路基,然后施工队也在赶工维修,有些段在旁边修加固的新路,所以现在进藏的话这段当时唯一存在的烂路应该也都没有了,整体路况应该都不错了。沿着金沙江走了接近两小时的烂路,一路走走停停,单向放行。路上碰到很多骑行的、徒步的、推小推车,甚至是遛狗进藏的人,很是热闹。走过了一段烂路就差不多中午了,刚好到了一个小镇(或者小县城吧,现在看应该是在芒康)吃了午饭,修整了一下就继续出发啦。明显能感觉到山开始变得越来越凸,然后云层也越来越低,景色也依旧非常美。一路上有羊啊牦牛啊就在路上散养着,在路上的牦牛还不让道,跟在它们屁股后面开了半天才空出个位置让我们超牛过去。天上是不是的出现一两只没见过的鸟,或者是疑似是老鹰的鸟(山里长大的我,连猫头鹰都逮家里养过,但是居然没见过老鹰)。不知道大家有没有发现,不管是在海拔五千多的山上,还是三四千的各种道路上的照片,都有各种高压线以及拖着高压线的铁架,在荒无人烟的高原上,这些铁架与线特别突兀,强大的基建狂魔真的很是厉害。再往前开就是东达山口了(或者是八觉山口),山上的景很不错,赶紧拍了几张。离左贡不远啦,慢悠悠的开到左贡。左贡感觉也是整个城(镇)都在新修,找酒店找了半天,酒店是个新店,设施也很高大上,房间窗户外有个高大上的窗户,山景也很不错。以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
加油 | 200 | |
午饭 | 65 | |
加油 | 190 | |
酒店 | 268 | |
晚餐 | 65 | |
共计 | 1267 |
嗯今天又拍了五六百张照片,这个游记写得心好累。下面是手机一些照片的截图。
从左贡出发,前面就是邦达(4120m)和八宿(3280m),虽然八宿海拔比较低,但是因为在左贡呆了一晚上发现也没啥异样,所以今天就再赶赶,干脆到然乌了,总共约280公里,行程也还好不算太紧张。今天的天气特别的好,之前几天云比较多,云层比较低,但是今天的天空特别晴朗,云也很少,车在四千多米海拔跑着,人也非常兴奋。窗外是起伏的群山,掺点着一些高原植物的深绿色,黄沙一样的土地上遍布牦牛群,路边时不时出现一辆事故车摆着提示过往车辆安全驾驶。随手一拍都是桌面级的美景(嗯我拍照太烂了可能桌面比较丑)
今天路上的汽车很少,骑行的队伍也基本没有了。看到一段特别棒的路赶紧停下车跑去拍照,又被@xqq 的全景拍下来。往前走发现旁边的山顶怪石嶙峋,光秃秃的没有植物,很是奇特,别有一番风景。下了一个雪山口,一路的回头线,然后就沿着一条河各种拐。前面看到有个关卡居然有卫兵把守,抬头一看原来这就是怒江了。过了怒江隧道,山突然变得威武了许多,非常的霸气,江水虽然不大但是流得很急,路有种从山里砸开的感觉,让我这种山里出来的人也觉得很震撼。沿着怒江开了不久就到然乌镇了,这个镇感觉是个新建的镇,真的是到处都在新修,很多酒店都是刚开始接待客人。我们今天住的酒店虽然刚开业不久,但是感觉异常的烂。在酒店放好行李趁着天色还早赶紧去然乌湖溜一圈。到了湖边车还没停好就和@xqq 一起情不自禁感慨一句,卧槽太漂亮了吧。这里还是一片没有开发的景,没有太多游客,没有景区大门,没有门票,有的只是湖边的玛尼堆,以及远处的雪山,还有一阵阵寒风忒冷。即使穿着冲锋衣也扛不住了,呆了半小时不到就赶紧躲回车里了。回到酒店,发现空调不能用,取暖居然只有电热毯,想想就瑟瑟发抖。以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
午饭 | 95 | |
加油 | 210 | |
晚饭 | 83 | |
住宿 | 150 | |
共计 | 538 |
今天从然乌需要开到林芝,需要开350多公里,任务还是比较重的。昨天去看的是然乌湖三大湖的其中一个,其实然乌湖还有两个湖,早晨从酒店出发不久就看到了,就在318国道旁边。然乌湖是一个堰塞湖,貌似是刚好是在喜马拉雅、唐古拉山的对撞处,湖边还有个冰川,然而因为今天赶路没法去看了。
再往前开了不远,发现路边的植物开始变得比较茂盛,山上也开始出现郁郁葱葱的绿色连着山顶的白雪。路两边也是非常茂盛的树,完全看不出来这是在高原上。其中好几段路还非常笔直,印象比较深刻的一段路开了七八公里没有弯道(嗯全靠有段视频),路上还看到好多云南牌的房车,大家都是开一段,找个观景台停一段拍拍照,非常惬意。后来才知道,原来沿着开了这么久的河携着然乌湖流出的雪山融化的水,最后会汇入雅鲁藏布江,最终流向印度洋。顺便提一嘴,今天路上有个岔路开出去不远就是雅鲁藏布江大拐弯,那里有个雅鲁藏布江大峡谷是世界第一大峡谷,然而,我们纠结了一番没有拐弯,直奔林芝了,悔死。再往前到了追龙沟特大桥,@xqq 说之前这里没有桥和隧道的时候需要走一段盘山路,这里也被称为川藏路上的“天险”,之前这里垮塌的通麦大桥也被称为通麦坟场。而如今的追龙沟特大桥看上去非常的坚固,悬崖上也明显能看到防止塌方用的围栏。桥身是汹涌的河水,可惜我没有拍到。
过了隧道之后的一长段路,植被依然很丰富,路也修得不错。但是这边太多的区间测速,所以每到一个区间测速终点,就能看到一大堆车停路边拍照啥的等时间。还好我们比较怂严格按照限速来,区间测速终点也就直接过去了(嗯一路上我都怕路边有雷达测速,怕掉坑,但是实际上好像并没有)。再往前就开始翻色季拉山了,这是到林芝的最后一个雪山了。能明显的感觉到这个山口的景色更加漂亮,非常壮观。在山口修整了一小会儿,@xqq 去上厕所了,一个小女孩跑过来要吃的,我就随便给了点小零食打发走了,结果@xqq 回来说我怎么没给点其他吃的,太没同情心了,然后跑后备箱抓了几包泡面啥的跑过去给她了。下山不久就到林芝了,之前听说过这个地方很多次,没想到现在就在这个城市了。这里蛮大的,在路上跑了这么久,终于感觉到进城了。晚上和@xqq 跑去吃了下特色的石锅鸡,还挺好吃的。出门特意没开车步行去的饭店,吃完饭逛街走回来,好久没有踏踏实实的在马路上走过路了。
回到酒店突然发现,即使涂满了防晒的情况下,好像手心手背开始不是一个色号了,发了个朋友圈感慨了下还被插刀。看了下轨迹,3600多公里啦。到了林芝,明天终于能到拉萨了。
btw. @腿哥 他们今天已经浪完珠穆朗玛大本营往回走了,不知道路上能不能碰上了。以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
加油 | 60 | |
加油 | 150 | |
晚饭 | 174 | |
住宿 | 149 | |
共计 | 533 |
终于,今天能到拉萨了。一早起来就出发啦,直接上拉林高速,传说中的西藏最美高速,其实也不算高速了,因为居然不收费,感觉是一条高大上的快速路。具体的图片可以百度搜一下,非常漂亮。嗯这趟旅程让我非常的后悔没有买个无人机,很多景都没法拍出来了。当时走的时候整条路还没有通,中间还有几条隧道正在挖掘,但是大部分都可以通车了。路上整体车不多。
中午左右开到米拉山口,随便吃了点东西,这里拍照的人就很多了。下午四点不到就进拉萨城了。第一感觉就是,这个城市真的超级小,这是去过的最小的省会城市了。但是整个城市笼罩着一种充满信仰的感觉。酒店窗户正对着布达拉宫,然而距离比较远,相机的套头镜头根本拍不下来,但是晚上的布达拉宫真的很漂亮,然而和@xqq 一致同意去看场电影(已经忘记看了啥了)然后早点休息第二天好好浪。下午@腿哥 说布达拉宫需要提前预约,然而发现辛辛苦苦通过网站坑爹的验证登录进去后,根本没票了。嗯佛系旅游才不去打卡。对了忘了说,晚上八点十五分,天还没黑,感觉至少八点半了天还亮着,下图是八点十五拍的一张照片。名称 | 金额 | 备注-|-|-早餐 | 40晚餐 | 66电影 | 58住宿 | 170共计 | 334]]>这是一篇拖延了整整一年的游记。上次写到从下决心到到达成都的过程(两人一车,说走就走的西藏自驾游(1):西藏行之起源),接下来就走上318了。btw,虽然忙完618但是不知道为嘛还是忙成狗,周末感觉得撸一篇不然感觉就要鸽掉了。
在成都休息了一下后,算是正式踏上了进藏的路了(嗯睡了个自然醒)。第一件事就是被师姐提醒,我貌似有麻烦了?(垃圾iCloud照片死活加载不出来,开场就折腾我半天)
昨天晚上纠结半天要不要去乐山去驻扎一晚,顺便偷偷溜回家一趟。因为之前想着的行程是青藏出藏,然后就一路开回北京,这次不回家的话估计得等我毕业完才能回去了。但是现在看来,貌似行程允许后面出藏后开回来一趟,因此就先定直接进藏了。昨晚计划好今天到海螺沟,今晚到明天上午玩,下午开始赶路,因此并不着急,因此十一点五十了刚出门还在成都吃午饭。吃完饭就出发啦。从成雅高速上高速,在高速在乐山-雅安的分路三叉路口还感慨了一下,半年没回家的我居然要绕开不会去啦(可惜只是因为浪)。在雅安拐上了修了一半的雅康高速,走了一大段车机上没有的路(这坚定了我要升级车机系统换导航的想法,恰好这几天车友会中大家说新的车机系统就要放出来了,看样子我得路上升级一把了,幸好我也不知道为啥我进藏会带个U盘)。经过了传说中的二郎山隧道,@xqq 给我科(de)普(se)了半天,我只发现隧道里面居然还有全景LED,非常的高大上,以至于我还跑去行车记录仪上下载了个照片。下高速后就是下面这样的路了,嗯某@xqq 又开始激动了。回头看一眼正在修建的雅康高速,不得不感慨基建狂魔的厉害,嗯我貌似又没有拍出来那种震撼的感觉,客官们自己脑补吧。过了大渡河大桥,从泸定县绕了一下,过了大渡河大桥就看到“海螺沟欢迎您”的标语了。到了海螺沟入口的那个镇上(貌似是叫磨西镇)的时候已经比较晚了,然后在路上的时候就定了个酒店,所以就从景区大门旁边的路直接开去酒店了。这个酒店感觉大部分是木制的,踏上去咯吱咯吱响,然后最近不是旅游旺季,所以当晚就我们俩和老板娘她母亲在酒店,我们住三楼。房间阳台上可以直接去隔壁房间,然后阳台门还坏了,那天晚上打雷下雨,嗯我一点都没害怕(才怪)。不过酒店里做的家常菜不错,吃得很开心。和@xqq随便逛了下周围,快下雨了就溜回酒店了,我趁机解决那件头疼的事——打开。嗯万幸我带了电脑,然后,bingo,就在海螺沟上打了个香山旁边的卡,妈妈再也不担心我翘班啦!然后开心的高速师姐我搞定这件事了,然后就被师姐给扼杀在摇篮里了,多谢师姐让我能继续做个诚实的好孩子🤣。嗯,就这样,放心大胆的开始浪了,明天打卡海螺沟。以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
早餐 | 67 | |
停车 | 9 | |
门票 | 260 | 海螺沟 |
吃饭 | 85 | 晚饭+第二天早饭 |
住宿 | 128 | |
索道 | 300 | |
吃饭 | 65 | |
加油 | 230 | |
共计 | 1144 |
因为昨天让酒店帮忙订了今天去海螺沟的票,包含大巴,所以一大早起来吃了饭就卡着点在酒店门口坐大巴去景区啦。在景区发现可以多交几十块钱,然后以后能免费来(还是半价,记不清了),在@xqq 阻止下我成功没有入坑,嗯虽然这个景区还不错,但是的确半辈子都不会再来了。
刚进景区,空气非常棒,景色也很棒,然后登山步道给人一种九寨的感觉(嗯大夏天的越往上爬越冷。。这点非常像)。
登山步道沿路都是郁郁葱葱的树林,远处是斑驳的雪山(嗯我居然用“斑驳”形容雪山,对不起各位语文老师),呼吸着湿润而又清新的空气,但是贼累,并且走着热停下来冷。走了一个多小时前面一个大大的山谷里黑黢黢的,居然就是冰川?(这里是黑人问号脸)?好的先不吐槽冰川,一会儿说。在冰川山谷的边上筹到个监测站,看到是中科院的监测站,居然会莫名其妙的亲切。对的这一大坨跟千层雪一样的冻泥就是传说中的存在了数十万年的冰川,和想象中的完全不一样好吧。
在冰川(还是愿意叫它冰泥)旁边(貌似是一号营地)玩了一会儿太冷了,我们就回头去坐缆车准备上三号营地从上往下看冰川了。缆车上看冰川感觉挺酷,能看到悬崖数十万年间被冰川磨平的痕迹。
以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
晚饭 | 60 | |
住宿 | 198 | |
共计 | 258 | 午饭昨天付了,没计入今天 |
终于把手机上的照片大部分都移动到nas上了,再也不用忍受iCloud那龟速加载了
忘了说,新都桥街道在翻修,所以小镇里全是坑坑洼洼的,昨晚比较晚了一路开到酒店,早晨起来从酒店看了下,感觉整个小镇都在修。
从今天开始貌似就得不停的赶路了。现在照片都在nas上可以清楚看到了,这一天两个手机一个相机拍了460多张照片,之前其实每天也都拍了这么多,所以更新贼慢。新都桥海拔3600多米,昨晚吃完饭我出去走了走,然后@xqq 跟着走了一路就不行了,回酒店就开始吸氧🤣。早晨汽车发现背后的山上还有新都桥欢饮您的字样(瞎猜的),云不错山也很好看。刚出发时,发现路上骑行的人不太多,走了十几公里才发现骑行的大部队,好吧,虽然我们八点半就出发七点多就起床了,但是人家才叫一早起来。这边路不宽,双向一共两个车道,但是大家车速都不太快,因为骑行的人比较多所以开车也会特别注意,所以相对来说还算比较安全。在路上还顺便作死了一小下下。@xqq 开着的时候我没啥事手开始犯贱,然后想着带着U盘的就顺手在副驾上升级了把车机,从此就再也不用那坑爹的车机上八百年不更新的离线地图了,能用上高德了。一路上景色很美,我居然想用“鲜”来形容。大概十点多的时候,发现开始各种回头线爬山了(此处同情骑行的人们),感觉这个回头线能赶上进九寨前的那十多道弯了。爬到山上一看,嗯的确是山路十八弯。
山顶上是一个观景台,风景非常棒,然后有个“日照金山”的石碑,原来贡嘎雪山的日照金山需要在这里看,然而十点多了啥也没看到,不过能隐约看到贡嘎雪山。然后观景台上居然有卖烤肠的,@xqq开心得不行(看到这里我发现,我居然记漏了烤肠的钱!)。随便吃了点零食就继续赶路啦。在翻过4400多米的卡子拉山口后,遇到一段极其平坦,风景特别好的路,赶紧靠边开始拍照。对了这里补一句,在理塘吃午饭时候遇到一辆山东车,一辆北京车,山东车是一个七十多岁的大爷,自己开过来跑川藏线,和我们聊了一会儿,感觉贼霸气,希望我七十岁也能继续这么浪;北京车是一家三口,去准备分路去亚丁,老人大概50多的样子,很和蔼的一家。路边有个佛塔,路两旁就是雪山,而且雪线就在比路高不了多少的地方,简直棒呆。嗯这里也是见识太短了,后来发现,上了高原这种地方简直不要太多。不过雪山旁边的佛塔,然后周围还没有人眼,还是很有韵味的。下午三点多的时候下坡了很长一段路,到了一个叫做姊妹湖的高原湖,景色还行,就是有点冷,没有多停留,拍了几张游客照就撤了。继续赶路,因为从新都桥(3630m)出发,下一个合理的住宿的地方就是巴塘(2580m),这样海拔都不高,避免高反(后面下高原最后两晚都在4000m以上的地方住宿,我和@xqq就都有些高反了),但是巴塘出发就惨了,后面就都是3000m+了。路边到处是事故车,就被摆放在路边作为警示作用,山也慢慢变得更有特点了。大概下午七点半到达巴塘城外的一个小酒店,是个农家院,有三四层楼,每层有六七个房间的样子,老板非常的热情,给我们介绍当地的人文地理,推荐下一站应该在哪里歇脚,以及需要注意什么,最后还送了我们一张自驾地图,很实用。嗯他家的酥油茶也超好喝。btw. 七点半了太阳还在天上,时差能非常明显的感觉到了。以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
加油 | 260 | |
酒店 | 134 | |
午饭 | 60 | 补记,大概60,具体金额忘记了 |
晚饭 | 85 | |
共计 | 539 |
这是一篇拖延了整整一年的游记,这一年来一直牵挂着它,再不写下来它就要难产了。
最近有朋友想自驾西藏,然后刚好去年这几天我们正在高原上浪着,借着还没忘记一些东西,所以先写下来,补记一下当年的自由生活。再注明一下:1. 本文发生在18年;2. 本文所有图片未添加滤镜,均为手机/相机直出相片截图(不然图片太大本文打开可能比较费劲,照片都尽可能的截图+拼接了)。
嗯本来准备一口气写完,结果gg了我觉得得花小半个月写这玩意儿,果然当时拖延是有原因的。
大概说一下背景,5月11日晚上忘了为啥@腿哥 突然请客,然后说要抛弃我们几个,跟着本科同学去西藏租车自驾。其实我和@xqq 一直想着抽空去西藏,所以想要乘机一起去浪,晚上回宿舍后就和@xqq 刷机票,准备跟@腿哥 一起去浪。在机票下单前几秒,突然觉得自驾去西藏也行比在西藏自驾更酷,于是11号晚上睡前临时决定:走,自驾去西藏!12日睡到中午,两人赶紧的爬下床开始各种搜攻略,318国道安全吗,去西藏需要准备什么,高原反应要死人吗,无人区有狼吗,汽车常见故障及维修宝典,第一次自驾进藏走川藏线还是青藏线。反正就是一阵狂搜,然后晚上去物美买了一堆零食,在迪卡侬备了些乱七八糟的东西(帐篷、地垫、隔热垫、充气床、睡袋、充气枕头啥的都有,所以就买了些简单的露营灯这种),在京东上下单了一堆还缺的东西(血氧仪、葡萄糖、红景天、多功能逃生手链、野营套锅、各种袋装绿豆汤红豆汤啥的),再找@达叔 借上野用燃气灶、气罐、兵工铲、拖车绳这些,13号一早就出发啦!
物品名称 | 作用 | 价格 | 备注 |
---|---|---|---|
血氧仪 | 用来看是否发生高反 | 58 | 最开始一直测感觉都正常,因为开始并没有高反,后来忘记用了吃灰了 |
红景天 | 提高血氧含量 | 95 | 感觉没啥鸟用,吃了几天就没吃过了,可能没有提前吃吧 |
葡萄糖 | 补充能量 | 34.9 | 高反时候充点葡萄糖水会稍微好一些 |
多功能逃生手链 | 野外紧急情况使用,有打火石、齿刀、口哨,绳子可拆 | 20 | 鬼知道我内心戏里都发生了什么乱七八糟的事,实际上这玩意儿并没有用到 |
野营套锅 | 野外煮东西 | 80 | 这锅最终在“无人区”煮泡面了,很好用 |
袋装绿豆汤红豆汤 | 食品,野外可食用 | 40 | 万一在无人区车坏了能顶一阵儿,实际上刚到杭州租好房没啥吃的煮了几次 |
帐篷及配套 | 露营 | 并没用到,现在这些东西还在后备箱里面呆着的 | |
燃气灶 | 煮东西,与野营套锅配套 | 还需要气罐,不然是废铁 | |
兵工铲 | ┑( ̄Д  ̄)┍我最开始想的是需要用来打狼的 | ||
拖车绳、备胎、应急搭电线等修车工具 | |||
这里说一下,最后选择走川藏线进藏,因为川藏线海拔不断起伏,对于第一次进藏的人来说可能更容易适应高海拔,而青藏线海拔一直很高,容易发生高反。 |
一大早就出发啦,塞满后备箱以及后排,然后快出六环了发现忘记记录里程了,感觉拍张照记一下,想着等开回来看看走了多远,然而没想到,最终这辆车没机会开到北京而是停在了杭州(估计得等换车的时候它才再有机会回北京了)。
出发的第一天,其实还有很多东西并没有准备好,比如忘买氧气瓶了,这时候发现京东当日达很6啊,买了几瓶氧气,然后@xqq 看攻略买了一堆我觉得没有卵用的东西,当日达送到第一天晚上的酒店。物品名称 | 作用 | 价格 | 备注 |
---|---|---|---|
氧气瓶 | 补充氧气 | 79 | 最开始我觉得我这种4k+海拔上还能蹦蹦跳跳的人肯定用不上,后来发现真香 |
善存片、洋参含片 | 补充维生素,抗疲劳? | 142 | |
耳温枪 | 测体温 | 110 | 带得有一些感冒药,但是发现忘带体温计了 |
中午忘记吃什么了(貌似随便吃了点泡面和零食?),晚上我自己在服务区吃了顿自助餐,居然才35,刚从北京出来的我感觉真的是良心价啊(现在的我看着这个价格想着当年的心情突然觉得我现在真的穷成狗了),而且看上去以及吃上去还不错。而@xqq 在高速上没饿,到运城吃了顿貌似是啥砂锅,查了下账单是28,我印象中好像很不错的样子。
大概晚上七点半下高速到运城(我居然下高速还拍了张etc收费的照片),然后到了运城去的第一个地方居然是小米之家,嗯然后这个习惯莫名其妙保持到了现在,我去一个城市居然会先去转转这个城市的小米之家。在小米之家买了个逆变器,用来给相机、电脑等供电,然后买了个指尖积木,嗯就是下图这货,对于轮班坐副驾时的手贱晚期的我来说这是整个旅程性价比最高的东西🤣。第一天全程高速,两个人换着开,从早晨七点半左右到晚上七点半,12小时,中午、下午在服务器休息了会儿,总共应该开了900km左右,这也差不多是全程单日行程最多的一天了。这是下午六点半左右的手机截图。以下是这一天的花费:名称 | 金额 | 备注 |
---|---|---|
保险 | 286 | |
高速 | 14.5 | 北京 |
早餐 | 34 | |
高速 | 104.5 | 河北 |
加油 | 245 | |
午饭 | 30 | |
晚饭 | 35 | |
高速 | 185.25 | 运城 |
酒店 | 139 | 运城 |
晚饭 | 28 | 运城 |
西瓜 | 18 | |
加油 | 205.18 | |
共计 | 1324.43 |
一大早出发啦,然而今天显然没有昨天那么激动了(嗯我还记得这个)。一路上路挺好风景挺不错,从风陵渡黄河大桥过黄河(照片带GPS水印真是方便我等脑残换着)进入陕西地界,完成了全程唯一一次被交警拦下的记录,一定是@xqq 太像坏人了。
进入陕西地界后很快到了华山服务区,隔着服务区远远的看了几眼。上次到西安因为时(jin)间(jin)不(shi)足(lan),想着以后有的是机会就没去爬华山了,这次路过想着之后跑完西藏回程去爬,结果,至今我没有去过华山。从西安边上绕过后很快就开始穿秦岭了,地图上看见一层厚厚的山,一大波隧道连着隧道,幸好之前开过重庆的各种隧道,觉得没啥,某@xqq 倒是激动了半天,然后就开得困得不行。传出秦岭隧道后马上就是一个服务区,赶紧的停下来休息吃饭,本乡巴佬第一次见服务区居然有KFC,还兴奋的拍了照,结果发现江浙一带这简直再平常不过了。。。北方的服务区啊,你们好好学学吧。最开始过了秦岭赶紧就快到成都了,结果发现我想多了,穿过了一堆又一堆的山,终于才差不多平坦了。我大四川的云真好看。晚上到广元,准备开始此次旅程的第一次浪,去剑门关玩一趟。在入住酒店前收到个惊天大消息,实验室要求打卡了,我这毕业前一个月还能赶上,真是惨,嗯得想想办法。以下是这一天的花费:名称 | 金额 | 备注 |
---|---|---|
加油 | 70 | |
高速 | 28.5 | 运城 |
高速 | 90 | 陕西 |
早饭 | 44 | 运城 |
午饭 | 79 | 秦岭KFC |
油 | 225 | 加油 |
高速 | 200 | 广元 |
晚餐 | 50 | |
高速 | 49.4 | 剑阁 |
眼药水 | 33 | |
酒店 | 189 | 剑阁 |
共计 | 1057.9 |
作为四川人,“噫吁嚱,危乎高哉”、“剑阁峥嵘而崔嵬,一夫当关,万夫莫开”背了这么多年,居然第一次来剑门关,太惭愧了。整个景区超级大,因为今天计划到成都,所以只有小半天的时间玩,因此景区只走了一小部分,并且到了下图打问号的地方我就失忆了,忘了怎么出来的了┑( ̄Д  ̄)┍
从剑门关古镇进景区就是类似栈道的路,不是特别难走,栈道也修得很好,植物长得很茂盛,全程我巨怕窜出一条蛇来。那天慢热的,背着单反走走得巨累,结果发现叫累叫得太早了。到了关楼我以为差不多了,结果发现走了那天全程的不到一小半。关楼超有气势,一边靠山,一边靠水,山很险水很急,难怪能一夫当关,万夫莫开。但是原谅我累(tai)崩(cai)了没能拍出来气势。过了关楼往北想去看一线天,体会什么叫累(我觉得就是这么累断片了导致我忘了怎么出去的了)。虽然感觉有山的景区都有类似一线天的景点,但是这里比其他地方更突出那个“一线”的感觉,当然也可能是我最近长胖了觉得挤过去比较费劲吧。在费劲往上爬的时候发现居然还有一条“攀岩线”,就这条路都吓得腿软的@xqq 估计是不敢去了。嗯再往后就累得不行都懒得拍照了,一路就想赶紧出去豁冰阔乐。下面是当时手机上做的一段视频。出去后在古镇上随便吃了点东西就往成都去了,顺带带着@xqq 去逛了趟川大,本科四年的日子历历在目,一晃眼硕士也走到尽头了,很是感慨。晚上和@xqq 去街边小馆吃了点地道的家常川菜。
我擦放图片好累啊
看账本貌似记起来,后来是坐观光车出来的,我就说咋没啥印象了
那啥,成都下高速貌似etc设备问题没有扣钱,开心
以下是这一天的花费:
名称 | 金额 | 备注 |
---|---|---|
剑门关门票 | 220 | |
观光车 | 10 | |
可乐 | 8 | |
观光车 | 20 | |
公交车 | 10 | |
午餐 | 93 | |
加油 | 235 | 德阳 |
晚饭 | 78 | 成都 |
水果 | 23.8 | |
饮料 | 11.5 | |
住宿 | 238 | 成都 |
共计 | 837.3 |
发现尽管我图片压缩得够厉害了,一篇还是好几兆的图片,并且感觉后面的行程还有好多,所以我决定拆分一下,这一篇先写这么多,希望后面的不放鸽子。。。
]]>前段时间改造链路,发现链路上可能会出现丢掉threadlocal中数据的情况,排查发现是在执行parallelStream后出现了丢标,并且这个丢标是偶发的。
1 | public static void main(String[] args) { |
多次执行后发现,parallelStream之后的标出现偶发性的被吞。(注:这里因为使用了部分集团内部组件,因此使用threadLocal替代)
Stream(流)是JDK8中引入的一种类似与迭代器(Iterator)的单向迭代访问数据的工具。ParallelStream则是并行的流,它通过Fork/Join 框架(JSR166y)来拆分任务,加速流的处理过程。最开始接触parallelStream很容易把其当做一个普通的线程池使用,因此也出现了上面提到的开始的时候打标,结束的时候去掉标的动作。
ForkJoinPool是在Java 7中引入了一种新的线程池,其简单类图如下:
可以看到ForkJoinPool是ExecutorService的实现类,是一种线程池。创建了ForkJoinPool实例之后,可以通过调用submit(ForkJoinTask1 | public static void main(String[] args) { |
得到结果:
1 | [main] |
可以看出,串行的流使用的是main线程,而parallelStream使用了线程名为ForkJoinPool.commonPool-worker-*
的线程,而这些线程来自于:
从上面可以看出,commonPool的线程数量默认会使用处理器数量减去1,比如我的电脑是八核(其实是八线程)的,其实commonPool的线程池是7个线程,这个通过打印ForkJoinPool.getCommonPoolParallelism()
也能看出,这样做是因为还有一个主线程,主线程加上线程池线程刚好等于cpu核心数,这样能同时跑满cpu,并且不会因为线程太多造成线程本身的切换浪费资源,这样最有效率。
回过头来看上面代码的输出:[ForkJoinPool.commonPool-worker-7, ForkJoinPool.commonPool-worker-1, ForkJoinPool.commonPool-worker-2, main, ForkJoinPool.commonPool-worker-5, ForkJoinPool.commonPool-worker-6, ForkJoinPool.commonPool-worker-3, ForkJoinPool.commonPool-worker-4]
,发现main也参与了ParallelStream中的计算。这是因为forEach将执行forEach本身的线程也被当作为线程池中的一个工作线程进行工作,因此使用刚好等于cpu核心数的线程来执行了多个任务。
因此,前面说的丢标的问题也得到解决,因为ParallelStream任务执行时,可能将main线程作为执行线程,因此如果在forEach中清标,可能会导致主线程中的标被丢掉。解决的方式也很简单,在执行完并行流之后,重新set一下标即可。
对CPU密集型的任务来说,并行流使用ForkJoinPool,为每个CPU分配一个任务,这是非常有效率的,但是如果任务不是CPU密集的,而是I/O密集的,并且任务数相对线程数比较大,那么直接用ParallelStream并不是很好的选择。如下代码:
1 | public static void main(String[] args) { |
发现其执行时长为20秒,但是如下代码:
1 | public class Test { |
执行可以发现,10秒可以执行完毕。这是因为第一份代码中,每次提交8个任务到commonPool,提交了两次,第二次的任务得等第一次执行完毕后才能执行,并且主线程也被阻塞。而第二次,使用独立的ForkJoinPool来执行线程,没有影响主线程的执行,如果在每个任务中打印一下线程名字也能看出来:
1 | ForkJoinPool-2-worker-1 |
请看下面的代码
1 | public class Test { |
执行完后,发现parallelStorage中居然出现了null:
1 | null 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50 52 54 56 58 62 64 66 68 70 72 74 76 78 80 82 84 86 88 90 92 94 96 98 |
这是因为在ArrayList中存储数据的过程不是一个线程安全的过程导致的。因此使用ParallelStream时要注意这点。
勿在浮沙筑高台。一些常用的东西,底层的设计还是很巧妙的,而这些巧妙间却埋了不少的坑,当然踩坑还是应为CURD虽然撸得多但是对实现不了解,需要多去了解底层。
]]>曾几何时,每次建库都选utf8,觉得自己比那些用乱七八糟编码的人不知道酷到哪里去了。直到好多年前的某次课程设计做项目的时候,愉快的建了个用户表:
1 | CREATE TABLE `test_user` ( |
然后愉快的新增用户:INSERT INTO test_user(
name) VALUES("我是😁")
,接着愉快的反思人生:
1 | Incorrect string value: '\xF0\x9F\x98\x81' for column 'name' at row 1 |
我是谁?我来自哪里?我在干嘛?难道是我代码里面的字符集用错了?不对啊我所有地方都用的utf8啊……
首先来看官方文档:
The character set named utf8 uses a maximum of three bytes per character and contains only BMP characters. The utf8mb4 character set uses a maximum of four bytes per character supports supplementary characters:
For a BMP character, utf8 and utf8mb4 have identical storage characteristics: same code values, same encoding, same length.
For a supplementary character, utf8 cannot store the character at all, whereas utf8mb4 requires four bytes to store it. Because utf8 cannot store the character at all, you have no supplementary characters in utf8 columns and need not worry about converting characters or losing data when upgrading utf8 data from older versions of MySQL.
我们再看看维基百科对UTF8编码的解释:
可以看出,MySQL中的utf8实质上不是标准的UTF8。MySQL中,utf8对每个字符最多使用三个字节来表示,所以一些emoji甚至是一些生僻汉字就存不下来了,比如“𡋾”。UTF-8 is a variable width character encoding capable of encoding all 1,112,064 valid code points in Unicode using one to four 8-bit bytes.
MySQL一直不承认这是一个bug,他们在2010年发布了“utf8mb4”字符集来绕过这个问题,在MySQL中,utf8mb4才应该是标准的utf8编码,并且官方很鸡贼的偷偷在最新的文档中加上了,算是认识到错误了吧:
utf8 is an alias for the utf8mb3 character set.
The utf8mb3 character set will be replaced by utf8mb4 in some future MySQL version. Although utf8 is currently an alias for utf8mb3, at that point utf8 will become a reference to utf8mb4. To avoid ambiguity about the meaning of utf8, consider specifying utf8mb4 explicitly for character set references instead of utf8.
MySQL从4.1版本开始支持utf8,即2003年,但是现在的utf8标准(RFC 3629)是在其后发布的。MySQL在2002年3月28日的4.1预览版中使用了旧版的utf8标准(RFC 2279),该标准最多支持每个字符6个字节,同年9月MySQL调整其utf8字符集最多支持3字节,而这个调整可能只是为了优化空间(05年前推荐使用CHAR类字段,而一个utf8的CHAR将会占用6字节长度)和时间性能(05年前在MySQL中使用CHAR字段会有更优的速度)。嗯可以在GitHub中看到大家对这个坑的吐槽:
但是这个字符编码发布出来,就不能轻易的修改,因为如果已经有用户开始使用了,就需要这些用户重新构建其数据库。
怎么补救呢?在上面最新文档中可以看出,他们将当前的utf8作为utf8mb3的别名,并且在将来的某一天会把utf8重新作为utf8mb4别名,这样来解决这个多年的巨坑。
略
字符除了存储,还需要排序或者比较,这个操作与编码字符集有关,称为collation,与utf8mb4对应的是utf8mb4_unicode_ci 和 utf8mb4_general_ci这两个collation。
utf8mb4_unicode_ci 是基于标准Unicode来进行排序比较的,能保持在各个语言之间的精确排序;
utf8mb4_general_ci 并不基于Unicode排序规则,因此在某些特殊语言或者字符上的排序结果可能不是所期望的。
utf8mb4_general_ci 在比较和排序时更快,因为其实现了一些性能更好的操作,但是在现代服务器上,这种性能提升几乎可以忽略不计。
utf8mb4_unicode_ci 使用Unicode的规则进行排序和比较,其排序规则为了处理一些特殊字符,实现更加复杂。
现在基本没有理由继续使用utf8mb4_general_ci了,因为其带来的性能差异很小,远不如更好的数据设计,比如使用索引等等。
备份,不然崩了就只有删库跑路了;
升级MySQL服务端到5.3.3及以上版本,以支持utf8md4;
将数据库、表、列的字符编码、collation改为utf8md4:
1 | # For each database: |
检查列和索引键的最大长度;
修改连接、客户端、服务端的字符集;
修复和优化所有的表,以免出现一些莫名其妙的错误,可以使用如下的方式:
1 | # For each table |
或者是使用mysqlcheck
工具:
1 | $ mysqlcheck -u root -p --auto-repair --optimize --all-databases |
拨开云雾见天日 守得云开见月明
终于把毕设撸完了,我的毕设刚好值10斤肉。赶论文这段时间无比的想撸代码,第一版写完赶紧的写了一晚上 bug,愉快的熬到了半夜。效果见本站左上角,后端调用了韩寒的“一个”,每天都是不一样的话,后端主要是 Spring 那一套。计划最近写一些常用的接口并且开放出来。
在过程中遇到一个坑,尽管用了@CrossOrigin
注解,但是返回内容依然没有Access-Control-Allow-Origin头部,postman上没有(之前写 PHP,在 nginx 中直接对接口调用加上了这个头,所以。。。嗯是我太菜),ajax去访问也没有(这里是我自己脑抽了,后面讲),导致前端无法跨域访问,但是神奇的是,如果任务起在我电脑上,同一个局域网上室友的电脑上 postman 返回正常,微笑脸。。。嗯情况大概就是这样,然后这个 bug 调了我一晚。
这里首先说一下CORS,CORS是一个 W3C的标准,全称是跨域资源共享(Cross-origin resource sharing)。CORS需要服务器返回的数据头部包含相应的信息,CORS在客户端是浏览器自动支持的(IE 走开),如果浏览器收到服务器返回数据中包含了对应的头部,则可以使用其数据。对于简单请求(HEAD、GET、POST 方法之一,并且请求的头部不能超出如Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type这几个字段,并且Content-Type只限于application/x-www-form-urlencoded、multipart/form-data、text/plain这三个值),浏览器会自动在请求中增加一个 Origin
字段,对于非简单请求,浏览器会提前进行一次“预检”请求(请求方法为OPTIONS)查询服务器一些信息,然后再发送加上了Origin
字段的正常请求。比如发送一个PUT
请求,浏览器首先发送“预检”请求:
1 | OPTIONS /cors |
“预检”请求用来向服务器确认,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的请求。Access-Control-Request-Method
用来询问是否支持该方法,Access-Control-Request-Headers
用来指定浏览器CORS请求会额外发送的头信息字段。如果服务器确认了请求,则做出回应:
1 | 200 OK |
其中,Access-Control-Allow-Origin
即为允许访问的域名,可以为*或者域名,Access-Control-Allow-Methods
表示允许跨域的方法,Access-Control-Allow-Headers
为允许跨域需要的头部。
在预检通过之后,会发送正常的PUT
请求,并且在头部自动添加Origin
字段,服务器对这个请求需要校验是否含有Access-Control-Allow-Headers
标注的头部,是否是允许的方法以及来源域名,如果都通过则返回会带上Access-Control-Allow-Origin
,这时候浏览器才能使用。
在CORS的文档中可以看到这样一句话:
Server-side applications are enabled to discover that an HTTP request was deemed a cross-origin request by the user agent, through the Origin header.
这是第一个坑,当然这是我自己的问题,因为之前写PHP时候,为了方便,直接在通过Nginx代理中加上一个Access-Control-Allow-Origin
头部,而 Spring Boot 对于 CORS 是按照标准来的,这里后面会说。
前面说了,浏览器在跨域访问时,会自动加上一个Origin
头部来标注来源,这也是后端识别跨域访问的判定标准。而我在写 js 时(嗯我就是前端渣,不然咋会掉坑),域名没有加上协议,即 https://
或者//
,尽管浏览器给了我提示,然而我瞎了,而且还认为是浏览器 bug 换了一堆浏览器。
这下来了让我当时无比懵逼的一个坑了,就是我的 Postman 是 app 版本(Mac)的,我室友的是 Chrome 的应用版,而Chrome 应用版本的Postman在请求时,会加上一个Origin
字段,而 app 版的不会,因此这也算为啥我室友电脑上没事╭(╯^╰)╮
Spring 中,检查是否是CORS只是看Origin
头部,即完全按照W3C标准。Spring 通过org.springframework.web.cors
中的CorsUtils.isCorsRequest
来判断,如下图。
对于 CORS 的各种操作,会在org.springframework.web.cors
中进行,比如在org.springframework.web.cors.reactive
中的DefaultCorsProcessor。handleInternal
对 CORS 请求的返回进行各种校验并且添加头部操作,如下图。
anyway,总结一下这次的坑,首先是自己懒没看CORS详细的规范,以及习惯性的自己用错了都加上跨域的头部;其次是自己傻ajax写错了,导致一直以为是后端代码的问题;最后是postman太坑,室友的电脑上正常我的电脑错误,导致我懵逼了半天。嗯就这样。
画由心生,境为意造 —— 白居易
去年《你的名字》火了之后,某 APP 发布了一个风格迁移的滤镜,随后朋友圈火了一把动画风的图片秀。当时就像玩玩风格迁移,然后这个拖延症一直至今,前几天看吴恩达的深度学习课程,突然想起这个坑。我也不知道在忙到爆炸的毕设中期前哪来的勇气这么浪去玩其他( ̄. ̄)
# 风格迁移简介风格迁移(Style Transfer)是深度学习非常好玩的一个应用,它可以从一张图片获得风格,另一张图片或得内容,再合成为一张新的图片,比如:
左上角是需要的内容图片,右上角是想要学习的风格图片,想要学习、生成下面的新图片。对于图片分类,我们一般使用卷积神经网络获取图片信息,最终输出类别,比如下图中的网络,其实就是一层层的堆积卷积层和池化层,最后加几个全连接层:
我们常说,在深度神经网络中,浅层的神经元拟合简单的信息,深层的拟合更复杂的信息。那么在 CNN 中,每层卷积核分别在什么情况下激活呢?下图是上面的网络,每层卷积核激活时候的输入输入:可以看出,浅层的核只是拟合简单的线条,越是深层拟合的东西越复杂(可以想象卷积核移动的时候,浅层往往只关注局部的信息,而越是深层,对于输入图像关注的范围越大)。在我们的生成迁移模型中,将会使用预训练好的 CNN 网络模型,来提取图形特征与内容,使用越深的网络获取到的特征越高层,因此选用较浅的层将会更加像素级的趋近输入图片,越深则越在内容上趋近。
最早期的风格迁移模型非常缓慢,因为它把图片的生成过程当做一个“训练”过程,将风格图片和内容图片作为输入,生成最后的新图片,每生成一张图片都相当于训练一个模型。模型图如下:
因此我们需要加快风格迁移的速度,需要把其当做一个“生成”或者“执行”的过程,即快速风格迁移,该模型图如下:
首先随机生成一张图片 G,然后通过前面提到的网络选择出某特征层l,将内容图片和风格图片通过同样的方式得到某个特征层,再按照下面的方法得到损失函数进而通过反向传播修改 G。可以简化为如图所示:蓝色箭头为前向运算,红色箭头为反向运算,三个 Network 其实是同一个。训练机器学习模型,需要定义损失函数。从上面对风格迁移模型的描述可以看出,模型的损失由两部分来定义:内容损失与风格损失,即:
$$J(G) = \alpha J_{Content}(C,G) + \beta J_{Style}(S,G)$$
其中,C(Content)表示内容图片,S(Style)表示风格图片,G(Generate)即第一项表示内容的损失,第二项表示风格的损失。
那么如何来计算内容损失函数$\alpha J_{Content}(C,G)$呢?
首先应该使用一个预训练好的卷积神经网络模型,比如 VGG19,然后选择 l 层的激活来计算生成图片 G 和内容图片 C 的相似性,即计算 $a^{[l](C)}$ 和 $a^{[l](G)}$之间的相似度。这两个值越相似,那么 G 和 C 的内容就越相似。
$$J_{Content}(C,G) = \frac{1}{2} \lVert a^{[l](C)} - a^{[l](G)} \rVert^2$$
首先我们需要定义什么是风格。风格就是 l 层中不同通道的激活值的相关性。因为不同通道的卷积核,对不同的特征敏感,那么如果多个通道同时激活,说明图中出现了某些关联的特征,即风格。例如对于某层(l层)激活值的维度为:$n_H, n_W, n_C$,$a^{[l]}_{i,j,k}$表示$(i,j,k)$上的激活值,$G^{[l](S)}$表示风格矩阵,它是一个$n^{[l]}_C \times n^{[l]}C$维度的矩阵。$G^{[l](S)}{kk’}$表示在 k 通道和 k’ 通道的关联性:
$$G^{[l](S)}{kk’} = \sum{i=1}^{n_H^{[l]}} \sum_{j=1}^{n_w^{[l]}} a^{[l]}{i,j,k} a^{[l]}{i,j,k’}$$
进而,风格损失函数可以定义为:
$$J_{Stype}^{[l]}(S,G) = \frac{1}{2n_H^{[l]}n_W^{[l]}n_C^{[l]}} \sum_k \sum_{k’}\lVert G^{[l](S)}{kk’} - G^{[l](G)}{kk’} \rVert$$
对多个层求风格损失并且累加,可以同时得到多层的风格相似性,往往效果更好:
$$J_{Style}(S,G) = \sum_l \lambda^{[l]}J_{Stype}^{[l]}(S,G)$$
模型的代码具体见:Keras 官方 examples
这里给出几个运行的例子(从左到右依次是 风格图、内容图、生成图):
]]>梯度下降最直观的解释如图所示,在山上某处,沿着最陡的方向向下,直到能到达的最低点。
虽然各个深度学习框架封装了若干常用的梯度下降算法,可以当做黑盒来使用,但是作为调参工程师,这些东西原理都不懂还怎么愉快的调参。对于一般线性回归函数,其假设为:
$$h_{\theta}=\sum_{j=0}^{n}\theta_{j}x_{j}$$
对应的损失函数为:
$$J_{train}(\theta)=\frac{1}{2m}\sum_{i=1}^{m}(h_{\theta}(x^{(i)})-y^{(i)})^{2}$$
我们假设参数$\theta$是二维的,下图为一个二维参数($\theta_{0}$和$\theta_{1}$)组对应能量函数的可视化图:
本文大概分为:基本的梯度下降算法和梯度下降优化算法。
批量梯度下降算法使用整个数据集,算得整个数据集的损失,对目标参数$\theta$求导,用来更新$\theta$:
$$\theta = \theta - \alpha\nabla_{\theta}{J(\theta)}$$
其具体的过称为:
对上面的损失函数求偏导:
$$\frac{\partial{J(\theta)}}{\partial{\theta_j}} = - \frac{1}{m}\sum_{i=1}^{m}(y^i - h_{\theta}(x^i))x_{j}^{i}$$
最小化损失函数,按照每个参数$\theta$的梯度负方向来更新每个$\theta$:
$$\theta_{j} = \theta_{j} + \alpha\frac{1}{m}\sum_{i=1}^{m}(y^i - h_{\theta}(x^i))x_{j}^{i}$$
这种方法,每更新一次参数,需要对整个数据集进行运算,十分缓慢并且非常占内存,而且不能实时在线更新参数。这是最原始的梯度下降方法。从迭代的次数上来看,BGD迭代的次数相对较少。其迭代的收敛曲线示意图可以表示如下:
随机梯度下降一次只使用一个样本进行目标函数梯度计算,其公式为:
$$\theta = \theta - \alpha\nabla_{\theta}{J(\theta;x^i,j^i)}$$
即将上面的损失函数改写为:
$$J_{train}(\theta)=\frac{1}{m}\sum_{i=1}^{m}\frac{1}{2}(h_{\theta}(x^{(i)})-y^{(i)})^{2} = \frac{1}{m}\sum_{i=1}^{m}cost(\theta;x^i,j^i)$$
$$cost(\theta;x^i,j^i) = \frac{1}{2}(h_{\theta}(x^{(i)})-y^{(i)})^{2}$$
利用每个样本的损失函数对$\theta$求偏导得到对应的梯度,来更新$\theta$:
$$\theta_{j} = \theta_{j} + \alpha(y^i - h_{\theta}(x^i))x_{j}^{i}$$
随机梯度下降是通过每个样本来迭代更新一次,计算非常快并且适合线上更新模型。但是,SGD伴随的一个问题是噪音较BGD要多,使得SGD并不是每次迭代都向着整体最优化方向,在解空间的搜索过程看起来很盲目。其迭代的收敛曲线示意图可以表示如下:
## 小批量梯度下降(Mini-Batch Gradient Descent, MBGD)上述两种方法,BGD 样本多时,训练慢,占内存;SGD 找到的解却不如 BGD,并且干扰较大,不易于并行实现。而 MBGD 则是在二者之间找到一个平衡。它一次以小批量的训练数据计算目标函数的权重并更新参数。公式如下:
$$\theta = \theta - \alpha\nabla_{\theta}{J(\theta;x^{(i:i+n)},j^{(i:i+n)})}$$
其中,n为每批训练集的数量,一般设为50到256。
虽然 MBGD 相对 BGD 和 SGD 更加优秀,但是仍然存在许多问题,比如:
因此我们需要更好的方法来进行梯度下降的求解。
对于如图所示的损失函数等高线图:
其中中心的蓝色点表示了最优值。如图我们可以知道,其在 Y 轴比较陡峭,在 X 轴比较平缓。如果我们使用普通的梯度下降方法,如果选取较小的学习效率,则其收敛的图像如下面的第一张图。可以看出我们从某个点出发,整体趋势向着最优点前进,但是其在 Y 轴变化比较快,但是在 X 轴的前进非常缓慢。如果我们增大学习效率,则如第二张图,在 Y 轴抖动非常明显:
对于一组连续的数值,比如温度,可能变化(波动)较大,如下图的蓝色点。如果我们想要拟合温度,我们需要一个较为平缓的曲线(红色)。
如何求得这个平缓的曲线呢?我们应该想到,尽可能的平均一下前后的温度。指数平均加权是这样的思路,一个时刻的值,与上一个时刻有关(二者的加权平均)。即:$V_t = \beta V_{t-1} + (1 - \beta)\theta_t$,这里,$\theta_t$表示 t 时刻的测量值(即蓝色的点),$V_{t-1}$表示上一个时刻的计算值(即红色曲线的上一个值),$\theta$为加权参数。借助指数加权平均的思想,我们可以解决上面的抖动问题。即在SGD的基础上,加上了上一步的梯度:
$$v_t = \gamma v_{t-1} + (1 - \gamma)\nabla_{\theta}{J(\theta)}$$
$$\theta = \theta - \alpha v_t$$
其中$\gamma$通常设为0.9。由于目标函数在Y方向上摇摆,所以前后两次计算的梯度在Y方向上相反,所以相加后相互抵消,而X方向上梯度方向不变,所以X方向的梯度是累加的,其效果就是损失函数在Y方向上的震荡减小了,而更加迅速地从X方向接近最优点。如图所示:
也可以把这个过程和在山坡放一个球让其滚下类比:当从山顶释放一个小球时,由于重力的作用小球滚下的速度会越来越快;与此类似,冲量的作用会使相同方向的梯度不断累加,不同方向的梯度相互抵消,其效果就是逼近最优点的速度不断加快。但是上面,小球越来越快的往山谷滚动,越接近谷底越快,会导致冲过谷底。因此我们需要让小球感知坡度的变化,从而在它再次冲上山坡之前减速而避免错过山谷。NAG方法更新梯度的公式变为:
$$v_t = \gamma v_{t-1} + (1 - \gamma)\nabla_{\theta}{J(\theta - \gamma v_{t-1})}$$
$$\theta = \theta - \alpha v_t$$
即在 Momentum 的基础上,进行修正,达到减速的效果。
我们除了想让参数更新速率自适应坡度外,还需要适合处理稀疏特征的梯度更新算法。比如,稀疏特征采用高的更新速率,其他特征采用相对较低的更新速率。Adagrad是一种适合处理稀疏特征的梯度更新算法,它对稀疏特征采用高的更新速率,而对其他特征采用相对较低的更新速率。Dean等人发现Adagrad能很好地提高SGD的鲁棒性,它已经被谷歌用来训练大规模的神经网络。
Adagrad对每个参数使用不同的参数进行更新。如果用$g_{t,i}$来表示参数$\theta_i$在第t次更新时的梯度,即$g_{t,i} = \nabla_{\theta}{J(\theta_i)}$,则SGD的更新规则可以写作:
$$\theta_{t+1, i} = \theta_i - \alpha g_{t,i}$$
而Adagrad的更新规则可以表示为:
$$\theta_{t+1, i} = \theta_i - \frac{\alpha}{\sqrt{G_{t,ii} + \epsilon}} g_{t,i}$$
其中,$G_{t,ii}$是一个$\mathbb{R}^{d×d}$维的对角矩阵,其第i行第i列的元素为过去到当前第i个参数的梯度平方和,$\epsilon$是为了防止分母为0的平滑项。进一步,可以将上式向量化如下:
$$\theta_{t+1} = \theta_i - \frac{\alpha}{\sqrt{G_{t} + \epsilon}} \bigodot g_{t}$$
这样,利用Adagrad就可以自动根据每个特征的稀疏性来设置不同的学习率。但是$G_t$累加了参数的历史梯度的平方,所以到后期学习率会越来越小,最后无法再学习到新的信息。
Adadelta 和 Adagrad 的主要区别就是把 $G_t$变为$E[g^2]_t$,即不再累加参数所有的历史梯度平方和,转而设定一个窗口w,只求前w个历史梯度平方的平均数。而$E[g^2]t = \beta E[g^2]{t-1} + (1-\beta)g_t^2$,因此Adadelta更新规则可以写作:
$$\theta_{t+1} = \theta_i - \frac{\alpha}{\sqrt{E[g^2]t + \epsilon}} \bigodot g{t}$$
RMSprop(Root Mean Square prop)由Hinton提出,实际上是Adadelta的一种特殊形式:
$$E[g^2]t = \beta E[g^2]{t-1} + (1-\beta)g_t^2$$
$$\theta_{t+1} = \theta_t - \frac{\alpha}{\sqrt{E[g^2]t} + \epsilon} \bigodot g{t}$$
Adam的全称是Adaptive Moment Estimation, 它也是一种自适应学习率方法。可以把它看做 RMSprop 和 Momentum 的结合。
$$m_t = \beta_1 m_{t - 1} + (1 - \beta_1)g_t$$
$$v_t = \beta_2 v_{t - 1} + (1 - \beta_2)g_t^2$$
$m_t$,$v_t$分别是梯度的带权平均和带权有偏方差,由于当$\beta_1$,$\beta_2$接近于1时,这两项接近于0,因此对他们进行了偏差修正:
$$\hat{m_t} = \frac{m_t}{1 - \beta_1^t}$$
$$\hat{v_t} = \frac{v_t}{1 - \beta_2^t}$$
最终更新方程为:
$$\theta_{t+1} = \theta_t - \frac{\alpha}{\sqrt{\hat{v_t}} + \epsilon} \bigodot m_{t}$$
一般将$\beta_1$设为0.9,$\beta_2$设为0.999, $\epsilon$设为10−8。一般在深度学习的梯度优化中,会使用 Adam。
如图所示,所有方法都从相同位置出发,经历不同的路径到达了最小点,其中Adagrad、Adadelta和RMSprop一开始就朝向正确的方向并且迅速收敛,而冲量、NAG则会冲向错误的方向,但是由于NAG会向前多“看”一步所以能很快找到正确的方向。
下图显示了这些方法逃离鞍点的能力,鞍点有部分方向有正梯度另一些方向有负梯度,SGD方法逃离能力最差,冲量和NAG方法也不尽如人意,而Adagrad、RMSprop、Adadelta很快就能从鞍点逃离出来。]]>九月中旬鹏哥给我打电话,让我给新入学的大一新生们写点东西,告诉他们我的大学四年是怎么样走过来的,然而国庆大东北去浪了,这件事情丢得一干二净。昨天鹏哥电话来催账了,才想起这件忘记得差不多的事情。
写给大一的新生,教他们怎么进入这个领域,如何去成长。教别人,这是一件大事。无论是工作,还是读研,亦或是技术上,有些人自己都 Low 得不行,却喜欢到处拼凑成一篇篇的“如何求职”或者“如何读研”的长文,装作一副过来人的样子到处分享。我不认为我有这个能力去“教”或者是告诉别人应该怎么做,这里只是一个心得,或者是对大学四年的一个总结吧,写个18岁的自己,各位看看就好。
写给18岁的自己,这是无数的科幻作品的题材吧。希望18岁刚进入大学的懵懂的自己,能在某个晴朗的下午,在景观水渠边漫步,在环形大道上闲逛之时,亦或是在图书馆某个能看到远处风景的角落,读到这封信。
时间的长河,其实就是一次次的选择过程。18岁的你,已经成年,虽然可以继续任性,但是做的每一个选择,都决定了之后的你,而你做的每一个选择,都基于你来的路,也即你做过的选择。
这些文字,不可避免的会带有说教的意味,但是这篇文章,毫无说教的目的,只是希望能给18岁的你一些现在的我的一些看法,亦或是现在的我觉得后悔当时没做好的一些东西,希望能够帮到你做得更好。
从未想到,现在的我想告诉你的第一点会是这个。但是的确,学会接受,是通往社会的第一个桥梁。18岁的你,一定还是愤慨,为什么高考会如此糟糕,来到了这个之前十多年一直瞧不起的大学,恨不得掐死高考场上的自己。但是,愤慨又有什么用呢?各种班委选拔,学生会、社团的面试,明明觉得自己很优秀,可是却落选了,觉得面试自己的人眼瞎,觉得别人一定用了不知道什么样的非正常手段吧。但是,结果这样,你不爽又有什么用呢?明明自己认真的感情付出,但是却换不回预想的结果,觉得人心险恶。但是,你即使伤心,即使自暴自弃,又有什么用呢?自己认真学习,认真完成作业,但是最终成绩却不如别人玩些小把戏,你会觉得老师一定是傻X。但是你骂老师一顿又有什么用呢?
18岁了,你会发现,这个学校,这个社会,并不像自己从小长大的环境,这里的一些事,充满了各种不可抗力的因素。不管你是为此多么努力,奋斗是多么艰辛,结果却和付出不成正比。你可以自暴自弃,但是学会接受,以后你可以做得更好。和结果比起来,也许中间的过程,才是对你之后的人生更加重要的。
也许当年在一本忘了什么名字的书上看到的一句话可以帮助到你:一个人可以被毁灭,但是不能被打败。凡不能毁灭我的,将使我更强大。
大学虽然只有四年,但是这四年决定了你今后的高度。如果挥霍掉了,之后你也许10年也补不回来。刚进入大学,脱离了家长老师的严加管教,也许你觉得尽情的玩游戏了,可以尽情的看小说了,可以随便出去玩了。也许室友每天很开心的玩,很让你心动,想和他们一起开黑一起吃鸡。你可以一起玩,但是控制好自己的时间,若干年后看着他们,你会觉得,幸好当年没和他们一样堕落下去。
上面说的挥霍,是指把时间大量的耗费在玩上面。但是如果你把“玩”当做自己的职业,那即使是游戏,也可以定性为在学习,那么花时间在它身上,又何尝不可。
大学四年,是你能够连续性的把时间花费在学习上面的最宝贵的阶段。好好珍惜这段时间,系统性的把自己应该掌握的知识学好,这些系统性学习的东西,将会伴随你一生,成为你整个知识体系的主干。在之后的职业生涯中,你学会的各种其他知识、技能、经验,也只是这主干上的枝叶。干之不成,枝叶焉在?
18岁了,已经没有人在你耳边天天念叨好好学习了,也没有人来守着你学习了。成年的意义,在于自己知道自己应该做什么,应该如何做了。失去了身边的鞭策,你需要学会在心里给自己定一个标杆,一个人或者一个里程碑,不断的去完善自己,去鞭策自己,达到自己的目标。
上面说到花时间学习,但是如果杂乱无章的学,往往会被一个个的散乱的知识点吞噬,事半功倍。你要学会系统的去整理知识点,找出一个个散乱知识点之间的联系,然后有针对的主动去积累学到的知识。而系统化,能够让你知道,什么是主干,什么是枝叶,主干和枝叶之间又是怎么联系起来的。
时间长河带来的是熵的增加,但是我们存在的意义却是不断积累系统化的知识体系,这是一件很有趣的对抗。18岁的你,应该开始在自己选定的领域里面,主动去积累系统化的知识,积累得越多,越系统,你能够更加透彻的看透这个领域中的迷雾,而你一辈子工作的本质,甚至是人生的本质,也是这个不断积累系统化知识的过程。
即使选择了这个专业,也不意味着对这个专业涉及到的所有知识感兴趣。因此需要找到自己的兴趣点,把这个点深挖,做到自己能做的最好。当然这个兴趣点也行会随着时间的变化而变化,但是每到一个点,你应该真正的喜欢它,有一种执着的渴望去钻研,这样就会自然的有自驱动力。
如果一个人靠着自制力去抵抗诱惑做某件事,那他还只是寻常人;如果他能够靠着内心对某事的执着追求去做这件事而抵御其他诱惑,这才是圣人
当你找到了兴趣点,那么你就应该有一个规划。我知道这对于你来说很难,但是你必须对自己有一个长线的规划,比如想要有个什么样的工作。有了这个长线的目标,那么你就能切分一下,比如这个工作,是否需要出国,或者这个工作是否需要读研,来制定一个中期的目标。并且不断的递归,能够制定精确到周或者月的目标更好。这样你就能更加明确的知道,现在应该做什么,做到什么程度了。比如你就能知道,你的更多的精力,是应该放在分数上,亦或是实践上。
你选择的专业,是一个理工类的专业,其实更加偏重工程实践。死学知识,你也许可以做到考高分,完成自己诸如考研、保研、留学的目标,但是没有实际的实践能力,会严重制约你之后的工作。书上的东西,自己照着实践一遍,或者是一些小的完整的项目,自己尝试去完成一下。在实践的过程中,你会发现你对一些已经学到的知识,有了更加深刻的了解,亦或是发现一些自己尚不了解的知识,能够通过实践过程来学习。
本来想告诉你,数学很重要。又想告诉你,算法很重要。想了下,工程也很重要。遂合并三者,一同告知。
数学是理工科的灵魂,也许你现在不知道学的乱七八糟的高数、线代、概统、离散等等数学有什么用,觉得认真学专业知识就够了。但是你要知道,你现在学习的专业知识,都是很基础的东西,当你以后涉及到一些更深层面的知识时,没有数学这把刀,没法在迷雾中抽丝剥茧,学到一些本质。
算法其实可以概括为数学,但是我觉得算法又不同于上面的数学。你可以理解为算法就是 ACM,就是刷各种 OJ。也许你觉得很无聊,但是在刷题的过程中,你对代码本身的理解,包括你的思维能力,都得到的提高。更加俗的来讲,算法能力可能决定了你在这个行业之后的工资水平。
工程能力,也可以理解为上面的实践能力。是你在代码世界的造物能力。工程能力需要多实践,多思考来培养,需要无数次的尝试,无数次的调试来历练。优秀的工程能力,能够让你的作品经受住各种外在需求的变更,负载起大规模的访问,能够一眼理出庞大系统逻辑。
也许在某个阶段,你会觉得自己比别人牛逼了,觉得自己实力屌炸。但是记住,牛逼只是相对的,你要和比你弱的比较,那你又怎好意思狂;你要和比你强的比较,你又怎敢狂。不用和别人,和明天的你自己比起来,你也是菜逼,因为如果明天的你不比今天的你强,那你还不菜?
分工和协作,是这个社会重要的两个特点。无论是同事之间,或者是朋友之间,都是一种协作的关系。尝试找到良师益友,能够在你疑惑、迷茫的时候帮助到你,你们能够相互激励,共同进步。也许很多年过后,即使你们相隔千里,但是朋友又永远在你身边。
世界上有两件事能深深震撼人们的心灵,一件是人们心中崇高的道德准则,另一件是我们头顶灿烂的星空。
成长过程中,你会有许多讨厌的人,厌烦的行为。也许你会发现这些讨厌的行为,能够在短时间内给你带来一些小利小惠,但是永远坚守自己内心的道德底线,坚持做好自己。如菩萨初心,不与后心俱。
在成长的途中,有的人会一味地追求着所谓的技术,所谓的知识,缺忽视了自己的内心。我想你应该不会这样,坚持读一些“杂乱”的书挺好的,扩充自己的眼界,学习别人的思想。但是也不要对某些领域有抵触,比如你现在讨厌的历史,在今天的我看来,也挺有趣。你也可以看看电影,IMDB Top 250就不错,电影往往传达出一种思想体系,从这种思想体系中,能更快的了解一种人生。
大学,有很多人心目中纯洁爱情。可以有一份爱情,能够伴着你,使你成为你,但是有些时候也会给你搞一些事。“爱是一种甜蜜的痛苦,真诚的爱情永不是走一条平坦的道路的。”如果你发现谁让你内心涌动,不用去压抑着自己的内心。但是也不要因为想要爱情而去爱情,这样只会伤害自己。会有属于你的爱情,在之后的路上等着你的,耐心的去迎接它吧。
啰里啰嗦不小心说了这么一大堆,有太多的话想告诉你,大学四年的种种事,种种经历,都想一股脑的给你塞过来。
四年很快,谢谢当年的自己成长的如此迅速,你一定想象不到这四年在江安经历的岁月,一定惊讶毕业时候的自己如此超出自己的预期。江安的日夜,明远湖的宁静,二基楼的深邃,这四年的岁月将会助你踏上未来的征程——星辰大海。加油吧!
]]>深度学习的参数分为超参(hyper parameters)和普通参数。超参是模型开始训练前,人工指定的参数,比如网络的层数、每层的神经元数、学习速率以及正则项系数等。超参对模型的效果非常重要。而普通参数,就是通常的 W 以及 b。深度学习模型的本质过程就是对权重(W)进行更新。而在开始训练神经网络前,需要初始化 W 以及 b 的值,这个初始值会影响模型训练的收敛速度以及质量。本文主要讲解 W 的初始化。
如图所示一个简单的神经网络,有一个中间层,中间层有两个神经元。
如果我们初始化权重: $W^{[1]}=\begin{bmatrix} 0&0\\\ 0&0 \end{bmatrix}$或者任意上下神经元权重相同,由于对称性,通过激活函数得到的值相同,并且通过梯度下降,更新后的权重也相同。因此无论进行多少次迭代,二者的权重依然保持不变,这种情况下,多个隐藏单元就会没有意义。上图为一个初始化权重为0的神经网络的损失函数值变化,可以看出,损失值并没有变化。通常来说,将权重都设置为0,意味着每层网络中的神经元都一样,等同于每层只有一个神经元,其效果并不会比线性分类器(如逻辑回归)好。如果我们将权重初始化为随机数,比如:np.random.randn(layers_dims[l], layers_dims[l - 1]) * 0.01
上述问题可以使用Xavier initialization 解决。Xavier 初始化的基本思想是保持输入和输出的方差一致。即将随机初始化的值乘以缩放因子:$\sqrt{\frac{1}{layers_dims[l-1]}}$。也就是将参数初始化变为:np.random.randn(layers_dims[l], layers_dims[l - 1]) * np.sqrt(1. / layers_dims[l - 1])
Xavier初始化是在线性函数上推导得出的,它能够保持输出在很多层之后依然有着良好的分布,如图为使用 tanh 激活函数后的输出概率分布:
但是其对于 ReLU 的效果并不好,如图:
He 初始化可以解决上面在 ReLU 激活函数时 Xavier 效果不好的问题。其思想是:在ReLU网络中,假定每一层有一半的神经元被激活,另一半为0,所以,要保持方差不变,只需要在 Xavier 的基础上再除以2。即缩放因子变为:$\sqrt{\frac{2}{layers_dims[l-1]}}$,初始化代码为:np.random.randn(layers_dims[l], layers_dims[l - 1]) * np.sqrt(2. / layers_dims[l - 1])
其分布如下图,可见效果很好。
其损失如图所示:
If you want it, just make it! 合理的参数初始化是为了避免梯度消失,有效的反向传播,需要进入激活函数的数值有一个合理的分布,以便于反向传播时计算梯度。其思想就是在线性变化和非线性激活函数之间,将数值做一次高斯归一化和线性变化。
Batch Normalization中所有的操作都是平滑可导,因此可以有效的学习到参数$\gamma$,$\beta$。需要注意的是,训练时的$\gamma$,$\beta$由当前batch计算得出,而测试时$\gamma$,$\beta$使用训练时保存的均值。
如图表示使用随机初始化的参数,ReLU 作为激活函数,未使用 Batch Normalization 时,每层激活函数的输出分布:
下图为使用 Batch Normalization 时,每层激活函数的输出分布:可见,使用 Batch Normalization 非常有效。#参考资料
try{}catch(){}finally{}
块来对异常进行捕获或者处理。但是对于 JVM 来说,是如何处理 try/catch 代码块与异常的呢。实际上 Java 编译后,会在代码后附加异常表的形式来实现 Java 的异常处理及 finally 机制(在 JDK1.4.2之前,javac 编译器使用 jsr 和 ret 指令来实现 finally 语句,但是1.4.2之后自动在每段可能的分支路径后将 finally 语句块内容冗余生成一遍来实现。JDK1.7及之后版本,则完全禁止在 Class 文件中使用 jsr 和 ret 指令)。
属性表(attribute_info)可以存在于 Class 文件、字段表、方法表中,用于描述某些场景的专有信息。属性表中有个 Code 属性,该属性在方法表中使用,Java 程序方法体中的代码被编译成的字节码指令存储在 Code 属性中。而异常表(exception_table)则是存储在 Code 属性表中的一个结构,这个结构是可选的。
异常表结构如下表所示。它包含四个字段:如果当字节码在第 start_pc 行到 end_pc 行之间(即[start_pc, end_pc))出现了类型为 catch_type 或者其子类的异常(catch_type 为指向一个 CONSTANT_Class_info 型常量的索引),则跳转到第 handler_pc 行执行。如果 catch_type 为0,表示任意异常情况都需要转到 handler_pc 处进行处理。
类型 | 名称 | 数量 |
---|---|---|
u2 | start_pc | 1 |
u2 | end_pc | 1 |
u2 | handler_pc | 1 |
u2 | catch_type | 1 |
如上面所说,每个类编译后,都会跟随一个异常表,如果发生异常,首先在异常表中查找对应的行(即代码中相应的 try{}catch(){}
代码块),如果找到,则跳转到异常处理代码执行,如果没有找到,则返回(执行 finally 之后),并 copy 异常的应用给父调用者,接着查询父调用的异常表,以此类推。
对于 Java 源码:
1 | public int inc() { |
将其编译为 ByteCode 字节码:
1 | public int inc(); |
首先可以看到,对于 finally,编译器将每个可能出现的分支后都放置了冗余。并且编译器生成了三个异常表记录,从 Java 代码的语义上讲,执行路径分别为:
- 如果 try 语句块中出现了属于 Exception 及其子类的异常,则跳转到 catch 处理;
- 如果 try 语句块中出现了不属于 Exception 及其子类的异常,则跳转到 finally 处理;
- 如果 catch 语句块中出现了任何异常,则跳转到 finally 处理。
由此可以分析此段代码可能的返回结果:
- 如果没有出现异常,返回1;
- 如果出现 Exception 异常,返回2;
- 如果出现了 Exception 意外的异常,非正常退出,没有返回;
我们来分析字节码:
首先,0-4行,就是把整数1赋值给 x,并且将此时 x 的值复制一个副本到本地变量表的 Slot 中(即 returnValue),这个 Slot 里面的值在 ireturn 指令执行前会被重新读到栈顶,作为返回值。这是如果没有异常,则执行5-9行,把 x 赋值为3,然后返回 returnValue 中保存的1,方法结束。如果出现异常,读取异常表发现应该执行第10行,pc 寄存器指针转向10行,10-20行就是把2赋值给 x,然后把 x 赋值给 returnValue,再将 x 赋值为3,然后将 returnValue 中的2读到操作栈顶返回。第21行开始是把 x 赋值为3并且将栈顶的异常抛出,方法结束。
]]>ps. 毕竟你的是高大上的17带bar rmbp。。。然而,bar的操作我都!不!知!道!
大概介绍 macOS 的一些基本操作以及常见的软件、开发环境的搭建。
17的触摸板应该是二段的按压,意思就是分为轻按和重按,按到底会响两次,轻按表示单击,重按表示双击。但是也可以配置成双指按表示双击;
键盘键位,Mac的键位和Windows的不太一样,没有Win
那个图标,取而代之的是command
(长这样:⌘
)但是功能差不多;alt
又叫做option
(一些地方画成⌥
);退格、del
合并为delete
;Shift
画成⇧
,control
画成⌃
,大小写锁定画成⇪
一些常用的快捷键:
- 截屏,默认的截屏快捷键有两种:
⌘+shift+control+4
(区域截图到剪切板),⌘+shift+control+3
(全屏截图到剪切板),⌘+shift+4
(区域截图到桌面),⌘+shift+3
(全屏截图到桌面)- 复制:
⌘+c
,粘贴:⌘+v
,全选:⌘+a
- 输入法切换:
control+空格
(选择上一个输入法),control+option+空格
(选择下一个输入法)- 其他自己看官方的文档
开机首先是各种基本的配置,什么语言呐区域呐用户名呐乱七八糟就不说啦。
首先是调整下分辨率。进入系统后,点击桌面左上角的,点击 系统偏好设置-显示器,这里把分辨率调了吧,默认的太太太大了,不仅看着难受,而且因为大,所以显示的东西太少了,怎么愉快的撸代码。
设置窗口的左上方,有后退前进的按钮,点击后退可以退回主菜单。
其次,在触摸板中把全部的东西都打开吧,rmbp最优秀的就是那个触摸板了。而且17款这么大的触摸板,好好用~ps.建议把“轻点来点按”打开,就不用每次都按下去了。ps. 每一个选项右边都有图示,可以看看。
然后打开一些安全设置,不然装不上第三方的软件。找到“安全性与隐私”,点击左下角的🔐并输入密码,以允许后面的修改。然后“允许从以下位置下载的应用”中选择“任何来源”。
接着在系统设置的辅助功能中,左边一列选中“鼠标与触控板”,右边点击“触控板选项…”,然后打开启用拖拽-“三指拖拽”,这样,鼠标放在窗口的标题栏,三个手指就能拖动窗口了,非常方便。
iCloud 中,登录 iCloud 账户并且开启“查找我的 Mac”选项。
Docker(类似 Windows 底部的菜单栏) 配置。建议小一些,放左边或者右边,并且自动隐藏。
接着配置网络,网络选项在桌面上面一栏,一个WiFi图标,点击可以选择网络。
其他设置:点击电池图标,勾选显示百分比;时间显示日期、星期等
首先 macOS 是基于 Unix 的发行版,所以文件结构、命令上,都和 *nix 系统非常像(但是非常多坑)。
点击 Dock 上的 Finder(即 macOS 的资源管理器),可以看到左边有一栏,里面有个应用程序(/Applications),应用程序放在这里的。
Mac 上传说中最强大的终端工具,主页戳这里。建议把 iTerm2 固定到 Docker,方便打开,双指点按 iTerm 图标-选项-在 Docker 中固定。
一个包管理工具,在终端(iTerm)中输入下面语句即可安装。其官网为:点这里
1 | /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" |
brew upgrade
更新软件包,然后brew install 软件名
即可安装一些软件。
Homebrew-Cask是 Homebrew 的扩展,用以安装其他一些软件,可以用于安装其他一些软件以前需要使用brew cask
来使用 Cask,现在直接使用 Cask 的 tap 即可:
1 | brew install caskroom/cask/google-chrome #安装chrome |
90后用的 shell~其他常用的还有 zsh。使用 brew 安装即可:
1 | brew install fish |
然后切换默认的 shell 为 fish:
1 | chsh -s /usr/local/bin/fish |
安装好后,还有个方便的 fish 管理工具:oh my fish,安装命令:
1 | curl -L https://get.oh-my.fish | fish |
然后可以选择主题,我用的 ocean,这么安装:
1 | omf install ocean |
当然也可以安装其他主题,去上面那个链接选选。。安装好后长这样:
ShadowsocksX这个就不多说了。。。
Karabiner-Elements 是一个修改键位的软件,建议用它把 caps lock
和control
位置替换。因为大小写锁定占据了更好的位置,而 control
是更加频繁使用的一个按键,调换后更方便。
IntelliJ IDEA(不过强烈建议通过JetBrains Toolbox 安装)、
Office 365、
搜狗输入法、
迅雷、
Alfred(这里有一堆工作流工具)、
iStat、
腾讯电脑管家、
QQ、
微信
自己折腾去吧~╰( ̄▽ ̄)╭
]]>在计算机中,不同硬件的处理速度不同,往往有几个数量级的差距。比如 CPU 的处理速度往往高于内存数个数量级。因此计算机体系结构中引入了告诉缓存(Cache)放在内存和处理器之间作为缓冲。将 CPU 可能将要访问的数据先放在缓存中,因为访问缓存的速度远远高于直接访问内存,因此能加速运算。
虽然高速缓存解决了 CPU 和内存之间的速度问题,但是引入了另一个问题:缓存一致性。在多处理系统中,每一个处理器都有自己独立的缓存,但是整个系统共享一个内存,因此需要保证每个 CPU 读写的数据一致。常见的一致性协议有 MSI、MESI、MOSI以及 Dragon Protocol 等。JMM 中定义的内存访问操作与计算机的高速缓存访问是类似的。
JMM 定义了程序中各个变量的访问规则。JMM 规定,所有的变量都存储在主内存中,但是每个线程还会有自己的工作内存,其中保存该线程使用到的变量副本,并且线程对内存的操作必须在工作线程中进行,不能直接访问主内存变量。如图所示:
在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。在共享内存的模型中,线程之间通过共享内存中的一个公共状态来进行通信。在消息传递模型中,通过显示的发送消息来通信。Java线程之间的通信采用的是过共享内存模型,即 JMM。线程之间的通信必须要经历将要发送的消息通过共享变量的方式写入主内存,再被其他线程读取的过程。
为了保证其他线程读取的是最新写入的数据,因此 Java 内存模型定义了如下几种操作:
从 jdk5 开始,java 使用新的 JSR-133 内存模型,基于 happens-before 的概念来阐述操作之间的内存可见性。
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间,store 和 write 之间是可以插入其他指令的。Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:
如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。
编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。
不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime 和处理器都必须遵守 as-if-serial 语义。
为了保证内存的可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。Java 内存模型把内存屏障分为 LoadLoad、LoadStore、StoreLoad和StoreStore 四种。
]]>逻辑斯谛分布的分布函数和密度函数如下:
$$F(x) = P(X <= x) = \frac{1}{1+e^{-(x - \mu) / \gamma}}$$
$$f(x) = F’(x) = \frac{e^{-(x-\mu) / \gamma}}{\gamma (1+e^{-(x-\mu) / \gamma})^2}$$
其中$\mu$为位置参数;$\gamma > 0$为形状参数,值越小,分布越集中于$\mu$附近。其分布的密度函数与分布函数如图:
二项逻辑斯谛回归模型是如下的条件概率分布:
$$P(Y=1|x) = \frac{exp(w \cdot x + b)}{1+ exp(w \cdot x + b)}$$
$$P(Y=0|x) = \frac{1}{1+ exp(w \cdot x + b)}$$
逻辑斯谛回归模型是指,输出$Y=1$的对数几率,其是由输入$x$的线性函数表示的模型。可以用极大似然估计法估计模型参数,从而得到逻辑斯谛回归模型。学习过程通常采用的方法是梯度下降法及拟牛顿法。
最大熵原理认为,学习概率模型时,在所有可能的概率模型(分布)中,熵最大的模型是最好的模型。通常用约束条件来确定概率模型的集合,所以最大熵原理也可以表述为在满足约束条件的模型集合汇总选取熵最大的模型。最大熵模型的学习可以形式化为约束最优化问题:
$$\max_{P \in C} H(P) = - \sum_{x,y}\tilde{P}(x)p(y|x)logP(y|x)$$
$$s.t. \qquad E_p(f_i) = E_\tilde{p}(f_i) $$
$$\qquad \sum_yP(y|x)=1$$
最大熵模型是由以下条件概率分布表示的分类模型:
$$P_w(y|x) = \frac{1}{Z_w(x)}exp(\sum_{i=1}^{n}w_if_i(x,y))$$
$$Z_w(x)=\sum_yexp(\sum_{i=1}^{n}w_if_i(x,y))$$
其中$Z_w(x)$是规范化因子,$f_i$为特征函数,$w_i$为特征的权值。
逻辑斯谛回归模型与最大熵模型都属于对数线性模型。二者的学习一般采用极大似然估计,或正则化的极大似然估计。逻辑斯谛回归模型及最大熵模型学习可以形式化为无约束最优化问题。求解该最优化问题的算法有改进的迭代尺度法、梯度下降法、拟牛顿法。
]]>分类决策树模型是一种描述对实例进行分类的树形结构。内部节点表示特征或者属性,叶节点表示一个类。分类时,从根节点开始,对实例某一特征进行测试,根据测试结果分配到子节点,递归进行,直到到达叶节点,即为实例所属的类,如图:
可以将决策树看做一个 if-then 规则的集合,并且其路径具有互斥且完备性质。如上图,最终的类别为 Yes 和 No 两类,假设存在数据$x={青少年,是学生}$,首先判断第一个特征,年龄,为青少年,跳转到最左边节点,判断是否是学生,则类别为 Yes。决策树还能表示给定特征条件下类的条件概率分布。
决策树学习本质上是从训练数据集中归纳出一组分类规则。能对训练数据集进行正确分类的决策树可能有多个,也可能没有。需要选择一个与训练数据集矛盾小,有很好泛化能力的决策树。从另一个角度,把决策树学习看做训练数据集估计条件概率模型,基于特种空间划分的类的条件概率模型有无穷多个,我们选择的模型应该对训练数据有很好的拟合,对未知数据有很好的预测。
决策树的学习损失函数通常是正则化的极大似然函数。从所有可能的决策树中选取最有决策树是 NP 完全问题,所以通常学习算法采用启发式方法,求得次最优的决策树。对于过拟合现象,采用自下而上的剪枝操作,将树变得更简单。决策树的生成只考虑局部最优,剪枝则考虑全局最优。学习常用算法有 ID3、C4.5和 CART。
特征选择的准则通常是信息增益或者信息增益比。
熵是表示随机变量的不确定性的度量。设 X 是一个取有限个值的离散随机变量,其概率分布为:
$$P(X=x_i)=p_i, \quad i=1,2,…,n$$
随机变量 X 的熵定义为:
$$H(X)=-\sum \limits_{i=1}^n p_i \log p_i$$
上式中,若$p_i=0$,定义$0\log 0=0$。对数底可以为2或者 e,这时熵的单位分别为比特或纳特。由定义,熵只依赖于 X 的分布,与 X 的取值无关,所以也可以记为:
$$H(p)=-\sum \limits_{i=1}^n p_i \log p_i$$
熵越大,随机变量的不确定性越大,所以:$0 \le H(p) \le \log n$。
对于随机变量 (X,Y),其联合概率分布:
$$P(X=x_i,Y=y_j)=P_{ij},\quad i=1,2,…,n;j=1,2,…,m$$
条件熵表示在已知随机变量 X 的条件下,变量 Y 的不确定性,定义为 X 给定条件下 Y 的条件概率分布的熵对 X 的数学期望:
$$H(Y|X)=\sum \limits_{i=1}^n p_iH(Y|X=X_i)$$
这里$p_i=P(X=x_i), i=1,2,…,n$。
信息增益表示得知特征 X 的信息而是类 Y 的信息的不确定性减少的程度,也称为互信息。
特征 A 对训练数据集 D 的信息增益 g(D,A) 定义为集合 D 的经验熵 H(D) 与特征 A 给定条件下 D 的检验条件熵之差,即:
$$g(D,A)=H(D)-H(D|A)$$
因此,决策树特征选择的方法可以为:对训练集 D,计算其每个特征的信息增益,并比较它们的大小,选择信息增益最大的特征。
但是,以信息增益作为划分的特征,可能存在偏向于选择取值较多的特征的问题,因此可以改为使用信息增益比:
$$g_R(D,A)=\frac {g(D,A)}{H_A(D)}$$
其中,$H_A(D)=-\sum \limits_{i=1}^n \frac {|D_i|}{|D|}\log_2 \frac {|D_i|}{|D|}$
#决策树的生成
ID3 核心是在决策树的各个节点上应用信息增益准则选择特征,递归地构建决策树。从根节点开始,对节点计算所有可能的特征的信息增益,选择信息增益最大的特征作为节点的特征,由该特征的不同取值建立子节点。ID3相当于用极大似然法进行概率模型的选择。但是 ID3 算法只有树的生成,所以容易产生过拟合。
C4.5 与 ID3 类似,但是生成过程,使用的是信息增益比来选择特征。
决策树生成过程,可能会导致过拟合。可以使用剪枝对已生成的树进行简化。具体的,剪枝从已生成的树上裁掉一些子树或者叶节点,并将其根节点或父节点作为新的叶节点,从而简化分类树模型。
决策树的剪枝往往通过极小化决策树整体的损失函数或代价函数来实现。
设树 T 的叶节点个数为 |T|,t 是树 T 的叶节点,该叶节点有$N_t$个样本点,其中 k 类样本有$N_{tk}$个,$H_{t}(T)$为叶节点 t 上的经验熵,$\alpha \ge 0$为参数,则决策树学习的损失函数为:
$$C_{\alpha}(T)=\sum \limits_{t=1}^{|T|}N_tH_t(T)+\alpha{T}$$
其中经验熵:
$$H_t(T)=-\sum_k \frac{N_{tk}}{N_t}\log \frac{N_{tk}}{N_t}$$
将损失函数记为:
$$C_{\alpha}(T)=C(T)+\alpha |T|$$
其中第一项表示对训练数据的预测误差,|T|表示复杂度,参数$\alpha \ge 0$控制两者间的影响。
剪枝时,计算每个节点的经验熵,然后递归地从树的叶节点往上回缩。如果一组叶节点回缩到其父节点之前与之后的整体树分别为$T_B$和$T_A$,如果$C_\alpha (T_A) \le C_\alpha(T_B)$则进行剪枝,将父节点变为新的叶节点。
剪枝算法可以由动态规划算法实现。
CART,全称 classification and regression tree,即分类与回归树,既可以用于分类也能用于回归。CART 是在给定输入随机变量 X 条件下输出随机变量 Y 的条件概率分布的学习方法,其假设决策树是二叉树。
决策树的生成就是递归地构建儿茶决策树的过程。对回归树用平方误差最小准则,对分类树用基尼指数最小化准则,进行特征选择,生成二叉树。
假设将输入划分为 M 个单元$R_1,R_2,…,R_M$,并且在每个单元$R_m$上有一个固定输出值$c_m$,于是回归树模型可以表示为:
$$f(x)=\sum_{m=1}^Mc_mI(x \in R_M)$$
当输入空间划分确定时,可以用平方误差$\sum_\limits{x_i \in R_M}(y_i-f(x_i))^2$来表示回归树对于数据的预测误差,用平方误差最小准则求解每个单元上的最有输出值。采用启发式的方法划分输入空间。
生成算法为:
选择最优切分变量 j 和切分点 s,求解:
$$\min \limits_{j,s}[\min \limits_{c_1} \sum_{x_i\in R_1(j,s)}(y_i-c_1)^2 + \min \limits_{c_2} \sum_{x_i\in R_2(j,s)}(y_i-c_2)^2]$$
遍历变量 j,对固定的切分变量 j 扫描切分点 s,选择使上式达到最小值的对 (j,s)。
对选用的对 (j,s) 换分区域并决定相应的输出值:
$$R_1(j,s)={x|x^{(j)} \le s}, \quad R_2(j,s)={x|x^{(j)} \gt s}$$
$$\hat{c_m}=\frac{1}{N_m}\sum_{x_i \in R_m(j,s)}y_i,\quad x\in R_m,\quad m=1,2$$
继续对子区域调用1、2步骤,直到满足停止条件。
将输入空间划分为 M 个区域,生成决策树:
$$f(x)=\sum_{m=1}^{M}\hat{c_m}I(x\in R_m)$$
分类树使用基尼指数选择最优特征,同时决定该特征的最优二值切分点。假设有 K 个分类,样本点属于第 k 类的概率为$p_k$,则概率分布的基尼指数为:
$$Gini(p)=\sum_{k=1}^{K}p_k(1-p_k)=1-\sum_{k=1}^{K}p_k^2$$
对于二分类问题,如果样本属于第一类的概率是 p,则:
$$Gini(p)=2p(1-p)$$
如果样本 D 根据特征 A 是否取某一可能值$\alpha$被分割成$D_1$和$D_2$两部分,则在特征 A 的条件下,集合 D 的基尼指数为:
$$Gini(D,A)=\frac{|D_1|}{D}Gini(D_1)+\frac{|D_2|}{D}Gini(D_2)$$
基尼指数Gini(D,A)表示经 A=a 分割后集合 D 的不确定性。基尼指数值越大,样本集合的不确定性也就越大。
CART 生成算法为:1. 计算现有特征对数据集 D 的基尼指数,对每一个特征 A,对可能的每个取值$\alpha$的测试为“是”或者“否”将 D 分割成两部分,计算$A=\alpha$ 时的基尼指数;2. 选择基尼指数最小的特征及其对应的且分店作为最优特征和最优切分点,将 D 分配到两个子节点,并递归调用1,2。停止计算的条件是节点中样本个数小雨预定阈值,或者样本集的基尼指数小雨预定阈值,或者没有更多特征。
对于生成的决策树$T_0$,进行 CART 剪枝:
设 $k=0,T=T_0$
设 $\alpha=+\infty$
自下而上对各内部节点 t 计算$C(T_t),|T_t|$以及
$$g(t)=\frac{C(t)-C(T_t)}{|T_t| - 1}$$
$$\alpha=\min(\alpha,g(t))$$
这里,$T_t$表示以 t 为根节点的字数,$C(T_t)$是对训练数据的预测误差,$|T_t|$是$T_t$的叶节点数量
自上而下的访问内部节点 t,如果有$g(t)=\alpha$,进行剪枝,并对叶节点 t 以多数表决法决定其类,得到树 T
设$k=k+1,\alpha_k=\alpha,T_k=T$
如果 T 不是由根节点单独构成的树,则回到步骤4
采用交叉验证法在子树序列$T_0,T_1,…,T_n$中选取最优子树$T_\alpha$
朴素贝叶斯法是典型的生成学习方法。利用训练数据学习$P(X|Y)$和$P(Y)$的估计,得到联合概率分布
$$P(X,Y) = P(X)P(X|Y)$$
概率估计方法可以是极大似然估计或者贝叶斯估计。
朴素贝叶斯法的基本假设使条件独立性。
$$P(X=x|Y=c_k)=P(X^{(1)}=x^{(1)},…,X^{(n)}=x^{(n)}|Y=c_k)\\ =\prod \limits_{j=1}^nP(X^{(j)}=x^{(j)}|Y=c_k)$$
条件独立假设使朴素贝叶斯法变得简单,但是有时候会牺牲一定的分类准确率。
朴素贝叶斯法利用贝叶斯定理与学到的联合概率模型进行分类预测,将输入的 x 分到后验概率最大的类 y:
$$y=\arg \max {c_k}P(Y=c_k)\prod \limits{j=1}^nP(X^{(j)}=x^{(j)}|Y=c_k)$$
后验概率最大等价于0-1函数损失时的期望风险最小化。
在朴素贝叶斯法中,学习即估计$P(Y=c_k)$和$P(X^{(j)}=x^{(j)}|Y=c_k)$
先验概率$P(Y=c_k)$的极大似然估计是:
$$P(Y=c_k)=\frac{\sum \limits_{i=1}^NI(y_i=c_k)}{N},\quad k=1,2,…,K$$
设第 j 个特征$x^{(j)}$可能取值集合为${a_{j1},a_{j2},…,a_{jS_j} }$,则条件概率为:
$$P(X^{(j)}=a_{jl}|Y=c_k)=\frac{\sum \limits_{i=1}^NI(x_i^{(j)}=a_{jl},y_i=c_k)}{\sum \limits_{i=1}^NI(y_i=c_k)}\ j=1,2,…,n;\quad l=1,2,…S;\quad k=1,2,…,K$$
极大似然估计可能会出现估计的概率值为 0 的情况,这会影响到后验概率的计算结果。可以使用贝叶斯估计来解决这一问题。贝叶斯估计即加入一个系数防止0情况出现。
$$P_\lambda(Y=c_k)=\frac{\sum \limits_{i=1}^NI(y_i=c_k)+\lambda}{N+K\lambda},\quad k=1,2,…,K$$
$$P_\lambda(X^{(j)}=a_{jl}|Y=c_k)=\frac{\sum \limits_{i=1}^NI(x_i^{(j)}=a_{jl},y_i=c_k)+\lambda}{\sum \limits_{i=1}^NI(y_i=c_k)+S_j\lambda}\ j=1,2,…,n;\quad l=1,2,…S;\quad k=1,2,…,K$$
上面两式中,$\lambda\geq0$,当取1时,称为拉普拉斯平滑。
]]>根据上述描述,其算法表示为:
$$y=\arg \max \limits_{c_j} \sum \limits_{x_i \in N_{k}(x)} I(y_i = c_j),\quad i=1,2,…,N;j=1,2,…,K$$
其中,$N_{k}(x)$表示涵盖训练集 T 中与 x 最邻近的 k 个点的邻域。I 为指示函数,当$y_i=c_j$时为1,否则为0。
显然,k 近邻法没有显示的学习过程。
knn 实际使用的模型对应于对特征空间的划分。模型由三个要素——距离度量、k 值的选择和分类策略规则决定。当上面三要素与训练集确定后,对于任意输入实例,所属的类别唯一地确定。
相当于在特征空间中划分一些子空间,判断新输入落入哪个子空间中即可。knn 中,k 为1叫做最近邻。这时候对每个训练集实例点划分一个区域(单元),如图:
对于 knn,距离度量是一个比较关键的因素,一般使用欧式距离,也可能使用其他距离。对于两点$x_i,x_j\in \mathcal{X}$,其距离定义为:$$L_p(x_i,x_j)=(\sum \limits_{l=1}^n |x_i^{(l)}-x_j^{(l)}|^p)^{\frac{1}{p}}$$
这里,$p\geq 1$,p=1 时叫做曼哈顿距离,p=2 时叫做欧式距离,$p=\infty$时,为求各个坐标距离的最大值:
$$L_{\infty}(x_i,x_j)=\max \limits_l|x_i^{(l)}-x_j^{(l)}|$$
不同的距离度量所确定的最近邻点是不同的。如图:
其次,k 值的选择对结果影响也很大。k 较小时,近似误差小,但是估算误差大,噪声对其影响大,模型复杂,容易过拟合;k 较大时,可减小估算误差,但是近似误差大,模型变得简单。在应用时,取较小 k 的值,通过交叉验证来选取合适的值。knn 的分类策略往往是多数表决,其等价于经验风险最小化。
knn 最简单的实现是线性扫描,但是其开销巨大,不可取。
kd 树是一种对 k 维空间中的实例点进行存储以便对其进行快速检索的二叉树形数据结构。
构造 kd 树相当于不断地用垂直于坐标轴的超平面将 k 维空间切分,构成一系列的 k 维超矩形区域。kd 树的每个节点对应于一个 k 维超矩形区域。通常,一次选择坐标轴对空间切分,选择训练实例点在选定坐标轴的中位数为切分点,直到子区域没有实例点,这样得到的 kd 树是平衡的(搜索效率未必最优)。
搜索时,从根开始,递归向下访问,如果目标点当前维的坐标小于切分点的坐标,则移动到左子树,否则移动到右子树,直到叶节点,这时候取当前叶节点为当前最近点。这时候递归向上回退,如果该节点比当前实例点距离目标点更近,则当前点为当前最近点;检查当前最近点另一个子节点对应的区域是否与目标点为球心、目标点与当前最近点为半径的超球体相交,如果相交则另一个子节点中可能存在更近的点,移动到另一子节点递归进行搜索,如果不想交则向上回退;直到会退到根节点,最后的当前最近点即为目标点的最近邻点。
如果实例点是随机分布的,则搜索的平均复杂度为$O(\log N)$,kd 树更适合训练实例点远大于空间维数时的 k 近邻搜索。当空间位数接近训练实例数时,效率会迅速下降,几乎接近线性扫描。
]]>